Files
HealthLog/src/components/settings/timezone-picker.tsx
T
Marc-André Bombeck 9d2fd1db14 fix(tz): one default zone, and the user's own calendar where Berlin was hard-coded
The status assessments folded their recent days, weeks, months and years on
Berlin days whatever the user's zone, the mood and cycle crosstabs keyed
their metric days in Berlin, the resting-pulse proxy defaulted to Berlin
days, the briefing's opener rotated at Berlin midnight, and every
assessment prompt labelled today's date as Berlin's. They now use the
user's zone. Rollup months and years are labelled by their UTC start,
which is how they are cut; read in a zone west of UTC the label named the
month before.

"Europe/Berlin" was written out in some eighty places. DEFAULT_TIMEZONE in
lib/tz/format is now the one definition: the second copy in mood/date-key,
DISPLAY_TIMEZONE, the Berlin-only day key helpers and the per-route copies
are gone. An in-repo lint rule rejects the literal outside lib/tz and
tests; four areas another change owns are listed as pending.
2026-09-26 16:23:14 +02:00

102 lines
3.4 KiB
TypeScript

"use client";
/**
* Per-user timezone picker (v1.4.25 W7).
*
* Renders a native `<select>` over `Intl.supportedValuesOf('timeZone')`.
* The control is uncontrolled w.r.t. its parent — the parent passes in
* `value` + `onChange`, but the parent owns the network round-trip so
* this component stays synchronous + presentational. The save POSTs
* `PUT /api/auth/me/timezone` in the parent form's submit handler.
*
* v1.4.37 — the "Browser-Zeitzone übernehmen" button retired. The
* account-section bootstrap effect now seeds the form silently with
* `detectBrowserTimezone()` on first mount when the stored value is
* still the Europe/Berlin default, so the affordance is no longer
* needed and the picker reads cleaner alongside the surrounding form
* rows.
*
* Fallback: on older engines without `Intl.supportedValuesOf` we
* render a free-text input. Every modern browser back to mid-2022
* (Chrome 99 / Safari 15.4 / Firefox 99) ships the API; the fallback
* is defensive only.
*/
import { useMemo } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { listSupportedTimezones, DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { useTranslations } from "@/lib/i18n/context";
// v1.4.27 MB7 / CF-52 — the in-file `NATIVE_SELECT_CLASS` constant
// retired; the shared `<NativeSelect>` primitive owns the visual
// contract now. The select spans the full width of its grid cell so it
// lines up with the fields above it rather than sitting narrower.
export interface TimezonePickerProps {
/** The user's current stored timezone. */
value: string;
/** Called when the user picks (or types) a new zone. */
onChange: (next: string) => void;
/** Render-only id for the label association. */
id?: string;
/** Optional override of the hint copy under the picker. */
hint?: string;
}
export function TimezonePicker({
value,
onChange,
id = "timezone",
hint,
}: TimezonePickerProps) {
const { t } = useTranslations();
// `listSupportedTimezones()` is engine-side; memoise so the list
// isn't reconstructed on every render.
const zones = useMemo(() => listSupportedTimezones(), []);
const labelHint = hint ?? t("settings.timezoneHint");
if (zones.length === 0) {
// Older engine — free-text fallback.
return (
<div className="space-y-2">
<Label htmlFor={id}>{t("settings.timezone")}</Label>
<Input
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={DEFAULT_TIMEZONE}
autoComplete="off"
/>
<p className="text-muted-foreground text-xs">{labelHint}</p>
</div>
);
}
return (
<div className="space-y-2">
<Label htmlFor={id}>{t("settings.timezone")}</Label>
<NativeSelect
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full"
>
{/* Preserve the stored value even if the runtime's IANA
list changed across engines or tzdata rolls. */}
{!zones.includes(value) && value.length > 0 && (
<option value={value}>{value}</option>
)}
{zones.map((zone) => (
<option key={zone} value={zone}>
{zone}
</option>
))}
</NativeSelect>
<p className="text-muted-foreground text-xs">{labelHint}</p>
</div>
);
}