mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
fix(insights): scope intraday-pulse reads to the local day and fix the baseline tz
Two bugs in the S11 intraday pulse seam: - The PULSE/step reads queried a ±15h/39h padded superset around the viewed day, then capped the row count ascending. On a dense account the cap could be entirely consumed by the PREVIOUS local day's samples before reaching the viewed day at all, truncating the chart partway through the afternoon. Query the exact local-day window (DST-aware) instead — the workout-overlap read keeps the wider padding since it has no cap and a workout can legitimately span a local-day boundary. - The resting-baseline PULSE-proxy fallback bucketed every user's samples by a hardcoded Berlin day, not the caller's own timezone, shifting the personal floor the tension detector judges the day against for every non-Berlin user. Threads `timezone` into `resolveBaseline`'s `dayKeyOf`.
This commit is contained in:
1 parent
5169be5887
commit
71d7ca78cb
2 files changed
+186
-4
No files matched your search
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Unit coverage for `loadIntradayPulse` — the two DATAINT-audit fixes:
|
||||
*
|
||||
* - M2: the PULSE/step reads are bounded to the exact local-day window
|
||||
* (`localDayWindow`), not the ±15h/39h padded superset a dense account's
|
||||
* read cap could be entirely consumed by (the previous evening's
|
||||
* samples starving the viewed day's afternoon).
|
||||
* - M5: the resting-baseline PULSE-proxy fallback buckets by the CALLER's
|
||||
* timezone, not a hardcoded Berlin default — `resolveBaseline` now
|
||||
* threads `timezone` into `resolveRestingPulseSeries`'s `dayKeyOf`.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
const findManyMock = vi.fn();
|
||||
const workoutFindManyMock = vi.fn();
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: (...args: unknown[]) => findManyMock(...args) },
|
||||
workout: { findMany: (...args: unknown[]) => workoutFindManyMock(...args) },
|
||||
},
|
||||
}));
|
||||
|
||||
import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io";
|
||||
import {
|
||||
resolveRestingPulseSeries,
|
||||
type PulseSample,
|
||||
} from "@/lib/analytics/resting-pulse";
|
||||
import { toBerlinDayKey } from "@/lib/tz/resolver";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { percentile } from "@/lib/insights/strain-score";
|
||||
import { localDayWindow } from "@/lib/measurements/consolidation-tz";
|
||||
|
||||
/** Mirrors the private `median()` helper in `intraday-pulse-io.ts`. */
|
||||
function median(values: number[]): number | null {
|
||||
if (values.length === 0) return null;
|
||||
return Math.round(percentile(values, 50) * 10) / 10;
|
||||
}
|
||||
|
||||
describe("loadIntradayPulse", () => {
|
||||
beforeEach(() => {
|
||||
findManyMock.mockReset();
|
||||
workoutFindManyMock.mockReset();
|
||||
workoutFindManyMock.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("M2 — queries PULSE/step rows against the exact local-day window, not the padded lead/trail superset", async () => {
|
||||
const tz = "America/New_York";
|
||||
const dateKey = "2026-06-15";
|
||||
const { dayStart, dayEnd } = localDayWindow(dateKey, tz);
|
||||
|
||||
findManyMock.mockResolvedValue([]);
|
||||
|
||||
await loadIntradayPulse("user-1", tz, dateKey);
|
||||
|
||||
const pulseCall = findManyMock.mock.calls.find(
|
||||
(call) => call[0]?.where?.type === "PULSE",
|
||||
);
|
||||
const stepCall = findManyMock.mock.calls.find(
|
||||
(call) => call[0]?.where?.type === "ACTIVITY_STEPS",
|
||||
);
|
||||
expect(pulseCall).toBeDefined();
|
||||
expect(stepCall).toBeDefined();
|
||||
expect(pulseCall![0].where.measuredAt).toEqual({
|
||||
gte: dayStart,
|
||||
lt: dayEnd,
|
||||
});
|
||||
expect(stepCall![0].where.measuredAt).toEqual({
|
||||
gte: dayStart,
|
||||
lt: dayEnd,
|
||||
});
|
||||
});
|
||||
|
||||
it("M5 — the PULSE-proxy baseline buckets by the caller's timezone, not Berlin", async () => {
|
||||
const tz = "Pacific/Kiritimati"; // UTC+14, no DST — deterministic offset
|
||||
const dateKey = "2026-06-15";
|
||||
|
||||
// Two UTC instants that land on DIFFERENT Kiritimati calendar days but
|
||||
// the SAME Berlin calendar day (Kiritimati is ~13h ahead of Berlin in
|
||||
// June) — a clean tz-bucketing fork.
|
||||
const dayA = [
|
||||
new Date("2026-06-10T09:00:00.000Z"), // Kiritimati: 2026-06-10T23:00 (day A)
|
||||
new Date("2026-06-10T10:00:00.000Z"),
|
||||
new Date("2026-06-10T11:00:00.000Z"),
|
||||
];
|
||||
const dayB = [
|
||||
new Date("2026-06-10T12:00:00.000Z"), // Kiritimati: 2026-06-11T02:00 (day B)
|
||||
new Date("2026-06-10T13:00:00.000Z"),
|
||||
new Date("2026-06-10T14:00:00.000Z"),
|
||||
];
|
||||
expect(userDayKey(dayA[0], tz)).not.toBe(userDayKey(dayB[0], tz));
|
||||
// Sanity: under the OLD hardcoded-Berlin bug all six samples fall on the
|
||||
// SAME Berlin day — proving the two tz groupings genuinely diverge.
|
||||
const berlinDays = new Set(
|
||||
[...dayA, ...dayB].map((d) => toBerlinDayKey(d)),
|
||||
);
|
||||
expect(berlinDays.size).toBe(1);
|
||||
|
||||
const pulseHistory: Array<{ value: number; measuredAt: Date }> = [
|
||||
...dayA.map((measuredAt, i) => ({ value: 50 + i, measuredAt })),
|
||||
...dayB.map((measuredAt, i) => ({ value: 80 + i, measuredAt })),
|
||||
];
|
||||
|
||||
findManyMock.mockImplementation(
|
||||
async (args: { where: { type: string } }) => {
|
||||
if (args.where.type === "PULSE") return [];
|
||||
if (args.where.type === "RESTING_HEART_RATE") return [];
|
||||
return [];
|
||||
},
|
||||
);
|
||||
// `resolveBaseline` fires a SECOND PULSE read (take: 365, no window) when
|
||||
// resting rows are thin — swap the mock to serve it on the second PULSE
|
||||
// call specifically by checking call order isn't reliable, so key off
|
||||
// `select`/`take` shape instead.
|
||||
findManyMock.mockImplementation(
|
||||
async (args: {
|
||||
where: { type: string; measuredAt?: unknown };
|
||||
take?: number;
|
||||
}) => {
|
||||
if (args.where.type === "RESTING_HEART_RATE") return [];
|
||||
if (args.where.type === "PULSE" && args.take === 365) {
|
||||
return pulseHistory;
|
||||
}
|
||||
// The view-day-windowed PULSE / ACTIVITY_STEPS reads — irrelevant here.
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
const result = await loadIntradayPulse("user-1", tz, dateKey);
|
||||
|
||||
expect(result.baselineSource).toBe("proxy");
|
||||
|
||||
const expected = resolveRestingPulseSeries({
|
||||
restingSamples: [],
|
||||
pulseSamples: pulseHistory as PulseSample[],
|
||||
dayKeyOf: (d) => userDayKey(d, tz),
|
||||
});
|
||||
const expectedBaseline = median(
|
||||
expected.series.slice(-30).map((p) => p.value),
|
||||
);
|
||||
expect(result.baseline).toBe(expectedBaseline);
|
||||
|
||||
// Regression guard: the OLD hardcoded-Berlin bucketing would have folded
|
||||
// every sample into ONE day (see the `berlinDays.size === 1` sanity
|
||||
// check above) and produced a materially different proxy value — assert
|
||||
// the fixed result does NOT match that wrong grouping's outcome.
|
||||
const buggyBerlin = resolveRestingPulseSeries({
|
||||
restingSamples: [],
|
||||
pulseSamples: pulseHistory as PulseSample[],
|
||||
dayKeyOf: (d) => toBerlinDayKey(d),
|
||||
});
|
||||
const buggyBaseline = median(
|
||||
buggyBerlin.series.slice(-30).map((p) => p.value),
|
||||
);
|
||||
expect(result.baseline).not.toBe(buggyBaseline);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,8 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { percentile } from "@/lib/insights/strain-score";
|
||||
import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse";
|
||||
import { localDayWindow } from "@/lib/measurements/consolidation-tz";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import {
|
||||
BUCKET_MINUTES,
|
||||
HOURLY_BUCKET_MINUTES,
|
||||
@@ -73,8 +75,18 @@ function median(values: number[]): number | null {
|
||||
* baseline is the median of the resolved daily series — robust to the odd
|
||||
* outlier — and is "mature" only once at least `MIN_BASELINE_DAYS` distinct
|
||||
* daily points exist.
|
||||
*
|
||||
* `timezone` buckets the PULSE-proxy fallback in the USER's local day
|
||||
* (DATAINT M5) — the caller previously omitted it, so
|
||||
* `deriveRestingProxyFromPulse` defaulted to Berlin-day buckets for every
|
||||
* user. A non-Berlin user's late-evening (often workout-heavy) samples then
|
||||
* folded into the wrong day's bucket, shifting the daily floor the tension
|
||||
* detector's `baseline + 12 bpm` threshold judges the day against.
|
||||
*/
|
||||
async function resolveBaseline(userId: string): Promise<{
|
||||
async function resolveBaseline(
|
||||
userId: string,
|
||||
timezone: string,
|
||||
): Promise<{
|
||||
baseline: number | null;
|
||||
mature: boolean;
|
||||
source: "resting" | "proxy" | "none";
|
||||
@@ -106,6 +118,7 @@ async function resolveBaseline(userId: string): Promise<{
|
||||
measuredAt: r.measuredAt,
|
||||
value: r.value,
|
||||
})),
|
||||
dayKeyOf: (d) => userDayKey(d, timezone),
|
||||
});
|
||||
|
||||
const recent = resolved.series.slice(-30).map((p) => p.value);
|
||||
@@ -148,6 +161,18 @@ export async function loadIntradayPulse(
|
||||
const anchorMs = new Date(`${dateKey}T00:00:00.000Z`).getTime();
|
||||
const start = new Date(anchorMs - WINDOW_LEAD_MS);
|
||||
const end = new Date(anchorMs + WINDOW_TRAIL_MS);
|
||||
// DATAINT M2 — PULSE/step reads are bounded to the EXACT local-day window
|
||||
// (DST-aware; 23/24/25h), not the ±15h/39h padded superset. The prior
|
||||
// padded read meant `take: MAX_DAY_*_ROWS` (an ascending cap) could be
|
||||
// entirely consumed by the PREVIOUS local day's samples (e.g. a
|
||||
// workout-heavy evening) before reaching the viewed day at all on a dense
|
||||
// account — the cap now only ever spends against rows that are actually
|
||||
// inside `dateKey`. `computeTenMinuteMeanSeries` / the step-bucket loop
|
||||
// already re-filter by `dayKey` defensively, so this is a pure narrowing.
|
||||
// The workout-overlap read keeps the wider padded window: it has no `take`
|
||||
// cap, so there is no truncation risk, and a workout can legitimately
|
||||
// start the local-day before or end the local-day after.
|
||||
const { dayStart, dayEnd } = localDayWindow(dateKey, timezone);
|
||||
|
||||
const [pulseRows, stepRows, workoutRows, baseline] = await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
@@ -155,7 +180,7 @@ export async function loadIntradayPulse(
|
||||
userId,
|
||||
type: "PULSE",
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: start, lte: end },
|
||||
measuredAt: { gte: dayStart, lt: dayEnd },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
take: MAX_DAY_PULSE_ROWS,
|
||||
@@ -166,7 +191,7 @@ export async function loadIntradayPulse(
|
||||
userId,
|
||||
type: "ACTIVITY_STEPS",
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: start, lte: end },
|
||||
measuredAt: { gte: dayStart, lt: dayEnd },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
take: MAX_DAY_STEP_ROWS,
|
||||
@@ -180,7 +205,7 @@ export async function loadIntradayPulse(
|
||||
},
|
||||
select: { startedAt: true, endedAt: true },
|
||||
}),
|
||||
resolveBaseline(userId),
|
||||
resolveBaseline(userId, timezone),
|
||||
]);
|
||||
|
||||
// Split live PULSE rows into raw per-sample rows and already-folded
|
||||
|
||||
Reference in new issue
Block a user