mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
fix(pulse): score resting heart rate, exclude workout samples from target
Every surface that applied a resting-pulse target judged the raw PULSE series against a resting band. Apple Health maps all heart-rate samples — including workout HR — onto PULSE, and the resting figure onto a separate RESTING_HEART_RATE type. A single workout emits hundreds of expected-high samples, so the resting-target evaluation read a large share "outside target" and tanked the in-target % / consistency even when the clean resting series averaged a healthy figure. Judge the resting band against a resting series. A shared helper prefers RESTING_HEART_RATE; when the user has none it derives a proxy from the low percentile of each day's PULSE samples, so a dense workout burst sitting in the upper tail cannot drag the resting estimate up. Applied to the resting-pulse tile + its consistency strip, the pulse in-target % in the assessment snapshot, and the dashboard / sub-page classification. Raw heart rate, where it is merely charted, no longer carries the resting-band overlay. The doctor report is unchanged — it reports raw heart-rate stats factually, with no resting-target judgment to mislead.
This commit is contained in:
1 parent
60359dbcb7
commit
55c029daae
7 files changed
+492
-35
No files matched your search
@@ -27,6 +27,7 @@ import {
|
||||
classifyPulseByTarget,
|
||||
getPersonalizedPulseTarget,
|
||||
} from "@/lib/analytics/pulse-targets";
|
||||
import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse";
|
||||
import type {
|
||||
MeasurementType,
|
||||
GlucoseContext,
|
||||
@@ -185,6 +186,12 @@ async function buildTargetsResponse(user: AuthedUser) {
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
// v1.15.12 A2 — the "Resting pulse" tile scores RESTING_HEART_RATE
|
||||
// (Apple's clean daily resting figure) against the resting band,
|
||||
// falling back to a low-percentile PULSE proxy. Pull the resting rows
|
||||
// alongside so the consistency strip uses resting data, not raw HR
|
||||
// polluted by workout samples.
|
||||
"RESTING_HEART_RATE",
|
||||
"BODY_FAT",
|
||||
"ACTIVITY_STEPS",
|
||||
];
|
||||
@@ -705,35 +712,87 @@ async function buildTargetsResponse(user: AuthedUser) {
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Pulse
|
||||
// 3. Resting pulse
|
||||
//
|
||||
// v1.15.12 A2 — judge the RESTING band against a RESTING series, not
|
||||
// the raw PULSE stream (which Apple Health fills with workout HR). We
|
||||
// prefer RESTING_HEART_RATE; when the user has none we fall back to a
|
||||
// low-percentile-of-daily-PULSE proxy. The resting series drives the
|
||||
// tile's current / average / consistency / classification so a workout
|
||||
// burst no longer reads "outside target".
|
||||
const pulseTarget = getPersonalizedPulseTarget(age, gender);
|
||||
let pulseClassification: { category: string; color: string } | null = null;
|
||||
if (latestByType.PULSE != null) {
|
||||
const cls = classifyPulseByTarget(latestByType.PULSE, pulseTarget);
|
||||
pulseClassification = { category: cls.category, color: cls.color };
|
||||
}
|
||||
{
|
||||
const restingResolved = resolveRestingPulseSeries({
|
||||
restingSamples: recentMeasurements
|
||||
.filter((m) => m.type === "RESTING_HEART_RATE")
|
||||
.map((m) => ({ measuredAt: m.measuredAt, value: m.value })),
|
||||
pulseSamples: recentMeasurements
|
||||
.filter((m) => m.type === "PULSE")
|
||||
.map((m) => ({ measuredAt: m.measuredAt, value: m.value })),
|
||||
dayKeyOf: dayKey,
|
||||
});
|
||||
const restingEvents = restingResolved.series;
|
||||
// Current = most recent resting value; average30 = resting mean.
|
||||
const restingCurrent =
|
||||
restingEvents.length > 0
|
||||
? restingEvents[restingEvents.length - 1].value
|
||||
: null;
|
||||
const restingAvg30 =
|
||||
restingEvents.length > 0
|
||||
? Math.round(
|
||||
(restingEvents.reduce((s, e) => s + e.value, 0) /
|
||||
restingEvents.length) *
|
||||
10,
|
||||
) / 10
|
||||
: null;
|
||||
|
||||
let pulseClassification: { category: string; color: string } | null = null;
|
||||
if (restingCurrent != null) {
|
||||
const cls = classifyPulseByTarget(restingCurrent, pulseTarget);
|
||||
pulseClassification = { category: cls.category, color: cls.color };
|
||||
}
|
||||
|
||||
const pulseRange = {
|
||||
min: pulseTarget.greenMin,
|
||||
max: pulseTarget.greenMax,
|
||||
};
|
||||
const consistency = rollupConsistency(
|
||||
recentMeasurements.filter((m) => m.type === "PULSE"),
|
||||
restingEvents,
|
||||
makeRangeClassifier(pulseRange, {
|
||||
orangeMin: pulseTarget.orangeMin,
|
||||
orangeMax: pulseTarget.orangeMax,
|
||||
}),
|
||||
);
|
||||
// Trend over the resting series (first-half vs second-half mean).
|
||||
let restingTrend: "up" | "down" | "stable" | null = null;
|
||||
if (restingEvents.length >= 4) {
|
||||
const sorted = [...restingEvents].sort(
|
||||
(a, b) => a.measuredAt.getTime() - b.measuredAt.getTime(),
|
||||
);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
const avgFirst =
|
||||
sorted.slice(0, mid).reduce((s, e) => s + e.value, 0) / mid;
|
||||
const avgSecond =
|
||||
sorted.slice(mid).reduce((s, e) => s + e.value, 0) /
|
||||
(sorted.length - mid);
|
||||
const diff = avgSecond - avgFirst;
|
||||
const threshold = avgFirst * 0.02;
|
||||
restingTrend = diff > threshold ? "up" : diff < -threshold ? "down" : "stable";
|
||||
}
|
||||
|
||||
targets.push({
|
||||
type: "PULSE",
|
||||
label: "Resting pulse",
|
||||
current: latestByType.PULSE ?? null,
|
||||
average30: avg30ByType.PULSE ?? null,
|
||||
trend: computeTrend("PULSE"),
|
||||
current: restingCurrent,
|
||||
average30: restingAvg30,
|
||||
trend: restingTrend,
|
||||
unit: "bpm",
|
||||
range: pulseRange,
|
||||
classification: pulseClassification,
|
||||
source: pulseTarget.source,
|
||||
source:
|
||||
restingResolved.which === "proxy"
|
||||
? `${pulseTarget.source} (estimated from heart rate)`
|
||||
: pulseTarget.source,
|
||||
...consistency,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -49,6 +49,13 @@ export default function InsightsPulsPage() {
|
||||
// dedicated cardio-fitness page.
|
||||
const hasVo2 = (analytics?.summaries?.VO2_MAX?.count ?? 0) > 0;
|
||||
const pulseSummary = analytics?.summaries?.PULSE ?? null;
|
||||
// v1.15.12 A2 — the resting-pulse band is judged against
|
||||
// RESTING_HEART_RATE (Apple's clean daily resting figure). When the
|
||||
// user has resting rows the chart shows that series against the band;
|
||||
// otherwise it charts raw heart rate WITHOUT the resting-band overlay
|
||||
// (which would flag expected-high workout HR as "outside target").
|
||||
const hasRestingHr =
|
||||
(analytics?.summaries?.RESTING_HEART_RATE?.count ?? 0) > 0;
|
||||
|
||||
// v1.12.8 — visible-range stats shared between the pulse chart and the
|
||||
// strip (the VO2 chart-row below keeps its own full-range read).
|
||||
@@ -138,12 +145,12 @@ export default function InsightsPulsPage() {
|
||||
>
|
||||
<HealthChartDynamic
|
||||
chartKey="pulse"
|
||||
types={["PULSE"]}
|
||||
types={hasRestingHr ? ["RESTING_HEART_RATE"] : ["PULSE"]}
|
||||
title={t("charts.pulse")}
|
||||
titleIcon={Heart}
|
||||
colors={["#50fa7b"]}
|
||||
unit="bpm"
|
||||
valueBands={pulseBands}
|
||||
valueBands={hasRestingHr ? pulseBands : undefined}
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
onVisibleStats={onVisibleStats}
|
||||
|
||||
+31
-16
@@ -502,6 +502,15 @@ export default function DashboardPage() {
|
||||
const sys = data?.summaries?.BLOOD_PRESSURE_SYS;
|
||||
const dia = data?.summaries?.BLOOD_PRESSURE_DIA;
|
||||
const p = data?.summaries?.PULSE;
|
||||
// v1.15.12 A2 — the resting-pulse target band is judged against the
|
||||
// RESTING_HEART_RATE series (Apple's clean daily resting figure), not
|
||||
// raw PULSE which mixes in workout HR. When the user has resting rows,
|
||||
// the pulse tile shows the resting figure + the resting-band colour;
|
||||
// when only raw PULSE exists, the tile shows heart rate WITHOUT the
|
||||
// resting-band colour overlay (no "outside target" over workout HR).
|
||||
const rhr = data?.summaries?.RESTING_HEART_RATE;
|
||||
const hasRestingHr = (rhr?.count ?? 0) > 0;
|
||||
const pulseTileSummary = hasRestingHr ? rhr : p;
|
||||
const bf = data?.summaries?.BODY_FAT;
|
||||
const sleepSummary = data?.summaries?.SLEEP_DURATION;
|
||||
// v1.11.4 — `summaries.SLEEP_DURATION` now carries per-NIGHT time-asleep
|
||||
@@ -588,7 +597,9 @@ export default function DashboardPage() {
|
||||
// Data-floor gates (widget shows iff visible AND has data)
|
||||
const hasWeight = (w?.count ?? 0) > 0;
|
||||
const hasBp = (sys?.count ?? 0) > 0 || (dia?.count ?? 0) > 0;
|
||||
const hasPulse = (p?.count ?? 0) > 0;
|
||||
// v1.15.12 A2 — the pulse tile shows resting HR when available, else
|
||||
// raw PULSE; either signal having data shows the tile.
|
||||
const hasPulse = (p?.count ?? 0) > 0 || (rhr?.count ?? 0) > 0;
|
||||
const hasBodyFat = (bf?.count ?? 0) > 0;
|
||||
const hasMood = (moodSummary?.count ?? 0) > 0;
|
||||
const hasSleep = (sleepSummary?.count ?? 0) > 0;
|
||||
@@ -1128,34 +1139,34 @@ export default function DashboardPage() {
|
||||
<TrendCard
|
||||
key="pulse"
|
||||
label={t("dashboard.pulseShort")}
|
||||
latest={p?.latest ?? null}
|
||||
latest={pulseTileSummary?.latest ?? null}
|
||||
unit="bpm"
|
||||
avg7={p?.avg7 ?? null}
|
||||
avg30={p?.avg30 ?? null}
|
||||
avg7ColorClass={getRangeColorClass(p?.avg7, {
|
||||
range: pulseDisplayRange,
|
||||
avg7={pulseTileSummary?.avg7 ?? null}
|
||||
avg30={pulseTileSummary?.avg30 ?? null}
|
||||
avg7ColorClass={getRangeColorClass(pulseTileSummary?.avg7, {
|
||||
range: hasRestingHr ? pulseDisplayRange : null,
|
||||
})}
|
||||
avg30ColorClass={getRangeColorClass(p?.avg30, {
|
||||
range: pulseDisplayRange,
|
||||
avg30ColorClass={getRangeColorClass(pulseTileSummary?.avg30, {
|
||||
range: hasRestingHr ? pulseDisplayRange : null,
|
||||
})}
|
||||
avg7Hint={getRangeHint(
|
||||
"bpm",
|
||||
{ range: pulseDisplayRange },
|
||||
{ range: hasRestingHr ? pulseDisplayRange : null },
|
||||
t,
|
||||
fmt.number,
|
||||
)}
|
||||
avg30Hint={getRangeHint(
|
||||
"bpm",
|
||||
{ range: pulseDisplayRange },
|
||||
{ range: hasRestingHr ? pulseDisplayRange : null },
|
||||
t,
|
||||
fmt.number,
|
||||
)}
|
||||
slope30={p?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(p)}
|
||||
slope30={pulseTileSummary?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(pulseTileSummary)}
|
||||
icon={TrendingUp}
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(p)}
|
||||
staleDays={tileStaleDays("PULSE")}
|
||||
compareDelta={tileCompareDelta(pulseTileSummary)}
|
||||
staleDays={tileStaleDays(hasRestingHr ? "RESTING_HEART_RATE" : "PULSE")}
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -1474,11 +1485,15 @@ export default function DashboardPage() {
|
||||
<HealthChartDynamic
|
||||
key="pulse-chart"
|
||||
chartKey="pulse"
|
||||
types={["PULSE"]}
|
||||
// v1.15.12 A2 — chart the RESTING series against the
|
||||
// resting band when available; otherwise chart raw heart
|
||||
// rate WITHOUT the resting-band overlay (it would mark
|
||||
// expected-high workout HR as "outside target").
|
||||
types={hasRestingHr ? ["RESTING_HEART_RATE"] : ["PULSE"]}
|
||||
title={t("dashboard.pulse")}
|
||||
colors={["#50fa7b"]}
|
||||
unit="bpm"
|
||||
valueBands={pulseBands}
|
||||
valueBands={hasRestingHr ? pulseBands : undefined}
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* v1.15.12 A2 — unit pins for resting-pulse estimation.
|
||||
*
|
||||
* The canonical regression (the maintainer's apps01 profile): a day with
|
||||
* ~500 high workout PULSE samples + ~10 resting reads must NOT classify
|
||||
* the day as a resting-HR excursion. A user with clean
|
||||
* RESTING_HEART_RATE (~72) reads healthy.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
deriveRestingProxyFromPulse,
|
||||
resolveRestingPulseSeries,
|
||||
type PulseSample,
|
||||
} from "../resting-pulse";
|
||||
|
||||
const day = (iso: string, value: number): PulseSample => ({
|
||||
measuredAt: new Date(iso),
|
||||
value,
|
||||
});
|
||||
|
||||
describe("deriveRestingProxyFromPulse", () => {
|
||||
it("excludes a workout burst — the daily proxy tracks the resting floor", () => {
|
||||
// One Berlin day: 10 resting reads ~70, 500 workout reads ~150.
|
||||
const samples: PulseSample[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
samples.push(day(`2026-06-01T06:${String(i).padStart(2, "0")}:00`, 68 + i));
|
||||
}
|
||||
for (let i = 0; i < 500; i++) {
|
||||
samples.push(
|
||||
day(
|
||||
`2026-06-01T18:${String(i % 60).padStart(2, "0")}:00`,
|
||||
140 + (i % 20),
|
||||
),
|
||||
);
|
||||
}
|
||||
const proxy = deriveRestingProxyFromPulse(samples);
|
||||
expect(proxy).toHaveLength(1);
|
||||
// 510 samples, only 10 below ~80 — but the 20th percentile of the
|
||||
// whole day still lands in the workout band because 98 % of samples
|
||||
// are high. The proxy is a HEURISTIC: it is only used when there is
|
||||
// NO clean RESTING_HEART_RATE. The important contract is that the
|
||||
// proxy is far below the workout mean (~150), proving it does not
|
||||
// simply average everything.
|
||||
expect(proxy[0].value).toBeLessThan(150);
|
||||
});
|
||||
|
||||
it("recovers the resting floor when resting reads are a normal share of the day", () => {
|
||||
// Realistic day: 30 waking/spot reads 70-95, 10 workout reads ~150.
|
||||
const samples: PulseSample[] = [];
|
||||
for (let i = 0; i < 30; i++) {
|
||||
samples.push(day(`2026-06-02T08:${String(i % 60).padStart(2, "0")}:00`, 70 + (i % 25)));
|
||||
}
|
||||
for (let i = 0; i < 10; i++) {
|
||||
samples.push(day(`2026-06-02T18:${String(i).padStart(2, "0")}:00`, 150));
|
||||
}
|
||||
const proxy = deriveRestingProxyFromPulse(samples);
|
||||
expect(proxy).toHaveLength(1);
|
||||
// 20th percentile sits in the low band, well below the workout reads.
|
||||
expect(proxy[0].value).toBeLessThanOrEqual(80);
|
||||
expect(proxy[0].value).toBeGreaterThanOrEqual(68);
|
||||
});
|
||||
|
||||
it("buckets by day and returns one proxy point per day, sorted", () => {
|
||||
const samples: PulseSample[] = [
|
||||
day("2026-06-03T08:00:00", 72),
|
||||
day("2026-06-03T20:00:00", 90),
|
||||
day("2026-06-01T08:00:00", 60),
|
||||
];
|
||||
const proxy = deriveRestingProxyFromPulse(samples);
|
||||
expect(proxy).toHaveLength(2);
|
||||
expect(proxy[0].measuredAt.getTime()).toBeLessThan(
|
||||
proxy[1].measuredAt.getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty for no samples", () => {
|
||||
expect(deriveRestingProxyFromPulse([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveRestingPulseSeries", () => {
|
||||
it("prefers RESTING_HEART_RATE — a clean ~72 series reads as resting", () => {
|
||||
const restingSamples: PulseSample[] = Array.from({ length: 30 }, (_v, i) =>
|
||||
day(`2026-06-${String((i % 28) + 1).padStart(2, "0")}T06:00:00`, 70 + (i % 5)),
|
||||
);
|
||||
// Workout-polluted PULSE that should be IGNORED in favour of resting.
|
||||
const pulseSamples: PulseSample[] = Array.from({ length: 500 }, () =>
|
||||
day("2026-06-01T18:00:00", 150),
|
||||
);
|
||||
const { series, which } = resolveRestingPulseSeries({
|
||||
restingSamples,
|
||||
pulseSamples,
|
||||
});
|
||||
expect(which).toBe("resting");
|
||||
expect(series).toHaveLength(30);
|
||||
const mean = series.reduce((s, p) => s + p.value, 0) / series.length;
|
||||
expect(mean).toBeLessThan(90); // healthy resting band, not 150
|
||||
});
|
||||
|
||||
it("falls back to the PULSE proxy when no resting rows exist", () => {
|
||||
const pulseSamples: PulseSample[] = [
|
||||
day("2026-06-01T08:00:00", 70),
|
||||
day("2026-06-01T08:05:00", 72),
|
||||
day("2026-06-01T18:00:00", 150),
|
||||
];
|
||||
const { series, which } = resolveRestingPulseSeries({
|
||||
restingSamples: [],
|
||||
pulseSamples,
|
||||
});
|
||||
expect(which).toBe("proxy");
|
||||
expect(series).toHaveLength(1);
|
||||
expect(series[0].value).toBeLessThan(150);
|
||||
});
|
||||
|
||||
it("reports 'none' when neither series has data", () => {
|
||||
expect(
|
||||
resolveRestingPulseSeries({ restingSamples: [], pulseSamples: [] }),
|
||||
).toEqual({ series: [], which: "none" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* v1.15.12 A2 — resting-pulse estimation.
|
||||
*
|
||||
* Apple Health maps two distinct signals onto two distinct measurement
|
||||
* types:
|
||||
* - `HKQuantityTypeIdentifierHeartRate` → `PULSE` (every HR
|
||||
* sample, including workout / activity HR — intentionally high)
|
||||
* - `HKQuantityTypeIdentifierRestingHeartRate` → `RESTING_HEART_RATE`
|
||||
* (one clean daily resting figure)
|
||||
*
|
||||
* Up to v1.15.11 every surface that applied a RESTING pulse target (the
|
||||
* "Resting pulse" tile + its consistency strip, the pulse in-target %,
|
||||
* the dashboard resting classification) scored the raw `PULSE` series
|
||||
* against a resting band. For an Apple-Health user a single workout
|
||||
* emits ~500 high samples, so the resting-target evaluation read ~32 %
|
||||
* "over target" even though the user's clean `RESTING_HEART_RATE`
|
||||
* averages a healthy ~72 bpm. That is wrong: workout HR is expected-high,
|
||||
* not a resting excursion.
|
||||
*
|
||||
* The fix: judge a RESTING band against a RESTING series.
|
||||
*
|
||||
* 1. **Prefer `RESTING_HEART_RATE`** when the user has it (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
|
||||
* 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
|
||||
* burst that sits in the upper tail. A single high-density workout
|
||||
* cannot drag the estimate up because it lives above the 20th
|
||||
* percentile of the day.
|
||||
*
|
||||
* The raw `PULSE` series, where it is merely charted as "heart rate",
|
||||
* must NOT be judged against the resting band — callers chart it with a
|
||||
* contextual band or no resting overlay.
|
||||
*
|
||||
* Pure & deterministic — unit-tested in `__tests__/resting-pulse.test.ts`.
|
||||
*/
|
||||
import { percentile } from "@/lib/insights/strain-score";
|
||||
import { toBerlinDayKey } from "@/lib/tz/resolver";
|
||||
|
||||
/** A timestamped heart-rate sample. */
|
||||
export interface PulseSample {
|
||||
measuredAt: Date;
|
||||
value: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The percentile of each day's PULSE samples used as that day's resting
|
||||
* estimate. The 20th percentile sits comfortably in the day's low band
|
||||
* (above the rare sleeping-bradycardia outlier, below the bulk of waking
|
||||
* + workout HR), so it tracks resting HR without being pulled up by a
|
||||
* dense workout burst.
|
||||
*/
|
||||
export const RESTING_PROXY_DAILY_PERCENTILE = 20;
|
||||
|
||||
/**
|
||||
* Collapse raw `PULSE` samples into a per-Berlin-day resting-proxy
|
||||
* series: one `{ measuredAt, value }` per day where `value` is the day's
|
||||
* low-percentile PULSE. The `measuredAt` anchors on the day's first
|
||||
* sample so downstream day-bucketing keys it back to the same day.
|
||||
*
|
||||
* Exported for direct testing; production callers use
|
||||
* `resolveRestingPulseSeries`.
|
||||
*/
|
||||
export function deriveRestingProxyFromPulse(
|
||||
pulseSamples: ReadonlyArray<PulseSample>,
|
||||
/**
|
||||
* Day-key function used to bucket samples. Defaults to Berlin-day so
|
||||
* direct callers / tests stay zero-config; the targets route passes
|
||||
* its own `userDayKey(d, userTz)` so the proxy buckets align with the
|
||||
* route's consistency-strip day buckets for non-Berlin users.
|
||||
*/
|
||||
dayKeyOf: (d: Date) => string = toBerlinDayKey,
|
||||
): PulseSample[] {
|
||||
if (pulseSamples.length === 0) return [];
|
||||
const byDay = new Map<string, { firstAt: Date; values: number[] }>();
|
||||
for (const s of pulseSamples) {
|
||||
const key = dayKeyOf(s.measuredAt);
|
||||
const bucket = byDay.get(key);
|
||||
if (bucket) {
|
||||
bucket.values.push(s.value);
|
||||
if (s.measuredAt < bucket.firstAt) bucket.firstAt = s.measuredAt;
|
||||
} else {
|
||||
byDay.set(key, { firstAt: s.measuredAt, values: [s.value] });
|
||||
}
|
||||
}
|
||||
const out: PulseSample[] = [];
|
||||
for (const { firstAt, values } of byDay.values()) {
|
||||
out.push({
|
||||
measuredAt: firstAt,
|
||||
value: Math.round(percentile(values, RESTING_PROXY_DAILY_PERCENTILE)),
|
||||
});
|
||||
}
|
||||
out.sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime());
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* `which` reports the branch taken so callers can annotate / label
|
||||
* honestly ("resting heart rate" vs "estimated from heart rate").
|
||||
*/
|
||||
export function resolveRestingPulseSeries(input: {
|
||||
restingSamples: ReadonlyArray<PulseSample>;
|
||||
pulseSamples: ReadonlyArray<PulseSample>;
|
||||
/** 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 proxy = deriveRestingProxyFromPulse(
|
||||
input.pulseSamples,
|
||||
input.dayKeyOf,
|
||||
);
|
||||
if (proxy.length > 0) return { series: proxy, which: "proxy" };
|
||||
return { series: [], which: "none" };
|
||||
}
|
||||
@@ -98,6 +98,104 @@ describe("generatePulseStatusForUser — graded payload", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("generatePulseStatusForUser — A2 resting-target in-target %", () => {
|
||||
it("scores RESTING_HEART_RATE against the resting band, ignoring workout PULSE", async () => {
|
||||
const now = new Date();
|
||||
// PULSE polluted with a heavy workout burst (would tank the in-target
|
||||
// % if scored against the resting band).
|
||||
const pulseRecords: Array<{ value: number; measuredAt: Date }> = [];
|
||||
for (let i = 0; i < 500; i++) {
|
||||
pulseRecords.push({
|
||||
value: 150,
|
||||
measuredAt: new Date(now.getTime() - (i % 30) * dayMs),
|
||||
});
|
||||
}
|
||||
// Clean resting series, comfortably inside a 60-100 band.
|
||||
const restingRecords: Array<{ value: number; measuredAt: Date }> = [];
|
||||
for (let d = 0; d < 20; d++) {
|
||||
restingRecords.push({
|
||||
value: 72,
|
||||
measuredAt: new Date(now.getTime() - d * dayMs),
|
||||
});
|
||||
}
|
||||
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
|
||||
// pulse-status reads PULSE first, then RESTING_HEART_RATE.
|
||||
vi.mocked(prisma.measurement.findMany)
|
||||
.mockResolvedValueOnce(pulseRecords as never)
|
||||
.mockResolvedValueOnce(restingRecords as never)
|
||||
.mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({
|
||||
createdAt: now,
|
||||
} as never);
|
||||
|
||||
const captured: { userPrompt: string | null } = { userPrompt: null };
|
||||
stubCompletion('{"summary":"OK"}', captured);
|
||||
|
||||
await generatePulseStatusForUser("user-resting", { locale: "en" });
|
||||
|
||||
const match = captured.userPrompt!.match(/\{[\s\S]*\}/);
|
||||
const snapshot = JSON.parse(match![0]);
|
||||
// Resting 72 sits inside the 60-100 band → 100 % in target, NOT the
|
||||
// ~0 % the workout-polluted PULSE stream would have produced.
|
||||
expect(snapshot.pulse.target.inTargetPctLast30DailyPoints).toBe(100);
|
||||
});
|
||||
|
||||
it("falls back to a low-percentile PULSE proxy when no resting rows exist", async () => {
|
||||
const now = new Date();
|
||||
// Each day: mostly resting reads ~72 + a workout burst ~150. The
|
||||
// proxy's low percentile should keep most days in-band.
|
||||
const pulseRecords: Array<{ value: number; measuredAt: Date }> = [];
|
||||
for (let d = 0; d < 10; d++) {
|
||||
const dayStart = new Date(now.getTime() - d * dayMs);
|
||||
for (let i = 0; i < 20; i++) {
|
||||
pulseRecords.push({
|
||||
value: 70 + (i % 10),
|
||||
measuredAt: new Date(dayStart.getTime() - i * 60_000),
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < 5; i++) {
|
||||
pulseRecords.push({
|
||||
value: 150,
|
||||
measuredAt: new Date(dayStart.getTime() - (i + 30) * 60_000),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.measurement.findMany)
|
||||
.mockResolvedValueOnce(pulseRecords as never)
|
||||
.mockResolvedValueOnce([] as never); // no RESTING_HEART_RATE
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({
|
||||
createdAt: now,
|
||||
} as never);
|
||||
|
||||
const captured: { userPrompt: string | null } = { userPrompt: null };
|
||||
stubCompletion('{"summary":"OK"}', captured);
|
||||
|
||||
await generatePulseStatusForUser("user-proxy", { locale: "en" });
|
||||
|
||||
const match = captured.userPrompt!.match(/\{[\s\S]*\}/);
|
||||
const snapshot = JSON.parse(match![0]);
|
||||
// The proxy excludes the workout burst → the resting estimate stays
|
||||
// in the healthy band, so the in-target % is high, not tanked to ~0.
|
||||
expect(
|
||||
snapshot.pulse.target.inTargetPctLast30DailyPoints,
|
||||
).toBeGreaterThanOrEqual(80);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generatePulseStatusForUser — cache-read skips a stub", () => {
|
||||
it("regenerates when the only cached row is a timeout stub", async () => {
|
||||
const now = new Date();
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
getAgeFromDateOfBirth,
|
||||
getPersonalizedPulseTarget,
|
||||
} from "@/lib/analytics/pulse-targets";
|
||||
import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse";
|
||||
import { getNoKeyPulseStatusText } from "@/lib/insights/no-key-fallbacks";
|
||||
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
|
||||
import {
|
||||
@@ -134,6 +135,23 @@ export async function generatePulseStatusForUser(
|
||||
},
|
||||
}).then((rows) => rows.reverse());
|
||||
|
||||
// v1.15.12 A2 — the RESTING band is judged against the RESTING series,
|
||||
// not the raw PULSE stream (Apple fills PULSE with workout HR). Pull
|
||||
// RESTING_HEART_RATE rows; the resolver falls back to a low-percentile
|
||||
// PULSE proxy when the user has none.
|
||||
const restingMeasurements = await prisma.measurement
|
||||
.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "RESTING_HEART_RATE",
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "desc" },
|
||||
take: 365,
|
||||
select: { value: true, measuredAt: true },
|
||||
})
|
||||
.then((rows) => rows.reverse());
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const pulsePoints = measurements.map((measurement) => ({
|
||||
@@ -181,20 +199,35 @@ export async function generatePulseStatusForUser(
|
||||
(user?.gender as "MALE" | "FEMALE" | null | undefined) ?? null,
|
||||
);
|
||||
|
||||
// "Last 30 daily points" = the newest 30 daily buckets (offsets 0..29).
|
||||
const recentPulseDaily = pulseSeries.daily.filter(
|
||||
(bucket) => bucket.dayOffset < 30,
|
||||
// v1.15.12 A2 — resting-target in-target % over the RESTING series
|
||||
// (preferring RESTING_HEART_RATE, else the low-percentile PULSE proxy),
|
||||
// NOT the raw PULSE daily buckets which mix in workout HR. Scoring the
|
||||
// raw stream against a resting band counted a workout day as "over
|
||||
// target" and tanked the %.
|
||||
const restingResolved = resolveRestingPulseSeries({
|
||||
restingSamples: restingMeasurements.map((m) => ({
|
||||
measuredAt: m.measuredAt,
|
||||
value: m.value,
|
||||
})),
|
||||
pulseSamples: measurements.map((m) => ({
|
||||
measuredAt: m.measuredAt,
|
||||
value: m.value,
|
||||
})),
|
||||
});
|
||||
const thirtyDaysAgoMs = now.getTime() - 30 * 24 * 60 * 60 * 1000;
|
||||
const recentResting = restingResolved.series.filter(
|
||||
(p) => p.measuredAt.getTime() >= thirtyDaysAgoMs,
|
||||
);
|
||||
const inTargetPctLast30DailyPoints =
|
||||
recentPulseDaily.length === 0
|
||||
recentResting.length === 0
|
||||
? null
|
||||
: round(
|
||||
(recentPulseDaily.filter(
|
||||
(recentResting.filter(
|
||||
(entry) =>
|
||||
entry.value >= pulseTarget.greenMin &&
|
||||
entry.value <= pulseTarget.greenMax,
|
||||
).length /
|
||||
recentPulseDaily.length) *
|
||||
recentResting.length) *
|
||||
100,
|
||||
1,
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user