feat: add the depletion meter

A discrete tick-strip rather than a smooth progress bar, borrowed from the
stamina meters these games all use — ticks read as a finite resource visibly
draining, which is the right metaphor for a limited-time event.

An event with no announced end gets a hatched strip, never a full meter.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-15 00:28:45 +02:00
co-authored by Claude Opus 5
parent ac165f0df7
commit ec8cc97c1a
+65
View File
@@ -0,0 +1,65 @@
import type { Urgency } from "../../shared/time.ts";
const TICKS = 24;
export const URGENCY_COLOR: Record<Urgency, string> = {
expired: "var(--color-faint)",
critical: "var(--color-critical)",
soon: "var(--color-soon)",
near: "var(--color-near)",
calm: "var(--color-calm)",
};
interface MeterProps {
/** 01 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 (
<div
className="meter"
role="img"
aria-label={label}
style={{ ["--tick" as string]: URGENCY_COLOR[urgency] }}
>
{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 (
<span
key={i}
className="meter-tick"
data-live={live}
data-unknown={unknown}
style={
animate ? { animationDelay: `${Math.min(i, 12) * 14}ms` } : undefined
}
/>
);
})}
</div>
);
}