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.

278 linesCodeBlameRaw
1/**
2 * Home's week (lib/signals.ts): the week in a sentence and four figures,
3 * the last two weeks day by day (what agents and people landed), and what
4 * is worth a look, each pinned to the day it happened where it happened
5 * on one. The page leads with what needs you; this is the work's own
6 * account, so it never says whether anything needs anyone.
7 */
8import { ChevronRight, MessageSquare, TriangleAlert } from "lucide-react";
9import { useState } from "react";
10import { Link } from "react-router";
11
12import { askG1t } from "../lib/ask";
13import { cn } from "../lib/cn";
14import type { DailySignal, DayBar, PulseTile, Signal } from "../lib/signals";
15import { TrendLine } from "./signals";
16import { Hint } from "./ui/hint";
17import { Skeleton } from "./ui/skeleton";
18
19const DELTA_TONE: Record<"good" | "bad" | "flat", string> = { good: "text-success", bad: "text-danger", flat: "text-faint" };
20
21/** One figure: what it counts, this week's value, and the change on last week. */
22function Figure({ tile }: { tile: PulseTile }) {
23 return (
24 <Hint label={tile.hint}>
25 <Link to={tile.to} className="group min-w-0 rounded-lg px-3 py-2 transition-colors hover:bg-raised">
26 <p className="truncate text-xs text-muted group-hover:text-fg">{tile.label}</p>
27 <p className="mt-0.5 flex items-baseline gap-2">
28 <span className="text-xl font-semibold tracking-tight text-fg tabular-nums">{tile.value}</span>
29 {tile.delta && (
30 <span className={cn("text-xs tabular-nums", DELTA_TONE[tile.delta.tone])}>
31 {tile.delta.text}
32 <span className="text-faint"> on last week</span>
33 </span>
34 )}
35 </p>
36 </Link>
37 </Hint>
38 );
39}
40
41function dayName(day: string, style: "short" | "long"): string {
42 const date = new Date(`${day}T00:00:00Z`);
43 return style === "short"
44 ? date.toLocaleDateString("en-US", { weekday: "narrow", timeZone: "UTC" })
45 : date.toLocaleDateString("en-US", { weekday: "long", month: "short", day: "numeric", timeZone: "UTC" });
46}
47
48function dayWords(bar: DayBar): string {
49 const landed = bar.agent + bar.people;
50 if (landed === 0 && bar.sessions === 0) return `${dayName(bar.day, "long")}: nothing landed`;
51 const parts = [`${landed} landed`, `${bar.agent} by agents`, `${bar.people} by people`];
52 if (bar.sessions > 0) parts.push(`${bar.sessions} ${bar.sessions === 1 ? "session" : "sessions"} started`);
53 return `${dayName(bar.day, "long")}: ${parts.join(", ")}`;
54}
55
56/**
57 * Two weeks of days as columns: agents' changes in the accent, people's
58 * above them, last week's faded. A finding that happened on a day is
59 * pinned over it with its number in the list below.
60 */
61function Days({ days, pins }: { days: DayBar[]; pins: Map<string, number[]> }) {
62 const [active, setActive] = useState<number | null>(null);
63 const top = Math.max(1, ...days.map((bar) => bar.agent + bar.people));
64 const thisWeek = days.length - 7;
65 const shown = active ?? days.length - 1;
66 return (
67 <figure className="px-5">
68 <div
69 className="grid h-36 items-end gap-1.5 sm:gap-2"
70 style={{ gridTemplateColumns: `repeat(${days.length}, minmax(0, 1fr))` }}
71 onPointerLeave={() => setActive(null)}
72 >
73 {days.map((bar, i) => {
74 const landed = bar.agent + bar.people;
75 const pinned = pins.get(bar.day);
76 return (
77 <button
78 key={bar.day}
79 type="button"
80 aria-label={dayWords(bar)}
81 onPointerEnter={() => setActive(i)}
82 onFocus={() => setActive(i)}
83 onBlur={() => setActive(null)}
84 className={cn(
85 "group relative flex h-full flex-col items-stretch justify-end rounded-md outline-none focus-visible:ring-2 focus-visible:ring-accent/40",
86 i < thisWeek && "opacity-45",
87 active === i && "opacity-100",
88 )}
89 >
90 {pinned && (
91 <span className="absolute inset-x-0 -top-1 flex justify-center gap-0.5">
92 {pinned.map((n) => (
93 <span key={n} className="flex size-4 items-center justify-center rounded-full bg-warn text-[0.625rem] font-semibold text-bg">
94 {n}
95 </span>
96 ))}
97 </span>
98 )}
99 <span
100 className="mx-auto flex w-full max-w-11 flex-col justify-end overflow-hidden rounded-[5px]"
101 style={{ height: `${Math.max(landed > 0 ? 6 : 2, (landed / top) * 80)}%` }}
102 >
103 {landed === 0 ? (
104 <span className="h-full bg-line" />
105 ) : (
106 <>
107 <span className="bg-line-strong transition-colors group-hover:bg-fg-soft/50" style={{ flexGrow: bar.people }} />
108 <span className="bg-accent transition-colors group-hover:bg-accent-hover" style={{ flexGrow: bar.agent }} />
109 </>
110 )}
111 </span>
112 </button>
113 );
114 })}
115 </div>
116 <div className="mt-1.5 grid gap-1.5 sm:gap-2" style={{ gridTemplateColumns: `repeat(${days.length}, minmax(0, 1fr))` }} aria-hidden>
117 {days.map((bar, i) => (
118 <span key={bar.day} className={cn("text-center font-mono text-[0.625rem] text-faint", i === days.length - 1 && "text-fg-soft")}>
119 {dayName(bar.day, "short")}
120 </span>
121 ))}
122 </div>
123 <figcaption className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted">
124 <span className="text-fg-soft" aria-live="polite">
125 {days[shown] ? dayWords(days[shown]) : ""}
126 </span>
127 <span className="ml-auto flex items-center gap-3">
128 <span className="flex items-center gap-1.5">
129 <span className="size-2 rounded-sm bg-accent" />
130 Agents
131 </span>
132 <span className="flex items-center gap-1.5">
133 <span className="size-2 rounded-sm bg-line-strong" />
134 People
135 </span>
136 <span className="text-faint">last week faded</span>
137 </span>
138 </figcaption>
139 </figure>
140 );
141}
142
143/** A finding: what the work says, the evidence after it, where to see it, and Ask. */
144function Finding({ signal, n }: { signal: Signal; n: number | null }) {
145 return (
146 <li className={cn("grid gap-3 border-t border-line px-5 py-4", signal.trend && "md:grid-cols-[minmax(0,1fr)_9rem] md:gap-6")}>
147 <div className="flex min-w-0 gap-3">
148 <span
149 className={cn(
150 "mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full text-[0.6875rem] font-semibold",
151 n != null ? "bg-warn text-bg" : "border border-line-strong text-faint",
152 )}
153 aria-hidden
154 >
155 {n ?? "·"}
156 </span>
157 <div className="min-w-0 space-y-1">
158 <p className="text-sm leading-snug font-medium text-fg">{signal.headline}</p>
159 <p className="text-xs leading-relaxed text-muted">{signal.facts.join(" · ")}</p>
160 <div className="flex flex-wrap items-center gap-x-4 gap-y-1 pt-0.5">
161 {signal.link && (
162 <Link to={signal.link.to} className="inline-flex items-center gap-0.5 text-xs font-medium text-accent hover:underline">
163 {signal.link.label}
164 <ChevronRight size={12} />
165 </Link>
166 )}
167 <button type="button" onClick={() => askG1t(signal.ask)} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
168 <MessageSquare size={11} />
169 Ask g1t
170 </button>
171 </div>
172 </div>
173 </div>
174 {signal.trend && (
175 <div className="hidden md:block">
176 <TrendLine
177 values={signal.trend.values}
178 points={signal.trend.points}
179 label={`${signal.trend.caption}, ${signal.trend.range}`}
180 width={144}
181 height={32}
182 />
183 <p className="mt-1 font-mono text-[0.625rem] text-faint">{signal.trend.range}</p>
184 </div>
185 )}
186 </li>
187 );
188}
189
190/** Findings shown before "N more". */
191const SHOWN = 4;
192
193/** Home's week: its figures, its days with findings pinned, and what is worth a look. */
194export function HomeWeek({ view }: { view: DailySignal | null }) {
195 const [more, setMore] = useState(false);
196 if (view == null) {
197 return (
198 <section aria-labelledby="week" className="rounded-2xl border border-line bg-surface px-5 py-4">
199 <h2 id="week" className="text-base font-semibold tracking-tight text-fg">
200 This week
201 </h2>
202 <p className="mt-2 flex items-center gap-1.5 text-sm text-muted">
203 <TriangleAlert size={13} className="shrink-0 text-warn" />
204 The week&apos;s work couldn&apos;t be read just now.
205 </p>
206 </section>
207 );
208 }
209 // A number for each finding pinned to a day in the chart, in the list's order.
210 const days = new Set(view.days.map((bar) => bar.day));
211 const numbers = new Map<string, number>();
212 const pins = new Map<string, number[]>();
213 for (const signal of view.findings) {
214 if (!signal.day || !days.has(signal.day)) continue;
215 const n = numbers.size + 1;
216 numbers.set(signal.key, n);
217 pins.set(signal.day, [...(pins.get(signal.day) ?? []), n]);
218 }
219 const shown = more ? view.findings : view.findings.slice(0, SHOWN);
220 return (
221 <section aria-labelledby="week" className="rounded-2xl border border-line bg-surface">
222 <header className="flex flex-wrap items-baseline gap-x-3 gap-y-1 px-5 pt-4">
223 <h2 id="week" className="text-base font-semibold tracking-tight text-fg">
224 This week
225 </h2>
226 <p className="text-sm text-muted">{view.summary}</p>
227 </header>
228 {view.tiles.length > 0 && (
229 <div className="mt-2 grid grid-cols-2 gap-1 px-2 lg:grid-cols-4">
230 {view.tiles.map((tile) => (
231 <Figure key={tile.key} tile={tile} />
232 ))}
233 </div>
234 )}
235 <div className="mt-4">
236 <Days days={view.days} pins={pins} />
237 </div>
238 <div className="mt-5">
239 <h3 className="px-5 pb-2 text-xs font-medium text-muted">Worth a look</h3>
240 {view.findings.length === 0 ? (
241 <p className="border-t border-line px-5 py-4 text-sm text-muted">Nothing in the work stands out against its last weeks.</p>
242 ) : (
243 <ul>
244 {shown.map((signal) => (
245 <Finding key={signal.key} signal={signal} n={numbers.get(signal.key) ?? null} />
246 ))}
247 </ul>
248 )}
249 {view.findings.length > SHOWN && (
250 <button type="button" onClick={() => setMore(!more)} className="border-t border-line px-5 py-3 text-left text-xs text-muted hover:text-fg w-full">
251 {more ? "Show fewer" : `${view.findings.length - SHOWN} more worth a look`}
252 </button>
253 )}
254 </div>
255 {!view.complete && (
256 <p className="flex items-center gap-1.5 border-t border-line px-5 py-2.5 text-xs text-faint">
257 <TriangleAlert size={12} className="shrink-0" />
258 Some projects didn&apos;t answer, so the week leaves them out.
259 </p>
260 )}
261 <div className="h-2" />
262 </section>
263 );
264}
265
266export function HomeWeekSkeleton() {
267 return (
268 <section className="space-y-4 rounded-2xl border border-line bg-surface px-5 py-4" aria-busy="true">
269 <Skeleton className="h-5 w-72" />
270 <div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
271 {[0, 1, 2, 3].map((i) => (
272 <Skeleton key={i} className="h-10" />
273 ))}
274 </div>
275 <Skeleton className="h-36 w-full" />
276 </section>
277 );
278}