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