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.

254 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 (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 */
10import { useState } from "react";
11import { Link } from "react-router";
12
13import { cn } from "../lib/cn";
14import { type RhythmDay, type RhythmRow, rhythmLine, rhythmSum } from "../lib/signals";
15
16type Layer = "prs" | "rework" | "agent" | "reviews";
17
18const 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
25const WIDTH = 700;
26const HEIGHT = 112;
27const MID = HEIGHT / 2;
28const BAR = 5;
29
30/** A smooth path through points, as a closed wash from the baseline. */
31function 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
43function 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
47function 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
76function 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
171export 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}