mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
merge: s8-baselines into release/v1.34.0
This commit is contained in:
commit
b14943ea1b
32 files changed
+2312
-30
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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":
|
||||
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user