Merge pull request #491 from MBombeck/release/v1.28.35

release/v1.28.35 — timestamps honor the profile timezone
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-14 20:32:36 +02:00
commit aefddf24a8
41 files changed
+958 -90

No files matched your search

+18
View File
@@ -2,6 +2,24 @@
## [Unreleased]
## [1.28.35] — 2026-07-14 — Timestamps honor the profile timezone
Client-rendered times previously fell back to a hardcoded display zone
(Europe/Berlin) no matter what the profile timezone said; a user in Manila saw
Berlin clock times on every card, chart, and table. Display now follows the
profile timezone everywhere, with a strict fallback to the legacy display zone
— never the browser zone, so screen, PDF, and export can no longer disagree.
An invalid stored timezone can never break rendering; it falls back safely.
The same pass closed the latent inconsistencies the audit surfaced: the
"Updated today" cards no longer mix one zone for the day boundary and another
for the clock; calendar-date fields no longer shift a day for profiles west of
UTC; the dose-history day headings and weekday labels render in the display
zone; chart day/month labels stay correct for every zone (bucket math is
untouched); and the clinician share view renders in the patient's timezone
instead of the server container's. Day groupings, schedules, and aggregates
were already timezone-correct server-side and are byte-identical.
## [1.28.34] — 2026-07-14 — Nutrient intake lands as quiet context (server side)
Supplement-style intake written to Apple Health by nutrition apps — vitamins,
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.28.34
version: 1.28.35
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+100
View File
@@ -0,0 +1,100 @@
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* Issue #490 — client-rendered timestamps follow the PROFILE timezone.
*
* `/api/auth/me` is mocked with an `Asia/Manila` profile; the sessions
* card on /settings/privacy renders `lastActiveAt` through
* `useFormatters()`, which reads the localStorage timezone mirror that
* `fetchMe` fills from the mocked payload. A fixed UTC instant must
* therefore paint the MANILA wall clock (14:30), never the Berlin
* fallback (08:30) and never the browser zone (the Playwright context is
* pinned to Europe/Berlin precisely so this assertion is meaningful).
*
* Asserts against the stable `data-slot="security-session-row"` hook,
* not viewport-dependent text.
*/
// 06:30 UTC = 14:30 Asia/Manila (+8, no DST) = 08:30 Europe/Berlin (CEST).
const LAST_ACTIVE_UTC = "2026-07-14T06:30:00.000Z";
test.describe("profile-timezone display (#490)", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("sessions card renders lastActiveAt in the Manila profile zone", async ({
page,
}) => {
await page.route("**/api/auth/me", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
id: "user_e2e",
username: "e2e-tester",
email: "e2e@healthlog.test",
role: "USER",
heightCm: 180,
dateOfBirth: "1990-01-01",
gender: "MALE",
timezone: "Asia/Manila",
timeFormat: "H24",
dateFormat: "AUTO",
onboardingCompletedAt: "2025-01-01T00:00:00.000Z",
onboardingTourCompleted: true,
gravatarUrl: null,
glucoseUnit: "mg/dL",
},
error: null,
}),
}),
);
await page.route("**/api/auth/me/sessions", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
sessions: [
{
id: "sess_e2e_current",
device: "Chrome on macOS",
ipMasked: "192.168.1.xxx",
location: null,
lastActiveAt: LAST_ACTIVE_UTC,
createdAt: LAST_ACTIVE_UTC,
isCurrent: true,
},
],
},
error: null,
}),
}),
);
await page.goto("/settings/privacy", { waitUntil: "domcontentloaded" });
// The sessions card is collapsed by default — expand it first.
const toggle = page.locator(
'[data-slot="settings-security-sessions-toggle"]',
);
await expect(toggle).toBeVisible({ timeout: 10_000 });
await toggle.click();
const row = page.locator('[data-slot="security-session-row"]').first();
await expect(row).toBeVisible({ timeout: 10_000 });
// Manila wall clock (H24). The mirror write from the mocked /me may
// land after the first paint of the sessions row — the poll absorbs
// the re-render.
await expect(row).toContainText("14:30", { timeout: 10_000 });
// Never the Berlin fallback for this instant…
await expect(row).not.toContainText("08:30");
// …and never a 12-hour rendering (the H24 preference rides the same
// mirror pipeline).
await expect(row).not.toContainText("PM");
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.28.34",
"version": "1.28.35",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+6
View File
@@ -59,6 +59,12 @@ export default defineConfig({
// matches what real users render on first paint and is the only honest
// a11y baseline for this app.
colorScheme: "dark",
// Issue #490 — pin the browser timezone. Without this the context
// inherits the HOST zone (UTC on CI, Europe/Berlin on a local Mac),
// so any surface that renders a clock or a day boundary could pass
// locally and skew two hours / a calendar day on CI. Berlin matches
// the app's own display fallback, so CI and local render identically.
timezoneId: "Europe/Berlin",
},
projects: [
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.28.34";
/* @sw-version-fallback */ "v1.28.35";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
@@ -36,6 +36,11 @@ vi.mock("@/lib/clinician-share/resolve-share-token", () => ({
vi.mock("@/lib/clinician-share/share-view-data", () => ({
loadShareViewData: vi.fn(),
}));
// Issue #490 — the page resolves the share OWNER's timezone for the date
// rendering; the Prisma-backed resolver is stubbed like the data loader.
vi.mock("@/lib/tz/resolver", () => ({
resolveUserTimezone: vi.fn(async () => "Europe/Berlin"),
}));
vi.mock("@/components/clinician/clinician-view", () => ({
ClinicianView: () => null,
}));
+11 -2
View File
@@ -31,6 +31,7 @@ import {
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { ClinicianView } from "@/components/clinician/clinician-view";
import { ShareUnlockGate } from "@/components/clinician/share-unlock-gate";
@@ -103,8 +104,15 @@ export default async function ClinicianSharePage({
const context = await resolveShareToken(token);
if (!context) notFound();
const [{ report, sections, documents, documentOnly }, locale] =
await Promise.all([loadShareViewData(context), resolveLocale()]);
// Issue #490 — period/expiry dates render in the share OWNER's (patient's)
// profile timezone so they agree with the patient-tz aggregation behind the
// stats and with the doctor-report PDF (never the container's zone).
const [{ report, sections, documents, documentOnly }, locale, ownerTimezone] =
await Promise.all([
loadShareViewData(context),
resolveLocale(),
resolveUserTimezone(context.ownerUserId),
]);
const { t } = getServerTranslator(locale);
return (
@@ -118,6 +126,7 @@ export default async function ClinicianSharePage({
documentOnly={documentOnly}
token={token}
locale={locale}
timezone={ownerTimezone}
/>
);
}
+2 -6
View File
@@ -41,7 +41,7 @@ interface ComprehensiveMoodData {
}
export default function InsightsStimmungPage() {
const { isAuthenticated, user } = useAuth();
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
@@ -107,11 +107,7 @@ export default function InsightsStimmungPage() {
MeasurementType series, so the period-over-period range read
(`/api/analytics/range`, keyed on a MeasurementType enum) has
nothing to aggregate. */}
<MoodChart
chartKey="mood"
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
<MoodChart chartKey="mood" compareBaseline={compareBaseline} />
<MetricTargetSummary slug="mood" />
-1
View File
@@ -1375,7 +1375,6 @@ export default function DashboardPageClient() {
onDataReady={() => markChartReady("mood-chart")}
compareBaseline={compareBaseline}
chartKey="mood"
userTimezone={user?.timezone}
/>
),
});
+10 -9
View File
@@ -35,7 +35,7 @@ import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { computePaddedYDomain } from "@/lib/insights/chart-y-domain";
import { Button } from "@/components/ui/button";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { makeFormatters } from "@/lib/format-locale";
import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label";
import {
bucketTimeSeries,
pickBucket,
@@ -595,14 +595,15 @@ export function HealthChart({
const { isAuthenticated, user } = useAuth();
const { t, locale } = useTranslations();
const fmt = useFormatters();
// v1.4.25 W7b — tz-aware formatter for x-axis tick labels + tooltip
// date strings. `useFormatters()` reads the active UI locale only;
// this builds a locale + userTz pair so a Pacific/Auckland user reads
// their own day on the axis.
const tzFmt = useMemo(
() => makeFormatters(locale, userTimezone),
[locale, userTimezone],
);
// Issue #490 — x-axis tick + tooltip date labels. Every timestamp this
// chart formats encodes a CALENDAR DAY (noon-UTC of a profile-tz day
// key, or a `bucketTimeSeries` Berlin-day UTC-midnight bucket start),
// so labels render UTC-pinned via `makeBucketLabelFormatters` — the
// encoded day paints verbatim in every profile zone. Rendering these
// encodings through a profile-tz formatter shifted week/month bucket
// labels a day/month back for zones west of Berlin. Day-key BUCKETING
// below still uses `userTimezone` — only labels are pinned.
const tzFmt = useMemo(() => makeBucketLabelFormatters(locale), [locale]);
// v1.4.25 W7b — per-row day-key formatter used to bucket measurement
// rows by the user's local calendar day. Memoised on userTimezone so
// the inner per-row loop reuses a single Intl.DateTimeFormat.
+8 -14
View File
@@ -19,7 +19,7 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { useTranslations } from "@/lib/i18n/context";
import { makeFormatters } from "@/lib/format-locale";
import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label";
import type { DataSummary } from "@/lib/analytics/trends";
import {
bucketTimeSeries,
@@ -90,12 +90,6 @@ interface MoodChartProps {
* only surface — the cog stays hidden and overlays are clean.
*/
chartKey?: ChartOverlayKey;
/**
* v1.4.25 W7b — per-user display timezone. When passed, the x-axis
* tick labels and tooltip date render in the user's tz. Defaults to
* "Europe/Berlin" so older mount sites stay byte-identical.
*/
userTimezone?: string;
/**
* v1.16.0 — fires once the mood analytics query has settled (initial
* load finished). The dashboard's shared reveal gate listens here so
@@ -304,17 +298,17 @@ export function MoodChart({
windowOverride,
compareBaseline = "none",
chartKey,
userTimezone = "Europe/Berlin",
onDataReady,
}: MoodChartProps) {
const { isAuthenticated } = useAuth();
const { t, locale } = useTranslations();
// v1.4.25 W7b — tz-aware formatter for x-axis tick labels + tooltip
// date strings.
const tzFmt = useMemo(
() => makeFormatters(locale, userTimezone),
[locale, userTimezone],
);
// Issue #490 — x-axis tick + tooltip date labels. Every timestamp this
// chart formats encodes a CALENDAR DAY (noon-UTC of a server day key,
// or a `bucketTimeSeries` Berlin-day UTC-midnight bucket start), so
// labels render UTC-pinned via `makeBucketLabelFormatters` — the
// encoded day paints verbatim in every profile zone (a profile-tz
// formatter shifted week/month bucket labels for zones west of Berlin).
const tzFmt = useMemo(() => makeBucketLabelFormatters(locale), [locale]);
const initialRangePoints = windowOverride
? MINI_RANGE_POINTS[windowOverride]
: 30;
+18 -2
View File
@@ -17,6 +17,8 @@ import { formatBytes } from "@/components/documents/vault-utils";
import type { ShareViewDocument } from "@/lib/clinician-share/share-view-data";
import { DOCTOR_REPORT_TYPE_LABEL_KEYS } from "@/lib/doctor-report/type-label-keys";
import type { DoctorReportData } from "@/lib/doctor-report-data";
import { makeFormatters } from "@/lib/format-locale";
import type { Locale } from "@/lib/i18n/config";
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
import type { DoctorReportPrefs } from "@/lib/validations/doctor-report-prefs";
@@ -53,7 +55,17 @@ interface ClinicianViewProps {
/** The raw share token from the path — used to build serve-route URLs. */
token?: string;
/** Viewer locale (byte formatting only). Defaults to English. */
locale?: string;
locale?: Locale;
/**
* Issue #490 — the share OWNER's (patient's) profile timezone. Period
* start/end and the expiry date render in this zone so the dates agree
* with the patient-tz aggregation behind the stats (and with the
* doctor-report PDF). Pre-fix the dates rendered via a bare
* `toLocaleDateString()` — the server CONTAINER's zone (≈ UTC), neither
* the patient's nor the viewer's, with US-English field order regardless
* of the resolved page locale.
*/
timezone?: string;
}
/** Human-readable display per persisted wellness-score type (i18n key suffix). */
@@ -197,8 +209,12 @@ export function ClinicianView({
documentOnly = false,
token = "",
locale = "en",
timezone,
}: ClinicianViewProps) {
const fmtDate = (iso: string) => new Date(iso).toLocaleDateString();
// Owner-tz, locale-aware date rendering (issue #490) — `makeFormatters`
// guards the zone and falls back to Europe/Berlin on garbage/absence.
const fmt = makeFormatters(locale, timezone);
const fmtDate = (iso: string) => fmt.date(new Date(iso));
const fmtNum = (n: number) => Math.round(n * 100) / 100;
// A documents-only share carries no report — the aggregator is never called
+9 -4
View File
@@ -25,10 +25,13 @@ import { EmptyState } from "@/components/ui/empty-state";
import { ListRow } from "@/components/ui/list-row";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionHeading } from "@/components/insights/section-heading";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import {
useTranslations,
useFormatters,
useDisplayTimezone,
} from "@/lib/i18n/context";
import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { useAuth } from "@/hooks/use-auth";
import { cn } from "@/lib/utils";
import type {
DailyBriefing as DailyBriefingPayload,
@@ -371,7 +374,9 @@ export function DailyBriefing({
}: DailyBriefingProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const { user } = useAuth();
// Issue #490 — the SAME resolved zone `fmt.time` renders in (mirror →
// Berlin), so the today/yesterday boundary and the clock can never split.
const displayTz = useDisplayTimezone();
// v1.25.3 — when there is no last good text and the last attempt failed,
// point the hint at the lever that matches the failure class. A timeout (the
@@ -493,7 +498,7 @@ export function DailyBriefing({
t,
fmt.dateShort,
fmt.time,
user?.timezone,
displayTz,
)}
</p>
)}
+9 -2
View File
@@ -3,7 +3,11 @@
import Link from "next/link";
import { Sparkles } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import {
useTranslations,
useFormatters,
useDisplayTimezone,
} from "@/lib/i18n/context";
import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
import { useAuth } from "@/hooks/use-auth";
import { useModuleEnabled } from "@/hooks/use-module-enabled";
@@ -188,6 +192,9 @@ export function HeroStrip({
const { t } = useTranslations();
const fmt = useFormatters();
const { user } = useAuth();
// Issue #490 — the SAME resolved zone `fmt.time` renders in (mirror →
// Berlin), so the today/yesterday boundary and the clock can never split.
const displayTz = useDisplayTimezone();
// v1.21.2 (A5) — localise the Tension Verdict's contributor keys into the
// card's already-localised `{ positive, negative }` shape. Only forwarded when
@@ -237,7 +244,7 @@ export function HeroStrip({
// hero no longer reads relative while the cards below it read absolute. The
// day boundary follows the user's profile timezone.
const generatedLine = updatedAt
? formatUpdatedLabel(updatedAt, t, fmt.dateShort, fmt.time, user?.timezone)
? formatUpdatedLabel(updatedAt, t, fmt.dateShort, fmt.time, displayTz)
: null;
return (
@@ -5,12 +5,15 @@ import Link from "next/link";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { TileHeader } from "@/components/insights/tile-header";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import {
useTranslations,
useFormatters,
useDisplayTimezone,
} from "@/lib/i18n/context";
import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { ProseBlocks } from "@/components/insights/prose-blocks";
import { cn } from "@/lib/utils";
import { useAuth } from "@/hooks/use-auth";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { AskCoachIconButton } from "@/components/insights/ask-coach-action";
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
@@ -289,7 +292,9 @@ function StatusBody({ text }: { text: string }) {
function LastUpdatedFooter({ updatedAt }: { updatedAt: string | null }) {
const { t } = useTranslations();
const fmt = useFormatters();
const { user } = useAuth();
// Issue #490 — boundary zone == the zone `fmt.time` renders in (mirror →
// Berlin), so the today/yesterday bucket and the clock can never split.
const displayTz = useDisplayTimezone();
if (!updatedAt) return null;
return (
// v1.11.5 — right-aligned so the timestamp tucks to the trailing edge
@@ -300,13 +305,7 @@ function LastUpdatedFooter({ updatedAt }: { updatedAt: string | null }) {
// matching the briefing + per-metric cards. The day boundary follows the
// user's profile timezone, not the browser's.
<p className="text-muted-foreground text-right text-xs">
{formatUpdatedLabel(
updatedAt,
t,
fmt.dateShort,
fmt.time,
user?.timezone,
)}
{formatUpdatedLabel(updatedAt, t, fmt.dateShort, fmt.time, displayTz)}
</p>
);
}
@@ -4,10 +4,13 @@ import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { CalendarRange } from "lucide-react";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import {
useTranslations,
useFormatters,
useDisplayTimezone,
} from "@/lib/i18n/context";
import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
import { queryKeys } from "@/lib/query-keys";
import { useAuth } from "@/hooks/use-auth";
import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionHeading } from "@/components/insights/section-heading";
@@ -77,7 +80,9 @@ export function PeriodNarrativeCard({
}: PeriodNarrativeCardProps) {
const { t, locale } = useTranslations();
const fmt = useFormatters();
const { user } = useAuth();
// Issue #490 — the SAME resolved zone `fmt.time` renders in (mirror →
// Berlin), so the today/yesterday boundary and the clock can never split.
const displayTz = useDisplayTimezone();
const [period, setPeriod] = useState<NarrativePeriod>("week");
const query = useQuery({
@@ -197,7 +202,7 @@ export function PeriodNarrativeCard({
t,
fmt.dateShort,
fmt.time,
user?.timezone,
displayTz,
)}
</p>
</div>
@@ -261,11 +261,7 @@ function RationaleCard({
compliance get dedicated wrappers; everything else routes
through HealthChart with the metric-key vocabulary. */}
{isMoodMetric(metricType) ? (
<MoodChart
mini
windowOverride={rationale.dataWindow}
userTimezone={user?.timezone}
/>
<MoodChart mini windowOverride={rationale.dataWindow} />
) : isComplianceMetric(metricType) ? null : chartTypes.length > 0 ? (
<HealthChart
types={chartTypes}
+1 -1
View File
@@ -112,7 +112,7 @@ function MetricChart({
const { user } = useAuth();
const userTimezone = user?.timezone;
if (config.kind === "mood") {
return <MoodChart title={title} mini userTimezone={userTimezone} />;
return <MoodChart title={title} mini />;
}
return (
<HealthChartDynamicMini
@@ -40,7 +40,11 @@ import {
Wrench,
} from "lucide-react";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import {
useFormatters,
useTranslations,
useDisplayTimezone,
} from "@/lib/i18n/context";
import { relativeCalendarDate } from "@/lib/i18n/relative-time";
import { startOfLocalDayInTz } from "@/lib/tz/local-day";
import { cn } from "@/lib/utils";
@@ -804,6 +808,9 @@ function VorsorgeCard({
}) {
const { t } = useTranslations();
const fmt = useFormatters();
// Issue #490 — day-boundary zone for the relative "today / yesterday"
// bucket must match the zone `fmt.date` renders in (mirror → Berlin).
const displayTz = useDisplayTimezone();
const [confirmDelete, setConfirmDelete] = useState(false);
const due = relativeDueKey(reminder.nextDueAt, now);
const cadence =
@@ -1017,7 +1024,12 @@ function VorsorgeCard({
// the same `medications.today` / `medications.yesterday` keys the medication
// card renders. Falls back to "—" when there is no last-done value.
const lastValue = reminder.lastSatisfiedAt
? relativeCalendarDate(reminder.lastSatisfiedAt, t, (d) => fmt.date(d))
? relativeCalendarDate(
reminder.lastSatisfiedAt,
t,
(d) => fmt.date(d),
displayTz,
)
: "—";
// v1.18.9 (#39) — the next-due status reads as DISCREET COLOURED TEXT (never
@@ -1100,6 +1112,10 @@ function VorsorgeCard({
{reminder.endsOn && (
<p className="text-muted-foreground text-sm">
{/* `endsOn` is a real instant (`now + courseDays` at Coach
accept, plain timestamptz — NOT a `@db.Date` UTC-midnight
calendar date), so the profile-tz formatter renders the
correct local course-end day for every zone. */}
{t("measurementReminders.until", {
date: fmt.date(new Date(reminder.endsOn)),
})}
@@ -77,7 +77,12 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import {
useTranslations,
useFormatters,
useDateFormatPreference,
} from "@/lib/i18n/context";
import { formatDateWithWeekday } from "@/lib/date-format";
import {
invalidateKeys,
medicationDependentKeys,
@@ -152,8 +157,9 @@ export function DoseHistoryLedger({
schedules,
windowDays = 90,
}: DoseHistoryLedgerProps) {
const { t } = useTranslations();
const { t, locale } = useTranslations();
const fmt = useFormatters();
const dateFormatPref = useDateFormatPreference();
const { user } = useAuth();
const queryClient = useQueryClient();
const timeZone = user?.timezone || "Europe/Berlin";
@@ -437,7 +443,12 @@ export function DoseHistoryLedger({
data-day={group.dayKey}
>
<h3 className="text-muted-foreground px-1 text-xs font-medium">
{fmt.dateWithWeekday(new Date(`${group.dayKey}T00:00:00`))}
{/* Issue #490 — `dayKey` is a profile-timezone calendar day.
The old browser-local-midnight parse re-interpreted it in
the DEVICE zone and could shift the heading a day when
browser ≠ profile zone; the day-key label renders
UTC-pinned instead (zone-independent, weekday included). */}
{formatDateWithWeekday(group.dayKey, dateFormatPref, locale)}
</h3>
<ul className="border-border/60 divide-border/60 divide-y rounded-md border">
{group.rows.map((row, i) => (
@@ -9,7 +9,11 @@ import { MedicationStateBadges } from "@/components/medications/card-parts/medic
import { MedicationCardBody } from "@/components/medications/card-parts/medication-card-body";
import { useMedicationIntake } from "@/components/medications/use-medication-intake";
import { useWeekdayLabel } from "@/components/medications/card-parts/medication-next-last-slot";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import {
useTranslations,
useFormatters,
useDisplayTimezone,
} from "@/lib/i18n/context";
import {
invalidateKeys,
medicationDependentKeys,
@@ -19,6 +23,7 @@ import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { formatDateTime, formatTime } from "@/lib/format";
import { getMedicationCategoryLabel } from "@/lib/medications/category-label";
import { formatDose } from "@/lib/medications/format-dose";
import { getDayOfWeekInTz } from "@/lib/tz/local-day";
import { type InjectionSiteKey } from "@/lib/medications/injection-sites";
import { LogInjectionSiteDialog } from "@/components/medications/log-injection-site-dialog";
import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs";
@@ -185,6 +190,11 @@ export function Glp1MedicationCard({
const { user } = useAuth();
const userTz = user?.timezone || "Europe/Berlin";
const fmt = useFormatters();
// Issue #490 — the zone `fmt` renders dates in (mirror → Berlin). The
// weekday name paired with `fmt.dateShort` must be derived in this SAME
// zone: `Date.getDay()` reads the BROWSER zone and could name a weekday
// that contradicts the date printed next to it.
const displayTz = useDisplayTimezone();
const weekdayLabel = useWeekdayLabel();
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
// v1.8.5 — post-dose injection-site prompt (see medication-card.tsx).
@@ -403,7 +413,7 @@ export function Glp1MedicationCard({
if (!next) return null;
if (next.daysAway === 0) return t("medications.glp1NextInjectionToday");
if (next.daysAway === 1) return t("medications.glp1NextInjectionTomorrow");
const dayName = weekdayLabel(next.date.getDay());
const dayName = weekdayLabel(getDayOfWeekInTz(next.date, displayTz));
const dateShort = fmt.dateShort(next.date);
return t("medications.glp1NextInjectionDays", {
label: `${dayName}, ${dateShort}`,
@@ -453,7 +463,7 @@ export function Glp1MedicationCard({
medication.nextDueOverdue && next
? diffDays(next.date, now) === 0
? formatTime(next.date.toISOString())
: `${weekdayLabel(next.date.getDay())}, ${fmt.dateShort(next.date)}`
: `${weekdayLabel(getDayOfWeekInTz(next.date, displayTz))}, ${fmt.dateShort(next.date)}`
: null;
const nextLine =
@@ -21,7 +21,12 @@ import {
summariseCadence,
type MedicationPayload,
} from "@/components/medications/wizard/wizard-payload";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import {
useTranslations,
useFormatters,
useDateFormatPreference,
} from "@/lib/i18n/context";
import { formatDate as formatCalendarDate } from "@/lib/date-format";
export interface MedicationDetailSummaryProps {
name: string;
@@ -58,8 +63,9 @@ export function MedicationDetailSummary({
asNeeded = false,
startsOn,
}: MedicationDetailSummaryProps) {
const { t } = useTranslations();
const { t, locale } = useTranslations();
const formatters = useFormatters();
const dateFormatPref = useDateFormatPreference();
const status = resolveStatus(active, endsOn);
const statusLabel =
@@ -80,10 +86,15 @@ export function MedicationDetailSummary({
? "bg-warning"
: "bg-muted-foreground";
// Issue #490 — `Medication.startsOn` is a `@db.Date` calendar date
// (serialised as UTC midnight). The old `formatters.dateTime(startsOn)`
// re-read that instant in the display zone: it invented a meaningless
// clock time and shifted the day for zones west of UTC. A calendar date
// renders UTC-pinned (date only), correct in every zone.
const cadenceLine = oneShot
? startsOn
? t("medications.detail.cadence.oneShotOn", {
date: formatters.dateTime(startsOn),
date: formatCalendarDate(new Date(startsOn), dateFormatPref, locale),
})
: t("medications.detail.cadence.oneShotPending")
: asNeeded
@@ -215,6 +215,12 @@ export function ScheduleHistoryTimeline({
</span>
)}
</p>
{/* `validFrom` / `validUntil` (like `currentSince`
above) are real era-boundary instants
(`@db.Timestamptz`, the moment an edit archived the
plan) — NOT `@db.Date` calendar dates — so the
profile-timezone formatter renders the correct
local day (issue #490 audit). */}
<p className="text-muted-foreground text-xs tabular-nums">
{fmt.date(revision.validFrom)}
{" – "}
@@ -40,7 +40,11 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { MedicationCardHeader } from "@/components/medications/medication-card-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import {
useFormatters,
useTranslations,
useDisplayTimezone,
} from "@/lib/i18n/context";
import { relativeCalendarDate } from "@/lib/i18n/relative-time";
import {
INSTRUMENTS,
@@ -67,6 +71,9 @@ export function InstrumentCard({
}) {
const { t, locale } = useTranslations();
const { date: formatDate } = useFormatters();
// Issue #490 — day-boundary zone for the relative "today / yesterday"
// bucket must match the zone `formatDate` renders in (mirror → Berlin).
const displayTz = useDisplayTimezone();
const def = INSTRUMENTS[instrument];
const key = def.i18nKey;
const title = t(`mentalHealth.instrument.${key}`);
@@ -139,7 +146,7 @@ export function InstrumentCard({
{t("mentalHealth.lastResult")}
</span>
<span className="text-foreground text-right">
{relativeCalendarDate(last.takenAt, t, formatDate)}
{relativeCalendarDate(last.takenAt, t, formatDate, displayTz)}
</span>
</div>
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
@@ -11,7 +11,11 @@
* deliberate click, never pushed at the moment someone arrives to check in.
*/
import { Button } from "@/components/ui/button";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import {
useFormatters,
useTranslations,
useDisplayTimezone,
} from "@/lib/i18n/context";
import { relativeCalendarDate } from "@/lib/i18n/relative-time";
import { INSTRUMENTS } from "@/lib/mental-health/instruments";
@@ -31,6 +35,9 @@ export function InstrumentDetail({
}) {
const { t } = useTranslations();
const { date: formatDate } = useFormatters();
// Issue #490 — day-boundary zone for the relative "today / yesterday"
// bucket must match the zone `formatDate` renders in (mirror → Berlin).
const displayTz = useDisplayTimezone();
const def = INSTRUMENTS[instrument];
const last = rows.find((r) => r.instrument === instrument);
@@ -45,7 +52,7 @@ export function InstrumentDetail({
{t("mentalHealth.lastResult")}
</span>
<span className="text-foreground text-right">
{relativeCalendarDate(last.takenAt, t, formatDate)}
{relativeCalendarDate(last.takenAt, t, formatDate, displayTz)}
</span>
</div>
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
@@ -25,6 +25,7 @@ import { AboutMeSection } from "@/components/settings/about-me-section";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TimezonePicker } from "@/components/settings/timezone-picker";
import { storeTimezone } from "@/lib/timezone-mirror";
import { TimeFormatSelect } from "@/components/settings/time-format-select";
import { DateFormatSelect } from "@/components/settings/date-format-select";
import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select";
@@ -142,6 +143,13 @@ export function AccountSection() {
]);
if (profileRes.ok && tzRes.ok) {
// Instant same-tab flip of every rendered timestamp (issue #490):
// mirror the accepted zone before the `/me` refetch confirms it —
// the same synchronous-update pattern the time/date-format selects
// use. `refetch()` → `fetchMe` re-writes the mirror authoritatively.
if (user && timezone && timezone !== user.timezone) {
storeTimezone(timezone);
}
setSaveMsg({ key: "settings.profileSaved" });
setSaveMsgType("success");
await refetch();
+7
View File
@@ -18,6 +18,7 @@ import type {
} from "@/lib/format-locale";
import { isTimeFormatPreference, storeTimeFormat } from "@/lib/time-format";
import { isDateFormatPreference, storeDateFormat } from "@/lib/date-format";
import { storeTimezone } from "@/lib/timezone-mirror";
import type { ModuleKey } from "@/lib/modules/registry";
import type { TourProgress } from "@/lib/onboarding/tour-progress";
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
@@ -241,6 +242,12 @@ async function fetchMe(): Promise<AuthUser> {
? data.dateFormat
: "AUTO";
storeDateFormat(dateFormat);
// Profile timezone (issue #490): mirror into localStorage so
// `useFormatters()` and the legacy format helpers render timestamps in
// the user's zone. `storeTimezone` validates the IANA name and CLEARS
// the mirror on garbage, so a poison value falls back to Berlin instead
// of reaching `Intl.DateTimeFormat` (RangeError).
storeTimezone(typeof data.timezone === "string" ? data.timezone : "");
// A confirmed session: refresh the non-sensitive offline-relaunch marker.
storeWasAuthenticated();
return {
+41
View File
@@ -9,6 +9,7 @@ import { describe, expect, it } from "vitest";
import {
formatDate,
formatDateWithWeekday,
parseIsoDate,
resolveDateLocale,
isDateFormatPreference,
@@ -71,6 +72,46 @@ describe("formatDate", () => {
});
});
// ── Issue #490 — `@db.Date` / day-key rendering regressions ─────────────
//
// A serialised calendar date (`@db.Date` → UTC-midnight instant, or a raw
// profile-tz `yyyy-MM-dd` day key) must render the ENCODED day in every
// display zone — the pre-fix paths re-read the encoding in a timezone-aware
// formatter and shifted the day for zones west of UTC (medication one-shot
// `startsOn`) or for browser ≠ profile pairs (dose-ledger day headings).
describe("formatDateWithWeekday (#490)", () => {
// 2026-07-14 is a Tuesday.
it("renders a day key with its zone-independent weekday", () => {
expect(formatDateWithWeekday("2026-07-14", "AUTO", "en")).toBe(
"Tue, 07/14",
);
expect(formatDateWithWeekday("2026-07-14", "AUTO", "de")).toBe(
"Di., 14.07.",
);
});
it("honours the date-order preference like the formatter hook", () => {
expect(formatDateWithWeekday("2026-07-14", "DMY", "en")).toBe(
"Di., 14.07.",
);
expect(formatDateWithWeekday("2026-07-14", "MDY", "de")).toBe("Tue, 07/14");
});
it("renders a UTC-midnight Date on its encoded day (never the day before)", () => {
// The `@db.Date` wire shape — the exact value class the medication
// one-shot `startsOn` fix feeds through `formatDate`.
const utcMidnight = new Date("2026-07-01T00:00:00.000Z");
expect(formatDateWithWeekday(utcMidnight, "AUTO", "en")).toBe("Wed, 07/01");
expect(formatDate(utcMidnight, "AUTO", "en")).toBe("07/01/2026");
});
it("returns an empty string for empty / unparseable input", () => {
expect(formatDateWithWeekday("", "AUTO", "en")).toBe("");
expect(formatDateWithWeekday(null, "AUTO", "en")).toBe("");
expect(formatDateWithWeekday("14.07.2026", "AUTO", "en")).toBe("");
});
});
describe("parseIsoDate", () => {
it("parses a valid ISO calendar date to a UTC midnight Date", () => {
const d = parseIsoDate("2026-02-19");
+75
View File
@@ -137,6 +137,81 @@ describe("makeFormatters", () => {
// The narrow check: day-of-month should be 11, not 10.
expect(tokyo.date(lateUtc)).toMatch(/11/);
});
// ── Issue #490 — profile-timezone display matrix ──────────────────────
//
// The client formatters now receive the mirrored profile zone. Pin the
// three matrix zones (east + DST, west of Berlin + DST, east no-DST)
// and the poison values that must NEVER reach `Intl.DateTimeFormat`
// unguarded (RangeError there would white-screen every date render).
describe("profile-timezone matrix (#490)", () => {
// sample = 2026-04-18T14:30:00Z (see above).
it("renders Pacific/Auckland (UTC+12, NZST after the April DST end)", () => {
const fmt = makeFormatters("en", "Pacific/Auckland", "H24");
expect(fmt.time(sample)).toBe("02:30"); // next local day
expect(fmt.date(sample)).toBe("04/19/2026");
});
it("renders America/New_York (west of Berlin, EDT)", () => {
const fmt = makeFormatters("en", "America/New_York", "H24");
expect(fmt.time(sample)).toBe("10:30");
expect(fmt.date(sample)).toBe("04/18/2026");
});
it("renders Asia/Manila (UTC+8, no DST)", () => {
const fmt = makeFormatters("en", "Asia/Manila", "H24");
expect(fmt.time(sample)).toBe("22:30");
expect(fmt.date(sample)).toBe("04/18/2026");
});
it.each([["Mars/Olympus"], ["garbage"], [""], [undefined]])(
"poison zone %s never throws and falls back to Berlin",
(zone) => {
const fmt = makeFormatters("en", zone as string | undefined, "H24");
expect(fmt.time(sample)).toBe("16:30");
expect(fmt.date(sample)).toBe("04/18/2026");
expect(fmt.dateTime(sample)).toBe("04/18/2026, 16:30");
expect(fmt.dateWithWeekday(sample)).toContain("18");
expect(fmt.monthShort(sample)).toBe("Apr");
},
);
// The 23:30-Manila boundary pin: the rendered day label must equal the
// server's profile-tz day key for the same instant — the exact split
// #490 reported (Manila-grouped data under Berlin labels).
it("renders the same calendar day the server day-keys (Asia/Manila)", () => {
// YMD preference → the date renders as the ISO day key itself.
const manila = makeFormatters("en", "Asia/Manila", "H24", "YMD");
// 15:30 UTC = 23:30 Manila, still Jul 14 in Manila.
const lateManilaEvening = new Date("2026-07-14T15:30:00Z");
expect(manila.date(lateManilaEvening)).toBe("2026-07-14");
// 17:30 UTC = 01:30 Jul 15 Manila — Berlin (19:30 Jul 14) would
// label the previous day; the profile zone must win.
const pastManilaMidnight = new Date("2026-07-14T17:30:00Z");
expect(manila.date(pastManilaMidnight)).toBe("2026-07-15");
expect(manila.time(pastManilaMidnight)).toBe("01:30");
});
// Berlin DST pins — the zone maths must follow the IANA rules at the
// instant, never a cached offset.
it("renders across the Berlin 2026-03-29 spring-forward", () => {
const fmt = makeFormatters("de", "Europe/Berlin", "H24");
// 00:30 UTC = 01:30 CET (before the 02:00→03:00 jump).
expect(fmt.time(new Date("2026-03-29T00:30:00Z"))).toBe("01:30");
// 01:30 UTC = 03:30 CEST (the 02:xx hour does not exist).
expect(fmt.time(new Date("2026-03-29T01:30:00Z"))).toBe("03:30");
expect(fmt.date(new Date("2026-03-29T01:30:00Z"))).toBe("29.03.2026");
});
it("renders across the Berlin 2026-10-25 fall-back (doubled hour)", () => {
const fmt = makeFormatters("de", "Europe/Berlin", "H24");
// 00:30 UTC = 02:30 CEST (first pass through the doubled hour).
expect(fmt.time(new Date("2026-10-25T00:30:00Z"))).toBe("02:30");
// 01:30 UTC = 02:30 CET (second pass).
expect(fmt.time(new Date("2026-10-25T01:30:00Z"))).toBe("02:30");
expect(fmt.date(new Date("2026-10-25T01:30:00Z"))).toBe("25.10.2026");
});
});
});
// v1.25.3 — the time-format preference must reach every hour/minute renderer,
+125
View File
@@ -0,0 +1,125 @@
/**
* Issue #490 — localStorage mirror of the per-user display timezone.
*
* The suite runs in the node environment (no jsdom), so a minimal
* `window` stub provides localStorage + the event target the mirror
* wires. The stub is deliberately tiny: the mirror only calls
* `getItem` / `setItem` / `removeItem` and `add/removeEventListener` /
* `dispatchEvent`.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import {
readStoredTimezone,
storeTimezone,
subscribeTimezone,
} from "../timezone-mirror";
import { makeFormatters } from "../format-locale";
const STORAGE_KEY = "healthlog-timezone";
function stubWindow() {
const store = new Map<string, string>();
const listeners = new Map<string, Set<() => void>>();
const win = {
localStorage: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => {
store.set(k, v);
},
removeItem: (k: string) => {
store.delete(k);
},
},
addEventListener: (type: string, fn: () => void) => {
const set = listeners.get(type) ?? new Set<() => void>();
set.add(fn);
listeners.set(type, set);
},
removeEventListener: (type: string, fn: () => void) => {
listeners.get(type)?.delete(fn);
},
dispatchEvent: (ev: Event) => {
for (const fn of listeners.get(ev.type) ?? []) fn();
return true;
},
};
vi.stubGlobal("window", win);
return { store };
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("timezone mirror (issue #490)", () => {
it("reads '' when no mirror exists", () => {
stubWindow();
expect(readStoredTimezone()).toBe("");
});
it("stores a valid IANA zone and reads it back", () => {
const { store } = stubWindow();
storeTimezone("Asia/Manila");
expect(store.get(STORAGE_KEY)).toBe("Asia/Manila");
expect(readStoredTimezone()).toBe("Asia/Manila");
});
it("notifies same-tab subscribers on change, not on a no-op write", () => {
stubWindow();
const onChange = vi.fn();
const unsubscribe = subscribeTimezone(onChange);
storeTimezone("Pacific/Auckland");
expect(onChange).toHaveBeenCalledTimes(1);
// Same value again → no event (mirrors the time-format contract).
storeTimezone("Pacific/Auckland");
expect(onChange).toHaveBeenCalledTimes(1);
unsubscribe();
storeTimezone("America/New_York");
expect(onChange).toHaveBeenCalledTimes(1);
});
it("clears the mirror instead of storing a garbage zone", () => {
const { store } = stubWindow();
storeTimezone("Europe/Berlin");
expect(store.get(STORAGE_KEY)).toBe("Europe/Berlin");
storeTimezone("Mars/Olympus");
expect(store.has(STORAGE_KEY)).toBe(false);
expect(readStoredTimezone()).toBe("");
});
it("clears the mirror on an empty value", () => {
const { store } = stubWindow();
storeTimezone("Europe/Berlin");
storeTimezone("");
expect(store.has(STORAGE_KEY)).toBe(false);
});
it("treats a stale invalid mirror (pre-validation build) as absent", () => {
const { store } = stubWindow();
store.set(STORAGE_KEY, "not-a-zone");
expect(readStoredTimezone()).toBe("");
// …and the value must never reach Intl: the formatter chain renders
// the Berlin fallback instead of throwing.
const fmt = makeFormatters("en", readStoredTimezone(), "H24");
expect(fmt.time(new Date("2026-04-18T14:30:00Z"))).toBe("16:30");
});
it("stale-mirror tolerance: an old but valid zone keeps rendering", () => {
const { store } = stubWindow();
// The profile moved to Auckland on another device; this tab still
// mirrors Manila until the next `/api/auth/me` fetch. That must
// render (in the stale zone), never throw.
store.set(STORAGE_KEY, "Asia/Manila");
const fmt = makeFormatters("en", readStoredTimezone(), "H24");
expect(fmt.time(new Date("2026-04-18T14:30:00Z"))).toBe("22:30");
});
it("no-ops on SSR (window undefined)", () => {
// Node environment without the stub — `window` is undefined.
expect(readStoredTimezone()).toBe("");
expect(() => storeTimezone("Europe/Berlin")).not.toThrow();
const unsubscribe = subscribeTimezone(() => {});
expect(() => unsubscribe()).not.toThrow();
});
});
@@ -0,0 +1,87 @@
/**
* Issue #490 — chart bucket labels must not shift for profiles west of
* Berlin.
*
* `bucketTimeSeries` encodes each week/month bucket as `Date.UTC(...)` of
* the BERLIN calendar day the bucket starts on; day rows encode noon-UTC
* of a profile-tz day key. Both are calendar-day encodings, not instants,
* so labels render through the UTC-pinned `makeBucketLabelFormatters` —
* byte-identical for a Berlin profile, and immune to the west-of-Berlin
* month/day slide a profile-tz formatter would introduce. The bucket MATH
* itself is pinned by `bucket-time-series.test.ts` and stays untouched.
*/
import { describe, expect, it } from "vitest";
import { bucketTimeSeries } from "../bucket-time-series";
import { makeBucketLabelFormatters } from "../bucket-label";
import { makeFormatters } from "@/lib/format-locale";
describe("makeBucketLabelFormatters (#490)", () => {
// Two July instants (Berlin summer) forced into a month bucket.
const monthBucket = bucketTimeSeries(
[
{ timestamp: new Date("2026-07-05T10:00:00Z"), values: { WEIGHT: 80 } },
{ timestamp: new Date("2026-07-20T10:00:00Z"), values: { WEIGHT: 82 } },
],
{ bucket: "month" },
);
it("labels a Berlin July month bucket as July for every profile", () => {
const ts = monthBucket.points[0].timestamp;
// The bucket encodes the Berlin month start as UTC midnight.
expect(ts).toBe(Date.UTC(2026, 6, 1));
const label = makeBucketLabelFormatters("en");
expect(label.monthShort(new Date(ts))).toBe("Jul");
expect(label.date(new Date(ts))).toBe("07/01/2026");
});
it("documents the trap: a profile-tz formatter slides the label west of Berlin", () => {
const ts = monthBucket.points[0].timestamp;
// Jul 1 00:00 UTC = Jun 30 20:00 in New York — the exact month-label
// slide the UTC pin exists to prevent. If this expectation ever
// changes, the label pin above is what protects users.
const newYork = makeFormatters("en", "America/New_York");
expect(newYork.monthShort(new Date(ts))).toBe("Jun");
});
it("stays byte-identical to the legacy Berlin rendering", () => {
const berlin = makeFormatters("en", "Europe/Berlin");
const label = makeBucketLabelFormatters("en");
// Month bucket start (UTC midnight of a Berlin day)…
const bucketTs = new Date(monthBucket.points[0].timestamp);
expect(label.date(bucketTs)).toBe(berlin.date(bucketTs));
expect(label.monthShort(bucketTs)).toBe(berlin.monthShort(bucketTs));
// …and a chart day row (noon-UTC of a day key, `dayKeyToTimestamp`).
const dayTs = new Date(Date.UTC(2026, 6, 14, 12));
expect(label.date(dayTs)).toBe(berlin.date(dayTs));
expect(label.dateShort(dayTs)).toBe(berlin.dateShort(dayTs));
});
it("labels an ISO-week bucket with its Monday for every profile", () => {
const weekBucket = bucketTimeSeries(
[
{
timestamp: new Date("2026-07-15T10:00:00Z"), // Wed, Berlin week of Mon Jul 13
values: { WEIGHT: 80 },
},
],
{ bucket: "week" },
);
const ts = weekBucket.points[0].timestamp;
expect(ts).toBe(Date.UTC(2026, 6, 13));
const label = makeBucketLabelFormatters("en");
expect(label.dateWithWeekday(new Date(ts))).toContain("Mon");
// A New-York-tz render would name it "Sun" — the week-label slide.
const newYork = makeFormatters("en", "America/New_York");
expect(newYork.dateWithWeekday(new Date(ts))).toContain("Sun");
});
it("labels a noon-UTC day key correctly even for a UTC+13 profile", () => {
// Day rows encode noon UTC; an Auckland (UTC+13 in January) profile
// formatter would render the NEXT day. The UTC pin renders the key.
const dayTs = new Date(Date.UTC(2026, 0, 14, 12));
expect(makeBucketLabelFormatters("en").date(dayTs)).toBe("01/14/2026");
const auckland = makeFormatters("en", "Pacific/Auckland");
expect(auckland.date(dayTs)).toBe("01/15/2026");
});
});
+31
View File
@@ -0,0 +1,31 @@
/**
* Issue #490 — axis/tooltip label formatters for CALENDAR-DAY-ENCODED chart
* timestamps.
*
* Every timestamp the health/mood chart data pipeline hands to its label
* formatters encodes a calendar day, not an instant:
*
* - day-granularity rows carry `Date.UTC(y, m-1, d, 12)` (noon UTC) of a
* profile-timezone day key (`dayKeyToTimestamp` in both charts);
* - week/month buckets from `bucketTimeSeries` carry
* `Date.UTC(y, m-1, d)` (UTC midnight) of the Berlin calendar day the
* bucket starts on (ISO-week Monday / month 1st).
*
* Formatting those encodings through a PROFILE-timezone formatter re-reads
* the encoded day in a different zone: a Berlin `Jul 1` month bucket
* renders as "Jun 30" for every profile west of Berlin (month/weekday axis
* labels slide), and a noon-UTC day key slips a day for profiles at
* UTC+13. The label is fully determined by the encoded calendar day, so it
* renders UTC-pinned — byte-identical for Berlin profiles (UTC midnight /
* noon stay on the same Berlin day) and correct for every other zone.
*
* The bucket MATH in `bucket-time-series.ts` is deliberately untouched
* (DSTMIG-scarred); this is the label-side half of the contract.
*/
import { makeFormatters, type Formatters } from "@/lib/format-locale";
import type { Locale } from "@/lib/i18n/config";
export function makeBucketLabelFormatters(locale: Locale): Formatters {
return makeFormatters(locale, "UTC");
}
+28
View File
@@ -139,6 +139,34 @@ export function formatDate(
});
}
/**
* Format a plain calendar date (ISO `yyyy-MM-dd` day key or a Date) as
* short weekday + day + month — the `fmt.dateWithWeekday` field set
* ("Di., 14.07." / "Tue, 07/14") — pinned to UTC like {@link formatDate}.
*
* Issue #490: for a value that is a DAY KEY (e.g. the dose-history
* ledger's profile-timezone day groups), parsing it as a local-midnight
* instant and re-formatting through a timezone-aware formatter can shift
* the rendered day when browser and profile zones differ. The calendar
* date (and therefore its weekday) is already fully determined by the
* key, so it renders UTC-pinned — correct in every zone, no DST edge.
*/
export function formatDateWithWeekday(
value: Date | string | null | undefined,
pref: DateFormatPreference,
locale: Locale,
): string {
if (value === null || value === undefined || value === "") return "";
const date = value instanceof Date ? value : parseIsoDate(value);
if (date === null || Number.isNaN(date.getTime())) return "";
return date.toLocaleDateString(resolveDateLocale(pref, locale), {
timeZone: "UTC",
weekday: "short",
day: "2-digit",
month: "2-digit",
});
}
/**
* Parse a `yyyy-MM-dd` string to a UTC `Date` at midnight. Returns null on
* a value that is not exactly the ISO calendar-date shape so callers can
+7 -1
View File
@@ -15,6 +15,7 @@
*/
import type { Locale } from "./i18n/config";
import { isValidTimezone } from "./tz/format";
/**
* Fallback timezone for surfaces that are not yet user-scoped (admin
@@ -146,7 +147,12 @@ export function makeFormatters(
dateFormat: DateFormatPreference = "AUTO",
): Formatters {
const intlLocale = resolveIntlLocale(locale);
const tz = userTz && userTz.length > 0 ? userTz : DISPLAY_TIMEZONE;
// Poison guard: an invalid IANA name would make `Intl.DateTimeFormat`
// throw `RangeError` inside every date/time call — i.e. white-screen
// every page that renders a timestamp. Validate here (cheap — the
// probe memoises successful constructions) and fall back to Berlin,
// matching `formatInUserTz` / `hourInTz` / `isNearUtc`.
const tz = userTz && isValidTimezone(userTz) ? userTz : DISPLAY_TIMEZONE;
const hourOpts = hourCycleOptions(timeFormat);
// Field-order locale for the numeric date renderers. AUTO keeps the
// user's own locale; DMY/MDY/YMD pin a canonical locale whose default
+10 -4
View File
@@ -25,6 +25,7 @@
import { makeFormatters } from "./format-locale";
import { readStoredTimeFormat } from "./time-format";
import { readStoredTimezone } from "./timezone-mirror";
import { locales, type Locale } from "./i18n/config";
function isLocale(value: string | null | undefined): value is Locale {
@@ -46,10 +47,15 @@ function activeLocale(): Locale {
}
function formatters() {
// Honour the mirrored hour-cycle preference so these legacy helpers render
// the same clock as `useFormatters()` call sites. SSR reads AUTO — same
// post-hydration caveat as `activeLocale()` above.
return makeFormatters(activeLocale(), undefined, readStoredTimeFormat());
// Honour the mirrored hour-cycle preference AND the mirrored profile
// timezone (issue #490) so these legacy helpers render the same clock and
// zone as `useFormatters()` call sites. SSR reads AUTO + "" (→ Berlin) —
// same post-hydration caveat as `activeLocale()` above.
return makeFormatters(
activeLocale(),
readStoredTimezone(),
readStoredTimeFormat(),
);
}
/** Locale-aware "19.02.2026, 14:30" or "02/19/2026, 2:30 PM". */
@@ -2,9 +2,12 @@
* v1.22 (W6) — `formatUpdatedLabel`: time only for today, no time for
* yesterday, locale date for older (honouring the passed formatter).
*/
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
import {
formatUpdatedLabel,
relativeCalendarDate,
} from "@/lib/i18n/relative-time";
import { makeFormatters } from "@/lib/format-locale";
const t = (key: string, params?: Record<string, string | number>): string => {
@@ -90,3 +93,81 @@ describe("formatUpdatedLabel", () => {
expect(out).toMatch(/PM/);
});
});
// ── Issue #490 — half-fix closure ─────────────────────────────────────────
//
// Pre-fix, `timeZone: undefined` flowed into `Intl.DateTimeFormat`, where it
// means the HOST/browser zone — so the today/yesterday day boundary followed
// the device while the clock (`fmt.time`) followed the profile. Boundary and
// clock must resolve the SAME zone in every state: valid zone → itself;
// undefined ("mirror empty") / garbage ("poison") → Europe/Berlin, never the
// host zone. The suite runs under `TZ=UTC`, so a Berlin-vs-host split is
// observable around Berlin midnight.
describe("formatUpdatedLabel boundary-zone closure (#490)", () => {
// now = 22:30 UTC Jul 14 = 00:30 Jul 15 in Berlin: host says Jul 14,
// Berlin says Jul 15 — the discriminating window.
const NOW = new Date("2026-07-14T22:30:00Z");
// target = 21:00 UTC Jul 14 = 23:00 Jul 14 Berlin: "today" for the host,
// "yesterday" for Berlin.
const TARGET = "2026-07-14T21:00:00.000Z";
afterEach(() => {
vi.useRealTimers();
});
it("mirror empty (timeZone undefined) → Berlin boundary, not the host's", () => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
const fmt = makeFormatters("en", undefined, "H24"); // clock: Berlin fallback
const out = formatUpdatedLabel(TARGET, t, fmt.dateShort, fmt.time);
expect(out).toBe("Updated yesterday");
});
it("poison zone → Berlin boundary AND Berlin clock, no throw", () => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
const fmt = makeFormatters("en", "Mars/Olympus", "H24"); // clock: Berlin
const out = formatUpdatedLabel(
TARGET,
t,
fmt.dateShort,
fmt.time,
"Mars/Olympus",
);
expect(out).toBe("Updated yesterday");
});
it("mirror set → boundary and clock both in the profile zone", () => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
// Manila: now = 06:30 Jul 15, target = 05:00 Jul 15 → "today, 05:00".
const fmt = makeFormatters("en", "Asia/Manila", "H24");
const out = formatUpdatedLabel(
TARGET,
t,
fmt.dateShort,
fmt.time,
"Asia/Manila",
);
expect(out).toBe("Updated today, 05:00");
});
it("relativeCalendarDate resolves undefined/garbage zones to Berlin too", () => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
const tRel = (key: string): string =>
key === "medications.today"
? "Today"
: key === "medications.yesterday"
? "Yesterday"
: key;
const fmtDateAbs = (d: Date) => d.toISOString();
expect(relativeCalendarDate(TARGET, tRel, fmtDateAbs)).toBe("Yesterday");
expect(relativeCalendarDate(TARGET, tRel, fmtDateAbs, "garbage")).toBe(
"Yesterday",
);
expect(relativeCalendarDate(TARGET, tRel, fmtDateAbs, "Asia/Manila")).toBe(
"Today",
);
});
});
+33 -3
View File
@@ -13,6 +13,7 @@ import {
} from "react";
import { locales, defaultLocale, type Locale } from "./config";
import {
DISPLAY_TIMEZONE,
makeFormatters,
type Formatters,
type TimeFormatPreference,
@@ -20,6 +21,7 @@ import {
} from "../format-locale";
import { readStoredTimeFormat, subscribeTimeFormat } from "../time-format";
import { readStoredDateFormat, subscribeDateFormat } from "../date-format";
import { readStoredTimezone, subscribeTimezone } from "../timezone-mirror";
import { resolveKey } from "./resolve-key";
import {
getCachedMessages,
@@ -324,19 +326,47 @@ export function useDateFormatPreference(): DateFormatPreference {
);
}
/**
* The user's mirrored profile timezone, resolved to a renderable IANA zone:
* valid mirror value → `DISPLAY_TIMEZONE` (Europe/Berlin). Reactive to
* mirror changes (profile save, cross-tab, `/api/auth/me` re-fetch) via the
* same localStorage-mirror pattern as the hour-cycle preference. SSR and the
* pre-fetch state resolve Berlin — deliberately NO browser-timezone rung
* (issue #490): the chain must match the server's `resolveUserTimezone`
* fallback so on-screen times agree with the PDF / exports.
*
* Use this wherever a day-boundary or weekday must be derived in the SAME
* zone `useFormatters()` renders clocks in (e.g. `formatUpdatedLabel`).
*/
export function useDisplayTimezone(): string {
const stored = useSyncExternalStore(
subscribeTimezone,
readStoredTimezone,
() => "",
);
return stored !== "" ? stored : DISPLAY_TIMEZONE;
}
/**
* Locale-aware formatters tied to the active UI locale. Use for every number,
* date, and time rendered in the UI so regional conventions (70,5 vs 70.5,
* 19.02.2026 vs Feb 19, 2026) follow the user's language choice. Times honour
* the per-user hour-cycle preference (AUTO follows the locale, H12 / H24 pin
* the cycle).
* the cycle) and render in the user's profile timezone (issue #490 — the
* localStorage mirror `fetchMe` keeps in sync; "" falls back to Berlin
* inside `makeFormatters`).
*/
export function useFormatters(): Formatters {
const { locale } = useTranslations();
const timeFormat = useTimeFormatPreference();
const dateFormat = useDateFormatPreference();
const timezone = useSyncExternalStore(
subscribeTimezone,
readStoredTimezone,
() => "",
);
return useMemo(
() => makeFormatters(locale, undefined, timeFormat, dateFormat),
[locale, timeFormat, dateFormat],
() => makeFormatters(locale, timezone, timeFormat, dateFormat),
[locale, timezone, timeFormat, dateFormat],
);
}
+18 -2
View File
@@ -13,6 +13,22 @@
* dashboard.staleHintWeeksOne / Other pattern already in the bundle.
* Eliminates the "vor 1 Minuten" grammar bug a German user spotted.
*/
import { DEFAULT_TIMEZONE, isValidTimezone } from "@/lib/tz/format";
/**
* Resolve the day-boundary zone for the calendar-bucketed labels below.
* Issue #490 half-fix closure: `timeZone` used to flow straight into
* `Intl.DateTimeFormat`, where `undefined` silently meant the BROWSER
* zone — so pre-`/api/auth/me` the today/yesterday boundary followed the
* device while the rendered clock (`fmt.time`) followed the profile
* mirror. Boundary and clock must resolve the SAME zone in every state:
* valid zone → itself; undefined / "" / garbage → Berlin (the exact
* fallback `makeFormatters` applies to the clock). Never the browser.
*/
function resolveBoundaryZone(timeZone: string | undefined): string {
return timeZone && isValidTimezone(timeZone) ? timeZone : DEFAULT_TIMEZONE;
}
export function formatRelativeTime(
iso: string,
t: (key: string, params?: Record<string, string | number>) => string,
@@ -71,7 +87,7 @@ export function relativeCalendarDate(
const target = new Date(iso);
if (Number.isNaN(target.getTime())) return "";
const dayFormatter = new Intl.DateTimeFormat("en-CA", {
timeZone,
timeZone: resolveBoundaryZone(timeZone),
year: "numeric",
month: "2-digit",
day: "2-digit",
@@ -112,7 +128,7 @@ export function formatUpdatedLabel(
const target = new Date(iso);
if (Number.isNaN(target.getTime())) return "";
const dayFormatter = new Intl.DateTimeFormat("en-CA", {
timeZone,
timeZone: resolveBoundaryZone(timeZone),
year: "numeric",
month: "2-digit",
day: "2-digit",
+84
View File
@@ -0,0 +1,84 @@
/**
* Client-side mirror of the per-user display timezone.
*
* The server-authoritative value lives on the user row (`users.timezone`,
* surfaced by `/api/auth/me` and editable via `PUT /api/auth/me/timezone`).
* The mirror follows the `time-format.ts` / `date-format.ts` pattern exactly:
* `fetchMe` writes the resolved value into localStorage so that
*
* 1. `useFormatters()` can read it through `useSyncExternalStore` without
* requiring a QueryClient in the tree, and
* 2. the legacy helpers in `src/lib/format.ts` (plain functions, no React
* context) render the same zone as hook-based call sites.
*
* Fallback chain: valid mirrored profile zone → `DISPLAY_TIMEZONE`
* (Europe/Berlin) inside `makeFormatters`. Deliberately NO browser-timezone
* rung — every server-rendered artifact (doctor-report PDF, exports,
* briefing) resolves profile → Berlin, and a browser rung would let the same
* timestamp render differently on screen vs in the PDF for exactly the
* accounts whose profile zone is stale.
*
* Validity is guarded on BOTH sides: `storeTimezone` refuses to persist a
* non-IANA value (clears the mirror instead, so a poison `/me` payload can
* never wedge a broken zone), and `readStoredTimezone` re-validates so a
* stale mirror written by a pre-validation build reads as absent rather
* than reaching `Intl.DateTimeFormat` (which would throw `RangeError`).
*
* SSR always resolves "" (`window` is undefined) → Berlin fallback; the same
* caveat as the sibling mirrors applies — call sites render their formatted
* strings post-fetch, so there is no hydration-mismatch path today.
*/
import { isValidTimezone } from "./tz/format";
const STORAGE_KEY = "healthlog-timezone";
const CHANGE_EVENT = "healthlog:timezone-change";
/**
* Best-effort read of the mirrored zone. "" on SSR / no mirror / invalid
* stored value — callers treat "" as "fall back to `DISPLAY_TIMEZONE`"
* (which `makeFormatters` does on its own for an empty `userTz`).
*/
export function readStoredTimezone(): string {
if (typeof window === "undefined") return "";
try {
const stored = window.localStorage?.getItem(STORAGE_KEY);
return stored && isValidTimezone(stored) ? stored : "";
} catch {
return "";
}
}
/**
* Persist the mirror and notify same-tab subscribers. Cross-tab updates ride
* the browser's native `storage` event. An invalid or empty value CLEARS the
* mirror (fail toward the deterministic Berlin fallback) rather than storing
* a zone `Intl` would throw on.
*/
export function storeTimezone(value: string): void {
if (typeof window === "undefined") return;
const next = value && isValidTimezone(value) ? value : null;
try {
const previous = window.localStorage?.getItem(STORAGE_KEY) ?? null;
if (previous === next) return;
if (next === null) {
window.localStorage?.removeItem(STORAGE_KEY);
} else {
window.localStorage?.setItem(STORAGE_KEY, next);
}
} catch {
return;
}
window.dispatchEvent(new Event(CHANGE_EVENT));
}
/** `useSyncExternalStore`-shaped subscription for the mirror. */
export function subscribeTimezone(onChange: () => void): () => void {
if (typeof window === "undefined") return () => {};
window.addEventListener(CHANGE_EVENT, onChange);
window.addEventListener("storage", onChange);
return () => {
window.removeEventListener(CHANGE_EVENT, onChange);
window.removeEventListener("storage", onChange);
};
}
@@ -147,6 +147,25 @@ describe("per-user timezone — Pacific/Auckland end-to-end", () => {
expect(await resolveUserTimezone(me.id)).toBe("Asia/Tokyo");
});
// Issue #490 — the client display fix rides a localStorage mirror that
// `fetchMe` fills from `/api/auth/me`. Pin that the route actually
// carries the profile zone (the value the mirror consumes), so a field
// rename / select-list slip can't silently collapse every client render
// back to the Berlin fallback.
it("GET /api/auth/me carries the profile timezone the client mirror consumes", async () => {
await seedAucklandUser();
const { GET } = await import("@/app/api/auth/me/route");
// The handler resolves the session from the mocked cookie jar; it
// takes no request argument.
const res = await GET();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { timezone?: string } | null;
};
expect(body.data?.timezone).toBe("Pacific/Auckland");
});
it("PUT /api/auth/me/timezone rejects an invalid IANA zone with 422", async () => {
await seedAucklandUser();