mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user