feat(analytics): berlinDayKey callers honour user displayTimezone

The four routes that bucket by Berlin calendar day (dashboard summary,
medication intake, analytics correlations and sleep, insights targets)
now read userTz from the authenticated User and pass it through
userDayKey(). Streaks and consistency strips for a Pacific/Auckland user
land on their Auckland-day, not the legacy Berlin-day.

The aggregation logic is unchanged — only the bucket key function
swaps from berlinDayKey(date) to userDayKey(date, userTz). Existing
Berlin users see no behaviour change (Europe/Berlin is the schema
default).

Integration test extended with a two-case assertion: the userDayKey()
boundary case (13:00 UTC May 14 → 2026-05-15 Auckland vs 2026-05-14
Berlin) and the dashboard summary route returning a non-zero streak for
an Auckland account with three consecutive Auckland-day measurements.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 12:52:01 +02:00
1 parent b5062ead1c
commit 35f068ac26
5 files changed
+185 -62

No files matched your search

+27 -13
View File
@@ -5,7 +5,7 @@ import { apiSuccess } from "@/lib/api-response";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import { computeBpInTargetWindows } from "@/lib/analytics/bp-in-target";
import { berlinDayKey } from "@/lib/analytics/berlin-day";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { calculateCompliance } from "@/lib/analytics/compliance";
import {
computeHealthScore,
@@ -28,6 +28,14 @@ export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "analytics.get" } });
// v1.4.25 W7b — every day-bucket call inside this route now honours
// the user's display timezone. The legacy `berlinDayKey()` import
// remains for sleep-stage and correlation paths that share their
// helper signature with non-tz-aware code (`computeSleepStageBreakdown`
// is called with a userId only); the per-call sites below all pass
// `userTz` through `userDayKey()`.
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
// Derived from canonical enum so a new measurement type is auto-summarised
// by /api/analytics (V3 audit: enum drift cousins).
const types = [...measurementTypeEnum.options] as MeasurementType[];
@@ -55,7 +63,7 @@ export const GET = apiHandler(async () => {
if (type === "SLEEP_DURATION") {
const byDay = new Map<string, { total: number; date: Date }>();
for (const m of measurements) {
const key = berlinDayKey(m.measuredAt);
const key = userDayKey(m.measuredAt, userTz);
const slot = byDay.get(key) ?? {
total: 0,
date: m.measuredAt,
@@ -104,7 +112,7 @@ export const GET = apiHandler(async () => {
// included when the user has stage-tagged rows in window; null
// otherwise so the UI can render a plain total without the
// breakdown card painting empty.
const sleepStages = await computeSleepStageBreakdown(user.id);
const sleepStages = await computeSleepStageBreakdown(user.id, userTz);
// BMI calculation
let bmi: number | null = null;
@@ -202,7 +210,7 @@ export const GET = apiHandler(async () => {
// floor from 14 → 20). Each runner gates on n >= 20 + p < 0.05;
// below the bar the result.status === "insufficient" and the UI
// paints an EmptyState.
const correlations = await computeCorrelationHypotheses(user.id);
const correlations = await computeCorrelationHypotheses(user.id, userTz);
// v1.4.20 phase B5 — Personal Health Score. Server-deterministic
// composite of BP-in-target % + weight-trend alignment + mood
@@ -249,7 +257,10 @@ export const GET = apiHandler(async () => {
* case. Returns the sum-per-stage AND the day count covered so the
* UI can render an "averaged across N nights" caption truthfully.
*/
async function computeSleepStageBreakdown(userId: string): Promise<{
async function computeSleepStageBreakdown(
userId: string,
userTz: string,
): Promise<{
windowDays: number;
nights: number;
totalMinutes: number;
@@ -276,7 +287,7 @@ async function computeSleepStageBreakdown(userId: string): Promise<{
if (!row.sleepStage) continue;
stages[row.sleepStage] = (stages[row.sleepStage] ?? 0) + row.value;
totalMinutes += row.value;
dayKeys.add(berlinDayKey(row.measuredAt));
dayKeys.add(userDayKey(row.measuredAt, userTz));
}
return {
@@ -295,7 +306,10 @@ async function computeSleepStageBreakdown(userId: string): Promise<{
* because the user-facing "based on N paired readings · last 30 days"
* source-chip has to remain truthful.
*/
async function computeCorrelationHypotheses(userId: string): Promise<{
async function computeCorrelationHypotheses(
userId: string,
userTz: string,
): Promise<{
bpCompliance: CorrelationResult;
moodPulse: CorrelationResult;
weightWeekday: CorrelationResult;
@@ -324,10 +338,10 @@ async function computeCorrelationHypotheses(userId: string): Promise<{
]);
// ── Hypothesis 1: BP × medication compliance ────────────────
// Aggregate by Berlin-day key so DST + UTC boundary issues don't split
// a day's readings. The day's "compliance %" is taken / expected for
// that calendar day across all medications.
const dayKey = (d: Date): string => berlinDayKey(d);
// Aggregate by the user's display-tz day key so DST + UTC boundary
// issues don't split a day's readings. The day's "compliance %" is
// taken / expected for that calendar day across all medications.
const dayKey = (d: Date): string => userDayKey(d, userTz);
const dailySys = new Map<string, number[]>();
for (const row of sysRows) {
@@ -373,14 +387,14 @@ async function computeCorrelationHypotheses(userId: string): Promise<{
// resting-pulse field, so we accept the noise rather than skip.
const dailyMood = new Map<string, number[]>();
for (const row of moodRows) {
const key = berlinDayKey(row.moodLoggedAt);
const key = userDayKey(row.moodLoggedAt, userTz);
const list = dailyMood.get(key) ?? [];
list.push(row.score);
dailyMood.set(key, list);
}
const dailyPulse = new Map<string, number[]>();
for (const row of pulseRows) {
const key = berlinDayKey(row.measuredAt);
const key = userDayKey(row.measuredAt, userTz);
const list = dailyPulse.get(key) ?? [];
list.push(row.value);
dailyPulse.set(key, list);
+32 -25
View File
@@ -17,6 +17,7 @@ import type { MeasurementType } from "@/generated/prisma/client";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { defaultLocale, type Locale } from "@/lib/i18n/config";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
const SPARK_DAYS = 7;
const STREAK_WINDOW_DAYS = 365;
@@ -81,35 +82,35 @@ function trendOf(values: number[]): MetricCard["trend"] {
return delta > 0 ? "up" : "down";
}
function startOfDayBerlin(date: Date): Date {
// Compute midnight in Europe/Berlin → UTC ms.
const berlin = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
function startOfDayInTz(date: Date, tz: string): Date {
// Compute midnight in the user's tz → UTC ms.
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(date);
const y = berlin.find((p) => p.type === "year")?.value ?? "1970";
const m = berlin.find((p) => p.type === "month")?.value ?? "01";
const d = berlin.find((p) => p.type === "day")?.value ?? "01";
const y = parts.find((p) => p.type === "year")?.value ?? "1970";
const m = parts.find((p) => p.type === "month")?.value ?? "01";
const d = parts.find((p) => p.type === "day")?.value ?? "01";
return new Date(`${y}-${m}-${d}T00:00:00.000Z`);
}
function berlinDayKey(date: Date): string {
return new Intl.DateTimeFormat("sv-SE", {
timeZone: "Europe/Berlin",
}).format(date);
}
interface StreakInfo {
currentDays: number;
longest: number;
}
/** Compute the current logging-day streak (days where any measurement or
* intake event was recorded, in Berlin time) plus the longest streak in
* the last `STREAK_WINDOW_DAYS` days. */
function computeStreak(activityDays: Set<string>): StreakInfo {
* intake event was recorded, in the user's display timezone) plus the
* longest streak in the last `STREAK_WINDOW_DAYS` days.
*
* v1.4.25 W7b — `userTz` parameterises the "today" pivot so a Pacific/
* Auckland user gets their Auckland-day streak rather than the Berlin
* one. The activity-day Set entries are already produced in the same
* zone by the caller (via `userDayKey`), so the cursor walk only needs
* the same zone here to align. */
function computeStreak(activityDays: Set<string>, userTz: string): StreakInfo {
if (activityDays.size === 0) return { currentDays: 0, longest: 0 };
const sorted = [...activityDays].sort();
@@ -126,15 +127,15 @@ function computeStreak(activityDays: Set<string>): StreakInfo {
}
}
// Current streak: walk back from today (Berlin).
const todayKey = berlinDayKey(new Date());
// Current streak: walk back from today (user's tz).
const todayKey = userDayKey(new Date(), userTz);
let currentDays = 0;
let cursor = new Date(`${todayKey}T00:00:00.000Z`);
// Allow yesterday's last day to count if today not yet logged.
if (!activityDays.has(berlinDayKey(cursor))) {
if (!activityDays.has(userDayKey(cursor, userTz))) {
cursor = new Date(cursor.getTime() - 86_400_000);
}
while (activityDays.has(berlinDayKey(cursor))) {
while (activityDays.has(userDayKey(cursor, userTz))) {
currentDays += 1;
cursor = new Date(cursor.getTime() - 86_400_000);
}
@@ -146,12 +147,18 @@ export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "dashboard.summary" } });
// v1.4.25 W7b — anchor every day-bucket call to the user's display
// timezone. Falls back to Europe/Berlin when the column is somehow
// missing (defensive — the schema's NOT NULL default normally pins
// it).
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
const now = new Date();
const sevenDaysAgo = new Date(now.getTime() - SPARK_DAYS * 86_400_000);
const streakWindowStart = new Date(
now.getTime() - STREAK_WINDOW_DAYS * 86_400_000,
);
const todayStart = startOfDayBerlin(now);
const todayStart = startOfDayInTz(now, userTz);
const todayEnd = new Date(todayStart.getTime() + 86_400_000);
// Derived from canonical enum so a new measurement type is auto-included
@@ -192,7 +199,7 @@ export const GET = apiHandler(async () => {
const activityDays = new Set<string>();
for (const m of recentMeasurements)
activityDays.add(berlinDayKey(m.measuredAt));
activityDays.add(userDayKey(m.measuredAt, userTz));
// Pull a wider measurement window for the streak so it isn't capped by
// SPARK_DAYS — but only if the user has any data at all.
if (recentMeasurements.length > 0) {
@@ -203,13 +210,13 @@ export const GET = apiHandler(async () => {
},
select: { measuredAt: true },
});
for (const m of wider) activityDays.add(berlinDayKey(m.measuredAt));
for (const m of wider) activityDays.add(userDayKey(m.measuredAt, userTz));
}
for (const e of streakActivity) {
activityDays.add(berlinDayKey(e.takenAt ?? e.scheduledFor));
activityDays.add(userDayKey(e.takenAt ?? e.scheduledFor, userTz));
}
const streak = computeStreak(activityDays);
const streak = computeStreak(activityDays, userTz);
// Per-type latest + sparkline.
const byType = new Map<MeasurementType, { value: number; at: Date }[]>();
+22 -13
View File
@@ -14,7 +14,7 @@ import {
import { calculateCompliance } from "@/lib/analytics/compliance";
import { pairByTimestamp } from "@/lib/analytics/correlations";
import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target";
import { berlinDayKey } from "@/lib/analytics/berlin-day";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import {
classifyPulseByTarget,
getPersonalizedPulseTarget,
@@ -110,6 +110,15 @@ export const GET = apiHandler(async () => {
const userId = user.id;
// v1.4.25 W7b — every per-day bucket key in this route resolves
// against the user's display timezone so a Pacific/Auckland user gets
// their Auckland-day streaks, their Auckland-day "last met goal" date,
// and their Auckland-day consistency strip. Falls back to
// Europe/Berlin when the column is somehow missing (defensive — the
// schema's NOT NULL default normally pins it).
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
const dayKey = (d: Date): string => userDayKey(d, userTz);
// Fetch user profile
const dbUser = await prisma.user.findUnique({
where: { id: userId },
@@ -212,7 +221,7 @@ export const GET = apiHandler(async () => {
const last7DayKeys: string[] = [];
for (let i = 6; i >= 0; i--) {
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
last7DayKeys.push(berlinDayKey(d));
last7DayKeys.push(dayKey(d));
}
type DayBand = "in" | "near" | "out";
@@ -267,7 +276,7 @@ export const GET = apiHandler(async () => {
const byDay = new Map<string, { sum: number; count: number }>();
for (const ev of events) {
const day = berlinDayKey(ev.measuredAt);
const day = dayKey(ev.measuredAt);
const bucket = byDay.get(day) ?? { sum: 0, count: 0 };
bucket.sum += ev.value;
bucket.count += 1;
@@ -281,7 +290,7 @@ export const GET = apiHandler(async () => {
let daysLogged30d = 0;
for (let i = 29; i >= 0; i--) {
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
const key = berlinDayKey(d);
const key = dayKey(d);
const bucket = byDay.get(key);
if (!bucket) continue;
daysLogged30d += 1;
@@ -293,7 +302,7 @@ export const GET = apiHandler(async () => {
let lastMetGoalAt: string | null = null;
for (let i = 0; i < 30; i++) {
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
const key = berlinDayKey(d);
const key = dayKey(d);
const bucket = byDay.get(key);
if (!bucket) continue;
const mean = bucket.sum / bucket.count;
@@ -309,7 +318,7 @@ export const GET = apiHandler(async () => {
let streakDays = 0;
for (let i = 0; i < 365; i++) {
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
const key = berlinDayKey(d);
const key = dayKey(d);
const bucket = byDay.get(key);
if (!bucket) break;
const mean = bucket.sum / bucket.count;
@@ -343,7 +352,7 @@ export const GET = apiHandler(async () => {
} {
const byDay = new Map<string, { sum: number; count: number }>();
for (const ev of events) {
const day = berlinDayKey(ev.measuredAt);
const day = dayKey(ev.measuredAt);
const bucket = byDay.get(day) ?? { sum: 0, count: 0 };
bucket.sum += ev.value;
bucket.count += 1;
@@ -403,12 +412,12 @@ export const GET = apiHandler(async () => {
const diaByDay = new Map<string, Array<{ date: Date; value: number }>>();
for (const m of recentMeasurements) {
if (m.type === "BLOOD_PRESSURE_SYS") {
const k = berlinDayKey(m.measuredAt);
const k = dayKey(m.measuredAt);
const arr = sysByDay.get(k) ?? [];
arr.push({ date: m.measuredAt, value: m.value });
sysByDay.set(k, arr);
} else if (m.type === "BLOOD_PRESSURE_DIA") {
const k = berlinDayKey(m.measuredAt);
const k = dayKey(m.measuredAt);
const arr = diaByDay.get(k) ?? [];
arr.push({ date: m.measuredAt, value: m.value });
diaByDay.set(k, arr);
@@ -476,7 +485,7 @@ export const GET = apiHandler(async () => {
let daysLogged30d = 0;
for (let i = 29; i >= 0; i--) {
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
const key = berlinDayKey(d);
const key = dayKey(d);
const band = dayBandByKey.get(key);
if (!band) continue;
daysLogged30d += 1;
@@ -486,7 +495,7 @@ export const GET = apiHandler(async () => {
let lastMetGoalAt: string | null = null;
for (let i = 0; i < 30; i++) {
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
const key = berlinDayKey(d);
const key = dayKey(d);
if (dayBandByKey.get(key) === "in") {
lastMetGoalAt = key;
break;
@@ -496,7 +505,7 @@ export const GET = apiHandler(async () => {
let streakDays = 0;
for (let i = 0; i < 365; i++) {
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
const key = berlinDayKey(d);
const key = dayKey(d);
const band = dayBandByKey.get(key);
if (band === undefined || band === null) break;
if (band !== "in") break;
@@ -898,7 +907,7 @@ export const GET = apiHandler(async () => {
{ taken: number; expected: number }
>();
for (const event of intakeEvents) {
const key = berlinDayKey(event.scheduledFor);
const key = dayKey(event.scheduledFor);
const cur = dayCountsByKey.get(key) ?? { taken: 0, expected: 0 };
cur.expected += 1;
if (event.takenAt && !event.skipped) cur.taken += 1;
+11 -11
View File
@@ -23,6 +23,7 @@ import {
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
const querySchema = z.object({
scope: z.enum(["today", "compliance"]),
@@ -42,9 +43,9 @@ const updateSchema = z.object({
.optional(),
});
function startOfDayBerlin(date: Date): Date {
function startOfDayInTz(date: Date, tz: string): Date {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
@@ -55,12 +56,6 @@ function startOfDayBerlin(date: Date): Date {
return new Date(`${y}-${m}-${d}T00:00:00.000Z`);
}
function berlinDayKey(date: Date): string {
return new Intl.DateTimeFormat("sv-SE", {
timeZone: "Europe/Berlin",
}).format(date);
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
@@ -73,8 +68,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { scope, days } = parsed.data;
// v1.4.25 W7b — anchor "today" and per-day compliance buckets to the
// user's display timezone so a 23:30 reading in Pacific/Auckland lands
// in today's bucket rather than yesterday's Berlin one.
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
if (scope === "today") {
const todayStart = startOfDayBerlin(new Date());
const todayStart = startOfDayInTz(new Date(), userTz);
const todayEnd = new Date(todayStart.getTime() + 86_400_000);
const events = await prisma.medicationIntakeEvent.findMany({
where: {
@@ -119,10 +119,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
const buckets = new Map<string, { scheduled: number; taken: number }>();
for (let i = 0; i < days; i++) {
const d = new Date(Date.now() - i * 86_400_000);
buckets.set(berlinDayKey(d), { scheduled: 0, taken: 0 });
buckets.set(userDayKey(d, userTz), { scheduled: 0, taken: 0 });
}
for (const e of events) {
const key = berlinDayKey(e.scheduledFor);
const key = userDayKey(e.scheduledFor, userTz);
const bucket = buckets.get(key);
if (!bucket) continue;
bucket.scheduled += 1;
@@ -278,4 +278,97 @@ describe("per-user timezone — Pacific/Auckland end-to-end", () => {
});
expect(stored?.timezone).toBe("Europe/Berlin");
});
it("dashboard streak counts the Auckland-day for a 23:50 NZST reading", async () => {
// v1.4.25 W7b — surface 1. A Pacific/Auckland user logs a
// reading at 11:50 UTC, which is 23:50 NZST (May, UTC+12). In
// Berlin that's 13:50, well clear of midnight either way — but
// the test pins the principle: streak day-keys honour the user's
// tz. Without the fix, the legacy Berlin bucketing would pin
// this reading to the same Berlin day for every user regardless
// of where their personal "today" actually is.
const prisma = getPrismaClient();
const me = await seedAucklandUser();
// 11:50 UTC on May 14 → 23:50 NZST May 14 (UTC+12 in May).
await prisma.measurement.create({
data: {
userId: me.id,
type: "WEIGHT",
value: 80,
unit: "kg",
measuredAt: new Date("2026-05-14T11:50:00.000Z"),
source: "MANUAL",
},
});
const { userDayKey } = await import("@/lib/tz/resolver");
// The Auckland day-key for this reading is "2026-05-14"; the
// Berlin day-key would be "2026-05-14" too in May because Berlin
// is UTC+2, but Auckland users at the day boundary depend on
// their own tz. Pin the principle directly: the key the
// dashboard would store for this reading IS the user's tz day.
expect(
userDayKey(new Date("2026-05-14T11:50:00.000Z"), "Pacific/Auckland"),
).toBe("2026-05-14");
expect(
userDayKey(new Date("2026-05-14T11:50:00.000Z"), "Europe/Berlin"),
).toBe("2026-05-14");
// The actual boundary case: 13:00 UTC on May 14 → 01:00 NZST
// May 15 in Auckland, but still 15:00 on May 14 in Berlin. The
// dashboard's streak for an Auckland user should pin this to
// May 15 (their tomorrow); a Berlin user would pin it to May 14.
expect(
userDayKey(new Date("2026-05-14T13:00:00.000Z"), "Pacific/Auckland"),
).toBe("2026-05-15");
expect(
userDayKey(new Date("2026-05-14T13:00:00.000Z"), "Europe/Berlin"),
).toBe("2026-05-14");
});
it("dashboard summary aggregates streaks in the user's tz", async () => {
// Hit the actual /api/dashboard/summary route as the Auckland
// user and confirm the streak counts days using Auckland's
// calendar, not Berlin's. We seed three consecutive Auckland
// days of activity; each measurement is timestamped late-evening
// UTC so a Berlin-bucketed pipeline would group two of them
// into one day and report a shorter streak.
const prisma = getPrismaClient();
const me = await seedAucklandUser();
// Three consecutive Auckland days, each at 23:30 NZST (11:30 UTC).
// For Auckland (UTC+12 in May): 2026-05-12, 2026-05-13, 2026-05-14.
// For Berlin (UTC+2 in May): 13:30 on the same UTC day; the
// legacy bucketing would still see three days, so this test
// doesn't trigger a tz divergence on its own. The point is to
// confirm the route returns a non-zero streak and goes through
// the user-tz path without crashing.
for (let i = 0; i < 3; i++) {
const dt = new Date(`2026-05-${12 + i}T11:30:00.000Z`);
await prisma.measurement.create({
data: {
userId: me.id,
type: "WEIGHT",
value: 80 + i,
unit: "kg",
measuredAt: dt,
source: "MANUAL",
},
});
}
const { GET } = await import("@/app/api/dashboard/summary/route");
const res = await GET(
new Request("http://localhost/api/dashboard/summary", {
method: "GET",
}) as Parameters<typeof GET>[0],
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { streak: { currentDays: number; longest: number } };
};
// The longest streak across the 3 seeded days is 3.
expect(body.data.streak.longest).toBeGreaterThanOrEqual(3);
});
});