Files
HealthLog/src/lib/insights/chart-y-domain.ts
T
Marc-André Bombeck f5c676bbeb fix(insights): give a metric's numeric domain one home
Three defects on one card of the step sub-page, all of them a surface
answering a question that belongs to the metric.

"Your usual range is -5,595.6-13,387.6 steps", under a chart whose own
y-axis refuses to draw a negative step. Same app, same question, two
answers: the axis clamped and the sentence beneath it did not. Both now
read the same fact. `VALUE_RANGES` already declares every metric's
plausibility floor for ingest; `value-domain.ts` reads it and answers
"can this metric be negative" for the padded y-domain and for the median
+/- k.MAD band alike. The band's lower edge floors at zero for a
non-negative metric; `spread` stays the unclamped dispersion so a
deviation-in-sigma calculation is unaffected. `buildBaselineBand` now
takes the metric it is describing as a required argument, so a new caller
cannot quietly reintroduce a negative edge. Two types carried no
plausibility range at all, which let any number through ingest and read
as "unknown" on the negativity question: walking steadiness gets the same
0-100 percent band as its two gait siblings, the loud-listening event a
floor of 1 with a generous ceiling.

"13,387.6 steps" also said steps to a reader of German. Five sub-page
units are English words, not symbols - steps, flights, falls, years, and
a "count" that was a poor label in any language. They now travel as i18n
keys through a new `unitKey` on the metric-page scaffold and resolve in
all six locales. The thirteen real symbols (bpm, mmHg, %, m/s, kg/m2,
...) stay literals; translating a symbol would be the opposite mistake.

And the Min read "104.0 steps". Precision was a per-page prop defaulting
to one decimal, so every page had to remember that its metric is a count
- and the step page did not. It follows the metric now: a discrete metric
(tallies, category events, closed integer instrument scores, ordinal
levels) resolves zero decimals wherever it is rendered, and a page only
passes the prop to override that.
2026-07-27 23:01:33 +02:00

54 lines
2.3 KiB
TypeScript

/**
* Shared Y-axis domain computation for every chart surface (dashboard
* charts, insights sub-page charts, mini charts). One source of truth so
* the padding rules cannot drift per chart.
*
* Padding rules:
* - Flat series (min === max): pad symmetrically by 5% of the magnitude
* (at least 1 unit), with a little extra headroom on top.
* - Otherwise: pad the bottom by 8% of the span (min 0.5) and the top by
* 16% of the span (min 1).
* - Zero clamp: the lower bound never dips below 0 when the charted metrics
* cannot be negative — padding only extends upward or down to 0. Counts,
* steps, weights etc. must not render a negative axis. Series with genuine
* negative values (deltas, differences) keep the full downward padding.
*
* "Cannot be negative" is answered by `areAllNonNegativeMetrics` off the
* metric's own declared plausibility domain — the SAME fact the personal
* baseline band floors against, so the axis and the sentence printed under
* it can no longer disagree. It also covers the derived overlays: a trend
* line extrapolating a step count below zero now clips at the axis floor
* instead of dragging the whole chart into negative territory. The observed
* minimum stays a second, weaker trigger, so a caller that names no type
* (a mini chart over an anonymous series) keeps the data-derived behaviour.
*/
import { areAllNonNegativeMetrics } from "@/lib/measurements/value-domain";
export function computePaddedYDomain(
values: readonly number[],
types: readonly (string | null | undefined)[] = [],
): [number, number] | undefined {
const finite = values.filter(
(value): value is number =>
typeof value === "number" && Number.isFinite(value),
);
if (!finite.length) return undefined;
const min = Math.min(...finite);
const max = Math.max(...finite);
// Non-negative series never get a negative lower bound.
const nonNegative = min >= 0 || areAllNonNegativeMetrics(types);
const clampLower = (lower: number) =>
nonNegative ? Math.max(0, lower) : lower;
if (min === max) {
const delta = Math.max(Math.abs(min) * 0.05, 1);
return [clampLower(min - delta), max + delta * 1.35];
}
const span = max - min;
const paddingBottom = Math.max(span * 0.08, 0.5);
const paddingTop = Math.max(span * 0.16, 1);
return [clampLower(min - paddingBottom), max + paddingTop];
}