mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
v1.4.25 W7 phase 4 — proposal §5.
Adds a per-user timezone picker to /settings#account between
date-of-birth and language. The control is a native <select> over
Intl.supportedValuesOf('timeZone') with a 'use browser timezone'
button that pre-fills detectBrowserTimezone(). Older engines without
Intl.supportedValuesOf fall back to a free-text input — every Chrome
99+/Safari 15.4+/Firefox 99+ ships the API so the fallback is
defensive only.
Form save uses Promise.all over two PUTs:
- /api/auth/profile (email, height, dob, gender)
- /api/auth/me/timezone (single field, dedicated route)
The split keeps the timezone validation + resolver cache
invalidation on a clean path; profile errors don't poison the
timezone write and vice versa.
The new route PUT /api/auth/me/timezone:
- requireAuth gates the cookie + Bearer paths
- 422 on any string the runtime's Intl.DateTimeFormat rejects
- 200 on success, returns the persisted value
- audits 'user.timezone.update' with previous + next
- invalidates the resolver cache so the next read sees the new
value within one tick instead of waiting 60s for the TTL
i18n: 4 new keys (timezone, timezoneHint, timezoneDetect,
timezoneInvalid) in messages/de.json + messages/en.json. Hint copy
follows proposal §5: 'Used for chart axis labels, reminder times,
export timestamps, and AI Coach context. Stored data is unchanged.'
Tests: 6-case suite on the new route covers auth, valid + invalid
zones, empty / missing field, and whitespace trimming.
124 lines
4.0 KiB
TypeScript
124 lines
4.0 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* Per-user timezone picker (v1.4.25 W7).
|
|
*
|
|
* Renders a native `<select>` over `Intl.supportedValuesOf('timeZone')`
|
|
* plus a "use my browser zone" button. 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.
|
|
*
|
|
* 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, useState } from "react";
|
|
import { Compass } from "lucide-react";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
detectBrowserTimezone,
|
|
listSupportedTimezones,
|
|
} from "@/lib/tz/resolver";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
|
|
const NATIVE_SELECT_CLASS =
|
|
"border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none";
|
|
|
|
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 [detected] = useState(() => detectBrowserTimezone());
|
|
|
|
const handleUseBrowserTz = () => {
|
|
if (detected) onChange(detected);
|
|
};
|
|
|
|
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="Europe/Berlin"
|
|
autoComplete="off"
|
|
/>
|
|
<p className="text-muted-foreground text-xs">{labelHint}</p>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleUseBrowserTz}
|
|
>
|
|
<Compass className="mr-2 h-4 w-4" />
|
|
{t("settings.timezoneDetect", { tz: detected })}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<Label htmlFor={id}>{t("settings.timezone")}</Label>
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
|
<select
|
|
id={id}
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
className={`${NATIVE_SELECT_CLASS} sm:max-w-sm`}
|
|
>
|
|
{/* 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>
|
|
))}
|
|
</select>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleUseBrowserTz}
|
|
className="sm:w-auto"
|
|
>
|
|
<Compass className="mr-2 h-4 w-4" />
|
|
{t("settings.timezoneDetect", { tz: detected })}
|
|
</Button>
|
|
</div>
|
|
<p className="text-muted-foreground text-xs">{labelHint}</p>
|
|
</div>
|
|
);
|
|
}
|