| 1 | /** |
| 2 | * The daily signal: what a workspace's work says, worked out from the |
| 3 | * work service's `workspaceSignals` (per repository, added up here) and |
| 4 | * billing's usage. Home leads with it (the week's pulse and the findings |
| 5 | * worth a look, each with its evidence and where to see it), and the |
| 6 | * Workspace overview draws the rest: spend threaded to the work it |
| 7 | * produced, what is moving and what is waiting, and quiet changes in who |
| 8 | * works where. Every number is worked out here, so each definition is |
| 9 | * tested on its own (signals.test.ts) and written down in the Home guide. |
| 10 | * Nothing here ranks people or teams against each other or against other |
| 11 | * workspaces: a workspace is compared with its own last weeks. |
| 12 | * Imports only types. |
| 13 | */ |
| 14 | import type { |
| 15 | RepoPath, |
| 16 | SignalAuthor, |
| 17 | SignalDay, |
| 18 | SignalItem, |
| 19 | SignalMilestone, |
| 20 | SignalPull, |
| 21 | SignalWeek, |
| 22 | Usage, |
| 23 | WaitingPull, |
| 24 | WorkspaceSignals, |
| 25 | } from "@g1t/contracts"; |
| 26 | |
| 27 | const DAY_MS = 86_400_000; |
| 28 | |
| 29 | /** Weeks of history the pulse and the trends read. */ |
| 30 | export const SIGNAL_WEEKS = 10; |
| 31 | /** Days of history the rhythm of the work draws: last week and this one. */ |
| 32 | export const SIGNAL_DAYS = 14; |
| 33 | /** How far back pull requests are read: a milestone's build, and the quarter. */ |
| 34 | export const SIGNAL_REACH_DAYS = 120; |
| 35 | |
| 36 | // --- Days and weeks --------------------------------------------------------------- |
| 37 | |
| 38 | /** `YYYY-MM-DD` of a time, in UTC. */ |
| 39 | export function utcDay(at: number): string { |
| 40 | return new Date(at).toISOString().slice(0, 10); |
| 41 | } |
| 42 | |
| 43 | export function addDays(day: string, days: number): string { |
| 44 | return utcDay(Date.parse(`${day}T00:00:00Z`) + days * DAY_MS); |
| 45 | } |
| 46 | |
| 47 | /** The first day of each of `weeks` weeks of seven days, the last ending with `until`. */ |
| 48 | export function weekStarts(until: string, weeks = SIGNAL_WEEKS): string[] { |
| 49 | return Array.from({ length: weeks }, (_, i) => addDays(until, -7 * (weeks - 1 - i) - 6)); |
| 50 | } |
| 51 | |
| 52 | /** Each of `days` days ending with `until`. */ |
| 53 | export function dayList(until: string, days = SIGNAL_DAYS): string[] { |
| 54 | return Array.from({ length: days }, (_, i) => addDays(until, i - days + 1)); |
| 55 | } |
| 56 | |
| 57 | /** The first day of the quarter `day` is in. */ |
| 58 | export function quarterStart(day: string): string { |
| 59 | const month = Number(day.slice(5, 7)); |
| 60 | const first = month - ((month - 1) % 3); |
| 61 | return `${day.slice(0, 4)}-${String(first).padStart(2, "0")}-01`; |
| 62 | } |
| 63 | |
| 64 | /** What the loader asks the work service for, for a page drawn at `now`. */ |
| 65 | export function signalsRange(now: number): { until: string; since: string; weeks: number; days: number; quarter: string } { |
| 66 | const until = utcDay(now); |
| 67 | const quarter = quarterStart(until); |
| 68 | const reach = addDays(until, -SIGNAL_REACH_DAYS); |
| 69 | return { until, since: quarter < reach ? quarter : reach, weeks: SIGNAL_WEEKS, days: SIGNAL_DAYS, quarter }; |
| 70 | } |
| 71 | |
| 72 | // --- Adding up batches ------------------------------------------------------------ |
| 73 | |
| 74 | export function emptySignals(): WorkspaceSignals { |
| 75 | return { weeks: [], days: [], waiting: [], pulls: [], pullsCapped: false, authors: [], harnesses: [], items: [], milestones: [] }; |
| 76 | } |
| 77 | |
| 78 | /** The batches' answers as one: rows are per repository, so they add; items and milestones once each. */ |
| 79 | export function mergeSignals(batches: WorkspaceSignals[]): WorkspaceSignals { |
| 80 | const all = emptySignals(); |
| 81 | const items = new Map<number, SignalItem>(); |
| 82 | const milestones = new Map<number, SignalMilestone>(); |
| 83 | for (const batch of batches) { |
| 84 | all.weeks.push(...batch.weeks); |
| 85 | all.days.push(...batch.days); |
| 86 | all.waiting.push(...batch.waiting); |
| 87 | all.pulls.push(...batch.pulls); |
| 88 | all.authors.push(...batch.authors); |
| 89 | all.harnesses.push(...(batch.harnesses ?? [])); |
| 90 | all.pullsCapped ||= batch.pullsCapped; |
| 91 | for (const item of batch.items) items.set(item.number, item); |
| 92 | for (const milestone of batch.milestones) milestones.set(milestone.number, milestone); |
| 93 | } |
| 94 | all.waiting.sort((a, b) => a.createdAt.localeCompare(b.createdAt)); |
| 95 | all.pulls.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); |
| 96 | all.items = [...items.values()]; |
| 97 | all.milestones = [...milestones.values()]; |
| 98 | return all; |
| 99 | } |
| 100 | |
| 101 | /** One week's measures, every repository's added up. */ |
| 102 | export type WeekTotals = Omit<SignalWeek, "repoId">; |
| 103 | |
| 104 | const ZERO_WEEK = (start: string): WeekTotals => ({ |
| 105 | start, |
| 106 | merged: 0, |
| 107 | agentMerged: 0, |
| 108 | agentReworked: 0, |
| 109 | agentDrafted: 0, |
| 110 | agentDraftSeconds: 0, |
| 111 | reviewed: 0, |
| 112 | reviewSeconds: 0, |
| 113 | stalled: 0, |
| 114 | sessionCostUsd: 0, |
| 115 | }); |
| 116 | |
| 117 | /** Every week in `starts`, quiet ones too, for the repositories in `only` (every one when not given). */ |
| 118 | export function weekTotals(rows: SignalWeek[], starts: string[], only?: ReadonlySet<string>): WeekTotals[] { |
| 119 | const byStart = new Map(starts.map((start) => [start, ZERO_WEEK(start)])); |
| 120 | for (const row of rows) { |
| 121 | const week = byStart.get(row.start); |
| 122 | if (!week || (only && !only.has(row.repoId))) continue; |
| 123 | week.merged += row.merged; |
| 124 | week.agentMerged += row.agentMerged; |
| 125 | week.agentReworked += row.agentReworked; |
| 126 | week.agentDrafted += row.agentDrafted; |
| 127 | week.agentDraftSeconds += row.agentDraftSeconds; |
| 128 | week.reviewed += row.reviewed; |
| 129 | week.reviewSeconds += row.reviewSeconds; |
| 130 | week.stalled += row.stalled; |
| 131 | week.sessionCostUsd += row.sessionCostUsd ?? 0; |
| 132 | } |
| 133 | return starts.map((start) => byStart.get(start)!); |
| 134 | } |
| 135 | |
| 136 | /** |
| 137 | * What the agents' usage came to in each week, from usage's `byDay` |
| 138 | * (`YYYY-MM-DD/task`). Null when usage did not answer. |
| 139 | */ |
| 140 | export function weeklySpend(usage: Usage | null, starts: string[]): number[] | null { |
| 141 | if (!usage) return null; |
| 142 | const spend = starts.map(() => 0); |
| 143 | for (const slice of usage.byDay) { |
| 144 | const day = slice.key.slice(0, 10); |
| 145 | // The week a day falls in: the last start on or before it. |
| 146 | for (let i = starts.length - 1; i >= 0; i--) { |
| 147 | if (day >= starts[i]! && day < addDays(starts[i]!, 7)) { |
| 148 | spend[i]! += slice.micros; |
| 149 | break; |
| 150 | } |
| 151 | } |
| 152 | } |
| 153 | return spend; |
| 154 | } |
| 155 | |
| 156 | // --- Measures ------------------------------------------------------------------- |
| 157 | |
| 158 | /** Seconds a first review waited, on average, or null with none given. */ |
| 159 | export function reviewWait(week: WeekTotals): number | null { |
| 160 | return week.reviewed > 0 ? week.reviewSeconds / week.reviewed : null; |
| 161 | } |
| 162 | |
| 163 | /** Seconds from an agent's session starting to its pull request opening, on average. */ |
| 164 | export function draftTime(week: WeekTotals): number | null { |
| 165 | return week.agentDrafted > 0 ? week.agentDraftSeconds / week.agentDrafted : null; |
| 166 | } |
| 167 | |
| 168 | /** The share of an agent's merged pull requests a person asked for changes on, 0 to 1. */ |
| 169 | export function reworkRate(week: WeekTotals): number | null { |
| 170 | return week.agentMerged > 0 ? week.agentReworked / week.agentMerged : null; |
| 171 | } |
| 172 | |
| 173 | /** The share of merges that were an agent's, 0 to 1. */ |
| 174 | export function agentShare(week: WeekTotals): number | null { |
| 175 | return week.merged > 0 ? week.agentMerged / week.merged : null; |
| 176 | } |
| 177 | |
| 178 | /** Usage per agent merge, in micros. */ |
| 179 | export function spendPerMerge(micros: number | null, week: WeekTotals): number | null { |
| 180 | return micros != null && week.agentMerged > 0 ? micros / week.agentMerged : null; |
| 181 | } |
| 182 | |
| 183 | // --- Words ------------------------------------------------------------------------ |
| 184 | |
| 185 | const WORDS = ["No", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten"]; |
| 186 | |
| 187 | /** A count as a sentence starts it: "Three", or "12". */ |
| 188 | export function countWord(n: number): string { |
| 189 | return WORDS[n] ?? String(n); |
| 190 | } |
| 191 | |
| 192 | /** A wait in words: "48 minutes", "5 hours", "2.1 days". */ |
| 193 | export function spoken(seconds: number): string { |
| 194 | if (seconds < 90) return "a minute"; |
| 195 | if (seconds < 3600) return `${Math.round(seconds / 60)} minutes`; |
| 196 | if (seconds < 1.5 * 3600) return "an hour"; |
| 197 | if (seconds < 20 * 3600) return `${Math.round(seconds / 3600)} hours`; |
| 198 | if (seconds < 36 * 3600) return "a day"; |
| 199 | const days = seconds / 86400; |
| 200 | return `${days < 10 ? days.toFixed(1).replace(/\.0$/, "") : Math.round(days)} days`; |
| 201 | } |
| 202 | |
| 203 | /** A wait in a few characters, for figures: "48m", "5h", "2.1d". */ |
| 204 | export function short(seconds: number | null): string { |
| 205 | if (seconds == null) return "—"; |
| 206 | if (seconds < 3600) return `${Math.max(1, Math.round(seconds / 60))}m`; |
| 207 | if (seconds < 86400) return `${(seconds / 3600).toFixed(seconds < 36_000 ? 1 : 0).replace(/\.0$/, "")}h`; |
| 208 | return `${(seconds / 86400).toFixed(1).replace(/\.0$/, "")}d`; |
| 209 | } |
| 210 | |
| 211 | export function percent(share: number | null): string { |
| 212 | return share == null ? "—" : `${Math.round(share * 100)}%`; |
| 213 | } |
| 214 | |
| 215 | /** Money in micros as the pulse shows it: whole dollars, compact from $10K. */ |
| 216 | export function dollars(micros: number | null): string { |
| 217 | if (micros == null) return "—"; |
| 218 | const value = micros / 1_000_000; |
| 219 | if (value >= 10_000) return `$${(value / 1000).toFixed(1).replace(/\.0$/, "")}K`; |
| 220 | if (value >= 10) return `$${Math.round(value).toLocaleString("en-US")}`; |
| 221 | return `$${value.toFixed(2)}`; |
| 222 | } |
| 223 | |
| 224 | /** The day a page is drawn on, as the daily signal heads it: "Sunday, October 18". */ |
| 225 | export function signalDate(now: number, tz: string | null): string { |
| 226 | const options: Intl.DateTimeFormatOptions = { weekday: "long", month: "long", day: "numeric" }; |
| 227 | try { |
| 228 | return new Date(now).toLocaleDateString("en-US", { ...options, timeZone: tz ?? "UTC" }); |
| 229 | } catch { |
| 230 | return new Date(now).toLocaleDateString("en-US", { ...options, timeZone: "UTC" }); |
| 231 | } |
| 232 | } |
| 233 | |
| 234 | /** A week's first day as a trend's point names it: "Oct 12". */ |
| 235 | export function weekLabel(start: string): string { |
| 236 | return new Date(`${start}T00:00:00Z`).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" }); |
| 237 | } |
| 238 | |
| 239 | // --- The pulse -------------------------------------------------------------------- |
| 240 | |
| 241 | export type Tone = "good" | "bad" | "flat"; |
| 242 | |
| 243 | export type PulseTile = { |
| 244 | key: "merges" | "spend" | "stalled" | "rework"; |
| 245 | label: string; |
| 246 | /** This week's figure. */ |
| 247 | value: string; |
| 248 | /** Against last week: the difference as a chip says it, and whether that is good news. */ |
| 249 | delta: { text: string; tone: Tone } | null; |
| 250 | /** Each week's figure, oldest first; null for a week with nothing to measure. */ |
| 251 | series: (number | null)[]; |
| 252 | /** Each week's point, as its hover says it. */ |
| 253 | points: string[]; |
| 254 | /** What it counts, in a sentence. */ |
| 255 | hint: string; |
| 256 | to: string; |
| 257 | }; |
| 258 | |
| 259 | function tone(change: number, upIsGood: boolean): Tone { |
| 260 | if (change === 0) return "flat"; |
| 261 | return change > 0 === upIsGood ? "good" : "bad"; |
| 262 | } |
| 263 | |
| 264 | function signed(n: number, format: (abs: number) => string): string { |
| 265 | return `${n > 0 ? "+" : n < 0 ? "−" : "±"}${format(Math.abs(n))}`; |
| 266 | } |
| 267 | |
| 268 | function deltaOf(now: number | null, before: number | null, format: (abs: number) => string, upIsGood: boolean) { |
| 269 | if (now == null || before == null) return null; |
| 270 | const change = now - before; |
| 271 | return { text: signed(change, format), tone: tone(change, upIsGood) }; |
| 272 | } |
| 273 | |
| 274 | export type PulseInput = { |
| 275 | slug: string; |
| 276 | weeks: WeekTotals[]; |
| 277 | /** Usage per week, aligned with `weeks`; null when usage did not answer. */ |
| 278 | spend: number[] | null; |
| 279 | }; |
| 280 | |
| 281 | /** |
| 282 | * The past week in four figures, each against the week before and drawn |
| 283 | * over the last ten: agent merges, usage per agent merge, stalled changes, |
| 284 | * and how often people asked for changes on an agent's work. |
| 285 | */ |
| 286 | export function pulse({ slug, weeks, spend }: PulseInput): PulseTile[] { |
| 287 | const at = weeks.length - 1; |
| 288 | const now = weeks[at]; |
| 289 | const before = weeks[at - 1] ?? null; |
| 290 | if (!now) return []; |
| 291 | const labels = weeks.map((week) => `Week of ${weekLabel(week.start)}`); |
| 292 | const perMerge = weeks.map((week, i) => spendPerMerge(spend?.[i] ?? null, week)); |
| 293 | const rework = weeks.map(reworkRate); |
| 294 | const count = (n: number) => String(Math.round(n)); |
| 295 | return [ |
| 296 | { |
| 297 | key: "merges", |
| 298 | label: "Agent-authored merges", |
| 299 | value: String(now.agentMerged), |
| 300 | delta: deltaOf(now.agentMerged, before?.agentMerged ?? null, count, true), |
| 301 | series: weeks.map((week) => week.agentMerged), |
| 302 | points: weeks.map((week, i) => `${labels[i]}: ${week.agentMerged} of ${week.merged} merges`), |
| 303 | hint: "Merged pull requests an agent made: g1t's agent, or a harness session tied to it", |
| 304 | to: `/${slug}/-/overview?tab=landed`, |
| 305 | }, |
| 306 | { |
| 307 | key: "spend", |
| 308 | label: "Spend per agent merge", |
| 309 | value: dollars(perMerge[at] ?? null), |
| 310 | delta: deltaOf(perMerge[at] ?? null, before ? (perMerge[at - 1] ?? null) : null, (abs) => dollars(abs), false), |
| 311 | series: perMerge, |
| 312 | points: perMerge.map((micros, i) => `${labels[i]}: ${micros == null ? "no agent merges" : dollars(micros)}`), |
| 313 | hint: "The week's agent usage at price, over the agent pull requests that merged", |
| 314 | to: `/${slug}/-/usage`, |
| 315 | }, |
| 316 | { |
| 317 | key: "stalled", |
| 318 | label: "Stalled changes", |
| 319 | value: String(now.stalled), |
| 320 | delta: deltaOf(now.stalled, before?.stalled ?? null, count, false), |
| 321 | series: weeks.map((week) => week.stalled), |
| 322 | points: weeks.map((week, i) => `${labels[i]}: ${week.stalled} open over a week`), |
| 323 | hint: "Pull requests open more than seven days, neither merged nor closed", |
| 324 | to: `/${slug}/-/overview?tab=needs#work`, |
| 325 | }, |
| 326 | { |
| 327 | key: "rework", |
| 328 | label: "Agent rework rate", |
| 329 | value: percent(rework[at] ?? null), |
| 330 | delta: deltaOf(rework[at] ?? null, before ? (rework[at - 1] ?? null) : null, (abs) => `${(abs * 100).toFixed(1).replace(/\.0$/, "")} pts`, false), |
| 331 | series: rework, |
| 332 | points: rework.map((share, i) => `${labels[i]}: ${share == null ? "no agent merges" : percent(share)}`), |
| 333 | hint: "Of the agent pull requests that merged, the share a person asked for changes on first", |
| 334 | to: `/${slug}/-/insights`, |
| 335 | }, |
| 336 | ]; |
| 337 | } |
| 338 | |
| 339 | // --- The findings ----------------------------------------------------------------- |
| 340 | |
| 341 | export type Signal = { |
| 342 | key: string; |
| 343 | /** The finding, in one sentence. */ |
| 344 | headline: string; |
| 345 | /** The evidence, as short facts: "48 min to draft", "2.1 days to first review". */ |
| 346 | facts: string[]; |
| 347 | link: { label: string; to: string } | null; |
| 348 | /** A measure over the weeks behind the finding, when it is a trend. */ |
| 349 | trend?: { values: (number | null)[]; points: string[]; caption: string; range: string }; |
| 350 | /** How much it matters today: findings are shown highest first. */ |
| 351 | weight: number; |
| 352 | /** What Ask is asked, to look into it. */ |
| 353 | ask: string; |
| 354 | }; |
| 355 | |
| 356 | export type SignalContext = { |
| 357 | slug: string; |
| 358 | now: number; |
| 359 | repos: Record<string, RepoPath>; |
| 360 | signals: WorkspaceSignals; |
| 361 | weeks: WeekTotals[]; |
| 362 | /** The quarter's usage (by pull request and project); null when usage did not answer. */ |
| 363 | quarterUsage: Usage | null; |
| 364 | quarter: string; |
| 365 | }; |
| 366 | |
| 367 | export function pullPath(repo: RepoPath, number: number): string { |
| 368 | return `/${repo.namespace}/${repo.name}/pull/${number}`; |
| 369 | } |
| 370 | |
| 371 | export function itemPath(slug: string, number: number): string { |
| 372 | return `/${slug}/-/work/${number}`; |
| 373 | } |
| 374 | |
| 375 | export function milestonePath(slug: string, number: number): string { |
| 376 | return `/${slug}/-/work/milestones/${number}`; |
| 377 | } |
| 378 | |
| 379 | function ago(now: number, at: string): number { |
| 380 | return Math.max(0, (now - Date.parse(at)) / 1000); |
| 381 | } |
| 382 | |
| 383 | /** Several work items wait on one change no one has reviewed: the one holding up the most. */ |
| 384 | export function blockedSignal(ctx: SignalContext): Signal | null { |
| 385 | const top = ctx.signals.waiting |
| 386 | .filter((pull) => pull.blocks.length > 0 && ctx.repos[pull.repoId]) |
| 387 | .sort((a, b) => b.blocks.length - a.blocks.length || a.createdAt.localeCompare(b.createdAt))[0]; |
| 388 | if (!top) return null; |
| 389 | const repo = ctx.repos[top.repoId]!; |
| 390 | const n = top.blocks.length; |
| 391 | const shown = top.blocks.slice(0, 3).map((number) => `#${number}`); |
| 392 | return { |
| 393 | key: "blocked", |
| 394 | headline: n === 1 ? "A work item is waiting on a change no one has reviewed." : `${countWord(n)} work items are waiting on one unreviewed change.`, |
| 395 | facts: [ |
| 396 | `${repo.name}#${top.number} ${top.title}`, |
| 397 | `open ${spoken(ago(ctx.now, top.createdAt))}`, |
| 398 | `holds up ${shown.join(", ")}${n > 3 ? ` and ${n - 3} more` : ""}`, |
| 399 | ], |
| 400 | link: { label: "See what's blocked", to: pullPath(repo, top.number) }, |
| 401 | weight: 60 + 8 * n, |
| 402 | ask: `Why is ${repo.namespace}/${repo.name}#${top.number} still waiting for review, and what is waiting on it?`, |
| 403 | }; |
| 404 | } |
| 405 | |
| 406 | /** |
| 407 | * Agents draft quickly and the change then waits for a person: this |
| 408 | * week's first reviews against how long the agents took to open them, |
| 409 | * with first-review time over the weeks. Or, without that gap, first |
| 410 | * reviews that slowed against the weeks before. |
| 411 | */ |
| 412 | export function reviewSignal(ctx: SignalContext): Signal | null { |
| 413 | const weeks = ctx.weeks; |
| 414 | const now = weeks[weeks.length - 1]; |
| 415 | if (!now) return null; |
| 416 | const wait = reviewWait(now); |
| 417 | if (wait == null) return null; |
| 418 | const draft = draftTime(now); |
| 419 | const series = weeks.map(reviewWait); |
| 420 | const earlier = series.slice(0, -1).filter((value): value is number => value != null); |
| 421 | const usual = earlier.length ? earlier.reduce((sum, value) => sum + value, 0) / earlier.length : null; |
| 422 | const first = series.find((value) => value != null) ?? null; |
| 423 | const trend = { |
| 424 | values: series, |
| 425 | points: series.map((value, i) => `Week of ${weekLabel(weeks[i]!.start)}: ${value == null ? "no first reviews" : short(value)}`), |
| 426 | caption: "Time to first review", |
| 427 | range: `${short(first)} → ${short(wait)} over ${weeks.length} weeks`, |
| 428 | }; |
| 429 | const link = { label: "See the review queue", to: `/${ctx.slug}/-/overview?tab=needs#work` }; |
| 430 | if (draft != null && wait >= 4 * draft && wait >= 4 * 3600) { |
| 431 | return { |
| 432 | key: "review", |
| 433 | headline: `Changes an agent drafts in ${draft < 3600 ? "under an hour" : spoken(draft)} are waiting ${spoken(wait)} for first review.`, |
| 434 | facts: [`${short(draft)} to draft`, `${short(wait)} to first review`, "this week"], |
| 435 | link, |
| 436 | trend, |
| 437 | weight: 40 + Math.min(40, (wait / Math.max(draft, 60)) * 2), |
| 438 | ask: "Which agent pull requests are waiting longest for a first review, and who could review them?", |
| 439 | }; |
| 440 | } |
| 441 | if (usual != null && wait >= 1.5 * usual && wait - usual >= 2 * 3600) { |
| 442 | return { |
| 443 | key: "review", |
| 444 | headline: `First reviews are taking ${spoken(wait)}, up from ${spoken(usual)} in the weeks before.`, |
| 445 | facts: [`${now.reviewed} first reviews this week`, `${short(wait)} on average`], |
| 446 | link, |
| 447 | trend, |
| 448 | weight: 30 + Math.min(30, (wait / usual) * 5), |
| 449 | ask: "Why are first reviews slower this week than usual?", |
| 450 | }; |
| 451 | } |
| 452 | return null; |
| 453 | } |
| 454 | |
| 455 | /** What each of the quarter's pull requests cost, by `namespace/name#number`. */ |
| 456 | function pullSpend(usage: Usage | null): Map<string, number> { |
| 457 | const spend = new Map<string, number>(); |
| 458 | for (const slice of usage?.byPull ?? []) spend.set(slice.key.toLowerCase(), (spend.get(slice.key.toLowerCase()) ?? 0) + slice.micros); |
| 459 | return spend; |
| 460 | } |
| 461 | |
| 462 | function pullKey(repo: RepoPath, number: number): string { |
| 463 | return `${repo.namespace}/${repo.name}#${number}`.toLowerCase(); |
| 464 | } |
| 465 | |
| 466 | /** Where the quarter's agent usage went, by milestone (else work item, else project): the biggest share. */ |
| 467 | export function spendSignal(ctx: SignalContext): Signal | null { |
| 468 | const usage = ctx.quarterUsage; |
| 469 | if (!usage) return null; |
| 470 | const spend = pullSpend(usage); |
| 471 | const total = usage.byPull.reduce((sum, slice) => sum + slice.micros, 0); |
| 472 | if (total <= 0) return null; |
| 473 | const items = new Map(ctx.signals.items.map((item) => [item.number, item])); |
| 474 | const milestones = new Map(ctx.signals.milestones.map((milestone) => [milestone.number, milestone])); |
| 475 | const groups = new Map<string, { label: string; to: string; micros: number }>(); |
| 476 | for (const pull of ctx.signals.pulls) { |
| 477 | const repo = ctx.repos[pull.repoId]; |
| 478 | if (!repo) continue; |
| 479 | const micros = spend.get(pullKey(repo, pull.number)); |
| 480 | if (!micros) continue; |
| 481 | const milestone = pull.milestone != null ? milestones.get(pull.milestone) : undefined; |
| 482 | const item = pull.item != null ? items.get(pull.item) : undefined; |
| 483 | const group = milestone |
| 484 | ? { key: `m${milestone.number}`, label: milestone.title, to: milestonePath(ctx.slug, milestone.number) } |
| 485 | : item |
| 486 | ? { key: `i${item.number}`, label: item.title, to: itemPath(ctx.slug, item.number) } |
| 487 | : { key: `r${pull.repoId}`, label: repo.name, to: `/${repo.namespace}/${repo.name}` }; |
| 488 | const entry = groups.get(group.key) ?? { label: group.label, to: group.to, micros: 0 }; |
| 489 | entry.micros += micros; |
| 490 | groups.set(group.key, entry); |
| 491 | } |
| 492 | const top = [...groups.values()].sort((a, b) => b.micros - a.micros)[0]; |
| 493 | if (!top) return null; |
| 494 | const share = top.micros / total; |
| 495 | if (share < 0.35 || groups.size < 2) return null; |
| 496 | const amount = share > 0.5 ? "More than half" : share > 1 / 3 ? "More than a third" : `${Math.round(share * 100)}%`; |
| 497 | return { |
| 498 | key: "spend", |
| 499 | headline: `${amount} of this quarter's agent spend is going into ${top.label}.`, |
| 500 | facts: [`${dollars(top.micros)} of ${dollars(total)}`, "quarter to date"], |
| 501 | link: { label: "See the spend", to: `/${ctx.slug}/-/workspace#spend` }, |
| 502 | weight: 25 + share * 40, |
| 503 | ask: `What has the agent spend on ${top.label} produced this quarter?`, |
| 504 | }; |
| 505 | } |
| 506 | |
| 507 | const WEEK_MS = 7 * DAY_MS; |
| 508 | |
| 509 | /** A milestone closed in the last week, against its due date, with what it took. */ |
| 510 | export function shippedSignal(ctx: SignalContext): Signal | null { |
| 511 | const recent = ctx.signals.milestones |
| 512 | .filter((milestone) => milestone.state === "closed" && milestone.closedAt && ctx.now - Date.parse(milestone.closedAt) <= WEEK_MS) |
| 513 | .sort((a, b) => b.closedAt!.localeCompare(a.closedAt!))[0]; |
| 514 | if (!recent) return null; |
| 515 | const closed = Date.parse(recent.closedAt!); |
| 516 | const pulls = ctx.signals.pulls.filter((pull) => pull.milestone === recent.number && pull.status === "merged"); |
| 517 | const started = Math.min(Date.parse(recent.createdAt), ...pulls.map((pull) => Date.parse(pull.createdAt))); |
| 518 | const weeksIn = Math.max(1, Math.round((closed - started) / WEEK_MS)); |
| 519 | const projects = new Set(pulls.map((pull) => pull.repoId)).size; |
| 520 | const weekday = new Date(closed).toLocaleDateString("en-US", { weekday: "long", timeZone: "UTC" }); |
| 521 | let schedule = ""; |
| 522 | let weight = 35; |
| 523 | if (recent.dueOn) { |
| 524 | const early = Math.round((Date.parse(`${recent.dueOn}T23:59:59Z`) - closed) / DAY_MS); |
| 525 | if (early >= 14) { |
| 526 | schedule = `, ${countWord(Math.round(early / 7)).toLowerCase()} weeks ahead of schedule`; |
| 527 | weight = 45; |
| 528 | } else if (early >= 2) schedule = `, ${early} days ahead of schedule`; |
| 529 | else if (early >= 0) schedule = ", on schedule"; |
| 530 | else schedule = `, ${spoken(-early * 86400)} late`; |
| 531 | } |
| 532 | return { |
| 533 | key: "shipped", |
| 534 | headline: `${recent.title} shipped on ${weekday}${schedule}.`, |
| 535 | facts: [ |
| 536 | `${weeksIn} ${weeksIn === 1 ? "week" : "weeks"} in build`, |
| 537 | `${pulls.length} ${pulls.length === 1 ? "change" : "changes"}`, |
| 538 | ...(projects > 0 ? [`${projects} ${projects === 1 ? "project" : "projects"}`] : []), |
| 539 | ], |
| 540 | link: { label: "See the work", to: milestonePath(ctx.slug, recent.number) }, |
| 541 | weight, |
| 542 | ask: `Summarize what shipped in the milestone ${recent.title}.`, |
| 543 | }; |
| 544 | } |
| 545 | |
| 546 | /** More changes stalled than last week. */ |
| 547 | export function stalledSignal(ctx: SignalContext): Signal | null { |
| 548 | const now = ctx.weeks[ctx.weeks.length - 1]; |
| 549 | const before = ctx.weeks[ctx.weeks.length - 2]; |
| 550 | if (!now || !before) return null; |
| 551 | const more = now.stalled - before.stalled; |
| 552 | if (more < 3 || now.stalled < 1.25 * before.stalled) return null; |
| 553 | return { |
| 554 | key: "stalled", |
| 555 | headline: `${countWord(now.stalled)} changes have been open over a week, ${more} more than last week.`, |
| 556 | facts: [`${before.stalled} last week`, `${now.stalled} now`], |
| 557 | link: { label: "See what's stalled", to: `/${ctx.slug}/-/overview?tab=needs#work` }, |
| 558 | trend: { |
| 559 | values: ctx.weeks.map((week) => week.stalled), |
| 560 | points: ctx.weeks.map((week) => `Week of ${weekLabel(week.start)}: ${week.stalled}`), |
| 561 | caption: "Stalled changes", |
| 562 | range: `${ctx.weeks[0]!.stalled} → ${now.stalled} over ${ctx.weeks.length} weeks`, |
| 563 | }, |
| 564 | weight: 30 + Math.min(25, more * 2), |
| 565 | ask: "Which pull requests have stalled this week, and what are they waiting on?", |
| 566 | }; |
| 567 | } |
| 568 | |
| 569 | /** People asking for changes on agents' work more often, or less, than the weeks before. */ |
| 570 | export function reworkSignal(ctx: SignalContext): Signal | null { |
| 571 | const now = ctx.weeks[ctx.weeks.length - 1]; |
| 572 | if (!now || now.agentMerged < 5) return null; |
| 573 | const rate = reworkRate(now)!; |
| 574 | const earlier = ctx.weeks.slice(-5, -1); |
| 575 | const merged = earlier.reduce((sum, week) => sum + week.agentMerged, 0); |
| 576 | if (merged < 5) return null; |
| 577 | const usual = earlier.reduce((sum, week) => sum + week.agentReworked, 0) / merged; |
| 578 | const change = rate - usual; |
| 579 | if (Math.abs(change) < 0.08) return null; |
| 580 | const up = change > 0; |
| 581 | return { |
| 582 | key: "rework", |
| 583 | headline: up |
| 584 | ? `People asked for changes on ${percent(rate)} of agents' merged work this week, up from ${percent(usual)}.` |
| 585 | : `Agents' work is landing with fewer requests for changes: ${percent(rate)} this week, down from ${percent(usual)}.`, |
| 586 | facts: [`${now.agentReworked} of ${now.agentMerged} agent merges reworked`, "against the four weeks before"], |
| 587 | link: { label: "See Insights", to: `/${ctx.slug}/-/insights` }, |
| 588 | weight: up ? 30 + change * 100 : 15 + Math.abs(change) * 60, |
| 589 | ask: up ? "What did reviewers ask agents to change this week?" : "What changed in how agents' work is reviewed this week?", |
| 590 | }; |
| 591 | } |
| 592 | |
| 593 | // --- Who works where -------------------------------------------------------------- |
| 594 | |
| 595 | /** People who never merged to a project before, now have: in the last `days`, with merges elsewhere first. */ |
| 596 | export type NewGround = { ownerId: string; name: string; repoId: string; firstMergedAt: string; elsewhere: number }; |
| 597 | |
| 598 | export function newGround(authors: SignalAuthor[], now: number, days = 14): NewGround[] { |
| 599 | const byOwner = new Map<string, SignalAuthor[]>(); |
| 600 | for (const row of authors) byOwner.set(row.ownerId, [...(byOwner.get(row.ownerId) ?? []), row]); |
| 601 | const found: NewGround[] = []; |
| 602 | for (const rows of byOwner.values()) { |
| 603 | for (const row of rows) { |
| 604 | if (now - Date.parse(row.firstMergedAt) > days * DAY_MS) continue; |
| 605 | // Merges elsewhere before this one: someone new to the project, not new to the workspace. |
| 606 | const elsewhere = rows |
| 607 | .filter((other) => other.repoId !== row.repoId && other.firstMergedAt < row.firstMergedAt) |
| 608 | .reduce((sum, other) => sum + other.merged, 0); |
| 609 | if (elsewhere >= 3) found.push({ ownerId: row.ownerId, name: row.ownerName, repoId: row.repoId, firstMergedAt: row.firstMergedAt, elsewhere }); |
| 610 | } |
| 611 | } |
| 612 | return found.sort((a, b) => b.firstMergedAt.localeCompare(a.firstMergedAt)); |
| 613 | } |
| 614 | |
| 615 | /** |
| 616 | * A project where one person has done most of the recent work: their |
| 617 | * share of its merges in the last 90 days, and whoever else has started |
| 618 | * merging there in the last 30 (knowledge spreading, or not yet). |
| 619 | */ |
| 620 | export type Silo = { |
| 621 | repoId: string; |
| 622 | ownerId: string; |
| 623 | name: string; |
| 624 | share: number; |
| 625 | theirs: number; |
| 626 | recent: number; |
| 627 | joining: { ownerId: string; name: string }[]; |
| 628 | }; |
| 629 | |
| 630 | export function silos(authors: SignalAuthor[], now: number, { minRecent = 6, minShare = 0.75 } = {}): Silo[] { |
| 631 | const byRepo = new Map<string, SignalAuthor[]>(); |
| 632 | for (const row of authors) byRepo.set(row.repoId, [...(byRepo.get(row.repoId) ?? []), row]); |
| 633 | const found: Silo[] = []; |
| 634 | for (const [repoId, rows] of byRepo) { |
| 635 | const recent = rows.reduce((sum, row) => sum + row.recent, 0); |
| 636 | if (recent < minRecent) continue; |
| 637 | const top = [...rows].sort((a, b) => b.recent - a.recent)[0]!; |
| 638 | const share = top.recent / recent; |
| 639 | if (share < minShare) continue; |
| 640 | const joining = rows |
| 641 | .filter((row) => row.ownerId !== top.ownerId && row.recent > 0 && now - Date.parse(row.firstMergedAt) <= 30 * DAY_MS) |
| 642 | .map((row) => ({ ownerId: row.ownerId, name: row.ownerName })); |
| 643 | found.push({ repoId, ownerId: top.ownerId, name: top.ownerName, share, theirs: top.recent, recent, joining }); |
| 644 | } |
| 645 | return found.sort((a, b) => b.recent * b.share - a.recent * a.share); |
| 646 | } |
| 647 | |
| 648 | /** People whose first merged agent pull request came in the last `days`, after merging their own work before. */ |
| 649 | export type NewHabit = { ownerId: string; name: string; firstAgentAt: string; repoId: string; byHand: number }; |
| 650 | |
| 651 | export function newHabits(pulls: SignalPull[], now: number, days = 14): NewHabit[] { |
| 652 | const byOwner = new Map<string, SignalPull[]>(); |
| 653 | for (const pull of pulls) if (pull.status === "merged" && pull.mergedAt) byOwner.set(pull.ownerId, [...(byOwner.get(pull.ownerId) ?? []), pull]); |
| 654 | const found: NewHabit[] = []; |
| 655 | for (const merged of byOwner.values()) { |
| 656 | merged.sort((a, b) => a.mergedAt!.localeCompare(b.mergedAt!)); |
| 657 | const first = merged.find((pull) => pull.agent); |
| 658 | if (!first || now - Date.parse(first.mergedAt!) > days * DAY_MS) continue; |
| 659 | const byHand = merged.filter((pull) => !pull.agent && pull.mergedAt! < first.mergedAt!).length; |
| 660 | if (byHand >= 3) found.push({ ownerId: first.ownerId, name: first.ownerName, firstAgentAt: first.mergedAt!, repoId: first.repoId, byHand }); |
| 661 | } |
| 662 | return found.sort((a, b) => b.firstAgentAt.localeCompare(a.firstAgentAt)); |
| 663 | } |
| 664 | |
| 665 | /** The quiet changes as one finding: someone moving into a project one person has carried, else anyone new to one. */ |
| 666 | export function groundSignal(ctx: SignalContext): Signal | null { |
| 667 | const spreading = silos(ctx.signals.authors, ctx.now).find((silo) => silo.joining.length > 0 && ctx.repos[silo.repoId]); |
| 668 | if (spreading) { |
| 669 | const repo = ctx.repos[spreading.repoId]!; |
| 670 | const joiner = spreading.joining[0]!; |
| 671 | return { |
| 672 | key: "ground", |
| 673 | headline: `${joiner.name} is starting to work in ${repo.name}, which ${spreading.name} has carried almost alone.`, |
| 674 | facts: [`${spreading.theirs} of ${spreading.recent} merges in 90 days were ${spreading.name}'s`, `${joiner.name} started this month`], |
| 675 | link: { label: "See the people", to: `/${ctx.slug}/-/workspace#quiet` }, |
| 676 | weight: 28, |
| 677 | ask: `What has ${joiner.name} worked on in ${repo.namespace}/${repo.name} so far?`, |
| 678 | }; |
| 679 | } |
| 680 | const moved = newGround(ctx.signals.authors, ctx.now, 7).filter((row) => ctx.repos[row.repoId]); |
| 681 | if (moved.length === 0) return null; |
| 682 | const first = moved[0]!; |
| 683 | const repo = ctx.repos[first.repoId]!; |
| 684 | const others = new Set(moved.map((row) => row.ownerId)).size - 1; |
| 685 | return { |
| 686 | key: "ground", |
| 687 | headline: |
| 688 | others > 0 |
| 689 | ? `${first.name} and ${others === 1 ? "one other person" : `${others} others`} merged their first change to a project new to them this week.` |
| 690 | : `${first.name} merged a first change to ${repo.name} this week, new ground for them.`, |
| 691 | facts: [`${first.elsewhere} merges elsewhere before`, `first in ${repo.name}`], |
| 692 | link: { label: "See the people", to: `/${ctx.slug}/-/workspace#quiet` }, |
| 693 | weight: 20 + Math.min(10, others * 3), |
| 694 | ask: `What did ${first.name} change in ${repo.namespace}/${repo.name}?`, |
| 695 | }; |
| 696 | } |
| 697 | |
| 698 | const FINDINGS = [blockedSignal, reviewSignal, shippedSignal, spendSignal, stalledSignal, reworkSignal, groundSignal]; |
| 699 | |
| 700 | /** Every finding worth a look today, the one that matters most first. A finding that cannot be worked out is left out. */ |
| 701 | export function findings(ctx: SignalContext): Signal[] { |
| 702 | const found: Signal[] = []; |
| 703 | for (const make of FINDINGS) { |
| 704 | try { |
| 705 | const signal = make(ctx); |
| 706 | if (signal) found.push(signal); |
| 707 | } catch { |
| 708 | // One finding's arithmetic never takes the rest down. |
| 709 | } |
| 710 | } |
| 711 | return found.sort((a, b) => b.weight - a.weight); |
| 712 | } |
| 713 | |
| 714 | /** The headline when nothing stands out: still a sentence about the week. */ |
| 715 | export function quietHeadline(week: WeekTotals | undefined): string { |
| 716 | if (!week || week.merged === 0) return "A quiet week: nothing has merged yet, and nothing is stuck."; |
| 717 | const share = agentShare(week); |
| 718 | return `${countWord(week.merged)} ${week.merged === 1 ? "change" : "changes"} merged this week${share ? `, ${percent(share)} of them an agent's` : ""}, and nothing needs a closer look.`; |
| 719 | } |
| 720 | |
| 721 | /** What Home shows: the pulse, and the findings with the one that leads. */ |
| 722 | export type DailySignal = { |
| 723 | tiles: PulseTile[]; |
| 724 | findings: Signal[]; |
| 725 | /** The headline when no finding stands out. */ |
| 726 | quiet: string; |
| 727 | complete: boolean; |
| 728 | }; |
| 729 | |
| 730 | export function dailySignal(data: { |
| 731 | slug: string; |
| 732 | now: number; |
| 733 | repos: Record<string, RepoPath>; |
| 734 | signals: WorkspaceSignals; |
| 735 | complete: boolean; |
| 736 | weeks: WeekTotals[]; |
| 737 | spend: number[] | null; |
| 738 | quarterUsage: Usage | null; |
| 739 | range: { quarter: string }; |
| 740 | }): DailySignal { |
| 741 | return { |
| 742 | tiles: pulse({ slug: data.slug, weeks: data.weeks, spend: data.spend }), |
| 743 | findings: findings({ |
| 744 | slug: data.slug, |
| 745 | now: data.now, |
| 746 | repos: data.repos, |
| 747 | signals: data.signals, |
| 748 | weeks: data.weeks, |
| 749 | quarterUsage: data.quarterUsage, |
| 750 | quarter: data.range.quarter, |
| 751 | }), |
| 752 | quiet: quietHeadline(data.weeks[data.weeks.length - 1]), |
| 753 | complete: data.complete, |
| 754 | }; |
| 755 | } |
| 756 | |
| 757 | // --- Spend threaded to the work --------------------------------------------------- |
| 758 | |
| 759 | /** How much of a node's work was an agent's: 0 (none) to 4 (nearly all), the flow's five steps. */ |
| 760 | export function agentStep(share: number): 0 | 1 | 2 | 3 | 4 { |
| 761 | if (share < 0.2) return 0; |
| 762 | if (share < 0.4) return 1; |
| 763 | if (share < 0.6) return 2; |
| 764 | if (share < 0.8) return 3; |
| 765 | return 4; |
| 766 | } |
| 767 | |
| 768 | export type FlowPull = { key: string; repo: string; number: number; title: string; agent: boolean; micros: number; status: SignalPull["status"]; to: string }; |
| 769 | |
| 770 | export type FlowNode = { |
| 771 | key: string; |
| 772 | label: string; |
| 773 | /** Where its own page is, when it has one. */ |
| 774 | to: string | null; |
| 775 | pulls: number; |
| 776 | agent: number; |
| 777 | micros: number; |
| 778 | children: FlowNode[]; |
| 779 | /** Its pull requests, for the last level: the costliest (else newest) first. */ |
| 780 | list?: FlowPull[]; |
| 781 | }; |
| 782 | |
| 783 | /** Nodes a column shows before the rest fold into one. */ |
| 784 | export const FLOW_NODES = 5; |
| 785 | /** Pull requests listed for a change set. */ |
| 786 | export const FLOW_PULLS = 7; |
| 787 | |
| 788 | function fold(nodes: FlowNode[], label: string): FlowNode[] { |
| 789 | nodes.sort((a, b) => b.micros - a.micros || b.pulls - a.pulls); |
| 790 | if (nodes.length <= FLOW_NODES) return nodes; |
| 791 | const rest = nodes.slice(FLOW_NODES - 1); |
| 792 | const other: FlowNode = { |
| 793 | key: `other:${rest.map((node) => node.key).join(",")}`.slice(0, 120), |
| 794 | label, |
| 795 | to: null, |
| 796 | pulls: rest.reduce((sum, node) => sum + node.pulls, 0), |
| 797 | agent: rest.reduce((sum, node) => sum + node.agent, 0), |
| 798 | micros: rest.reduce((sum, node) => sum + node.micros, 0), |
| 799 | children: fold( |
| 800 | rest.flatMap((node) => node.children), |
| 801 | label, |
| 802 | ), |
| 803 | list: rest.flatMap((node) => node.list ?? []).slice(0, FLOW_PULLS), |
| 804 | }; |
| 805 | return [...nodes.slice(0, FLOW_NODES - 1), other]; |
| 806 | } |
| 807 | |
| 808 | /** |
| 809 | * The quarter's work as a tree: milestone, work item, the work item's |
| 810 | * pull requests in each project (its change set there), and the pull |
| 811 | * requests, each node with its pull requests, how many were an agent's, |
| 812 | * and what the agents' usage on them came to. Work with no milestone or |
| 813 | * no work item has a node of its own. |
| 814 | */ |
| 815 | export function spendFlow(ctx: Pick<SignalContext, "slug" | "repos" | "signals" | "quarterUsage" | "quarter">): FlowNode[] { |
| 816 | const spend = pullSpend(ctx.quarterUsage); |
| 817 | const items = new Map(ctx.signals.items.map((item) => [item.number, item])); |
| 818 | const milestones = new Map(ctx.signals.milestones.map((milestone) => [milestone.number, milestone])); |
| 819 | type Build = FlowNode & { index: Map<string, Build> }; |
| 820 | const make = (key: string, label: string, to: string | null): Build => ({ key, label, to, pulls: 0, agent: 0, micros: 0, children: [], index: new Map() }); |
| 821 | const roots = new Map<string, Build>(); |
| 822 | const child = (parent: Map<string, Build>, key: string, label: string, to: string | null) => { |
| 823 | let node = parent.get(key); |
| 824 | if (!node) parent.set(key, (node = make(key, label, to))); |
| 825 | return node; |
| 826 | }; |
| 827 | for (const pull of ctx.signals.pulls) { |
| 828 | const repo = ctx.repos[pull.repoId]; |
| 829 | if (!repo) continue; |
| 830 | if (pull.createdAt.slice(0, 10) < ctx.quarter && !(pull.mergedAt && pull.mergedAt.slice(0, 10) >= ctx.quarter)) continue; |
| 831 | const micros = spend.get(pullKey(repo, pull.number)) ?? 0; |
| 832 | const milestone = pull.milestone != null ? milestones.get(pull.milestone) : undefined; |
| 833 | const item = pull.item != null ? items.get(pull.item) : undefined; |
| 834 | const path = [ |
| 835 | child(roots, milestone ? `m${milestone.number}` : "m-", milestone?.title ?? "No milestone", milestone ? milestonePath(ctx.slug, milestone.number) : null), |
| 836 | ]; |
| 837 | path.push( |
| 838 | child( |
| 839 | path[0]!.index, |
| 840 | `${path[0]!.key}/${item ? `i${item.number}` : "i-"}`, |
| 841 | item ? item.title : "No work item", |
| 842 | item ? itemPath(ctx.slug, item.number) : null, |
| 843 | ), |
| 844 | ); |
| 845 | path.push(child(path[1]!.index, `${path[1]!.key}/${pull.repoId}`, repo.name, `/${repo.namespace}/${repo.name}`)); |
| 846 | for (const node of path) { |
| 847 | node.pulls += 1; |
| 848 | node.agent += pull.agent ? 1 : 0; |
| 849 | node.micros += micros; |
| 850 | } |
| 851 | const set = path[2]!; |
| 852 | (set.list ??= []).push({ |
| 853 | key: pullKey(repo, pull.number), |
| 854 | repo: repo.name, |
| 855 | number: pull.number, |
| 856 | title: pull.title, |
| 857 | agent: pull.agent, |
| 858 | micros, |
| 859 | status: pull.status, |
| 860 | to: pullPath(repo, pull.number), |
| 861 | }); |
| 862 | } |
| 863 | const finish = (nodes: Map<string, Build>, label: string, depth: number): FlowNode[] => |
| 864 | fold( |
| 865 | [...nodes.values()].map(({ index, ...node }) => ({ |
| 866 | ...node, |
| 867 | children: depth < 2 ? finish(index, depth === 0 ? "Other work items" : "Other projects", depth + 1) : [], |
| 868 | list: node.list?.sort((a, b) => b.micros - a.micros || b.number - a.number).slice(0, FLOW_PULLS), |
| 869 | })), |
| 870 | label, |
| 871 | ); |
| 872 | return finish(roots, "Other milestones", 0); |
| 873 | } |
| 874 | |
| 875 | // --- The rhythm of the work ------------------------------------------------------- |
| 876 | |
| 877 | export type RhythmDay = { day: string; opened: number; merged: number; reworked: number; reviews: number; sessions: number }; |
| 878 | |
| 879 | export type RhythmRow = { |
| 880 | key: string; |
| 881 | label: string; |
| 882 | /** "3 people", or what the row is. */ |
| 883 | note: string; |
| 884 | to: string; |
| 885 | days: RhythmDay[]; |
| 886 | /** Everything in the fortnight, to rank and to say so. */ |
| 887 | total: number; |
| 888 | }; |
| 889 | |
| 890 | export type Group = { key: string; label: string; note: string; to: string; repoIds: string[] }; |
| 891 | |
| 892 | /** Rows of the rhythm: each group's days, quiet days too; busiest first, at most `max`. */ |
| 893 | export function rhythm(days: SignalDay[], list: string[], groups: Group[], max = 6): RhythmRow[] { |
| 894 | const rows = groups.map((group) => { |
| 895 | const ids = new Set(group.repoIds); |
| 896 | const byDay = new Map(list.map((day) => [day, { day, opened: 0, merged: 0, reworked: 0, reviews: 0, sessions: 0 }])); |
| 897 | for (const row of days) { |
| 898 | const day = byDay.get(row.day); |
| 899 | if (!day || !ids.has(row.repoId)) continue; |
| 900 | day.opened += row.opened; |
| 901 | day.merged += row.merged; |
| 902 | day.reworked += row.reworked; |
| 903 | day.reviews += row.reviews; |
| 904 | day.sessions += row.sessions; |
| 905 | } |
| 906 | const out = list.map((day) => byDay.get(day)!); |
| 907 | const total = out.reduce((sum, day) => sum + day.opened + day.merged + day.reworked + day.reviews + day.sessions, 0); |
| 908 | return { key: group.key, label: group.label, note: group.note, to: group.to, days: out, total }; |
| 909 | }); |
| 910 | return rows |
| 911 | .filter((row) => row.total > 0) |
| 912 | .sort((a, b) => b.total - a.total) |
| 913 | .slice(0, max); |
| 914 | } |
| 915 | |
| 916 | /** A row's week, added up, as its hover and its line say it. */ |
| 917 | export function rhythmSum(days: RhythmDay[]) { |
| 918 | return days.reduce( |
| 919 | (sum, day) => ({ |
| 920 | opened: sum.opened + day.opened, |
| 921 | merged: sum.merged + day.merged, |
| 922 | reworked: sum.reworked + day.reworked, |
| 923 | reviews: sum.reviews + day.reviews, |
| 924 | sessions: sum.sessions + day.sessions, |
| 925 | }), |
| 926 | { opened: 0, merged: 0, reworked: 0, reviews: 0, sessions: 0 }, |
| 927 | ); |
| 928 | } |
| 929 | |
| 930 | /** How a row's this week compares with its last, in a few words. */ |
| 931 | export function rhythmLine(row: RhythmRow): string { |
| 932 | const half = Math.floor(row.days.length / 2); |
| 933 | const last = rhythmSum(row.days.slice(0, half)); |
| 934 | const now = rhythmSum(row.days.slice(half)); |
| 935 | const prs = (sum: ReturnType<typeof rhythmSum>) => sum.opened + sum.merged; |
| 936 | if (prs(now) === 0 && prs(last) > 0) return "Quiet this week after a busy last week"; |
| 937 | if (now.reviews === 0 && now.opened > 0) return `${now.opened} opened this week, no reviews yet`; |
| 938 | if (prs(now) > 1.5 * prs(last) && prs(now) >= 4) return "Busier than last week"; |
| 939 | if (now.reworked > Math.max(2, last.reworked * 2)) return "More rework than last week"; |
| 940 | return `${now.merged} merged this week, ${last.merged} last week`; |
| 941 | } |
| 942 | |
| 943 | export type WaitingRow = WaitingPull & { repo: RepoPath; to: string }; |
| 944 | |
| 945 | /** The unreviewed pull requests with their paths, for the Workspace overview's waiting list. */ |
| 946 | export function waitingRows(waiting: WaitingPull[], repos: Record<string, RepoPath>, max = 8): WaitingRow[] { |
| 947 | return waiting |
| 948 | .filter((pull) => repos[pull.repoId]) |
| 949 | .slice(0, max) |
| 950 | .map((pull) => ({ ...pull, repo: repos[pull.repoId]!, to: pullPath(repos[pull.repoId]!, pull.number) })); |
| 951 | } |