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