mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
`instant.toISOString().slice(0, 10)` and parsing a key back as UTC midnight are the idioms behind every day-boundary fix this year: the evening west of UTC and the small hours east of it land on the neighbouring day. A new rule, healthlog/no-utc-day-key, refuses them in src/ outside src/lib/tz and tests, and refuses a disable directive for itself that is not next-line or gives no reason. The time zone module now names every legitimate form: userDayKey for a person's day, dateOnlyKey / dateOnlyAtNoonUtc / dayKeyAsUtcMidnight for date-only values and day labels, daysBetweenDateKeys / weekdayOfDateKey / isCalendarDateKey / statedDateKey for key arithmetic and parsing. Of the 133 sites the rule found, 84 are gone. Most moved onto a named helper with no change in behaviour; these changed behaviour: - cycle calendar, insights, Coach snapshot and prediction refresh start their lookbacks at the first instant of the user's day - a model-transcribed date like "June 10, 2026" no longer lands a day early in a process east of UTC (documents and lab scans) - a custom report range covers exactly the picked days in the profile zone instead of starting the evening before west of UTC - date-of-birth pickers cap at the user's today, not the UTC one - an allergy onset date is stored at noon UTC like other date-only values - the report's cycle summary, the Coach's usual BP range and the intake projector's rollup recompute use the user's day - vaccination dates render as dates instead of the previous evening 49 remain, each with its reason on the line above: 39 UTC by design (15 file name stamps, rollup and object keys, the UTC-day score engines) and 10 marked baseline. A guard test requires a classified reason on every directive and holds the baseline to a ceiling that only goes down.
386 lines
15 KiB
TypeScript
386 lines
15 KiB
TypeScript
/**
|
||
* S11 — IO seam for the intraday pulse / tension layer.
|
||
*
|
||
* Loads ONE local day's raw signals on demand (read-swap; never persisted as
|
||
* 10-min rollups) and hands them to the pure `intraday-pulse` computations:
|
||
*
|
||
* - raw PULSE for the day → the 10-minute mean shape;
|
||
* - the personal resting baseline (RESTING_HEART_RATE, else the
|
||
* low-percentile PULSE proxy) → the elevation line;
|
||
* - intraday step samples → the mandatory low-movement gate;
|
||
* - workouts overlapping the day → exercise never reads as tension;
|
||
* - intraday HRV (WHOOP RMSSD) below the personal median → an optional
|
||
* confirming flag.
|
||
*
|
||
* Shared by `GET /api/insights/pulse/intraday` (the chart) and the daily
|
||
* digest's `tension_window` builder, so the signal is computed one way.
|
||
*/
|
||
import { prisma } from "@/lib/db";
|
||
import { annotate } from "@/lib/logging/context";
|
||
import { percentile } from "@/lib/insights/strain-score";
|
||
import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse";
|
||
import { localDayWindow } from "@/lib/tz/local-day";
|
||
import { userDayKey } from "@/lib/tz/format";
|
||
import {
|
||
BUCKET_MINUTES,
|
||
HOURLY_BUCKET_MINUTES,
|
||
MIN_BASELINE_DAYS,
|
||
computeHourlyMeanSeries,
|
||
computeTenMinuteMeanSeries,
|
||
computeUploadedBucketSeries,
|
||
detectTensionWindow,
|
||
makeLocalResolver,
|
||
type IntradayHrBucket,
|
||
type IntradayResolution,
|
||
type LocalResolver,
|
||
type TensionWindow,
|
||
type WorkoutInterval,
|
||
} from "@/lib/analytics/intraday-pulse";
|
||
import {
|
||
isAggregatedBucketExternalId,
|
||
parseAggregatedBucketStart,
|
||
} from "@/lib/measurements/apple-health-mapping";
|
||
|
||
/** UTC padding around the local calendar day so the query is a safe superset. */
|
||
const WINDOW_LEAD_MS = 15 * 60 * 60 * 1000; // covers UTC−14…+12 day starts
|
||
const WINDOW_TRAIL_MS = 39 * 60 * 60 * 1000;
|
||
|
||
/** Defensive read caps — dense Apple-Health accounts emit ~1 sample/second. */
|
||
const MAX_DAY_PULSE_ROWS = 6000;
|
||
const MAX_DAY_STEP_ROWS = 2000;
|
||
|
||
/** The DTO both the route and the digest read. */
|
||
export interface IntradayPulseResult {
|
||
dateKey: string;
|
||
timezone: string;
|
||
bucketMinutes: number;
|
||
series: IntradayHrBucket[];
|
||
baseline: number | null;
|
||
baselineSource: "resting" | "proxy" | "none";
|
||
tension: TensionWindow | null;
|
||
/**
|
||
* S11 day navigator — `"tenMin"` when `series` was folded from live raw
|
||
* per-sample rows, `"hourly"` when the day fell outside
|
||
* `DENSE_INTRADAY_RETENTION_DAYS` and `series` is the coarser fallback
|
||
* read off the folded `stats:` hourly-mean tier instead. `tension` is
|
||
* always `null` on an `"hourly"` day — see `detectTensionWindow`'s
|
||
* docblock for why a tension read needs per-sample resolution.
|
||
*/
|
||
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;
|
||
return Math.round(percentile(values, 50) * 10) / 10;
|
||
}
|
||
|
||
/**
|
||
* Resolve the personal resting baseline and its maturity. Prefers the clean
|
||
* RESTING_HEART_RATE rows; falls back to the low-percentile PULSE proxy. The
|
||
* 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,
|
||
timezone: string,
|
||
): Promise<{
|
||
baseline: number | null;
|
||
mature: boolean;
|
||
source: "resting" | "proxy" | "none";
|
||
}> {
|
||
const restingRows = await prisma.measurement.findMany({
|
||
where: { userId, type: "RESTING_HEART_RATE", deletedAt: null },
|
||
orderBy: { measuredAt: "desc" },
|
||
take: 90,
|
||
select: { value: true, measuredAt: true },
|
||
});
|
||
|
||
// 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 && !restingIsStale
|
||
? []
|
||
: await prisma.measurement.findMany({
|
||
where: { userId, type: "PULSE", deletedAt: null },
|
||
orderBy: { measuredAt: "desc" },
|
||
take: 365,
|
||
select: { value: true, measuredAt: true },
|
||
});
|
||
|
||
const resolved = resolveRestingPulseSeries({
|
||
restingSamples: restingRows.map((r) => ({
|
||
measuredAt: r.measuredAt,
|
||
value: r.value,
|
||
})),
|
||
pulseSamples: pulseHistory.map((r) => ({
|
||
measuredAt: r.measuredAt,
|
||
value: r.value,
|
||
})),
|
||
dayKeyOf: (d) => userDayKey(d, timezone),
|
||
});
|
||
|
||
const recent = resolved.series.slice(-30).map((p) => p.value);
|
||
return {
|
||
baseline: median(recent),
|
||
mature: resolved.series.length >= MIN_BASELINE_DAYS,
|
||
source: resolved.which,
|
||
};
|
||
}
|
||
|
||
/** Clamp a workout's local span to [0, 1440) minutes on `dateKey`, or null. */
|
||
function workoutToInterval(
|
||
startedAt: Date,
|
||
endedAt: Date,
|
||
dateKey: string,
|
||
localOf: LocalResolver,
|
||
): WorkoutInterval | null {
|
||
const s = localOf(startedAt);
|
||
const e = localOf(endedAt);
|
||
// Reject spans entirely outside the day (both ends on another calendar day
|
||
// on the same side); otherwise clamp an overhanging end into the day.
|
||
const startMinute =
|
||
s.dayKey === dateKey ? s.minuteOfDay : s.dayKey < dateKey ? 0 : -1;
|
||
const endMinute =
|
||
e.dayKey === dateKey ? e.minuteOfDay : e.dayKey > dateKey ? 24 * 60 : -1;
|
||
if (startMinute < 0 || endMinute < 0 || endMinute <= startMinute) return null;
|
||
return { startMinute, endMinute };
|
||
}
|
||
|
||
/**
|
||
* Compute the intraday pulse shape + (cautious) tension window for one local
|
||
* day. Deterministic given the DB state; performs no AI/provider call.
|
||
*/
|
||
export async function loadIntradayPulse(
|
||
userId: string,
|
||
timezone: string,
|
||
dateKey: string,
|
||
): Promise<IntradayPulseResult> {
|
||
const localOf = makeLocalResolver(timezone);
|
||
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: anchor of a superset read window; rows are cut to the exact local day afterwards
|
||
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({
|
||
where: {
|
||
userId,
|
||
type: "PULSE",
|
||
deletedAt: null,
|
||
measuredAt: { gte: dayStart, lt: dayEnd },
|
||
},
|
||
orderBy: { measuredAt: "asc" },
|
||
take: MAX_DAY_PULSE_ROWS,
|
||
select: {
|
||
value: true,
|
||
valueMin: true,
|
||
valueMax: true,
|
||
measuredAt: true,
|
||
externalId: true,
|
||
},
|
||
}),
|
||
prisma.measurement.findMany({
|
||
where: {
|
||
userId,
|
||
type: "ACTIVITY_STEPS",
|
||
deletedAt: null,
|
||
measuredAt: { gte: dayStart, lt: dayEnd },
|
||
},
|
||
orderBy: { measuredAt: "asc" },
|
||
take: MAX_DAY_STEP_ROWS,
|
||
select: { value: true, measuredAt: true, externalId: true },
|
||
}),
|
||
prisma.workout.findMany({
|
||
where: {
|
||
userId,
|
||
startedAt: { lt: end },
|
||
endedAt: { gt: start },
|
||
},
|
||
select: { startedAt: true, endedAt: true },
|
||
}),
|
||
resolveBaseline(userId, timezone),
|
||
]);
|
||
|
||
// v1.30.7 (iOS #34) — classify the day's PULSE rows three ways by externalId
|
||
// shape (the shapes are disjoint, so this is unambiguous):
|
||
// · raw per-sample — no `stats:` prefix;
|
||
// · uploaded 10-min bucket — `stats:HK…:<10-min ISO instant with Z>`
|
||
// (the go-forward aggregated wire contract);
|
||
// · server-folded hourly — any other `stats:` row (the dense-intraday
|
||
// retention fold's `stats:<HK>:<day>T<HH>` local-hour output).
|
||
// Per the per-day-exclusive cutover invariant a day is normally single-grain;
|
||
// the one cutover-straddling local day is handled by the overlay below.
|
||
const rawPulseRows = pulseRows.filter(
|
||
(r) => !r.externalId?.startsWith("stats:"),
|
||
);
|
||
const uploadedBucketRows = pulseRows.filter((r) =>
|
||
isAggregatedBucketExternalId(r.externalId),
|
||
);
|
||
const foldedPulseRows = pulseRows.filter(
|
||
(r) =>
|
||
r.externalId?.startsWith("stats:") &&
|
||
!isAggregatedBucketExternalId(r.externalId),
|
||
);
|
||
|
||
const rawSeries = computeTenMinuteMeanSeries(
|
||
rawPulseRows.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
|
||
dateKey,
|
||
localOf,
|
||
);
|
||
const bucketSeries = computeUploadedBucketSeries(
|
||
uploadedBucketRows.flatMap((r) => {
|
||
const bucketStart = parseAggregatedBucketStart(r.externalId);
|
||
return bucketStart
|
||
? [{ bucketStart, mean: r.value, min: r.valueMin, max: r.valueMax }]
|
||
: [];
|
||
}),
|
||
dateKey,
|
||
localOf,
|
||
);
|
||
|
||
// Merge the two 10-min grains. Normal days are single-grain, so one of the
|
||
// two is empty. The cutover-straddling local day is the only mix: overlay the
|
||
// uploaded buckets onto the raw slots, bucket authoritative on collision
|
||
// (the aggregate is the intended go-forward value for that slot).
|
||
let tenMinSeries: IntradayHrBucket[];
|
||
if (bucketSeries.length > 0 && rawSeries.length > 0) {
|
||
const bySlot = new Map(rawSeries.map((b) => [b.startMinute, b]));
|
||
for (const b of bucketSeries) bySlot.set(b.startMinute, b);
|
||
tenMinSeries = [...bySlot.values()].sort(
|
||
(a, b) => a.startMinute - b.startMinute,
|
||
);
|
||
} else {
|
||
tenMinSeries = bucketSeries.length > 0 ? bucketSeries : rawSeries;
|
||
}
|
||
|
||
// Day-navigator fallback (S11 / v1.29.x): a day outside
|
||
// `DENSE_INTRADAY_RETENTION_DAYS` has its raw rows tombstoned by the
|
||
// retention fold, so `tenMinSeries` comes back empty even though the day's
|
||
// shape survives at hour resolution in the folded `stats:` tier. Fall back
|
||
// to that coarser read rather than rendering an empty chart. A day with
|
||
// genuinely no data at all (never synced) stays on the empty "tenMin"
|
||
// series — `foldedPulseRows` is empty there too, so `hourlySeries` is
|
||
// empty and the branch below is skipped.
|
||
const useHourlyFallback = tenMinSeries.length === 0;
|
||
const hourlySeries = useHourlyFallback
|
||
? computeHourlyMeanSeries(
|
||
foldedPulseRows.map((r) => ({
|
||
measuredAt: r.measuredAt,
|
||
value: r.value,
|
||
})),
|
||
dateKey,
|
||
localOf,
|
||
)
|
||
: [];
|
||
|
||
const resolution: IntradayResolution =
|
||
useHourlyFallback && hourlySeries.length > 0 ? "hourly" : "tenMin";
|
||
const series = resolution === "hourly" ? hourlySeries : tenMinSeries;
|
||
|
||
// Intraday step buckets — EXCLUDE the consolidated `stats:` daily totals, or a
|
||
// single day-sum row would dump the whole day's steps into one bucket and
|
||
// wrongly mark an at-rest stretch as moving.
|
||
const stepBuckets = new Map<number, number>();
|
||
for (const row of stepRows) {
|
||
if (row.externalId?.startsWith("stats:")) continue;
|
||
const local = localOf(row.measuredAt);
|
||
if (local.dayKey !== dateKey) continue;
|
||
const startMinute =
|
||
Math.floor(local.minuteOfDay / BUCKET_MINUTES) * BUCKET_MINUTES;
|
||
stepBuckets.set(
|
||
startMinute,
|
||
(stepBuckets.get(startMinute) ?? 0) + row.value,
|
||
);
|
||
}
|
||
|
||
const workouts = workoutRows
|
||
.map((w) => workoutToInterval(w.startedAt, w.endedAt, dateKey, localOf))
|
||
.filter((w): w is WorkoutInterval => w !== null);
|
||
|
||
// Tension needs per-sample resolution (see `detectTensionWindow`'s
|
||
// docblock) — never compute it against the hourly fallback, regardless of
|
||
// what the (independently folded) step tier happens to report.
|
||
const tension =
|
||
resolution === "tenMin"
|
||
? detectTensionWindow({
|
||
buckets: series,
|
||
baseline: baseline.baseline,
|
||
baselineMature: baseline.mature,
|
||
stepBuckets,
|
||
workouts,
|
||
hrvConfirmMinutes: [],
|
||
})
|
||
: null;
|
||
|
||
// v1.32.1 (issue #585) — per-shape row/bucket breakdown. A report of
|
||
// "the chart looks sparser after the ZIP-import cutoff" is otherwise
|
||
// undiagnosable from server logs: the route's own annotation only ever
|
||
// surfaced the FINAL bucket count, with no visibility into which
|
||
// ingestion shape (raw ZIP-imported samples vs uploaded 10-min
|
||
// aggregates) the day's data actually came from, nor how many DB rows
|
||
// fed each shape before bucketing. Grepping `insights.pulse.intraday`
|
||
// now shows both — e.g. `uploaded_bucket_rows: 6` next to
|
||
// `ten_min_buckets: 6` confirms the uploaded shape IS being read and
|
||
// placed 1:1, narrowing a future report to "the client uploaded too few
|
||
// buckets" rather than leaving the read path itself as a live suspect.
|
||
annotate({
|
||
meta: {
|
||
intraday_raw_sample_rows: rawPulseRows.length,
|
||
intraday_uploaded_bucket_rows: uploadedBucketRows.length,
|
||
intraday_folded_hourly_rows: foldedPulseRows.length,
|
||
intraday_ten_min_buckets: tenMinSeries.length,
|
||
},
|
||
});
|
||
|
||
return {
|
||
dateKey,
|
||
timezone,
|
||
bucketMinutes:
|
||
resolution === "hourly" ? HOURLY_BUCKET_MINUTES : BUCKET_MINUTES,
|
||
series,
|
||
baseline: baseline.baseline,
|
||
baselineSource: baseline.source,
|
||
tension,
|
||
resolution,
|
||
};
|
||
}
|