Fold the brain and health screens into Home

Home is the one overview now: the brain graph flat across the top, the flow
switches, then the health sections. The /brain and /health routes and their
sidebar entries are gone.

- charts report their cursor and clicks, so hovering one filters the list
  beside it to that minute and a click pins it until Escape or Clear
- chart values round to about three significant digits, the legend mounts
  under the plot so it can wrap without leaving the card, and axis ticks
  shorten past a thousand
- the embedded brain leaves the wheel to the page rather than zooming
- number fields no longer draw their up/down spinner (NOTEPAD)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017MeiWk3Yq12n2pTvnQWYvt
This commit is contained in:
2026-08-17 00:17:14 +02:00
co-authored by Claude Fable 5
parent fe346ea7a3
commit affb0a5f8c
15 changed files with 835 additions and 592 deletions
+83 -13
View File
@@ -4,6 +4,7 @@ import "uplot/dist/uPlot.min.css"
import type { HistoryPoint } from "@/client"
import { useTheme } from "@/components/theme-provider"
import { compact } from "@/lib/utils"
/**
* How many lines one chart carries.
@@ -16,11 +17,8 @@ export const MAX_SERIES = 5
/** Room for the axis ticks; uPlot measures the rest of the box itself. */
const PADDING: uPlot.Padding = [10, 12, 0, 0]
/** The legend sits under the canvas, so the canvas has to leave it room. */
const LEGEND_HEIGHT = 26
const canvasHeight = (element: HTMLElement) =>
Math.max(60, (element.clientHeight || 180) - LEGEND_HEIGHT)
Math.max(60, element.clientHeight || 180)
/** A token, resolved for the canvas — which cannot read CSS variables. */
function token(name: string): string {
@@ -31,6 +29,17 @@ function token(name: string): string {
const seriesColor = (index: number) => token(`--chart-${(index % 5) + 1}`)
/**
* An axis tick, kept short.
*
* The gutter the ticks are drawn in has a fixed width, so a grouped "15,000"
* is clipped to something that reads as a different number entirely.
*/
const tick = (value: number) =>
Math.abs(value) >= 1000
? `${+(value / 1000).toPrecision(3)}k`
: compact(value)
/** The series joined onto one x axis, which is what uPlot draws. */
function table(plots: HistoryPoint[][]): uPlot.AlignedData {
return uPlot.join(
@@ -52,22 +61,39 @@ function table(plots: HistoryPoint[][]): uPlot.AlignedData {
* that arrives. Its own legend doubles as the hover readout, so the cursor
* tells you what each line was worth at that moment — and with more than one
* line a legend is required anyway.
*
* `onCursor` / `onSelect` report where the pointer is in the data, so a page
* can tie a list to the chart without reaching into the uPlot instance.
*/
export function UplotChart({
labels,
plots,
empty = "Nothing has come through yet.",
onCursor,
onSelect,
}: {
/** One label per series; the set of them is the chart's identity. */
labels: string[]
/** The points of each series, in the same order as `labels`. */
plots: HistoryPoint[][]
empty?: string
/** The x value under the pointer, and null once it leaves the plot. */
onCursor?: (ts: number | null) => void
/** The x value clicked, or null for a click that landed on no point. */
onSelect?: (ts: number | null) => void
}) {
const host = useRef<HTMLDivElement>(null)
const legend = useRef<HTMLDivElement>(null)
const chart = useRef<uPlot | null>(null)
// The chart outlives a render, so its handlers are read through a ref
// rather than baked into the config it was built with.
const report = useRef({ onCursor, onSelect })
const { resolvedTheme } = useTheme()
useEffect(() => {
report.current = { onCursor, onSelect }
})
const points = plots.reduce((total, plot) => total + plot.length, 0)
// The identity of the series set: the chart is rebuilt when it changes,
// while a new reading only sets its data.
@@ -88,6 +114,11 @@ export function UplotChart({
ticks: { stroke: () => token("--border"), width: 1 },
font: `11px ${getComputedStyle(element).fontFamily}`,
}
/** The x value the page was last told about, so a move within one bucket
* does not re-render it. */
let told: number | null = null
const under = (self: uPlot) =>
self.cursor.idx == null ? null : Number(self.data[0][self.cursor.idx])
const plot = new uPlot(
{
@@ -95,11 +126,38 @@ export function UplotChart({
height: canvasHeight(element),
padding: PADDING,
cursor: { y: false },
legend: { live: true },
legend: {
live: true,
// Mounted in its own row under the plot rather than inside it: a
// legend that wraps then takes height from the chart instead of
// spilling past the bottom of the card.
mount: (_self, element) => legend.current?.appendChild(element),
},
hooks: {
setCursor: [
(self) => {
const ts = under(self)
if (ts === told) return
told = ts
report.current.onCursor?.(ts)
},
],
ready: [
(self) => {
self.over.addEventListener("click", () =>
report.current.onSelect?.(under(self)),
)
},
],
},
scales: { x: { time: true } },
axes: [
{ ...axis, size: 28 },
{ ...axis, size: 46 },
{
...axis,
size: 46,
values: (_self: uPlot, ticks: number[]) => ticks.map(tick),
},
],
series: [
{},
@@ -109,6 +167,10 @@ export function UplotChart({
// Read at draw time, so a theme toggle is a redraw rather than a
// rebuilt chart.
stroke: () => seriesColor(index),
// The cursor readout is what decides how wide the legend gets, so
// it is rounded here and the unit named in the card's title.
value: (_self: uPlot, raw: number) =>
Number.isFinite(raw) ? compact(raw) : "--",
// Series arrive on their own clocks; a joined table is mostly
// holes, and a line with a hole per point is not a line.
spanGaps: true,
@@ -134,6 +196,9 @@ export function UplotChart({
return () => {
observer.disconnect()
plot.destroy()
// The legend was moved out of the plot's root, so destroying it leaves
// the table behind.
legend.current?.replaceChildren()
chart.current = null
}
}, [key, ready])
@@ -151,13 +216,18 @@ export function UplotChart({
}, [resolvedTheme])
return (
<div className="relative min-h-0 flex-1">
<div ref={host} className="absolute inset-0" />
{points === 0 ? (
<p className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
{empty}
</p>
) : null}
<div className="flex min-h-0 flex-1 flex-col">
<div className="relative min-h-0 flex-1">
<div ref={host} className="absolute inset-0" />
{points === 0 ? (
<p className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
{empty}
</p>
) : null}
</div>
{/* Kept at the legend's resting height, so the plot does not resize
under the pointer the first time a reading arrives. */}
<div ref={legend} className="min-h-6 shrink-0" />
</div>
)
}