diff --git a/src/client/components/Meter.tsx b/src/client/components/Meter.tsx new file mode 100644 index 0000000..134fe71 --- /dev/null +++ b/src/client/components/Meter.tsx @@ -0,0 +1,65 @@ +import type { Urgency } from "../../shared/time.ts"; + +const TICKS = 24; + +export const URGENCY_COLOR: Record = { + expired: "var(--color-faint)", + critical: "var(--color-critical)", + soon: "var(--color-soon)", + near: "var(--color-near)", + calm: "var(--color-calm)", +}; + +interface MeterProps { + /** 0–1 through the event's own window. Null when the end is unannounced. */ + progress: number | null; + urgency: Urgency; + /** Suppresses the entry animation for long lists. */ + animate?: boolean | undefined; + label: string; +} + +/** + * The depletion meter — this interface's signature element. + * + * A discrete tick-strip rather than a smooth bar, borrowed from the stamina + * meters these games all use. Ticks read as a countable resource in a way a + * continuous bar does not, which is the right metaphor: what is left is + * finite and visibly draining. + * + * An event with no announced end gets a hatched strip instead of a filled one. + * It must not look like a full meter — "we don't know" and "loads of time" are + * different facts and conflating them is the failure this product avoids. + */ +export function Meter({ progress, urgency, animate = true, label }: MeterProps) { + const unknown = progress === null; + const remainingTicks = unknown + ? 0 + : Math.max(0, Math.round((1 - progress) * TICKS)); + + return ( +
+ {Array.from({ length: TICKS }, (_, i) => { + // Ticks drain from the right, so the surviving run sits at the left + // edge and rows align into a readable ramp down the list. + const live = i < remainingTicks; + return ( + + ); + })} +
+ ); +}