From e9e186dca5ad11be181e23ef209cfb1d20a09059 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 04:42:49 +0200 Subject: [PATCH] fix(gamification): bucket achievement streaks by the badge owner's own tz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every day-key derivation feeding the achievement streak/count badges (login, entry, weekend, miss-free, sleep-log, on-time, compliance-80) plus the hidden night-owl/early-bird triggers hardcoded Europe/Berlin, including the SQL vitals-bucket query's `AT TIME ZONE` literal. A non-CET user's evening entries could fold into the wrong calendar day, or two genuinely distinct local days could merge into one, silently breaking streak badges for every non-CET account. Threads the account's own `User.timezone` through `buildAchievementsResult` into the SQL query (bound as a parameter, not spliced — Postgres accepts a text value on the right side of `AT TIME ZONE`, verified against a live instance) and every JS-side day-key helper. The pure helpers in `expansion-metrics.ts` and `care-metrics.ts` keep the Berlin default when `tz` is omitted, so every existing caller/fixture is unaffected. --- .../__tests__/care-metrics.test.ts | 16 +++ .../__tests__/expansion-metrics.test.ts | 55 +++++++++ src/lib/gamification/achievements-result.ts | 111 +++++++++++------- src/lib/gamification/care-metrics.ts | 14 ++- src/lib/gamification/expansion-metrics.ts | 97 +++++++-------- 5 files changed, 200 insertions(+), 93 deletions(-) diff --git a/src/lib/gamification/__tests__/care-metrics.test.ts b/src/lib/gamification/__tests__/care-metrics.test.ts index c4cc41904..00a97b356 100644 --- a/src/lib/gamification/__tests__/care-metrics.test.ts +++ b/src/lib/gamification/__tests__/care-metrics.test.ts @@ -73,6 +73,22 @@ describe("getMissFreeDayKeys", () => { ]); expect(keys).toEqual(["2026-05-01"]); }); + + it("buckets by the CALLER's timezone when supplied (DATAINT M4)", () => { + // 2026-05-01T23:30:00Z: Berlin (CEST, UTC+2) reads 2026-05-02T01:30 — + // the NEXT day. A resolved slot at that instant must key to 05-01 for + // an America/New_York (UTC-4 in May) caller, not 05-02. + const lateEvent: CareIntakeEventRecord = { + scheduledFor: new Date("2026-05-01T23:30:00Z"), + takenAt: new Date("2026-05-01T23:35:00Z"), + skipped: false, + autoMissed: false, + }; + const berlinDefault = getMissFreeDayKeys([lateEvent]); + const newYork = getMissFreeDayKeys([lateEvent], "America/New_York"); + expect(berlinDefault).toEqual(["2026-05-02"]); + expect(newYork).toEqual(["2026-05-01"]); + }); }); describe("getWeeklyConsistency", () => { diff --git a/src/lib/gamification/__tests__/expansion-metrics.test.ts b/src/lib/gamification/__tests__/expansion-metrics.test.ts index 4a5671a16..6dde24ab6 100644 --- a/src/lib/gamification/__tests__/expansion-metrics.test.ts +++ b/src/lib/gamification/__tests__/expansion-metrics.test.ts @@ -268,6 +268,61 @@ describe("getHiddenMetrics", () => { }); }); +describe("getEngagementMetrics — tz threading (DATAINT M4)", () => { + it("buckets entry-day-streak by the CALLER's timezone, not a hardcoded Berlin default", () => { + // Two instants 2.5h apart in UTC. Under Berlin (CEST, UTC+2 in June) + // they straddle a MIDNIGHT boundary and land on two DIFFERENT + // consecutive local days (streak 2). Under Kiritimati (UTC+14) both + // fall on the SAME local day (streak 1). If `tz` were silently ignored + // (still hardcoded Berlin), both calls would report streak 2. + const measurements = [ + { type: "WEIGHT", measuredAt: new Date("2026-06-10T21:00:00.000Z") }, + { type: "PULSE", measuredAt: new Date("2026-06-10T23:30:00.000Z") }, + ]; + const berlinDefault = getEngagementMetrics({ + measurements, + moodEntries: [], + intakeEvents: [], + }); + const kiritimati = getEngagementMetrics({ + measurements, + moodEntries: [], + intakeEvents: [], + tz: "Pacific/Kiritimati", + }); + expect(berlinDefault.entryDayStreak).toBe(2); + expect(kiritimati.entryDayStreak).toBe(1); + }); +}); + +describe("getHiddenMetrics — tz threading (DATAINT M4)", () => { + it("classifies night-owl/early-bird hours against the caller's own timezone", () => { + // 2026-04-15T00:30:00Z: Berlin (CEST, UTC+2) reads 02:30 → night-owl. + // The same instant in America/Los_Angeles (PDT, UTC-7) reads 17:30 the + // PREVIOUS day → neither night-owl nor early-bird. If `tz` were still + // hardcoded Berlin, both calls would report the same night-owl count. + const measurements = [ + { type: "WEIGHT", measuredAt: new Date("2026-04-15T00:30:00Z") }, + ]; + const berlin = getHiddenMetrics({ + measurements, + moodEntries: [], + intakeEvents: [], + auditEvents: [], + }); + const losAngeles = getHiddenMetrics({ + measurements, + moodEntries: [], + intakeEvents: [], + auditEvents: [], + tz: "America/Los_Angeles", + }); + expect(berlin.nightOwlCount).toBe(1); + expect(losAngeles.nightOwlCount).toBe(0); + expect(losAngeles.earlyBirdCount).toBe(0); + }); +}); + describe("getEarnabilityFlags", () => { it("flags categories the user has data for", () => { expect( diff --git a/src/lib/gamification/achievements-result.ts b/src/lib/gamification/achievements-result.ts index e6da30bc1..ed8115368 100644 --- a/src/lib/gamification/achievements-result.ts +++ b/src/lib/gamification/achievements-result.ts @@ -22,7 +22,6 @@ import { calculateLongestStreak, evaluateAchievementsWithCompletionDates, moduleForMetric, - toBerlinDayKey, type AchievementMetrics, type AchievementProgress, } from "@/lib/gamification/achievements"; @@ -41,6 +40,7 @@ import { } from "@/lib/analytics/classifications"; import { classifyIntakeTiming } from "@/lib/analytics/compliance"; import { wallClockInTz } from "@/lib/tz/wall-clock"; +import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format"; export type AuthedUser = Awaited>["user"]; @@ -74,22 +74,23 @@ type MedicationScheduleRecord = { /** * v1.28.25 — one SQL-aggregated vitals bucket: every non-deleted, non-import - * row of `type` inside the Berlin-local hour `hour` of day `dayKey`, folded - * to a row count + value sum by the database. The vitals window is - * rollout-date → now and grows forever, and PULSE can be per-sample heart - * rate (six figures on a long-tenured wearable account) — reading raw rows - * here was the scale hazard. Every consumer's semantics survive the fold: - * green-day classification needs the per-day per-type MEAN (= Σsum / Σcount - * across the day's hour buckets — identical to the raw mean up to - * floating-point summation order), the count badges need row counts, the - * engagement / consistency series need day presence, and the hidden + * row of `type` inside the badge-owner's LOCAL hour `hour` of day `dayKey` + * (DATAINT M4 — was hardcoded Berlin; the query now binds the caller's own + * `User.timezone`), folded to a row count + value sum by the database. The + * vitals window is rollout-date → now and grows forever, and PULSE can be + * per-sample heart rate (six figures on a long-tenured wearable account) — + * reading raw rows here was the scale hazard. Every consumer's semantics + * survive the fold: green-day classification needs the per-day per-type MEAN + * (= Σsum / Σcount across the day's hour buckets — identical to the raw mean + * up to floating-point summation order), the count badges need row counts, + * the engagement / consistency series need day presence, and the hidden * night-owl / early-bird / leap-day tallies need per-hour row counts. */ type VitalsHourBucket = { type: "WEIGHT" | "BLOOD_PRESSURE_SYS" | "BLOOD_PRESSURE_DIA" | "PULSE"; - /** Berlin-local YYYY-MM-DD. */ + /** User-local YYYY-MM-DD. */ dayKey: string; - /** Berlin-local hour 0–23. */ + /** User-local hour 0–23. */ hour: number; /** Number of raw rows folded into this bucket. */ rowCount: number; @@ -102,21 +103,22 @@ function maxDate(a: Date, b: Date): Date { } /** - * v1.18.1 P4 — every Berlin-local day key covered by an illness episode - * (onset day through the resolved day, or through `now` while ongoing), - * inclusive on both ends. These are the days a streak is allowed to lapse - * across without breaking (the Rest Mode freeze). Uses the same - * `toBerlinDayKey` day anchoring every other streak series uses, so the + * v1.18.1 P4 — every local day key covered by an illness episode (onset day + * through the resolved day, or through `now` while ongoing), inclusive on + * both ends. These are the days a streak is allowed to lapse across without + * breaking (the Rest Mode freeze). Uses the same `tz` day anchoring every + * other streak series uses (DATAINT M4 — was hardcoded Berlin), so the * frozen days line up with the qualifying days exactly. */ function collectIllnessDayKeys( episodes: Array<{ onsetAt: Date; resolvedAt: Date | null }>, now: Date, + tz: string, ): Set { const frozen = new Set(); for (const ep of episodes) { - const startSerial = dayKeyToSerial(toBerlinDayKey(ep.onsetAt)); - const endSerial = dayKeyToSerial(toBerlinDayKey(ep.resolvedAt ?? now)); + const startSerial = dayKeyToSerial(userDayKey(ep.onsetAt, tz)); + const endSerial = dayKeyToSerial(userDayKey(ep.resolvedAt ?? now, tz)); for (let serial = startSerial; serial <= endSerial; serial++) { frozen.add(serialToDayKey(serial)); } @@ -188,14 +190,17 @@ function toDaySeries(dayKeys: string[]): { }; } -function getEventDaySeries(dates: Date[]): { +function getEventDaySeries( + dates: Date[], + tz: string, +): { dayKeys: string[]; firstDateByDay: Map; } { const firstDateByDay = new Map(); for (const date of dates) { - const dayKey = toBerlinDayKey(date); + const dayKey = userDayKey(date, tz); const existing = firstDateByDay.get(dayKey); if (!existing || date < existing) { @@ -288,7 +293,10 @@ function getOnTimePerfectDaySeries( const eventsByDay = new Map(); for (const event of intakeEvents) { - const dayKey = toBerlinDayKey(event.scheduledFor); + // DATAINT M4 — was `toBerlinDayKey`; this function already threads `tz` + // for the schedule-window classification below, but the day-BUCKETING + // key itself was still hardcoded Berlin. + const dayKey = userDayKey(event.scheduledFor, tz); const list = eventsByDay.get(dayKey) ?? []; list.push(event); eventsByDay.set(dayKey, list); @@ -392,6 +400,7 @@ function getExpectedIntakesForDay( function getIntakeIssueMetrics( intakeEvents: IntakeEventRecord[], schedulesByMedicationId: Map, + tz: string, ) { const skippedIntakeDates = intakeEvents .filter((event) => event.skipped) @@ -408,7 +417,7 @@ function getIntakeIssueMetrics( continue; } - const dayKey = toBerlinDayKey(event.scheduledFor); + const dayKey = userDayKey(event.scheduledFor, tz); const byDay = eventsByMedicationDay.get(event.medicationId) ?? new Map(); const list = byDay.get(dayKey) ?? []; list.push(event); @@ -470,11 +479,12 @@ function getCompliance80DaySeries( intakeEvents: IntakeEventRecord[], startDate: Date, endDate: Date, + tz: string, ) { const perDayCounts = new Map(); for (const event of intakeEvents) { - const serial = dayKeyToSerial(toBerlinDayKey(event.scheduledFor)); + const serial = dayKeyToSerial(userDayKey(event.scheduledFor, tz)); const current = perDayCounts.get(serial) ?? { logged: 0, taken: 0 }; const logged = event.skipped || event.takenAt !== null; @@ -486,8 +496,8 @@ function getCompliance80DaySeries( perDayCounts.set(serial, current); } - const startSerial = dayKeyToSerial(toBerlinDayKey(startDate)); - const endSerial = dayKeyToSerial(toBerlinDayKey(endDate)); + const startSerial = dayKeyToSerial(userDayKey(startDate, tz)); + const endSerial = dayKeyToSerial(userDayKey(endDate, tz)); let rollingLogged = 0; let rollingTaken = 0; @@ -538,6 +548,14 @@ export async function buildAchievementsResult( const now = new Date(); const userId = user.id; const startDate = maxDate(user.createdAt, GAMIFICATION_ROLLOUT_AT); + // v1.30 (DATAINT M4) — every day-key derivation below anchors on the + // BADGE OWNER's own timezone, not a hardcoded Berlin default. Before this, + // a non-CET user's evening entries could fold into the wrong calendar day + // (or merge two real local days into one), silently breaking every + // day-streak badge (login, entry, weekend, miss-free, sleep-log, on-time, + // compliance-80) plus the hidden night-owl / early-bird / leap-day + // triggers for every non-CET account. + const tz = user.timezone || DEFAULT_TIMEZONE; const [ vitalsBuckets, @@ -550,7 +568,7 @@ export async function buildAchievementsResult( healthProfile, illnessEpisodes, ] = await Promise.all([ - // v1.28.25 — vitals folded to (type, Berlin day, Berlin hour) buckets in + // v1.28.25 — vitals folded to (type, local day, local hour) buckets in // SQL instead of one row per sample. The window (rollout → now) grows // forever and PULSE can be per-sample heart rate, so the raw read ran to // six figures on long-tenured wearable accounts; the bucket set is @@ -561,18 +579,21 @@ export async function buildAchievementsResult( // (never user input), source ≠ IMPORT, and soft-deleted rows stay // excluded (v1.4.41 W-DELETED-2) so deleting a row immediately rolls // back any streak / count badge it earned. `AT TIME ZONE 'UTC' AT TIME - // ZONE 'Europe/Berlin'` re-tags the naive UTC `measured_at` and converts - // it to the same Berlin wall clock `toBerlinDayKey` / `berlinHour` - // derive via Intl. Ordering pins day → hour → type for determinism. + // ZONE ${tz}` re-tags the naive UTC `measured_at` and converts it to the + // BADGE OWNER's own wall clock (DATAINT M4 — was hardcoded + // 'Europe/Berlin'; `tz` is bound as a query parameter, never spliced, and + // is only ever a value `PUT /api/auth/me/timezone` already validated + // against `Intl`'s IANA zone list before it reached the column). + // Ordering pins day → hour → type for determinism. prisma.$queryRaw` SELECT m."type"::text AS "type", to_char( - (m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE 'Europe/Berlin', + (m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${tz}, 'YYYY-MM-DD' ) AS "dayKey", EXTRACT( - HOUR FROM (m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE 'Europe/Berlin' + HOUR FROM (m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${tz} )::int AS "hour", COUNT(*)::int AS "rowCount", SUM(m."value")::double precision AS "valueSum" @@ -708,12 +729,12 @@ export async function buildAchievementsResult( : Promise.resolve([]), ]); - // v1.18.1 P4 — Rest Mode streak-freeze. Build the set of Berlin-local day - // keys covered by an illness episode (onset → resolved, or → now while + // v1.18.1 P4 — Rest Mode streak-freeze. Build the set of local-day keys + // covered by an illness episode (onset → resolved, or → now while // ongoing). A day-streak that lapses only across these days is bridged // rather than broken (see `bridgeFrozenStreakGaps`): being unwell pauses a // streak, it does not fail it. - const frozenIllnessDayKeys = collectIllnessDayKeys(illnessEpisodes, now); + const frozenIllnessDayKeys = collectIllnessDayKeys(illnessEpisodes, now, tz); const freeze = (dayKeys: string[]): string[] => bridgeFrozenStreakGaps(dayKeys, frozenIllnessDayKeys); @@ -723,6 +744,7 @@ export async function buildAchievementsResult( const intakeIssueMetrics = getIntakeIssueMetrics( intakeEvents, schedulesByMedicationId, + tz, ); const takenIntakeDates = intakeEvents @@ -735,12 +757,12 @@ export async function buildAchievementsResult( const passwordLoginDates = auditEvents .filter((event) => event.action === "auth.login.password") .map((event) => event.createdAt); - const loginDaySeries = getEventDaySeries([ - ...passkeyLoginDates, - ...passwordLoginDates, - ]); + const loginDaySeries = getEventDaySeries( + [...passkeyLoginDates, ...passwordLoginDates], + tz, + ); - // v1.28.25 — the buckets already carry the Berlin day-key + hour straight + // v1.28.25 — the buckets already carry the local day-key + hour straight // from SQL (superseding the v1.18.11 Intl-once optimisation: no Intl pass // over the vitals remains at all). The parallel arrays thread into every // consumer exactly as the per-row keys did; day-presence consumers dedup @@ -762,21 +784,23 @@ export async function buildAchievementsResult( const onTimeSeries = getOnTimePerfectDaySeries( intakeEvents, schedulesByMedicationId, - user.timezone, + tz, ); const complianceSeries = getCompliance80DaySeries( intakeEvents, startDate, now, + tz, ); // v1.16.1 — care-routine series. Miss-free days and sleep-logging // days reuse the standard day-series plumbing; the weekly measurement // consistency folds distinct active vitals days into Monday-anchored // weeks (≥5 active days each, 4 consecutive weeks for the badge). - const missFreeSeries = toDaySeries(getMissFreeDayKeys(intakeEvents)); + const missFreeSeries = toDaySeries(getMissFreeDayKeys(intakeEvents, tz)); const sleepSeries = getEventDaySeries( sleepMeasurements.map((m) => m.measuredAt), + tz, ); const weeklyConsistency = getWeeklyConsistency( // Bucket day-keys — `getWeeklyConsistency` dedups to distinct days, so @@ -799,11 +823,12 @@ export async function buildAchievementsResult( moodEntries, intakeEvents, auditEvents, - // The SQL-derived Berlin day-keys + hours ride in parallel to the + // The SQL-derived local day-keys + hours ride in parallel to the // weighted bucket records — the expansion passes never touch a bucket's // representative `measuredAt`. measurementDayKeys: vitalsDayKeys, measurementHours: vitalsHours, + tz, }); const earnability = getEarnabilityFlags({ hasMedication: medications.length > 0, diff --git a/src/lib/gamification/care-metrics.ts b/src/lib/gamification/care-metrics.ts index 5ed7f8cc7..0ae370622 100644 --- a/src/lib/gamification/care-metrics.ts +++ b/src/lib/gamification/care-metrics.ts @@ -24,7 +24,7 @@ * boring, clinically useful habit of measuring most days of the * week, week after week, without demanding a perfect daily streak. */ -import { toBerlinDayKey } from "./achievements"; +import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format"; export interface CareIntakeEventRecord { scheduledFor: Date; @@ -37,11 +37,19 @@ export interface CareIntakeEventRecord { * Qualifying day keys for the miss-free streak, sorted ascending. * Feed the result to `calculateLongestStreak` / * `findStreakCompletionDate` like every other day series. + * + * `tz` defaults to `DEFAULT_TIMEZONE` ("Europe/Berlin") so every existing + * caller/fixture that never set one keeps its prior behaviour byte-for- + * byte (DATAINT M4) — the achievements builder now threads the badge + * owner's real `User.timezone` in. */ -export function getMissFreeDayKeys(events: CareIntakeEventRecord[]): string[] { +export function getMissFreeDayKeys( + events: CareIntakeEventRecord[], + tz: string = DEFAULT_TIMEZONE, +): string[] { const byDay = new Map(); for (const event of events) { - const dayKey = toBerlinDayKey(event.scheduledFor); + const dayKey = userDayKey(event.scheduledFor, tz); const bucket = byDay.get(dayKey) ?? { resolved: 0, missed: 0 }; if (event.autoMissed) { bucket.missed += 1; diff --git a/src/lib/gamification/expansion-metrics.ts b/src/lib/gamification/expansion-metrics.ts index 34285f2b9..f85e28742 100644 --- a/src/lib/gamification/expansion-metrics.ts +++ b/src/lib/gamification/expansion-metrics.ts @@ -7,35 +7,27 @@ * `evaluateAchievementsWithCompletionDates` evaluates the new * achievements with no further plumbing. */ -import { - calculateLongestStreak, - toBerlinDayKey, - type EarnabilityFlags, -} from "./achievements"; +import { calculateLongestStreak, type EarnabilityFlags } from "./achievements"; +import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; -const BERLIN_TIMEZONE = "Europe/Berlin"; - -const BERLIN_HOUR_FORMATTER = new Intl.DateTimeFormat("en-US", { - timeZone: BERLIN_TIMEZONE, - hour: "2-digit", - hour12: false, -}); - -function berlinHour(date: Date): number { - const value = BERLIN_HOUR_FORMATTER.format(date); - // formatToParts is more reliable across runtimes — split + parseInt - // works because the formatter uses 24-hour with no extra tokens. - const parsed = Number.parseInt(value, 10); - return Number.isNaN(parsed) ? 0 : parsed; +/** + * v1.30 (DATAINT M4) — day-key / hour derivation now takes the CALLER's + * timezone (`User.timezone`), defaulting to `DEFAULT_TIMEZONE` ("Europe/ + * Berlin") when omitted so every existing fixture/caller that never set a + * `tz` keeps its prior (Berlin-anchored) behaviour byte-for-byte. Before + * this, every derivation hardcoded Berlin regardless of the achievement + * owner's real timezone — a non-CET user's evening entries could fold + * into the wrong calendar day (or merge two real days into one), silently + * breaking day-streak badges (`entryDayStreak`, `weekendStreakCount`) and + * the hidden night-owl / early-bird triggers for every non-CET account. + */ +function hourInTz(date: Date, tz: string): number { + return wallClockInTz(date, tz).hour; } -const BERLIN_WEEKDAY_FORMATTER = new Intl.DateTimeFormat("en-US", { - timeZone: BERLIN_TIMEZONE, - weekday: "short", -}); - -function berlinWeekday(date: Date): string { - return BERLIN_WEEKDAY_FORMATTER.format(date); +function isSaturdayInTz(date: Date, tz: string): boolean { + return wallClockInTz(date, tz).weekday === 6; } export interface MoodEntryRecord { @@ -165,30 +157,33 @@ export function getEngagementMetrics(input: { moodEntries: MoodEntryRecord[]; intakeEvents: IntakeEventRecord[]; /** - * v1.18.11 (W5 perf) — Berlin day-keys for `measurements`, precomputed - * once by the caller and passed in parallel to the array. When present the - * measurement rows reuse these instead of re-deriving a `toBerlinDayKey` - * per row; mood / intake timestamps (far fewer) still derive inline. Falls - * back to per-row derivation when absent — byte-identical result either way. + * v1.18.11 (W5 perf) — day-keys for `measurements`, precomputed once by + * the caller and passed in parallel to the array. When present the + * measurement rows reuse these instead of re-deriving a day key per row; + * mood / intake timestamps (far fewer) still derive inline. Falls back to + * per-row derivation when absent — byte-identical result either way. */ measurementDayKeys?: string[]; + /** v1.30 (DATAINT M4) — day-key timezone; defaults to Berlin (see file docblock). */ + tz?: string; }): { consistentMonthCount: number; entryDayStreak: number; weekendStreakCount: number; } { + const tz = input.tz ?? DEFAULT_TIMEZONE; const dayKeyAccum: string[] = []; for (let i = 0; i < input.measurements.length; i++) { dayKeyAccum.push( input.measurementDayKeys?.[i] ?? - toBerlinDayKey(input.measurements[i].measuredAt), + userDayKey(input.measurements[i].measuredAt, tz), ); } for (const e of input.moodEntries) - dayKeyAccum.push(toBerlinDayKey(e.moodLoggedAt)); + dayKeyAccum.push(userDayKey(e.moodLoggedAt, tz)); for (const i of input.intakeEvents) { - if (i.takenAt) dayKeyAccum.push(toBerlinDayKey(i.takenAt)); - else if (i.skipped) dayKeyAccum.push(toBerlinDayKey(i.scheduledFor)); + if (i.takenAt) dayKeyAccum.push(userDayKey(i.takenAt, tz)); + else if (i.skipped) dayKeyAccum.push(userDayKey(i.scheduledFor, tz)); } if (dayKeyAccum.length === 0) { @@ -238,20 +233,20 @@ export function getEngagementMetrics(input: { let weekendStreakCount = 0; let weekendRun = 0; // Walk one Saturday at a time. Use a simple `Date` cursor; the - // Berlin TZ formatter resolves the weekday so DST shifts are safe. + // caller's tz formatter resolves the weekday so DST shifts are safe. const cursor = parseDayKey(firstDay); const end = parseDayKey(lastDay); // Snap cursor to the next Saturday at 12:00 UTC (avoids DST edges). cursor.setUTCHours(12, 0, 0, 0); - while (berlinWeekday(cursor) !== "Sat") { + while (!isSaturdayInTz(cursor, tz)) { cursor.setUTCDate(cursor.getUTCDate() + 1); if (cursor > end) break; } while (cursor <= end) { - const sat = toBerlinDayKey(cursor); + const sat = userDayKey(cursor, tz); const sunDate = new Date(cursor); sunDate.setUTCDate(sunDate.getUTCDate() + 1); - const sun = toBerlinDayKey(sunDate); + const sun = userDayKey(sunDate, tz); if (daySet.has(sat) && daySet.has(sun)) { weekendRun += 1; weekendStreakCount = Math.max(weekendStreakCount, weekendRun); @@ -290,6 +285,8 @@ export function getHiddenMetrics(input: { */ measurementDayKeys?: string[]; measurementHours?: number[]; + /** v1.30 (DATAINT M4) — day-key timezone; defaults to Berlin (see file docblock). */ + tz?: string; }): { nightOwlCount: number; earlyBirdCount: number; @@ -297,6 +294,7 @@ export function getHiddenMetrics(input: { doctorPdfCount: number; localeFlipCount: number; } { + const tz = input.tz ?? DEFAULT_TIMEZONE; let nightOwl = 0; let earlyBird = 0; let leapDay = 0; @@ -314,16 +312,16 @@ export function getHiddenMetrics(input: { for (let i = 0; i < input.measurements.length; i++) { const m = input.measurements[i]; tally( - input.measurementHours?.[i] ?? berlinHour(m.measuredAt), - input.measurementDayKeys?.[i] ?? toBerlinDayKey(m.measuredAt), + input.measurementHours?.[i] ?? hourInTz(m.measuredAt, tz), + input.measurementDayKeys?.[i] ?? userDayKey(m.measuredAt, tz), m.count ?? 1, ); } for (const e of input.moodEntries) { - tally(berlinHour(e.moodLoggedAt), toBerlinDayKey(e.moodLoggedAt)); + tally(hourInTz(e.moodLoggedAt, tz), userDayKey(e.moodLoggedAt, tz)); } for (const i of input.intakeEvents) { - if (i.takenAt) tally(berlinHour(i.takenAt), toBerlinDayKey(i.takenAt)); + if (i.takenAt) tally(hourInTz(i.takenAt, tz), userDayKey(i.takenAt, tz)); } let doctorPdfCount = 0; @@ -412,22 +410,26 @@ export function buildExpansionMetricValues(input: { * hours derive from `measuredAt` exactly as before. */ measurementHours?: number[]; + /** v1.30 (DATAINT M4) — day-key timezone; defaults to Berlin (see file docblock). */ + tz?: string; }): ExpansionMetrics { + const tz = input.tz ?? DEFAULT_TIMEZONE; const counts = countMeasurementsByType(input.measurements); const mood = getMoodMetrics(input.moodEntries); - // The hidden pass also needs the per-row Berlin hour; derive it once here - // (only when the caller supplied day-keys, i.e. on the hot achievements - // path) so a single hour pass replaces the per-pass re-derivation. + // The hidden pass also needs the per-row hour; derive it once here (only + // when the caller supplied day-keys, i.e. on the hot achievements path) so + // a single hour pass replaces the per-pass re-derivation. const measurementHours = input.measurementHours ?? (input.measurementDayKeys - ? input.measurements.map((m) => berlinHour(m.measuredAt)) + ? input.measurements.map((m) => hourInTz(m.measuredAt, tz)) : undefined); const engagement = getEngagementMetrics({ measurements: input.measurements, moodEntries: input.moodEntries, intakeEvents: input.intakeEvents, measurementDayKeys: input.measurementDayKeys, + tz, }); const hidden = getHiddenMetrics({ measurements: input.measurements, @@ -436,6 +438,7 @@ export function buildExpansionMetricValues(input: { auditEvents: input.auditEvents, measurementDayKeys: input.measurementDayKeys, measurementHours, + tz, }); return {