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.

160 linesCodeBlameRaw
1/**
2 * The week's figures on the Workspace overview (lib/signals.ts), each
3 * against the week before and drawn over ten, and the trend line they and
4 * Home's week (components/home-week.tsx) draw with.
5 */
6import { useId, useState } from "react";
7import { Link } from "react-router";
8
9import { cn } from "../lib/cn";
10import type { PulseTile } from "../lib/signals";
11import { Skeleton } from "./ui/skeleton";
12
13// --- A trend line -------------------------------------------------------------------
14
15/**
16 * A measure over the weeks as a thin line, the latest point marked. Weeks
17 * with nothing to measure break the line. Pointing at a week (or moving
18 * along with the arrow keys) says its value through `onPoint`; every
19 * value is also in a list for screen readers.
20 */
21export function TrendLine({
22 values,
23 points,
24 label,
25 width = 120,
26 height = 28,
27 onPoint,
28}: {
29 values: (number | null)[];
30 points: string[];
31 label: string;
32 width?: number;
33 height?: number;
34 onPoint?: (index: number | null) => void;
35}) {
36 const [active, setActive] = useState<number | null>(null);
37 const list = useId();
38 const known = values.filter((value): value is number => value != null);
39 if (known.length === 0) return <div style={{ width, height }} aria-hidden />;
40 const pad = 4;
41 const min = Math.min(...known);
42 const max = Math.max(...known);
43 const step = values.length > 1 ? (width - 2 * pad) / (values.length - 1) : 0;
44 const x = (i: number) => pad + i * step;
45 const y = (value: number) => (max === min ? height / 2 : pad + (1 - (value - min) / (max - min)) * (height - 2 * pad));
46 // One path, lifted where a week has nothing.
47 let d = "";
48 values.forEach((value, i) => {
49 if (value == null) return;
50 d += `${i > 0 && values[i - 1] != null ? "L" : "M"}${x(i).toFixed(1)},${y(value).toFixed(1)}`;
51 });
52 const last = values.length - 1 - [...values].reverse().findIndex((value) => value != null);
53 const point = (index: number | null) => {
54 setActive(index);
55 onPoint?.(index);
56 };
57 const shown = active ?? last;
58 return (
59 <>
60 <svg
61 width={width}
62 height={height}
63 viewBox={`0 0 ${width} ${height}`}
64 className="block overflow-visible outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-accent/40"
65 role="img"
66 aria-label={label}
67 aria-describedby={list}
68 tabIndex={0}
69 onMouseLeave={() => point(null)}
70 onBlur={() => point(null)}
71 onKeyDown={(event) => {
72 if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
73 event.preventDefault();
74 const from = active ?? last;
75 point(Math.max(0, Math.min(values.length - 1, from + (event.key === "ArrowLeft" ? -1 : 1))));
76 }}
77 >
78 {active != null && <line x1={x(active)} x2={x(active)} y1={0} y2={height} className="stroke-line-strong" strokeWidth={1} />}
79 <path d={d} fill="none" className="stroke-muted" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
80 {values[shown] != null && <circle cx={x(shown)} cy={y(values[shown]!)} r={3.5} className="fill-accent" stroke="var(--color-surface)" strokeWidth={2} />}
81 {values.map((_, i) => (
82 <rect key={i} x={x(i) - step / 2} y={-4} width={Math.max(step, 8)} height={height + 8} fill="transparent" onMouseEnter={() => point(i)} />
83 ))}
84 </svg>
85 <ul id={list} className="sr-only">
86 {points.map((text, i) => (
87 <li key={i}>{text}</li>
88 ))}
89 </ul>
90 </>
91 );
92}
93
94// --- The pulse ----------------------------------------------------------------------
95
96const TONE: Record<"good" | "bad" | "flat", string> = {
97 good: "bg-success/12 text-success",
98 bad: "bg-danger/10 text-danger",
99 flat: "bg-line text-muted",
100};
101
102const TONE_WORDS: Record<"good" | "bad" | "flat", string> = { good: "better than last week", bad: "worse than last week", flat: "the same as last week" };
103
104function Tile({ tile }: { tile: PulseTile }) {
105 const [point, setPoint] = useState<number | null>(null);
106 return (
107 <div className="group relative flex min-w-0 flex-col gap-2 bg-surface px-4 py-3.5 transition-colors hover:bg-raised">
108 <Link to={tile.to} className="truncate text-xs text-muted after:absolute after:inset-0 group-hover:text-fg">
109 {tile.label}
110 </Link>
111 <div className="flex items-center gap-2">
112 <span className="text-2xl leading-none font-semibold tracking-tight text-fg">{tile.value}</span>
113 {tile.delta && (
114 <span className={cn("rounded-full px-1.5 py-0.5 text-[0.6875rem] font-medium tabular-nums", TONE[tile.delta.tone])}>
115 {tile.delta.text}
116 <span className="sr-only">, {TONE_WORDS[tile.delta.tone]}</span>
117 </span>
118 )}
119 </div>
120 {/* Above the link's cover, so pointing at a week says it. */}
121 <div className="relative">
122 <TrendLine values={tile.series} points={tile.points} label={`${tile.label} over the last ${tile.series.length} weeks`} width={132} onPoint={setPoint} />
123 </div>
124 <p className="truncate text-[0.6875rem] text-faint">{point != null ? tile.points[point] : tile.hint}</p>
125 </div>
126 );
127}
128
129/** The past week in four figures, across the top of Home. */
130export function PulseStrip({ tiles }: { tiles: PulseTile[] }) {
131 if (tiles.length === 0) return null;
132 return (
133 <section
134 aria-label="The past week"
135 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))]"
136 >
137 <div className="col-span-2 flex items-center bg-surface px-4 py-2 lg:col-span-1 lg:py-0">
138 <p className="font-mono text-[0.6875rem] tracking-[0.14em] text-muted uppercase">Past week</p>
139 </div>
140 {tiles.map((tile) => (
141 <Tile key={tile.key} tile={tile} />
142 ))}
143 </section>
144 );
145}
146
147export function PulseSkeleton() {
148 return (
149 <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))]">
150 <div className="col-span-2 bg-surface px-4 py-3 lg:col-span-1" />
151 {[0, 1, 2, 3].map((i) => (
152 <div key={i} className="space-y-2 bg-surface px-4 py-3.5">
153 <Skeleton className="h-3 w-28" />
154 <Skeleton className="h-6 w-16" />
155 <Skeleton className="h-5 w-32" />
156 </div>
157 ))}
158 </div>
159 );
160}