feat(profile): timezone picker on account settings page

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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 12:01:58 +02:00
1 parent 75cf0b60dc
commit 65ce9fd4c8
6 files changed
+359 -11

No files matched your search

+41 -11
View File
@@ -43,6 +43,7 @@ import { formatDate } from "@/lib/format";
import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
import { useTranslations } from "@/lib/i18n/context";
import { describePasskeyError } from "@/lib/passkey-errors";
import { TimezonePicker } from "@/components/settings/timezone-picker";
interface PasskeyInfo {
id: string;
@@ -71,6 +72,7 @@ export function AccountSection() {
const [heightCm, setHeightCm] = useState("");
const [dateOfBirth, setDateOfBirth] = useState("");
const [gender, setGender] = useState<string>("");
const [timezone, setTimezone] = useState<string>("Europe/Berlin");
const [saving, setSaving] = useState(false);
const [saveMsg, setSaveMsg] = useState<string | null>(null);
const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>(
@@ -132,6 +134,7 @@ export function AccountSection() {
: "",
);
setGender(user.gender ?? "");
setTimezone(user.timezone || "Europe/Berlin");
}
async function handleSaveProfile(e: React.FormEvent) {
@@ -140,23 +143,48 @@ export function AccountSection() {
setSaveMsg(null);
setSaveMsgType(null);
const res = await fetch("/api/auth/profile", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: email.trim() || null,
heightCm: heightCm ? parseFloat(heightCm) : null,
dateOfBirth: dateOfBirth || null,
gender: gender || null,
// The timezone is owned by a dedicated route (v1.4.25 W7) so the
// resolver cache can be invalidated without piping the flag
// through the bigger profile patch path. Run the two PUTs in
// parallel — they're independent.
const [profileRes, tzRes] = await Promise.all([
fetch("/api/auth/profile", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: email.trim() || null,
heightCm: heightCm ? parseFloat(heightCm) : null,
dateOfBirth: dateOfBirth || null,
gender: gender || null,
}),
}),
});
user && timezone && timezone !== user.timezone
? fetch("/api/auth/me/timezone", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ timezone }),
})
: Promise.resolve({ ok: true } as Response),
]);
if (res.ok) {
if (profileRes.ok && tzRes.ok) {
setSaveMsg(t("settings.profileSaved"));
setSaveMsgType("success");
await refetch();
} else if (!tzRes.ok) {
// The dedicated tz endpoint owns the IANA validation error
// text. Surface its message verbatim so the user sees
// "Not a valid IANA timezone." instead of the generic save
// failure copy.
try {
const json = (await (tzRes as Response).json()) as { error?: string };
setSaveMsg(json.error || t("settings.timezoneInvalid"));
} catch {
setSaveMsg(t("settings.timezoneInvalid"));
}
setSaveMsgType("error");
} else {
const json = await res.json();
const json = await profileRes.json();
setSaveMsg(json.error || t("settings.savingError"));
setSaveMsgType("error");
}
@@ -396,6 +424,8 @@ export function AccountSection() {
</div>
</div>
<TimezonePicker value={timezone} onChange={setTimezone} />
<div className="space-y-2">
<Label htmlFor="language-select">{t("settings.language")}</Label>
<select
+123
View File
@@ -0,0 +1,123 @@
"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>
);
}