mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #491 from MBombeck/release/v1.28.35
release/v1.28.35 — timestamps honor the profile timezone
This commit is contained in:
41 files changed
+958
-90
No files matched your search
@@ -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,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.
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -1375,7 +1375,6 @@ export default function DashboardPageClient() {
|
||||
onDataReady={() => markChartReady("mood-chart")}
|
||||
compareBaseline={compareBaseline}
|
||||
chartKey="mood"
|
||||
userTimezone={user?.timezone}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in new issue
Block a user