feat(insights): surface the same-time baseline on Today and in the grid

The rail gains a same_time_baseline kind and a server builder, which is the
growth path that type documents for itself. It speaks only when today is
actually outside the typical band: a card that reports doing exactly what you
usually do teaches people to stop reading the rail. Dismissing it is keyed by
day and metric, not by the hour, so it stays dismissed instead of coming back
every sixty minutes.

The Insights grid gains a section of four tiles, one per cumulative metric,
each honestly quiet until the history accrues.

The two-trajectory chart it needs did not exist, so DeltaSparkline gains a
second mode rather than a second chart family: the comparison curve rides the
same container, the same fixed height and the same shared y-domain as a dashed
muted line behind the live one, which is the only way the two can be read
against each other.

Six locales. The wipe and backup plans learn about the new table: the profile
cannot be rebuilt once the samples are gone, so it is carried, not derived.
This commit is contained in:
Marc-André Bombeck committed 2026-07-27 23:32:22 +02:00
1 parent b2955beffd
commit 6bd8d387ae
24 files changed
+1204 -32

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}
@@ -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
@@ -52,6 +52,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");
});
});
@@ -128,12 +128,14 @@ export interface SameTimeBaselineOpts {
* `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. The timezone it does need is read here, because a comparison
* whose whole subject is "at this hour" cannot borrow anybody else's clock.
* 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,
_profile: BaselineProfile | null,
opts: SameTimeBaselineOpts,
): Promise<Derived<SameTimeBaselineValue>> {
const now = opts.now ?? new Date();
@@ -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);
});
});