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.

257 linesCodeBlameRaw
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 and commits agents pushed outside one (bars), and agent sessions
5 * started (a wash); below it, reviews and
6 * comments (a wash) and requests for changes (bars). Each row is drawn to
7 * its own scale, so a small team's week is as legible as a big one's; the
8 * numbers are in the readout and the table. Layers turn on and off above
9 * the rows, and pointing at a day says what happened on it.
10 */
11import { useState } from "react";
12import { Link } from "react-router";
13
14import { cn } from "../lib/cn";
15import { type RhythmDay, type RhythmRow, rhythmLine, rhythmSum } from "../lib/signals";
16
17type Layer = "prs" | "rework" | "agent" | "reviews";
18
19const LAYERS: { key: Layer; label: string; swatch: string }[] = [
20 { key: "prs", label: "Changes", swatch: "bg-fg-soft" },
21 { key: "rework", label: "Rework", swatch: "bg-faint" },
22 { key: "agent", label: "Agent activity", swatch: "bg-series-1" },
23 { key: "reviews", label: "Reviews & comments", swatch: "bg-series-4" },
24];
25
26const WIDTH = 700;
27const HEIGHT = 112;
28const MID = HEIGHT / 2;
29const BAR = 5;
30
31/** A smooth path through points, as a closed wash from the baseline. */
32function wash(points: [number, number][]): string {
33 if (points.length === 0) return "";
34 let d = `M${points[0]![0]},${MID} L${points[0]![0]},${points[0]![1]}`;
35 for (let i = 1; i < points.length; i++) {
36 const [x0, y0] = points[i - 1]!;
37 const [x1, y1] = points[i]!;
38 const cx = (x0 + x1) / 2;
39 d += ` C${cx},${y0} ${cx},${y1} ${x1},${y1}`;
40 }
41 return `${d} L${points[points.length - 1]![0]},${MID} Z`;
42}
43
44function dayWords(day: string): string {
45 return new Date(`${day}T00:00:00Z`).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" });
46}
47
48function Readout({ row, day }: { row: RhythmRow; day: RhythmDay | null }) {
49 const sum = day ?? rhythmSum(row.days.slice(Math.floor(row.days.length / 2)));
50 return (
51 <div className="min-w-48 rounded-xl border border-line bg-surface px-3.5 py-3 text-xs shadow-lg">
52 <p className="font-mono text-[0.625rem] tracking-[0.14em] text-muted uppercase">
53 {row.label} · {day ? dayWords(day.day) : "this week"}
54 </p>
55 <ul className="mt-2 space-y-1 text-fg">
56 <li className="flex items-center gap-2">
57 <span className="size-2 rounded-full bg-fg-soft" />
58 {sum.opened} opened · {sum.merged} merged{sum.commits > 0 && ` · ${sum.commits} pushed by agents`}
59 </li>
60 <li className="flex items-center gap-2">
61 <span className="size-2 rounded-full bg-faint" />
62 {sum.reworked} {sum.reworked === 1 ? "request" : "requests"} for changes
63 </li>
64 <li className="flex items-center gap-2">
65 <span className="size-2 rounded-full bg-series-4" />
66 {sum.reviews} reviews &amp; comments
67 </li>
68 <li className="flex items-center gap-2">
69 <span className="size-2 rounded-full bg-series-1" />
70 {sum.sessions} agent {sum.sessions === 1 ? "session" : "sessions"}
71 </li>
72 </ul>
73 </div>
74 );
75}
76
77function Row({
78 row,
79 on,
80 active,
81 faded,
82 onDay,
83}: {
84 row: RhythmRow;
85 on: Set<Layer>;
86 active: number | null;
87 faded: boolean;
88 onDay: (day: number | null) => void;
89}) {
90 const n = row.days.length;
91 const slot = WIDTH / n;
92 const x = (i: number) => slot * i + slot / 2;
93 const up = Math.max(1, ...row.days.map((day) => Math.max(on.has("prs") ? day.opened + day.merged + day.commits : 0, on.has("agent") ? day.sessions : 0)));
94 const down = Math.max(1, ...row.days.map((day) => Math.max(on.has("rework") ? day.reworked : 0, on.has("reviews") ? day.reviews : 0)));
95 const above = (value: number) => MID - (value / up) * (MID - 6);
96 const below = (value: number) => MID + (value / down) * (MID - 6);
97 const day = active != null ? row.days[active]! : null;
98 return (
99 <li className={cn("grid gap-3 py-5 transition-opacity md:grid-cols-[12rem_minmax(0,1fr)] md:gap-6", faded && "opacity-45")}>
100 <div className="min-w-0">
101 <Link to={row.to} className="text-base font-semibold text-fg hover:text-accent">
102 {row.label}
103 </Link>
104 {row.note && <p className="text-xs text-muted">{row.note}</p>}
105 <p className="mt-1.5 text-xs text-fg-soft">{rhythmLine(row)}</p>
106 </div>
107 <div className="relative">
108 <svg
109 viewBox={`0 0 ${WIDTH} ${HEIGHT}`}
110 preserveAspectRatio="none"
111 className="block h-28 w-full overflow-visible"
112 role="img"
113 aria-label={`${row.label}: the last ${n} days`}
114 onMouseLeave={() => onDay(null)}
115 >
116 {active != null && <rect x={slot * active} y={0} width={slot} height={HEIGHT} className="fill-accent/8" />}
117 <line x1={WIDTH / 2} x2={WIDTH / 2} y1={0} y2={HEIGHT} className="stroke-line" strokeWidth={1} vectorEffect="non-scaling-stroke" />
118 {on.has("agent") && (
119 <path
120 d={wash(row.days.map((d, i) => [x(i), above(d.sessions)]))}
121 className="fill-series-1/15 stroke-series-1"
122 strokeWidth={1.5}
123 vectorEffect="non-scaling-stroke"
124 />
125 )}
126 {on.has("reviews") && (
127 <path
128 d={wash(row.days.map((d, i) => [x(i), below(d.reviews)]))}
129 className="fill-series-4/18 stroke-series-4"
130 strokeWidth={1.5}
131 vectorEffect="non-scaling-stroke"
132 />
133 )}
134 <line x1={0} x2={WIDTH} y1={MID} y2={MID} className="stroke-line-strong" strokeWidth={1} vectorEffect="non-scaling-stroke" />
135 {on.has("prs") &&
136 row.days.map((d, i) =>
137 d.opened + d.merged + d.commits > 0 ? (
138 <rect
139 key={`p${i}`}
140 x={x(i) - BAR / 2}
141 y={above(d.opened + d.merged + d.commits)}
142 width={BAR}
143 height={MID - above(d.opened + d.merged + d.commits)}
144 rx={1.5}
145 className="fill-fg-soft"
146 />
147 ) : null,
148 )}
149 {on.has("rework") &&
150 row.days.map((d, i) =>
151 d.reworked > 0 ? (
152 <rect key={`r${i}`} x={x(i) - BAR / 2} y={MID} width={BAR} height={below(d.reworked) - MID} rx={1.5} className="fill-faint" />
153 ) : null,
154 )}
155 {row.days.map((_, i) => (
156 <rect key={`h${i}`} x={slot * i} y={0} width={slot} height={HEIGHT} fill="transparent" onMouseEnter={() => onDay(i)} />
157 ))}
158 </svg>
159 {day && (
160 <div
161 className="pointer-events-none absolute top-0 z-10 -translate-y-1/3"
162 style={active! < n / 2 ? { left: `${((active! + 1) / n) * 100}%` } : { right: `${((n - active!) / n) * 100}%` }}
163 >
164 <Readout row={row} day={day} />
165 </div>
166 )}
167 </div>
168 </li>
169 );
170}
171
172export function Rhythm({ rows, by }: { rows: RhythmRow[]; by: "team" | "project" }) {
173 const [on, setOn] = useState<Set<Layer>>(new Set(["prs", "rework", "agent", "reviews"]));
174 const [hover, setHover] = useState<{ row: string; day: number } | null>(null);
175 const toggle = (layer: Layer) => {
176 const next = new Set(on);
177 if (next.has(layer)) next.delete(layer);
178 else next.add(layer);
179 setOn(next);
180 };
181 if (rows.length === 0) return <p className="text-sm text-muted">Nothing has moved in the last two weeks.</p>;
182 return (
183 <div>
184 <div className="flex flex-wrap gap-2" role="group" aria-label="What the rows show">
185 {LAYERS.map((layer) => (
186 <button
187 key={layer.key}
188 type="button"
189 aria-pressed={on.has(layer.key)}
190 onClick={() => toggle(layer.key)}
191 className={cn(
192 "inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs transition-colors",
193 on.has(layer.key) ? "border-line-strong bg-surface text-fg" : "border-line bg-transparent text-faint",
194 )}
195 >
196 <span className={cn("h-2.5 w-2.5 rounded-sm", on.has(layer.key) ? layer.swatch : "bg-line")} />
197 {layer.label}
198 </button>
199 ))}
200 </div>
201 <div className="mt-5 hidden grid-cols-[12rem_minmax(0,1fr)] gap-6 md:grid">
202 <span />
203 <div className="grid grid-cols-2 font-mono text-[0.625rem] tracking-[0.14em] text-muted uppercase">
204 <span>Last week</span>
205 <span className="pl-2">This week</span>
206 </div>
207 </div>
208 <ul className="divide-y divide-line">
209 {rows.map((row) => (
210 <Row
211 key={row.key}
212 row={row}
213 on={on}
214 active={hover?.row === row.key ? hover.day : null}
215 faded={hover != null && hover.row !== row.key}
216 onDay={(day) => setHover(day == null ? null : { row: row.key, day })}
217 />
218 ))}
219 </ul>
220 <details className="mt-2 text-xs text-muted">
221 <summary className="cursor-pointer hover:text-fg">The numbers, as a table</summary>
222 <table className="mt-3 w-full text-left tabular-nums">
223 <thead className="text-faint">
224 <tr>
225 <th className="py-1 font-normal">{by === "team" ? "Team" : "Project"}</th>
226 <th className="py-1 font-normal">Opened</th>
227 <th className="py-1 font-normal">Merged</th>
228 <th className="py-1 font-normal">Pushed by agents</th>
229 <th className="py-1 font-normal">Rework</th>
230 <th className="py-1 font-normal">Reviews &amp; comments</th>
231 <th className="py-1 font-normal">Agent sessions</th>
232 </tr>
233 </thead>
234 <tbody>
235 {rows.map((row) => {
236 const half = Math.floor(row.days.length / 2);
237 const last = rhythmSum(row.days.slice(0, half));
238 const now = rhythmSum(row.days.slice(half));
239 const cell = (a: number, b: number) => `${b} (${a} last week)`;
240 return (
241 <tr key={row.key} className="border-t border-line text-fg-soft">
242 <td className="py-1.5 text-fg">{row.label}</td>
243 <td>{cell(last.opened, now.opened)}</td>
244 <td>{cell(last.merged, now.merged)}</td>
245 <td>{cell(last.commits, now.commits)}</td>
246 <td>{cell(last.reworked, now.reworked)}</td>
247 <td>{cell(last.reviews, now.reviews)}</td>
248 <td>{cell(last.sessions, now.sessions)}</td>
249 </tr>
250 );
251 })}
252 </tbody>
253 </table>
254 </details>
255 </div>
256 );
257}