chore(release): v1.28.35 — timestamps honor the profile timezone

Client-rendered times fell back to the hardcoded display zone
(Europe/Berlin) because neither useFormatters() nor the legacy format.ts
helpers ever received users.timezone. A third localStorage mirror
(timezone-mirror.ts, following the time-format/date-format pattern)
carries the validated profile zone from /api/auth/me into both read
paths; the fallback chain is strictly profile → Europe/Berlin — never
the browser zone, so screen, PDF, and export cannot disagree.
makeFormatters validates its zone before Intl (a poison value falls back
instead of throwing inside every render).

Same pass, per the two-document audit: formatUpdatedLabel boundary and
clock now resolve one zone in every state (the undefined-tz browser hole
is closed); @db.Date calendar fields render UTC-pinned so profiles west
of UTC cannot shift a day; dose-history day headings and GLP-1 weekday
labels derive from the display zone; chart day/month labels are
encoding-derived and UTC-pinned (bucket math untouched, byte-identical
for Berlin profiles); the clinician share view renders in the patient's
timezone instead of the container's; Playwright pins Europe/Berlin.
Server day-key semantics, streak anchoring, insight cache keys, and
verbatim wall-clock strings are untouched by construction and pinned by
the new Auckland/New_York/Manila + DST test matrix.
This commit is contained in:
Marc-André Bombeck committed 2026-07-14 20:19:23 +02:00
1 parent 136653e3d7
commit 9bbd8a89dc
41 files changed
+958 -90

No files matched your search

+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();