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,076 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});
118
119/** Every week in `starts`, quiet ones too, for the repositories in `only` (every one when not given). */
120export 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 */
142export 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. */
161export 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. */
166export 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. */
171export 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. */
176export 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. */
181export 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
187const WORDS = ["No", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten"];
188
189/** A count as a sentence starts it: "Three", or "12". */
190export 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". */
195export 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". */
206export 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
213export 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. */
218export 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". */
227export 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". */
237export 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
243export type Tone = "good" | "bad" | "flat";
244
245export 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
261function tone(change: number, upIsGood: boolean): Tone {
262 if (change === 0) return "flat";
263 return change > 0 === upIsGood ? "good" : "bad";
264}
265
266function signed(n: number, format: (abs: number) => string): string {
267 return `${n > 0 ? "+" : n < 0 ? "−" : "±"}${format(Math.abs(n))}`;
268}
269
270function 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
276export 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 */
288export 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
343export 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
358export 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
369export function pullPath(repo: RepoPath, number: number): string {
370 return `/${repo.namespace}/${repo.name}/pull/${number}`;
371}
372
373export function itemPath(slug: string, number: number): string {
374 return `/${slug}/-/work/${number}`;
375}
376
377export function milestonePath(slug: string, number: number): string {
378 return `/${slug}/-/work/milestones/${number}`;
379}
380
381function 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. */
386export 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 */
414export 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
457const MICROS = 1_000_000;
458
459/** Whether a pull request's work falls in the quarter: opened in it, or merged in it. */
460function 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 */
471export 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
495function 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. */
500export 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
538const WEEK_MS = 7 * DAY_MS;
539
540/** A milestone closed in the last week, against its due date, with what it took. */
541export 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. */
578export 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. */
601export 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. */
627export type NewGround = { ownerId: string; name: string; repoId: string; firstMergedAt: string; elsewhere: number };
628
629export 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 */
651export 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
661export 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. */
680export type NewHabit = { ownerId: string; name: string; firstAgentAt: string; repoId: string; byHand: number };
681
682export 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 */
702export 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
709export 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
729const 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. */
738export 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". */
743export 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. */
750export 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. */
768export 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
800const 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. */
803export 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. */
817export 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. */
824export type DailySignal = {
825 tiles: PulseTile[];
826 findings: Signal[];
827 /** The headline when no finding stands out. */
828 quiet: string;
829 complete: boolean;
830};
831
832export 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. */
862export 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
870export type FlowPull = { key: string; repo: string; number: number; title: string; agent: boolean; micros: number; status: SignalPull["status"]; to: string };
871
872export 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. */
886export const FLOW_NODES = 5;
887/** Pull requests listed for a change set. */
888export const FLOW_PULLS = 7;
889
890function 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 */
917export 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
979export type RhythmDay = { day: string; opened: number; merged: number; reworked: number; reviews: number; sessions: number };
980
981export 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
992export 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`. */
995export 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. */
1019export 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. */
1033export 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
1045export type WaitingRow = WaitingPull & { repo: RepoPath; to: string };
1046
1047/** The unreviewed pull requests with their paths, for the Workspace overview's waiting list. */
1048export 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
1057export 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 */
1070export 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}