fix(gamification): bucket achievement streaks by the badge owner's own tz

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 05:06:24 +02:00
1 parent 44ab74e125
commit e9e186dca5
5 files changed
+200 -93

No files matched your search

@@ -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", () => {
@@ -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(
+68 -43
View File
@@ -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<ReturnType<typeof requireAuth>>["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<string> {
const frozen = new Set<string>();
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<string, Date>;
} {
const firstDateByDay = new Map<string, Date>();
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<string, IntakeEventRecord[]>();
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<string, MedicationScheduleRecord[]>,
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<number, { logged: number; taken: number }>();
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<VitalsHourBucket[]>`
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,
+11 -3
View File
@@ -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<string, { resolved: number; missed: number }>();
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;
+50 -47
View File
@@ -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 {