merge: s8-baselines into release/v1.34.0

This commit is contained in:
Marc-André Bombeck committed 2026-07-27 23:41:34 +02:00
commit b14943ea1b
32 files changed
+2312 -30

No files matched your search

+2
View File
@@ -22,6 +22,7 @@ import {
Activity,
Award,
CalendarClock,
Footprints,
HeartPulse,
MessageCircle,
Pill,
@@ -53,6 +54,7 @@ const KIND_ICON: Record<PriorityItemKind, LucideIcon> = {
milestone: Award,
ecg_new_recording: HeartPulse,
tension_window: Activity,
same_time_baseline: Footprints,
};
/**
@@ -10,20 +10,49 @@ import { Area, AreaChart, ResponsiveContainer, YAxis } from "recharts";
* (`@/components/charts/chart-runtime`); its fixed 40 px container is owned
* by the tile, so the async gap paints an empty band of identical size —
* zero layout shift, pixel-identical once mounted.
*
* Two modes, one chart.
*
* - **Trend** (the original): one series, area-filled under a sentiment
* stroke. Every baseline tile in the vitals grid uses this.
* - **Comparison** (v1.34.0): the same series against a second one, drawn
* index-aligned behind it as a dashed muted line with no fill. The
* same-time baseline needs two trajectories on one pair of axes — today's
* running total and the person's typical one — and nothing in the tree
* drew that. It is a second mode here rather than a new chart family
* because it is the same picture with one more line: same container, same
* fixed height, same shared y-domain, so the two curves are read against
* each other and not against two different scales.
*
* The comparison line renders as an `<Area>` with no fill rather than a
* `<Line>` so the chart stays a plain `AreaChart` and both series contribute
* to the `dataMin`/`dataMax` domain automatically.
*/
export function DeltaSparkline({
data,
strokeVar,
comparisonLabel,
}: {
/** `{ i, v }` points, already length-guarded (≥2) by the tile. */
data: Array<{ i: number; v: number }>;
/**
* `{ i, v }` points, already length-guarded (≥2) by the tile. An optional
* `b` on a point is the comparison series' value at the same index —
* present on every point or on none.
*/
data: Array<{ i: number; v: number; b?: number }>;
/** CSS var expression for the sentiment stroke, e.g. `var(--success)`. */
strokeVar: string;
/**
* Accessible name for the comparison curve, when one is present. Passed
* through to the series so a screen reader reaching the chart is told what
* the second line is rather than meeting an unnamed shape.
*/
comparisonLabel?: string;
}) {
// A stable per-instance id for the gradient <defs>. Deriving it from a
// label slug collides when two tiles share a localized label (the gradient
// fill on the second tile would not resolve); useId() is collision-free.
const fillId = useId();
const hasComparison = data.some((point) => point.b !== undefined);
return (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 2, right: 0, bottom: 2, left: 0 }}>
@@ -34,6 +63,21 @@ export function DeltaSparkline({
</linearGradient>
</defs>
<YAxis hide domain={["dataMin", "dataMax"]} />
{/* The comparison curve sits UNDER the live one so today's line is
never occluded by the reference it is being judged against. */}
{hasComparison ? (
<Area
type="monotone"
dataKey="b"
name={comparisonLabel}
stroke="var(--muted-foreground)"
strokeWidth={1.5}
strokeDasharray="3 3"
fill="none"
isAnimationActive={false}
dot={false}
/>
) : null}
<Area
type="monotone"
dataKey="v"
@@ -53,6 +53,17 @@ export interface SparklineDeltaTileProps {
* small fixed height.
*/
series?: number[];
/**
* v1.34.0 — an optional second curve drawn behind `series` on the same
* axes, index-aligned with it. This is what turns the sparkline into the
* two-trajectory comparison the same-time baseline needs: today's running
* total against the person's typical one. Ignored unless it is exactly as
* long as `series` — two curves of different lengths would be read as
* aligned when they are not, which is worse than one curve.
*/
comparisonSeries?: number[];
/** Accessible name for `comparisonSeries` (e.g. "your typical day"). */
comparisonLabel?: string;
/** Signed delta vs the baseline; drives the arrow + the (Δ) caption. */
delta?: number | null;
/** Whether an up move is good / bad / neutral for this metric. */
@@ -90,6 +101,8 @@ export function SparklineDeltaTile({
unit,
icon: Icon,
series,
comparisonSeries,
comparisonLabel,
delta = null,
directionSentiment = "neutral",
framing,
@@ -117,8 +130,16 @@ export function SparklineDeltaTile({
return `${sign}${fmt.number(Math.abs(d), precision)}`;
};
// The comparison curve only rides along when it lines up point-for-point;
// a mismatched length would draw two curves that look aligned and are not.
const paired =
comparisonSeries && series && comparisonSeries.length === series.length
? comparisonSeries
: null;
const sparkData =
series && series.length >= 2 ? series.map((v, i) => ({ i, v })) : null;
series && series.length >= 2
? series.map((v, i) => (paired ? { i, v, b: paired[i] } : { i, v }))
: null;
// The sparkline tints with the trend sentiment so it reads "same signal"
// as the arrow; neutral metrics ride the muted-foreground line.
@@ -199,7 +220,11 @@ export function SparklineDeltaTile({
className="mt-3 h-10 w-full"
data-slot="sparkline-delta-tile-spark"
>
<DeltaSparkline data={sparkData} strokeVar={strokeVar} />
<DeltaSparkline
data={sparkData}
strokeVar={strokeVar}
comparisonLabel={comparisonLabel}
/>
</div>
) : null}
@@ -178,4 +178,14 @@ export const METRIC_PROVENANCE: Record<DerivedMetricId, MetricProvenanceMeta> =
},
caveatKey: "insights.derived.composite.TRAJECTORY.caveat",
},
SAME_TIME_BASELINE: {
methodKey: "insights.derived.composite.SAME_TIME_BASELINE.method",
standard: {
// Same robust median ± k·MAD estimator as the vitals baseline, applied
// to the window's totals at one local hour rather than to daily means.
name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
url: "https://doi.org/10.1016/j.jesp.2013.03.013",
},
caveatKey: "insights.derived.composite.SAME_TIME_BASELINE.caveat",
},
};
@@ -42,12 +42,25 @@ export const SECTION_MOBILITY: { metric: DerivedMetricId; type: string }[] = [
{ metric: "WRIST_TEMPERATURE_BASELINE", type: "WRIST_TEMPERATURE" },
];
/**
* v1.34.0 — the cumulative metrics that carry a same-time baseline. Order is
* the order the section renders in: steps leads because it is the number
* people actually check, and the one a "how am I doing" question is usually
* about.
*/
export const SECTION_CUMULATIVE: string[] = [
"ACTIVITY_STEPS",
"ACTIVE_ENERGY_BURNED",
"WALKING_RUNNING_DISTANCE",
"FLIGHTS_CLIMBED",
];
/**
* The full set of tokens the overview reads in one batch — the five wellness
* scores + the four derived re-frames + one baseline per vital (minus HRV,
* which has its own balance tile) + the mobility/body metrics. The
* coincident-deviation flag is read by the dedicated "Today's signal" card,
* not here.
* which has its own balance tile) + the mobility/body metrics + one same-time
* baseline per cumulative metric. The coincident-deviation flag is read by the
* dedicated "Today's signal" card, not here.
*/
export function dashboardTokens(): DerivedBatchToken[] {
const tokens: DerivedBatchToken[] = [
@@ -69,6 +82,9 @@ export function dashboardTokens(): DerivedBatchToken[] {
for (const { metric } of SECTION_MOBILITY) {
tokens.push({ metric });
}
for (const type of SECTION_CUMULATIVE) {
tokens.push({ metric: "SAME_TIME_BASELINE", type });
}
return tokens;
}
@@ -23,8 +23,10 @@ import { type DerivedBatchRead } from "./use-derived-metric";
import {
SECTION_VITALS,
SECTION_MOBILITY,
SECTION_CUMULATIVE,
type DashboardDerived,
} from "./use-dashboard-derived";
import { SAME_TIME_BASELINE_MIN_HISTORY_DAYS } from "@/lib/insights/derived/registry";
import { resolveTileLayout, type InsightsLayout } from "@/lib/insights-layout";
import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment";
// Type-only — the compute payloads never drag the server graph into the bundle.
@@ -34,6 +36,7 @@ import type { VascularAgeDeltaValue } from "@/lib/insights/derived/vascular-age"
import type { HrvBalanceValue } from "@/lib/insights/derived/hrv-balance";
import type { BmiValue } from "@/lib/insights/derived/bmi";
import type { SixMinuteWalkValue } from "@/lib/insights/derived/six-minute-walk";
import type { SameTimeBaselineValue } from "@/lib/insights/derived/same-time-baseline";
import type { DerivedProvenance } from "@/lib/insights/derived/types";
/**
@@ -129,6 +132,14 @@ const MOBILITY_TILE_LAYOUT_ID: Record<string, string> = {
WRIST_TEMPERATURE: "wrist-temperature",
};
/** Same-time-baseline `MeasurementType` → layout tile id (routed slug). */
const CUMULATIVE_TILE_LAYOUT_ID: Record<string, string> = {
ACTIVITY_STEPS: "steps",
ACTIVE_ENERGY_BURNED: "active-energy",
WALKING_RUNNING_DISTANCE: "walking-distance",
FLIGHTS_CLIMBED: "flights-climbed",
};
/**
* v1.15.11 W2c — `true` when the layout marks this tile id visible (or the
* layout does not enumerate the id, in which case the tile is always-on). A
@@ -308,6 +319,118 @@ function BaselineTile({
);
}
/**
* v1.34.0 — the same-time baseline tile for one cumulative metric.
*
* Every other tile in this grid frames a latest reading against a band. A
* cumulative metric has no latest reading, so this one frames today's running
* total against the person's own typical total at the same local hour, and the
* sparkline carries both trajectories on one pair of axes rather than one.
*
* The states are the states the rest of the grid already has, with one
* addition that matters more here than anywhere else: a baseline built from
* three days is not a baseline, so below the floor the tile says it is still
* learning and shows how far along it is. It never renders a number it does
* not have the history for.
*/
function SameTimeBaselineTile({
type,
read,
isLoading,
}: TileProps & {
type: string;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const unitDisplay = useUnitDisplay();
const data = read<SameTimeBaselineValue>({
metric: "SAME_TIME_BASELINE",
type,
});
if (isLoading || !data) return null;
const Icon = MEASUREMENT_TYPE_ICONS[type] ?? Footprints;
const labelKey = MEASUREMENT_TYPE_LABEL_KEYS[type];
const label = labelKey ? t(labelKey) : type;
const tileId = CUMULATIVE_TILE_LAYOUT_ID[type] ?? type;
if (data.status === "insufficient") {
// Absent, not gated. No sub-daily rows today (every daily-total source —
// Fitbit, Withings, Polar — is permanently here), an unsupported type, or
// a day whose first hour has not finished yet. None of these is a state
// the user can act on, so the tile does not render at all rather than
// occupying the grid with an apology.
if (data.reason !== "learning_usual_day") return null;
return (
<div
data-slot="same-time-tile"
data-metric={tileId}
data-state="learning"
className="bg-card border-border flex h-full w-full min-w-0 flex-col gap-3 rounded-xl border p-4 md:p-6"
>
<TileHeader
icon={Icon}
title={label}
titleAs="h2"
titleClassName="truncate"
/>
<p
className="text-muted-foreground text-sm"
data-slot="same-time-tile-learning"
>
{t("insights.derived.sameTime.learning", {
count: data.coverage.historyDays,
target: SAME_TIME_BASELINE_MIN_HISTORY_DAYS,
})}
</p>
<CoverageMeter coverage={data.coverage} />
</div>
);
}
const v = data.value!;
const unit = unitDisplay.unitFor(type);
const toShown = (raw: number) => unitDisplay.toDisplay(type, raw);
// A transformed distance reads in kilometres and wants a decimal; a step,
// flight or kilocalorie count is a whole number and a decimal on it is noise.
const precision = unitDisplay.isTransformed(type) ? 1 : 0;
// The comparison is anchored to the END of `asOfHour`, which is the top of
// the following hour on the clock. 24-hour form, built from the number the
// server already resolved — deriving a locale-formatted instant on the
// client from a server-side local hour is how a hydration mismatch starts.
const byClock = `${String(v.asOfHour + 1).padStart(2, "0")}:00`;
return (
<div data-slot="same-time-tile" data-metric={tileId} data-state="ok">
<SparklineDeltaTile
label={label}
value={toShown(v.todayValue)}
unit={unit}
icon={Icon}
series={v.todayCurve.map(toShown)}
comparisonSeries={v.typicalCurve.map(toShown)}
comparisonLabel={t("insights.derived.sameTime.comparisonLabel")}
delta={unitDisplay.toDisplayDelta(type, v.delta)}
directionSentiment="up-good"
precision={precision}
framing={t("insights.derived.sameTime.framing", {
time: byClock,
typical: fmt.number(toShown(v.typicalValue), precision),
unit,
})}
provenance={
<MetricProvenance
metric="SAME_TIME_BASELINE"
provenance={data.provenance}
/>
}
footer={<LearnMoreLink concept={tileId} />}
/>
</div>
);
}
/**
* Estimated 6-minute-walk band tile (`SIX_MINUTE_WALK_BAND` passthrough
* re-frame). Surfaces the device's estimated distance + trend always; the
@@ -632,6 +755,29 @@ function hasRenderableMobility(
return false;
}
/**
* Whether the batch holds at least one same-time tile worth painting. Mirrors
* the tile's own gate exactly: only an `ok` value or the honest "still
* learning" state counts, so the section heading never strands over a grid
* that turned out to be empty (every daily-total source lands there).
*/
function hasRenderableSameTime(
read: DerivedBatchRead,
layout: InsightsLayout | undefined,
): boolean {
for (const type of SECTION_CUMULATIVE) {
if (!tileVisible(layout, CUMULATIVE_TILE_LAYOUT_ID[type] ?? type)) continue;
const data = read<SameTimeBaselineValue>({
metric: "SAME_TIME_BASELINE",
type,
});
if (!data) continue;
if (data.status === "ok") return true;
if (data.reason === "learning_usual_day") return true;
}
return false;
}
export function VitalsDashboard({ batch, layout, className }: DashboardProps) {
const { t } = useTranslations();
@@ -724,6 +870,20 @@ export function VitalsDashboard({ batch, layout, className }: DashboardProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layout, read, isLoading]);
const cumulativeTiles = useMemo(() => {
return SECTION_CUMULATIVE.map((type) => {
const id = CUMULATIVE_TILE_LAYOUT_ID[type] ?? type;
return {
id,
order: tileOrder(layout, id),
node: <SameTimeBaselineTile key={type} type={type} {...tileProps} />,
};
})
.filter((e) => tileVisible(layout, e.id))
.sort((a, b) => a.order - b.order);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layout, read, isLoading]);
// A failed batch (retry:0 + the 8 s client ceiling) must read as an error,
// not as "no data" — otherwise the whole Vitals + Wellness surface silently
// vanishes. Surface a compact message + a Retry that refetches the one
@@ -761,6 +921,7 @@ export function VitalsDashboard({ batch, layout, className }: DashboardProps) {
// pops in below it — the CLS this pass removes.
const showSection = isLoading || hasRenderableVital(read, layout);
const showMobility = !isLoading && hasRenderableMobility(read, layout);
const showSameTime = !isLoading && hasRenderableSameTime(read, layout);
return (
<div
@@ -802,6 +963,30 @@ export function VitalsDashboard({ batch, layout, className }: DashboardProps) {
</div>
</section>
)}
{showSameTime && (
<section
data-slot="vitals-same-time"
aria-label={t("insights.derived.sameTime.sectionTitle")}
className="space-y-3"
>
<SectionHeading
icon={Footprints}
title={t("insights.derived.sameTime.sectionTitle")}
subtitle={t("insights.derived.sameTime.sectionSubtitle")}
/>
<div
data-slot="vitals-same-time-grid"
className={cn(
"grid grid-cols-1 gap-4",
// A lone tile spans the full width rather than orphaning a
// one-third column, matching the sections above it.
cumulativeTiles.length > 1 && "sm:grid-cols-2 lg:grid-cols-3",
)}
>
{cumulativeTiles.map((tile) => tile.node)}
</div>
</section>
)}
{showMobility && (
<section
data-slot="vitals-mobility"