| 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 | */ |
| 6 | import { useId, useState } from "react"; |
| 7 | import { Link } from "react-router"; |
| 8 | |
| 9 | import { cn } from "../lib/cn"; |
| 10 | import type { PulseTile } from "../lib/signals"; |
| 11 | import { 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 | */ |
| 21 | export 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 | |
| 96 | const 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 | |
| 102 | const TONE_WORDS: Record<"good" | "bad" | "flat", string> = { good: "better than last week", bad: "worse than last week", flat: "the same as last week" }; |
| 103 | |
| 104 | function 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. */ |
| 130 | export 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 | |
| 147 | export 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 | } |