| 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 | */ |
| 8 | import { ChevronRight, MessageSquare, TriangleAlert } from "lucide-react"; |
| 9 | import { useState } from "react"; |
| 10 | import { Link } from "react-router"; |
| 11 | |
| 12 | import { askG1t } from "../lib/ask"; |
| 13 | import { cn } from "../lib/cn"; |
| 14 | import type { DailySignal, DayBar, PulseTile, Signal } from "../lib/signals"; |
| 15 | import { TrendLine } from "./signals"; |
| 16 | import { Hint } from "./ui/hint"; |
| 17 | import { Skeleton } from "./ui/skeleton"; |
| 18 | |
| 19 | const 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. */ |
| 22 | function 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 | |
| 41 | function 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 | |
| 48 | function 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 | */ |
| 61 | function 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. */ |
| 144 | function 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". */ |
| 191 | const SHOWN = 4; |
| 192 | |
| 193 | /** Home's week: its figures, its days with findings pinned, and what is worth a look. */ |
| 194 | export 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's work couldn'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't answer, so the week leaves them out. |
| 259 | </p> |
| 260 | )} |
| 261 | <div className="h-2" /> |
| 262 | </section> |
| 263 | ); |
| 264 | } |
| 265 | |
| 266 | export 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 | } |