# Stat

> Package `@umriss-ui/core`, version 0.40.0. Demo page: <https://romanhaendler.github.io/umriss-ui/core/stat/>. Every page in one line: [llms.txt](https://romanhaendler.github.io/umriss-ui/core/llms.txt); every page in full: [llms-full.txt](https://romanhaendler.github.io/umriss-ui/core/llms-full.txt).

A single figure with its verdict: read against limits, with the history before it, the deviation from its target and how fresh it is (also called a KPI tile or metric card). Reach for it for the few numbers a screen is watched for.

```ts
import { Stat, useFreshness } from "@umriss-ui/core";
```

Demo page: https://romanhaendler.github.io/umriss-ui/core/stat/

The tile gets a rule, not a colour: `limits` are values with a side and a severity, and the colour and the verdict word follow from them. The same limit model drives the charts' limit lines ([ADR-0006](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0006-the-limit-model-lives-in-both-packages.md)).

Four verdicts, in order: in order, unknown, warning, alarm. A missing or non-finite value is unknown, never in order. A `target` is missed by an amount and shown as a signed deviation; it is never a limit.

Freshness is a separate axis: with `asOf` and `ages` the tile says when its value is stale or lost and keeps its verdict, since the last value is the best one there is ([ADR-0010](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0010-a-stale-value-keeps-its-verdict.md)). [`useFreshness`](https://romanhaendler.github.io/umriss-ui/core/api/#useFreshness) gives the same reading for your own parts.

## Examples

### A figure

A `label`, a `value` and its `unit`; without limits the figure is neutral, as it should be for a number nobody judged.

```tsx
import { Stat } from "@umriss-ui/core";

export default function AFigure() {
  return (
    <div style={{ maxWidth: 240 }}>
      <Stat label="Requests per minute · Checkout" value={812} decimals={0} />
    </div>
  );
}
```

### Read a value against its limits

Pass `limits` – a value, a side and a severity each – and the tile takes its colour and its verdict word from them.

```tsx
import { Grid, Stat } from "@umriss-ui/core";
import type { LimitSet } from "@umriss-ui/core";

/** Checkout's latency objective: warn from 240 ms, alarm from 300 ms. */
const CHECKOUT_P95: LimitSet = {
  limits: [
    { value: 240, side: "upper", severity: "warning" },
    { value: 300, side: "upper", severity: "alarm" },
  ],
};

export default function AgainstLimits() {
  return (
    <Grid minItemWidth="200px" gap={4}>
      <Stat label="p95 latency at 08:00" value={187} unit="ms" decimals={0} limits={CHECKOUT_P95} />
      <Stat label="p95 latency at 09:35" value={262} unit="ms" decimals={0} limits={CHECKOUT_P95} />
      <Stat label="p95 latency at 09:50" value={498} unit="ms" decimals={0} limits={CHECKOUT_P95} />
    </Grid>
  );
}
```

### Show a value nobody has

A `null`, missing or non-finite value is judged unknown and says so – never a zero, never “in order”.

```tsx
import { Grid, Stat } from "@umriss-ui/core";
import type { LimitSet } from "@umriss-ui/core";

const ERROR_RATE: LimitSet = {
  limits: [
    { value: 1, side: "upper", severity: "warning" },
    { value: 2, side: "upper", severity: "alarm" },
  ],
};

export default function Unknown() {
  return (
    <Grid minItemWidth="200px" gap={4}>
      <Stat label="Error rate · Sign-in · no data" value={null} unit="%" limits={ERROR_RATE} />
      <Stat label="Error rate · Search · not reported" value={undefined} unit="%" limits={ERROR_RATE} />
      <Stat label="Error rate · Reporting · no requests" value={0 / 0} unit="%" limits={ERROR_RATE} />
    </Grid>
  );
}
```

### Add a history

`history` draws the values before it as a line across the tile - under the figure, or beside it where the tile is wide: the shape of the last hours, not a trend arrow.

```tsx
import { Stat } from "@umriss-ui/core";
import type { LimitSet } from "@umriss-ui/core";

/* Data from the operations world, written out here so the example runs on its own. */
/** A small LCG - the same numbers on every computer. */
function random(seed: number): () => number {
  let s = seed >>> 0;
  return () => {
    s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
    return s / 4294967296;
  };
}

const at = (day: number, hours: number, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();
const MINUTE = 60_000;

/** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
const NOW = at(17, 10, 30);

interface Service {
  id: string;
  name: string;
  team: string;
  /** 1 is customer-facing and pages at night; 3 waits for the morning. */
  tier: 1 | 2 | 3;
  /** The latency objective: the 95th percentile stays below this, in ms. */
  latencySlo: number;
  /** The availability promised for a month, in per cent. */
  availabilityTarget: number;
}

const SERVICES: readonly Service[] = [
  { id: "checkout", name: "Checkout", team: "Payments", tier: 1, latencySlo: 300, availabilityTarget: 99.95 },
  { id: "billing", name: "Billing", team: "Payments", tier: 1, latencySlo: 400, availabilityTarget: 99.9 },
  { id: "sign-in", name: "Sign-in", team: "Identity", tier: 1, latencySlo: 200, availabilityTarget: 99.95 },
  { id: "search", name: "Search", team: "Discovery", tier: 1, latencySlo: 250, availabilityTarget: 99.9 },
  { id: "images", name: "Image service", team: "Discovery", tier: 2, latencySlo: 500, availabilityTarget: 99.5 },
  { id: "notifications", name: "Notifications", team: "Messaging", tier: 2, latencySlo: 800, availabilityTarget: 99.5 },
  { id: "webhooks", name: "Webhooks", team: "Integrations", tier: 2, latencySlo: 1000, availabilityTarget: 99.5 },
  { id: "reports", name: "Reporting", team: "Insights", tier: 3, latencySlo: 2000, availabilityTarget: 99 },
];

interface MetricPoint {
  t: number;
  /** Median and 95th percentile latency, in ms. */
  p50: number;
  p95: number;
  /** Failed requests, in per cent. */
  errorRate: number;
  /** Requests per minute. */
  requests: number;
}

/** Requests per minute at the busiest hour, by tier. */
const VOLUME = { 1: 900, 2: 400, 3: 120 } as const;

/** A service's latency and error rate every five minutes of today, from
    midnight up to now. Busy by day, quiet at night; Checkout's 95th percentile
    climbs well past its objective from 09:40 to 10:10, and its errors with it. */
function metrics(serviceId: string): MetricPoint[] {
  const index = SERVICES.findIndex((one) => one.id === serviceId);
  const service = SERVICES[index];
  if (service === undefined) throw new Error(`No service "${serviceId}".`);
  const r = random(1000 + index * 37);
  const points: MetricPoint[] = [];
  let drift = 0;
  for (let t = at(17, 0); t <= NOW; t += 5 * MINUTE) {
    const hour = (t - at(17, 0)) / (60 * MINUTE);
    const load = 0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16));
    drift = 0.8 * drift + (r() - 0.5) * 0.08;
    const base = service.latencySlo * (0.45 + 0.2 * load + drift);
    const surge = serviceId === "checkout" && t >= at(17, 9, 40) && t < at(17, 10, 10) ? 1.9 : 1;
    const p95 = Math.round(base * surge * (1 + r() * 0.08));
    points.push({
      t,
      p50: Math.round(p95 * (0.42 + r() * 0.06)),
      p95,
      errorRate: Math.round((0.05 + r() * 0.12 + (surge > 1 ? 2.4 + r() : 0)) * 100) / 100,
      requests: Math.round(VOLUME[service.tier] * load * (1 + (r() - 0.5) * 0.1)),
    });
  }
  return points;
}

const CHECKOUT_P95: LimitSet = {
  limits: [
    { value: 240, side: "upper", severity: "warning" },
    { value: 300, side: "upper", severity: "alarm" },
  ],
};

/** The last two hours, every five minutes. */
const LAST = metrics("checkout").slice(-24);

export default function History() {
  return (
    <div style={{ maxWidth: 260 }}>
      <Stat
        label="p95 latency · Checkout"
        value={LAST.at(-1)?.p95}
        unit="ms"
        decimals={0}
        limits={CHECKOUT_P95}
        history={LAST.map((point) => point.p95)}
      />
    </div>
  );
}
```

### Give the history room

A tile of 380 px and more puts its history beside the figures, as high as they are; a narrower one keeps it beneath. The tile decides by its own width.

```tsx
import { Grid, Stat } from "@umriss-ui/core";
import type { LimitSet } from "@umriss-ui/core";

/* Data from the operations world, written out here so the example runs on its own. */
/** A small LCG - the same numbers on every computer. */
function random(seed: number): () => number {
  let s = seed >>> 0;
  return () => {
    s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
    return s / 4294967296;
  };
}

const at = (day: number, hours: number, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();
const MINUTE = 60_000;

/** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
const NOW = at(17, 10, 30);

interface Service {
  id: string;
  name: string;
  team: string;
  /** 1 is customer-facing and pages at night; 3 waits for the morning. */
  tier: 1 | 2 | 3;
  /** The latency objective: the 95th percentile stays below this, in ms. */
  latencySlo: number;
  /** The availability promised for a month, in per cent. */
  availabilityTarget: number;
}

const SERVICES: readonly Service[] = [
  { id: "checkout", name: "Checkout", team: "Payments", tier: 1, latencySlo: 300, availabilityTarget: 99.95 },
  { id: "billing", name: "Billing", team: "Payments", tier: 1, latencySlo: 400, availabilityTarget: 99.9 },
  { id: "sign-in", name: "Sign-in", team: "Identity", tier: 1, latencySlo: 200, availabilityTarget: 99.95 },
  { id: "search", name: "Search", team: "Discovery", tier: 1, latencySlo: 250, availabilityTarget: 99.9 },
  { id: "images", name: "Image service", team: "Discovery", tier: 2, latencySlo: 500, availabilityTarget: 99.5 },
  { id: "notifications", name: "Notifications", team: "Messaging", tier: 2, latencySlo: 800, availabilityTarget: 99.5 },
  { id: "webhooks", name: "Webhooks", team: "Integrations", tier: 2, latencySlo: 1000, availabilityTarget: 99.5 },
  { id: "reports", name: "Reporting", team: "Insights", tier: 3, latencySlo: 2000, availabilityTarget: 99 },
];

interface MetricPoint {
  t: number;
  /** Median and 95th percentile latency, in ms. */
  p50: number;
  p95: number;
  /** Failed requests, in per cent. */
  errorRate: number;
  /** Requests per minute. */
  requests: number;
}

/** Requests per minute at the busiest hour, by tier. */
const VOLUME = { 1: 900, 2: 400, 3: 120 } as const;

/** A service's latency and error rate every five minutes of today, from
    midnight up to now. Busy by day, quiet at night; Checkout's 95th percentile
    climbs well past its objective from 09:40 to 10:10, and its errors with it. */
function metrics(serviceId: string): MetricPoint[] {
  const index = SERVICES.findIndex((one) => one.id === serviceId);
  const service = SERVICES[index];
  if (service === undefined) throw new Error(`No service "${serviceId}".`);
  const r = random(1000 + index * 37);
  const points: MetricPoint[] = [];
  let drift = 0;
  for (let t = at(17, 0); t <= NOW; t += 5 * MINUTE) {
    const hour = (t - at(17, 0)) / (60 * MINUTE);
    const load = 0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16));
    drift = 0.8 * drift + (r() - 0.5) * 0.08;
    const base = service.latencySlo * (0.45 + 0.2 * load + drift);
    const surge = serviceId === "checkout" && t >= at(17, 9, 40) && t < at(17, 10, 10) ? 1.9 : 1;
    const p95 = Math.round(base * surge * (1 + r() * 0.08));
    points.push({
      t,
      p50: Math.round(p95 * (0.42 + r() * 0.06)),
      p95,
      errorRate: Math.round((0.05 + r() * 0.12 + (surge > 1 ? 2.4 + r() : 0)) * 100) / 100,
      requests: Math.round(VOLUME[service.tier] * load * (1 + (r() - 0.5) * 0.1)),
    });
  }
  return points;
}

const P95: LimitSet = {
  limits: [
    { value: 240, side: "upper", severity: "warning" },
    { value: 300, side: "upper", severity: "alarm" },
  ],
};

const ERRORS: LimitSet = {
  limits: [
    { value: 1, side: "upper", severity: "warning" },
    { value: 2, side: "upper", severity: "alarm" },
  ],
};

/** Checkout since six this morning, every five minutes. */
const MORNING = metrics("checkout").slice(-54);

export default function GiveTheHistoryRoom() {
  return (
    <Grid minItemWidth="380px" gap={4}>
      <Stat
        label="p95 latency · Checkout · since 06:00"
        value={MORNING.at(-1)?.p95}
        unit="ms"
        decimals={0}
        limits={P95}
        history={MORNING.map((point) => point.p95)}
      />
      <Stat
        label="Error rate · Checkout · since 06:00"
        value={MORNING.at(-1)?.errorRate}
        unit="%"
        decimals={2}
        limits={ERRORS}
        history={MORNING.map((point) => point.errorRate)}
      />
    </Grid>
  );
}
```

### Measure against a target

A `target` is missed by an amount, never violated: the tile shows the signed deviation beside its verdict from the limits.

```tsx
import { Grid, Stat } from "@umriss-ui/core";
import type { LimitSet } from "@umriss-ui/core";

/** February's spend in k€, against each centre's monthly budget as target; over by 5 % warns, by 15 % alarms. */
function budget(monthly: number): LimitSet {
  return {
    target: monthly,
    limits: [
      { value: monthly * 1.05, side: "upper", severity: "warning" },
      { value: monthly * 1.15, side: "upper", severity: "alarm" },
    ],
  };
}

export default function ATarget() {
  return (
    <Grid minItemWidth="200px" gap={4}>
      <Stat label="Sales · February" value={138.9} unit="k€" decimals={1} limits={budget(142)} />
      <Stat label="Marketing · February" value={74.3} unit="k€" decimals={1} limits={budget(68)} />
      <Stat label="IT · February" value={96.1} unit="k€" decimals={1} limits={budget(83)} />
    </Grid>
  );
}
```

### Show how fresh a value is

With `asOf` and `ages` the tile says when its value turns stale or lost – and keeps its verdict, since the last value is still the best one.

```tsx
import { Grid, Stack, Stat, Text, useFreshness } from "@umriss-ui/core";
import type { FreshnessAges, LimitSet } from "@umriss-ui/core";

/* Distances from the moment the page loaded, so the three always read the same. */
const LOADED = Date.now();
const MIN = 60_000;

/** Stale after two minutes without a new value, lost after ten. */
const AGES: FreshnessAges = { stale: 2 * MIN, lost: 10 * MIN };

const CHECKOUT_P95: LimitSet = {
  limits: [
    { value: 240, side: "upper", severity: "warning" },
    { value: 300, side: "upper", severity: "alarm" },
  ],
};

function Caption({ asOf }: { asOf: number }) {
  const { freshness } = useFreshness(asOf, AGES);
  return (
    <Text size="xs" tone="muted">
      Feed: {freshness}
    </Text>
  );
}

export default function Freshness() {
  return (
    <Grid minItemWidth="200px" gap={4}>
      {[
        { name: "live", asOf: LOADED - 20_000 },
        { name: "delayed", asOf: LOADED - 4 * MIN },
        { name: "cut off", asOf: LOADED - 25 * MIN },
      ].map((feed) => (
        <Stack key={feed.name} gap={2}>
          <Stat label={`p95 latency · ${feed.name}`} value={318} unit="ms" decimals={0} limits={CHECKOUT_P95} asOf={feed.asOf} ages={AGES} />
          <Caption asOf={feed.asOf} />
        </Stack>
      ))}
    </Grid>
  );
}
```

### Watch a kiln zone

One limit set with a target and limits on both sides: the tolerance band between the warnings is derived, never passed.

```tsx
import { Grid, Stat } from "@umriss-ui/core";
import type { LimitSet } from "@umriss-ui/core";

/* Data from the plant world, written out here so the example runs on its own. */

/** A small LCG - reproducible across runs and platforms. */
function random(seed: number): () => number {
  let s = seed >>> 0;
  return () => {
    s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
    return s / 4294967296;
  };
}

/** The early shift, 06:00 to 14:00. */
const SHIFT_MINUTES = 480;

/** The kiln's zone 3, in °C: the numbers every part reads it against, once.
    Between the two warnings lies the tolerance - a tile fired outside it is
    scrap; `returnTo` is the alarm's dead band. */
const KILN = { target: 1200, tolerance: [1185, 1215], alarm: 1230, returnTo: 1222 } as const;

/** The same numbers as the limit set the tile and the verdict read. */
const KILN_LIMITS: LimitSet = {
  target: KILN.target,
  limits: [
    { value: KILN.tolerance[1], side: "upper", severity: "warning" },
    { value: KILN.alarm, side: "upper", severity: "alarm" },
    { value: KILN.tolerance[0], side: "lower", severity: "warning" },
  ],
};

/** The kiln fires one tile every quarter of a minute when nothing holds it up -
    the ideal cycle time of the OEE's performance. */
const IDEAL_CYCLE_MINUTES = 0.25;

/** One minute of the line. */
interface Reading {
  /** Minutes since the start of the shift. */
  minute: number;
  /** Zone 3 of the kiln, in °C. */
  kiln: number;
  /** The exit pyrometer, in °C - `null` while it sends nothing. */
  exit: number | null;
  /** Whether the belt runs. */
  running: boolean;
  /** Tiles out of the kiln in this minute. */
  fired: number;
  /** Of them, the ones fired inside the tolerance. */
  good: number;
}

/** A tile taken off the belt and measured - one every ten minutes while it runs. */
interface Sample {
  minute: number;
  /** The tile's length after firing, in mm (nominal 600). */
  length: number;
}

/** A batch of tiles through the three stations. */
interface Batch {
  id: string;
  name: string;
  /** How many tiles the batch is planned for. */
  tiles: number;
  /** Minutes since the start of the shift, per station. */
  press: readonly [number, number];
  dryer: readonly [number, number];
  kiln: readonly [number, number];
}

interface Plant {
  readings: readonly Reading[];
  samples: readonly Sample[];
  batches: readonly Batch[];
}

/* When the exit pyrometer goes silent, and for how long. Fixed rather than
   seeded, so that a running control room shows its reading turn stale, then
   lost, and come back. */
const SILENT_FROM = 288;
const SILENT_FOR = 50;

/** The shift, from a seed. */
function plant(seed: number): Plant {
  const r = random(seed);

  /* The one excursion: a burner overshoots somewhere between 02:30 and 03:30
     into the shift, for half an hour, peaking near 1242 °C. The operator stops
     the feed twenty-two minutes in, for eighteen minutes. The noise around it
     is too small to reach a warning on its own, so this is the only crossing -
     which is what the tests hold. */
  const surgeFrom = 150 + Math.floor(r() * 60);
  const surgeFor = 30;
  const stopFrom = surgeFrom + 22;
  const stopFor = 18;

  /* The plan: batches through the kiln one after another, each pressed an
     hour and dried half an hour before - the first ones during the night
     shift. Tiles come out of the kiln only while a batch is in it, so the
     OEE's count and the batches' counts are the same tiles. The plan is a
     plan: a stop of the belt does not move it. */
  const batches: Batch[] = [];
  for (let at = 0, i = 0; at < SHIFT_MINUTES; i++) {
    const length = 70 + Math.floor(r() * 25);
    batches.push({
      id: `b-${4121 + i}`,
      name: `B-${4121 + i}`,
      tiles: Math.round(length / IDEAL_CYCLE_MINUTES / 10) * 10,
      press: [at - 60, at - 60 + length],
      dryer: [at - 30, at - 30 + length],
      kiln: [at, at + length],
    });
    at += length + 5;
  }

  const readings: Reading[] = [];
  const samples: Sample[] = [];
  let drift = 0;
  for (let minute = 0; minute < SHIFT_MINUTES; minute++) {
    drift = 0.9 * drift + (r() - 0.5) * 3;
    const into = minute - surgeFrom;
    const surge = into >= 0 && into <= surgeFor ? 42 * Math.sin((Math.PI * into) / surgeFor) : 0;
    const kiln = Math.round((KILN.target + drift + surge) * 10) / 10;

    const running = !(minute >= stopFrom && minute < stopFrom + stopFor);
    const loaded = batches.some((batch) => batch.kiln[0] <= minute && minute < batch.kiln[1]);
    const jam = r() < 0.12;
    const fired = running && loaded ? (jam ? 3 : 4) : 0;
    const inTolerance = kiln >= KILN.tolerance[0] && kiln <= KILN.tolerance[1];
    const silent = minute >= SILENT_FROM && minute < SILENT_FROM + SILENT_FOR;
    const exit = silent ? null : Math.round((kiln - 380 + (r() - 0.5) * 4) * 10) / 10;

    readings.push({ minute, kiln, exit, running, fired, good: inTolerance ? fired : 0 });

    if (running && minute % 10 === 0) {
      /* Hotter shrinks more: the length follows the kiln, so the control
         chart sees the excursion the trend sees. */
      const length = 600 + (KILN.target - kiln) * 0.05 + (r() - 0.5) * 0.6;
      samples.push({ minute, length: Math.round(length * 100) / 100 });
    }
  }

  return { readings, samples, batches };
}

const { readings } = plant(17);

/** The last hour of the shift so far, minute by minute. */
function lastHour(until: number) {
  return readings.filter((one) => one.minute > until - 60 && one.minute <= until).map((one) => one.kiln);
}

export default function AKilnZone() {
  return (
    <Grid minItemWidth="220px" gap={4}>
      {[120, 170, 180].map((minute) => {
        const hour = lastHour(minute);
        return (
          <Stat
            key={minute}
            label={`Kiln K1 · zone 3 · ${String(6 + Math.floor(minute / 60)).padStart(2, "0")}:${String(minute % 60).padStart(2, "0")}`}
            value={hour.at(-1)}
            unit="°C"
            decimals={0}
            limits={KILN_LIMITS}
            history={hour}
          />
        );
      })}
    </Grid>
  );
}
```

### Watch the services that page at night

A row of tiles per service, each read against its own objective, with the last two hours beneath and a freshness for the feed.

```tsx
import { Card, CardBody, CardHeader, Grid, Stat } from "@umriss-ui/core";
import type { FreshnessAges, LimitSet } from "@umriss-ui/core";

/* Data from the operations world, written out here so the example runs on its own. */
/** A small LCG - the same numbers on every computer. */
function random(seed: number): () => number {
  let s = seed >>> 0;
  return () => {
    s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
    return s / 4294967296;
  };
}

const at = (day: number, hours: number, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();
const MINUTE = 60_000;

/** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
const NOW = at(17, 10, 30);

interface Service {
  id: string;
  name: string;
  team: string;
  /** 1 is customer-facing and pages at night; 3 waits for the morning. */
  tier: 1 | 2 | 3;
  /** The latency objective: the 95th percentile stays below this, in ms. */
  latencySlo: number;
  /** The availability promised for a month, in per cent. */
  availabilityTarget: number;
}

const SERVICES: readonly Service[] = [
  { id: "checkout", name: "Checkout", team: "Payments", tier: 1, latencySlo: 300, availabilityTarget: 99.95 },
  { id: "billing", name: "Billing", team: "Payments", tier: 1, latencySlo: 400, availabilityTarget: 99.9 },
  { id: "sign-in", name: "Sign-in", team: "Identity", tier: 1, latencySlo: 200, availabilityTarget: 99.95 },
  { id: "search", name: "Search", team: "Discovery", tier: 1, latencySlo: 250, availabilityTarget: 99.9 },
  { id: "images", name: "Image service", team: "Discovery", tier: 2, latencySlo: 500, availabilityTarget: 99.5 },
  { id: "notifications", name: "Notifications", team: "Messaging", tier: 2, latencySlo: 800, availabilityTarget: 99.5 },
  { id: "webhooks", name: "Webhooks", team: "Integrations", tier: 2, latencySlo: 1000, availabilityTarget: 99.5 },
  { id: "reports", name: "Reporting", team: "Insights", tier: 3, latencySlo: 2000, availabilityTarget: 99 },
];

interface MetricPoint {
  t: number;
  /** Median and 95th percentile latency, in ms. */
  p50: number;
  p95: number;
  /** Failed requests, in per cent. */
  errorRate: number;
  /** Requests per minute. */
  requests: number;
}

/** Requests per minute at the busiest hour, by tier. */
const VOLUME = { 1: 900, 2: 400, 3: 120 } as const;

/** A service's latency and error rate every five minutes of today, from
    midnight up to now. Busy by day, quiet at night; Checkout's 95th percentile
    climbs well past its objective from 09:40 to 10:10, and its errors with it. */
function metrics(serviceId: string): MetricPoint[] {
  const index = SERVICES.findIndex((one) => one.id === serviceId);
  const service = SERVICES[index];
  if (service === undefined) throw new Error(`No service "${serviceId}".`);
  const r = random(1000 + index * 37);
  const points: MetricPoint[] = [];
  let drift = 0;
  for (let t = at(17, 0); t <= NOW; t += 5 * MINUTE) {
    const hour = (t - at(17, 0)) / (60 * MINUTE);
    const load = 0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16));
    drift = 0.8 * drift + (r() - 0.5) * 0.08;
    const base = service.latencySlo * (0.45 + 0.2 * load + drift);
    const surge = serviceId === "checkout" && t >= at(17, 9, 40) && t < at(17, 10, 10) ? 1.9 : 1;
    const p95 = Math.round(base * surge * (1 + r() * 0.08));
    points.push({
      t,
      p50: Math.round(p95 * (0.42 + r() * 0.06)),
      p95,
      errorRate: Math.round((0.05 + r() * 0.12 + (surge > 1 ? 2.4 + r() : 0)) * 100) / 100,
      requests: Math.round(VOLUME[service.tier] * load * (1 + (r() - 0.5) * 0.1)),
    });
  }
  return points;
}

const LOADED = Date.now();
const AGES: FreshnessAges = { stale: 2 * 60_000, lost: 10 * 60_000 };

/** Warn at 80 % of the latency objective, alarm at the objective. */
function latency(service: Service): LimitSet {
  return {
    limits: [
      { value: service.latencySlo * 0.8, side: "upper", severity: "warning" },
      { value: service.latencySlo, side: "upper", severity: "alarm" },
    ],
  };
}

const ERRORS: LimitSet = {
  limits: [
    { value: 1, side: "upper", severity: "warning" },
    { value: 2, side: "upper", severity: "alarm" },
  ],
};

export default function AServiceOverview() {
  return (
    <Grid minItemWidth="320px" gap={4}>
      {SERVICES.filter((service) => service.tier === 1).map((service) => {
        const last = metrics(service.id).slice(-24);
        const now = last.at(-1);
        return (
          <Card key={service.id}>
            <CardHeader eyebrow={service.team} title={service.name} />
            <CardBody>
              <Grid minItemWidth="130px" gap={3}>
                <Stat
                  label="p95 latency"
                  value={now?.p95}
                  unit="ms"
                  decimals={0}
                  limits={latency(service)}
                  history={last.map((point) => point.p95)}
                  asOf={LOADED - 40_000}
                  ages={AGES}
                />
                <Stat
                  label="Error rate"
                  value={now?.errorRate}
                  unit="%"
                  decimals={2}
                  limits={ERRORS}
                  history={last.map((point) => point.errorRate)}
                  asOf={LOADED - 40_000}
                  ages={AGES}
                />
              </Grid>
            </CardBody>
          </Card>
        );
      })}
    </Grid>
  );
}
```

## When to use something else

- A share of a whole, drawn as a bar → Meter
- Only the shape of a history, in a row or a cell → Sparkline
- A value over time with axes and limit lines → `Line` and `LimitLine` from @umriss-ui/charts

## Accessibility

A `group` whose name says what the tile shows: the label, the value with its unit, and where the verdict is not OK its words, as in "p95 latency at 09:50: 498 ms, Alarm limit exceeded". A missing value is named "No value". The verdict also stands in the tile as a word, so the colour is never alone; the history line is hidden from screen readers.

It announces nothing: a value that changes or a verdict that turns is read when a reader reaches the tile. Where a change must be heard at once, say it in an [Alert](https://romanhaendler.github.io/umriss-ui/core/alert/).

## API

### `StatProps`

| Prop | Type | Default | Description |
|---|---|---|---|
| `label` *required* | `string` | — | What is being measured.<br>Shown in: [Configurator](https://romanhaendler.github.io/umriss-ui/core/stat/#configurator-stat), [A figure](https://romanhaendler.github.io/umriss-ui/core/stat/#a-figure), [Read a value against its limits](https://romanhaendler.github.io/umriss-ui/core/stat/#against-limits) and 15 more |
| `value` *required* | `number \| null \| undefined` | — | The measured value. Absent or non-finite gives the verdict `unknown` – and the tile says so, instead of showing a zero or an empty bar.<br>Shown in: [Configurator](https://romanhaendler.github.io/umriss-ui/core/stat/#configurator-stat), [A figure](https://romanhaendler.github.io/umriss-ui/core/stat/#a-figure), [Read a value against its limits](https://romanhaendler.github.io/umriss-ui/core/stat/#against-limits) and 15 more |
| `unit` | `string` | — | The unit as it stands behind the value – „ms", „%", „GB".<br>Shown in: [Configurator](https://romanhaendler.github.io/umriss-ui/core/stat/#configurator-stat), [Read a value against its limits](https://romanhaendler.github.io/umriss-ui/core/stat/#against-limits), [Show a value nobody has](https://romanhaendler.github.io/umriss-ui/core/stat/#unknown) and 14 more |
| `decimals` | `number` | — | Fixed number of decimal places. Without it the notation decides; a metric whose number of digits jumps reads as a jump in the value.<br>Shown in: [Configurator](https://romanhaendler.github.io/umriss-ui/core/stat/#configurator-stat), [A figure](https://romanhaendler.github.io/umriss-ui/core/stat/#a-figure), [Read a value against its limits](https://romanhaendler.github.io/umriss-ui/core/stat/#against-limits) and 12 more |
| `limits` | [`LimitSet`](https://romanhaendler.github.io/umriss-ui/core/stat/#type-LimitSet) | — | The rule the value is read against. Without it the tile is neutral – the honest display of a number for which nobody has named a rule.<br>Shown in: [Read a value against its limits](https://romanhaendler.github.io/umriss-ui/core/stat/#against-limits), [Show a value nobody has](https://romanhaendler.github.io/umriss-ui/core/stat/#unknown), [Add a history](https://romanhaendler.github.io/umriss-ui/core/stat/#history) and 12 more |
| `history` | `readonly number[]` | — | Values in chronological order; yields the history line, across the tile beneath the figures, or beside them in a tile of 380 px and more.<br>Shown in: [Add a history](https://romanhaendler.github.io/umriss-ui/core/stat/#history), [Give the history room](https://romanhaendler.github.io/umriss-ui/core/stat/#give-the-history-room), [Watch a kiln zone](https://romanhaendler.github.io/umriss-ui/core/stat/#a-kiln-zone) and 4 more |
| `asOf` | `Date \| number \| null` | — | When the value was true – not when it was fetched.<br>Shown in: [Show how fresh a value is](https://romanhaendler.github.io/umriss-ui/core/stat/#freshness), [Watch the services that page at night](https://romanhaendler.github.io/umriss-ui/core/stat/#a-service-overview), [Watch a kiln line over a shift (Scenarios)](https://romanhaendler.github.io/umriss-ui/core/#watch-a-kiln-line) |
| `ages` | [`FreshnessAges`](https://romanhaendler.github.io/umriss-ui/core/stat/#type-FreshnessAges) | — | Without ages there is no freshness display and no cadence.<br>Shown in: [Show how fresh a value is](https://romanhaendler.github.io/umriss-ui/core/stat/#freshness), [Watch the services that page at night](https://romanhaendler.github.io/umriss-ui/core/stat/#a-service-overview), [Watch a kiln line over a shift (Scenarios)](https://romanhaendler.github.io/umriss-ui/core/#watch-a-kiln-line) |
| `ref` | `Ref<HTMLDivElement>` | — | Receives the component's root element. From `RefProp`.<br>Shown in: [A row that holds still (Sizes)](https://romanhaendler.github.io/umriss-ui/core/sizes/#a-row-that-holds-still), [An anchored surface (Popover)](https://romanhaendler.github.io/umriss-ui/core/popover/#anchored-surface), [Take the width of the trigger (Popover)](https://romanhaendler.github.io/umriss-ui/core/popover/#width-from-anchor) and 2 more |

Also takes every attribute of `<div>` – without `children`.

### Types on this page

#### `LimitSet`

The set a value is read against.

The target stands beside the limits and is not one: it is missed, never
violated, and it is never the edge of a region.

| Prop | Type | Default | Description |
|---|---|---|---|
| `limits` | `readonly `[`Limit`](https://romanhaendler.github.io/umriss-ui/core/stat/#type-Limit)`[]` | — | In any order; none, one or several.<br>Shown in: [Read a value against its limits](https://romanhaendler.github.io/umriss-ui/core/stat/#against-limits), [Show a value nobody has](https://romanhaendler.github.io/umriss-ui/core/stat/#unknown), [Add a history](https://romanhaendler.github.io/umriss-ui/core/stat/#history) and 12 more |
| `target` | `number` | — | <br>Shown in: [Measure against a target](https://romanhaendler.github.io/umriss-ui/core/stat/#a-target), [Watch a kiln zone](https://romanhaendler.github.io/umriss-ui/core/stat/#a-kiln-zone), [English and German side by side (Language)](https://romanhaendler.github.io/umriss-ui/core/language/#side-by-side) and 3 more |

#### `FreshnessAges`

The two ages at which a value changes its state, in milliseconds. Both
bounds belong to the older state: exactly five minutes old is already
`stale`.

`alt <= lost` is the sensible order. Stated the other way round, the
harder statement still wins - the middle state simply falls away.

| Prop | Type | Default | Description |
|---|---|---|---|
| `stale` *required* | `number` | — | From this age on the value counts as stale, but keeps its verdict.<br>Shown in: [Show how fresh a value is](https://romanhaendler.github.io/umriss-ui/core/stat/#freshness), [Watch the services that page at night](https://romanhaendler.github.io/umriss-ui/core/stat/#a-service-overview), [Watch a kiln line over a shift (Scenarios)](https://romanhaendler.github.io/umriss-ui/core/#watch-a-kiln-line) |
| `lost` *required* | `number` | — | From this age on the connection counts as disconnected.<br>Shown in: [Show how fresh a value is](https://romanhaendler.github.io/umriss-ui/core/stat/#freshness), [Watch the services that page at night](https://romanhaendler.github.io/umriss-ui/core/stat/#a-service-overview), [Watch a kiln line over a shift (Scenarios)](https://romanhaendler.github.io/umriss-ui/core/#watch-a-kiln-line) |

#### `Limit`

A bound: a value, a side, a severity.

No tolerance band as input. A band is derived - two limits of equal
severity on opposite sides enclose one - and as an input type it would
force a `null` on one edge in the one-sided case.

| Prop | Type | Default | Description |
|---|---|---|---|
| `value` *required* | `number` | — | <br>Shown in: [Read a value against its limits](https://romanhaendler.github.io/umriss-ui/core/stat/#against-limits), [Show a value nobody has](https://romanhaendler.github.io/umriss-ui/core/stat/#unknown), [Add a history](https://romanhaendler.github.io/umriss-ui/core/stat/#history) and 12 more |
| `side` *required* | [`Side`](https://romanhaendler.github.io/umriss-ui/core/stat/#type-Side)<br>`"upper" \| "lower"` | — | <br>Shown in: [Read a value against its limits](https://romanhaendler.github.io/umriss-ui/core/stat/#against-limits), [Show a value nobody has](https://romanhaendler.github.io/umriss-ui/core/stat/#unknown), [Add a history](https://romanhaendler.github.io/umriss-ui/core/stat/#history) and 12 more |
| `severity` *required* | [`Severity`](https://romanhaendler.github.io/umriss-ui/core/stat/#type-Severity)<br>`"warning" \| "alarm"` | — | <br>Shown in: [Read a value against its limits](https://romanhaendler.github.io/umriss-ui/core/stat/#against-limits), [Show a value nobody has](https://romanhaendler.github.io/umriss-ui/core/stat/#unknown), [Add a history](https://romanhaendler.github.io/umriss-ui/core/stat/#history) and 12 more |

#### `Side`

Whether a limit is an upper or a lower bound.

```ts
type Side = "upper" | "lower";
```

#### `Severity`

How seriously a violation weighs: one severity that advises and one that
calls for action. Exactly two; for a third distinction one sets a
second limit.

```ts
type Severity = "warning" | "alarm";
```

## Known limits

- No trend arrow: a direction from two noisy points is read as information it does not carry; the history line shows the shape.
- It fetches and polls nothing: freshness is judged from the `asOf` you give it.

What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/).
