merge: s15-stepsdisplay into release/v1.34.0

This commit is contained in:
Marc-André Bombeck committed 2026-07-27 23:41:34 +02:00
commit 2aeb925e28
27 files changed
+716 -39

No files matched your search

@@ -59,8 +59,8 @@ export function buildCoreMetricsBlocks(
);
const olderSys = sysRows.filter((row) => row.measuredAt < ctx.recentCutoff);
const olderDia = diaRows.filter((row) => row.measuredAt < ctx.recentCutoff);
const sysBand = bpBandFromRows(sysRows);
const diaBand = bpBandFromRows(diaRows);
const sysBand = bpBandFromRows(sysRows, "BLOOD_PRESSURE_SYS");
const diaBand = bpBandFromRows(diaRows, "BLOOD_PRESSURE_DIA");
const usualRange =
sysBand || diaBand
? {
+2 -1
View File
@@ -293,6 +293,7 @@ export function buildDailyBpRows(
*/
export function bpBandFromRows(
rows: ReadonlyArray<{ measuredAt: Date; value: number }>,
type: "BLOOD_PRESSURE_SYS" | "BLOOD_PRESSURE_DIA",
): { low: number; high: number } | null {
const byDay = new Map<string, { sum: number; count: number }>();
for (const r of rows) {
@@ -304,7 +305,7 @@ export function bpBandFromRows(
}
const dayMeans = [...byDay.values()].map((a) => a.sum / a.count);
if (dayMeans.length < 7) return null;
const band = buildBaselineBand(dayMeans);
const band = buildBaselineBand(dayMeans, type);
if (!band) return null;
return { low: Math.round(band.low), high: Math.round(band.high) };
}
+4 -1
View File
@@ -425,7 +425,10 @@ function bandFor(series: VitalSeries): {
spread: number;
} | null {
if (series.baselineDays.length < MIN_BASELINE_DAYS) return null;
const band = buildBaselineBand(series.baselineDays.map((p) => p.mean));
const band = buildBaselineBand(
series.baselineDays.map((p) => p.mean),
series.type,
);
if (!band || band.spread <= 0) return null;
return { center: band.center, spread: band.spread };
}
+19 -6
View File
@@ -8,14 +8,25 @@
* (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: when the series contains no negative values the lower
* bound never dips below 0 — 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.
* - 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 =>
@@ -26,7 +37,9 @@ export function computePaddedYDomain(
const min = Math.min(...finite);
const max = Math.max(...finite);
// Non-negative series never get a negative lower bound.
const clampLower = (lower: number) => (min >= 0 ? Math.max(0, lower) : lower);
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);
@@ -53,7 +53,7 @@ describe("pure statistics", () => {
});
it("buildBaselineBand returns a symmetric MAD band", () => {
const band = buildBaselineBand([50, 52, 54, 56, 58], 3);
const band = buildBaselineBand([50, 52, 54, 56, 58], "PULSE", 3);
expect(band).not.toBeNull();
expect(band!.center).toBe(54);
expect(band!.low).toBeLessThan(band!.center);
@@ -65,8 +65,8 @@ describe("pure statistics", () => {
it("parity: a composed-DAY-bucket series and the raw-DAY series yield the same band", () => {
// Same per-day means whether they arrive as raw rows or DAY rollups.
const perDayMeans = [60, 61, 62, 59, 63, 58, 64];
const fromRaw = buildBaselineBand(perDayMeans, 3);
const fromRollup = buildBaselineBand([...perDayMeans], 3);
const fromRaw = buildBaselineBand(perDayMeans, "PULSE", 3);
const fromRollup = buildBaselineBand([...perDayMeans], "PULSE", 3);
expect(fromRollup).toEqual(fromRaw);
});
});
+19 -2
View File
@@ -32,6 +32,7 @@ import type { MeasurementType, PrismaClient } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
import { toProfileSex } from "@/lib/profile/sex";
import { clampDerivedLowerBound } from "@/lib/measurements/value-domain";
import type { ProfileSex } from "@/lib/profile/sex";
import {
probeRollupCoverage,
@@ -63,7 +64,11 @@ export interface VitalsBaselineValue {
type: MeasurementType;
/** Robust center (median of the per-day means). */
center: number;
/** Band lower edge (center − k·MAD·scale). */
/**
* Band lower edge (center − k·MAD·scale), floored at 0 for a metric whose
* plausibility domain forbids negative values. `spread` below stays the
* unclamped dispersion, so a deviation-in-σ calculation is unaffected.
*/
low: number;
/** Band upper edge (center + k·MAD·scale). */
high: number;
@@ -173,9 +178,20 @@ export function medianAbsoluteDeviation(values: number[]): number {
* caller supplies the already-resolved DAY-native series so this can be
* unit-tested against both a raw-DAY series and a rollup-composed series
* (they must agree: DAY rollup `mean` equals the per-day raw mean).
*
* `type` names the metric the series belongs to and is REQUIRED, not
* optional: `center − spread` is arithmetic, and arithmetic does not know
* that a step, a flight, or a kilocalorie cannot be owed. On a metric with a
* wide relative spread the lower edge crosses zero and the strip underneath
* the chart reads "your usual range is −5,595.6–13,387.6 steps". The floor
* comes from `clampDerivedLowerBound`, which reads the metric's declared
* plausibility domain — the same fact the chart's y-axis clamps against, now
* answered in one place instead of two. Pass `null` only for a series with no
* metric identity; the band then keeps the raw arithmetic edge.
*/
export function buildBaselineBand(
dayMeans: number[],
type: MeasurementType | null,
k: number = DEFAULT_MAD_K,
): Omit<VitalsBaselineValue, "type" | "series"> | null {
if (dayMeans.length === 0) return null;
@@ -185,7 +201,7 @@ export function buildBaselineBand(
const spread = k * mad * MAD_SIGMA_SCALE;
return {
center,
low: center - spread,
low: clampDerivedLowerBound(type, center - spread),
high: center + spread,
spread,
sampleDays: dayMeans.length,
@@ -334,6 +350,7 @@ export async function computeVitalsBaseline(
const band = buildBaselineBand(
points.map((p) => p.mean),
type,
k,
);
if (!band) {
@@ -363,7 +363,7 @@ export function assemblePeriodNarrativeContext(
const { current, prior } = splitByPeriod(points, currentFrom, priorFrom);
if (prior.length < MIN_BASELINE_DAYS) continue;
if (current.length < MIN_COVERED_DAYS_PER_METRIC) continue;
const band = buildBaselineBand(prior);
const band = buildBaselineBand(prior, type);
if (!band) continue;
const center = median(current);
const above = center > band.high;
@@ -454,7 +454,7 @@ function computeCoincidentFlags(
}
}
if (prior.length < MIN_BASELINE_DAYS) continue;
const band = buildBaselineBand(prior);
const band = buildBaselineBand(prior, type);
if (band) bands.set(type, { low: band.low, high: band.high });
}
+20 -3
View File
@@ -28,6 +28,7 @@
*
* Pure: no DB, no clock, no network. Fully unit-testable over injected series.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { buildBaselineBand } from "@/lib/insights/derived/baseline";
/** One DAY-bucket point: a calendar day key (YYYY-MM-DD) + that day's mean. */
@@ -103,10 +104,15 @@ function placeValue(value: number, band: StreakBand): BandPlacement {
* `band` may be supplied (e.g. a prior-period band) — when omitted it is built
* from the whole series via `buildBaselineBand`. A gap day (a calendar day with
* no point between two present days) BREAKS any streak that would span it.
*
* `metricType` names the metric the series belongs to so a self-built band
* floors at zero on a metric that cannot go negative. Omit it only when the
* series genuinely has no metric identity, or when the caller supplies `band`.
*/
export function detectStreak(
series: StreakPoint[],
band?: StreakBand | null,
metricType?: string | null,
): StreakResult {
const empty: StreakResult = {
latestPlacement: null,
@@ -127,7 +133,12 @@ export function detectStreak(
// Resolve the personal band: caller-supplied, else median ± k·MAD over the
// whole series. Null (too little history) → omit rather than guess.
const resolvedBand = band ?? buildBandFromSeries(points.map((p) => p.value));
const resolvedBand =
band ??
buildBandFromSeries(
points.map((p) => p.value),
metricType,
);
if (!resolvedBand) return empty;
// Per-day placement, ascending. A gap day inserts an explicit break marker so
@@ -194,8 +205,14 @@ export function detectStreak(
}
/** Build the personal band from a value series; null when too little data. */
function buildBandFromSeries(values: number[]): StreakBand | null {
const band = buildBaselineBand(values);
function buildBandFromSeries(
values: number[],
metricType: string | null | undefined,
): StreakBand | null {
const band = buildBaselineBand(
values,
(metricType ?? null) as MeasurementType | null,
);
if (!band) return null;
if (!Number.isFinite(band.low) || !Number.isFinite(band.high)) return null;
return { low: band.low, high: band.high };
@@ -0,0 +1,116 @@
import { describe, it, expect } from "vitest";
import {
VALUE_RANGES,
measurementTypeEnum,
} from "@/lib/validations/measurement";
import {
areAllNonNegativeMetrics,
isNonNegativeMetric,
} from "@/lib/measurements/value-domain";
import { buildBaselineBand } from "@/lib/insights/derived/baseline";
import { computePaddedYDomain } from "@/lib/insights/chart-y-domain";
/**
* GUARD — a non-negative metric's baseline `low` is never below zero.
*
* The step sub-page printed "your usual range is −5,595.6–13,387.6 steps"
* because the band's lower edge is `median − k·MAD·scale` and nothing said a
* step cannot be owed. The chart above it already refused a negative axis, so
* the app held both answers at once.
*
* These assertions pin the fixed shape end to end: the fact is declared once
* (the metric's plausibility floor), the band reads it, and the axis reads the
* same one. A series engineered so the spread exceeds the median is the exact
* condition that produced the reported sentence.
*/
/**
* Median 10, MAD 10 → spread = 3 · 10 · 1.4826 ≈ 44.5, i.e. well past the
* centre. The raw arithmetic edge is ≈ −34.5.
*/
const SPREAD_EXCEEDS_CENTRE = [0, 0, 0, 10, 20, 30, 40];
/** The two types whose declared floor is genuinely signed. */
const SIGNED_TYPES = ["ANS_CHARGE", "BODY_TEMPERATURE_DEVIATION"] as const;
describe("non-negative metric guard", () => {
it("every MeasurementType declares a plausibility range", () => {
// The range map is the home of the "can this be negative" fact. A type
// missing from it answers "unknown" and silently loses the floor — and,
// separately, passes any number at all through ingest validation.
const missing = measurementTypeEnum.options.filter(
(type) => VALUE_RANGES[type] === undefined,
);
expect(missing).toEqual([]);
});
it("classifies every type from its own declared floor", () => {
for (const type of measurementTypeEnum.options) {
expect(isNonNegativeMetric(type)).toBe(VALUE_RANGES[type].min >= 0);
}
for (const type of SIGNED_TYPES) {
expect(isNonNegativeMetric(type)).toBe(false);
}
// An unnamed or unknown metric is "unknown", never "non-negative".
expect(isNonNegativeMetric(null)).toBe(false);
expect(isNonNegativeMetric("NOT_A_METRIC")).toBe(false);
expect(areAllNonNegativeMetrics([])).toBe(false);
expect(areAllNonNegativeMetrics(["ACTIVITY_STEPS", "ANS_CHARGE"])).toBe(
false,
);
});
it("never floors the baseline band below zero for a non-negative metric", () => {
const nonNegative = measurementTypeEnum.options.filter((type) =>
isNonNegativeMetric(type),
);
// Sanity: the sweep must actually cover the bulk of the catalogue.
expect(nonNegative.length).toBeGreaterThan(60);
for (const type of nonNegative) {
const band = buildBaselineBand(SPREAD_EXCEEDS_CENTRE, type);
expect(band, type).not.toBeNull();
expect(band!.low, `${type} band low`).toBeGreaterThanOrEqual(0);
// The dispersion itself stays unclamped — a deviation-in-σ calculation
// reads `spread`, and flooring it would understate every excursion.
expect(band!.spread, `${type} spread`).toBeGreaterThan(band!.center);
}
});
it("reproduces the reported step sentence with a non-negative lower edge", () => {
// Roughly the reported shape: a median around 4,000 steps with days near
// zero, which is what pushed the MAD band past the origin.
const dayMeans = [0, 120, 900, 4000, 6200, 9800, 11762];
const band = buildBaselineBand(dayMeans, "ACTIVITY_STEPS");
expect(band!.low).toBe(0);
expect(band!.high).toBeGreaterThan(band!.center);
});
it("keeps the real lower edge for a metric that can genuinely be negative", () => {
for (const type of SIGNED_TYPES) {
const band = buildBaselineBand(SPREAD_EXCEEDS_CENTRE, type);
expect(band!.low, type).toBeLessThan(0);
}
// No metric identity → no invented floor either.
expect(buildBaselineBand(SPREAD_EXCEEDS_CENTRE, null)!.low).toBeLessThan(0);
});
it("gives the chart axis the same answer as the band", () => {
// A derived overlay (trend line, moving average) can extrapolate a step
// count below zero even though no observation ever was. The axis floors
// it because the METRIC cannot be negative, not because the data was.
const domain = computePaddedYDomain([-320, 1200, 8400], ["ACTIVITY_STEPS"]);
expect(domain![0]).toBe(0);
// A signed metric keeps its downward padding.
const signed = computePaddedYDomain([-40, 5, 60], ["ANS_CHARGE"]);
expect(signed![0]).toBeLessThan(0);
// An anonymous series keeps the older data-derived behaviour: padding
// that would dip under an all-positive series stops at 0, a series with
// real negative values keeps the full downward padding.
expect(computePaddedYDomain([0.2, 0.5])![0]).toBe(0);
expect(computePaddedYDomain([-5, 3, 10])![0]).toBeLessThan(0);
});
});
+115
View File
@@ -0,0 +1,115 @@
/**
* Per-metric numeric-domain facts — the ONE home for the two questions the
* derivation and render layers kept answering independently, and therefore
* kept answering differently:
*
* 1. **Can this metric be negative?** The chart's y-axis already refused a
* negative lower bound (`chart-y-domain.ts`) while the personal-baseline
* band underneath the same chart happily printed one, so a step page
* could read "your usual range is −5,595.6–13,387.6 steps". Same app,
* same question, two answers. The fact is a property of the METRIC, not
* of the surface that renders it, so it belongs beside the metric — and
* it is already declared there: `VALUE_RANGES` in
* `@/lib/validations/measurement` is the app-wide plausibility domain
* every ingest path validates against, and its `min` IS the metric's
* floor. Nothing new to maintain; this module only reads it. Two types
* declare a genuinely signed floor (`ANS_CHARGE`,
* `BODY_TEMPERATURE_DEVIATION`) and are correctly excluded.
*
* 2. **Is this metric integer-valued?** A step count has no fractional
* part, so "104.0 steps" is not a rounding preference, it is wrong. This
* one had no home at all — every sub-page passed (or forgot) its own
* `fractionDigits` prop, and the pages that forgot rendered a fraction
* of a step. The set below is the metrics whose values are discrete by
* construction: tallies of events, category flags pinned to 1, closed
* integer instrument scores, and integer ordinal levels.
*
* Deliberately NOT in the integer set: rates and continuous quantities that
* merely tend to be logged as whole numbers (bpm, mmHg, kcal, minutes,
* metres). A mean of 68.4 bpm is a real quantity; a mean of 104.3 steps is a
* real quantity too, but a step is indivisible, so the metric is read as a
* count and rendered as one.
*/
import { VALUE_RANGES } from "@/lib/validations/measurement";
/**
* Decimal places used for a metric with no discreteness constraint — the
* long-standing default of the stat strip and the coach-read strip.
*/
export const DEFAULT_METRIC_FRACTION_DIGITS = 1;
/**
* Metrics whose stored values are whole numbers by construction. Keys are
* `MeasurementType` values; typed as strings so client components can ask
* without importing the Prisma client into the browser bundle.
*/
export const INTEGER_VALUED_MEASUREMENT_TYPES: ReadonlySet<string> = new Set([
// Tallies — a fraction of one of these does not exist.
"ACTIVITY_STEPS",
"FLIGHTS_CLIMBED",
"FALL_COUNT",
"BREATHING_DISTURBANCES",
"SLEEP_DISTURBANCE_COUNT",
// Category events — the row carries a fixed `value = 1` (one fired event).
"AUDIO_EXPOSURE_EVENT",
"BREATHING_DISTURBANCE_EVENT",
"HIGH_HEART_RATE_EVENT",
"IRREGULAR_RHYTHM_NOTIFICATION",
"LOW_HEART_RATE_EVENT",
"WALKING_STEADINESS_EVENT",
// Closed integer instrument scores — the questionnaire sums whole items.
"PAIN_NRS",
"PHQ9_SCORE",
"GAD7_SCORE",
"WHO5_SCORE",
"SCI_SCORE",
// Integer ordinal / rating scales.
"RESILIENCE",
"VISCERAL_FAT",
// Whole years, as the device reports it.
"VASCULAR_AGE",
]);
/**
* True when the metric's own plausibility floor forbids a negative value.
* Unknown types answer `false` — an absent range is an absent fact, never a
* guessed one.
*/
export function isNonNegativeMetric(type: string | null | undefined): boolean {
if (!type) return false;
const range = VALUE_RANGES[type];
return range !== undefined && range.min >= 0;
}
/**
* True when EVERY named metric forbids negative values (and at least one was
* named). Used by the chart, which can paint several series in one axis: a
* single signed series keeps the full downward padding for all of them.
*/
export function areAllNonNegativeMetrics(
types: readonly (string | null | undefined)[],
): boolean {
return types.length > 0 && types.every((type) => isNonNegativeMetric(type));
}
/**
* Clamp a DERIVED lower edge (a baseline band edge, a padded axis bound) to
* the metric's zero floor. Derived edges are arithmetic — median − k·MAD, a
* padded minimum — and arithmetic does not know that a step cannot be owed.
*/
export function clampDerivedLowerBound(
type: string | null | undefined,
lower: number,
): number {
return isNonNegativeMetric(type) ? Math.max(0, lower) : lower;
}
/**
* Decimal places a metric's values are read at. Zero for a discrete metric,
* the shared default otherwise. Surfaces call this instead of hard-coding a
* default, so a page that says nothing still renders the metric correctly.
*/
export function metricFractionDigits(type: string | null | undefined): number {
if (type && INTEGER_VALUED_MEASUREMENT_TYPES.has(type)) return 0;
return DEFAULT_METRIC_FRACTION_DIGITS;
}
+13
View File
@@ -495,6 +495,11 @@ export const VALUE_RANGES: Record<string, { min: number; max: number }> = {
// the ×100 server-side scaling the canonical band is 0..100.
WALKING_ASYMMETRY: { min: 0, max: 100 },
WALKING_DOUBLE_SUPPORT: { min: 0, max: 100 },
// Same ×100-scaled 0..100 percent as its two gait siblings above. The
// entry was simply missing; the map is the app's plausibility domain AND
// (via `value-domain.ts`) the answer to "can this metric be negative",
// so an omission reads as "unknown" on both questions.
WALKING_STEADINESS: { min: 0, max: 100 },
// ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ──
// Step length (metres) — adult walking sits around 0.5–0.8 m; the
// 0.1 floor captures shuffling gait and the 2.0 ceiling covers
@@ -541,6 +546,14 @@ export const VALUE_RANGES: Record<string, { min: number; max: number }> = {
LOW_HEART_RATE_EVENT: { min: 1, max: 1 },
WALKING_STEADINESS_EVENT: { min: 1, max: 1 },
BREATHING_DISTURBANCE_EVENT: { min: 1, max: 1 },
// Loud-listening event flag (env + headphone). One fired event per row, so
// the floor is 1 — a non-event is not stored. The entry was missing from
// the map entirely, which let ANY number through ingest, including a
// negative one. The ceiling stays generous rather than pinned at 1 like the
// five events above: this type is written only by the iOS client and
// nothing in this repo asserts the exact value, so the floor is the part
// that is certain.
AUDIO_EXPOSURE_EVENT: { min: 1, max: 1000 },
// ── v1.10.0 — computed scores (WX-C) ──
// Server-derived 0–100 scores. The plausibility band pins them to the
// score range so a malformed store (a stray negative or > 100) is rejected.