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"
+124
View File
@@ -10,6 +10,7 @@ import {
MAX_WORTH_A_LOOK,
type DailyDigestCoachPlan,
type DailyDigestInput,
type DailyDigestSameTime,
} from "@/lib/daily/digest";
import {
COACH_CHECKIN_KEEP_INTENT,
@@ -21,6 +22,7 @@ import type { PriorityItem } from "@/lib/daily/priority-item";
import {
ecgItemKey,
milestoneItemKey,
sameTimeBaselineItemKey,
tensionWindowItemKey,
} from "@/lib/daily/priority-item-key";
@@ -823,6 +825,128 @@ describe("buildDailyDigest — S11 tension_window item", () => {
});
});
describe("buildDailyDigest — same_time_baseline item", () => {
const sameTimeItem = (d: ReturnType<typeof buildDailyDigest>) =>
d.worthALook.find((i) => i.kind === "same_time_baseline");
function sameTime(
over: Partial<DailyDigestSameTime> = {},
): DailyDigestSameTime {
return {
type: "ACTIVITY_STEPS",
band: "below",
asOfHour: 20,
todayValue: 1834,
typicalValue: 5111,
todayLabel: "1,834",
typicalLabel: "5,111",
...over,
};
}
it("names both totals and the hour they are compared at", () => {
const d = buildDailyDigest(input({ sameTime: sameTime() }), t);
const item = sameTimeItem(d);
expect(item).toBeDefined();
expect(item?.status).toBe("info");
// The comparison covers everything through the END of hour 20, which
// reads on the clock as 21:00.
expect(item?.body).toContain("21:00");
expect(item?.body).toContain("1,834");
expect(item?.body).toContain("5,111");
expect(item?.actions[0].intent).toBe("steps.view");
expect(item?.actions[0].href).toBe("/insights/steps");
});
it("says nothing about a day that is tracking its own normal", () => {
// A rail that reports "exactly as usual" every afternoon teaches people to
// stop reading the rail.
const d = buildDailyDigest(
input({ sameTime: sameTime({ band: "within" }) }),
t,
);
expect(sameTimeItem(d)).toBeUndefined();
});
it("says nothing when the engine returned no comparison", () => {
expect(
sameTimeItem(buildDailyDigest(input({ sameTime: null }), t)),
).toBeUndefined();
expect(sameTimeItem(buildDailyDigest(input({}), t))).toBeUndefined();
});
it("stays silent when the insights module is off", () => {
const d = buildDailyDigest(
input({ modules: { insights: false }, sameTime: sameTime() }),
t,
);
expect(sameTimeItem(d)).toBeUndefined();
});
it("uses a different sentence when today is ahead", () => {
const below = buildDailyDigest(input({ sameTime: sameTime() }), t);
const above = buildDailyDigest(
input({ sameTime: sameTime({ band: "above" }) }),
t,
);
expect(sameTimeItem(above)?.title).not.toBe(sameTimeItem(below)?.title);
expect(sameTimeItem(above)?.body).not.toBe(sameTimeItem(below)?.body);
});
it("keys the dismissal by day and metric, never by the hour", () => {
// Folding the hour in would undo the dismissal every sixty minutes.
const d = buildDailyDigest(
input({ sameTime: sameTime(), todayLocalDate: "2026-07-16" }),
t,
);
expect(sameTimeItem(d)?.itemKey).toBe(
sameTimeBaselineItemKey("2026-07-16", "ACTIVITY_STEPS"),
);
// The same key whatever the clock says — that is the whole point.
const later = buildDailyDigest(
input({
sameTime: sameTime({ asOfHour: 9 }),
todayLocalDate: "2026-07-16",
}),
t,
);
expect(sameTimeItem(later)?.itemKey).toBe(sameTimeItem(d)?.itemKey);
});
it("is dropped once dismissed, and stays dropped as the hour moves on", () => {
const dismissed = new Set([
sameTimeBaselineItemKey("2026-07-16", "ACTIVITY_STEPS"),
]);
for (const asOfHour of [9, 14, 20]) {
const d = buildDailyDigest(
input({
sameTime: sameTime({ asOfHour }),
todayLocalDate: "2026-07-16",
dismissedItemKeys: dismissed,
}),
t,
);
expect(sameTimeItem(d)).toBeUndefined();
}
});
it("yields the bounded rail to time-sensitive actions first", () => {
const d = buildDailyDigest(
input({
medsToday: meds({ nextDueOverdue: true, nextDueMedicationName: "X" }),
syncIssues: [
{ integration: "withings", state: "error_reauth" },
{ integration: "nightscout", state: "parked" },
],
sameTime: sameTime(),
}),
t,
);
expect(d.worthALook).toHaveLength(MAX_WORTH_A_LOOK);
expect(sameTimeItem(d)).toBeUndefined();
});
});
describe("buildDailyDigest — ecg_new_recording (S10)", () => {
const ecgItem = (d: ReturnType<typeof buildDailyDigest>) =>
d.worthALook.find((i) => i.kind === "ecg_new_recording");
+96
View File
@@ -31,6 +31,7 @@ import {
import {
ecgItemKey,
milestoneItemKey,
sameTimeBaselineItemKey,
tensionWindowItemKey,
} from "@/lib/daily/priority-item-key";
import {
@@ -127,6 +128,37 @@ export interface DailyDigestTensionWindow {
* DTO carries no samples). The builder decides "new" from `recordedAt`; the
* card attributes any verdict to the recording device.
*/
/**
* v1.34.0 — the day's same-time activity read, already computed and gated by
* `computeSameTimeBaseline`. The builder formats it and decides nothing: the
* band verdict, the two totals and the hour are the server's, and the copy is
* a deterministic template over them.
*
* Null whenever the engine returned its `insufficient` arm — still learning
* the usual day, no intraday rows today, or the day too young to compare. The
* rail stays quiet in every one of those cases rather than guessing.
*/
export interface DailyDigestSameTime {
/** The cumulative metric compared (`ACTIVITY_STEPS`). */
type: string;
/** Where today sits against the typical band at this hour. */
band: "below" | "within" | "above";
/** The last completed local hour the comparison is anchored to (0–23). */
asOfHour: number;
/** Today's running total through the end of `asOfHour`, already rounded. */
todayValue: number;
/** The typical total at the same hour, already rounded. */
typicalValue: number;
/**
* The same two figures grouped for the reader's locale. The copy
* interpolates these because the server translator substitutes raw strings
* and "1834 steps" is not how anybody writes it; the numbers above stay so a
* consumer that wants to compute rather than read still can.
*/
todayLabel: string;
typicalLabel: string;
}
export interface DailyDigestEcg {
recordedAt: Date;
/** The recording device's OWN verdict, or null when unclassified. */
@@ -213,6 +245,13 @@ export interface DailyDigestInput {
milestone?: Milestone | null;
/** S11 — the day's detected elevated-at-rest window, or null (honest-absent). */
tensionWindow: DailyDigestTensionWindow | null;
/**
* v1.34.0 — today's cumulative-activity read against this person's own
* typical total at the same local hour, or null. Optional so a consumer that
* predates it stays valid; the builder treats a missing value as "nothing to
* say about today's activity yet".
*/
sameTime?: DailyDigestSameTime | null;
/**
* S10 — the freshest ECG recording (device verdict + recordedAt only), or
* null. Optional so consumers that predate the ECG weave stay valid; the
@@ -514,6 +553,53 @@ function buildTensionWindowItem(
};
}
/**
* v1.34.0 — the same-time activity card.
*
* A cumulative metric has no meaningful latest reading, so this is the one
* rail item whose whole content is a comparison: today's running total against
* the person's own typical total at this hour. Every number in it was computed
* and gated server-side; the builder only chooses which of two sentences to
* render and fills the blanks.
*
* It speaks only when today is actually OUTSIDE the typical band. A day
* tracking its own normal is not worth a card, and a rail that says "you are
* doing exactly what you usually do" every afternoon teaches people to stop
* reading the rail.
*/
function buildSameTimeBaselineItem(
sameTime: DailyDigestSameTime | null | undefined,
modules: DigestModuleMap,
todayLocalDate: string,
t: Translate,
): PriorityItem | null {
if (!moduleEnabled(modules, "insights")) return null;
if (!sameTime) return null;
if (sameTime.band === "within") return null;
// The comparison is against everything accumulated through the END of
// `asOfHour`, which reads on the clock as the top of the next hour.
const time = `${String(sameTime.asOfHour + 1).padStart(2, "0")}:00`;
return {
kind: "same_time_baseline",
itemKey: sameTimeBaselineItemKey(todayLocalDate, sameTime.type),
title: t(`daily.item.sameTimeBaseline.title.${sameTime.band}`),
body: t(`daily.item.sameTimeBaseline.body.${sameTime.band}`, {
time,
today: sameTime.todayLabel,
typical: sameTime.typicalLabel,
}),
status: "info",
actions: [
{
labelKey: "daily.action.viewSteps",
intent: "steps.view",
href: "/insights/steps",
},
],
moduleKey: "insights",
};
}
/**
* The instant a plan's check-in came due (§2.3). A coach-pinned `reviewDate`
* wins. Otherwise, a `reviewed` plan lost its `reviewDate` to the daily sweep
@@ -779,6 +865,16 @@ export function buildDailyDigest(
t,
);
if (tension) worthALook.push(tension);
// v1.34.0 — the same-time activity read sits last for the same reason the
// tension marker does: it is context about the day so far, not an action
// that expires, so anything time-sensitive wins the bounded rail ahead of it.
const sameTime = buildSameTimeBaselineItem(
input.sameTime,
input.modules,
input.todayLocalDate,
t,
);
if (sameTime) worthALook.push(sameTime);
// Defence-in-depth: no card ever exceeds the P1 action cap.
for (const item of worthALook) {
+62 -5
View File
@@ -33,12 +33,14 @@ import {
type DailyDigestEcg,
type DailyDigestPreventiveDue,
type DailyDigestScore,
type DailyDigestSameTime,
type DailyDigestSyncIssue,
type DailyDigestTensionWindow,
} from "@/lib/daily/digest";
import {
ecgItemKey,
milestoneItemKey,
sameTimeBaselineItemKey,
tensionWindowItemKey,
} from "@/lib/daily/priority-item-key";
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
@@ -52,6 +54,7 @@ import {
type Milestone,
} from "@/lib/daily/milestones";
import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io";
import { computeSameTimeBaseline } from "@/lib/insights/derived/same-time-baseline";
/** Integration states that mean "your action is needed to keep data flowing". */
const SYNC_ISSUE_STATES = ["error_reauth", "parked"] as const;
@@ -166,8 +169,25 @@ const DIGEST_EXTRAS_CACHE_TTL_MS = DASHBOARD_REFETCH_INTERVAL_MS + 60_000;
interface DailyDigestExtras {
milestone: Milestone | null;
tensionWindow: DailyDigestTensionWindow | null;
/**
* The cached cell holds the same-time read WITHOUT its locale-formatted
* labels: the cell is keyed on (user, local day) and a locale-specific
* string in it would be served to a reader who has since switched language.
* The labels are grouped at the call site, where the locale is resolved.
*/
sameTime: Omit<DailyDigestSameTime, "todayLabel" | "typicalLabel"> | null;
}
/**
* v1.34.0 — the rail's same-time read is STEPS only.
*
* All four cumulative metrics carry a same-time baseline and all four have a
* tile in the Insights grid. The rail is a glance of at most three items, so
* putting four activity cards on it would crowd out an overdue dose to say the
* same thing four ways. Steps is the number people actually check.
*/
const SAME_TIME_RAIL_TYPE = "ACTIVITY_STEPS";
function digestExtrasCacheKey(userId: string, dateKey: string): string {
return `${userId}|daily-digest-extras|${dateKey}`;
}
@@ -186,13 +206,35 @@ async function loadDailyDigestExtrasCached(
// revision ran them sequentially). Each is already fault-isolated
// internally (a milestone read-hiccup or a tension-read failure leaves
// its own field quiet rather than breaking the other).
const [milestone, tensionWindow] = await Promise.all([
const [milestone, tensionWindow, sameTime] = await Promise.all([
gatherFreshMilestone(userId, now),
loadIntradayPulse(userId, timezone, todayLocalDate)
.then((r) => (r.tension ? { partOfDay: r.tension.partOfDay } : null))
.catch(() => null),
// The engine gates itself — it returns its `insufficient` arm while it
// is still learning the usual day, when the account has no intraday
// rows at all (every daily-total source), and before the day's first
// hour has finished. Only the `ok` arm reaches the rail. Passing no
// profile because the engine reads none; fault-isolated like its
// siblings, since the digest is a must-not-fail path.
computeSameTimeBaseline(userId, null, {
type: SAME_TIME_RAIL_TYPE,
now,
})
.then((d): DailyDigestExtras["sameTime"] =>
d.status === "ok"
? {
type: d.value.type,
band: d.value.band,
asOfHour: d.value.asOfHour,
todayValue: Math.round(d.value.todayValue),
typicalValue: Math.round(d.value.typicalValue),
}
: null,
)
.catch(() => null),
]);
return { milestone, tensionWindow };
return { milestone, tensionWindow, sameTime };
},
annotate,
DIGEST_EXTRAS_CACHE_TTL_MS,
@@ -435,14 +477,14 @@ export async function loadDailyDigest(
// per-sample tension reads on every request — see its docblock for the
// cache/invalidation contract.
const insightsEnabled = modules.insights !== false;
const { milestone, tensionWindow } = insightsEnabled
const { milestone, tensionWindow, sameTime } = insightsEnabled
? await loadDailyDigestExtrasCached(
user.id,
user.timezone,
todayLocalDate,
now,
)
: { milestone: null, tensionWindow: null };
: { milestone: null, tensionWindow: null, sameTime: null };
// Dismiss ledger (P — Today rail dismiss). Only the OBSERVATIONAL kinds
// (milestone / ecg_new_recording / tension_window) can ever be dismissed, so
@@ -454,6 +496,7 @@ export async function loadDailyDigest(
tensionWindow
? tensionWindowItemKey(todayLocalDate, tensionWindow.partOfDay)
: null,
sameTime ? sameTimeBaselineItemKey(todayLocalDate, sameTime.type) : null,
].filter((k): k is string => k !== null);
const dismissedRows =
@@ -465,7 +508,20 @@ export async function loadDailyDigest(
: [];
const dismissedItemKeys = new Set(dismissedRows.map((r) => r.itemKey));
const { t } = getServerTranslator(resolveLocale(locale));
const resolvedLocale = resolveLocale(locale);
const { t } = getServerTranslator(resolvedLocale);
// Group the two figures for the reader. `Intl.NumberFormat` is the only
// locale-aware step the digest takes on its own; everything else it renders
// comes through the translator.
const groupNumber = new Intl.NumberFormat(resolvedLocale).format;
const sameTimeItem: DailyDigestSameTime | null = sameTime
? {
...sameTime,
todayLabel: groupNumber(sameTime.todayValue),
typicalLabel: groupNumber(sameTime.typicalValue),
}
: null;
const digest = buildDailyDigest(
{
@@ -481,6 +537,7 @@ export async function loadDailyDigest(
coachPlans,
milestone,
tensionWindow,
sameTime: sameTimeItem,
latestEcg,
todayLocalDate,
dismissedItemKeys,
+16
View File
@@ -39,3 +39,19 @@ export function tensionWindowItemKey(
): string {
return `tension_window:${localDayKey}:${partOfDay}`;
}
/**
* `same_time_baseline:<localDayKey>:<MeasurementType>` — one key per metric
* per local day.
*
* Deliberately NOT keyed on the hour the comparison was anchored to. The card
* refreshes as the day goes on, and folding the hour into the key would mean a
* dismissal at 14:00 was undone at 15:00 — the user would have to swat the
* same card away nine times before bed. Dismissing it means "not today".
*/
export function sameTimeBaselineItemKey(
localDayKey: string,
type: string,
): string {
return `same_time_baseline:${localDayKey}:${type}`;
}
+4 -1
View File
@@ -36,13 +36,15 @@ export const PRIORITY_ITEM_KINDS = [
"milestone",
"ecg_new_recording",
"tension_window",
"same_time_baseline",
] as const;
export type PriorityItemKind = (typeof PRIORITY_ITEM_KINDS)[number];
/**
* The OBSERVATIONAL kinds — a durable-state reward, a new-ECG pointer, an
* elevated-at-rest window. These carry no pending action; the user can only
* elevated-at-rest window, a same-time activity read. These carry no pending
* action; the user can only
* acknowledge them, so they're the only kinds the Today rail lets you
* dismiss. The remaining kinds (`dose_window`, `sync_issue`,
* `preventive_care`, `coach_checkin`) are ACTIONABLE — they clear on their
@@ -54,6 +56,7 @@ export const DISMISSIBLE_PRIORITY_ITEM_KINDS = [
"milestone",
"ecg_new_recording",
"tension_window",
"same_time_baseline",
] as const satisfies readonly PriorityItemKind[];
export type DismissiblePriorityItemKind =
+5
View File
@@ -49,6 +49,11 @@ export const WIPE_MODELS = [
// The rollup tier is a per-day, per-metric reconstruction of the same
// history. A wipe that leaves it behind is not a wipe.
"MeasurementRollup",
// The intraday cumulative profile is the hourly shape of a day that the
// fold kept after deleting the samples it came from. It is that day's
// record and nothing else holds it, so a wipe that leaves it behind leaves
// behind an account's activity pattern.
"IntradayCumulativeProfile",
"MeasurementReminder",
// ── Medication ──────────────────────────────────────────────────────────
+6
View File
@@ -49,6 +49,12 @@
export const BACKED_UP_MODELS = [
// ── Measurements ──────────────────────────────────────────────────────────
"Measurement",
// Not derived, despite looking it. The hourly shape of a cumulative day is
// folded out of the per-sample rows in the same transaction that deletes
// them, so past the 36-hour grace window there is nothing left to rebuild it
// from. A restore without it silently resets the same-time baseline to
// "still learning" for two weeks.
"IntradayCumulativeProfile",
// ── Medications, and everything hanging off one ───────────────────────────
// The parent alone restores a drug list with no schedule, no history and no
@@ -85,7 +85,8 @@ describe("registry", () => {
expect(DERIVED_METRIC_IDS).toContain("STAIR_DESCENT_SPEED_BASELINE");
expect(DERIVED_METRIC_IDS).toContain("SIX_MINUTE_WALK_BAND");
expect(DERIVED_METRIC_IDS).toContain("TRAJECTORY");
expect(DERIVED_METRIC_IDS.length).toBe(16);
expect(DERIVED_METRIC_IDS).toContain("SAME_TIME_BASELINE");
expect(DERIVED_METRIC_IDS.length).toBe(17);
});
});
@@ -0,0 +1,278 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const findUniqueUser = vi.fn();
const findFirstMeasurement = vi.fn();
const findManyProfiles = vi.fn();
const findManyMeasurements = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: (...a: unknown[]) => findUniqueUser(...a) },
measurement: {
findFirst: (...a: unknown[]) => findFirstMeasurement(...a),
findMany: (...a: unknown[]) => findManyMeasurements(...a),
},
intradayCumulativeProfile: {
findMany: (...a: unknown[]) => findManyProfiles(...a),
},
},
}));
import { computeSameTimeBaseline } from "../same-time-baseline";
import { SAME_TIME_BASELINE_MIN_HISTORY_DAYS } from "../registry";
/**
* The honesty contract, pinned.
*
* A same-time comparison is only worth anything if it refuses to speak when it
* cannot. Most of what follows tests silence, on purpose: the failure this
* feature must never have is a confident "you are behind" derived from three
* days of history, or from an account that has no intraday stream at all and
* therefore has today's total wrong.
*/
const TZ = "UTC";
// 20:30 UTC, so the last COMPLETED local hour is 19.
const NOW = new Date("2026-06-10T20:30:00Z");
const TODAY = "2026-06-10";
/** A curve that reaches `total` linearly across the day. */
function curve(total: number): number[] {
return Array.from({ length: 24 }, (_, h) =>
Math.round((total * (h + 1)) / 24),
);
}
function profileRow(dateKey: string, total: number, sampleCount = 200) {
return {
dateKey,
timezone: TZ,
hourlyCumulative: curve(total),
dayTotal: total,
sampleCount,
};
}
/** `days` historical profiles, newest first, ending the day before today. */
function history(days: number, total: number, sampleCount = 200) {
return Array.from({ length: days }, (_, i) => {
const d = new Date(`${TODAY}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() - (i + 1));
return profileRow(d.toISOString().slice(0, 10), total, sampleCount);
});
}
beforeEach(() => {
vi.clearAllMocks();
findUniqueUser.mockResolvedValue({ timezone: TZ });
findFirstMeasurement.mockResolvedValue({ unit: "steps" });
// No live rows by default — every day under test comes off a stored profile.
findManyMeasurements.mockResolvedValue([]);
});
describe("computeSameTimeBaseline", () => {
it("refuses to speak below the history floor", async () => {
findManyProfiles.mockResolvedValue([
profileRow(TODAY, 4000),
...history(SAME_TIME_BASELINE_MIN_HISTORY_DAYS - 1, 10_000),
]);
const d = await computeSameTimeBaseline("u1", null, {
type: "ACTIVITY_STEPS",
now: NOW,
});
expect(d.status).toBe("insufficient");
if (d.status !== "insufficient") throw new Error("unreachable");
expect(d.reason).toBe("learning_usual_day");
expect(d.coverage.historyDays).toBe(
SAME_TIME_BASELINE_MIN_HISTORY_DAYS - 1,
);
});
it("speaks once the floor is reached", async () => {
findManyProfiles.mockResolvedValue([
profileRow(TODAY, 4000),
...history(SAME_TIME_BASELINE_MIN_HISTORY_DAYS, 10_000),
]);
const d = await computeSameTimeBaseline("u1", null, {
type: "ACTIVITY_STEPS",
now: NOW,
});
expect(d.status).toBe("ok");
if (d.status !== "ok") throw new Error("unreachable");
expect(d.value.baselineDays).toBe(SAME_TIME_BASELINE_MIN_HISTORY_DAYS);
expect(d.value.asOfHour).toBe(19);
// Both curves cover hour 0 … 19 and are index-aligned.
expect(d.value.todayCurve).toHaveLength(20);
expect(d.value.typicalCurve).toHaveLength(20);
expect(d.value.todayValue).toBe(d.value.todayCurve[19]);
expect(d.value.typicalValue).toBe(d.value.typicalCurve[19]);
expect(d.value.delta).toBe(d.value.todayValue - d.value.typicalValue);
expect(d.value.band).toBe("below");
expect(d.value.unit).toBe("steps");
});
it("says nothing when the account has no intraday rows today", async () => {
// The permanent state for every daily-total source. Honest absence, and
// it must never be confused with a day of zero activity.
findManyProfiles.mockResolvedValue(history(20, 10_000));
const d = await computeSameTimeBaseline("u1", null, {
type: "ACTIVITY_STEPS",
now: NOW,
});
expect(d.status).toBe("insufficient");
if (d.status !== "insufficient") throw new Error("unreachable");
expect(d.reason).toBe("no_intraday_today");
});
it("says nothing before the day's first hour has finished", async () => {
findManyProfiles.mockResolvedValue([
profileRow(TODAY, 100),
...history(20, 10_000),
]);
const d = await computeSameTimeBaseline("u1", null, {
type: "ACTIVITY_STEPS",
now: new Date("2026-06-10T00:20:00Z"),
});
expect(d.status).toBe("insufficient");
if (d.status !== "insufficient") throw new Error("unreachable");
expect(d.reason).toBe("day_too_young");
});
it("drops days that were cut on another timezone", async () => {
// After a move, "by 21:00" means a different moment. Averaging the two
// would quietly compare unlike hours.
const moved = history(20, 10_000).map((p) => ({
...p,
timezone: "Pacific/Auckland",
}));
findManyProfiles.mockResolvedValue([profileRow(TODAY, 4000), ...moved]);
const d = await computeSameTimeBaseline("u1", null, {
type: "ACTIVITY_STEPS",
now: NOW,
});
expect(d.status).toBe("insufficient");
if (d.status !== "insufficient") throw new Error("unreachable");
// Twenty stored days, none of them usable, and the metric says so rather
// than comparing hours that mean different things.
expect(d.reason).toBe("learning_usual_day");
expect(d.coverage.historyDays).toBe(0);
});
it("drops days too sparsely sampled to be a shape", async () => {
findManyProfiles.mockResolvedValue([
profileRow(TODAY, 4000),
...history(20, 10_000, 2),
]);
const d = await computeSameTimeBaseline("u1", null, {
type: "ACTIVITY_STEPS",
now: NOW,
});
expect(d.status).toBe("insufficient");
if (d.status !== "insufficient") throw new Error("unreachable");
expect(d.reason).toBe("learning_usual_day");
expect(d.coverage.historyDays).toBe(0);
});
it("calls a day inside its own band 'within'", async () => {
findManyProfiles.mockResolvedValue([
profileRow(TODAY, 10_000),
...history(20, 10_000),
]);
const d = await computeSameTimeBaseline("u1", null, {
type: "ACTIVITY_STEPS",
now: NOW,
});
expect(d.status).toBe("ok");
if (d.status !== "ok") throw new Error("unreachable");
expect(d.value.band).toBe("within");
expect(d.value.percentOfTypical).toBe(100);
});
it("calls a busy day 'above'", async () => {
findManyProfiles.mockResolvedValue([
profileRow(TODAY, 30_000),
...history(20, 10_000),
]);
const d = await computeSameTimeBaseline("u1", null, {
type: "ACTIVITY_STEPS",
now: NOW,
});
expect(d.status).toBe("ok");
if (d.status !== "ok") throw new Error("unreachable");
expect(d.value.band).toBe("above");
expect(d.value.delta).toBeGreaterThan(0);
});
it("never puts the band's lower edge below zero", async () => {
// A cumulative total cannot be negative, and a sub-zero edge would widen
// the "within" verdict on the low side for free.
findManyProfiles.mockResolvedValue([
profileRow(TODAY, 0),
...history(20, 200),
]);
const d = await computeSameTimeBaseline("u1", null, {
type: "ACTIVITY_STEPS",
now: NOW,
});
expect(d.status).toBe("ok");
if (d.status !== "ok") throw new Error("unreachable");
expect(d.value.typicalLow).toBeGreaterThanOrEqual(0);
});
it("reports no percentage rather than a fabricated one at an empty hour", async () => {
// At 05:00 the typical total is legitimately zero. A ratio against zero is
// undefined, not a hundred percent.
findManyProfiles.mockResolvedValue([
{ ...profileRow(TODAY, 0), hourlyCumulative: new Array(24).fill(0) },
...history(20, 0).map((p) => ({
...p,
hourlyCumulative: new Array(24).fill(0),
})),
]);
const d = await computeSameTimeBaseline("u1", null, {
type: "ACTIVITY_STEPS",
now: NOW,
});
expect(d.status).toBe("ok");
if (d.status !== "ok") throw new Error("unreachable");
expect(d.value.percentOfTypical).toBeNull();
});
it("ships no projected day total", async () => {
// Extrapolating a partial day is a guess, and this feature exists to
// replace guesses with the person's own history.
findManyProfiles.mockResolvedValue([
profileRow(TODAY, 4000),
...history(20, 10_000),
]);
const d = await computeSameTimeBaseline("u1", null, {
type: "ACTIVITY_STEPS",
now: NOW,
});
expect(d.status).toBe("ok");
if (d.status !== "ok") throw new Error("unreachable");
expect(Object.keys(d.value)).not.toContain("projectedDayTotal");
});
});
+31
View File
@@ -15,6 +15,7 @@ import type { MeasurementType } from "@/generated/prisma/client";
import { buildInsufficient, nowProvenanceTimestamp } from "./coverage";
import {
getDerivedMetricMeta,
isSameTimeBaselineType,
isVitalsBaselineType,
isTrajectoryType,
type DerivedMetricId,
@@ -29,6 +30,7 @@ import { computeSleepScore } from "./sleep-score";
import { computeReadiness } from "./readiness";
import { computeCoincidentDeviation } from "./coincident-deviation";
import { computeTrajectory } from "./trajectory";
import { computeSameTimeBaseline } from "./same-time-baseline";
import {
computeWellnessScore,
type WellnessScoreType,
@@ -207,6 +209,35 @@ export async function computeDerivedMetric(
now,
}) as Promise<Derived<unknown>>;
}
case "SAME_TIME_BASELINE": {
// Today's running total against this person's own typical total at the
// same local hour. Steps is the default because it is the case the
// architecture structurally could not answer before, and the one the
// request was written around.
const requested = args.type ?? "ACTIVITY_STEPS";
if (!isSameTimeBaselineType(requested)) {
return buildInsufficient<unknown>({
coverage: {
requiredInputs: 1,
presentInputs: 0,
historyDays: 0,
missing: [requested],
},
provenance: {
inputs: [requested],
source: "none",
windowDays: 0,
computedAt: nowProvenanceTimestamp(now),
},
reason: "unsupported_baseline_type",
});
}
return computeSameTimeBaseline(args.userId, args.profile, {
type: requested as MeasurementType,
windowDays: args.windowDays,
now,
}) as Promise<Derived<unknown>>;
}
case "RECOVERY_SCORE":
case "STRESS_SCORE":
case "STRAIN_SCORE":
+12
View File
@@ -40,10 +40,14 @@ export {
DERIVED_METRIC_IDS,
VITALS_BASELINE_TYPES,
TRAJECTORY_TYPES,
SAME_TIME_BASELINE_TYPES,
SAME_TIME_BASELINE_WINDOW_DAYS,
SAME_TIME_BASELINE_MIN_HISTORY_DAYS,
isDerivedMetricId,
getDerivedMetricMeta,
isVitalsBaselineType,
isTrajectoryType,
isSameTimeBaselineType,
} from "./registry";
export type {
DerivedMetricId,
@@ -171,3 +175,11 @@ export type {
TrajectoryOpts,
OlsFit,
} from "./trajectory";
// ── v1.34.0 same-time baseline for cumulative metrics (server-only) ──
export { computeSameTimeBaseline } from "./same-time-baseline";
export type {
SameTimeBaselineValue,
SameTimeBaselineOpts,
SameTimeBand,
} from "./same-time-baseline";
+64 -1
View File
@@ -56,7 +56,9 @@ export type DerivedMetricId =
/** v1.10.3: estimated 6-minute-walk distance vs Enright-predicted (passthrough re-frame). */
| "SIX_MINUTE_WALK_BAND"
/** v1.11.0 (Epic B): short-horizon OLS projection with a widening prediction band. */
| "TRAJECTORY";
| "TRAJECTORY"
/** v1.34.0: today's running total for a cumulative metric vs this person's own typical total at the same local hour. */
| "SAME_TIME_BASELINE";
// Documented-as-omitted (v1.10.3): two additive HealthKit signals stay
// trend-only with NO derived band, on purpose —
@@ -126,6 +128,58 @@ export const VITALS_BASELINE_TYPES: MeasurementType[] = [
"WEIGHT",
];
/**
* The cumulative metrics a same-time baseline is defined for.
*
* These four accumulate through the day, which is exactly why a "latest
* reading" means nothing for them and a same-hour comparison means everything.
*
* `TIME_IN_DAYLIGHT` and `FALL_COUNT` are cumulative too and are deliberately
* out. Daylight exposure is environmental rather than behavioural — a same-time
* verdict on it would read as a judgement on the weather. A fall count is a
* near-always-zero safety event whose target is zero, and "you are behind your
* usual falls for this hour" is not a sentence this application will ever say.
*/
export const SAME_TIME_BASELINE_TYPES: MeasurementType[] = [
"ACTIVITY_STEPS",
"ACTIVE_ENERGY_BURNED",
"WALKING_RUNNING_DISTANCE",
"FLIGHTS_CLIMBED",
];
/** `true` when the type is one the same-time baseline is defined for. */
export function isSameTimeBaselineType(type: string): boolean {
return (SAME_TIME_BASELINE_TYPES as string[]).includes(type);
}
/**
* The trailing window the same-time baseline draws its typical day from.
*
* Four weeks. Long enough that every weekday is represented several times, so
* a single unusual Tuesday cannot move the median; short enough that the
* comparison still describes the life the person is living now rather than the
* one they had last season.
*/
export const SAME_TIME_BASELINE_WINDOW_DAYS = 28;
/**
* How many usable days must be in hand before the highlight says anything.
*
* THIS IS THE THRESHOLD. It is defined here, in the client-safe registry, so
* the compute engine, the rail builder and the copy that explains the wait all
* read the same number rather than each carrying their own.
*
* Two weeks, not the seven days the vitals baseline uses. A resting heart rate
* is a stable physiological quantity and seven readings genuinely bound it. A
* person's day is not: it has a weekday shape and a weekend shape, and a median
* built from three working days would confidently call a Saturday morning
* "behind" for no better reason than that Saturdays were never in the sample.
* Fourteen days covers both twice over. Below it the metric returns the
* `insufficient` arm with `learning_usual_day`, which is the honest answer and
* the one this arm exists for.
*/
export const SAME_TIME_BASELINE_MIN_HISTORY_DAYS = 14;
const REGISTRY: Record<DerivedMetricId, DerivedMetricMeta> = {
VITALS_BASELINE: {
id: "VITALS_BASELINE",
@@ -282,6 +336,15 @@ const REGISTRY: Record<DerivedMetricId, DerivedMetricMeta> = {
minInputs: 1,
implemented: true,
},
SAME_TIME_BASELINE: {
id: "SAME_TIME_BASELINE",
displayName: "Same-time baseline",
archetype: "any-user-baseline",
inputs: SAME_TIME_BASELINE_TYPES,
minHistoryDays: SAME_TIME_BASELINE_MIN_HISTORY_DAYS,
minInputs: 1,
implemented: true,
},
};
/** Closed set of ids the generic route accepts (Zod enum source). */
@@ -0,0 +1,292 @@
/**
* v1.34.0 — the same-time baseline for a cumulative metric.
*
* `signals-of-day` excludes steps and says why in its own comment: intraday
* step rows arrive as many partial-day fragments, so the newest raw row is not
* a reading. Every other derived metric in this directory compares a latest
* value to a band, and for a quantity that accumulates through the day that
* comparison has no meaning — four thousand steps is a good day at 09:00 and a
* poor one at 22:00.
*
* So this engine compares along the other axis. It takes today's running total
* at the last COMPLETED local hour and puts it against the same person's own
* typical total at that same hour, drawn from a trailing four weeks of their
* stored intraday profiles (`intraday-cumulative-profile.ts`). Same estimator
* as the vitals baseline — median for the centre, median ± k·MAD·1.4826 for
* the band — so a reader who understands one understands the other.
*
* Everything the surface renders is decided HERE: the two curves, the delta,
* the percentage, the band edges and the verdict of where today sits. The copy
* is a deterministic template over these numbers. Nothing downstream computes a
* comparison, and the Coach may explain one that already exists but may never
* produce one.
*
* What it deliberately does not produce: a projected day total. Extrapolating
* a partial day is a guess, and a feature that exists to replace guesses with
* the person's own history has no business shipping one.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import {
PROFILE_HOURS,
readCumulativeDayProfiles,
localDateKey,
type CumulativeDayProfile,
} from "@/lib/measurements/intraday-cumulative-profile";
import { hourOfDayForUserTz } from "@/lib/measurements/consolidation-tz";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { buildBaselineBand, median, type BaselineProfile } from "./baseline";
import { buildInsufficient, buildOk, deriveCoverage } from "./coverage";
import {
SAME_TIME_BASELINE_MIN_HISTORY_DAYS,
SAME_TIME_BASELINE_WINDOW_DAYS,
} from "./registry";
import type { Derived } from "./types";
/**
* Raw per-sample rows a stored day must have been folded from before it counts
* towards the typical curve.
*
* A day the watch contributed two readings to is not the shape of that day —
* it is two moments — and letting it into the median would quietly claim the
* person walked nothing for twenty-two hours. Four is a low bar that a worn
* watch clears by two orders of magnitude and a barely-synced day does not.
*/
const MIN_PROFILE_SAMPLES = 4;
/**
* Canonical fallback units, matching what the Apple Health mapping writes for
* each cumulative type. Only used when the account holds no row to read the
* unit off — which, given the profile exists, essentially never happens.
*/
const FALLBACK_UNIT: Partial<Record<MeasurementType, string>> = {
ACTIVITY_STEPS: "steps",
ACTIVE_ENERGY_BURNED: "kcal",
WALKING_RUNNING_DISTANCE: "m",
FLIGHTS_CLIMBED: "flights",
};
/** Where today's running total sits against the typical band at this hour. */
export type SameTimeBand = "below" | "within" | "above";
/** The successful `value` payload for a same-time baseline. */
export interface SameTimeBaselineValue {
/** The cumulative metric this compares. */
type: MeasurementType;
/** Canonical unit of every figure below. */
unit: string;
/** IANA zone every "hour" here is expressed in. */
timezone: string;
/** Local calendar day the comparison is for. */
dateKey: string;
/**
* The last COMPLETED local hour (0–23). Both curves are `asOfHour + 1`
* points long and index-aligned: element `h` is the total through the end of
* local hour `h`.
*/
asOfHour: number;
/** Today's running total through the end of `asOfHour`. */
todayValue: number;
/** The median of the window's days at that same hour. */
typicalValue: number;
/** Band lower edge — median − k·MAD·1.4826, floored at zero. */
typicalLow: number;
/** Band upper edge — median + k·MAD·1.4826. */
typicalHigh: number;
/** Signed difference, today − typical. */
delta: number;
/**
* Today as a percentage of typical, rounded. `null` when the typical total
* at this hour is zero — which is the normal state at 05:00 and is an
* undefined ratio, not a hundred percent and not a failure.
*/
percentOfTypical: number | null;
/** The server's verdict of where today sits against the band. */
band: SameTimeBand;
/** Today's cumulative curve, hours 0 … `asOfHour`. */
todayCurve: number[];
/** The typical cumulative curve over the same hours. */
typicalCurve: number[];
/** Days that actually backed the typical curve. */
baselineDays: number;
/** Trailing window the days were drawn from. */
windowDays: number;
}
export interface SameTimeBaselineOpts {
/** The cumulative metric to compare. */
type: MeasurementType;
/** Trailing window override (days). */
windowDays?: number;
now?: Date;
}
/**
* Compute the same-time baseline for one cumulative metric.
*
* `profile` is accepted for signature parity with every other engine in this
* directory — the dispatcher hands each one the same once-loaded profile — but
* a same-time comparison is purely against the person's own history and reads
* nothing off it. It is therefore nullable, so a caller outside the dispatcher
* (the daily digest) does not have to issue a profile read it has no use for.
* The timezone the engine does need is read here, because a comparison whose
* whole subject is "at this hour" cannot borrow anybody else's clock.
*/
export async function computeSameTimeBaseline(
userId: string,
_profile: BaselineProfile | null,
opts: SameTimeBaselineOpts,
): Promise<Derived<SameTimeBaselineValue>> {
const now = opts.now ?? new Date();
const type = opts.type;
const windowDays = opts.windowDays ?? SAME_TIME_BASELINE_WINDOW_DAYS;
const computedAt = now.toISOString();
const user = await prisma.user.findUnique({
where: { id: userId },
select: { timezone: true },
});
const timezone = user?.timezone || DEFAULT_TIMEZONE;
const dateKey = localDateKey(now, timezone);
const gate = (reason: string, historyDays: number) =>
buildInsufficient<SameTimeBaselineValue>({
coverage: {
requiredInputs: 1,
presentInputs: historyDays > 0 ? 1 : 0,
historyDays,
missing: historyDays > 0 ? [] : [type],
},
provenance: {
inputs: [type],
source: historyDays > 0 ? "live" : "none",
windowDays,
computedAt,
},
reason,
});
// The comparison is anchored to the last hour that has FINISHED. Comparing
// against an hour still in progress would put a partial hour of today
// against a whole one of every other day and manufacture a shortfall out of
// nothing. Before 01:00 there is no completed hour, and the honest answer is
// that the day is too young to say anything about.
const asOfHour = hourOfDayForUserTz(now, timezone) - 1;
if (asOfHour < 0) return gate("day_too_young", 0);
const profiles = await readCumulativeDayProfiles({
userId,
type,
timezone,
endDateKey: dateKey,
windowDays,
});
const today = profiles.find((p) => p.dateKey === dateKey) ?? null;
const history = profiles.filter(
(p) => p.dateKey !== dateKey && isUsableHistoryDay(p),
);
// No sub-daily rows for today at all. This is the permanent state for every
// account whose activity arrives as a daily total — Fitbit, Withings, Polar
// all write one row per day and never had an intraday stream. It is honest
// absence and must never render as a failure.
if (!today) return gate("no_intraday_today", history.length);
if (history.length < SAME_TIME_BASELINE_MIN_HISTORY_DAYS) {
return gate("learning_usual_day", history.length);
}
// Both curves run hour 0 … asOfHour, index-aligned.
const hours = asOfHour + 1;
const todayCurve = today.hourlyCumulative.slice(0, hours);
const typicalCurve: number[] = [];
for (let h = 0; h < hours; h += 1) {
typicalCurve.push(median(history.map((p) => p.hourlyCumulative[h])));
}
const todayValue = todayCurve[asOfHour];
const typicalValue = typicalCurve[asOfHour];
// The band comes off the same estimator the vitals baseline uses, applied to
// the window's totals AT THIS HOUR rather than to daily means. Floored at
// zero: a cumulative total cannot be negative, and a band edge below zero
// would widen the "within" verdict on the low side for free.
const atHour = history.map((p) => p.hourlyCumulative[asOfHour]);
const spread = buildBaselineBand(atHour);
const typicalLow = Math.max(0, spread ? spread.low : typicalValue);
const typicalHigh = spread ? spread.high : typicalValue;
const band: SameTimeBand =
todayValue < typicalLow
? "below"
: todayValue > typicalHigh
? "above"
: "within";
const { coverage, confidence } = deriveCoverage({
requiredInputs: 1,
presentInputs: 1,
historyDays: history.length,
missing: [],
fullHistoryDays: windowDays,
});
return buildOk<SameTimeBaselineValue>({
value: {
type,
unit: await resolveUnit(userId, type),
timezone,
dateKey,
asOfHour,
todayValue,
typicalValue,
typicalLow,
typicalHigh,
delta: todayValue - typicalValue,
percentOfTypical:
typicalValue > 0 ? Math.round((todayValue / typicalValue) * 100) : null,
band,
todayCurve,
typicalCurve,
baselineDays: history.length,
windowDays,
},
coverage,
confidence,
provenance: {
inputs: [type],
// The profiles are their own tier, neither a rollup bucket nor a live
// measurement read, so the honest label for the dominant read is `live`.
source: "live",
windowDays,
computedAt,
},
});
}
/** A stored day dense enough to describe the shape of that day. */
function isUsableHistoryDay(p: CumulativeDayProfile): boolean {
return (
p.sampleCount >= MIN_PROFILE_SAMPLES &&
p.hourlyCumulative.length === PROFILE_HOURS
);
}
/**
* The unit the account's own rows for this type carry. Read rather than
* assumed, so a source that stores distance in a different unit than Apple
* Health does labels its own numbers correctly.
*/
async function resolveUnit(
userId: string,
type: MeasurementType,
): Promise<string> {
const row = await prisma.measurement.findFirst({
where: { userId, type, deletedAt: null },
orderBy: { measuredAt: "desc" },
select: { unit: true },
});
return row?.unit ?? FALLBACK_UNIT[type] ?? "count";
}
@@ -788,3 +788,154 @@ describe("drainPerSampleCumulative — concurrent fold does not double-count (F2
expect(update).not.toHaveBeenCalled();
});
});
/** The columns the drain writes onto one stored profile. */
interface StoredProfile {
dateKey: string;
hourlyCumulative: number[];
dayTotal: number;
sampleCount: number;
timezone: string;
}
describe("drainPerSampleCumulative — the day's shape survives the delete", () => {
// The whole point of the fold: the per-sample rows are the ONLY thing an
// hourly curve can be derived from, and the next statement deletes them.
// A test that lets the merge fail silently is worse than no test, because
// the evidence it was supposed to keep is gone by the time anybody looks.
function buildShapeMock(options: { drainedByAnotherTick?: boolean } = {}) {
const findManyUser = vi
.fn()
.mockResolvedValue([{ id: "user-1", timezone: "Europe/Berlin" }]);
// Two samples on 2026-05-16 Berlin: 08:00 local (06:00 UTC) and
// 20:00 local (18:00 UTC).
const rows = [
{
id: "samp-1",
type: "ACTIVITY_STEPS" as const,
value: 1200,
measuredAt: new Date("2026-05-16T06:00:00.000Z"),
externalId: "hk-uuid-1",
},
{
id: "samp-2",
type: "ACTIVITY_STEPS" as const,
value: 800,
measuredAt: new Date("2026-05-16T18:00:00.000Z"),
externalId: "hk-uuid-2",
},
];
const findManyMeasurement = vi.fn(
async (args: { where: { type: string } }) =>
args.where.type === "ACTIVITY_STEPS" ? rows : [],
);
const order: string[] = [];
const create = vi.fn(async () => {
order.push("create");
return { id: "stats-row-new" };
});
const update = vi.fn().mockResolvedValue({ id: "stats-row" });
const deleteMany = vi.fn(async () => {
order.push("deleteMany");
return { count: rows.length };
});
const findFirst = vi.fn(
async (args: { where: Record<string, unknown> }) => {
if ("externalId" in args.where) return null;
if ("measuredAt" in args.where) return null;
return { unit: "steps" };
},
);
// The in-transaction re-read of the contributing rows, under the lock. The
// serialised loser of two overlapping ticks sees none of them.
const findMany = vi
.fn()
.mockResolvedValue(options.drainedByAnotherTick ? [] : rows);
const $queryRaw = vi.fn().mockResolvedValue([{ locked: 1 }]);
const profileFindUnique = vi.fn().mockResolvedValue(null);
let storedProfile: StoredProfile | null = null;
const profileUpsert = vi.fn(async (args: { create: StoredProfile }) => {
order.push("profileUpsert");
storedProfile = args.create;
return {};
});
const tx = {
$queryRaw,
measurement: { update, create, deleteMany, findFirst, findMany },
intradayCumulativeProfile: {
findUnique: profileFindUnique,
upsert: profileUpsert,
},
};
const prisma = {
user: { findMany: findManyUser },
measurement: { findMany: findManyMeasurement },
intradayCumulativeProfile: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
$transaction: vi.fn(async (cb: (t: typeof tx) => Promise<unknown>) =>
cb(tx),
),
} as unknown as PrismaClient;
return {
prisma,
profileUpsert,
order,
deleteMany,
storedProfile: () => storedProfile,
};
}
it("writes the hourly curve before deleting the rows it came from", async () => {
const { prisma, profileUpsert, order, deleteMany } = buildShapeMock();
const summary = await drainPerSampleCumulative(prisma, { log: () => {} });
expect(summary.totals.daysFailed).toBe(0);
expect(profileUpsert).toHaveBeenCalledTimes(1);
expect(deleteMany).toHaveBeenCalledTimes(1);
// Order inside the one transaction is the whole guarantee.
expect(order.indexOf("profileUpsert")).toBeLessThan(
order.indexOf("deleteMany"),
);
});
it("stores a running total that matches where the samples actually fell", async () => {
const { prisma, storedProfile } = buildShapeMock();
await drainPerSampleCumulative(prisma, { log: () => {} });
const stored = storedProfile();
expect(stored).not.toBeNull();
expect(stored!.dateKey).toBe("2026-05-16");
expect(stored!.timezone).toBe("Europe/Berlin");
expect(stored!.sampleCount).toBe(2);
expect(stored!.dayTotal).toBe(2000);
expect(stored!.hourlyCumulative).toHaveLength(24);
// 08:00 and 20:00 Berlin — nothing before hour 8, nothing added between.
expect(stored!.hourlyCumulative[7]).toBe(0);
expect(stored!.hourlyCumulative[8]).toBe(1200);
expect(stored!.hourlyCumulative[19]).toBe(1200);
expect(stored!.hourlyCumulative[20]).toBe(2000);
});
it("writes no profile when the re-read finds the rows already drained", async () => {
// The serialised loser of two overlapping nightly ticks. It contributes
// nothing to the canonical total and must contribute nothing to the shape,
// or the additive merge would double the day.
const { prisma, profileUpsert } = buildShapeMock({
drainedByAnotherTick: true,
});
await drainPerSampleCumulative(prisma, { log: () => {} });
expect(profileUpsert).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,177 @@
import { describe, it, expect } from "vitest";
import {
PROFILE_HOURS,
foldHourlyCumulative,
} from "../intraday-cumulative-profile";
import type { PerSampleRow } from "../consolidation-tz";
/**
* The pure fold is the whole substrate: everything the same-time baseline
* compares is a slice of what this function produced, months earlier, in the
* transaction that deleted the rows it read. If the fold is wrong nothing
* downstream can notice, because the evidence is gone.
*/
const TZ = "Europe/Berlin";
function row(
iso: string,
value: number,
externalId: string | null = null,
): PerSampleRow {
return {
id: `row-${iso}-${value}`,
type: "ACTIVITY_STEPS",
value,
measuredAt: new Date(iso),
externalId,
};
}
describe("foldHourlyCumulative", () => {
it("accumulates into the local hour each sample fell in", () => {
// 07:30 and 07:50 Berlin (= 05:30 / 05:50 UTC in June) land in hour 7;
// 21:10 Berlin lands in hour 21.
const fold = foldHourlyCumulative(
[
row("2026-06-10T05:30:00Z", 400),
row("2026-06-10T05:50:00Z", 600),
row("2026-06-10T19:10:00Z", 1000),
],
"2026-06-10",
TZ,
);
expect(fold).not.toBeNull();
expect(fold!.hourlyCumulative).toHaveLength(PROFILE_HOURS);
expect(fold!.hourlyCumulative[6]).toBe(0);
expect(fold!.hourlyCumulative[7]).toBe(1000);
expect(fold!.hourlyCumulative[20]).toBe(1000);
expect(fold!.hourlyCumulative[21]).toBe(2000);
expect(fold!.hourlyCumulative[23]).toBe(2000);
expect(fold!.dayTotal).toBe(2000);
expect(fold!.sampleCount).toBe(3);
});
it("never decreases", () => {
const fold = foldHourlyCumulative(
[
row("2026-06-10T06:00:00Z", 120),
row("2026-06-10T10:00:00Z", 80),
row("2026-06-10T16:00:00Z", 300),
],
"2026-06-10",
TZ,
);
const curve = fold!.hourlyCumulative;
for (let h = 1; h < curve.length; h += 1) {
expect(curve[h]).toBeGreaterThanOrEqual(curve[h - 1]);
}
});
it("skips an already-collapsed daily total", () => {
// The trap this guards: a `stats:` day-sum row would dump the whole day
// into whichever hour it happens to sit in and invent a shape that never
// happened. Only the per-sample row may contribute.
const fold = foldHourlyCumulative(
[
row(
"2026-06-10T10:00:00Z",
12_000,
"stats:HKQuantityTypeIdentifierStepCount:2026-06-10",
),
row("2026-06-10T06:00:00Z", 500),
],
"2026-06-10",
TZ,
);
expect(fold!.dayTotal).toBe(500);
expect(fold!.sampleCount).toBe(1);
});
it("skips rows from another local day", () => {
// 23:30 UTC on the 9th is already the 10th in Berlin, and 22:30 UTC on the
// 10th is already the 11th — a caller may hand over a padded window.
const fold = foldHourlyCumulative(
[
row("2026-06-09T21:00:00Z", 999),
row("2026-06-10T06:00:00Z", 500),
row("2026-06-10T22:30:00Z", 777),
],
"2026-06-10",
TZ,
);
expect(fold!.dayTotal).toBe(500);
expect(fold!.sampleCount).toBe(1);
});
it("returns null for a day with no usable sample", () => {
// Absence is absence. A zero-filled profile would claim the person walked
// nothing all day, which is a different statement from "nobody measured".
expect(foldHourlyCumulative([], "2026-06-10", TZ)).toBeNull();
expect(
foldHourlyCumulative(
[row("2026-06-10T10:00:00Z", 9000, "stats:X:2026-06-10")],
"2026-06-10",
TZ,
),
).toBeNull();
});
it("keeps a measured day of genuine zeros as a real profile", () => {
const fold = foldHourlyCumulative(
[row("2026-06-10T06:00:00Z", 0), row("2026-06-10T14:00:00Z", 0)],
"2026-06-10",
TZ,
);
expect(fold).not.toBeNull();
expect(fold!.dayTotal).toBe(0);
expect(fold!.sampleCount).toBe(2);
});
it("still produces 24 slots across a DST transition", () => {
// 2026-03-29 is the spring-forward day in Berlin: 02:00 local never
// happens. The profile must still be 24 slots so every stored day stays
// index-comparable with every other.
const fold = foldHourlyCumulative(
[row("2026-03-29T00:30:00Z", 100), row("2026-03-29T09:00:00Z", 400)],
"2026-03-29",
"Europe/Berlin",
);
expect(fold!.hourlyCumulative).toHaveLength(PROFILE_HOURS);
expect(fold!.dayTotal).toBe(500);
});
it("folds a fall-back day's repeated hour into one slot", () => {
// 2026-10-25 in Berlin runs 02:00–02:59 twice (00:30 and 01:30 UTC).
const fold = foldHourlyCumulative(
[row("2026-10-25T00:30:00Z", 10), row("2026-10-25T01:30:00Z", 20)],
"2026-10-25",
"Europe/Berlin",
);
expect(fold!.hourlyCumulative).toHaveLength(PROFILE_HOURS);
expect(fold!.hourlyCumulative[2]).toBe(30);
expect(fold!.dayTotal).toBe(30);
expect(fold!.sampleCount).toBe(2);
});
it("adds element-wise to the same curve a single fold would produce", () => {
// The property the additive merge in the drain relies on: a cumulative sum
// is linear, so folding two halves and adding equals folding the union.
const first = [row("2026-06-10T06:00:00Z", 500)];
const second = [row("2026-06-10T16:00:00Z", 700)];
const a = foldHourlyCumulative(first, "2026-06-10", TZ)!;
const b = foldHourlyCumulative(second, "2026-06-10", TZ)!;
const union = foldHourlyCumulative(
[...first, ...second],
"2026-06-10",
TZ,
)!;
const merged = a.hourlyCumulative.map((v, h) => v + b.hourlyCumulative[h]);
expect(merged).toEqual(union.hourlyCumulative);
expect(a.sampleCount + b.sampleCount).toBe(union.sampleCount);
});
});
@@ -18,7 +18,12 @@
* 5. Otherwise preserve legacy late-increment semantics, explicitly stamp
* LEGACY_UNKNOWN, and increment syncVersion; fresh/adopted rows are also
* LEGACY_UNKNOWN.
* 6. DELETE the original per-sample rows in the same transaction.
* 6. Fold the day's still-live per-sample rows into 24 hourly running totals
* and merge them into `IntradayCumulativeProfile` (v1.34.0). This runs
* inside the same transaction and BEFORE the delete: the shape of a
* cumulative day is the only substrate a "typical by 21:00" comparison
* can be built from, and step 7 is where it used to be destroyed.
* 7. DELETE the original per-sample rows in the same transaction.
*
* Designed to be invoked by both:
* - the CLI at `scripts/drain-per-sample-cumulative.ts`
@@ -56,6 +61,11 @@ import {
localStartOfDay,
type PerSampleRow,
} from "./consolidation-tz";
import {
foldHourlyCumulative,
mergeIntradayCumulativeProfile,
pruneIntradayCumulativeProfiles,
} from "./intraday-cumulative-profile";
// Re-export the shared timezone day-math primitives + `PerSampleRow`
// shape so every established `drain-per-sample-cumulative` import site
@@ -128,6 +138,11 @@ export interface DrainSummary {
* whole global walk — every other day still collapses.
*/
daysFailed: number;
/**
* Intraday cumulative profiles dropped past the retention bound. Zero on
* a dry run, and zero on most nights — the bound is a year and a bit.
*/
profilesPruned: number;
};
}
@@ -202,6 +217,7 @@ export async function drainPerSampleCumulative(
perSampleRowsDeleted: 0,
dailyRowsUpserted: 0,
daysFailed: 0,
profilesPruned: 0,
},
};
@@ -249,6 +265,8 @@ export async function drainPerSampleCumulative(
reducedValue,
dayRows,
sourceRowIds,
tz,
dateKey,
}): Promise<DayWriteOutcome> => {
// Adopt-in-place canonical-noon resolution + a single P2002 retry,
// mirroring `dense-intraday-retention.ts`. Two unique indexes can
@@ -323,15 +341,23 @@ export async function drainPerSampleCumulative(
AUTHORITATIVE_AGGREGATION_PROVENANCE.has(adoptProvenance);
// Re-read contributors under the advisory lock. A serialized loser
// sees zero live rows and therefore performs no canonical update.
// sees zero live rows and therefore performs no canonical update —
// and, below, contributes nothing to the day's stored shape either.
// One read serves both: the sum the canonical row needs and the
// timestamps the hourly fold needs.
const liveRows = await tx.measurement.findMany({
where: { id: { in: sourceRowIds } },
select: {
id: true,
type: true,
value: true,
measuredAt: true,
externalId: true,
},
});
const liveIncrement =
eidRow !== null && !protectedAggregate
? (
await tx.measurement.findMany({
where: { id: { in: sourceRowIds } },
select: { value: true },
})
).reduce((sum, row) => sum + row.value, 0)
? liveRows.reduce((sum, row) => sum + row.value, 0)
: 0;
const mergedValue =
eidRow !== null ? eidRow.value + liveIncrement : reducedValue;
@@ -381,6 +407,23 @@ export async function drainPerSampleCumulative(
canonicalRowId = created.id;
}
// v1.34.0 — keep the day's SHAPE before the delete below removes the
// only rows it can be derived from. A cumulative day has no
// meaningful latest reading, so the one honest comparison is against
// this person's own typical total at the same local hour; that needs
// an hourly substrate, and this transaction is the last moment it
// exists. Folded from the rows re-read under the advisory lock, so a
// serialised loser folds nothing and a late-arrival drain merges only
// its own new rows.
const shape = foldHourlyCumulative(liveRows, dateKey, tz);
if (shape) {
await mergeIntradayCumulativeProfile(tx, {
userId,
type,
increment: shape,
});
}
// Hard-delete the per-sample rows that contributed to the sum.
// EXCLUDE the adopted canonical row: a per-sample row that fell on
// local-noon may be the row we just adopted as the daily total, so
@@ -480,8 +523,22 @@ export async function drainPerSampleCumulative(
summary.totals.usersScanned = usersScanned;
// v1.34.0 — bound the profile sidecar. One indexed range delete per run,
// across every account, on the same nightly tick that writes the profiles.
// A failure here must never fail the drain: the fold is the load-bearing
// half and a missed prune costs a few hundred bytes until tomorrow.
if (!summary.dryRun) {
try {
summary.totals.profilesPruned =
await pruneIntradayCumulativeProfiles(prismaClient);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log(`[drain] intraday-profile prune skipped — ${message}`);
}
}
log(
`[drain] done — usersScanned=${summary.totals.usersScanned} bucketsCollapsed=${summary.totals.bucketsCollapsed} perSampleRowsDeleted=${summary.totals.perSampleRowsDeleted} dailyRowsUpserted=${summary.totals.dailyRowsUpserted} daysFailed=${summary.totals.daysFailed}${options.dryRun ? " (dry-run)" : ""}`,
`[drain] done — usersScanned=${summary.totals.usersScanned} bucketsCollapsed=${summary.totals.bucketsCollapsed} perSampleRowsDeleted=${summary.totals.perSampleRowsDeleted} dailyRowsUpserted=${summary.totals.dailyRowsUpserted} daysFailed=${summary.totals.daysFailed} profilesPruned=${summary.totals.profilesPruned}${options.dryRun ? " (dry-run)" : ""}`,
);
return summary;
}
@@ -0,0 +1,373 @@
/**
* v1.34.0 — the intraday shape of a cumulative day, kept before the fold
* destroys it.
*
* A cumulative metric has no meaningful "latest reading": four thousand steps
* is a good day at 09:00 and a poor one at 22:00, and `signals-of-day` excludes
* steps for exactly that reason. The only honest comparison is against the same
* person's own typical total AT THE SAME LOCAL HOUR — and that needs an hourly
* substrate, which did not exist. `drainPerSampleCumulative` collapses the
* per-sample Apple Health rows into one daily total after the 36-hour grace
* window and hard-deletes the originals in the same transaction; Fitbit,
* Withings and Polar write daily totals directly and never had an intraday
* stream at all.
*
* This module owns that substrate, in three parts:
*
* 1. `foldHourlyCumulative` — the PURE fold. Per-sample rows for one local
* day become 24 running totals, element `h` carrying everything
* accumulated through the END of local hour `h`. No IO, so the drain and
* the live read share one definition of what a day's shape is.
* 2. `mergeIntradayCumulativeProfile` — the write the drain calls INSIDE its
* own transaction, immediately before the delete. Keyed on
* `(userId, type, dateKey)` and additive, so a day drained twice keeps
* both halves.
* 3. `readCumulativeDayProfiles` — the trailing-window read the same-time
* baseline issues. Persisted rows for the drained days, a live fold for
* the days still inside the grace window (which have no row yet), so the
* current day is always available.
*
* The table is a SIDECAR. The canonical `stats:<HKType>:<day>` measurement
* stays the reading of record; nothing here is a measurement, and no read path
* takes a value off this table that the measurement rows do not already carry.
*/
import type {
MeasurementType,
Prisma,
PrismaClient,
} from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
import {
dayKeyForUserTz,
hourOfDayForUserTz,
localDayWindow,
type PerSampleRow,
} from "./consolidation-tz";
/** Hours in a stored profile. Always 24, DST days included — see below. */
export const PROFILE_HOURS = 24;
/**
* How long a stored profile survives.
*
* The baseline only ever reads a trailing month, so this is not a data
* requirement — it is the bound that keeps a sidecar from growing forever. A
* year and a bit lets a same-time comparison one day reach across a full
* seasonal cycle without the table ever becoming something an operator has to
* think about: at ~200 bytes a row and four supported types, a decade of one
* account is still under a megabyte.
*/
export const CUMULATIVE_PROFILE_RETENTION_DAYS = 400;
/**
* How far back a MISSING profile is folded live from raw rows.
*
* Days inside the drain's 36-hour grace window still hold their per-sample
* rows and have no persisted profile yet, so the reader folds them on demand —
* otherwise today and yesterday would be permanently invisible to a feature
* whose whole subject is today. Two days covers the 36-hour window plus the
* gap until the next nightly tick.
*
* It is deliberately NOT larger. Folding an arbitrary historical day live
* would mean an unbounded per-sample read on an account that has never been
* drained, on a request path — and on a drained account it would find nothing
* anyway, because the rows are gone. A day older than this with no profile is
* simply absent, which is the truth.
*/
export const CUMULATIVE_PROFILE_LIVE_FOLD_DAYS = 2;
/**
* Rows a single live day-fold will read. A dense Apple Watch step day is
* ~1 440 rows; the cap is generous headroom over that and exists so a
* pathological account cannot turn a highlight into an unbounded scan.
*/
const MAX_LIVE_FOLD_ROWS = 5000;
/** Prefix marking an already-collapsed daily-stats row. */
const DAILY_STATS_PREFIX = "stats:";
/** One local day's cumulative shape, as the pure fold produces it. */
export interface CumulativeDayFold {
/** Local calendar day (`YYYY-MM-DD`) in `timezone`. */
dateKey: string;
/** IANA zone the day was cut on. */
timezone: string;
/**
* 24 running totals — element `h` is everything accumulated from local
* midnight through the END of local hour `h`. Non-decreasing by
* construction; `[23]` equals `dayTotal`.
*/
hourlyCumulative: number[];
/** The day's total. */
dayTotal: number;
/** Raw per-sample rows that backed the fold. */
sampleCount: number;
}
/** A day profile as a reader resolved it, tagged with where it came from. */
export interface CumulativeDayProfile extends CumulativeDayFold {
/** Whether this came off the table or was folded live from raw rows. */
origin: "stored" | "live";
}
/**
* Fold one local day's per-sample rows into 24 running totals.
*
* Pure. Rows already collapsed into a `stats:` daily total are skipped — a
* day-sum row would dump the whole day into a single hour and invent a shape
* that never happened, the same trap `loadIntradayPulse` documents at its own
* step-bucket loop. Rows that fall outside `dateKey` are skipped too, so a
* caller may hand over a padded window without corrupting the day.
*
* DST costs nothing here. A fall-back day's repeated local hour folds into its
* one slot, and a spring-forward day's skipped hour simply repeats its
* predecessor's running total. Every stored day therefore has exactly 24 slots
* and is index-comparable with every other, which is the only property the
* same-hour comparison actually needs.
*
* Returns `null` when the day carried no usable per-sample row — an honest
* absence, never a zero-filled profile. A day with genuinely zero steps that
* WAS sampled comes back as a real profile of zeros; a day nobody measured
* comes back as nothing, and the two must not be confused.
*/
export function foldHourlyCumulative(
rows: readonly PerSampleRow[],
dateKey: string,
timezone: string,
): CumulativeDayFold | null {
const perHour = new Array<number>(PROFILE_HOURS).fill(0);
let sampleCount = 0;
for (const row of rows) {
if (row.externalId?.startsWith(DAILY_STATS_PREFIX)) continue;
if (dayKeyForUserTz(row.measuredAt, timezone) !== dateKey) continue;
const hour = hourOfDayForUserTz(row.measuredAt, timezone);
if (hour < 0 || hour >= PROFILE_HOURS) continue;
perHour[hour] += row.value;
sampleCount += 1;
}
if (sampleCount === 0) return null;
const hourlyCumulative = new Array<number>(PROFILE_HOURS);
let running = 0;
for (let h = 0; h < PROFILE_HOURS; h += 1) {
running += perHour[h];
hourlyCumulative[h] = running;
}
return {
dateKey,
timezone,
hourlyCumulative,
dayTotal: running,
sampleCount,
};
}
/**
* Add one fold onto the stored profile for its day, inside the caller's
* transaction.
*
* The drain calls this immediately before deleting the rows the fold came
* from, so the shape is committed by the same commit that destroys its source
* — there is no window in which the samples are gone and the shape was never
* written.
*
* The merge is ADDITIVE, and it has to be. The drain deletes what it folds, so
* it only ever hands over rows nobody folded before: a day drained at the grace
* cutoff and then drained again a week later after a late watch sync would lose
* its first eleven hours under last-write-wins. Element-wise addition of two
* running totals is exactly the running total of the union, because a cumulative
* sum is linear — so the merged curve is the same one a single fold over all the
* rows would have produced.
*
* The caller must serialise its own reads and writes for the day; the cumulative
* drain does, under the per-bucket advisory lock it already takes. And the
* caller must pass only rows still LIVE at merge time: a re-run or the
* serialised loser of two overlapping ticks sees none, folds nothing, and never
* reaches here.
*
* A stored profile cut on a different timezone is replaced rather than added
* to. After a move the old curve's hours mean something else, and summing the
* two would produce a shape that describes no day that ever happened.
*/
export async function mergeIntradayCumulativeProfile(
tx: Prisma.TransactionClient,
args: {
userId: string;
type: MeasurementType;
increment: CumulativeDayFold;
},
): Promise<void> {
const { userId, type, increment } = args;
const key = { userId, type, dateKey: increment.dateKey };
const existing = await tx.intradayCumulativeProfile.findUnique({
where: { userId_type_dateKey: key },
select: { hourlyCumulative: true, sampleCount: true, timezone: true },
});
const reusable =
existing !== null &&
existing.timezone === increment.timezone &&
existing.hourlyCumulative.length === PROFILE_HOURS;
const hourlyCumulative = reusable
? increment.hourlyCumulative.map((v, h) => v + existing.hourlyCumulative[h])
: increment.hourlyCumulative;
const sampleCount = reusable
? existing.sampleCount + increment.sampleCount
: increment.sampleCount;
const data = {
hourlyCumulative,
dayTotal: hourlyCumulative[PROFILE_HOURS - 1],
sampleCount,
timezone: increment.timezone,
};
await tx.intradayCumulativeProfile.upsert({
where: { userId_type_dateKey: key },
create: { ...key, ...data },
update: data,
});
}
/**
* Drop profiles older than the retention bound, across every account.
*
* `dateKey` is a zero-padded `YYYY-MM-DD` string, so the lexicographic
* comparison IS the chronological one and the delete is an index range scan.
* The cutoff is computed in UTC: it is 400 days back, and no timezone on earth
* moves that boundary by more than a day.
*/
export async function pruneIntradayCumulativeProfiles(
prismaClient: PrismaClient,
now: Date = new Date(),
retentionDays: number = CUMULATIVE_PROFILE_RETENTION_DAYS,
): Promise<number> {
const cutoffKey = shiftDateKey(
now.toISOString().slice(0, 10),
-retentionDays,
);
const { count } = await prismaClient.intradayCumulativeProfile.deleteMany({
where: { dateKey: { lt: cutoffKey } },
});
return count;
}
/**
* Read a trailing window of day profiles, newest day first.
*
* Stored rows answer the drained days. The days still inside the grace window
* hold no stored row yet, so each is folded live from its raw per-sample rows —
* bounded to `CUMULATIVE_PROFILE_LIVE_FOLD_DAYS` and to `MAX_LIVE_FOLD_ROWS`
* per day. A day that yields neither is absent from the result; the caller
* counts what it got rather than assuming a length.
*
* Profiles cut on a different timezone than the account's current one are
* dropped. After a move, "by 21:00" means a different moment than it did
* before, and averaging the two would quietly compare unlike hours.
*/
export async function readCumulativeDayProfiles(args: {
userId: string;
type: MeasurementType;
timezone: string;
/** Inclusive newest local day to consider (usually the caller's today). */
endDateKey: string;
/** How many days back from `endDateKey`, inclusive of it. */
windowDays: number;
prismaClient?: PrismaClient;
}): Promise<CumulativeDayProfile[]> {
const { userId, type, timezone, endDateKey, windowDays } = args;
const db = args.prismaClient ?? prisma;
const startDateKey = shiftDateKey(endDateKey, -(windowDays - 1));
const stored = await db.intradayCumulativeProfile.findMany({
where: {
userId,
type,
dateKey: { gte: startDateKey, lte: endDateKey },
},
orderBy: { dateKey: "desc" },
select: {
dateKey: true,
timezone: true,
hourlyCumulative: true,
dayTotal: true,
sampleCount: true,
},
});
const byDay = new Map<string, CumulativeDayProfile>();
for (const row of stored) {
// A day cut on another clock is not comparable — drop it rather than
// silently mix two meanings of "by 21:00".
if (row.timezone !== timezone) continue;
if (row.hourlyCumulative.length !== PROFILE_HOURS) continue;
byDay.set(row.dateKey, {
dateKey: row.dateKey,
timezone: row.timezone,
hourlyCumulative: row.hourlyCumulative,
dayTotal: row.dayTotal,
sampleCount: row.sampleCount,
origin: "stored",
});
}
// The recent days the drain has not reached yet. Folded live, newest first,
// and only for days actually inside the requested window.
for (let back = 0; back < CUMULATIVE_PROFILE_LIVE_FOLD_DAYS; back += 1) {
const dateKey = shiftDateKey(endDateKey, -back);
if (dateKey < startDateKey) break;
if (byDay.has(dateKey)) continue;
const folded = await foldLiveDay(db, userId, type, dateKey, timezone);
if (folded) byDay.set(dateKey, { ...folded, origin: "live" });
}
return [...byDay.values()].sort((a, b) => (a.dateKey < b.dateKey ? 1 : -1));
}
/**
* Fold one local day straight from its live per-sample rows. Scoped to the
* exact local-day window (DST-aware, 23/24/25 h) so the row cap is only ever
* spent on rows that are actually inside the day.
*/
async function foldLiveDay(
db: PrismaClient,
userId: string,
type: MeasurementType,
dateKey: string,
timezone: string,
): Promise<CumulativeDayFold | null> {
const { dayStart, dayEnd } = localDayWindow(dateKey, timezone);
const rows = await db.measurement.findMany({
where: {
userId,
type,
deletedAt: null,
measuredAt: { gte: dayStart, lt: dayEnd },
},
orderBy: { measuredAt: "asc" },
take: MAX_LIVE_FOLD_ROWS,
select: {
id: true,
type: true,
value: true,
measuredAt: true,
externalId: true,
},
});
return foldHourlyCumulative(rows, dateKey, timezone);
}
/**
* The caller's local calendar day. Thin wrapper so a consumer of this module
* never has to reach for a second day-key implementation.
*/
export function localDateKey(now: Date, timezone: string): string {
return userDayKey(now, timezone);
}