feat(settings): a time-format preference and one home for insight pills

The clock format followed the UI language with no way out — an English
interface forced AM/PM everywhere. A new profile preference (automatic,
24-hour, 12-hour) feeds every formatter through the shared locale
context, persists on the user row and rides the profile endpoints. The
insight pill list in settings gains the same eye toggle the overview
tiles have, so ordering and visibility live in one place; the page-side
'manage detail pages' block folds into a link there. Includes the
locale bundles for both features and the regenerated API contract.
This commit is contained in:
Marc-André Bombeck committed 2026-06-10 13:34:46 +02:00
1 parent 3cf48fa0f1
commit aabc7915cb
28 files changed
+1137 -462

No files matched your search

+3
View File
@@ -67,6 +67,9 @@ export const GET = apiHandler(async () => {
// storage stays SI; this only drives the display-time transform
// branch. Null defaults to "metric" on the client.
unitPreference: user.unitPreference === "imperial" ? "imperial" : "metric",
// Hour-cycle display preference (AUTO follows the locale convention,
// H12 / H24 pin the cycle). Clients mirror this into their formatters.
timeFormat: user.timeFormat ?? "AUTO",
lastReportPracticeName: user.lastReportPracticeName ?? null,
// v1.4.47 W3 — per-user Coach opt-out. Default `false` if the
// column is absent (partial-deploy rollback safety, see migration
@@ -200,4 +200,50 @@ describe("PATCH /api/user/profile", () => {
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
// v1.15.20 — hour-cycle display preference rides the shared profile
// update path: a valid enum value persists field-by-field and echoes
// back; anything outside AUTO/H12/H24 is rejected by the Zod schema.
it("persists a valid timeFormat and echoes it back", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
vi.mocked(prisma.user.update).mockResolvedValue({
id: "user-1",
username: "marc",
displayName: null,
email: null,
role: "USER",
heightCm: null,
dateOfBirth: null,
gender: null,
timezone: "Europe/Berlin",
locale: null,
timeFormat: "H24",
moodReminderEnabled: false,
fullName: null,
insurerName: null,
insurerIkNumber: null,
insuranceNumberEncrypted: null,
} as never);
const res = await PATCH(req({ timeFormat: "H24" }));
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "user-1" },
data: expect.objectContaining({ timeFormat: "H24" }),
}),
);
const body = (await res.json()) as {
data: { timeFormat: string };
};
expect(body.data.timeFormat).toBe("H24");
});
it("returns 422 for an invalid timeFormat", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await PATCH(req({ timeFormat: "12h" }));
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
+3
View File
@@ -36,6 +36,7 @@ export const GET = apiHandler(async () => {
heightCm: true,
locale: true,
timezone: true,
timeFormat: true,
moodReminderEnabled: true,
fullName: true,
insurerName: true,
@@ -64,6 +65,7 @@ export const GET = apiHandler(async () => {
heightCm: dbUser?.heightCm ?? null,
locale: dbUser?.locale ?? null,
timezone: dbUser?.timezone ?? "Europe/Berlin",
timeFormat: dbUser?.timeFormat ?? "AUTO",
moodReminderEnabled: dbUser?.moodReminderEnabled ?? false,
fullName: dbUser?.fullName ?? null,
insurerName: dbUser?.insurerName ?? null,
@@ -96,6 +98,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
heightCm: result.user.heightCm,
locale: result.user.locale,
timezone: result.user.timezone,
timeFormat: result.user.timeFormat,
moodReminderEnabled: result.user.moodReminderEnabled,
fullName: result.user.fullName,
insurerName: result.user.insurerName,