diff --git a/src/lib/analytics/intraday-pulse-io.ts b/src/lib/analytics/intraday-pulse-io.ts index 8a063a21d..9a4df4243 100644 --- a/src/lib/analytics/intraday-pulse-io.ts +++ b/src/lib/analytics/intraday-pulse-io.ts @@ -68,6 +68,13 @@ export interface IntradayPulseResult { resolution: IntradayResolution; } +/** + * How old the newest RESTING_HEART_RATE row may be before the baseline stops + * trusting the resting rows alone and pulls the PULSE history in as well. Two + * days tolerates a single missed night without widening the read. + */ +const RESTING_STALE_AFTER_MS = 2 * 24 * 60 * 60 * 1000; + /** Median (p50), or null on an empty series. */ function median(values: number[]): number | null { if (values.length === 0) return null; @@ -103,9 +110,21 @@ async function resolveBaseline( select: { value: true, measuredAt: true }, }); - // Only reach for the heavier PULSE-history read when resting rows are thin. + // Only reach for the heavier PULSE-history read when the resting rows + // cannot carry the baseline on their own: too few of them, OR the newest + // one is already stale. Staleness matters because the retention fold mints + // derived resting rows only for the days it has ALREADY folded, so a proxy + // account accumulates plenty of resting rows for OLD days while the RECENT + // days still hold nothing but raw PULSE. Gating on the row count alone + // pinned the baseline to the fold horizon and never advanced it past it. + // A genuinely native account reports a resting row daily, never trips the + // staleness arm, and pays for no extra read. + const newestRestingAt = restingRows[0]?.measuredAt ?? null; + const restingIsStale = + newestRestingAt === null || + Date.now() - newestRestingAt.getTime() > RESTING_STALE_AFTER_MS; const pulseHistory = - restingRows.length >= MIN_BASELINE_DAYS + restingRows.length >= MIN_BASELINE_DAYS && !restingIsStale ? [] : await prisma.measurement.findMany({ where: { userId, type: "PULSE", deletedAt: null }, diff --git a/src/lib/analytics/resting-pulse.ts b/src/lib/analytics/resting-pulse.ts index 5e62139c4..652079dae 100644 --- a/src/lib/analytics/resting-pulse.ts +++ b/src/lib/analytics/resting-pulse.ts @@ -19,10 +19,12 @@ * * The fix: judge a RESTING band against a RESTING series. * - * 1. **Prefer `RESTING_HEART_RATE`** when the user has it (Apple + * 1. **Prefer `RESTING_HEART_RATE`** on every day that has one (Apple * already separated the clean signal for us). - * 2. **Fallback proxy from `PULSE`** when no `RESTING_HEART_RATE` - * exists (manual-only users, older imports): take a robust + * 2. **Fallback proxy from `PULSE`** on the days that have no + * `RESTING_HEART_RATE` (manual-only users, older imports, and the + * recent days of an account whose derived rows only reach as far + * as the retention fold has run): take a robust * low-percentile of each calendar day's PULSE samples — the resting * heart rate is the floor of the day, not its average, so the daily * 20th percentile estimates resting while excluding the workout @@ -112,11 +114,27 @@ export function deriveRestingProxyFromPulse( /** * Resolve the resting-pulse series a resting-target surface should - * score. Prefers the clean `RESTING_HEART_RATE` rows; falls back to the - * low-percentile PULSE proxy when the user has no resting rows. + * score. Merges PER DAY: a day that has a `RESTING_HEART_RATE` row is + * represented by that row; a day that has none falls back to the + * low-percentile PULSE proxy. * - * `which` reports the branch taken so callers can annotate / label - * honestly ("resting heart rate" vs "estimated from heart rate"). + * v1.30.16 made the dense-intraday retention fold mint derived + * `RESTING_HEART_RATE` rows (source `COMPUTED`) for the days it folds. + * A proxy account therefore now carries derived rows for OLD folded + * days and nothing but raw `PULSE` for RECENT not-yet-folded days. The + * previous all-or-nothing shape returned the resting branch the moment + * a single resting row existed and never consulted the proxy again, so + * the visible series stopped dead at the fold horizon. Merging per day + * keeps the clean signal where it exists and carries the series + * forward across the days that only have the proxy. + * + * `which` reports how to LABEL the series, and it is deliberately + * conservative: `"resting"` only when EVERY point came from a real + * resting row, `"proxy"` as soon as one point is proxy-derived. The two + * error directions are not symmetric — calling a partly-estimated + * series "resting heart rate" is a false statement about the estimated + * days, whereas calling a partly-native series "estimated from heart + * rate" merely hedges. A labelling signal should hedge, never lie. */ export function resolveRestingPulseSeries(input: { restingSamples: ReadonlyArray; @@ -124,13 +142,24 @@ export function resolveRestingPulseSeries(input: { /** Optional day-key for the proxy buckets (defaults to Berlin-day). */ dayKeyOf?: (d: Date) => string; }): { series: PulseSample[]; which: "resting" | "proxy" | "none" } { - if (input.restingSamples.length > 0) { - const series = [...input.restingSamples].sort( - (a, b) => a.measuredAt.getTime() - b.measuredAt.getTime(), - ); - return { series, which: "resting" }; + const dayKeyOf = input.dayKeyOf ?? toBerlinDayKey; + const byTime = (a: PulseSample, b: PulseSample) => + a.measuredAt.getTime() - b.measuredAt.getTime(); + + const resting = [...input.restingSamples].sort(byTime); + // Days already covered by a real resting row — the proxy must not + // second-guess them. + const restingDays = new Set(resting.map((s) => dayKeyOf(s.measuredAt))); + const proxy = deriveRestingProxyFromPulse( + input.pulseSamples, + dayKeyOf, + ).filter((p) => !restingDays.has(dayKeyOf(p.measuredAt))); + + if (resting.length === 0) { + return proxy.length > 0 + ? { series: proxy, which: "proxy" } + : { series: [], which: "none" }; } - const proxy = deriveRestingProxyFromPulse(input.pulseSamples, input.dayKeyOf); - if (proxy.length > 0) return { series: proxy, which: "proxy" }; - return { series: [], which: "none" }; + if (proxy.length === 0) return { series: resting, which: "resting" }; + return { series: [...resting, ...proxy].sort(byTime), which: "proxy" }; } diff --git a/src/lib/measurements/dense-intraday-retention.ts b/src/lib/measurements/dense-intraday-retention.ts index 2bc1a9455..515f8ea19 100644 --- a/src/lib/measurements/dense-intraday-retention.ts +++ b/src/lib/measurements/dense-intraday-retention.ts @@ -423,12 +423,15 @@ export async function runDenseIntradayRetention( }; // iOS#34 — per-user memo of whether the user has ANY native - // `RESTING_HEART_RATE` row. The read-path resolver - // (`resolveRestingPulseSeries`) is all-or-nothing: a single native - // resting row makes it ignore the PULSE proxy entirely. So the derived - // resting row is minted ONLY for proxy users (zero native rows) — minting - // alongside native rows would double-count. Memoised so the per-day fold - // does not re-query, and cleared per user at the start of the walk. + // `RESTING_HEART_RATE` row. The derived resting row is minted ONLY for + // proxy users (zero native rows): a native account already reports the + // clean signal for those days, so a derived row would add nothing and + // would let a fold artefact outrank a device reading. The read-path + // resolver (`resolveRestingPulseSeries`) merges resting and proxy PER DAY + // and prefers a resting row wherever one exists, so the mint's job is + // narrow — preserve the day's figure before the fold tombstones the raw + // readings it was derived from. Memoised so the per-day fold does not + // re-query, and cleared per user at the start of the walk. const nativeRestingByUser = new Map(); async function userHasNativeResting(userId: string): Promise { const cached = nativeRestingByUser.get(userId);