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

+4
View File
@@ -1320,6 +1320,10 @@
"genderMale": "Männlich",
"genderFemale": "Weiblich",
"genderHint": "Wird für geschlechtsspezifische Zielwerte verwendet.",
"timezone": "Zeitzone",
"timezoneHint": "Wird für Diagrammachsen, Erinnerungszeiten, Export-Zeitstempel und den AI-Coach-Kontext verwendet. Gespeicherte Daten bleiben unverändert.",
"timezoneDetect": "Browser-Zeitzone übernehmen ({tz})",
"timezoneInvalid": "Keine gültige IANA-Zeitzone.",
"profileSaved": "Profil gespeichert",
"changePassword": "Passwort ändern",
"changePasswordDescription": "Ersetze dein aktuelles Passwort durch ein neues.",
+4
View File
@@ -1320,6 +1320,10 @@
"genderMale": "Male",
"genderFemale": "Female",
"genderHint": "Used for gender-specific target values.",
"timezone": "Timezone",
"timezoneHint": "Used for chart axis labels, reminder times, export timestamps, and AI Coach context. Stored data is unchanged.",
"timezoneDetect": "Use browser timezone ({tz})",
"timezoneInvalid": "Not a valid IANA timezone.",
"profileSaved": "Profile saved",
"changePassword": "Change password",
"changePasswordDescription": "Replace your current password with a new one.",
@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
appSettings: { findUnique: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { PUT } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
function mkReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/auth/me/timezone", {
method: "PUT",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("PUT /api/auth/me/timezone", () => {
it("rejects unauthenticated requests with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await PUT(mkReq({ timezone: "Europe/Berlin" }));
expect(res.status).toBe(401);
});
it("accepts a valid IANA zone and writes it to the user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "Europe/Berlin",
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({
timezone: "Pacific/Auckland",
} as never);
const res = await PUT(mkReq({ timezone: "Pacific/Auckland" }));
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { timezone: "Pacific/Auckland" },
});
});
it("rejects an invalid timezone with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await PUT(mkReq({ timezone: "Mars/Olympus" }));
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects an empty timezone with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await PUT(mkReq({ timezone: "" }));
expect(res.status).toBe(422);
});
it("rejects a missing timezone field with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await PUT(mkReq({}));
expect(res.status).toBe(422);
});
it("trims whitespace before validating", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "Europe/Berlin",
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({
timezone: "Asia/Tokyo",
} as never);
const res = await PUT(mkReq({ timezone: " Asia/Tokyo " }));
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { timezone: "Asia/Tokyo" },
});
});
});
+73
View File
@@ -0,0 +1,73 @@
/**
* PUT /api/auth/me/timezone
*
* v1.4.25 W7 — per-user timezone setter. Lifts the timezone write
* out of `PUT /api/auth/profile` so the picker UI can post the
* single field without round-tripping email/heightCm/etc, and so
* the change pipeline (validate → write → invalidate-cache) is
* isolated from the heavier profile patch.
*
* Auth: cookie session OR Bearer token.
* Audit: `user.timezone.update` with the old + new zone.
*
* Validation: the body is `{ "timezone": "Europe/Berlin" }`. We
* accept any string the runtime's `Intl.DateTimeFormat` constructor
* accepts — i.e. every IANA zone the active Node engine knows about.
* Rejecting unknown zones at the API surface stops a malformed
* client from poisoning the column with a value the resolver
* would later have to defend against.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserTimezone, isValidTimezone } from "@/lib/tz/resolver";
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "user.timezone.update" } });
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const tz =
body && typeof (body as { timezone?: unknown }).timezone === "string"
? ((body as { timezone: string }).timezone || "").trim()
: "";
if (!isValidTimezone(tz)) {
return apiError("Not a valid IANA timezone.", 422);
}
const before = await prisma.user.findUnique({
where: { id: user.id },
select: { timezone: true },
});
await prisma.user.update({
where: { id: user.id },
data: { timezone: tz },
});
invalidateUserTimezone(user.id);
await auditLog("user.timezone.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
previous: before?.timezone ?? null,
next: tz,
},
});
annotate({
meta: {
timezone_previous: before?.timezone ?? null,
timezone_next: tz,
},
});
return apiSuccess({ timezone: tz });
});
+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>
);
}