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