Files
HealthLog/src/lib/insights/score-row.ts
T
Marc-André BombeckandClaude Opus 4.8 148e6cd600 fix(insights): score the previous day in the nightly score engines
The Recovery/Stress/Strain crons fire in the small hours (04:45-04:55
Europe/Berlin, ~03 UTC) but scored the CURRENT UTC day, only a few hours
old: Strain missed the just-completed day's workouts and Stress saw a
near-empty intra-day SDNN set and falsely gated as insufficient. Score the
day that just ended (now - 1 day) across all three engines and their
measuredAt / externalId day stamps.

Resolve the previous-day key in one place by extracting the shared score-row
helpers the three engines each duplicated: scoreDayKey / scoreMeasuredAt /
scoreExternalId, the idempotent upsertScoreRow, and the cohort runScoreBatch
tally loop into src/lib/insights/score-row.ts; and loadBaselineProfile into
the baseline engine (the three byte-identical profile loaders + the partial
strain variant now route through it). The derived route should adopt
loadBaselineProfile too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:40:24 +02:00

150 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* v1.10.0 — computed scores (WX-C/E). Shared score-row helpers.
*
* The three nightly score engines (Recovery / Stress / Strain) all file one
* `COMPUTED` Measurement row per user per scored day, keyed by the same
* day-stamp triplet (a `prefix:YYYY-MM-DD` externalId, a noon-UTC
* `measuredAt`, the UTC day key) and written through the same idempotent
* `(userId, type, source, externalId)` upsert. Before this module each engine
* carried a byte-identical copy of that triplet + upsert, differing only by
* the `prefix` / `type` literal.
*
* SCORED DAY = THE PREVIOUS UTC DAY. The crons fire ~03:xx UTC (04:45–04:55
* Europe/Berlin). At that moment the CURRENT UTC day is only a few hours old:
* the Strain engine would miss the just-completed day's workouts and the
* Stress engine would see a near-empty intra-day SDNN set and gate as
* `insufficient`. Every engine therefore scores the day that just ENDED —
* `now − 1 day` — resolved once, here, so the externalId / measuredAt / day
* key can never drift between the three.
*
* `scoreDayKey` / `scoreMeasuredAt` / `scoreExternalId` are pure (no Prisma)
* so the engines' day-stamp tests can drive them directly; `upsertScoreRow`
* takes the (worker) client.
*/
import type { MeasurementType, PrismaClient } from "@/generated/prisma/client";
import { annotate } from "@/lib/logging/context";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/**
* The UTC calendar day a score run scores — the PREVIOUS day relative to
* `now`. The cron runs in the small hours; the just-completed day is the one
* with a full set of signals, so we resolve `now − 1 day` and take its UTC
* `YYYY-MM-DD`. Resolved in ONE place so all three engines agree.
*/
export function scoreDayKey(now: Date): string {
return new Date(now.getTime() - MS_PER_DAY).toISOString().slice(0, 10);
}
/**
* The canonical timestamp a stored score row carries: noon UTC on the scored
* (previous) day. Noon (not midnight) keeps the row inside its own UTC day
* under any reasonable display-timezone offset, mirroring the daily-stats
* convention so a chart never mis-buckets the point at a day boundary.
*/
export function scoreMeasuredAt(now: Date): Date {
return new Date(`${scoreDayKey(now)}T12:00:00.000Z`);
}
/**
* The full per-day idempotency-key `externalId` for a score row:
* `prefix:YYYY-MM-DD` over the scored (previous) day. `prefix` carries its
* own trailing colon (`recovery:` / `stress:` / `strain:`).
*/
export function scoreExternalId(prefix: string, now: Date): string {
return `${prefix}${scoreDayKey(now)}`;
}
/**
* Upsert one user's score row for the scored day. Idempotent on the
* `(userId, type, source = COMPUTED, externalId)` key so a re-fired cron tick
* (or a manual re-run) overwrites the day's row in place rather than minting a
* duplicate. The three engines differ only by `type` + `externalId` prefix;
* everything else (source, unit, the noon-UTC `measuredAt`) is shared.
*/
export async function upsertScoreRow(
prisma: PrismaClient,
args: {
userId: string;
type: MeasurementType;
externalIdPrefix: string;
score: number;
now: Date;
},
): Promise<void> {
const externalId = scoreExternalId(args.externalIdPrefix, args.now);
const measuredAt = scoreMeasuredAt(args.now);
await prisma.measurement.upsert({
where: {
userId_type_source_externalId: {
userId: args.userId,
type: args.type,
source: "COMPUTED",
externalId,
},
},
create: {
userId: args.userId,
type: args.type,
source: "COMPUTED",
value: args.score,
unit: "score",
measuredAt,
externalId,
},
update: {
value: args.score,
measuredAt,
},
});
}
/** The tally every score pass returns. */
export interface ScoreBatchResult {
considered: number;
stored: number;
insufficient: number;
errored: number;
}
/**
* Run one score pass over a candidate cohort. Each user is scored
* independently through `persistFn`; a single user's error is recorded and
* the pass continues (one bad account never blocks the cohort). Emits the
* pass tally under `actionName`. This is the identical loop the three nightly
* jobs each carried — only the persist function + the annotate action name
* differed.
*/
export async function runScoreBatch(
userIds: string[],
now: Date,
persistFn: (
userId: string,
now: Date,
) => Promise<{ outcome: "stored" | "insufficient" }>,
actionName: string,
): Promise<ScoreBatchResult> {
let stored = 0;
let insufficient = 0;
let errored = 0;
for (const userId of userIds) {
try {
const result = await persistFn(userId, now);
if (result.outcome === "stored") stored += 1;
else insufficient += 1;
} catch {
errored += 1;
}
}
annotate({
action: {
name: actionName,
details: { considered: userIds.length, stored, insufficient, errored },
},
});
return { considered: userIds.length, stored, insufficient, errored };
}