| 1 | /** |
| 2 | * The daily signal on Home (lib/signals.ts): the past week in four |
| 3 | * figures, each against the week before and drawn over ten, and the |
| 4 | * findings worth a look today, each a sentence with its evidence, where |
| 5 | * to see it, and a way to ask g1t about it. |
| 6 | */ |
| 7 | import { ChevronRight, MessageSquare, TriangleAlert } from "lucide-react"; |
| 8 | import { type ReactNode, useId, useState } from "react"; |
| 9 | import { Link } from "react-router"; |
| 10 | |
| 11 | import { askG1t } from "../lib/ask"; |
| 12 | import { cn } from "../lib/cn"; |
| 13 | import type { DailySignal as DailySignalView, PulseTile, Signal } from "../lib/signals"; |
| 14 | import { Skeleton } from "./ui/skeleton"; |
| 15 | |
| 16 | // --- A trend line ------------------------------------------------------------------- |
| 17 | |
| 18 | /** |
| 19 | * A measure over the weeks as a thin line, the latest point marked. Weeks |
| 20 | * with nothing to measure break the line. Pointing at a week (or moving |
| 21 | * along with the arrow keys) says its value through `onPoint`; every |
| 22 | * value is also in a list for screen readers. |
| 23 | */ |
| 24 | export function TrendLine({ |
| 25 | values, |
| 26 | points, |
| 27 | label, |
| 28 | width = 120, |
| 29 | height = 28, |
| 30 | onPoint, |
| 31 | }: { |
| 32 | values: (number | null)[]; |
| 33 | points: string[]; |
| 34 | label: string; |
| 35 | width?: number; |
| 36 | height?: number; |
| 37 | onPoint?: (index: number | null) => void; |
| 38 | }) { |
| 39 | const [active, setActive] = useState<number | null>(null); |
| 40 | const list = useId(); |
| 41 | const known = values.filter((value): value is number => value != null); |
| 42 | if (known.length === 0) return <div style={{ width, height }} aria-hidden />; |
| 43 | const pad = 4; |
| 44 | const min = Math.min(...known); |
| 45 | const max = Math.max(...known); |
| 46 | const step = values.length > 1 ? (width - 2 * pad) / (values.length - 1) : 0; |
| 47 | const x = (i: number) => pad + i * step; |
| 48 | const y = (value: number) => (max === min ? height / 2 : pad + (1 - (value - min) / (max - min)) * (height - 2 * pad)); |
| 49 | // One path, lifted where a week has nothing. |
| 50 | let d = ""; |
| 51 | values.forEach((value, i) => { |
| 52 | if (value == null) return; |
| 53 | d += `${i > 0 && values[i - 1] != null ? "L" : "M"}${x(i).toFixed(1)},${y(value).toFixed(1)}`; |
| 54 | }); |
| 55 | const last = values.length - 1 - [...values].reverse().findIndex((value) => value != null); |
| 56 | const point = (index: number | null) => { |
| 57 | setActive(index); |
| 58 | onPoint?.(index); |
| 59 | }; |
| 60 | const shown = active ?? last; |
| 61 | return ( |
| 62 | <> |
| 63 | <svg |
| 64 | width={width} |
| 65 | height={height} |
| 66 | viewBox={`0 0 ${width} ${height}`} |
| 67 | className="block overflow-visible outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-accent/40" |
| 68 | role="img" |
| 69 | aria-label={label} |
| 70 | aria-describedby={list} |
| 71 | tabIndex={0} |
| 72 | onMouseLeave={() => point(null)} |
| 73 | onBlur={() => point(null)} |
| 74 | onKeyDown={(event) => { |
| 75 | if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; |
| 76 | event.preventDefault(); |
| 77 | const from = active ?? last; |
| 78 | point(Math.max(0, Math.min(values.length - 1, from + (event.key === "ArrowLeft" ? -1 : 1)))); |
| 79 | }} |
| 80 | > |
| 81 | {active != null && <line x1={x(active)} x2={x(active)} y1={0} y2={height} className="stroke-line-strong" strokeWidth={1} />} |
| 82 | <path d={d} fill="none" className="stroke-muted" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" /> |
| 83 | {values[shown] != null && <circle cx={x(shown)} cy={y(values[shown]!)} r={3.5} className="fill-accent" stroke="var(--color-surface)" strokeWidth={2} />} |
| 84 | {values.map((_, i) => ( |
| 85 | <rect key={i} x={x(i) - step / 2} y={-4} width={Math.max(step, 8)} height={height + 8} fill="transparent" onMouseEnter={() => point(i)} /> |
| 86 | ))} |
| 87 | </svg> |
| 88 | <ul id={list} className="sr-only"> |
| 89 | {points.map((text, i) => ( |
| 90 | <li key={i}>{text}</li> |
| 91 | ))} |
| 92 | </ul> |
| 93 | </> |
| 94 | ); |
| 95 | } |
| 96 | |
| 97 | // --- The pulse ---------------------------------------------------------------------- |
| 98 | |
| 99 | const TONE: Record<"good" | "bad" | "flat", string> = { |
| 100 | good: "bg-success/12 text-success", |
| 101 | bad: "bg-danger/10 text-danger", |
| 102 | flat: "bg-line text-muted", |
| 103 | }; |
| 104 | |
| 105 | const TONE_WORDS: Record<"good" | "bad" | "flat", string> = { good: "better than last week", bad: "worse than last week", flat: "the same as last week" }; |
| 106 | |
| 107 | function Tile({ tile }: { tile: PulseTile }) { |
| 108 | const [point, setPoint] = useState<number | null>(null); |
| 109 | return ( |
| 110 | <div className="group relative flex min-w-0 flex-col gap-2 bg-surface px-4 py-3.5 transition-colors hover:bg-raised"> |
| 111 | <Link to={tile.to} className="truncate text-xs text-muted after:absolute after:inset-0 group-hover:text-fg"> |
| 112 | {tile.label} |
| 113 | </Link> |
| 114 | <div className="flex items-center gap-2"> |
| 115 | <span className="text-2xl leading-none font-semibold tracking-tight text-fg">{tile.value}</span> |
| 116 | {tile.delta && ( |
| 117 | <span className={cn("rounded-full px-1.5 py-0.5 text-[0.6875rem] font-medium tabular-nums", TONE[tile.delta.tone])}> |
| 118 | {tile.delta.text} |
| 119 | <span className="sr-only">, {TONE_WORDS[tile.delta.tone]}</span> |
| 120 | </span> |
| 121 | )} |
| 122 | </div> |
| 123 | {/* Above the link's cover, so pointing at a week says it. */} |
| 124 | <div className="relative"> |
| 125 | <TrendLine values={tile.series} points={tile.points} label={`${tile.label} over the last ${tile.series.length} weeks`} width={132} onPoint={setPoint} /> |
| 126 | </div> |
| 127 | <p className="truncate text-[0.6875rem] text-faint">{point != null ? tile.points[point] : tile.hint}</p> |
| 128 | </div> |
| 129 | ); |
| 130 | } |
| 131 | |
| 132 | /** The past week in four figures, across the top of Home. */ |
| 133 | export function PulseStrip({ tiles }: { tiles: PulseTile[] }) { |
| 134 | if (tiles.length === 0) return null; |
| 135 | return ( |
| 136 | <section |
| 137 | aria-label="The past week" |
| 138 | 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))]" |
| 139 | > |
| 140 | <div className="col-span-2 flex items-center bg-surface px-4 py-2 lg:col-span-1 lg:py-0"> |
| 141 | <p className="font-mono text-[0.6875rem] tracking-[0.14em] text-muted uppercase">Past week</p> |
| 142 | </div> |
| 143 | {tiles.map((tile) => ( |
| 144 | <Tile key={tile.key} tile={tile} /> |
| 145 | ))} |
| 146 | </section> |
| 147 | ); |
| 148 | } |
| 149 | |
| 150 | export function PulseSkeleton() { |
| 151 | return ( |
| 152 | <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))]"> |
| 153 | <div className="col-span-2 bg-surface px-4 py-3 lg:col-span-1" /> |
| 154 | {[0, 1, 2, 3].map((i) => ( |
| 155 | <div key={i} className="space-y-2 bg-surface px-4 py-3.5"> |
| 156 | <Skeleton className="h-3 w-28" /> |
| 157 | <Skeleton className="h-6 w-16" /> |
| 158 | <Skeleton className="h-5 w-32" /> |
| 159 | </div> |
| 160 | ))} |
| 161 | </div> |
| 162 | ); |
| 163 | } |
| 164 | |
| 165 | // --- The findings ------------------------------------------------------------------- |
| 166 | |
| 167 | function Facts({ facts }: { facts: string[] }) { |
| 168 | return ( |
| 169 | <p className="font-mono text-[0.75rem] leading-relaxed text-muted"> |
| 170 | {facts.map((fact, i) => ( |
| 171 | <span key={i}> |
| 172 | {i > 0 && <span className="px-1.5 text-faint">·</span>} |
| 173 | {fact} |
| 174 | </span> |
| 175 | ))} |
| 176 | </p> |
| 177 | ); |
| 178 | } |
| 179 | |
| 180 | function Actions({ signal }: { signal: Signal }) { |
| 181 | return ( |
| 182 | <div className="flex flex-wrap items-center gap-x-4 gap-y-1"> |
| 183 | {signal.link && ( |
| 184 | <Link to={signal.link.to} className="inline-flex items-center gap-0.5 text-sm font-medium text-accent hover:underline"> |
| 185 | {signal.link.label} |
| 186 | <ChevronRight size={14} /> |
| 187 | </Link> |
| 188 | )} |
| 189 | <button type="button" onClick={() => askG1t(signal.ask)} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg"> |
| 190 | <MessageSquare size={12} /> |
| 191 | Ask g1t |
| 192 | </button> |
| 193 | </div> |
| 194 | ); |
| 195 | } |
| 196 | |
| 197 | function Trend({ trend }: { trend: NonNullable<Signal["trend"]> }) { |
| 198 | const [point, setPoint] = useState<number | null>(null); |
| 199 | return ( |
| 200 | <figure className="space-y-1.5"> |
| 201 | <TrendLine values={trend.values} points={trend.points} label={`${trend.caption}, ${trend.range}`} width={196} height={40} onPoint={setPoint} /> |
| 202 | <figcaption> |
| 203 | <p className="text-xs text-fg-soft">{trend.caption}</p> |
| 204 | <p className="font-mono text-[0.6875rem] text-faint">{point != null ? trend.points[point] : trend.range}</p> |
| 205 | </figcaption> |
| 206 | </figure> |
| 207 | ); |
| 208 | } |
| 209 | |
| 210 | function Finding({ signal }: { signal: Signal }) { |
| 211 | return ( |
| 212 | <li className={cn("grid gap-4 py-5 first:pt-0 last:pb-0", signal.trend && "md:grid-cols-[minmax(0,1fr)_12.5rem] md:gap-8")}> |
| 213 | <div className="min-w-0 space-y-2"> |
| 214 | <h3 className="font-display text-lg leading-snug text-fg md:text-xl">{signal.headline}</h3> |
| 215 | <Facts facts={signal.facts} /> |
| 216 | <Actions signal={signal} /> |
| 217 | </div> |
| 218 | {signal.trend && ( |
| 219 | <div className="md:pt-1"> |
| 220 | <Trend trend={signal.trend} /> |
| 221 | </div> |
| 222 | )} |
| 223 | </li> |
| 224 | ); |
| 225 | } |
| 226 | |
| 227 | /** Findings under the lead, before "and N more". */ |
| 228 | const SHOWN = 4; |
| 229 | |
| 230 | /** |
| 231 | * The daily signal: the day, the finding that matters most as the page's |
| 232 | * headline, and the others under it. A quiet week still gets a sentence. |
| 233 | * Null `view`: the work service did not answer. |
| 234 | */ |
| 235 | export function DailySignal({ date, view, line, footer }: { date: string; view: DailySignalView | null; line?: ReactNode; footer?: ReactNode }) { |
| 236 | const [more, setMore] = useState(false); |
| 237 | const lead = view?.findings[0] ?? null; |
| 238 | const rest = view?.findings.slice(1) ?? []; |
| 239 | const shown = more ? rest : rest.slice(0, SHOWN); |
| 240 | return ( |
| 241 | <section aria-labelledby="daily-signal" className="rounded-2xl border border-line bg-surface px-5 py-6 md:px-8 md:py-8"> |
| 242 | <p className="font-mono text-[0.6875rem] tracking-[0.16em] text-accent uppercase">Daily signal</p> |
| 243 | <p className="mt-2 font-display text-xl text-fg-soft md:text-2xl">{date}</p> |
| 244 | {view == null ? ( |
| 245 | <> |
| 246 | <h1 id="daily-signal" className="mt-4 font-display text-3xl leading-tight tracking-tight text-fg md:text-[2.5rem]"> |
| 247 | Here's your day. |
| 248 | </h1> |
| 249 | <p className="mt-3 flex items-center gap-1.5 text-sm text-muted"> |
| 250 | <TriangleAlert size={13} className="shrink-0 text-warn" /> |
| 251 | What the work says couldn't be read just now, so today's findings aren't here. |
| 252 | </p> |
| 253 | </> |
| 254 | ) : ( |
| 255 | <> |
| 256 | <h1 |
| 257 | id="daily-signal" |
| 258 | className="mt-4 max-w-3xl font-display text-3xl leading-[1.12] tracking-tight text-balance text-fg md:text-[2.25rem] xl:text-[2.5rem]" |
| 259 | > |
| 260 | {lead?.headline ?? view.quiet} |
| 261 | </h1> |
| 262 | {lead && ( |
| 263 | <div className={cn("mt-4 grid gap-4", lead.trend && "md:grid-cols-[minmax(0,1fr)_12.5rem] md:gap-8")}> |
| 264 | <div className="space-y-2"> |
| 265 | <Facts facts={lead.facts} /> |
| 266 | <Actions signal={lead} /> |
| 267 | </div> |
| 268 | {lead.trend && <Trend trend={lead.trend} />} |
| 269 | </div> |
| 270 | )} |
| 271 | {!view.complete && ( |
| 272 | <p className="mt-3 flex items-center gap-1.5 text-xs text-faint"> |
| 273 | <TriangleAlert size={12} className="shrink-0" /> |
| 274 | Some projects didn't answer, so these findings leave them out. |
| 275 | </p> |
| 276 | )} |
| 277 | </> |
| 278 | )} |
| 279 | {line && <p className="mt-4 text-sm text-muted">{line}</p>} |
| 280 | {shown.length > 0 && ( |
| 281 | <> |
| 282 | <hr className="my-7 border-line" /> |
| 283 | <ul className="divide-y divide-line"> |
| 284 | {shown.map((signal) => ( |
| 285 | <Finding key={signal.key} signal={signal} /> |
| 286 | ))} |
| 287 | </ul> |
| 288 | {rest.length > SHOWN && ( |
| 289 | <button type="button" onClick={() => setMore(!more)} className="mt-5 text-xs text-muted hover:text-fg"> |
| 290 | {more ? "Show fewer" : `${rest.length - SHOWN} more ${rest.length - SHOWN === 1 ? "finding" : "findings"}`} |
| 291 | </button> |
| 292 | )} |
| 293 | </> |
| 294 | )} |
| 295 | {footer && <div className="mt-8 border-t border-line pt-6">{footer}</div>} |
| 296 | </section> |
| 297 | ); |
| 298 | } |
| 299 | |
| 300 | export function DailySignalSkeleton({ date }: { date: string }) { |
| 301 | return ( |
| 302 | <section className="rounded-2xl border border-line bg-surface px-5 py-6 md:px-8 md:py-8"> |
| 303 | <p className="font-mono text-[0.6875rem] tracking-[0.16em] text-accent uppercase">Daily signal</p> |
| 304 | <p className="mt-2 font-display text-xl text-fg-soft md:text-2xl">{date}</p> |
| 305 | <Skeleton className="mt-5 h-9 w-full max-w-2xl" /> |
| 306 | <Skeleton className="mt-2 h-9 w-2/3 max-w-xl" /> |
| 307 | <Skeleton className="mt-5 h-3 w-64" /> |
| 308 | </section> |
| 309 | ); |
| 310 | } |