From 86a9ca2e1e8f79fb3721cfb01935e497f28ce44d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 12:15:09 +0200 Subject: [PATCH] feat(profile): persist and expose the date-format preference Accept dateFormat on the profile PATCH field-by-field, echo it from the profile + /me reads, mirror it into the client auth user, and add the field to the OpenAPI request/response schemas. --- src/app/api/auth/me/route.ts | 4 ++++ src/app/api/user/profile/route.ts | 3 +++ .../__tests__/disable-coach-card.test.tsx | 1 + .../__tests__/modules-section.test.tsx | 1 + .../__tests__/time-format-select.test.tsx | 1 + .../__tests__/unit-preference-select.test.tsx | 1 + src/hooks/use-auth.ts | 24 ++++++++++++++++++- src/lib/auth/profile-update.ts | 6 +++++ .../__tests__/coach-user-disable.test.tsx | 1 + src/lib/openapi/routes/profile.ts | 16 +++++++++++++ 10 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 434f29221..4ef9ebe53 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -96,6 +96,10 @@ export const GET = apiHandler(async () => { // Hour-cycle display preference (AUTO follows the locale convention, // H12 / H24 pin the cycle). Clients mirror this into their formatters. timeFormat: user.timeFormat ?? "AUTO", + // Date-order display preference (AUTO follows the locale convention, + // DMY / MDY / YMD pin the field order). Clients mirror this into their + // formatters and the primitive. + dateFormat: user.dateFormat ?? "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 diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts index 095ade7e3..d3c211840 100644 --- a/src/app/api/user/profile/route.ts +++ b/src/app/api/user/profile/route.ts @@ -42,6 +42,7 @@ export const GET = apiHandler(async () => { locale: true, timezone: true, timeFormat: true, + dateFormat: true, moodReminderEnabled: true, fullName: true, insurerName: true, @@ -77,6 +78,7 @@ export const GET = apiHandler(async () => { locale: dbUser?.locale ?? null, timezone: dbUser?.timezone ?? "Europe/Berlin", timeFormat: dbUser?.timeFormat ?? "AUTO", + dateFormat: dbUser?.dateFormat ?? "AUTO", moodReminderEnabled: dbUser?.moodReminderEnabled ?? false, fullName: dbUser?.fullName ?? null, insurerName: dbUser?.insurerName ?? null, @@ -114,6 +116,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => { locale: result.user.locale, timezone: result.user.timezone, timeFormat: result.user.timeFormat, + dateFormat: result.user.dateFormat, moodReminderEnabled: result.user.moodReminderEnabled, fullName: result.user.fullName, insurerName: result.user.insurerName, diff --git a/src/components/settings/__tests__/disable-coach-card.test.tsx b/src/components/settings/__tests__/disable-coach-card.test.tsx index c9f40959e..945933485 100644 --- a/src/components/settings/__tests__/disable-coach-card.test.tsx +++ b/src/components/settings/__tests__/disable-coach-card.test.tsx @@ -68,6 +68,7 @@ function buildUser(disableCoach: boolean): AuthUser { glucoseUnit: null, unitPreference: "metric", timeFormat: "AUTO", + dateFormat: "AUTO", disableCoach, fullName: null, insurerName: null, diff --git a/src/components/settings/__tests__/modules-section.test.tsx b/src/components/settings/__tests__/modules-section.test.tsx index cb97fa27b..dbd2822da 100644 --- a/src/components/settings/__tests__/modules-section.test.tsx +++ b/src/components/settings/__tests__/modules-section.test.tsx @@ -70,6 +70,7 @@ function buildUser(modules: AuthUser["modules"]): AuthUser { glucoseUnit: null, unitPreference: "metric", timeFormat: "AUTO", + dateFormat: "AUTO", disableCoach: false, fullName: null, insurerName: null, diff --git a/src/components/settings/__tests__/time-format-select.test.tsx b/src/components/settings/__tests__/time-format-select.test.tsx index 2bb55ac6b..cea538c04 100644 --- a/src/components/settings/__tests__/time-format-select.test.tsx +++ b/src/components/settings/__tests__/time-format-select.test.tsx @@ -46,6 +46,7 @@ function buildUser(timeFormat: TimeFormatPreference): AuthUser { glucoseUnit: null, unitPreference: "metric", timeFormat, + dateFormat: "AUTO", disableCoach: false, fullName: null, insurerName: null, diff --git a/src/components/settings/__tests__/unit-preference-select.test.tsx b/src/components/settings/__tests__/unit-preference-select.test.tsx index c5e0a3b13..e944c2ddc 100644 --- a/src/components/settings/__tests__/unit-preference-select.test.tsx +++ b/src/components/settings/__tests__/unit-preference-select.test.tsx @@ -49,6 +49,7 @@ function buildUser(unitPreference: "metric" | "imperial"): AuthUser { glucoseUnit: null, unitPreference, timeFormat: "AUTO", + dateFormat: "AUTO", disableCoach: false, fullName: null, insurerName: null, diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index 499e2795f..98f6cc052 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -7,8 +7,12 @@ import { queryKeys } from "@/lib/query-keys"; import { apiGet, apiFetchRaw } from "@/lib/api/api-fetch"; import { retryOnceOnTransientError } from "@/lib/queries/retry-transient"; import { useTranslations } from "@/lib/i18n/context"; -import type { TimeFormatPreference } from "@/lib/format-locale"; +import type { + TimeFormatPreference, + DateFormatPreference, +} from "@/lib/format-locale"; import { isTimeFormatPreference, storeTimeFormat } from "@/lib/time-format"; +import { isDateFormatPreference, storeDateFormat } from "@/lib/date-format"; import type { ModuleKey } from "@/lib/modules/registry"; import type { TourProgress } from "@/lib/onboarding/tour-progress"; import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister"; @@ -75,6 +79,14 @@ export interface AuthUser { * "AUTO" against a stale /me payload. */ timeFormat: TimeFormatPreference; + /** + * Date-order display preference. AUTO follows the locale convention + * (de → dd.MM.yyyy, en → MM/dd/yyyy); DMY / MDY / YMD pin the field order + * regardless of locale. Mirrored into localStorage by `fetchMe` so + * `useFormatters()` and the `` primitive render the same order. + * Coerced to "AUTO" against a stale /me payload. + */ + dateFormat: DateFormatPreference; /** * v1.4.47 W3 — per-user Coach opt-out. When `true`, every Coach * mount point (``, ``, the @@ -157,6 +169,15 @@ async function fetchMe(): Promise { ? data.timeFormat : "AUTO"; storeTimeFormat(timeFormat); + // Date-order preference: coerce against a stale /me payload, then mirror + // into localStorage so `useFormatters()` and the `` primitive + // (which cannot reach the query cache) render the same field order. + const dateFormat: DateFormatPreference = isDateFormatPreference( + data.dateFormat, + ) + ? data.dateFormat + : "AUTO"; + storeDateFormat(dateFormat); return { ...(data as AuthUser), disableCoach: data.disableCoach ?? false, @@ -167,6 +188,7 @@ async function fetchMe(): Promise { // without the field) so the display defaults to metric. unitPreference: data.unitPreference === "imperial" ? "imperial" : "metric", timeFormat, + dateFormat, // v1.15.0 — coerce against a stale /me payload so the cycle nav entry // stays hidden by default when the field is absent. cycleTrackingEnabled: data.cycleTrackingEnabled === true, diff --git a/src/lib/auth/profile-update.ts b/src/lib/auth/profile-update.ts index e38410022..d62c3cb4e 100644 --- a/src/lib/auth/profile-update.ts +++ b/src/lib/auth/profile-update.ts @@ -27,6 +27,9 @@ const extendedProfileSchema = profileSchema.extend({ // Hour-cycle display preference. AUTO follows the locale convention, // H12 forces AM/PM, H24 forces 24-hour. timeFormat: z.enum(["AUTO", "H12", "H24"]).optional(), + // Date-order display preference. AUTO follows the locale convention, + // DMY pins day-month-year, MDY month-day-year, YMD ISO yyyy-MM-dd. + dateFormat: z.enum(["AUTO", "DMY", "MDY", "YMD"]).optional(), }); export interface ApplyProfileResult { @@ -43,6 +46,7 @@ export interface ApplyProfileResult { timezone: string; locale: string | null; timeFormat: "AUTO" | "H12" | "H24"; + dateFormat: "AUTO" | "DMY" | "MDY" | "YMD"; moodReminderEnabled: boolean; // v1.7.0 — patient-identity fields. `insuranceNumber` is returned in // plaintext (decrypted on read by the route) so the client can render @@ -110,6 +114,7 @@ export async function applyProfileUpdate( if (data.locale !== undefined) updates.locale = data.locale; if (data.timezone !== undefined) updates.timezone = data.timezone; if (data.timeFormat !== undefined) updates.timeFormat = data.timeFormat; + if (data.dateFormat !== undefined) updates.dateFormat = data.dateFormat; if (data.moodReminderEnabled !== undefined) { updates.moodReminderEnabled = data.moodReminderEnabled; } @@ -206,6 +211,7 @@ export async function applyProfileUpdate( timezone: updatedUser.timezone, locale: updatedUser.locale, timeFormat: updatedUser.timeFormat ?? "AUTO", + dateFormat: updatedUser.dateFormat ?? "AUTO", moodReminderEnabled: updatedUser.moodReminderEnabled, fullName: updatedUser.fullName, insurerName: updatedUser.insurerName, diff --git a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx index 71b725ea7..2675bc92e 100644 --- a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx @@ -95,6 +95,7 @@ function buildUser(disableCoach: boolean): AuthUser { glucoseUnit: null, unitPreference: "metric", timeFormat: "AUTO", + dateFormat: "AUTO", disableCoach, fullName: null, insurerName: null, diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index fa378dad8..941e53245 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -185,6 +185,12 @@ const profileUpdateRequest = z .describe( "Hour-cycle display preference. AUTO follows the locale convention, H12 forces AM/PM, H24 forces 24-hour.", ), + dateFormat: z + .enum(["AUTO", "DMY", "MDY", "YMD"]) + .optional() + .describe( + "Date-order display preference. AUTO follows the locale convention, DMY pins day-month-year (dd.MM.yyyy), MDY pins month-day-year (MM/dd/yyyy), YMD pins ISO yyyy-MM-dd.", + ), moodReminderEnabled: z.boolean().optional(), fullName: z.string().max(120).nullable().optional(), insurerName: z.string().max(120).nullable().optional(), @@ -222,6 +228,11 @@ const profileResponse = z .describe( "Hour-cycle display preference. AUTO follows the locale convention, H12 forces AM/PM, H24 forces 24-hour.", ), + dateFormat: z + .enum(["AUTO", "DMY", "MDY", "YMD"]) + .describe( + "Date-order display preference. AUTO follows the locale convention, DMY pins day-month-year (dd.MM.yyyy), MDY pins month-day-year (MM/dd/yyyy), YMD pins ISO yyyy-MM-dd.", + ), moodReminderEnabled: z.boolean(), fullName: z.string().nullable(), insurerName: z.string().nullable(), @@ -261,6 +272,11 @@ const profileUpdateResponse = z .describe( "Hour-cycle display preference. AUTO follows the locale convention, H12 forces AM/PM, H24 forces 24-hour.", ), + dateFormat: z + .enum(["AUTO", "DMY", "MDY", "YMD"]) + .describe( + "Date-order display preference. AUTO follows the locale convention, DMY pins day-month-year (dd.MM.yyyy), MDY pins month-day-year (MM/dd/yyyy), YMD pins ISO yyyy-MM-dd.", + ), moodReminderEnabled: z.boolean(), fullName: z.string().nullable(), insurerName: z.string().nullable(),