g1t/apps/web/app/lib/mission-control.ts

610 lines25,051 bytesCodeBlame
1/**
2 * The shaping behind mission control's dashboard: why each thing needs the
3 * viewer, what an agent already knows about it, the three tabs, the week
4 * split into what agents landed alone and what a person merged, and the
5 * one sentence under the greeting. Pure, so it is tested on its own; it
6 * imports only types.
7 */
8import type { AgentRun, CheckStatus, ChangedFile, Confidence, ConfidenceLevel, Lifecycle, RepoPath, RunKind, Stage } from "@g1t/contracts";
9
10import type { Need } from "./mission";
11
12const DAY = 24 * 60 * 60 * 1000;
13
14/** Accounts that are g1t's own agents and machinery: `isAgent` in ./mission, kept here so this module imports only types. */
15export function isAgent(name: string | null | undefined): boolean {
16 return name === "g1t" || (name ?? "").endsWith("-agent");
17}
18
19// --- Reasons ----------------------------------------------------------------
20
21/** Why something is waiting on a person, as the chip beside it says. */
22export type Reason =
23 | "blocking"
24 | "asked_for_you"
25 | "checks_failing"
26 | "outside_guardrails"
27 | "low_confidence"
28 | "needs_review"
29 | "stalled"
30 | "ready_to_merge";
31
32export const REASON_LABEL: Record<Reason, string> = {
33 blocking: "Blocking",
34 asked_for_you: "Asked for you",
35 checks_failing: "Checks failing",
36 outside_guardrails: "Outside guardrails",
37 low_confidence: "Low confidence",
38 needs_review: "Needs review",
39 stalled: "Stalled",
40 ready_to_merge: "Ready to merge",
41};
42
43/**
44 * The reason a need is shown, from its kind and, for a pull request g1t
45 * stopped seeing through, the sentence it stopped with.
46 */
47export function reasonFor(need: Pick<Need, "kind" | "detail">): Reason {
48 switch (need.kind) {
49 case "limit":
50 case "deploy":
51 case "conflict":
52 return "blocking";
53 case "invitation":
54 case "review":
55 return "asked_for_you";
56 case "checks":
57 return "checks_failing";
58 case "ready":
59 return "ready_to_merge";
60 case "stuck":
61 return "stalled";
62 case "stalled":
63 return stallReason(need.detail);
64 }
65}
66
67/** What a pull request's `needs_you` sentence says it is waiting for. */
68export function stallReason(detail: string): Reason {
69 // First: its reasons can name checks, caps or approval in passing.
70 if (/confidence in this change is low/i.test(detail)) return "low_confidence";
71 if (/cost cap|time cap|unusual CPU/i.test(detail)) return "outside_guardrails";
72 if (/conflict|could not (?:be )?merge/i.test(detail)) return "blocking";
73 if (/checks? (?:still )?fail|still fails?\b|could not be run|failed in the merge queue/i.test(detail)) return "checks_failing";
74 if (/approv|asked for changes|review (?:still|could not)/i.test(detail)) return "needs_review";
75 return "stalled";
76}
77
78/**
79 * The reason a pull request's need is shown, given how sure g1t is of the
80 * change: one that is ready, or held, with low confidence says so.
81 */
82export function withConfidence(reason: Reason, confidence: Pick<Confidence, "level"> | null | undefined): Reason {
83 if (confidence?.level !== "low") return reason;
84 return reason === "ready_to_merge" || reason === "stalled" || reason === "needs_review" ? "low_confidence" : reason;
85}
86
87const LEVEL: Record<ConfidenceLevel, string> = { low: "Low", medium: "Medium", high: "High" };
88
89/** "Low — tests not added, 3 revisions": a confidence in one line. */
90export function confidenceLine(confidence: Pick<Confidence, "level" | "reasons">): string {
91 return confidence.reasons.length > 0 ? `${LEVEL[confidence.level]} — ${confidence.reasons.join(", ")}` : LEVEL[confidence.level];
92}
93
94/** The ask, for a change held for its low confidence. */
95export function confidenceAsk(confidence: Pick<Confidence, "reasons">): string {
96 const why = confidence.reasons.length > 0 ? `: ${confidence.reasons.join(", ")}` : "";
97 return `The agent finished, but g1t is not sure of the change${why}. Approve it to let it land, or ask for changes.`;
98}
99
100/** Why only a person can move it: the callout beside what the agent knows. */
101export function whyFor(
102 reason: Reason,
103 need: Pick<Need, "kind" | "detail">,
104 confidence?: Pick<Confidence, "reasons" | "uncertainAbout"> | null,
105): string {
106 if (reason === "low_confidence") {
107 const reasons = confidence?.reasons ?? [];
108 const what = reasons.length > 0 ? reasons.join(", ") : "what g1t observed of the change";
109 const unsure =
110 confidence && confidence.uncertainAbout.length > 0 ? ` The agent said it was unsure about ${confidence.uncertainAbout.join("; ")}.` : "";
111 // Held: the repository asks a person first. Otherwise it is ready and waits for a merge anyway.
112 const held =
113 need.kind === "stalled"
114 ? "This repository asks a person before a change like that lands, so nothing merges it until you approve it."
115 : "Look at it before you merge it.";
116 return `g1t rates its confidence in this change low, from ${what}.${unsure} ${held}`;
117 }
118 if (need.kind === "limit") return "Agents start nothing new past the workspace's usage limit. Only an owner can raise it or add a card.";
119 if (need.kind === "deploy")
120 return "Production still serves the build before this one. Every push to the default branch builds again, so this fails until what broke is fixed.";
121 if (need.kind === "invitation") return "An invitation is to you. No one else can accept it.";
122 if (need.kind === "review") return "You were asked to review it by name, so it waits for your verdict.";
123 if (need.kind === "stuck")
124 return "A running agent has stopped reporting. It may be working on something long, or stuck; a look at its session tells which.";
125 switch (reason) {
126 case "outside_guardrails":
127 return "The run reached a limit set in Guardrails. g1t does not lift a cap on its own; a person raises it, then asks for the next step.";
128 case "blocking":
129 return "g1t could not land it and stopped rather than guess. A person decides how it lands, or whether it should.";
130 case "checks_failing":
131 return "The agent revised and the checks still fail, so g1t stopped sending it back instead of looping. Guide it, fix it yourself, or close it.";
132 case "needs_review":
133 return "This repository wants a person's verdict before it lands, and the agent has done what it can without one.";
134 case "ready_to_merge":
135 return "Checks passed and it was approved. This repository lands a change only when a person merges it.";
136 case "stalled":
137 return "The agent stopped and g1t does not start it again on its own. Ask for a review, a revision or a catch-up, or close it.";
138 case "asked_for_you":
139 return "It was put to you by name.";
140 }
141}
142
143/** Reasons where nothing moves until a person acts on it. */
144export const BLOCKING: ReadonlySet<Reason> = new Set<Reason>(["blocking"]);
145
146// --- What the agent knows ---------------------------------------------------
147
148export type FactTone = "good" | "warn" | "bad" | null;
149/** One thing known; a `wide` one takes the whole row and wraps. */
150export type Fact = { label: string; value: string; tone: FactTone; wide?: boolean };
151
152const CONFIDENCE_TONE: Record<ConfidenceLevel, FactTone> = { low: "bad", medium: "warn", high: "good" };
153
154/** How sure g1t is of the change, with its reasons, as a fact. */
155export function confidenceFact(confidence: Pick<Confidence, "level" | "reasons">): Fact {
156 return { label: "Confidence", value: confidenceLine(confidence), tone: CONFIDENCE_TONE[confidence.level], wide: true };
157}
158
159/** The stages a pull request reaches only once its required checks have passed. */
160const PAST_CHECKS = new Set(["reviewing", "catching_up", "answering", "queued", "ready"]);
161
162/**
163 * How the checks the default branch requires stand on a pull request, from
164 * what a list knows without opening it: the merge queue taking it out, and
165 * for one g1t sees through, the stage it is at and what that stage says.
166 */
167export function checksFact(
168 checkStatus: CheckStatus | null,
169 lifecycle?: Pick<Lifecycle, "stage" | "detail"> | null,
170): Fact {
171 const fact = (value: string, tone: FactTone): Fact => ({ label: "Required checks", value, tone });
172 if (checkStatus === "failed") return fact("Failed in the merge queue", "bad");
173 if (!lifecycle?.stage) return fact(checkStatus === "passed" ? "Passing" : "Not known yet", checkStatus === "passed" ? "good" : null);
174 const detail = lifecycle.detail ?? "";
175 // "CI / pull_request failed", "The required check CI still fails ...".
176 const failing = /\b(?:failed|still fails?)\b/i.test(detail) && !/review|asked for changes|merge queue/i.test(detail);
177 if (lifecycle.stage === "checking") return /to report/i.test(detail) ? fact("Not reported yet", "warn") : fact("Running", null);
178 if (lifecycle.stage === "working") return fact("Not run yet", null);
179 if ((lifecycle.stage === "revising" || lifecycle.stage === "needs_you") && failing) return fact("Failing", "bad");
180 if (PAST_CHECKS.has(lifecycle.stage) || lifecycle.stage === "revising" || lifecycle.stage === "needs_you") return fact("Passing", "good");
181 return fact("Not known yet", null);
182}
183
184/** Test files, by the names test runners look for. */
185export function isTestFile(path: string): boolean {
186 return /(^|\/)(__tests__|tests?|spec)\//i.test(path) || /[._-](test|spec)\.[a-z0-9]+$/i.test(path) || /_test\.(go|rs|py)$/i.test(path);
187}
188
189const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`;
190
191/** "$0.42", or "<$0.01" for a sliver. */
192export function usd(value: number): string {
193 if (value > 0 && value < 0.01) return "<$0.01";
194 return `$${value.toFixed(2)}`;
195}
196
197/**
198 * What is known about a pull request without opening it: its checks, how
199 * much it changes, the tests it touches, how often the agent was sent back,
200 * and what its runs cost. Only what is known is listed.
201 */
202export function pullFacts(input: {
203 checkStatus: CheckStatus | null;
204 files: ChangedFile[];
205 lifecycle?: (Pick<Lifecycle, "revisions"> & Partial<Pick<Lifecycle, "stage" | "detail">>) | null;
206 runs?: Pick<AgentRun, "costUsd" | "kind">[];
207 confidence?: Pick<Confidence, "level" | "reasons"> | null;
208}): Fact[] {
209 const facts: Fact[] = [];
210 facts.push(
211 checksFact(input.checkStatus, input.lifecycle?.stage ? { stage: input.lifecycle.stage, detail: input.lifecycle.detail ?? "" } : null),
212 );
213 if (input.files.length > 0) {
214 const added = input.files.reduce((sum, f) => sum + f.additions, 0);
215 const removed = input.files.reduce((sum, f) => sum + f.deletions, 0);
216 facts.push({ label: "Files changed", value: input.files.length.toLocaleString("en-US"), tone: null });
217 facts.push({ label: "Lines", value: `+${added.toLocaleString("en-US")} −${removed.toLocaleString("en-US")}`, tone: null });
218 const tests = input.files.filter((f) => isTestFile(f.path)).length;
219 facts.push({ label: "Tests", value: tests > 0 ? plural(tests, "file") : "None touched", tone: tests > 0 ? "good" : "warn" });
220 }
221 if (input.lifecycle && input.lifecycle.revisions > 0) {
222 facts.push({
223 label: "Sent back",
224 value: plural(input.lifecycle.revisions, "time"),
225 tone: input.lifecycle.revisions > 1 ? "warn" : null,
226 });
227 }
228 const agentRuns = (input.runs ?? []).filter((run) => run.kind !== "checks" && run.kind !== "queue" && run.kind !== "mergecheck");
229 if (agentRuns.length > 0) {
230 const costs = agentRuns.filter((run) => run.costUsd != null);
231 const cost = costs.reduce((sum, run) => sum + (run.costUsd ?? 0), 0);
232 facts.push({
233 label: "Agent runs",
234 value: costs.length > 0 ? `${agentRuns.length} · ${usd(cost)}` : String(agentRuns.length),
235 tone: null,
236 });
237 }
238 if (input.confidence) facts.push(confidenceFact(input.confidence));
239 return facts;
240}
241
242// --- Rows -------------------------------------------------------------------
243
244/** Someone or something on a row: an agent, a person, or g1t itself. */
245export type Who = { name: string; agent: boolean };
246
247export const who = (name: string | null | undefined): Who | null => (name ? { name, agent: isAgent(name) } : null);
248
249/** A form on mission control that acts without leaving it. */
250export type QuickAction = { label: string; to: string; fields: Record<string, string>; done: string };
251
252/** One thing that needs the viewer, with what is known about it. */
253export type NeedRow = {
254 key: string;
255 reason: Reason;
256 repo: RepoPath | null;
257 /** "#41", when it is an issue or pull request. */
258 ref: string | null;
259 title: string;
260 /** The ask, in one sentence. */
261 ask: string;
262 /** Who is waiting: the agent on it, or the person who asked. */
263 by: Who | null;
264 /** The person who started it, when an agent did the work. */
265 for: string | null;
266 at: number;
267 to: string;
268 open: string;
269 facts: Fact[];
270 why: string;
271 quick: QuickAction | null;
272 /** Somewhere else to act, when that is the next step. */
273 link: { label: string; to: string } | null;
274};
275
276/** The agents' stages a pull request can wait in, as their chips say them. */
277export const STAGE_CHIP: Record<Exclude<Stage, "needs_you" | "ready">, string> = {
278 working: "Working",
279 checking: "Checking",
280 reviewing: "Reviewing",
281 revising: "Revising",
282 catching_up: "Catching up",
283 answering: "Answering",
284 queued: "In queue",
285};
286
287/** A run's kind, as its chip says it. */
288export const RUN_LABEL: Record<RunKind, string> = {
289 implement: "Working",
290 revise: "Revising",
291 review: "Reviewing",
292 answer: "Answering",
293 update: "Catching up",
294 plan: "Planning",
295 checks: "Checking",
296 queue: "In queue",
297 mergecheck: "Checking",
298};
299
300/** Something agents are doing, which the viewer can leave to them. */
301export type WaitingRow = {
302 key: string;
303 repo: RepoPath;
304 ref: string | null;
305 title: string;
306 chip: string;
307 detail: string;
308 by: Who | null;
309 at: number;
310 to: string;
311 /** Whether a run is going on it right now. */
312 live: boolean;
313 /** The run going on it, to watch. */
314 run: string | null;
315 facts: Fact[];
316};
317
318const pathKey = (repo: RepoPath, number: number | null) => `${repo.namespace}/${repo.name}#${number ?? "-"}`.toLowerCase();
319
320/**
321 * What is in agents' hands: pull requests in an agent's stage, runs going
322 * now, and drafts agents are still making. Each once, and nothing that is
323 * already waiting on the viewer.
324 */
325export function waitingRows(input: {
326 active: {
327 pull: { number: number; title: string; agent: string; updatedAt: string; checkStatus: CheckStatus | null; files: ChangedFile[] };
328 lifecycle: Lifecycle | null;
329 repo: RepoPath;
330 }[];
331 live: Pick<
332 AgentRun,
333 "id" | "repo" | "number" | "title" | "kind" | "agent" | "step" | "costUsd" | "startedAt" | "createdAt" | "updatedAt"
334 >[];
335 drafts: {
336 number: number;
337 title: string;
338 agent: string;
339 updatedAt: string;
340 checkStatus: CheckStatus | null;
341 files: ChangedFile[];
342 repo: RepoPath;
343 }[];
344 needKeys: ReadonlySet<string>;
345}): WaitingRow[] {
346 const rows = new Map<string, WaitingRow>();
347 for (const { pull, lifecycle, repo } of input.active) {
348 if (!lifecycle || lifecycle.stage === "needs_you" || lifecycle.stage === "ready") continue;
349 const key = pathKey(repo, pull.number);
350 if (input.needKeys.has(key)) continue;
351 rows.set(key, {
352 key,
353 repo,
354 ref: `#${pull.number}`,
355 title: pull.title,
356 chip: STAGE_CHIP[lifecycle.stage],
357 detail: lifecycle.detail,
358 by: who(pull.agent),
359 at: Date.parse(pull.updatedAt),
360 to: `/${repo.namespace}/${repo.name}/pull/${pull.number}`,
361 live: false,
362 run: null,
363 facts: pullFacts({ checkStatus: pull.checkStatus, files: pull.files, lifecycle }),
364 });
365 }
366 for (const run of input.live) {
367 const key = run.number != null ? pathKey(run.repo, run.number) : `run:${run.id}`;
368 if (input.needKeys.has(key)) continue;
369 const runTo = `/${run.repo.namespace}/${run.repo.name}/agents/runs/${run.id}`;
370 const existing = rows.get(key);
371 const started = Date.parse(run.startedAt ?? run.createdAt);
372 const runFacts: Fact[] = [
373 { label: "Run", value: RUN_LABEL[run.kind], tone: null },
374 ...(run.costUsd != null ? [{ label: "Cost so far", value: usd(run.costUsd), tone: null }] : []),
375 ];
376 if (existing) {
377 existing.live = true;
378 existing.run = runTo;
379 if (run.step) existing.detail = run.step;
380 existing.facts = [...runFacts, ...existing.facts.filter((f) => f.label !== "Run")];
381 existing.at = Math.max(existing.at, Date.parse(run.updatedAt));
382 continue;
383 }
384 rows.set(key, {
385 key,
386 repo: run.repo,
387 ref: run.number != null ? `#${run.number}` : null,
388 title: run.title ?? `${RUN_LABEL[run.kind]} in ${run.repo.name}`,
389 chip: RUN_LABEL[run.kind],
390 detail: run.step ?? "Starting.",
391 by: who(run.agent),
392 at: Number.isFinite(started) ? started : Date.parse(run.updatedAt),
393 to: runTo,
394 live: true,
395 run: runTo,
396 facts: runFacts,
397 });
398 }
399 for (const draft of input.drafts) {
400 const key = pathKey(draft.repo, draft.number);
401 if (rows.has(key) || input.needKeys.has(key) || !isAgent(draft.agent)) continue;
402 rows.set(key, {
403 key,
404 repo: draft.repo,
405 ref: `#${draft.number}`,
406 title: draft.title,
407 chip: "Working",
408 detail: `${draft.agent} is making the change.`,
409 by: who(draft.agent),
410 at: Date.parse(draft.updatedAt),
411 to: `/${draft.repo.namespace}/${draft.repo.name}/pull/${draft.number}`,
412 live: false,
413 run: null,
414 facts: pullFacts({ checkStatus: draft.checkStatus, files: draft.files }),
415 });
416 }
417 // Running now first, then what moved most recently.
418 return [...rows.values()].sort((a, b) => Number(b.live) - Number(a.live) || b.at - a.at);
419}
420
421/** The key a need is known by when matching it to agents' work. */
422export const needPathKey = pathKey;
423
424// --- Landed -----------------------------------------------------------------
425
426/** A merged pull request, as the week counts it. */
427export type Merged = {
428 repo: RepoPath;
429 number: number;
430 title: string;
431 agent: string;
432 mergedBy: string | null;
433 mergedAt: string;
434 files: ChangedFile[];
435};
436
437/**
438 * Whether a change landed without a person: g1t merged it, by auto-merge or
439 * from the merge queue, rather than someone pressing merge.
440 */
441export function landedByAgents(change: Pick<Merged, "mergedBy">): boolean {
442 return change.mergedBy == null || isAgent(change.mergedBy);
443}
444
445/** A day's date in a time zone, as `YYYY-MM-DD`; UTC when the zone is unknown. */
446export function dayKey(at: number, timeZone: string | null): string {
447 try {
448 return new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", timeZone: timeZone || "UTC" }).format(at);
449 } catch {
450 return new Date(at).toISOString().slice(0, 10);
451 }
452}
453
454export type WeekDay = { key: string; label: string; agents: number; people: number };
455
456export type Week = {
457 days: WeekDay[];
458 /** Changes landed in the last seven days, and how many without a person. */
459 total: number;
460 byAgents: number;
461 /** The seven days before, or null when the lists read do not reach back that far. */
462 previous: number | null;
463};
464
465/**
466 * The last seven days in the viewer's zone, oldest first, each split into
467 * what agents landed alone and what a person merged, and the week before
468 * as one number. `complete` says whether what was read reaches back two
469 * weeks; when it does not, the week before is not guessed.
470 */
471export function weekOf(changes: Pick<Merged, "mergedAt" | "mergedBy">[], now: number, timeZone: string | null, complete = true): Week {
472 const days: WeekDay[] = [];
473 const index = new Map<string, number>();
474 for (let back = 6; back >= 0; back -= 1) {
475 const at = now - back * DAY;
476 const key = dayKey(at, timeZone);
477 if (index.has(key)) continue;
478 index.set(key, days.length);
479 let label: string;
480 try {
481 label = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone: timeZone || "UTC" }).format(at);
482 } catch {
483 label = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone: "UTC" }).format(at);
484 }
485 days.push({ key, label, agents: 0, people: 0 });
486 }
487 const oldest = days[0]?.key ?? "";
488 const twoWeeks = dayKey(now - 13 * DAY, timeZone);
489 let previous = 0;
490 for (const change of changes) {
491 const at = Date.parse(change.mergedAt);
492 if (!Number.isFinite(at) || at > now) continue;
493 const key = dayKey(at, timeZone);
494 const slot = index.get(key);
495 if (slot != null) {
496 if (landedByAgents(change)) days[slot].agents += 1;
497 else days[slot].people += 1;
498 } else if (key < oldest && key >= twoWeeks) {
499 previous += 1;
500 }
501 }
502 const byAgents = days.reduce((sum, day) => sum + day.agents, 0);
503 const total = byAgents + days.reduce((sum, day) => sum + day.people, 0);
504 return { days, total, byAgents, previous: complete ? previous : null };
505}
506
507/** The change from one number to another, as a share; null from nothing. */
508export function change(current: number, previous: number | null): number | null {
509 if (previous == null || previous === 0) return null;
510 return (current - previous) / previous;
511}
512
513/** "+18%", "−5%", "0%". */
514export function signedPercent(share: number): string {
515 const n = Math.round(share * 100);
516 return n > 0 ? `+${n}%` : n < 0 ? `−${Math.abs(n)}%` : "0%";
517}
518
519/**
520 * Whether the merged pull requests read for one repository reach back to
521 * `since`: the list is not full, or its oldest goes back that far.
522 */
523export function reachesBack(list: { mergedAt: string | null; updatedAt: string }[], since: number, page: number): boolean {
524 if (list.length < page) return true;
525 const oldest = Math.min(...list.map((pull) => Date.parse(pull.mergedAt ?? pull.updatedAt)).filter(Number.isFinite));
526 return oldest <= since;
527}
528
529/** One change that landed, as the third tab lists it. */
530export type LandedRow = {
531 key: string;
532 repo: RepoPath;
533 ref: string;
534 title: string;
535 by: Who | null;
536 agent: string;
537 byAgents: boolean;
538 at: number;
539 to: string;
540 facts: Fact[];
541};
542
543/** What landed on the viewer's calendar day, newest first. */
544export function landedToday(changes: Merged[], now: number, timeZone: string | null): LandedRow[] {
545 const today = dayKey(now, timeZone);
546 return changes
547 .filter((change) => dayKey(Date.parse(change.mergedAt), timeZone) === today)
548 .sort((a, b) => Date.parse(b.mergedAt) - Date.parse(a.mergedAt))
549 .map((change) => ({
550 key: pathKey(change.repo, change.number),
551 repo: change.repo,
552 ref: `#${change.number}`,
553 title: change.title,
554 by: who(change.mergedBy ?? "g1t"),
555 agent: change.agent,
556 byAgents: landedByAgents(change),
557 at: Date.parse(change.mergedAt),
558 to: `/${change.repo.namespace}/${change.repo.name}/pull/${change.number}`,
559 facts: pullFacts({ checkStatus: "passed", files: change.files }).filter((fact) => fact.label !== "Required checks"),
560 }));
561}
562
563// --- Tabs and sorting -------------------------------------------------------
564
565export type Tab = "needs" | "waiting" | "landed";
566export type Sort = "impact" | "newest";
567
568export const TABS: Tab[] = ["needs", "waiting", "landed"];
569
570export function parseTab(value: string | null): Tab | null {
571 return value === "needs" || value === "waiting" || value === "landed" ? value : null;
572}
573
574export function parseSort(value: string | null): Sort {
575 return value === "newest" ? "newest" : "impact";
576}
577
578/** Rows in the order chosen: as ranked (most urgent first), or newest first. */
579export function sortRows<T extends { at: number }>(rows: T[], sort: Sort): T[] {
580 return sort === "newest" ? [...rows].sort((a, b) => b.at - a.at) : rows;
581}
582
583// --- The summary ------------------------------------------------------------
584
585/** The sentence under the greeting: the week, honestly, in one line. */
586export function summaryLine(input: { total: number; byAgents: number; live: number; needs: number }): string {
587 const { total, byAgents, live, needs } = input;
588 if (total > 0) {
589 const landed =
590 byAgents === total
591 ? `Agents landed all ${plural(total, "change")} this week without you.`
592 : byAgents === 0
593 ? `${plural(total, "change")} landed this week, each merged by a person.`
594 : `Agents landed ${byAgents} of ${plural(total, "change")} this week without you.`;
595 return landed;
596 }
597 if (live > 0) return `${plural(live, "agent is", "agents are")} at work. Nothing has landed this week yet.`;
598 if (needs > 0) return "Nothing has landed this week. What is waiting on you is below.";
599 return "Nothing has landed this week yet. Assign an issue to g1t and it starts in seconds.";
600}
601
602/** "Monday, Oct 5", in the viewer's zone. */
603export function dateLine(now: number, timeZone: string | null): string {
604 const options: Intl.DateTimeFormatOptions = { weekday: "long", month: "short", day: "numeric" };
605 try {
606 return new Intl.DateTimeFormat("en-US", { ...options, timeZone: timeZone || "UTC" }).format(now);
607 } catch {
608 return new Intl.DateTimeFormat("en-US", { ...options, timeZone: "UTC" }).format(now);
609 }
610}