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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-20 17:10:36 +02:00
1 parent 320c33b918
commit a9c327401a
3 files changed
+57 -10

No files matched your search

+20 -4
View File
@@ -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<number | null> {
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<Derived<ReadinessValue>> {
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,
),
]);
+20 -6
View File
@@ -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;
}
+17
View File
@@ -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;