From a9c327401af1fa243347977f47318aed509bb3a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 17:10:36 +0200 Subject: [PATCH] fix(insights): key readiness + mood-streak day buckets in user tz The readiness same-day grouping and the mood-streak anchor/walk both sliced the UTC instant for their day key, mis-bucketing a late-evening reading for a logger east or west of UTC. Thread an optional tz (default Europe/Berlin) through both paths and key the buckets via userDayKey so the day boundary follows the user's wall clock. Centralise the inline day-in-ms literal in a shared time-constants module and use it here. --- src/lib/insights/derived/readiness.ts | 24 ++++++++++++++++++++---- src/lib/insights/mood-narratives.ts | 26 ++++++++++++++++++++------ src/lib/time-constants.ts | 17 +++++++++++++++++ 3 files changed, 57 insertions(+), 10 deletions(-) create mode 100644 src/lib/time-constants.ts diff --git a/src/lib/insights/derived/readiness.ts b/src/lib/insights/derived/readiness.ts index 80c22acd4..87e1c5603 100644 --- a/src/lib/insights/derived/readiness.ts +++ b/src/lib/insights/derived/readiness.ts @@ -40,6 +40,8 @@ import { type RollupCoverageMap, } from "@/lib/rollups/measurement-coverage"; import { computeMoodStability } from "@/lib/insights/mood-aggregates"; +import { MS_PER_DAY } from "@/lib/time-constants"; +import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format"; import { buildInsufficient, buildOk, @@ -50,7 +52,6 @@ import { computeVitalsBaseline, type BaselineProfile } from "./baseline"; import { computeSleepScore } from "./sleep-score"; import type { Derived, DerivedProvenanceSource } from "./types"; -const MS_PER_DAY = 24 * 60 * 60 * 1000; const DEFAULT_WINDOW_DAYS = 30; /** * Row cap for the latest-day mean read. A dense intra-day day (high-frequency @@ -171,6 +172,7 @@ async function readLatestDayMean( type: MeasurementType, windowDays: number, now: Date, + tz: string, ): Promise { const since = new Date(now.getTime() - windowDays * MS_PER_DAY); const rows = await prisma.measurement.findMany({ @@ -181,13 +183,15 @@ async function readLatestDayMean( }); if (rows.length === 0) return null; // Derive the most-recent day defensively (do not assume the DB ordering). + // Day-keys are minted in the user's timezone so a 23:30-local reading lands + // on the right "today" bucket regardless of UTC offset. let latestDay = ""; for (const r of rows) { - const d = r.measuredAt.toISOString().slice(0, 10); + const d = userDayKey(r.measuredAt, tz); if (d > latestDay) latestDay = d; } const sameDay = rows.filter( - (r) => r.measuredAt.toISOString().slice(0, 10) === latestDay, + (r) => userDayKey(r.measuredAt, tz) === latestDay, ); return sameDay.reduce((s, r) => s + r.value, 0) / sameDay.length; } @@ -216,6 +220,7 @@ async function scoreVitalDeviation( windowDays: number, now: Date, coverage: RollupCoverageMap, + tz: string, ): Promise<{ value: number | null; source: DerivedProvenanceSource; @@ -235,7 +240,7 @@ async function scoreVitalDeviation( historyDays: baseline.coverage.historyDays, }; } - const today = await readLatestDayMean(userId, type, windowDays, now); + const today = await readLatestDayMean(userId, type, windowDays, now, tz); // No recent reading, or a non-finite baseline center / today value: there // is no scorable deviation. Return null (hidden) rather than letting a NaN // fall through `scoreDeviation` → `clamp100`, which would surface a @@ -271,6 +276,13 @@ export interface ReadinessOpts { now?: Date; /** Pre-probed coverage (shared across metrics in one request). */ coverage?: RollupCoverageMap; + /** + * IANA timezone the "same day" grouping is keyed in, so a 23:30-local + * reading lands on the correct readiness day. Defaults to `Europe/Berlin` + * (the canonical tenant zone) when the caller has not resolved the user's + * zone. + */ + tz?: string; } /** @@ -284,6 +296,7 @@ export async function computeReadiness( ): Promise> { const windowDays = opts.windowDays ?? DEFAULT_WINDOW_DAYS; const now = opts.now ?? new Date(); + const tz = opts.tz ?? DEFAULT_TIMEZONE; const computedAt = nowProvenanceTimestamp(now); const coverage = opts.coverage ?? (await probeRollupCoverage(userId)); @@ -305,6 +318,7 @@ export async function computeReadiness( windowDays, now, coverage, + tz, ), scoreVitalDeviation( userId, @@ -314,6 +328,7 @@ export async function computeReadiness( windowDays, now, coverage, + tz, ), scoreVitalDeviation( userId, @@ -323,6 +338,7 @@ export async function computeReadiness( windowDays, now, coverage, + tz, ), ]); diff --git a/src/lib/insights/mood-narratives.ts b/src/lib/insights/mood-narratives.ts index 43ed97069..243d26e22 100644 --- a/src/lib/insights/mood-narratives.ts +++ b/src/lib/insights/mood-narratives.ts @@ -16,6 +16,8 @@ import { dayOffsetToBerlinDayKey } from "@/lib/insights/bucket-series"; import { round } from "@/lib/insights/status-shared"; +import { MS_PER_DAY } from "@/lib/time-constants"; +import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format"; import type { StructuredTagRow, TagSummaryRow, @@ -80,6 +82,13 @@ export interface MoodNarrativeInput { /** Day keys (YYYY-MM-DD) the user logged a mood on. */ loggedDayKeys: string[]; now: Date; + /** + * IANA timezone the streak anchor + day-walk are keyed in, so the "today" + * key matches the tz-anchored `loggedDayKeys` for a logger east/west of + * UTC near midnight. Defaults to `Europe/Berlin` when the caller has not + * resolved the user's zone. + */ + tz?: string; } /** i18n label keys for each part-of-day bucket. */ @@ -100,8 +109,6 @@ const WEEKDAY_LABEL_KEYS = [ "charts.weekdaysFull.sun", ] as const; -const MS_PER_DAY = 24 * 60 * 60 * 1000; - function mean(values: number[]): number | null { if (values.length === 0) return null; return values.reduce((sum, v) => sum + v, 0) / values.length; @@ -349,22 +356,29 @@ function inTargetNarrative(input: MoodNarrativeInput): MoodNarrative | null { function streakNarrative(input: MoodNarrativeInput): MoodNarrative | null { if (input.loggedDayKeys.length === 0) return null; + const tz = input.tz ?? DEFAULT_TIMEZONE; const keys = new Set(input.loggedDayKeys); - const todayKey = input.now.toISOString().slice(0, 10); - const todayMs = Date.parse(todayKey + "T00:00:00Z"); + // Anchor "today" in the user's timezone so the key matches the tz-anchored + // `loggedDayKeys` for a late-evening logger east/west of UTC. + const todayKey = userDayKey(input.now, tz); + // Walk the cursor from a NOON-UTC anchor of the day key: ±12h covers every + // IANA offset, so `new Date(cursor)` always re-derives the same calendar day + // in the user's zone regardless of whether they are east or west of UTC. + const noonMs = (key: string) => Date.parse(key + "T12:00:00Z"); + const todayMs = noonMs(todayKey); // Anchor the streak at today if logged, else the most recent logged day. let anchorMs = todayMs; if (!keys.has(todayKey)) { const sorted = [...keys].sort().reverse(); - anchorMs = Date.parse(sorted[0] + "T00:00:00Z"); + anchorMs = noonMs(sorted[0]); // A streak that does not include today is stale — don't congratulate. if (anchorMs !== todayMs) return null; } let run = 0; let cursor = anchorMs; - while (keys.has(new Date(cursor).toISOString().slice(0, 10))) { + while (keys.has(userDayKey(new Date(cursor), tz))) { run += 1; cursor -= MS_PER_DAY; } diff --git a/src/lib/time-constants.ts b/src/lib/time-constants.ts new file mode 100644 index 000000000..e1f8c3b02 --- /dev/null +++ b/src/lib/time-constants.ts @@ -0,0 +1,17 @@ +/** + * Shared duration constants in milliseconds. + * + * Replaces the inline `24 * 60 * 60 * 1000` (and friends) scattered across + * the codebase. Centralising the literal keeps the intent readable at the + * call site and removes the off-by-a-zero risk of hand-typing the product. + * Pure module — no imports, safe to use anywhere (server or client). + */ + +/** One minute in milliseconds. */ +export const MS_PER_MINUTE = 60 * 1000; + +/** One hour in milliseconds. */ +export const MS_PER_HOUR = 60 * MS_PER_MINUTE; + +/** One day in milliseconds. */ +export const MS_PER_DAY = 24 * MS_PER_HOUR;