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.

Commit

Home leads with the daily signal; the workspace overview threads spend to the work

Home opens with the past week in four figures (agent-authored merges, spend per agent merge, stalled changes, agent rework rate), each against the week before and drawn over ten. Then the daily signal: the finding that matters most today as the page's headline, and the others under it. Each finding is a sentence with its evidence, a link to where to see it, and Ask g1t: - work items waiting on one unreviewed change - agents' drafts waiting days for review - a milestone shipped against its due date - where the quarter's agent spend is going - stalled changes and agent rework moving - who is moving into a project one person has carried The box that asks g1t sits under the findings. The workspace overview opens with the same week, then: - the quarter's spend threaded through milestone, work item, change set and pull request, sized by spend and shaded by agent share - each team's last two weeks, day by day, with what waits on a first review - quiet changes: knowledge silos, new ground and new habits Everything is worked out in lib/signals.ts from workspace_signals and usage, tested on its own, and compared only with the workspace's own last weeks. The Home and Workspaces guides say how each figure is counted. Part of #157

G1t-Session:
ses_01m57128wre9bvvj1y4z4b8dk0
syntaqxcommitted Parentdcc5317Browse files
11 files+2660−480/11 viewed
+85−9
11 ---
22 title: Home
3−description: Your workspace's front page. Ask g1t what to do, where you're needed now, what happened since you were last here in plain English, what is running, and the one item to start with. How every number is worked out.
3+description: Your workspace's front page. The past week in four figures, the daily signal (what your work says today, with the evidence), where you're needed now, what happened since you were last here in plain English, what is running, and the one item to start with. How every number is worked out.
44 ---
55
66 **Home** is the first page of your workspace, at `/<workspace>/-/home`.
7−It opens with **What are we building today?** and a box that
8−[asks g1t](#ask-from-home). Under it, the main column is read top to
7+Across the top is [the past week](#the-past-week) in four figures, each
8+against the week before. Under it, the main column is read top to
99 bottom:
1010
11−1. **Where you're needed**: everything waiting on you, however long ago
11+1. **[The daily signal](#the-daily-signal)**: what the workspace's work
12+ says today. Its headline is the finding that matters most, and the
13+ others follow, each a sentence with its evidence and where to see it.
14+ Under them is a box that [asks g1t](#ask-from-home) about any of it.
15+2. **Where you're needed**: everything waiting on you, however long ago
1216 it started, with the action beside each item. Its first row is
1317 [Start here](#start-here), the one item to do first, and why.
14−2. **Since you were last here**: one card for the span. It shows what people
18+3. **Since you were last here**: one card for the span. It shows what people
1519 did, what changed in plain English when you ask for it, and what landed,
1620 each change with whether it is live yet.
1721
2125 - **[Spent](#spent)**: what the span cost, and the month so far.
2226 - **[Settled for you](#settled-for-you)**: what finished by itself.
2327
24−On a phone it is one column: Where you're needed, Running now, the span,
25−then Spent and Settled for you.
28+On a phone it is one column: the past week, the daily signal, Where
29+you're needed, Running now, the span, then Spent and Settled for you.
2630
2731 For a workspace's owners, Home also opens
2832 [Getting started](/guides/workspaces/#getting-started), the setup guide,
3034 of Home opens it again at any time, as `?setup=1` does.
3135
3236 Every number on the page comes from what g1t has recorded. Nothing is
33−estimated. When a part of g1t does not answer, the section it feeds says
37+estimated, and nothing compares your workspace or its teams with anyone
38+else's: each figure is set against your own last weeks. When a part of g1t does not answer, the section it feeds says
3439 so, and the rest of the page still shows. Only what you ask g1t is written
3540 by a model: [What changed](#what-changed) and the answers in Ask.
3641
42+## The past week
43+
44+Four figures for the last seven UTC days. Each shows how it moved since the
45+seven days before (green when that is good news, red when it is not) and a
46+line over the last ten weeks. Point at the line, or focus it and use the
47+arrow keys, to read any week. Each figure opens the page behind it.
48+
49+| Figure | What it counts |
50+| --- | --- |
51+| **Agent-authored merges** | Pull requests that merged in the week and were an [agent's](#whose-work-is-an-agents). |
52+| **Spend per agent merge** | The week's agent [usage](/guides/usage-and-billing/#the-usage-page) at price, divided by the agent pull requests that merged in it: an average, not what any one cost. A dash when usage can't be read or nothing an agent made merged. |
53+| **Stalled changes** | Pull requests open more than seven days at the end of each week (now, for this one), neither merged nor closed. Drafts count. |
54+| **Agent rework rate** | Of the agent pull requests that merged in the week, the share a person asked for changes on before they merged. |
55+
56+### Whose work is an agent's
57+
58+A pull request is an agent's when g1t's agent opened it, it was opened as
59+an agent's, or a [session](/guides/sessions/) is tied to it: linked to it,
60+started for it, or pushing its branch. Its owner is the person who asked
61+for it, else its author, so an agent's work counts toward the person it
62+was for.
63+
64+## The daily signal
65+
66+First the day, then one sentence for the finding that matters most today,
67+then the others. Each finding names its evidence in a line of figures,
68+links to where you can see it, and has **Ask g1t**, which asks
69+[Ask](/guides/ask/) to look into it. A finding with a trend has its line
70+beside it. When nothing stands out, the headline says what the week did.
71+
72+| Finding | When it shows |
73+| --- | --- |
74+| **Work items waiting on one unreviewed change** | An open pull request no one has reviewed is for a work item that other open work items are blocked by. The one holding up the most leads. |
75+| **Agent drafts waiting for review** | This week's first reviews waited at least four times as long as agents took to open the pull requests (from their session starting), and at least four hours. Without that gap: first reviews took half as long again as the weeks before. |
76+| **A milestone shipped** | A milestone closed in the last seven days, against its due date, with its weeks in build, its merged pull requests and its projects. |
77+| **Where agent spend is going** | More than a third of the quarter's agent usage by pull request went into one milestone (else one work item, else one project). |
78+| **Stalled changes rising** | At least three more stalled changes than last week, and a quarter more. |
79+| **Agent rework moving** | People asked for changes on agents' merged work at least 8 points more, or less, often than over the four weeks before (with five agent merges or more). |
80+| **Who works where** | Someone started merging to a project one person has carried (three quarters of its last 90 days' merges), or merged to a project for the first time this week after work elsewhere. |
81+
82+Findings are ordered by how much they hold up: blocked work first, then
83+waits, then what shipped and where spend went. Up to five show, and the
84+rest are one click away. The same reads, at more length, are on the
85+[workspace overview](/guides/workspaces/#the-workspace-overview).
86+
3787 ## Ask from Home
3888
39−The box at the top asks g1t anything about your work. Type a question and
89+The box under the daily signal asks g1t anything about your work. Type a question and
4090 press **Enter** (**Shift+Enter** starts a new line): the top bar's
4191 [Ask](/guides/ask/) panel opens and sends it, and the answer streams in
4292 there. Under the box are questions to start with:
341391 A member whose membership does not include Code sees Home without anything
342392 from Code:
343393
394+- The past week and the daily signal are not shown. The page opens with
395+ **What are we building today?** and the box that asks g1t.
344396 - People shows what was said and the artifacts edited; nothing from
345397 repositories.
346398 - Where you're needed has no rows from Code, and no notifications about a
362414 deployments or sessions can't be read, Landed shows its rows without
363415 their live labels or agent markers.
364416 - Settled for you says when your notifications can't be read.
417+- The daily signal says when the work couldn't be read, and the past week
418+ is left out. When some projects didn't answer, it says its findings
419+ leave them out.
365420 - What changed says when Ask can't be reached or isn't set up, or when
366421 the question could not be sent.
367422 - If your last visit can't be read, the span is the last 24 hours and the
409464 `until`) returns `messages`, `channels` (conversations with at least one)
410465 and `authors` (`key`, `messages`; most first), over text messages and
411466 thread replies not deleted in the conversations the viewer can read.
467+
468+**`workspace_signals`** on the work service takes `workspace`,
469+`repo_ids` (at most 100), `viewer`, `until` (a UTC day), `weeks` (1 to
470+13), `days` (1 to 28) and `since` (a UTC day, at most 400 days before
471+`until`). For the repositories the viewer can read, it returns:
472+
473+- `weeks`: per repository and week of seven days ending with `until`,
474+ `merged`, `agent_merged`, `agent_reworked`, `agent_drafted` and
475+ `agent_draft_seconds`, `reviewed` and `review_seconds`, and `stalled`.
476+- `days`: per repository and day, `opened`, `merged`, `agent_merged`,
477+ `reworked`, `reviews` and `sessions`.
478+- `waiting`: open pull requests with no first review, oldest first, at
479+ most 50, each with its work item and the open work items it `blocks`.
480+- `pulls`: those opened or merged since `since`, at most 3,000 newest
481+ first, each with its owner, work item and milestone; `pulls_capped`
482+ when there were more.
483+- `authors`: per repository and owner, their merges over its history and
484+ in the last 90 days.
485+- The `items` and `milestones` those name.
486+
487+Rows are per repository, so a caller asking in batches adds them up.
+37−1
671671 says so and shows a dash in place of numbers it cannot vouch for. **Explore
672672 costs** opens [Usage](/guides/usage-and-billing/#the-usage-page) with the same period and project.
673673
674+### The workspace overview
675+
676+The workspace's **Overview**, `g1t.sh/<workspace>/-/workspace`, opens with
677+what its work says, for members who can see Code. It is worked out the
678+same way as [Home's daily signal](/guides/home/#the-daily-signal), and
679+each person sees only the projects they can read.
680+
681+1. **[The past week](/guides/home/#the-past-week)**: the same four
682+ figures as Home.
683+2. **Spend, threaded to the work**: the quarter's work in four columns:
684+ each milestone, its work items, each work item's change set in each
685+ project, and that change set's pull requests. Each box is sized by
686+ what the agents' usage on its pull requests came to, and shaded by how
687+ many were an agent's (**Agent use**, less to more). Choose a box to
688+ open what is under it. Work with no milestone or work item has its own
689+ box. When usage can't be read, boxes are sized by pull requests.
690+3. **What's moving and what's waiting**: each team's last two weeks, day
691+ by day, by the projects the team works on (each project, when the
692+ workspace has no teams). Above the line are pull requests opened and
693+ merged, and agent sessions started; below it, reviews and comments and
694+ requests for changes. Turn each layer on or off above the rows, point
695+ at a day for its numbers, or open **The numbers, as a table**. Each
696+ row is drawn to its own scale. Beside it, **Waiting on a first
697+ review** lists the oldest open pull requests no one has reviewed.
698+4. **Quiet changes**: places to look, not verdicts.
699+ - **Knowledge silos**: projects where one person merged three quarters
700+ or more of the last 90 days' changes (six or more), and whoever has
701+ started there this month.
702+ - **New ground**: people who merged to a project for the first time in
703+ the last two weeks, after three or more merges elsewhere.
704+ - **New habits**: people whose first agent-made change merged in the
705+ last two weeks, after three or more of their own.
706+
707+Under it are the members and projects, the month's usage against the
708+plan, and the workspace's policies.
709+
674710 ## The rail, the sidebar and the page
675711
676712 Signed in, g1t is always about one workspace: the one you are in. On a
820856
821857 | Group | Page | Who | |
822858 | --- | --- | --- | --- |
823−| | **Overview** | Members | The workspace at a glance: members, plan and spend. `g1t.sh/<workspace>/-/workspace` |
859+| | **Overview** | Members | What the work says (the past week, spend threaded to the work, what is moving and waiting, quiet changes), then members, plan and spend. `g1t.sh/<workspace>/-/workspace` |
824860 | **General** | **General** | Owners | The icon, the display name, a one-line description, the address (the slug), [requiring two-factor authentication](#require-two-factor-authentication), [data residency](#data-residency), and deleting the workspace. `-/settings` |
825861 | **Access** | **Members** | Members | Who is in the workspace and their roles, with search and filters; owners [invite people](#add-people), change roles, and see **Outside collaborators**. `-/members` |
826862 | | **Invitations** | Owners | Every invitation to join, filtered by where it stands; send one again, revoke it, copy its link. `-/members/invitations` |
+17−7
3333 return span.key === "last" && span.note == null ? "since I was last here" : `${span.words.charAt(0).toLowerCase()}${span.words.slice(1)}`;
3434 }
3535
36−/** The top of Home: what are we building today, a box that asks g1t, and a few things to ask first. */
37−export function HomeAsk({ span }: { span: AskSpan }) {
36+/**
37+ * Home's box that asks g1t, with a few things to ask first: under the
38+ * daily signal, `compact`, it says to ask about any of it; on its own, it
39+ * leads the page with what are we building today.
40+ */
41+export function HomeAsk({ span, compact = false }: { span: AskSpan; compact?: boolean }) {
3842 const [draft, setDraft] = useState("");
3943 const send = () => {
4044 if (!draft.trim()) return;
5256 }
5357 };
5458 return (
55− <section aria-labelledby="home-ask" className="mx-auto flex w-full max-w-3xl flex-col items-center gap-5 pt-4 pb-2 md:pt-8">
56− <h1 id="home-ask" className="text-center text-[1.75rem] leading-tight font-semibold tracking-tight md:text-[2rem]">
57− What are we building today?
58− </h1>
59+ <section aria-labelledby="home-ask" className={compact ? "flex w-full flex-col gap-3" : "mx-auto flex w-full max-w-3xl flex-col items-center gap-5 pt-4 pb-2 md:pt-8"}>
60+ {compact ? (
61+ <h2 id="home-ask" className="text-sm font-semibold text-fg">
62+ Ask about any of this
63+ </h2>
64+ ) : (
65+ <h1 id="home-ask" className="text-center text-[1.75rem] leading-tight font-semibold tracking-tight md:text-[2rem]">
66+ What are we building today?
67+ </h1>
68+ )}
5969 <form onSubmit={submit} className="w-full">
6070 <div className="flex items-end gap-2 rounded-2xl border border-line bg-surface p-2 pl-4 shadow-sm transition-shadow focus-within:border-accent-dim focus-within:ring-2 focus-within:ring-accent/25">
6171 <MarkGlyph className="mb-2.5 h-4 shrink-0 max-sm:hidden" />
7989 </Hint>
8090 </div>
8191 </form>
82− <Suggestions className="justify-center">
92+ <Suggestions className={compact ? "" : "justify-center"}>
8393 <Suggestion
8494 suggestion={`Summarize what changed ${spanLabel(span)}`}
8595 onPick={() => window.dispatchEvent(new CustomEvent(SUMMARIZE_EVENT))}
+254−0
1+/**
2+ * What is moving and what is waiting (lib/signals.ts rhythm): each team's
3+ * last two weeks, day by day. Above the line, pull requests opened and
4+ * merged (bars) and agent sessions started (a wash); below it, reviews and
5+ * comments (a wash) and requests for changes (bars). Each row is drawn to
6+ * its own scale, so a small team's week is as legible as a big one's; the
7+ * numbers are in the readout and the table. Layers turn on and off above
8+ * the rows, and pointing at a day says what happened on it.
9+ */
10+import { useState } from "react";
11+import { Link } from "react-router";
12+
13+import { cn } from "../lib/cn";
14+import { type RhythmDay, type RhythmRow, rhythmLine, rhythmSum } from "../lib/signals";
15+
16+type Layer = "prs" | "rework" | "agent" | "reviews";
17+
18+const LAYERS: { key: Layer; label: string; swatch: string }[] = [
19+ { key: "prs", label: "PR activity", swatch: "bg-fg-soft" },
20+ { key: "rework", label: "Rework", swatch: "bg-faint" },
21+ { key: "agent", label: "Agent activity", swatch: "bg-series-1" },
22+ { key: "reviews", label: "Reviews & comments", swatch: "bg-series-4" },
23+];
24+
25+const WIDTH = 700;
26+const HEIGHT = 112;
27+const MID = HEIGHT / 2;
28+const BAR = 5;
29+
30+/** A smooth path through points, as a closed wash from the baseline. */
31+function wash(points: [number, number][]): string {
32+ if (points.length === 0) return "";
33+ let d = `M${points[0]![0]},${MID} L${points[0]![0]},${points[0]![1]}`;
34+ for (let i = 1; i < points.length; i++) {
35+ const [x0, y0] = points[i - 1]!;
36+ const [x1, y1] = points[i]!;
37+ const cx = (x0 + x1) / 2;
38+ d += ` C${cx},${y0} ${cx},${y1} ${x1},${y1}`;
39+ }
40+ return `${d} L${points[points.length - 1]![0]},${MID} Z`;
41+}
42+
43+function dayWords(day: string): string {
44+ return new Date(`${day}T00:00:00Z`).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" });
45+}
46+
47+function Readout({ row, day }: { row: RhythmRow; day: RhythmDay | null }) {
48+ const sum = day ?? rhythmSum(row.days.slice(Math.floor(row.days.length / 2)));
49+ return (
50+ <div className="min-w-48 rounded-xl border border-line bg-surface px-3.5 py-3 text-xs shadow-lg">
51+ <p className="font-mono text-[0.625rem] tracking-[0.14em] text-muted uppercase">
52+ {row.label} · {day ? dayWords(day.day) : "this week"}
53+ </p>
54+ <ul className="mt-2 space-y-1 text-fg">
55+ <li className="flex items-center gap-2">
56+ <span className="size-2 rounded-full bg-fg-soft" />
57+ {sum.opened} opened · {sum.merged} merged
58+ </li>
59+ <li className="flex items-center gap-2">
60+ <span className="size-2 rounded-full bg-faint" />
61+ {sum.reworked} {sum.reworked === 1 ? "request" : "requests"} for changes
62+ </li>
63+ <li className="flex items-center gap-2">
64+ <span className="size-2 rounded-full bg-series-4" />
65+ {sum.reviews} reviews &amp; comments
66+ </li>
67+ <li className="flex items-center gap-2">
68+ <span className="size-2 rounded-full bg-series-1" />
69+ {sum.sessions} agent {sum.sessions === 1 ? "session" : "sessions"}
70+ </li>
71+ </ul>
72+ </div>
73+ );
74+}
75+
76+function Row({
77+ row,
78+ on,
79+ active,
80+ faded,
81+ onDay,
82+}: {
83+ row: RhythmRow;
84+ on: Set<Layer>;
85+ active: number | null;
86+ faded: boolean;
87+ onDay: (day: number | null) => void;
88+}) {
89+ const n = row.days.length;
90+ const slot = WIDTH / n;
91+ const x = (i: number) => slot * i + slot / 2;
92+ const up = Math.max(1, ...row.days.map((day) => Math.max(on.has("prs") ? day.opened + day.merged : 0, on.has("agent") ? day.sessions : 0)));
93+ const down = Math.max(1, ...row.days.map((day) => Math.max(on.has("rework") ? day.reworked : 0, on.has("reviews") ? day.reviews : 0)));
94+ const above = (value: number) => MID - (value / up) * (MID - 6);
95+ const below = (value: number) => MID + (value / down) * (MID - 6);
96+ const day = active != null ? row.days[active]! : null;
97+ return (
98+ <li className={cn("grid gap-3 py-5 transition-opacity md:grid-cols-[12rem_minmax(0,1fr)] md:gap-6", faded && "opacity-45")}>
99+ <div className="min-w-0">
100+ <Link to={row.to} className="text-base font-semibold text-fg hover:text-accent">
101+ {row.label}
102+ </Link>
103+ {row.note && <p className="text-xs text-muted">{row.note}</p>}
104+ <p className="mt-1.5 text-xs text-fg-soft">{rhythmLine(row)}</p>
105+ </div>
106+ <div className="relative">
107+ <svg
108+ viewBox={`0 0 ${WIDTH} ${HEIGHT}`}
109+ preserveAspectRatio="none"
110+ className="block h-28 w-full overflow-visible"
111+ role="img"
112+ aria-label={`${row.label}: the last ${n} days`}
113+ onMouseLeave={() => onDay(null)}
114+ >
115+ {active != null && <rect x={slot * active} y={0} width={slot} height={HEIGHT} className="fill-accent/8" />}
116+ <line x1={WIDTH / 2} x2={WIDTH / 2} y1={0} y2={HEIGHT} className="stroke-line" strokeWidth={1} vectorEffect="non-scaling-stroke" />
117+ {on.has("agent") && (
118+ <path
119+ d={wash(row.days.map((d, i) => [x(i), above(d.sessions)]))}
120+ className="fill-series-1/15 stroke-series-1"
121+ strokeWidth={1.5}
122+ vectorEffect="non-scaling-stroke"
123+ />
124+ )}
125+ {on.has("reviews") && (
126+ <path
127+ d={wash(row.days.map((d, i) => [x(i), below(d.reviews)]))}
128+ className="fill-series-4/18 stroke-series-4"
129+ strokeWidth={1.5}
130+ vectorEffect="non-scaling-stroke"
131+ />
132+ )}
133+ <line x1={0} x2={WIDTH} y1={MID} y2={MID} className="stroke-line-strong" strokeWidth={1} vectorEffect="non-scaling-stroke" />
134+ {on.has("prs") &&
135+ row.days.map((d, i) =>
136+ d.opened + d.merged > 0 ? (
137+ <rect
138+ key={`p${i}`}
139+ x={x(i) - BAR / 2}
140+ y={above(d.opened + d.merged)}
141+ width={BAR}
142+ height={MID - above(d.opened + d.merged)}
143+ rx={1.5}
144+ className="fill-fg-soft"
145+ />
146+ ) : null,
147+ )}
148+ {on.has("rework") &&
149+ row.days.map((d, i) =>
150+ d.reworked > 0 ? (
151+ <rect key={`r${i}`} x={x(i) - BAR / 2} y={MID} width={BAR} height={below(d.reworked) - MID} rx={1.5} className="fill-faint" />
152+ ) : null,
153+ )}
154+ {row.days.map((_, i) => (
155+ <rect key={`h${i}`} x={slot * i} y={0} width={slot} height={HEIGHT} fill="transparent" onMouseEnter={() => onDay(i)} />
156+ ))}
157+ </svg>
158+ {day && (
159+ <div
160+ className="pointer-events-none absolute top-0 z-10 -translate-y-1/3"
161+ style={active! < n / 2 ? { left: `${((active! + 1) / n) * 100}%` } : { right: `${((n - active!) / n) * 100}%` }}
162+ >
163+ <Readout row={row} day={day} />
164+ </div>
165+ )}
166+ </div>
167+ </li>
168+ );
169+}
170+
171+export function Rhythm({ rows, by }: { rows: RhythmRow[]; by: "team" | "project" }) {
172+ const [on, setOn] = useState<Set<Layer>>(new Set(["prs", "rework", "agent", "reviews"]));
173+ const [hover, setHover] = useState<{ row: string; day: number } | null>(null);
174+ const toggle = (layer: Layer) => {
175+ const next = new Set(on);
176+ if (next.has(layer)) next.delete(layer);
177+ else next.add(layer);
178+ setOn(next);
179+ };
180+ if (rows.length === 0) return <p className="text-sm text-muted">Nothing has moved in the last two weeks.</p>;
181+ return (
182+ <div>
183+ <div className="flex flex-wrap gap-2" role="group" aria-label="What the rows show">
184+ {LAYERS.map((layer) => (
185+ <button
186+ key={layer.key}
187+ type="button"
188+ aria-pressed={on.has(layer.key)}
189+ onClick={() => toggle(layer.key)}
190+ className={cn(
191+ "inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs transition-colors",
192+ on.has(layer.key) ? "border-line-strong bg-surface text-fg" : "border-line bg-transparent text-faint",
193+ )}
194+ >
195+ <span className={cn("h-2.5 w-2.5 rounded-sm", on.has(layer.key) ? layer.swatch : "bg-line")} />
196+ {layer.label}
197+ </button>
198+ ))}
199+ </div>
200+ <div className="mt-5 hidden grid-cols-[12rem_minmax(0,1fr)] gap-6 md:grid">
201+ <span />
202+ <div className="grid grid-cols-2 font-mono text-[0.625rem] tracking-[0.14em] text-muted uppercase">
203+ <span>Last week</span>
204+ <span className="pl-2">This week</span>
205+ </div>
206+ </div>
207+ <ul className="divide-y divide-line">
208+ {rows.map((row) => (
209+ <Row
210+ key={row.key}
211+ row={row}
212+ on={on}
213+ active={hover?.row === row.key ? hover.day : null}
214+ faded={hover != null && hover.row !== row.key}
215+ onDay={(day) => setHover(day == null ? null : { row: row.key, day })}
216+ />
217+ ))}
218+ </ul>
219+ <details className="mt-2 text-xs text-muted">
220+ <summary className="cursor-pointer hover:text-fg">The numbers, as a table</summary>
221+ <table className="mt-3 w-full text-left tabular-nums">
222+ <thead className="text-faint">
223+ <tr>
224+ <th className="py-1 font-normal">{by === "team" ? "Team" : "Project"}</th>
225+ <th className="py-1 font-normal">Opened</th>
226+ <th className="py-1 font-normal">Merged</th>
227+ <th className="py-1 font-normal">Rework</th>
228+ <th className="py-1 font-normal">Reviews &amp; comments</th>
229+ <th className="py-1 font-normal">Agent sessions</th>
230+ </tr>
231+ </thead>
232+ <tbody>
233+ {rows.map((row) => {
234+ const half = Math.floor(row.days.length / 2);
235+ const last = rhythmSum(row.days.slice(0, half));
236+ const now = rhythmSum(row.days.slice(half));
237+ const cell = (a: number, b: number) => `${b} (${a} last week)`;
238+ return (
239+ <tr key={row.key} className="border-t border-line text-fg-soft">
240+ <td className="py-1.5 text-fg">{row.label}</td>
241+ <td>{cell(last.opened, now.opened)}</td>
242+ <td>{cell(last.merged, now.merged)}</td>
243+ <td>{cell(last.reworked, now.reworked)}</td>
244+ <td>{cell(last.reviews, now.reviews)}</td>
245+ <td>{cell(last.sessions, now.sessions)}</td>
246+ </tr>
247+ );
248+ })}
249+ </tbody>
250+ </table>
251+ </details>
252+ </div>
253+ );
254+}
+310−0
1+/**
2+ * The daily signal on Home (lib/signals.ts): the past week in four
3+ * figures, each against the week before and drawn over ten, and the
4+ * findings worth a look today, each a sentence with its evidence, where
5+ * to see it, and a way to ask g1t about it.
6+ */
7+import { ChevronRight, MessageSquare, TriangleAlert } from "lucide-react";
8+import { type ReactNode, useId, useState } from "react";
9+import { Link } from "react-router";
10+
11+import { askG1t } from "../lib/ask";
12+import { cn } from "../lib/cn";
13+import type { DailySignal as DailySignalView, PulseTile, Signal } from "../lib/signals";
14+import { Skeleton } from "./ui/skeleton";
15+
16+// --- A trend line -------------------------------------------------------------------
17+
18+/**
19+ * A measure over the weeks as a thin line, the latest point marked. Weeks
20+ * with nothing to measure break the line. Pointing at a week (or moving
21+ * along with the arrow keys) says its value through `onPoint`; every
22+ * value is also in a list for screen readers.
23+ */
24+export function TrendLine({
25+ values,
26+ points,
27+ label,
28+ width = 120,
29+ height = 28,
30+ onPoint,
31+}: {
32+ values: (number | null)[];
33+ points: string[];
34+ label: string;
35+ width?: number;
36+ height?: number;
37+ onPoint?: (index: number | null) => void;
38+}) {
39+ const [active, setActive] = useState<number | null>(null);
40+ const list = useId();
41+ const known = values.filter((value): value is number => value != null);
42+ if (known.length === 0) return <div style={{ width, height }} aria-hidden />;
43+ const pad = 4;
44+ const min = Math.min(...known);
45+ const max = Math.max(...known);
46+ const step = values.length > 1 ? (width - 2 * pad) / (values.length - 1) : 0;
47+ const x = (i: number) => pad + i * step;
48+ const y = (value: number) => (max === min ? height / 2 : pad + (1 - (value - min) / (max - min)) * (height - 2 * pad));
49+ // One path, lifted where a week has nothing.
50+ let d = "";
51+ values.forEach((value, i) => {
52+ if (value == null) return;
53+ d += `${i > 0 && values[i - 1] != null ? "L" : "M"}${x(i).toFixed(1)},${y(value).toFixed(1)}`;
54+ });
55+ const last = values.length - 1 - [...values].reverse().findIndex((value) => value != null);
56+ const point = (index: number | null) => {
57+ setActive(index);
58+ onPoint?.(index);
59+ };
60+ const shown = active ?? last;
61+ return (
62+ <>
63+ <svg
64+ width={width}
65+ height={height}
66+ viewBox={`0 0 ${width} ${height}`}
67+ className="block overflow-visible outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-accent/40"
68+ role="img"
69+ aria-label={label}
70+ aria-describedby={list}
71+ tabIndex={0}
72+ onMouseLeave={() => point(null)}
73+ onBlur={() => point(null)}
74+ onKeyDown={(event) => {
75+ if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
76+ event.preventDefault();
77+ const from = active ?? last;
78+ point(Math.max(0, Math.min(values.length - 1, from + (event.key === "ArrowLeft" ? -1 : 1))));
79+ }}
80+ >
81+ {active != null && <line x1={x(active)} x2={x(active)} y1={0} y2={height} className="stroke-line-strong" strokeWidth={1} />}
82+ <path d={d} fill="none" className="stroke-muted" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
83+ {values[shown] != null && <circle cx={x(shown)} cy={y(values[shown]!)} r={3.5} className="fill-accent" stroke="var(--color-surface)" strokeWidth={2} />}
84+ {values.map((_, i) => (
85+ <rect key={i} x={x(i) - step / 2} y={-4} width={Math.max(step, 8)} height={height + 8} fill="transparent" onMouseEnter={() => point(i)} />
86+ ))}
87+ </svg>
88+ <ul id={list} className="sr-only">
89+ {points.map((text, i) => (
90+ <li key={i}>{text}</li>
91+ ))}
92+ </ul>
93+ </>
94+ );
95+}
96+
97+// --- The pulse ----------------------------------------------------------------------
98+
99+const TONE: Record<"good" | "bad" | "flat", string> = {
100+ good: "bg-success/12 text-success",
101+ bad: "bg-danger/10 text-danger",
102+ flat: "bg-line text-muted",
103+};
104+
105+const TONE_WORDS: Record<"good" | "bad" | "flat", string> = { good: "better than last week", bad: "worse than last week", flat: "the same as last week" };
106+
107+function Tile({ tile }: { tile: PulseTile }) {
108+ const [point, setPoint] = useState<number | null>(null);
109+ return (
110+ <div className="group relative flex min-w-0 flex-col gap-2 bg-surface px-4 py-3.5 transition-colors hover:bg-raised">
111+ <Link to={tile.to} className="truncate text-xs text-muted after:absolute after:inset-0 group-hover:text-fg">
112+ {tile.label}
113+ </Link>
114+ <div className="flex items-center gap-2">
115+ <span className="text-2xl leading-none font-semibold tracking-tight text-fg">{tile.value}</span>
116+ {tile.delta && (
117+ <span className={cn("rounded-full px-1.5 py-0.5 text-[0.6875rem] font-medium tabular-nums", TONE[tile.delta.tone])}>
118+ {tile.delta.text}
119+ <span className="sr-only">, {TONE_WORDS[tile.delta.tone]}</span>
120+ </span>
121+ )}
122+ </div>
123+ {/* Above the link's cover, so pointing at a week says it. */}
124+ <div className="relative">
125+ <TrendLine values={tile.series} points={tile.points} label={`${tile.label} over the last ${tile.series.length} weeks`} width={132} onPoint={setPoint} />
126+ </div>
127+ <p className="truncate text-[0.6875rem] text-faint">{point != null ? tile.points[point] : tile.hint}</p>
128+ </div>
129+ );
130+}
131+
132+/** The past week in four figures, across the top of Home. */
133+export function PulseStrip({ tiles }: { tiles: PulseTile[] }) {
134+ if (tiles.length === 0) return null;
135+ return (
136+ <section
137+ aria-label="The past week"
138+ className="grid grid-cols-2 gap-px overflow-hidden rounded-2xl border border-line bg-line lg:grid-cols-[6.5rem_repeat(4,minmax(0,1fr))]"
139+ >
140+ <div className="col-span-2 flex items-center bg-surface px-4 py-2 lg:col-span-1 lg:py-0">
141+ <p className="font-mono text-[0.6875rem] tracking-[0.14em] text-muted uppercase">Past week</p>
142+ </div>
143+ {tiles.map((tile) => (
144+ <Tile key={tile.key} tile={tile} />
145+ ))}
146+ </section>
147+ );
148+}
149+
150+export function PulseSkeleton() {
151+ return (
152+ <div className="grid grid-cols-2 gap-px overflow-hidden rounded-2xl border border-line bg-line lg:grid-cols-[6.5rem_repeat(4,minmax(0,1fr))]">
153+ <div className="col-span-2 bg-surface px-4 py-3 lg:col-span-1" />
154+ {[0, 1, 2, 3].map((i) => (
155+ <div key={i} className="space-y-2 bg-surface px-4 py-3.5">
156+ <Skeleton className="h-3 w-28" />
157+ <Skeleton className="h-6 w-16" />
158+ <Skeleton className="h-5 w-32" />
159+ </div>
160+ ))}
161+ </div>
162+ );
163+}
164+
165+// --- The findings -------------------------------------------------------------------
166+
167+function Facts({ facts }: { facts: string[] }) {
168+ return (
169+ <p className="font-mono text-[0.75rem] leading-relaxed text-muted">
170+ {facts.map((fact, i) => (
171+ <span key={i}>
172+ {i > 0 && <span className="px-1.5 text-faint">·</span>}
173+ {fact}
174+ </span>
175+ ))}
176+ </p>
177+ );
178+}
179+
180+function Actions({ signal }: { signal: Signal }) {
181+ return (
182+ <div className="flex flex-wrap items-center gap-x-4 gap-y-1">
183+ {signal.link && (
184+ <Link to={signal.link.to} className="inline-flex items-center gap-0.5 text-sm font-medium text-accent hover:underline">
185+ {signal.link.label}
186+ <ChevronRight size={14} />
187+ </Link>
188+ )}
189+ <button type="button" onClick={() => askG1t(signal.ask)} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
190+ <MessageSquare size={12} />
191+ Ask g1t
192+ </button>
193+ </div>
194+ );
195+}
196+
197+function Trend({ trend }: { trend: NonNullable<Signal["trend"]> }) {
198+ const [point, setPoint] = useState<number | null>(null);
199+ return (
200+ <figure className="space-y-1.5">
201+ <TrendLine values={trend.values} points={trend.points} label={`${trend.caption}, ${trend.range}`} width={196} height={40} onPoint={setPoint} />
202+ <figcaption>
203+ <p className="text-xs text-fg-soft">{trend.caption}</p>
204+ <p className="font-mono text-[0.6875rem] text-faint">{point != null ? trend.points[point] : trend.range}</p>
205+ </figcaption>
206+ </figure>
207+ );
208+}
209+
210+function Finding({ signal }: { signal: Signal }) {
211+ return (
212+ <li className={cn("grid gap-4 py-5 first:pt-0 last:pb-0", signal.trend && "md:grid-cols-[minmax(0,1fr)_12.5rem] md:gap-8")}>
213+ <div className="min-w-0 space-y-2">
214+ <h3 className="font-display text-lg leading-snug text-fg md:text-xl">{signal.headline}</h3>
215+ <Facts facts={signal.facts} />
216+ <Actions signal={signal} />
217+ </div>
218+ {signal.trend && (
219+ <div className="md:pt-1">
220+ <Trend trend={signal.trend} />
221+ </div>
222+ )}
223+ </li>
224+ );
225+}
226+
227+/** Findings under the lead, before "and N more". */
228+const SHOWN = 4;
229+
230+/**
231+ * The daily signal: the day, the finding that matters most as the page's
232+ * headline, and the others under it. A quiet week still gets a sentence.
233+ * Null `view`: the work service did not answer.
234+ */
235+export function DailySignal({ date, view, line, footer }: { date: string; view: DailySignalView | null; line?: ReactNode; footer?: ReactNode }) {
236+ const [more, setMore] = useState(false);
237+ const lead = view?.findings[0] ?? null;
238+ const rest = view?.findings.slice(1) ?? [];
239+ const shown = more ? rest : rest.slice(0, SHOWN);
240+ return (
241+ <section aria-labelledby="daily-signal" className="rounded-2xl border border-line bg-surface px-5 py-6 md:px-8 md:py-8">
242+ <p className="font-mono text-[0.6875rem] tracking-[0.16em] text-accent uppercase">Daily signal</p>
243+ <p className="mt-2 font-display text-xl text-fg-soft md:text-2xl">{date}</p>
244+ {view == null ? (
245+ <>
246+ <h1 id="daily-signal" className="mt-4 font-display text-3xl leading-tight tracking-tight text-fg md:text-[2.5rem]">
247+ Here&apos;s your day.
248+ </h1>
249+ <p className="mt-3 flex items-center gap-1.5 text-sm text-muted">
250+ <TriangleAlert size={13} className="shrink-0 text-warn" />
251+ What the work says couldn&apos;t be read just now, so today&apos;s findings aren&apos;t here.
252+ </p>
253+ </>
254+ ) : (
255+ <>
256+ <h1
257+ id="daily-signal"
258+ className="mt-4 max-w-3xl font-display text-3xl leading-[1.12] tracking-tight text-balance text-fg md:text-[2.25rem] xl:text-[2.5rem]"
259+ >
260+ {lead?.headline ?? view.quiet}
261+ </h1>
262+ {lead && (
263+ <div className={cn("mt-4 grid gap-4", lead.trend && "md:grid-cols-[minmax(0,1fr)_12.5rem] md:gap-8")}>
264+ <div className="space-y-2">
265+ <Facts facts={lead.facts} />
266+ <Actions signal={lead} />
267+ </div>
268+ {lead.trend && <Trend trend={lead.trend} />}
269+ </div>
270+ )}
271+ {!view.complete && (
272+ <p className="mt-3 flex items-center gap-1.5 text-xs text-faint">
273+ <TriangleAlert size={12} className="shrink-0" />
274+ Some projects didn&apos;t answer, so these findings leave them out.
275+ </p>
276+ )}
277+ </>
278+ )}
279+ {line && <p className="mt-4 text-sm text-muted">{line}</p>}
280+ {shown.length > 0 && (
281+ <>
282+ <hr className="my-7 border-line" />
283+ <ul className="divide-y divide-line">
284+ {shown.map((signal) => (
285+ <Finding key={signal.key} signal={signal} />
286+ ))}
287+ </ul>
288+ {rest.length > SHOWN && (
289+ <button type="button" onClick={() => setMore(!more)} className="mt-5 text-xs text-muted hover:text-fg">
290+ {more ? "Show fewer" : `${rest.length - SHOWN} more ${rest.length - SHOWN === 1 ? "finding" : "findings"}`}
291+ </button>
292+ )}
293+ </>
294+ )}
295+ {footer && <div className="mt-8 border-t border-line pt-6">{footer}</div>}
296+ </section>
297+ );
298+}
299+
300+export function DailySignalSkeleton({ date }: { date: string }) {
301+ return (
302+ <section className="rounded-2xl border border-line bg-surface px-5 py-6 md:px-8 md:py-8">
303+ <p className="font-mono text-[0.6875rem] tracking-[0.16em] text-accent uppercase">Daily signal</p>
304+ <p className="mt-2 font-display text-xl text-fg-soft md:text-2xl">{date}</p>
305+ <Skeleton className="mt-5 h-9 w-full max-w-2xl" />
306+ <Skeleton className="mt-2 h-9 w-2/3 max-w-xl" />
307+ <Skeleton className="mt-5 h-3 w-64" />
308+ </section>
309+ );
310+}
+245−0
1+/**
2+ * Spend threaded to the work it produced (lib/signals.ts spendFlow): the
3+ * quarter's milestones, the chosen one's work items, the chosen work
4+ * item's change set in each project, and that change set's pull requests.
5+ * Each box is sized by what the agents' usage on it came to (by its pull
6+ * requests when usage is not known) and shaded by how much of it was an
7+ * agent's. Choosing a box opens what is under it; on a phone the columns
8+ * stack.
9+ */
10+import { Bot } from "lucide-react";
11+import { type CSSProperties, useState } from "react";
12+import { Link } from "react-router";
13+
14+import { cn } from "../lib/cn";
15+import { type FlowNode, type FlowPull, agentStep, dollars, percent } from "../lib/signals";
16+
17+/** The five steps of agent use: the accent, from a wash to full. */
18+const STEP_FILL = [
19+ "color-mix(in oklab, var(--color-accent) 10%, var(--color-surface))",
20+ "color-mix(in oklab, var(--color-accent) 24%, var(--color-surface))",
21+ "color-mix(in oklab, var(--color-accent) 42%, var(--color-surface))",
22+ "color-mix(in oklab, var(--color-accent) 68%, var(--color-surface))",
23+ "var(--color-accent)",
24+];
25+/** Steps deep enough that their text turns: white on the light theme's accent, near black on the dark theme's pale one. */
26+const DARK_STEP = 3;
27+
28+const HEIGHT = 480;
29+const GAP = 12;
30+const MIN_BOX = 76;
31+/** Columns' left edges and width, in percent of the canvas: the pull requests' column is wider, for their titles. */
32+const COLUMN = 19;
33+const left = (column: number) => column * 23.5;
34+
35+type Box = { top: number; height: number };
36+
37+/** Boxes down a column, each at least MIN_BOX tall and the rest shared by weight. */
38+function stack(weights: number[]): Box[] {
39+ const n = weights.length;
40+ if (n === 0) return [];
41+ const free = HEIGHT - GAP * (n - 1) - MIN_BOX * n;
42+ const total = weights.reduce((sum, w) => sum + w, 0);
43+ let top = 0;
44+ return weights.map((w) => {
45+ const height = MIN_BOX + (total > 0 ? (w / total) * free : free / n);
46+ const box = { top, height };
47+ top += height + GAP;
48+ return box;
49+ });
50+}
51+
52+const weightOf = (sized: "spend" | "pulls") => (node: { micros: number; pulls: number }) => (sized === "spend" ? node.micros : node.pulls);
53+
54+/** A band from a slice of `from` (in column `a`) to all of `to` (in column `a + 1`), as an SVG path in percent across. */
55+function band(a: number, from: Box, to: Box): string {
56+ const x0 = left(a) + COLUMN;
57+ const x1 = left(a + 1);
58+ const mid = (x0 + x1) / 2;
59+ return `M${x0},${from.top} C${mid},${from.top} ${mid},${to.top} ${x1},${to.top} L${x1},${to.top + to.height} C${mid},${to.top + to.height} ${mid},${from.top + from.height} ${x0},${from.top + from.height} Z`;
60+}
61+
62+/** Slices of a box, one per child, by weight. */
63+function slices(box: Box, weights: number[]): Box[] {
64+ const total = weights.reduce((sum, w) => sum + w, 0);
65+ let top = box.top;
66+ return weights.map((w) => {
67+ const height = total > 0 ? (w / total) * box.height : box.height / weights.length;
68+ const slice = { top, height };
69+ top += height;
70+ return slice;
71+ });
72+}
73+
74+function Legend() {
75+ return (
76+ <div className="flex items-center gap-2 text-xs text-muted">
77+ <span className="font-medium text-fg">Agent use</span>
78+ <span>less</span>
79+ <span className="flex gap-0.5" aria-hidden>
80+ {STEP_FILL.map((fill, i) => (
81+ <span key={i} className="h-2.5 w-5 rounded-sm" style={{ background: fill }} />
82+ ))}
83+ </span>
84+ <span>more</span>
85+ </div>
86+ );
87+}
88+
89+function figures(node: FlowNode, sized: "spend" | "pulls"): string {
90+ const share = node.pulls > 0 ? node.agent / node.pulls : 0;
91+ return `${node.pulls} ${node.pulls === 1 ? "PR" : "PRs"} · ${percent(share)} agent${sized === "spend" && node.micros > 0 ? ` · ${dollars(node.micros)}` : ""}`;
92+}
93+
94+function NodeBox({
95+ node,
96+ sized,
97+ chosen,
98+ onChoose,
99+ style,
100+}: {
101+ node: FlowNode;
102+ sized: "spend" | "pulls";
103+ chosen: boolean;
104+ onChoose?: () => void;
105+ style?: CSSProperties;
106+}) {
107+ const step = agentStep(node.pulls > 0 ? node.agent / node.pulls : 0);
108+ const dark = step >= DARK_STEP;
109+ return (
110+ <button
111+ type="button"
112+ onClick={onChoose}
113+ aria-pressed={chosen}
114+ className={cn(
115+ "flex flex-col items-start overflow-hidden rounded-xl px-3.5 py-3 text-left transition-shadow outline-none focus-visible:ring-2 focus-visible:ring-accent/50",
116+ chosen ? "ring-[1.5px] ring-fg/80" : "hover:ring-1 hover:ring-line-strong",
117+ dark ? "text-[light-dark(#ffffff,#141018)]" : "text-fg",
118+ )}
119+ style={{ background: STEP_FILL[step], ...style }}
120+ >
121+ <span className="line-clamp-2 text-sm leading-snug font-semibold">{node.label}</span>
122+ <span className={cn("mt-1 font-mono text-[0.6875rem]", dark ? "text-[light-dark(rgb(255_255_255/0.82),rgb(20_16_24/0.75))]" : "text-fg-soft")}>
123+ {figures(node, sized)}
124+ </span>
125+ </button>
126+ );
127+}
128+
129+function PullRow({ pull, style }: { pull: FlowPull; style?: CSSProperties }) {
130+ return (
131+ <Link to={pull.to} className="flex items-center gap-3 border-b border-line px-3 text-sm last:border-b-0 hover:bg-raised" style={style}>
132+ <span className="shrink-0 font-mono text-xs text-muted">#{pull.number}</span>
133+ <span className="min-w-0 grow truncate font-medium text-fg">{pull.title}</span>
134+ {pull.agent && <Bot size={13} className="shrink-0 text-accent" aria-label="An agent's" />}
135+ </Link>
136+ );
137+}
138+
139+const HEADS = ["Milestone", "Work item", "Change set", "Pull requests"];
140+
141+export function SpendFlow({ flow, sized }: { flow: FlowNode[]; sized: "spend" | "pulls" }) {
142+ const [path, setPath] = useState<string[]>([]);
143+ const weight = weightOf(sized);
144+ // The chosen node at each level: the one asked for, else the biggest.
145+ const columns: FlowNode[][] = [flow];
146+ const chosen: FlowNode[] = [];
147+ for (let level = 0; level < 3; level++) {
148+ const nodes = columns[level] ?? [];
149+ const pick = nodes.find((node) => node.key === path[level]) ?? nodes[0];
150+ if (!pick) break;
151+ chosen.push(pick);
152+ if (level < 2) columns.push(pick.children);
153+ }
154+ const pulls = chosen[2]?.list ?? [];
155+ const choose = (level: number, key: string) => setPath([...chosen.slice(0, level).map((node) => node.key), key]);
156+ const boxes = columns.map((nodes) => stack(nodes.map(weight)));
157+ const rowHeight = pulls.length > 0 ? HEIGHT / Math.max(pulls.length, 5) : 0;
158+ const rows: Box[] = pulls.map((_, i) => ({ top: i * rowHeight, height: rowHeight }));
159+
160+ if (flow.length === 0) return <p className="text-sm text-muted">No pull requests were opened or merged this quarter yet.</p>;
161+
162+ return (
163+ <div className="space-y-4">
164+ <Legend />
165+ {/* Wide: the four columns, joined. */}
166+ <div className="hidden md:block">
167+ <div className="relative h-7">
168+ {HEADS.map((head, column) => (
169+ <p key={head} className="absolute top-0 font-mono text-[0.6875rem] tracking-[0.14em] text-muted uppercase" style={{ left: `${left(column)}%` }}>
170+ {head}
171+ </p>
172+ ))}
173+ </div>
174+ <div className="relative" style={{ height: HEIGHT }}>
175+ <svg className="pointer-events-none absolute inset-0 h-full w-full" viewBox={`0 0 100 ${HEIGHT}`} preserveAspectRatio="none" aria-hidden>
176+ {chosen.map((node, level) => {
177+ const index = columns[level]!.indexOf(node);
178+ const from = boxes[level]![index]!;
179+ if (level < 2) {
180+ const children = columns[level + 1]!;
181+ return slices(from, children.map(weight)).map((slice, i) => (
182+ <path key={`${level}-${i}`} d={band(level, slice, boxes[level + 1]![i]!)} className="fill-line-strong" opacity={0.45} />
183+ ));
184+ }
185+ return slices(
186+ from,
187+ rows.map(() => 1),
188+ ).map((slice, i) => (
189+ <path
190+ key={`p-${i}`}
191+ d={band(level, slice, { top: rows[i]!.top + 6, height: rows[i]!.height - 12 })}
192+ className="fill-line-strong"
193+ opacity={0.35}
194+ />
195+ ));
196+ })}
197+ </svg>
198+ {columns.map((nodes, level) =>
199+ nodes.map((node, i) => (
200+ <NodeBox
201+ key={node.key}
202+ node={node}
203+ sized={sized}
204+ chosen={chosen[level]?.key === node.key}
205+ onChoose={() => choose(level, node.key)}
206+ style={{ position: "absolute", left: `${left(level)}%`, width: `${COLUMN}%`, top: boxes[level]![i]!.top, height: boxes[level]![i]!.height }}
207+ />
208+ )),
209+ )}
210+ <div
211+ className="absolute rounded-xl border border-line bg-surface"
212+ style={{ left: `${left(3)}%`, width: `${100 - left(3)}%`, top: 0, height: pulls.length * rowHeight || MIN_BOX }}
213+ >
214+ {pulls.length === 0 ? (
215+ <p className="px-3 py-4 text-sm text-muted">No pull requests here.</p>
216+ ) : (
217+ pulls.map((pull, i) => <PullRow key={pull.key} pull={pull} style={{ height: rows[i]!.height }} />)
218+ )}
219+ </div>
220+ </div>
221+ </div>
222+ {/* A phone: each level as a list, the chosen one open. */}
223+ <div className="space-y-5 md:hidden">
224+ {columns.map((nodes, level) => (
225+ <div key={level} className="space-y-2">
226+ <p className="font-mono text-[0.6875rem] tracking-[0.14em] text-muted uppercase">{HEADS[level]}</p>
227+ <div className="grid gap-2">
228+ {nodes.map((node) => (
229+ <NodeBox key={node.key} node={node} sized={sized} chosen={chosen[level]?.key === node.key} onChoose={() => choose(level, node.key)} />
230+ ))}
231+ </div>
232+ </div>
233+ ))}
234+ <div className="space-y-2">
235+ <p className="font-mono text-[0.6875rem] tracking-[0.14em] text-muted uppercase">{HEADS[3]}</p>
236+ <div className="rounded-xl border border-line bg-surface">
237+ {pulls.map((pull) => (
238+ <PullRow key={pull.key} pull={pull} style={{ height: 48 }} />
239+ ))}
240+ </div>
241+ </div>
242+ </div>
243+ </div>
244+ );
245+}
+114−0
1+/**
2+ * The daily signal's reads: the work service's `workspaceSignals` for the
3+ * workspace's repositories, a hundred at a time; the agents' usage for the
4+ * weeks and for the quarter; and, for the Workspace overview, its teams.
5+ * Each comes back on its own, so one that fails leaves only what needs it
6+ * saying so. The shaping is in ./signals.ts.
7+ */
8+import type { Repo, RepoPath, Usage, User, WorkspaceSignals } from "@g1t/contracts";
9+
10+import { billing, identity, work } from "./services.server";
11+import { type Group, type WeekTotals, mergeSignals, signalsRange, weekStarts, weekTotals, weeklySpend } from "./signals";
12+
13+/** Repositories asked of the work service at once: its limit. */
14+const BATCH = 100;
15+/** Teams the rhythm reads the projects of. */
16+const MAX_TEAMS = 8;
17+
18+export type SignalData = {
19+ slug: string;
20+ now: number;
21+ range: ReturnType<typeof signalsRange>;
22+ starts: string[];
23+ repos: Record<string, RepoPath>;
24+ signals: WorkspaceSignals;
25+ /** Every batch answered: otherwise the figures leave some projects out. */
26+ complete: boolean;
27+ weeks: WeekTotals[];
28+ /** Usage per week; null when billing did not answer. */
29+ spend: number[] | null;
30+ quarterUsage: Usage | null;
31+};
32+
33+async function usageSince(viewer: User, slug: string, day: string): Promise<Usage | null> {
34+ const result = await billing.usage(slug, viewer, `${day}T00:00:00.000Z`).catch(() => null);
35+ return result?.ok ? result.value : null;
36+}
37+
38+/**
39+ * What the workspace's work says, for a page drawn at `now`. Null when
40+ * the work service answered none of the batches: the page then says so.
41+ */
42+export async function loadSignalData(viewer: User, slug: string, now: number, owned: Repo[] | null): Promise<SignalData | null> {
43+ if (!owned) return null;
44+ const range = signalsRange(now);
45+ const starts = weekStarts(range.until, range.weeks);
46+ const ids = owned.map((repo) => repo.id);
47+ const repos: Record<string, RepoPath> = {};
48+ for (const repo of owned) repos[repo.id] = { namespace: repo.namespace, name: repo.name };
49+ const [batches, weekly, quarterUsage] = await Promise.all([
50+ Promise.allSettled(
51+ Array.from({ length: Math.ceil(ids.length / BATCH) }, (_, i) =>
52+ work.workspaceSignals(
53+ { workspace: slug, repoIds: ids.slice(i * BATCH, (i + 1) * BATCH), until: range.until, weeks: range.weeks, days: range.days, since: range.since },
54+ viewer,
55+ ),
56+ ),
57+ ),
58+ usageSince(viewer, slug, starts[0]!),
59+ usageSince(viewer, slug, range.quarter),
60+ ]);
61+ const answered = batches.flatMap((batch) => (batch.status === "fulfilled" ? [batch.value] : []));
62+ for (const batch of batches) if (batch.status === "rejected") console.warn("g1t: workspace signals could not be read:", batch.reason);
63+ if (ids.length > 0 && answered.length === 0) return null;
64+ const signals = mergeSignals(answered);
65+ return {
66+ slug,
67+ now,
68+ range,
69+ starts,
70+ repos,
71+ signals,
72+ complete: answered.length === batches.length,
73+ weeks: weekTotals(signals.weeks, starts),
74+ spend: weeklySpend(weekly, starts),
75+ quarterUsage,
76+ };
77+}
78+
79+/**
80+ * What the rhythm's rows are: the workspace's teams by the projects they
81+ * work on, with how many people each has; else, with no teams, the
82+ * projects themselves. Null when identity did not answer.
83+ */
84+export async function loadGroups(viewer: User, slug: string, owned: Repo[]): Promise<{ groups: Group[]; by: "team" | "project" } | null> {
85+ const projects = (): { groups: Group[]; by: "project" } => ({
86+ by: "project",
87+ groups: owned.map((repo) => ({ key: repo.id, label: repo.name, note: repo.namespace, to: `/${repo.namespace}/${repo.name}`, repoIds: [repo.id] })),
88+ });
89+ const listed = await identity.listTeams(viewer, slug).catch(() => null);
90+ if (!listed) return null;
91+ if (!listed.ok || listed.value.length === 0) return projects();
92+ const teams = listed.value.slice(0, MAX_TEAMS);
93+ const [repos, memberships] = await Promise.all([
94+ Promise.all(teams.map((team) => identity.teamRepos(viewer, slug, team.slug).catch(() => null))),
95+ identity.teamMemberships(viewer, slug).catch(() => null),
96+ ]);
97+ const people = new Map<string, number>();
98+ if (memberships?.ok) for (const member of memberships.value) for (const team of member.teams) people.set(team.slug, (people.get(team.slug) ?? 0) + 1);
99+ const groups = teams.flatMap((team, i) => {
100+ const found = repos[i];
101+ if (!found?.ok || found.value.length === 0) return [];
102+ const count = people.get(team.slug);
103+ return [
104+ {
105+ key: team.slug,
106+ label: team.name,
107+ note: count == null ? "" : `${count} ${count === 1 ? "person" : "people"}`,
108+ to: `/${slug}/-/teams/${team.slug}`,
109+ repoIds: found.value.map((repo) => repo.repo_id),
110+ },
111+ ];
112+ });
113+ return groups.length > 0 ? { by: "team", groups } : projects();
114+}
+382−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import type { SignalAuthor, SignalPull, SignalWeek, Usage, WorkspaceSignals } from "@g1t/contracts";
5+
6+import {
7+ type SignalContext,
8+ type WeekTotals,
9+ agentStep,
10+ blockedSignal,
11+ dayList,
12+ emptySignals,
13+ findings,
14+ mergeSignals,
15+ newGround,
16+ newHabits,
17+ pulse,
18+ quarterStart,
19+ quietHeadline,
20+ reviewSignal,
21+ reworkSignal,
22+ rhythm,
23+ shippedSignal,
24+ short,
25+ signalsRange,
26+ silos,
27+ spendFlow,
28+ spendSignal,
29+ spoken,
30+ stalledSignal,
31+ weekStarts,
32+ weekTotals,
33+ weeklySpend,
34+} from "./signals.ts";
35+
36+const NOW = Date.parse("2026-10-18T15:00:00Z");
37+const DAY = 86_400_000;
38+const repos = { r1: { namespace: "acme", name: "web" }, r2: { namespace: "acme", name: "tax" } };
39+
40+function week(start: string, fields: Partial<WeekTotals> = {}): WeekTotals {
41+ return { start, merged: 0, agentMerged: 0, agentReworked: 0, agentDrafted: 0, agentDraftSeconds: 0, reviewed: 0, reviewSeconds: 0, stalled: 0, ...fields };
42+}
43+
44+function usage(fields: Partial<Usage> = {}): Usage {
45+ return {
46+ since: "",
47+ spentMicros: 0,
48+ costMicros: 0,
49+ providerMicros: 0,
50+ usedMicros: 0,
51+ free: false,
52+ runs: 0,
53+ byDay: [],
54+ byTask: [],
55+ byRepo: [],
56+ byPull: [],
57+ byModel: [],
58+ addedMicros: 0,
59+ ...fields,
60+ };
61+}
62+
63+function ctx(fields: Partial<SignalContext> = {}): SignalContext {
64+ return { slug: "acme", now: NOW, repos, signals: emptySignals(), weeks: [], quarterUsage: null, quarter: "2026-10-01", ...fields };
65+}
66+
67+function pull(fields: Partial<SignalPull>): SignalPull {
68+ return {
69+ repoId: "r1",
70+ number: 1,
71+ title: "A change",
72+ status: "merged",
73+ agent: false,
74+ ownerId: "u1",
75+ ownerName: "ana",
76+ createdAt: "2026-10-05T00:00:00Z",
77+ mergedAt: "2026-10-06T00:00:00Z",
78+ item: null,
79+ milestone: null,
80+ ...fields,
81+ };
82+}
83+
84+function author(fields: Partial<SignalAuthor>): SignalAuthor {
85+ return {
86+ repoId: "r1",
87+ ownerId: "u1",
88+ ownerName: "ana",
89+ firstMergedAt: "2026-01-01T00:00:00Z",
90+ lastMergedAt: "2026-10-10T00:00:00Z",
91+ merged: 10,
92+ recent: 10,
93+ ...fields,
94+ };
95+}
96+
97+test("weeks of seven days end with the last day, and the quarter starts on its first month", () => {
98+ assert.deepEqual(weekStarts("2026-10-18", 3), ["2026-09-28", "2026-10-05", "2026-10-12"]);
99+ assert.deepEqual(dayList("2026-10-18", 3), ["2026-10-16", "2026-10-17", "2026-10-18"]);
100+ assert.equal(quarterStart("2026-10-18"), "2026-10-01");
101+ assert.equal(quarterStart("2026-03-31"), "2026-01-01");
102+ assert.equal(quarterStart("2026-08-02"), "2026-07-01");
103+ // The reach covers a milestone's build even early in a quarter.
104+ assert.equal(signalsRange(NOW).since, "2026-06-20");
105+});
106+
107+test("batches add up per repository, and quiet weeks stay in the line", () => {
108+ const rows: SignalWeek[] = [
109+ { repoId: "r1", ...week("2026-10-12", { merged: 2, agentMerged: 1 }) },
110+ { repoId: "r2", ...week("2026-10-12", { merged: 3, agentMerged: 3, stalled: 1 }) },
111+ { repoId: "r9", ...week("2026-01-01", { merged: 9 }) },
112+ ];
113+ const totals = weekTotals(rows, ["2026-10-05", "2026-10-12"]);
114+ assert.deepEqual(
115+ totals.map((w) => [w.start, w.merged, w.agentMerged, w.stalled]),
116+ [
117+ ["2026-10-05", 0, 0, 0],
118+ ["2026-10-12", 5, 4, 1],
119+ ],
120+ );
121+ assert.equal(weekTotals(rows, ["2026-10-12"], new Set(["r1"]))[0]!.merged, 2);
122+ const a: WorkspaceSignals = {
123+ ...emptySignals(),
124+ items: [{ number: 4, title: "Old", state: "open", milestone: null }],
125+ waiting: [{ repoId: "r1", number: 2, title: "b", createdAt: "2026-10-02", agent: false, draftSeconds: null, item: null, blocks: [] }],
126+ };
127+ const b: WorkspaceSignals = {
128+ ...emptySignals(),
129+ items: [{ number: 4, title: "Old", state: "open", milestone: null }],
130+ waiting: [{ repoId: "r2", number: 1, title: "a", createdAt: "2026-10-01", agent: false, draftSeconds: null, item: null, blocks: [] }],
131+ };
132+ const merged = mergeSignals([a, b]);
133+ assert.equal(merged.items.length, 1, "an item named by two batches is one item");
134+ assert.deepEqual(
135+ merged.waiting.map((w) => w.number),
136+ [1, 2],
137+ "the oldest waits first across batches",
138+ );
139+});
140+
141+test("usage by day falls into its week", () => {
142+ const spend = weeklySpend(
143+ usage({
144+ byDay: [
145+ { key: "2026-10-12/review", micros: 5, runs: 1 },
146+ { key: "2026-10-18/run", micros: 7, runs: 1 },
147+ { key: "2026-10-11/run", micros: 3, runs: 1 },
148+ ],
149+ }),
150+ ["2026-10-05", "2026-10-12"],
151+ );
152+ assert.deepEqual(spend, [3, 12]);
153+ assert.equal(weeklySpend(null, ["2026-10-12"]), null, "usage that did not answer is not zero");
154+});
155+
156+test("the pulse says each figure against last week, and whether that is good news", () => {
157+ const weeks = [
158+ week("2026-10-05", { merged: 60, agentMerged: 52, agentReworked: 11, stalled: 16 }),
159+ week("2026-10-12", { merged: 70, agentMerged: 61, agentReworked: 11, stalled: 23 }),
160+ ];
161+ const tiles = pulse({ slug: "acme", weeks, spend: [52 * 72_000_000, 61 * 86_000_000] });
162+ const by = Object.fromEntries(tiles.map((tile) => [tile.key, tile]));
163+ assert.equal(by.merges!.value, "61");
164+ assert.deepEqual(by.merges!.delta, { text: "+9", tone: "good" });
165+ assert.equal(by.spend!.value, "$86");
166+ assert.deepEqual(by.spend!.delta, { text: "+$14", tone: "bad" }, "spend going up is not good news");
167+ assert.deepEqual(by.stalled!.delta, { text: "+7", tone: "bad" });
168+ assert.equal(by.rework!.value, "18%");
169+ assert.equal(by.rework!.delta!.text, "−3.1 pts");
170+ assert.equal(by.rework!.delta!.tone, "good");
171+ const unknown = pulse({ slug: "acme", weeks, spend: null });
172+ assert.equal(unknown.find((tile) => tile.key === "spend")!.value, "—", "no usage, no figure");
173+ assert.equal(unknown.find((tile) => tile.key === "spend")!.delta, null);
174+});
175+
176+test("the change holding up the most work items leads, with what it holds up", () => {
177+ const signals = {
178+ ...emptySignals(),
179+ waiting: [
180+ { repoId: "r1", number: 7, title: "Small", createdAt: "2026-10-10T00:00:00Z", agent: true, draftSeconds: 600, item: 3, blocks: [4] },
181+ {
182+ repoId: "r2",
183+ number: 4821,
184+ title: "Nexus threshold configuration",
185+ createdAt: "2026-10-16T15:00:00Z",
186+ agent: true,
187+ draftSeconds: 600,
188+ item: 5,
189+ blocks: [8, 9, 10],
190+ },
191+ { repoId: "gone", number: 1, title: "Unreadable", createdAt: "2026-10-01T00:00:00Z", agent: false, draftSeconds: null, item: 1, blocks: [2, 3, 4, 5] },
192+ ],
193+ };
194+ const signal = blockedSignal(ctx({ signals }))!;
195+ assert.equal(signal.headline, "Three work items are waiting on one unreviewed change.");
196+ assert.deepEqual(signal.facts, ["tax#4821 Nexus threshold configuration", "open 2 days", "holds up #8, #9, #10"]);
197+ assert.equal(signal.link!.to, "/acme/tax/pull/4821");
198+ assert.equal(blockedSignal(ctx()), null);
199+});
200+
201+test("a quick draft waiting long for review is said with both times; a slowdown alone is said against the weeks before", () => {
202+ const weeks = [
203+ week("2026-09-28", { reviewed: 4, reviewSeconds: 4 * 0.8 * 86400 }),
204+ week("2026-10-05", { reviewed: 4, reviewSeconds: 4 * 1.2 * 86400 }),
205+ week("2026-10-12", { reviewed: 10, reviewSeconds: 10 * 2.1 * 86400, agentDrafted: 5, agentDraftSeconds: 5 * 48 * 60 }),
206+ ];
207+ const gap = reviewSignal(ctx({ weeks }))!;
208+ assert.equal(gap.headline, "Changes an agent drafts in under an hour are waiting 2.1 days for first review.");
209+ assert.deepEqual(gap.facts, ["48m to draft", "2.1d to first review", "this week"]);
210+ assert.equal(gap.trend!.range, "19h → 2.1d over 3 weeks");
211+ const slower = reviewSignal(ctx({ weeks: weeks.map((w, i) => (i === 2 ? { ...w, agentDrafted: 0, agentDraftSeconds: 0 } : w)) }))!;
212+ assert.match(slower.headline, /^First reviews are taking 2\.1 days, up from a day in the weeks before\.$/);
213+ assert.equal(reviewSignal(ctx({ weeks: [week("2026-10-12")] })), null, "no reviews, nothing to say");
214+});
215+
216+test("spend that concentrates in one milestone is a finding; spread-out spend is not", () => {
217+ const signals = {
218+ ...emptySignals(),
219+ milestones: [{ number: 1, title: "the Checkout replatform", state: "open", dueOn: null, createdAt: "2026-08-01T00:00:00Z", closedAt: null }],
220+ items: [{ number: 5, title: "Tax cutover", state: "open", milestone: 1 }],
221+ pulls: [pull({ number: 1, item: 5, milestone: 1 }), pull({ number: 2 }), pull({ repoId: "r2", number: 3 })],
222+ };
223+ const quarterUsage = usage({
224+ byPull: [
225+ { key: "acme/web#1", micros: 28_300_000_000, runs: 1 },
226+ { key: "acme/web#2", micros: 14_000_000_000, runs: 1 },
227+ { key: "acme/tax#3", micros: 10_100_000_000, runs: 1 },
228+ ],
229+ });
230+ const signal = spendSignal(ctx({ signals, quarterUsage }))!;
231+ assert.equal(signal.headline, "More than half of this quarter's agent spend is going into the Checkout replatform.");
232+ assert.deepEqual(signal.facts, ["$28.3K of $52.4K", "quarter to date"]);
233+ const even = usage({ byPull: quarterUsage.byPull.map((slice) => ({ ...slice, micros: 1_000_000 })) });
234+ assert.equal(spendSignal(ctx({ signals, quarterUsage: even })), null);
235+ assert.equal(spendSignal(ctx({ signals, quarterUsage: null })), null);
236+});
237+
238+test("a milestone closed this week says when against its due date, and what it took", () => {
239+ const closedAt = "2026-10-13T18:00:00Z";
240+ const signals = {
241+ ...emptySignals(),
242+ milestones: [{ number: 2, title: "Single sign-on", state: "closed", dueOn: "2026-11-03", createdAt: "2026-07-28T00:00:00Z", closedAt }],
243+ pulls: [
244+ pull({ milestone: 2, number: 1 }),
245+ pull({ milestone: 2, number: 2, repoId: "r2" }),
246+ pull({ milestone: 2, number: 3, status: "closed", mergedAt: null }),
247+ ],
248+ };
249+ const signal = shippedSignal(ctx({ signals }))!;
250+ assert.equal(signal.headline, "Single sign-on shipped on Tuesday, three weeks ahead of schedule.");
251+ assert.deepEqual(signal.facts, ["11 weeks in build", "2 changes", "2 projects"]);
252+ const old = { ...signals, milestones: [{ ...signals.milestones[0]!, closedAt: "2026-09-01T00:00:00Z" }] };
253+ assert.equal(shippedSignal(ctx({ signals: old })), null, "only the last week's");
254+});
255+
256+test("stalled changes and rework are findings only when they moved", () => {
257+ assert.equal(
258+ stalledSignal(ctx({ weeks: [week("a", { stalled: 16 }), week("b", { stalled: 23 })] }))!.headline,
259+ "23 changes have been open over a week, 7 more than last week.",
260+ );
261+ assert.equal(stalledSignal(ctx({ weeks: [week("a", { stalled: 16 }), week("b", { stalled: 17 })] })), null);
262+ const steady = [1, 2, 3, 4].map((i) => week(`w${i}`, { agentMerged: 10, agentReworked: 1 }));
263+ assert.match(
264+ reworkSignal(ctx({ weeks: [...steady, week("now", { agentMerged: 10, agentReworked: 4 })] }))!.headline,
265+ /^People asked for changes on 40% .* up from 10%\.$/,
266+ );
267+ assert.equal(reworkSignal(ctx({ weeks: [...steady, week("now", { agentMerged: 10, agentReworked: 1 })] })), null);
268+});
269+
270+test("who works where: new ground, one person carrying a project, and a first agent merge", () => {
271+ const recent = new Date(NOW - 3 * DAY).toISOString();
272+ const authors = [
273+ author({ ownerId: "u1", ownerName: "ana", repoId: "r1", recent: 9, merged: 40 }),
274+ author({ ownerId: "u2", ownerName: "bo", repoId: "r1", firstMergedAt: recent, recent: 1, merged: 1 }),
275+ author({ ownerId: "u2", ownerName: "bo", repoId: "r2", firstMergedAt: "2026-02-01T00:00:00Z", merged: 12, recent: 2 }),
276+ ];
277+ assert.deepEqual(
278+ newGround(authors, NOW).map((row) => [row.name, row.repoId, row.elsewhere]),
279+ [["bo", "r1", 12]],
280+ );
281+ const [silo] = silos(authors, NOW);
282+ assert.equal(silo!.name, "ana");
283+ assert.equal(silo!.share, 0.9);
284+ assert.deepEqual(silo!.joining, [{ ownerId: "u2", name: "bo" }]);
285+ const pulls = [1, 2, 3].map((n) => pull({ number: n, mergedAt: `2026-09-0${n}T00:00:00Z` }));
286+ const habits = newHabits([...pulls, pull({ number: 9, agent: true, mergedAt: recent })], NOW);
287+ assert.deepEqual(
288+ habits.map((row) => [row.name, row.byHand]),
289+ [["ana", 3]],
290+ );
291+ const signal = findings(ctx({ signals: { ...emptySignals(), authors } })).find((s) => s.key === "ground")!;
292+ assert.equal(signal.headline, "bo is starting to work in web, which ana has carried almost alone.");
293+});
294+
295+test("findings come highest first, and a quiet week still says something", () => {
296+ const signals = {
297+ ...emptySignals(),
298+ waiting: [{ repoId: "r1", number: 2, title: "Fix", createdAt: "2026-10-16T00:00:00Z", agent: false, draftSeconds: null, item: 1, blocks: [3, 4] }],
299+ };
300+ const found = findings(ctx({ signals, weeks: [week("a", { stalled: 1 }), week("b", { stalled: 10 })] }));
301+ assert.deepEqual(
302+ found.map((s) => s.key),
303+ ["blocked", "stalled"],
304+ );
305+ assert.equal(quietHeadline(week("x")), "A quiet week: nothing has merged yet, and nothing is stuck.");
306+ assert.equal(
307+ quietHeadline(week("x", { merged: 4, agentMerged: 2 })),
308+ "Four changes merged this week, 50% of them an agent's, and nothing needs a closer look.",
309+ );
310+});
311+
312+test("waits are said in words and in a few characters", () => {
313+ assert.equal(spoken(48 * 60), "48 minutes");
314+ assert.equal(spoken(5 * 3600), "5 hours");
315+ assert.equal(spoken(2.1 * 86400), "2.1 days");
316+ assert.equal(short(48 * 60), "48m");
317+ assert.equal(short(5.5 * 3600), "5.5h");
318+ assert.equal(short(null), "—");
319+});
320+
321+test("the spend flow threads each pull request through its milestone, work item and project", () => {
322+ const signals = {
323+ ...emptySignals(),
324+ milestones: [{ number: 1, title: "Checkout replatform", state: "open", dueOn: null, createdAt: "2026-08-01T00:00:00Z", closedAt: null }],
325+ items: [{ number: 5, title: "Tax service cutover", state: "open", milestone: 1 }],
326+ pulls: [
327+ pull({ number: 1, item: 5, milestone: 1, agent: true, createdAt: "2026-10-02T00:00:00Z" }),
328+ pull({ number: 2, item: 5, milestone: 1, repoId: "r2", createdAt: "2026-10-03T00:00:00Z" }),
329+ pull({ number: 3, createdAt: "2026-10-04T00:00:00Z" }),
330+ pull({ number: 4, createdAt: "2026-05-01T00:00:00Z", mergedAt: "2026-05-02T00:00:00Z" }),
331+ ],
332+ };
333+ const flow = spendFlow({
334+ slug: "acme",
335+ repos,
336+ signals,
337+ quarter: "2026-10-01",
338+ quarterUsage: usage({ byPull: [{ key: "acme/web#1", micros: 900, runs: 1 }] }),
339+ });
340+ assert.deepEqual(
341+ flow.map((node) => [node.label, node.pulls, node.agent, node.micros]),
342+ [
343+ ["Checkout replatform", 2, 1, 900],
344+ ["No milestone", 1, 0, 0],
345+ ],
346+ "last quarter's work is left out; the costliest comes first",
347+ );
348+ const item = flow[0]!.children[0]!;
349+ assert.equal(item.label, "Tax service cutover");
350+ assert.deepEqual(
351+ item.children.map((set) => [set.label, set.list!.map((p) => p.number)]),
352+ [
353+ ["web", [1]],
354+ ["tax", [2]],
355+ ],
356+ );
357+ assert.equal(agentStep(0.71), 3);
358+ assert.equal(agentStep(0.1), 0);
359+});
360+
361+test("the rhythm adds each group's days and leaves out groups with nothing", () => {
362+ const days = [
363+ { repoId: "r1", day: "2026-10-17", opened: 2, merged: 1, agentMerged: 1, reworked: 0, reviews: 3, sessions: 4 },
364+ { repoId: "r2", day: "2026-10-18", opened: 1, merged: 0, agentMerged: 0, reworked: 1, reviews: 0, sessions: 0 },
365+ ];
366+ const rows = rhythm(
367+ days,
368+ ["2026-10-17", "2026-10-18"],
369+ [
370+ { key: "pay", label: "Payments", note: "3 people", to: "/t/pay", repoIds: ["r1", "r2"] },
371+ { key: "idle", label: "Idle", note: "", to: "/t/idle", repoIds: ["r9"] },
372+ ],
373+ );
374+ assert.equal(rows.length, 1);
375+ assert.deepEqual(
376+ rows[0]!.days.map((day) => [day.opened, day.reviews, day.sessions, day.reworked]),
377+ [
378+ [2, 3, 4, 0],
379+ [1, 0, 0, 1],
380+ ],
381+ );
382+});
+948−0
Loading the diff…
+68−24
Loading the diff…
+200−7
Loading the diff…

Comments

Sign in to comment.