fix(tz): one default zone, and the user's own calendar where Berlin was hard-coded

The status assessments folded their recent days, weeks, months and years on
Berlin days whatever the user's zone, the mood and cycle crosstabs keyed
their metric days in Berlin, the resting-pulse proxy defaulted to Berlin
days, the briefing's opener rotated at Berlin midnight, and every
assessment prompt labelled today's date as Berlin's. They now use the
user's zone. Rollup months and years are labelled by their UTC start,
which is how they are cut; read in a zone west of UTC the label named the
month before.

"Europe/Berlin" was written out in some eighty places. DEFAULT_TIMEZONE in
lib/tz/format is now the one definition: the second copy in mood/date-key,
DISPLAY_TIMEZONE, the Berlin-only day key helpers and the per-route copies
are gone. An in-repo lint rule rejects the literal outside lib/tz and
tests; four areas another change owns are listed as pending.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 16:23:14 +02:00
1 parent c366d4946c
commit 9d2fd1db14
124 files changed
+705 -415

No files matched your search

@@ -9,6 +9,7 @@ import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised";
import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/tz/local-day";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
export const dynamic = "force-dynamic";
@@ -116,7 +117,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
let notificationsSent = 0;
for (const med of medications) {
const userTz = med.user.timezone || "Europe/Berlin";
const userTz = med.user.timezone || DEFAULT_TIMEZONE;
const { start: todayStart, end: todayEnd } = getUserTodayBounds(
now,
userTz,
@@ -44,8 +44,7 @@ import {
type ReminderScheduleInput,
} from "@/lib/measurement-reminders/scheduling";
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
const DEFAULT_TIMEZONE = "Europe/Berlin";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
async function postAction(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
+1 -1
View File
@@ -47,8 +47,8 @@ import {
type ReminderScheduleInput,
} from "@/lib/measurement-reminders/scheduling";
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
const DEFAULT_TIMEZONE = "Europe/Berlin";
const CHECKUP_NOTIFY_HOUR = 9;
async function postAction(request: NextRequest): Promise<Response> {
+2 -1
View File
@@ -39,7 +39,8 @@ import {
import { resolveCycleVerdict } from "@/lib/cycle/verdict";
import { addDays, dayDiff } from "@/lib/cycle/day-math";
import { BBT_WINDOW } from "@/lib/cycle/types";
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
import { moodDateKey } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
+1 -1
View File
@@ -28,7 +28,7 @@ import {
ensureCycleForBleedingDay,
removeCycleStartedOn,
} from "@/lib/cycle/cycle-boundaries";
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { encrypt, decrypt } from "@/lib/crypto";
import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
import { replaceSymptomLinks } from "@/lib/cycle/day-log-write";
+1 -1
View File
@@ -40,7 +40,7 @@ import {
type UnstableExternalIdShape,
} from "@/lib/validations/external-id";
import { upsertCycleDayLog } from "@/lib/cycle/day-log-write";
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
const BATCH_RATE_LIMIT_MAX = 60;
const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000;
+1 -1
View File
@@ -40,7 +40,7 @@ import {
import { upsertCycleDayLog } from "@/lib/cycle/day-log-write";
import { findOwningCycleId } from "@/lib/cycle/cycle-attribution";
import { toCycleDayLogDTO, dayLogSymptomInclude } from "@/lib/cycle/dto";
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
export const POST = apiHandler(withIdempotency<[NextRequest]>(postDayLog));
+4 -1
View File
@@ -43,7 +43,8 @@ import {
type SymptomDay,
} from "@/lib/cycle/symptom-phase";
import { addDays } from "@/lib/cycle/day-math";
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
import { moodDateKey } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
/** Trailing window the phase-contrast walks (days). */
const WINDOW_DAYS = 365;
@@ -228,6 +229,7 @@ export const GET = apiHandler(async () => {
phaseByDay,
measurements,
userPriorityJson,
timeZone: tz,
});
const headline = selectHeadlinePhaseRow(rows);
// The lagged rows are served with their narrated `interpretation` intact, so
@@ -239,6 +241,7 @@ export const GET = apiHandler(async () => {
phaseByDay,
measurements,
userPriorityJson,
timeZone: tz,
locale,
});
+1 -1
View File
@@ -37,7 +37,7 @@ import {
toMenstrualCycleDTO,
dayLogSymptomInclude,
} from "@/lib/cycle/dto";
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { openCycleAt, type MovedAnchors } from "@/lib/cycle/cycle-boundaries";
export const POST = apiHandler(withIdempotency<[NextRequest]>(postPeriod));
@@ -22,7 +22,7 @@ import { apiSuccess, apiError } from "@/lib/api-response";
import { requireIllnessEnabled } from "@/lib/illness/gate";
import { computeEpisodeCorrelation } from "@/lib/illness/correlation-read";
import { notifyIllnessRedFlag } from "@/lib/illness/red-flag-notify";
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
type RouteParams = { params: Promise<{ id: string }> };
@@ -34,7 +34,7 @@ import {
} from "@/lib/validations/illness";
import { upsertIllnessDayLog } from "@/lib/illness/day-log-write";
import { toIllnessDayLogDTO, dayLogSymptomInclude } from "@/lib/illness/dto";
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
type RouteParams = { params: Promise<{ id: string }> };
+2 -1
View File
@@ -37,7 +37,8 @@ import type { MeasurementType } from "@/generated/prisma/client";
import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit";
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
import { isP2002, isP2025 } from "@/lib/prisma-errors";
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
import { moodDateKey } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { deriveA1 } from "@/lib/mood/level-a";
import { zonedWallClockToUtc } from "@/lib/tz/wall-clock";
+2 -1
View File
@@ -34,6 +34,7 @@ import {
} from "@/lib/analytics/compliance";
import type { MeasurementType } from "@/generated/prisma/client";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
type Severity = "alert" | "caution" | "info" | "good";
@@ -216,7 +217,7 @@ export const GET = apiHandler(async () => {
arr.push(e);
eventsByMed.set(e.medicationId, arr);
}
const tz = user.timezone || "Europe/Berlin";
const tz = user.timezone || DEFAULT_TIMEZONE;
for (const med of activeMeds) {
const events = eventsByMed.get(med.id) ?? [];
const mapped = events.map((e) => ({
+3 -3
View File
@@ -41,7 +41,7 @@ import {
ensureUserMoodRollupsFresh,
readMoodDayRollups,
} from "@/lib/rollups/mood-rollups";
import { userDayKey } from "@/lib/tz/format";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
export const dynamic = "force-dynamic";
@@ -125,7 +125,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
// pass in this route (resting-pulse proxy, medication-continuity, BP
// pairing). Was previously re-derived ad hoc (or hardcoded UTC) at each
// call site.
const userTz = user.timezone || "Europe/Berlin";
const userTz = user.timezone || DEFAULT_TIMEZONE;
// Fetch user profile (height + DOB drive BMI + BP targets).
const dbUser = await prisma.user.findUnique({
@@ -445,7 +445,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
const medicationContext = buildComplianceMedicationContext(
med,
lastNonSkippedTakenAt(mapped),
user.timezone || "Europe/Berlin",
user.timezone || DEFAULT_TIMEZONE,
);
const c7 = calculateCompliance(mapped, med.schedules, 7, med.createdAt, {
medicationContext,
+2 -1
View File
@@ -41,6 +41,7 @@ import {
} from "@/lib/insights/correlation-patterns";
import { assembleDiscoveryMatrix } from "@/lib/insights/discovery-matrix";
import { fetchLabDraws } from "@/lib/insights/correlation-channel-series";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
export const dynamic = "force-dynamic";
@@ -115,7 +116,7 @@ async function buildCorrelationsResponse(
where: { id: userId },
select: { timezone: true },
});
const tz = profile?.timezone ?? "Europe/Berlin";
const tz = profile?.timezone ?? DEFAULT_TIMEZONE;
const now = new Date();
const since = new Date(now.getTime() - WINDOW_DAYS * MS_PER_DAY);
+1
View File
@@ -692,6 +692,7 @@ export const POST = apiHandler((request: NextRequest) =>
userId,
dbUser?.displayName ?? null,
locale,
await resolveUserTimezone(userId),
);
// v1.12.7 (B5) — inject the curated SOURCES block for the metric sections
+3 -2
View File
@@ -76,6 +76,7 @@ import type {
MeasurementSource,
GlucoseContext,
} from "@/generated/prisma/client";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "measurements");
@@ -192,7 +193,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
const tz =
user.timezone && user.timezone.length > 0
? user.timezone
: "Europe/Berlin";
: DEFAULT_TIMEZONE;
// Resolve `[dayStart, dayEnd)` for the requested calendar day in
// the user's local zone. v1.4.37 W10 — `localDayWindow` honours
// DST: the previous shape (`canonicalDailyTimestamp ± 12 h`)
@@ -242,7 +243,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
const tz =
user.timezone && user.timezone.length > 0
? user.timezone
: "Europe/Berlin";
: DEFAULT_TIMEZONE;
// v1.29.6 — DESC + take so a long history returns the MOST RECENT
// window of raw samples for bucketing. The previous `orderBy: asc`
// + `take: limit` sliced the OLDEST page of rows before the
@@ -9,6 +9,7 @@ import {
} from "@/lib/medications/compliance-payload";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { checkRateLimit } from "@/lib/rate-limit";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
type RouteParams = { params: Promise<{ id: string }> };
@@ -43,7 +44,7 @@ export const GET = apiHandler(
return apiError("Too many compliance requests. Please retry later.", 429);
}
const userTz = user.timezone || "Europe/Berlin";
const userTz = user.timezone || DEFAULT_TIMEZONE;
// Read-through the per-user compliance cache (15 min fresh TTL +
// SWR window). The ownership guard and the medication read live
@@ -41,6 +41,7 @@ import {
} from "@/lib/medications/scheduling/worker-helpers";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { dueSchedules } from "@/lib/medications/intake-tracking";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
type RouteParams = { params: Promise<{ id: string }> };
@@ -120,7 +121,7 @@ export const GET = apiHandler(
}
const now = new Date();
const userTz = user.timezone || "Europe/Berlin";
const userTz = user.timezone || DEFAULT_TIMEZONE;
const medication = await prisma.medication.findUnique({
where: { id },
@@ -2,6 +2,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { buildMedicationEfficacy } from "@/lib/medications/efficacy/build-efficacy";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
type RouteParams = { params: Promise<{ id: string }> };
@@ -31,7 +32,7 @@ export const GET = apiHandler(
return apiError("Too many efficacy requests. Please retry later.", 429);
}
const userTz = user.timezone || "Europe/Berlin";
const userTz = user.timezone || DEFAULT_TIMEZONE;
const dto = await buildMedicationEfficacy(user.id, id, userTz);
if (!dto) {
return apiError("Medication not found", 404);
+3 -2
View File
@@ -46,6 +46,7 @@ import {
isRecordOnly,
scheduleWireFields,
} from "@/lib/medications/intake-tracking";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
type RouteParams = { params: Promise<{ id: string }> };
@@ -175,7 +176,7 @@ export const GET = apiHandler(
},
schedules: liveSchedules,
now,
userTz: user.timezone || "Europe/Berlin",
userTz: user.timezone || DEFAULT_TIMEZONE,
lastIntakeAt: lastIntake?.takenAt ?? null,
resolvedSlots: resolvedRows.map(toResolvedSlotMark),
eraStart: latestRevision?.validUntil ?? null,
@@ -603,7 +604,7 @@ export const PUT = apiHandler(
// `syncVersion` so delta-sync clients drop them. The projector and
// the reminder worker re-mint the anchors for the new times on their
// next pass — the route does not pre-create them.
const userTz = user.timezone || "Europe/Berlin";
const userTz = user.timezone || DEFAULT_TIMEZONE;
const { start: todayStart } = getUserTodayBounds(new Date(), userTz);
const tombstoneWhere = {
userId: user.id,
+2 -1
View File
@@ -12,6 +12,7 @@ import {
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { checkRateLimit } from "@/lib/rate-limit";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
/**
* Batched card-compliance read: one round trip for every medication the
@@ -43,7 +44,7 @@ export const GET = apiHandler(async () => {
return apiError("Too many compliance requests. Please retry later.", 429);
}
const userTz = user.timezone || "Europe/Berlin";
const userTz = user.timezone || DEFAULT_TIMEZONE;
// Same ordering as the medications list so the page's card order and
// this payload walk the same sequence.
+2 -1
View File
@@ -14,7 +14,8 @@ import { NextRequest } from "next/server";
import { Prisma } from "@/generated/prisma/client";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { moodDateKey } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { deriveA1, shapeLevelA } from "@/lib/mood/level-a";
import { contextForWire, persistMoodContext } from "@/lib/mood/context";
import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher";
+2 -1
View File
@@ -51,7 +51,8 @@ import {
UNSTABLE_EXTERNAL_ID_REASON,
type UnstableExternalIdShape,
} from "@/lib/validations/external-id";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { moodDateKey } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { levelAForWrite } from "@/lib/mood/level-a";
import { persistMoodContext } from "@/lib/mood/context";
import { encryptNote } from "@/lib/crypto/note-cipher";
+2 -1
View File
@@ -24,7 +24,8 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { withIdempotency } from "@/lib/idempotency";
import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { moodDateKey } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { levelAForWrite, shapeLevelA } from "@/lib/mood/level-a";
import { contextForWire, persistMoodContext } from "@/lib/mood/context";
import { listMoodEntriesPage } from "@/lib/mood/list-read";
+1 -2
View File
@@ -38,8 +38,7 @@ import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { resolveLinkedDayContext } from "@/lib/mood/linked-context";
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { isValidTimezone } from "@/lib/tz/format";
import { DEFAULT_TIMEZONE, isValidTimezone } from "@/lib/tz/format";
export const dynamic = "force-dynamic";
+2 -1
View File
@@ -42,6 +42,7 @@ import {
NATIVE_REFRESH_TOKEN_DAYS,
TOMBSTONE_RETENTION_DAYS,
} from "@/lib/auth/native-client";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
export const GET = apiHandler(async (_request: NextRequest) => {
const { user } = await requireAuth();
@@ -119,7 +120,7 @@ export const GET = apiHandler(async (_request: NextRequest) => {
return apiSuccess({
userId: user.id,
timezone: currentUser?.timezone ?? "Europe/Berlin",
timezone: currentUser?.timezone ?? DEFAULT_TIMEZONE,
lastSyncedAt: lastSyncedAt?.toISOString() ?? null,
serverNow: serverNow.toISOString(),
measurements: {