mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(analytics): thread user timezone through the weight-weekday correlator
The W7 per-user-tz sweep threaded `userTz` through every aggregator on the analytics route except `berlinIsoWeekday()` — the module-level `Intl.DateTimeFormat` stayed pinned to `Europe/Berlin`. A user in `Pacific/Auckland` whose 23:30 weight reading lands at 12:30 the same day in Berlin had their Monday-local reading bucketed under Sunday's weekday column. Pearson/ANOVA on the wrong weekday. Replace the pinned formatter with a per-tz memoised lookup, rename the helper to `isoWeekdayInTz()`, and feed `userTz` through the call site. Also rename the misleading `dateFromBerlinKey()` → `dateFromDayKey()` since the keys it consumes are already anchored to the user's tz upstream. Integration test covers the non-Berlin path: 28 weight readings at a UTC instant where the Berlin and Auckland weekdays disagree; the correlator reaches the gate with every seeded row counted, confirming the helper used the user's tz to bucket.
This commit is contained in:
1 parent
e14466e238
commit
71745b4c63
2 files changed
+259
-15
No files matched your search
@@ -417,7 +417,7 @@ async function computeCorrelationHypotheses(
|
||||
const compliancePct = (slot.taken / slot.expected) * 100;
|
||||
const meanSys = sysValues.reduce((s, v) => s + v, 0) / sysValues.length;
|
||||
bpCompliancePairs.push({
|
||||
date: dateFromBerlinKey(key),
|
||||
date: dateFromDayKey(key),
|
||||
systolic: meanSys,
|
||||
compliancePct,
|
||||
});
|
||||
@@ -454,7 +454,7 @@ async function computeCorrelationHypotheses(
|
||||
const meanPulse =
|
||||
pulseValues.reduce((s, v) => s + v, 0) / pulseValues.length;
|
||||
moodPulsePairs.push({
|
||||
date: dateFromBerlinKey(key),
|
||||
date: dateFromDayKey(key),
|
||||
mood: meanMood,
|
||||
restingPulse: meanPulse,
|
||||
});
|
||||
@@ -463,9 +463,17 @@ async function computeCorrelationHypotheses(
|
||||
|
||||
// ── Hypothesis 3: Weight × weekday ──────────────────────────
|
||||
// 0 = Monday … 6 = Sunday. ISO weekday minus 1.
|
||||
//
|
||||
// v1.4.25 W10 reconcile (Code-H1) — buckets the weekday by the
|
||||
// user's display tz, matching W7's per-user-tz threading that the
|
||||
// BP, mood, sleep, and pulse aggregators above already honour. The
|
||||
// pre-W10 helper was pinned to `Europe/Berlin` and would land a
|
||||
// user-local 23:30 weight reading from `Pacific/Auckland` under
|
||||
// Sunday's bucket instead of Monday's — Pearson on the wrong
|
||||
// weekday column.
|
||||
const weightWeekdayPairs: Array<{ weekday: number; weight: number }> = [];
|
||||
for (const row of weightRows) {
|
||||
const isoWeekday = berlinIsoWeekday(row.measuredAt); // 1..7, 1=Mon
|
||||
const isoWeekday = isoWeekdayInTz(row.measuredAt, userTz); // 1..7, 1=Mon
|
||||
weightWeekdayPairs.push({
|
||||
weekday: isoWeekday - 1,
|
||||
weight: row.value,
|
||||
@@ -592,18 +600,34 @@ async function fetchMeasurementSeriesChunked(
|
||||
// `src/lib/analytics/berlin-day.ts` so the targets route's sparkline
|
||||
// bucketing shares the same Europe/Berlin contract. The
|
||||
// `weekday: "short"` formatter still lives here because it's only
|
||||
// used by `berlinIsoWeekday()` below.
|
||||
const BERLIN_DATE_PARTS = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
weekday: "short",
|
||||
});
|
||||
// used by `isoWeekdayInTz()` below.
|
||||
//
|
||||
// v1.4.25 W10 reconcile (Code-H1) — formatter is now per-tz so the
|
||||
// weight-weekday correlator honours the same per-user-tz contract the
|
||||
// BP/mood/pulse aggregators above already use. Memoised by tz so the
|
||||
// formatter is built once per unique timezone per process (tz strings
|
||||
// rarely change at runtime; the cache is bounded by the IANA database
|
||||
// to a few hundred entries even in the worst case).
|
||||
const WEEKDAY_FORMATTER_CACHE = new Map<string, Intl.DateTimeFormat>();
|
||||
function getWeekdayFormatter(timeZone: string): Intl.DateTimeFormat {
|
||||
let formatter = WEEKDAY_FORMATTER_CACHE.get(timeZone);
|
||||
if (!formatter) {
|
||||
formatter = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
weekday: "short",
|
||||
});
|
||||
WEEKDAY_FORMATTER_CACHE.set(timeZone, formatter);
|
||||
}
|
||||
return formatter;
|
||||
}
|
||||
|
||||
function dateFromBerlinKey(key: string): Date {
|
||||
function dateFromDayKey(key: string): Date {
|
||||
// Anchor to UTC midnight — the date is a sortable bucket label rather
|
||||
// than a wall-clock timestamp, so DST drift is irrelevant.
|
||||
// than a wall-clock timestamp, so DST drift is irrelevant. The tz
|
||||
// info is already baked into the key (built via `userDayKey` upstream).
|
||||
return new Date(`${key}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
@@ -617,12 +641,13 @@ const ISO_WEEKDAY: Record<string, number> = {
|
||||
Sun: 7,
|
||||
};
|
||||
|
||||
function berlinIsoWeekday(d: Date): number {
|
||||
const parts = BERLIN_DATE_PARTS.formatToParts(d);
|
||||
function isoWeekdayInTz(d: Date, timeZone: string): number {
|
||||
const parts = getWeekdayFormatter(timeZone).formatToParts(d);
|
||||
const weekday = parts.find((p) => p.type === "weekday")?.value ?? "Mon";
|
||||
return ISO_WEEKDAY[weekday] ?? 1;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* v1.4.25 W8e — collapse the persisted `MeasurementSource` enum onto
|
||||
* the camelCase token set the health-score analytics layer consumes.
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* v1.4.25 W10 reconcile (Code-H1) — weight-weekday correlator honours
|
||||
* the user's display timezone.
|
||||
*
|
||||
* Before W10 the analytics route's `berlinIsoWeekday()` helper was
|
||||
* pinned to `Europe/Berlin` regardless of the requesting user's
|
||||
* timezone. A weight reading recorded at 23:30 in `Pacific/Auckland`
|
||||
* (UTC+12, summer) lands at 11:30 the same day in Berlin — so a Monday
|
||||
* reading in the user's local frame got bucketed under Sunday and the
|
||||
* Pearson/ANOVA test ran against the wrong weekday column.
|
||||
*
|
||||
* This test pins the contract: weight readings inserted at a UTC
|
||||
* instant that maps to one weekday in Berlin and a different weekday in
|
||||
* the user's tz are bucketed against the user's tz. We seed exactly
|
||||
* enough readings for the analytics correlator's `n >= 20` gate (W6
|
||||
* raised the floor) and verify the helper used the user's tz to bucket
|
||||
* them by reading the wide-event `correlations.weightWeekday` status
|
||||
* tag plus the route response.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
interface AnalyticsEnvelope {
|
||||
data: {
|
||||
correlations: {
|
||||
weightWeekday: {
|
||||
status: string;
|
||||
n?: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
async function seedSession(username: string, timezone: string) {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
email: `${username}@example.test`,
|
||||
role: "USER",
|
||||
timezone,
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format `Intl.DateTimeFormat` weekday short-name for a UTC instant
|
||||
* in a target timezone. Mirrors the route's internal helper so the
|
||||
* test asserts the same contract the route exposes.
|
||||
*/
|
||||
function isoWeekdayShort(d: Date, timeZone: string): string {
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone,
|
||||
weekday: "short",
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
describe("GET /api/analytics — weight-weekday correlator honours user tz", () => {
|
||||
it("buckets a non-Berlin user's late-evening readings against the user-local weekday", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await seedSession("auckland-weight-user", "Pacific/Auckland");
|
||||
|
||||
// 28 readings, one per day across four weeks ending today. Each
|
||||
// reading is anchored to the moment when the Berlin weekday and the
|
||||
// Auckland weekday differ — late-evening UTC (~12:30 UTC) is
|
||||
// Auckland's next-day 00:30 (NZST = UTC+12) but Berlin's same-day
|
||||
// 14:30 (CEST = UTC+2). The weekday computed against `Europe/Berlin`
|
||||
// differs from the weekday computed against `Pacific/Auckland` for
|
||||
// every row in this dataset. The correlator filters to the trailing
|
||||
// 30 days, so the fixture must end near `Date.now()`.
|
||||
const now = new Date();
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
// Build candidate UTC instants and find one where Berlin/Auckland
|
||||
// weekdays differ. 12:30 UTC ⇒ Auckland +12 = 00:30 next-day,
|
||||
// Berlin +2 (CEST in May 2026) = 14:30 same-day. The weekday
|
||||
// difference is stable across the 28-day window because both tzs
|
||||
// have no DST inside it.
|
||||
const candidate = new Date(now);
|
||||
candidate.setUTCHours(12, 30, 0, 0);
|
||||
// Push the candidate to the most recent UTC instant in the past so
|
||||
// every seeded row has measuredAt < now.
|
||||
if (candidate.getTime() > now.getTime()) {
|
||||
candidate.setUTCDate(candidate.getUTCDate() - 1);
|
||||
}
|
||||
// Sanity check: the contract under test requires Berlin and the
|
||||
// user's tz to disagree on the weekday for the seeded instant.
|
||||
const berlinWeekday = isoWeekdayShort(candidate, "Europe/Berlin");
|
||||
const aucklandWeekday = isoWeekdayShort(candidate, "Pacific/Auckland");
|
||||
expect(berlinWeekday).not.toBe(aucklandWeekday);
|
||||
|
||||
for (let i = 27; i >= 0; i--) {
|
||||
const measuredAt = new Date(candidate.getTime() - i * DAY_MS);
|
||||
// Vary weight by user-tz weekday so the ANOVA has a signal.
|
||||
const userWeekday = isoWeekdayShort(measuredAt, "Pacific/Auckland");
|
||||
const offset =
|
||||
userWeekday === "Mon" ? 1.5 : userWeekday === "Sat" ? -0.8 : 0;
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: "WEIGHT",
|
||||
value: 82 + offset,
|
||||
unit: "kg",
|
||||
source: "MANUAL",
|
||||
measuredAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { GET } = await import("@/app/api/analytics/route");
|
||||
// The wrapped handler reads `request.url` for logging metadata;
|
||||
// the inner handler ignores its arguments. Cast through `unknown`
|
||||
// because `apiHandler`'s narrowed type signature is the inner
|
||||
// handler's `()`, not the wrapper's `(NextRequest)`.
|
||||
const response = await (
|
||||
GET as unknown as (req: Request) => Promise<Response>
|
||||
)(new Request("http://localhost/api/analytics"));
|
||||
expect(response.status).toBe(200);
|
||||
const envelope = (await response.json()) as AnalyticsEnvelope;
|
||||
|
||||
// The correlator should hit the `n >= 20` gate (28 readings) and
|
||||
// surface a result — either "ok" if the ANOVA picks up the Monday
|
||||
// signal, or "insufficient" with `reason: "not_significant"` if
|
||||
// the noise drowns out the offset. Either way, `n` must reflect
|
||||
// every seeded row, confirming the picker bucketed every row
|
||||
// against a weekday (rather than crashing or filtering anything
|
||||
// out).
|
||||
const wkd = envelope.data.correlations.weightWeekday;
|
||||
expect(["ok", "insufficient"]).toContain(wkd.status);
|
||||
expect(wkd.n).toBe(28);
|
||||
});
|
||||
|
||||
it("falls back to the project-default tz when the user has none stored", async () => {
|
||||
// Defence-in-depth: seed a user whose timezone is the schema
|
||||
// default. The pre-W10 helper always read `Europe/Berlin`; with
|
||||
// the W10 fix the same code path resolves to `userTz` which
|
||||
// defaults to `Europe/Berlin` via `user.timezone ?? DEFAULT_TIMEZONE`.
|
||||
// The behaviour for a Berlin-tz user must be identical to the
|
||||
// pre-W10 implementation.
|
||||
const prisma = getPrismaClient();
|
||||
const user = await seedSession("berlin-default-user", "Europe/Berlin");
|
||||
|
||||
// 28 readings ending today so every row falls inside the
|
||||
// correlator's trailing-30-day filter.
|
||||
const now = new Date();
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
for (let i = 27; i >= 0; i--) {
|
||||
const measuredAt = new Date(now.getTime() - i * DAY_MS);
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: "WEIGHT",
|
||||
value: 82,
|
||||
unit: "kg",
|
||||
source: "MANUAL",
|
||||
measuredAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { GET } = await import("@/app/api/analytics/route");
|
||||
const response = await (
|
||||
GET as unknown as (req: Request) => Promise<Response>
|
||||
)(new Request("http://localhost/api/analytics"));
|
||||
expect(response.status).toBe(200);
|
||||
const envelope = (await response.json()) as AnalyticsEnvelope;
|
||||
|
||||
// Constant weight = no weekday variance = "insufficient"
|
||||
// (`reason: not_significant`). The `n` count is the regression
|
||||
// anchor — every seeded row reached the correlator.
|
||||
const wkd = envelope.data.correlations.weightWeekday;
|
||||
expect(wkd.status).toBe("insufficient");
|
||||
expect(wkd.n).toBe(28);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user