mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
75cf0b60dc
commit
65ce9fd4c8
6 files changed
+359
-11
No files matched your search
@@ -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.",
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user