Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

1,139 linesCodeBlameRaw
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 */
14import 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
29const DAY_MS = 86_400_000;
30
31/** Weeks of history the pulse and the trends read. */
32export const SIGNAL_WEEKS = 10;
33/** Days of history the rhythm of the work draws: last week and this one. */
34export const SIGNAL_DAYS = 14;
35/** How far back pull requests are read: a milestone's build, and the quarter. */
36export const SIGNAL_REACH_DAYS = 120;
37
38// --- Days and weeks ---------------------------------------------------------------
39
40/** `YYYY-MM-DD` of a time, in UTC. */
41export function utcDay(at: number): string {
42 return new Date(at).toISOString().slice(0, 10);
43}
44
45export 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`. */
50export 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`. */
55export 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. */
60export 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`. */
67export 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
76export 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. */
81export 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. */
104export type WeekTotals = Omit<SignalWeek, "repoId">;
105
106const 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). */
121export 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 */
144export 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. */
163export 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. */
168export 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. */
173export 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 */
183export 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. */
188export 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. */
193export 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. */
198export 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
204const WORDS = ["No", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten"];
205
206/** A count as a sentence starts it: "Three", or "12". */
207export 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". */
212export 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". */
223export 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
230export 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. */
235export 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". */
244export 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". */
254export 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
260export type Tone = "good" | "bad" | "flat";
261
262export 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
278function tone(change: number, upIsGood: boolean): Tone {
279 if (change === 0) return "flat";
280 return change > 0 === upIsGood ? "good" : "bad";
281}
282
283function signed(n: number, format: (abs: number) => string): string {
284 return `${n > 0 ? "+" : n < 0 ? "−" : "±"}${format(Math.abs(n))}`;
285}
286
287function 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
293export 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 */
306export 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
361export 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
378export 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
389export function pullPath(repo: RepoPath, number: number): string {
390 return `/${repo.namespace}/${repo.name}/pull/${number}`;
391}
392
393export function itemPath(slug: string, number: number): string {
394 return `/${slug}/-/work/${number}`;
395}
396
397export function milestonePath(slug: string, number: number): string {
398 return `/${slug}/-/work/milestones/${number}`;
399}
400
401function 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. */
406export 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 */
437export 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
480const MICROS = 1_000_000;
481
482/** Whether a pull request's work falls in the quarter: opened in it, or merged in it. */
483function 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 */
494export 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
518function 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. */
523export 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
561const WEEK_MS = 7 * DAY_MS;
562
563/** A milestone closed in the last week, against its due date, with what it took. */
564export 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. */
602export 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. */
625export 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. */
651export type NewGround = { ownerId: string; name: string; repoId: string; firstMergedAt: string; elsewhere: number };
652
653export 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 */
676export 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
686export 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. */
708export type NewHabit = { ownerId: string; name: string; firstAgentAt: string; repoId: string; byHand: number };
709
710export 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 */
730export 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
737export 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
757const 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. */
766export 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". */
771export 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. */
778export 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. */
796export 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
829const 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. */
832export 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 */
849export 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. */
857export 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 */
865export 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. */
883export 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
892export 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. */
923export 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
931export type FlowPull = { key: string; repo: string; number: number; title: string; agent: boolean; micros: number; status: SignalPull["status"]; to: string };
932
933export 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. */
947export const FLOW_NODES = 5;
948/** Pull requests listed for a change set. */
949export const FLOW_PULLS = 7;
950
951function 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 */
978export 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
1040export type RhythmDay = { day: string; opened: number; merged: number; reworked: number; reviews: number; sessions: number; commits: number };
1041
1042export 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
1053export 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`. */
1056export 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. */
1081export 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. */
1096export 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
1108export type WaitingRow = WaitingPull & { repo: RepoPath; to: string };
1109
1110/** The unreviewed pull requests with their paths, for the Workspace overview's waiting list. */
1111export 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
1120export 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 */
1133export 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}