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

592 lines23,710 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Mission control shows where you are needed and what agents landed without you; git answers in about 200ms1/**
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 */
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step8import type { AgentRun, CheckStatus, ChangedFile, Confidence, ConfidenceLevel, Lifecycle, RepoPath, RunKind, Stage } from "@g1t/contracts";
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms9
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-agent" || name === "g1t" || (name ?? "").endsWith("-agent");
17}
18
19// --- Reasons ----------------------------------------------------------------
20
21/** Why something is waiting on a person, as the chip beside it says. */
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step22export 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";
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms31
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",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step37 low_confidence: "Low confidence",
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms38 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 {
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step69 // 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";
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms71 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|could not be run/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
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step78/**
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
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms100/** Why only a person can move it: the callout beside what the agent knows. */
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step101export 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 }
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms118 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;
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step149/** 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}
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms158
159const CHECKS: Record<CheckStatus, { text: string; tone: FactTone }> = {
160 passed: { text: "Passed", tone: "good" },
161 failed: { text: "Failing", tone: "bad" },
162 errored: { text: "Could not run", tone: "bad" },
163 running: { text: "Running", tone: null },
164 queued: { text: "Queued", tone: null },
165};
166
167/** Test files, by the names test runners look for. */
168export function isTestFile(path: string): boolean {
169 return /(^|\/)(__tests__|tests?|spec)\//i.test(path) || /[._-](test|spec)\.[a-z0-9]+$/i.test(path) || /_test\.(go|rs|py)$/i.test(path);
170}
171
172const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`;
173
174/** "$0.42", or "<$0.01" for a sliver. */
175export function usd(value: number): string {
176 if (value > 0 && value < 0.01) return "<$0.01";
177 return `$${value.toFixed(2)}`;
178}
179
180/**
181 * What is known about a pull request without opening it: its checks, how
182 * much it changes, the tests it touches, how often the agent was sent back,
183 * and what its runs cost. Only what is known is listed.
184 */
185export function pullFacts(input: {
186 checkStatus: CheckStatus | null;
187 files: ChangedFile[];
188 lifecycle?: Pick<Lifecycle, "revisions"> | null;
189 runs?: Pick<AgentRun, "costUsd" | "kind">[];
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step190 confidence?: Pick<Confidence, "level" | "reasons"> | null;
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms191}): Fact[] {
192 const facts: Fact[] = [];
193 const checks = input.checkStatus ? CHECKS[input.checkStatus] : null;
194 facts.push({ label: "Checks", value: checks?.text ?? "Not run", tone: checks?.tone ?? null });
195 if (input.files.length > 0) {
196 const added = input.files.reduce((sum, f) => sum + f.additions, 0);
197 const removed = input.files.reduce((sum, f) => sum + f.deletions, 0);
198 facts.push({ label: "Files changed", value: input.files.length.toLocaleString("en-US"), tone: null });
199 facts.push({ label: "Lines", value: `+${added.toLocaleString("en-US")} −${removed.toLocaleString("en-US")}`, tone: null });
200 const tests = input.files.filter((f) => isTestFile(f.path)).length;
201 facts.push({ label: "Tests", value: tests > 0 ? plural(tests, "file") : "None touched", tone: tests > 0 ? "good" : "warn" });
202 }
203 if (input.lifecycle && input.lifecycle.revisions > 0) {
204 facts.push({
205 label: "Sent back",
206 value: plural(input.lifecycle.revisions, "time"),
207 tone: input.lifecycle.revisions > 1 ? "warn" : null,
208 });
209 }
210 const agentRuns = (input.runs ?? []).filter((run) => run.kind !== "checks" && run.kind !== "queue" && run.kind !== "mergecheck");
211 if (agentRuns.length > 0) {
212 const costs = agentRuns.filter((run) => run.costUsd != null);
213 const cost = costs.reduce((sum, run) => sum + (run.costUsd ?? 0), 0);
214 facts.push({
215 label: "Agent runs",
216 value: costs.length > 0 ? `${agentRuns.length} · ${usd(cost)}` : String(agentRuns.length),
217 tone: null,
218 });
219 }
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step220 if (input.confidence) facts.push(confidenceFact(input.confidence));
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms221 return facts;
222}
223
224// --- Rows -------------------------------------------------------------------
225
226/** Someone or something on a row: an agent, a person, or g1t itself. */
227export type Who = { name: string; agent: boolean };
228
229export const who = (name: string | null | undefined): Who | null => (name ? { name, agent: isAgent(name) } : null);
230
231/** A form on mission control that acts without leaving it. */
232export type QuickAction = { label: string; to: string; fields: Record<string, string>; done: string };
233
234/** One thing that needs the viewer, with what is known about it. */
235export type NeedRow = {
236 key: string;
237 reason: Reason;
238 repo: RepoPath | null;
239 /** "#41", when it is an issue or pull request. */
240 ref: string | null;
241 title: string;
242 /** The ask, in one sentence. */
243 ask: string;
244 /** Who is waiting: the agent on it, or the person who asked. */
245 by: Who | null;
246 /** The person who started it, when an agent did the work. */
247 for: string | null;
248 at: number;
249 to: string;
250 open: string;
251 facts: Fact[];
252 why: string;
253 quick: QuickAction | null;
254 /** Somewhere else to act, when that is the next step. */
255 link: { label: string; to: string } | null;
256};
257
258/** The agents' stages a pull request can wait in, as their chips say them. */
259export const STAGE_CHIP: Record<Exclude<Stage, "needs_you" | "ready">, string> = {
260 working: "Working",
261 checking: "Checking",
262 reviewing: "Reviewing",
263 revising: "Revising",
264 catching_up: "Catching up",
265 answering: "Answering",
266 queued: "In queue",
267};
268
269/** A run's kind, as its chip says it. */
270export const RUN_LABEL: Record<RunKind, string> = {
271 implement: "Working",
272 revise: "Revising",
273 review: "Reviewing",
274 answer: "Answering",
275 update: "Catching up",
276 plan: "Planning",
277 checks: "Checking",
278 queue: "In queue",
279 mergecheck: "Checking",
280};
281
282/** Something agents are doing, which the viewer can leave to them. */
283export type WaitingRow = {
284 key: string;
285 repo: RepoPath;
286 ref: string | null;
287 title: string;
288 chip: string;
289 detail: string;
290 by: Who | null;
291 at: number;
292 to: string;
293 /** Whether a run is going on it right now. */
294 live: boolean;
295 /** The run going on it, to watch. */
296 run: string | null;
297 facts: Fact[];
298};
299
300const pathKey = (repo: RepoPath, number: number | null) => `${repo.namespace}/${repo.name}#${number ?? "-"}`.toLowerCase();
301
302/**
303 * What is in agents' hands: pull requests in an agent's stage, runs going
304 * now, and drafts agents are still making. Each once, and nothing that is
305 * already waiting on the viewer.
306 */
307export function waitingRows(input: {
308 active: {
309 pull: { number: number; title: string; agent: string; updatedAt: string; checkStatus: CheckStatus | null; files: ChangedFile[] };
310 lifecycle: Lifecycle | null;
311 repo: RepoPath;
312 }[];
313 live: Pick<
314 AgentRun,
315 "id" | "repo" | "number" | "title" | "kind" | "agent" | "step" | "costUsd" | "startedAt" | "createdAt" | "updatedAt"
316 >[];
317 drafts: {
318 number: number;
319 title: string;
320 agent: string;
321 updatedAt: string;
322 checkStatus: CheckStatus | null;
323 files: ChangedFile[];
324 repo: RepoPath;
325 }[];
326 needKeys: ReadonlySet<string>;
327}): WaitingRow[] {
328 const rows = new Map<string, WaitingRow>();
329 for (const { pull, lifecycle, repo } of input.active) {
330 if (!lifecycle || lifecycle.stage === "needs_you" || lifecycle.stage === "ready") continue;
331 const key = pathKey(repo, pull.number);
332 if (input.needKeys.has(key)) continue;
333 rows.set(key, {
334 key,
335 repo,
336 ref: `#${pull.number}`,
337 title: pull.title,
338 chip: STAGE_CHIP[lifecycle.stage],
339 detail: lifecycle.detail,
340 by: who(pull.agent),
341 at: Date.parse(pull.updatedAt),
342 to: `/${repo.namespace}/${repo.name}/pull/${pull.number}`,
343 live: false,
344 run: null,
345 facts: pullFacts({ checkStatus: pull.checkStatus, files: pull.files, lifecycle }),
346 });
347 }
348 for (const run of input.live) {
349 const key = run.number != null ? pathKey(run.repo, run.number) : `run:${run.id}`;
350 if (input.needKeys.has(key)) continue;
351 const runTo = `/${run.repo.namespace}/${run.repo.name}/agents/runs/${run.id}`;
352 const existing = rows.get(key);
353 const started = Date.parse(run.startedAt ?? run.createdAt);
354 const runFacts: Fact[] = [
355 { label: "Run", value: RUN_LABEL[run.kind], tone: null },
356 ...(run.costUsd != null ? [{ label: "Cost so far", value: usd(run.costUsd), tone: null }] : []),
357 ];
358 if (existing) {
359 existing.live = true;
360 existing.run = runTo;
361 if (run.step) existing.detail = run.step;
362 existing.facts = [...runFacts, ...existing.facts.filter((f) => f.label !== "Run")];
363 existing.at = Math.max(existing.at, Date.parse(run.updatedAt));
364 continue;
365 }
366 rows.set(key, {
367 key,
368 repo: run.repo,
369 ref: run.number != null ? `#${run.number}` : null,
370 title: run.title ?? `${RUN_LABEL[run.kind]} in ${run.repo.name}`,
371 chip: RUN_LABEL[run.kind],
372 detail: run.step ?? "Starting.",
373 by: who(run.agent),
374 at: Number.isFinite(started) ? started : Date.parse(run.updatedAt),
375 to: runTo,
376 live: true,
377 run: runTo,
378 facts: runFacts,
379 });
380 }
381 for (const draft of input.drafts) {
382 const key = pathKey(draft.repo, draft.number);
383 if (rows.has(key) || input.needKeys.has(key) || !isAgent(draft.agent)) continue;
384 rows.set(key, {
385 key,
386 repo: draft.repo,
387 ref: `#${draft.number}`,
388 title: draft.title,
389 chip: "Working",
390 detail: `${draft.agent} is making the change.`,
391 by: who(draft.agent),
392 at: Date.parse(draft.updatedAt),
393 to: `/${draft.repo.namespace}/${draft.repo.name}/pull/${draft.number}`,
394 live: false,
395 run: null,
396 facts: pullFacts({ checkStatus: draft.checkStatus, files: draft.files }),
397 });
398 }
399 // Running now first, then what moved most recently.
400 return [...rows.values()].sort((a, b) => Number(b.live) - Number(a.live) || b.at - a.at);
401}
402
403/** The key a need is known by when matching it to agents' work. */
404export const needPathKey = pathKey;
405
406// --- Landed -----------------------------------------------------------------
407
408/** A merged pull request, as the week counts it. */
409export type Merged = {
410 repo: RepoPath;
411 number: number;
412 title: string;
413 agent: string;
414 mergedBy: string | null;
415 mergedAt: string;
416 files: ChangedFile[];
417};
418
419/**
420 * Whether a change landed without a person: g1t merged it, by auto-merge or
421 * from the merge queue, rather than someone pressing merge.
422 */
423export function landedByAgents(change: Pick<Merged, "mergedBy">): boolean {
424 return change.mergedBy == null || isAgent(change.mergedBy);
425}
426
427/** A day's date in a time zone, as `YYYY-MM-DD`; UTC when the zone is unknown. */
428export function dayKey(at: number, timeZone: string | null): string {
429 try {
430 return new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", timeZone: timeZone || "UTC" }).format(at);
431 } catch {
432 return new Date(at).toISOString().slice(0, 10);
433 }
434}
435
436export type WeekDay = { key: string; label: string; agents: number; people: number };
437
438export type Week = {
439 days: WeekDay[];
440 /** Changes landed in the last seven days, and how many without a person. */
441 total: number;
442 byAgents: number;
443 /** The seven days before, or null when the lists read do not reach back that far. */
444 previous: number | null;
445};
446
447/**
448 * The last seven days in the viewer's zone, oldest first, each split into
449 * what agents landed alone and what a person merged, and the week before
450 * as one number. `complete` says whether what was read reaches back two
451 * weeks; when it does not, the week before is not guessed.
452 */
453export function weekOf(changes: Pick<Merged, "mergedAt" | "mergedBy">[], now: number, timeZone: string | null, complete = true): Week {
454 const days: WeekDay[] = [];
455 const index = new Map<string, number>();
456 for (let back = 6; back >= 0; back -= 1) {
457 const at = now - back * DAY;
458 const key = dayKey(at, timeZone);
459 if (index.has(key)) continue;
460 index.set(key, days.length);
461 let label: string;
462 try {
463 label = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone: timeZone || "UTC" }).format(at);
464 } catch {
465 label = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone: "UTC" }).format(at);
466 }
467 days.push({ key, label, agents: 0, people: 0 });
468 }
469 const oldest = days[0]?.key ?? "";
470 const twoWeeks = dayKey(now - 13 * DAY, timeZone);
471 let previous = 0;
472 for (const change of changes) {
473 const at = Date.parse(change.mergedAt);
474 if (!Number.isFinite(at) || at > now) continue;
475 const key = dayKey(at, timeZone);
476 const slot = index.get(key);
477 if (slot != null) {
478 if (landedByAgents(change)) days[slot].agents += 1;
479 else days[slot].people += 1;
480 } else if (key < oldest && key >= twoWeeks) {
481 previous += 1;
482 }
483 }
484 const byAgents = days.reduce((sum, day) => sum + day.agents, 0);
485 const total = byAgents + days.reduce((sum, day) => sum + day.people, 0);
486 return { days, total, byAgents, previous: complete ? previous : null };
487}
488
489/** The change from one number to another, as a share; null from nothing. */
490export function change(current: number, previous: number | null): number | null {
491 if (previous == null || previous === 0) return null;
492 return (current - previous) / previous;
493}
494
495/** "+18%", "−5%", "0%". */
496export function signedPercent(share: number): string {
497 const n = Math.round(share * 100);
498 return n > 0 ? `+${n}%` : n < 0 ? `−${Math.abs(n)}%` : "0%";
499}
500
501/**
502 * Whether the merged pull requests read for one repository reach back to
503 * `since`: the list is not full, or its oldest goes back that far.
504 */
505export function reachesBack(list: { mergedAt: string | null; updatedAt: string }[], since: number, page: number): boolean {
506 if (list.length < page) return true;
507 const oldest = Math.min(...list.map((pull) => Date.parse(pull.mergedAt ?? pull.updatedAt)).filter(Number.isFinite));
508 return oldest <= since;
509}
510
511/** One change that landed, as the third tab lists it. */
512export type LandedRow = {
513 key: string;
514 repo: RepoPath;
515 ref: string;
516 title: string;
517 by: Who | null;
518 agent: string;
519 byAgents: boolean;
520 at: number;
521 to: string;
522 facts: Fact[];
523};
524
525/** What landed on the viewer's calendar day, newest first. */
526export function landedToday(changes: Merged[], now: number, timeZone: string | null): LandedRow[] {
527 const today = dayKey(now, timeZone);
528 return changes
529 .filter((change) => dayKey(Date.parse(change.mergedAt), timeZone) === today)
530 .sort((a, b) => Date.parse(b.mergedAt) - Date.parse(a.mergedAt))
531 .map((change) => ({
532 key: pathKey(change.repo, change.number),
533 repo: change.repo,
534 ref: `#${change.number}`,
535 title: change.title,
536 by: who(change.mergedBy ?? "g1t"),
537 agent: change.agent,
538 byAgents: landedByAgents(change),
539 at: Date.parse(change.mergedAt),
540 to: `/${change.repo.namespace}/${change.repo.name}/pull/${change.number}`,
541 facts: pullFacts({ checkStatus: "passed", files: change.files }).filter((fact) => fact.label !== "Checks"),
542 }));
543}
544
545// --- Tabs and sorting -------------------------------------------------------
546
547export type Tab = "needs" | "waiting" | "landed";
548export type Sort = "impact" | "newest";
549
550export const TABS: Tab[] = ["needs", "waiting", "landed"];
551
552export function parseTab(value: string | null): Tab | null {
553 return value === "needs" || value === "waiting" || value === "landed" ? value : null;
554}
555
556export function parseSort(value: string | null): Sort {
557 return value === "newest" ? "newest" : "impact";
558}
559
560/** Rows in the order chosen: as ranked (most urgent first), or newest first. */
561export function sortRows<T extends { at: number }>(rows: T[], sort: Sort): T[] {
562 return sort === "newest" ? [...rows].sort((a, b) => b.at - a.at) : rows;
563}
564
565// --- The summary ------------------------------------------------------------
566
567/** The sentence under the greeting: the week, honestly, in one line. */
568export function summaryLine(input: { total: number; byAgents: number; live: number; needs: number }): string {
569 const { total, byAgents, live, needs } = input;
570 if (total > 0) {
571 const landed =
572 byAgents === total
573 ? `Agents landed all ${plural(total, "change")} this week without you.`
574 : byAgents === 0
575 ? `${plural(total, "change")} landed this week, each merged by a person.`
576 : `Agents landed ${byAgents} of ${plural(total, "change")} this week without you.`;
577 return landed;
578 }
579 if (live > 0) return `${plural(live, "agent is", "agents are")} at work. Nothing has landed this week yet.`;
580 if (needs > 0) return "Nothing has landed this week. What is waiting on you is below.";
581 return "Nothing has landed this week yet. Assign an issue to g1t-agent and it starts in seconds.";
582}
583
584/** "Monday, Oct 5", in the viewer's zone. */
585export function dateLine(now: number, timeZone: string | null): string {
586 const options: Intl.DateTimeFormatOptions = { weekday: "long", month: "short", day: "numeric" };
587 try {
588 return new Intl.DateTimeFormat("en-US", { ...options, timeZone: timeZone || "UTC" }).format(now);
589 } catch {
590 return new Intl.DateTimeFormat("en-US", { ...options, timeZone: "UTC" }).format(now);
591 }
592}