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:
Marc-André Bombeck committed 2026-06-07 17:36:25 +02:00
1 parent 60359dbcb7
commit 55c029daae
7 files changed
+492 -35

No files matched your search

+70 -11
View File
@@ -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,
});
}
+9 -2
View File
@@ -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
View File
@@ -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" });
});
});
+125
View File
@@ -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();
+39 -6
View File
@@ -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,
);