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

+37 -5
View File
@@ -4615,6 +4615,13 @@ components:
type: string
minLength: 1
maxLength: 64
timeFormat:
description: Hour-cycle display preference. AUTO follows the locale convention, H12 forces AM/PM, H24 forces 24-hour.
type: string
enum:
- AUTO
- H12
- H24
moodReminderEnabled:
type: boolean
fullName:
@@ -10022,6 +10029,13 @@ components:
- type: "null"
timezone:
type: string
timeFormat:
type: string
enum:
- AUTO
- H12
- H24
description: Hour-cycle display preference. AUTO follows the locale convention, H12 forces AM/PM, H24 forces 24-hour.
moodReminderEnabled:
type: boolean
fullName:
@@ -10050,6 +10064,7 @@ components:
- heightCm
- locale
- timezone
- timeFormat
- moodReminderEnabled
- fullName
- insurerName
@@ -10111,6 +10126,13 @@ components:
- type: "null"
timezone:
type: string
timeFormat:
type: string
enum:
- AUTO
- H12
- H24
description: Hour-cycle display preference. AUTO follows the locale convention, H12 forces AM/PM, H24 forces 24-hour.
moodReminderEnabled:
type: boolean
fullName:
@@ -10137,6 +10159,7 @@ components:
- heightCm
- locale
- timezone
- timeFormat
- moodReminderEnabled
- fullName
- insurerName
@@ -10668,10 +10691,16 @@ components:
- ready
- preparing
- disabled
- no-provider
briefingUpdatedAt:
anyOf:
- type: string
- type: "null"
briefingStale:
type: boolean
description: True when `briefing` carries the last good (expired-TTL) briefing while a refresh is pending (`preparing`)
or impossible (`no-provider`). Render the stale content with its `briefingUpdatedAt` timestamp instead of a
blank tile.
generatedAt:
type: string
required:
@@ -10684,15 +10713,18 @@ components:
- briefing
- briefingState
- briefingUpdatedAt
- briefingStale
- generatedAt
additionalProperties: false
description: Unified above-the-fold dashboard payload. `tiles` always arrives (slim summaries + mood + resolved widget
description: "Unified above-the-fold dashboard payload. `tiles` always arrives (slim summaries + mood + resolved widget
layout); `extras` (BD-in-target + per-context glucose) is null on a rollup-coverage miss so the strip never
waits on the slowest read. `briefing` is lifted read-only from the pre-generated insight cache — never generated
synchronously — and reports `ready` / `preparing` / `disabled` via `briefingState`. `layoutCatalogue` (full
27-id widget catalogue) and `metricStates` (latest reading per metric, keyed by iOS `MetricKind` raw value) are
additive cold-launch seeds for the native client; both derive in-process from data already fetched, adding no DB
round-trip.
synchronously — and reports `ready` / `preparing` / `disabled` / `no-provider` via `briefingState`
(`no-provider` = stale-or-missing cache with no AI provider configured anywhere, so no warm pass will fill it;
stop polling and surface a connect-provider hint). A stale-but-parseable briefing is still delivered with
`briefingStale: true`. `layoutCatalogue` (full 27-id widget catalogue) and `metricStates` (latest reading per
metric, keyed by iOS `MetricKind` raw value) are additive cold-launch seeds for the native client; both derive
in-process from data already fetched, adding no DB round-trip."
InsightsComprehensiveResponseEnvelope:
type: object
properties:
+35 -24
View File
@@ -1282,19 +1282,6 @@
"paused": "Pausiert",
"ended": "Beendet"
},
"today": {
"groupLabel": "Heutige Dosis",
"taken": "Genommen",
"skipped": "Übersprungen",
"toastTaken": "Heutige Dosis erfasst",
"toastSkipped": "Heutige Dosis übersprungen",
"recordedTaken": "Heute genommen um {time}",
"recordedSkipped": "Heute übersprungen um {time}",
"recordedOneShot": "Einmalig genommen um {time}",
"noneScheduled": "Heute keine Einnahme geplant.",
"pausedHint": "Pausiert — heute keine Erinnerung.",
"error": "Dosis konnte nicht erfasst werden."
},
"cadence": {
"oneShotOn": "Einmalige Dosis am {date}.",
"oneShotPending": "Einmalige Dosis ausstehend."
@@ -1349,6 +1336,9 @@
"add": "Eintrag",
"markTaken": "Genommen",
"markSkipped": "Übersprungen",
"rowActionsLabel": "Aktionen für {time}",
"markAsTaken": "Als genommen markieren",
"markAsSkipped": "Als übersprungen markieren",
"adHocTag": "außerplanmäßig",
"takenDetail": "geplant {planned} · genommen {taken}",
"status": {
@@ -1392,8 +1382,8 @@
"requiresCourseWindow": "Phasen sind nur mit Kurs-Fenster verfügbar."
},
"grace": {
"label": "Erinnerungsfenster — gilt für deinen Hauptzeitplan",
"primaryScheduleNote": "Andere Zeitpläne behalten ihr Standard-Fenster.",
"label": "Erinnerungsfenster",
"appliesToAllNote": "Gilt für alle Zeitpläne dieses Medikaments.",
"unit": "Minuten",
"saved": "Erinnerungsfenster gespeichert",
"failed": "Erinnerungsfenster konnte nicht gespeichert werden."
@@ -1468,7 +1458,6 @@
"title": "Erweiterte Einstellungen",
"group": {
"data": "Daten",
"reminders": "Erinnerungen",
"lifecycle": "Lebenszyklus",
"externalApi": "Externe API"
},
@@ -1487,8 +1476,7 @@
}
},
"header": {
"historyLabel": "Einnahmeverlauf ansehen",
"advancedLabel": "Erweiterte Einstellungen"
"historyLabel": "Einnahmeverlauf ansehen"
},
"history": {
"back": "Zurück zum Medikament",
@@ -1498,7 +1486,6 @@
"tabs": {
"uebersicht": "Übersicht",
"zeitplan": "Zeitplan",
"erinnerung": "Erinnerung",
"bestand": "Bestand",
"verlauf": "Verlauf",
"injektion": "Injektion",
@@ -1510,6 +1497,17 @@
"tablistLabel": "Medikamenten-Detailbereiche"
},
"uebersicht": {
"statusTitle": "Status",
"nextDoseLabel": "Nächste Dosis",
"nextDoseNone": "Keine anstehende Dosis.",
"pausedHint": "Pausiert — keine Erinnerungen.",
"reminderLabel": "Erinnerung",
"reminderOn": "Aktiv",
"reminderOff": "Aus",
"supplyLabel": "Bestand",
"supplyRunway": "Reicht noch ca. {days} Tage",
"jumpToZeitplan": "Zum Zeitplan",
"jumpToBestand": "Zum Bestand",
"complianceTitle": "Therapietreue",
"complianceEmpty": "Noch keine Daten zur Therapietreue."
},
@@ -1542,7 +1540,7 @@
"scheduleLabel": "Zeitplan {index}",
"fullEditNote": "Häufigkeit, Wirkstoffklasse und Kursfenster bearbeitest du über „Vollständig bearbeiten“.",
"graceEcho": "Erinnerungsfenster: {minutes} Minuten",
"graceLink": "In „Erinnerung“ ändern",
"reminderTitle": "Erinnerung",
"save": "Zeitplan speichern",
"saved": "Zeitplan gespeichert",
"failed": "Zeitplan konnte nicht gespeichert werden",
@@ -1712,8 +1710,7 @@
"dragHandle": "Zum Sortieren ziehen",
"show": "Einblenden",
"hide": "Ausblenden",
"manageSubPages": "Detailseiten verwalten",
"manageSubPagesHint": "Ausgeblendete Seiten verschwinden auch aus der oberen Navigation.",
"manageInSettings": "Pills & Detailseiten in den Einstellungen verwalten",
"groupSleep": "Schlaf",
"groupMood": "Stimmung",
"groupEvents": "Ereignisse",
@@ -1843,7 +1840,10 @@
"emptyTitle": "Noch kein Briefing",
"emptyDescription": "Erstelle das heutige Briefing, um eine Zusammenfassung deiner Trends zu bekommen.",
"emptyAction": "Briefing erstellen",
"loadingLabel": "Briefing wird zusammengestellt"
"loadingLabel": "Briefing wird zusammengestellt",
"noProviderTitle": "Kein KI-Anbieter verbunden",
"noProviderDescription": "Verbinde einen KI-Anbieter in den Einstellungen, um tägliche Briefings zu erhalten.",
"noProviderAction": "Zu den KI-Einstellungen"
},
"trendsRow": {
"title": "Trends",
@@ -3150,7 +3150,7 @@
},
"pillOrder": {
"title": "Navigations-Pills sortieren",
"description": "Ziehe die Pills in die Reihenfolge, in der sie oben in den Insights erscheinen sollen.",
"description": "Ziehe die Pills in die gewünschte Reihenfolge und blende sie über das Augensymbol ein oder aus. Ausgeblendete Detailseiten verschwinden auch aus der oberen Navigation.",
"dragHandle": "Verschieben",
"saveSuccess": "Reihenfolge der Pills gespeichert.",
"saveError": "Speichern der Pill-Reihenfolge fehlgeschlagen.",
@@ -3158,6 +3158,8 @@
"empty": "Noch keine Navigations-Pills verfügbar."
},
"customize": "Insights anpassen",
"regenerateRateLimited": "Aktualisierungslimit erreicht — bitte später erneut versuchen.",
"regenerateUnavailable": "Keine neue Analyse erhalten — bitte später erneut versuchen.",
"regenerateError": "Aktualisierung läuft noch — bitte gleich erneut versuchen."
},
"thresholds": {
@@ -3555,6 +3557,15 @@
"profile": "Profil",
"language": "Sprache",
"languageDescription": "Wähle die Anzeigesprache der Anwendung.",
"timeFormat": {
"title": "Stundenformat",
"description": "Bestimmt, ob Uhrzeiten im 24-Stunden- oder 12-Stunden-Format (AM/PM) angezeigt werden.",
"auto": "Automatisch (Sprache)",
"h24": "24 Stunden",
"h12": "12 Stunden (AM/PM)",
"saved": "Stundenformat gespeichert.",
"saveError": "Speichern des Stundenformats fehlgeschlagen."
},
"username": "Benutzername",
"height": "Körpergröße (cm)",
"dateOfBirth": "Geburtsdatum",
+35 -24
View File
@@ -1282,19 +1282,6 @@
"paused": "Paused",
"ended": "Ended"
},
"today": {
"groupLabel": "Today's dose",
"taken": "Taken",
"skipped": "Skipped",
"toastTaken": "Today's dose logged",
"toastSkipped": "Today's dose skipped",
"recordedTaken": "Taken today at {time}",
"recordedSkipped": "Skipped today at {time}",
"recordedOneShot": "One-shot dose taken at {time}",
"noneScheduled": "No dose scheduled for today.",
"pausedHint": "Paused — no reminder today.",
"error": "Could not log the dose."
},
"cadence": {
"oneShotOn": "One-time dose on {date}.",
"oneShotPending": "One-time dose pending."
@@ -1349,6 +1336,9 @@
"add": "Entry",
"markTaken": "Taken",
"markSkipped": "Skipped",
"rowActionsLabel": "Actions for {time}",
"markAsTaken": "Mark as taken",
"markAsSkipped": "Mark as skipped",
"adHocTag": "off-schedule",
"takenDetail": "planned {planned} · taken {taken}",
"status": {
@@ -1392,8 +1382,8 @@
"requiresCourseWindow": "Phases are available once a course window is set."
},
"grace": {
"label": "Reminder window — applies to your primary schedule",
"primaryScheduleNote": "Multi-schedule medications keep their other schedules' default window.",
"label": "Reminder window",
"appliesToAllNote": "Applies to every schedule of this medication.",
"unit": "minutes",
"saved": "Reminder window saved",
"failed": "Could not save the reminder window."
@@ -1468,7 +1458,6 @@
"title": "Advanced settings",
"group": {
"data": "Data",
"reminders": "Reminders",
"lifecycle": "Lifecycle",
"externalApi": "External API"
},
@@ -1487,8 +1476,7 @@
}
},
"header": {
"historyLabel": "View intake history",
"advancedLabel": "Advanced settings"
"historyLabel": "View intake history"
},
"history": {
"back": "Back to medication",
@@ -1498,7 +1486,6 @@
"tabs": {
"uebersicht": "Overview",
"zeitplan": "Schedule",
"erinnerung": "Reminders",
"bestand": "Supply",
"verlauf": "History",
"injektion": "Injection",
@@ -1510,6 +1497,17 @@
"tablistLabel": "Medication detail sections"
},
"uebersicht": {
"statusTitle": "Status",
"nextDoseLabel": "Next dose",
"nextDoseNone": "No upcoming dose.",
"pausedHint": "Paused — no reminders.",
"reminderLabel": "Reminder",
"reminderOn": "On",
"reminderOff": "Off",
"supplyLabel": "Supply",
"supplyRunway": "Lasts about {days} more days",
"jumpToZeitplan": "Open schedule",
"jumpToBestand": "Open supply",
"complianceTitle": "Adherence",
"complianceEmpty": "No adherence data yet."
},
@@ -1542,7 +1540,7 @@
"scheduleLabel": "Schedule {index}",
"fullEditNote": "Frequency, treatment class and course window are edited through “Full edit”.",
"graceEcho": "Reminder window: {minutes} minutes",
"graceLink": "Change under “Reminders”",
"reminderTitle": "Reminder",
"save": "Save schedule",
"saved": "Schedule saved",
"failed": "Could not save the schedule",
@@ -1714,8 +1712,7 @@
"dragHandle": "Drag to reorder",
"show": "Show",
"hide": "Hide",
"manageSubPages": "Manage detail pages",
"manageSubPagesHint": "Hidden pages are also removed from the top navigation.",
"manageInSettings": "Manage pills & detail pages in Settings",
"groupSleep": "Sleep",
"groupMood": "Mood",
"groupEvents": "Events",
@@ -1843,7 +1840,10 @@
"emptyTitle": "No briefing yet",
"emptyDescription": "Generate today's briefing to get a narrative read of your trends.",
"emptyAction": "Generate briefing",
"loadingLabel": "Synthesising today's briefing"
"loadingLabel": "Synthesising today's briefing",
"noProviderTitle": "No AI provider connected",
"noProviderDescription": "Connect an AI provider in settings to receive daily briefings.",
"noProviderAction": "Open AI settings"
},
"trendsRow": {
"title": "Trends",
@@ -3150,7 +3150,7 @@
},
"pillOrder": {
"title": "Sort the navigation pills",
"description": "Drag the pills into the order they should appear in along the top of Insights.",
"description": "Drag the pills into the order you want and show or hide them via the eye icon. Hidden detail pages are also removed from the top navigation.",
"dragHandle": "Reorder",
"saveSuccess": "Pill order saved.",
"saveError": "Saving the pill order failed.",
@@ -3158,6 +3158,8 @@
"empty": "No navigation pills available yet."
},
"customize": "Customize Insights",
"regenerateRateLimited": "Refresh limit reached — please try again later.",
"regenerateUnavailable": "No fresh analysis arrived — please try again later.",
"regenerateError": "Still refreshing — please try again in a moment."
},
"thresholds": {
@@ -3555,6 +3557,15 @@
"profile": "Profile",
"language": "Language",
"languageDescription": "Choose the display language of the application.",
"timeFormat": {
"title": "Hour format",
"description": "Controls whether times render on the 24-hour or the 12-hour (AM/PM) clock.",
"auto": "Automatic (language)",
"h24": "24-hour",
"h12": "12-hour (AM/PM)",
"saved": "Hour format saved.",
"saveError": "Saving the hour format failed."
},
"username": "Username",
"height": "Height (cm)",
"dateOfBirth": "Date of birth",
+35 -24
View File
@@ -1282,19 +1282,6 @@
"paused": "Pausado",
"ended": "Finalizado"
},
"today": {
"groupLabel": "Dosis de hoy",
"taken": "Tomada",
"skipped": "Omitida",
"toastTaken": "Dosis de hoy registrada",
"toastSkipped": "Dosis de hoy omitida",
"recordedTaken": "Tomada hoy a las {time}",
"recordedSkipped": "Omitida hoy a las {time}",
"recordedOneShot": "Dosis única tomada a las {time}",
"noneScheduled": "No hay ninguna dosis programada para hoy.",
"pausedHint": "En pausa — hoy no hay recordatorio.",
"error": "No se pudo registrar la dosis."
},
"cadence": {
"oneShotOn": "Dosis única el {date}.",
"oneShotPending": "Dosis única pendiente."
@@ -1349,6 +1336,9 @@
"add": "Entrada",
"markTaken": "Tomada",
"markSkipped": "Saltada",
"rowActionsLabel": "Acciones para {time}",
"markAsTaken": "Marcar como tomada",
"markAsSkipped": "Marcar como omitida",
"adHocTag": "fuera de horario",
"takenDetail": "prevista {planned} · tomada {taken}",
"status": {
@@ -1392,8 +1382,8 @@
"requiresCourseWindow": "Las fases están disponibles una vez fijado un periodo de tratamiento."
},
"grace": {
"label": "Ventana de recordatorio — se aplica a tu horario principal",
"primaryScheduleNote": "Los medicamentos con varios horarios mantienen la ventana predeterminada de sus demás horarios.",
"label": "Ventana de recordatorio",
"appliesToAllNote": "Se aplica a todos los horarios de este medicamento.",
"unit": "minutos",
"saved": "Ventana de recordatorio guardada",
"failed": "No se pudo guardar la ventana de recordatorio."
@@ -1468,7 +1458,6 @@
"title": "Ajustes avanzados",
"group": {
"data": "Datos",
"reminders": "Recordatorios",
"lifecycle": "Ciclo de vida",
"externalApi": "API externa"
},
@@ -1487,8 +1476,7 @@
}
},
"header": {
"historyLabel": "Ver historial de tomas",
"advancedLabel": "Configuración avanzada"
"historyLabel": "Ver historial de tomas"
},
"history": {
"back": "Volver al medicamento",
@@ -1498,7 +1486,6 @@
"tabs": {
"uebersicht": "Resumen",
"zeitplan": "Horario",
"erinnerung": "Recordatorios",
"bestand": "Existencias",
"verlauf": "Historial",
"injektion": "Inyección",
@@ -1510,6 +1497,17 @@
"tablistLabel": "Secciones de detalle del medicamento"
},
"uebersicht": {
"statusTitle": "Estado",
"nextDoseLabel": "Próxima dosis",
"nextDoseNone": "No hay dosis pendiente.",
"pausedHint": "En pausa — sin recordatorios.",
"reminderLabel": "Recordatorio",
"reminderOn": "Activado",
"reminderOff": "Desactivado",
"supplyLabel": "Existencias",
"supplyRunway": "Alcanza para unos {days} días más",
"jumpToZeitplan": "Ver horario",
"jumpToBestand": "Ver existencias",
"complianceTitle": "Adherencia",
"complianceEmpty": "Aún no hay datos de adherencia."
},
@@ -1542,7 +1540,7 @@
"scheduleLabel": "Horario {index}",
"fullEditNote": "La frecuencia, la clase de tratamiento y la ventana del curso se editan en «Edición completa».",
"graceEcho": "Ventana de recordatorio: {minutes} minutos",
"graceLink": "Cambiar en «Recordatorios»",
"reminderTitle": "Recordatorio",
"save": "Guardar horario",
"saved": "Horario guardado",
"failed": "No se pudo guardar el horario",
@@ -1714,8 +1712,7 @@
"dragHandle": "Arrastra para reordenar",
"show": "Mostrar",
"hide": "Ocultar",
"manageSubPages": "Gestionar páginas de detalle",
"manageSubPagesHint": "Las páginas ocultas también se quitan de la navegación superior.",
"manageInSettings": "Gestionar pestañas y páginas de detalle en los ajustes",
"groupSleep": "Sueño",
"groupMood": "Estado de ánimo",
"groupEvents": "Eventos",
@@ -1843,7 +1840,10 @@
"emptyTitle": "Aún no hay resumen",
"emptyDescription": "Genera el resumen de hoy para tener una vista rápida de tus tendencias.",
"emptyAction": "Generar resumen",
"loadingLabel": "Preparando el resumen"
"loadingLabel": "Preparando el resumen",
"noProviderTitle": "Ningún proveedor de IA conectado",
"noProviderDescription": "Conecta un proveedor de IA en los ajustes para recibir resúmenes diarios.",
"noProviderAction": "Abrir ajustes de IA"
},
"trendsRow": {
"title": "Tendencias",
@@ -3150,7 +3150,7 @@
},
"pillOrder": {
"title": "Ordenar las pestañas de navegación",
"description": "Arrastra las pestañas al orden en que deben aparecer en la parte superior de Análisis.",
"description": "Arrastra las pestañas al orden que quieras y muéstralas u ocúltalas con el icono del ojo. Las páginas de detalle ocultas también se quitan de la navegación superior.",
"dragHandle": "Reordenar",
"saveSuccess": "Orden de las pestañas guardado.",
"saveError": "No se pudo guardar el orden de las pestañas.",
@@ -3158,6 +3158,8 @@
"empty": "Aún no hay pestañas de navegación disponibles."
},
"customize": "Personalizar Análisis",
"regenerateRateLimited": "Límite de actualizaciones alcanzado: inténtalo de nuevo más tarde.",
"regenerateUnavailable": "No llegó un análisis nuevo: inténtalo de nuevo más tarde.",
"regenerateError": "Aún se está actualizando: inténtalo de nuevo en un momento."
},
"thresholds": {
@@ -3555,6 +3557,15 @@
"profile": "Perfil",
"language": "Idioma",
"languageDescription": "Elige el idioma de visualización de la aplicación.",
"timeFormat": {
"title": "Formato horario",
"description": "Determina si las horas se muestran en formato de 24 horas o de 12 horas (AM/PM).",
"auto": "Automático (idioma)",
"h24": "24 horas",
"h12": "12 horas (AM/PM)",
"saved": "Formato horario guardado.",
"saveError": "No se pudo guardar el formato horario."
},
"username": "Nombre de usuario",
"height": "Altura (cm)",
"dateOfBirth": "Fecha de nacimiento",
+35 -24
View File
@@ -1282,19 +1282,6 @@
"paused": "En pause",
"ended": "Terminé"
},
"today": {
"groupLabel": "Dose du jour",
"taken": "Pris",
"skipped": "Sauté",
"toastTaken": "Dose du jour enregistrée",
"toastSkipped": "Dose du jour sautée",
"recordedTaken": "Prise aujourd’hui à {time}",
"recordedSkipped": "Sautée aujourd’hui à {time}",
"recordedOneShot": "Dose unique prise à {time}",
"noneScheduled": "Aucune dose prévue aujourd’hui.",
"pausedHint": "En pause — pas de rappel aujourd’hui.",
"error": "Impossible d’enregistrer la dose."
},
"cadence": {
"oneShotOn": "Dose unique le {date}.",
"oneShotPending": "Dose unique en attente."
@@ -1349,6 +1336,9 @@
"add": "Entrée",
"markTaken": "Prise",
"markSkipped": "Sautée",
"rowActionsLabel": "Actions pour {time}",
"markAsTaken": "Marquer comme prise",
"markAsSkipped": "Marquer comme sautée",
"adHocTag": "hors planning",
"takenDetail": "prévue {planned} · prise {taken}",
"status": {
@@ -1392,8 +1382,8 @@
"requiresCourseWindow": "Les phases sont disponibles une fois une période de traitement définie."
},
"grace": {
"label": "Fenêtre de rappel — s’applique à votre horaire principal",
"primaryScheduleNote": "Les médicaments à horaires multiples conservent la fenêtre par défaut de leurs autres horaires.",
"label": "Fenêtre de rappel",
"appliesToAllNote": "S’applique à tous les horaires de ce médicament.",
"unit": "minutes",
"saved": "Fenêtre de rappel enregistrée",
"failed": "Impossible d’enregistrer la fenêtre de rappel."
@@ -1468,7 +1458,6 @@
"title": "Paramètres avancés",
"group": {
"data": "Données",
"reminders": "Rappels",
"lifecycle": "Cycle de vie",
"externalApi": "API externe"
},
@@ -1487,8 +1476,7 @@
}
},
"header": {
"historyLabel": "Voir l'historique des prises",
"advancedLabel": "Paramètres avancés"
"historyLabel": "Voir l'historique des prises"
},
"history": {
"back": "Retour au médicament",
@@ -1498,7 +1486,6 @@
"tabs": {
"uebersicht": "Aperçu",
"zeitplan": "Horaire",
"erinnerung": "Rappels",
"bestand": "Stock",
"verlauf": "Historique",
"injektion": "Injection",
@@ -1510,6 +1497,17 @@
"tablistLabel": "Sections de détail du médicament"
},
"uebersicht": {
"statusTitle": "Statut",
"nextDoseLabel": "Prochaine dose",
"nextDoseNone": "Aucune dose à venir.",
"pausedHint": "En pause — aucun rappel.",
"reminderLabel": "Rappel",
"reminderOn": "Activé",
"reminderOff": "Désactivé",
"supplyLabel": "Stock",
"supplyRunway": "Couvre encore environ {days} jours",
"jumpToZeitplan": "Voir l'horaire",
"jumpToBestand": "Voir le stock",
"complianceTitle": "Observance",
"complianceEmpty": "Aucune donnée d'observance pour l'instant."
},
@@ -1542,7 +1540,7 @@
"scheduleLabel": "Horaire {index}",
"fullEditNote": "La fréquence, la classe de traitement et la fenêtre de cure se modifient via « Édition complète ».",
"graceEcho": "Fenêtre de rappel : {minutes} minutes",
"graceLink": "Modifier dans « Rappels »",
"reminderTitle": "Rappel",
"save": "Enregistrer l'horaire",
"saved": "Horaire enregistré",
"failed": "Impossible d'enregistrer l'horaire",
@@ -1714,8 +1712,7 @@
"dragHandle": "Fais glisser pour réorganiser",
"show": "Afficher",
"hide": "Masquer",
"manageSubPages": "Gérer les pages de détail",
"manageSubPagesHint": "Les pages masquées disparaissent aussi de la navigation supérieure.",
"manageInSettings": "Gérer les onglets et les pages de détail dans les réglages",
"groupSleep": "Sommeil",
"groupMood": "Humeur",
"groupEvents": "Événements",
@@ -1843,7 +1840,10 @@
"emptyTitle": "Pas encore de briefing",
"emptyDescription": "Génère le briefing du jour pour obtenir un récap de tes tendances.",
"emptyAction": "Générer le briefing",
"loadingLabel": "Préparation du briefing"
"loadingLabel": "Préparation du briefing",
"noProviderTitle": "Aucun fournisseur d'IA connecté",
"noProviderDescription": "Connecte un fournisseur d'IA dans les réglages pour recevoir des briefings quotidiens.",
"noProviderAction": "Ouvrir les réglages IA"
},
"trendsRow": {
"title": "Tendances",
@@ -3150,7 +3150,7 @@
},
"pillOrder": {
"title": "Trier les onglets de navigation",
"description": "Faites glisser les onglets dans l'ordre où ils doivent apparaître en haut des Analyses.",
"description": "Faites glisser les onglets dans l'ordre souhaité et affichez-les ou masquez-les via l'icône en forme d'œil. Les pages de détail masquées disparaissent aussi de la navigation supérieure.",
"dragHandle": "Réorganiser",
"saveSuccess": "Ordre des onglets enregistré.",
"saveError": "L'enregistrement de l'ordre des onglets a échoué.",
@@ -3158,6 +3158,8 @@
"empty": "Aucun onglet de navigation disponible pour le moment."
},
"customize": "Personnaliser les Analyses",
"regenerateRateLimited": "Limite d'actualisation atteinte — réessayez plus tard.",
"regenerateUnavailable": "Aucune nouvelle analyse reçue — réessayez plus tard.",
"regenerateError": "Actualisation en cours — réessayez dans un instant."
},
"thresholds": {
@@ -3555,6 +3557,15 @@
"profile": "Profil",
"language": "Langue",
"languageDescription": "Choisissez la langue d’affichage de l’application.",
"timeFormat": {
"title": "Format horaire",
"description": "Détermine si les heures s’affichent au format 24 heures ou 12 heures (AM/PM).",
"auto": "Automatique (langue)",
"h24": "24 heures",
"h12": "12 heures (AM/PM)",
"saved": "Format horaire enregistré.",
"saveError": "Échec de l’enregistrement du format horaire."
},
"username": "Nom d'utilisateur",
"height": "Taille (cm)",
"dateOfBirth": "Date de naissance",
+35 -24
View File
@@ -1282,19 +1282,6 @@
"paused": "In pausa",
"ended": "Terminato"
},
"today": {
"groupLabel": "Dose di oggi",
"taken": "Assunta",
"skipped": "Saltata",
"toastTaken": "Dose di oggi registrata",
"toastSkipped": "Dose di oggi saltata",
"recordedTaken": "Assunta oggi alle {time}",
"recordedSkipped": "Saltata oggi alle {time}",
"recordedOneShot": "Dose singola assunta alle {time}",
"noneScheduled": "Nessuna dose prevista per oggi.",
"pausedHint": "In pausa — nessun promemoria oggi.",
"error": "Impossibile registrare la dose."
},
"cadence": {
"oneShotOn": "Dose singola il {date}.",
"oneShotPending": "Dose singola in sospeso."
@@ -1349,6 +1336,9 @@
"add": "Voce",
"markTaken": "Assunta",
"markSkipped": "Saltata",
"rowActionsLabel": "Azioni per {time}",
"markAsTaken": "Segna come assunta",
"markAsSkipped": "Segna come saltata",
"adHocTag": "fuori programma",
"takenDetail": "prevista {planned} · assunta {taken}",
"status": {
@@ -1392,8 +1382,8 @@
"requiresCourseWindow": "Le fasi sono disponibili dopo aver impostato un periodo di trattamento."
},
"grace": {
"label": "Finestra di promemoria — si applica al tuo programma principale",
"primaryScheduleNote": "I farmaci con più programmi mantengono la finestra predefinita degli altri programmi.",
"label": "Finestra di promemoria",
"appliesToAllNote": "Vale per tutti i programmi di questo farmaco.",
"unit": "minuti",
"saved": "Finestra di promemoria salvata",
"failed": "Impossibile salvare la finestra di promemoria."
@@ -1468,7 +1458,6 @@
"title": "Impostazioni avanzate",
"group": {
"data": "Dati",
"reminders": "Promemoria",
"lifecycle": "Ciclo di vita",
"externalApi": "API esterna"
},
@@ -1487,8 +1476,7 @@
}
},
"header": {
"historyLabel": "Visualizza cronologia assunzioni",
"advancedLabel": "Impostazioni avanzate"
"historyLabel": "Visualizza cronologia assunzioni"
},
"history": {
"back": "Torna al farmaco",
@@ -1498,7 +1486,6 @@
"tabs": {
"uebersicht": "Panoramica",
"zeitplan": "Programma",
"erinnerung": "Promemoria",
"bestand": "Scorta",
"verlauf": "Cronologia",
"injektion": "Iniezione",
@@ -1510,6 +1497,17 @@
"tablistLabel": "Sezioni di dettaglio del farmaco"
},
"uebersicht": {
"statusTitle": "Stato",
"nextDoseLabel": "Prossima dose",
"nextDoseNone": "Nessuna dose in programma.",
"pausedHint": "In pausa — nessun promemoria.",
"reminderLabel": "Promemoria",
"reminderOn": "Attivo",
"reminderOff": "Disattivo",
"supplyLabel": "Scorta",
"supplyRunway": "Copre ancora circa {days} giorni",
"jumpToZeitplan": "Apri programma",
"jumpToBestand": "Apri scorta",
"complianceTitle": "Aderenza",
"complianceEmpty": "Ancora nessun dato sull'aderenza."
},
@@ -1542,7 +1540,7 @@
"scheduleLabel": "Programma {index}",
"fullEditNote": "Frequenza, classe di trattamento e finestra del ciclo si modificano da «Modifica completa».",
"graceEcho": "Finestra di promemoria: {minutes} minuti",
"graceLink": "Modifica in «Promemoria»",
"reminderTitle": "Promemoria",
"save": "Salva programma",
"saved": "Programma salvato",
"failed": "Impossibile salvare il programma",
@@ -1714,8 +1712,7 @@
"dragHandle": "Trascina per riordinare",
"show": "Mostra",
"hide": "Nascondi",
"manageSubPages": "Gestisci pagine di dettaglio",
"manageSubPagesHint": "Le pagine nascoste vengono rimosse anche dalla navigazione in alto.",
"manageInSettings": "Gestisci schede e pagine di dettaglio nelle impostazioni",
"groupSleep": "Sonno",
"groupMood": "Umore",
"groupEvents": "Eventi",
@@ -1843,7 +1840,10 @@
"emptyTitle": "Ancora nessun briefing",
"emptyDescription": "Genera il briefing di oggi per avere un riepilogo dei tuoi trend.",
"emptyAction": "Genera briefing",
"loadingLabel": "Preparazione del briefing"
"loadingLabel": "Preparazione del briefing",
"noProviderTitle": "Nessun provider IA collegato",
"noProviderDescription": "Collega un provider IA nelle impostazioni per ricevere briefing giornalieri.",
"noProviderAction": "Apri impostazioni IA"
},
"trendsRow": {
"title": "Tendenze",
@@ -3150,7 +3150,7 @@
},
"pillOrder": {
"title": "Ordina le schede di navigazione",
"description": "Trascina le schede nell'ordine in cui devono comparire in alto negli Approfondimenti.",
"description": "Trascina le schede nell'ordine desiderato e mostrale o nascondile con l'icona dell'occhio. Le pagine di dettaglio nascoste vengono rimosse anche dalla navigazione in alto.",
"dragHandle": "Riordina",
"saveSuccess": "Ordine delle schede salvato.",
"saveError": "Salvataggio dell'ordine delle schede non riuscito.",
@@ -3158,6 +3158,8 @@
"empty": "Nessuna scheda di navigazione ancora disponibile."
},
"customize": "Personalizza gli Approfondimenti",
"regenerateRateLimited": "Limite di aggiornamento raggiunto — riprova più tardi.",
"regenerateUnavailable": "Nessuna nuova analisi ricevuta — riprova più tardi.",
"regenerateError": "Aggiornamento ancora in corso — riprova tra un momento."
},
"thresholds": {
@@ -3555,6 +3557,15 @@
"profile": "Profilo",
"language": "Lingua",
"languageDescription": "Scegli la lingua di visualizzazione dell’applicazione.",
"timeFormat": {
"title": "Formato orario",
"description": "Determina se gli orari vengono mostrati nel formato 24 ore o 12 ore (AM/PM).",
"auto": "Automatico (lingua)",
"h24": "24 ore",
"h12": "12 ore (AM/PM)",
"saved": "Formato orario salvato.",
"saveError": "Salvataggio del formato orario non riuscito."
},
"username": "Nome utente",
"height": "Altezza (cm)",
"dateOfBirth": "Data di nascita",
+35 -24
View File
@@ -1282,19 +1282,6 @@
"paused": "Wstrzymany",
"ended": "Zakończony"
},
"today": {
"groupLabel": "Dzisiejsza dawka",
"taken": "Przyjęto",
"skipped": "Pominięto",
"toastTaken": "Dzisiejsza dawka zapisana",
"toastSkipped": "Dzisiejsza dawka pominięta",
"recordedTaken": "Przyjęto dziś o {time}",
"recordedSkipped": "Pominięto dziś o {time}",
"recordedOneShot": "Jednorazową dawkę przyjęto o {time}",
"noneScheduled": "Na dziś nie zaplanowano żadnej dawki.",
"pausedHint": "Wstrzymano — dziś brak przypomnienia.",
"error": "Nie udało się zapisać dawki."
},
"cadence": {
"oneShotOn": "Dawka jednorazowa w dniu {date}.",
"oneShotPending": "Dawka jednorazowa oczekuje."
@@ -1349,6 +1336,9 @@
"add": "Wpis",
"markTaken": "Przyjęte",
"markSkipped": "Pominięte",
"rowActionsLabel": "Akcje dla {time}",
"markAsTaken": "Oznacz jako przyjętą",
"markAsSkipped": "Oznacz jako pominiętą",
"adHocTag": "poza planem",
"takenDetail": "planowo {planned} · przyjęto {taken}",
"status": {
@@ -1392,8 +1382,8 @@
"requiresCourseWindow": "Fazy są dostępne po ustawieniu okresu leczenia."
},
"grace": {
"label": "Okno przypomnienia — dotyczy głównego harmonogramu",
"primaryScheduleNote": "Leki z wieloma harmonogramami zachowują domyślne okno pozostałych harmonogramów.",
"label": "Okno przypomnienia",
"appliesToAllNote": "Dotyczy wszystkich harmonogramów tego leku.",
"unit": "minut",
"saved": "Okno przypomnienia zapisane",
"failed": "Nie udało się zapisać okna przypomnienia."
@@ -1468,7 +1458,6 @@
"title": "Ustawienia zaawansowane",
"group": {
"data": "Dane",
"reminders": "Przypomnienia",
"lifecycle": "Cykl życia",
"externalApi": "Zewnętrzne API"
},
@@ -1487,8 +1476,7 @@
}
},
"header": {
"historyLabel": "Zobacz historię przyjęć",
"advancedLabel": "Ustawienia zaawansowane"
"historyLabel": "Zobacz historię przyjęć"
},
"history": {
"back": "Powrót do leku",
@@ -1498,7 +1486,6 @@
"tabs": {
"uebersicht": "Przegląd",
"zeitplan": "Harmonogram",
"erinnerung": "Przypomnienia",
"bestand": "Zapas",
"verlauf": "Historia",
"injektion": "Iniekcja",
@@ -1510,6 +1497,17 @@
"tablistLabel": "Sekcje szczegółów leku"
},
"uebersicht": {
"statusTitle": "Status",
"nextDoseLabel": "Następna dawka",
"nextDoseNone": "Brak nadchodzącej dawki.",
"pausedHint": "Wstrzymano — brak przypomnień.",
"reminderLabel": "Przypomnienie",
"reminderOn": "Włączone",
"reminderOff": "Wyłączone",
"supplyLabel": "Zapas",
"supplyRunway": "Wystarczy na około {days} dni",
"jumpToZeitplan": "Otwórz harmonogram",
"jumpToBestand": "Otwórz zapas",
"complianceTitle": "Przestrzeganie",
"complianceEmpty": "Brak danych o przestrzeganiu."
},
@@ -1542,7 +1540,7 @@
"scheduleLabel": "Harmonogram {index}",
"fullEditNote": "Częstotliwość, klasę leczenia i okno kuracji edytujesz przez „Pełna edycja”.",
"graceEcho": "Okno przypomnienia: {minutes} minut",
"graceLink": "Zmień w „Przypomnienia”",
"reminderTitle": "Przypomnienie",
"save": "Zapisz harmonogram",
"saved": "Zapisano harmonogram",
"failed": "Nie udało się zapisać harmonogramu",
@@ -1714,8 +1712,7 @@
"dragHandle": "Przeciągnij, aby zmienić kolejność",
"show": "Pokaż",
"hide": "Ukryj",
"manageSubPages": "Zarządzaj stronami szczegółów",
"manageSubPagesHint": "Ukryte strony znikają również z górnej nawigacji.",
"manageInSettings": "Zarządzaj pigułkami i stronami szczegółów w ustawieniach",
"groupSleep": "Sen",
"groupMood": "Nastrój",
"groupEvents": "Zdarzenia",
@@ -1843,7 +1840,10 @@
"emptyTitle": "Jeszcze brak briefingu",
"emptyDescription": "Wygeneruj dzisiejszy briefing, aby zobaczyć podsumowanie trendów.",
"emptyAction": "Wygeneruj briefing",
"loadingLabel": "Przygotowanie briefingu"
"loadingLabel": "Przygotowanie briefingu",
"noProviderTitle": "Brak połączonego dostawcy AI",
"noProviderDescription": "Połącz dostawcę AI w ustawieniach, aby otrzymywać codzienne briefingi.",
"noProviderAction": "Otwórz ustawienia AI"
},
"trendsRow": {
"title": "Trendy",
@@ -3150,7 +3150,7 @@
},
"pillOrder": {
"title": "Sortuj pigułki nawigacyjne",
"description": "Przeciągnij pigułki w kolejności, w jakiej mają się pojawiać u góry Analiz.",
"description": "Przeciągnij pigułki w wybranej kolejności i pokaż lub ukryj je ikoną oka. Ukryte strony szczegółów znikają również z górnej nawigacji.",
"dragHandle": "Zmień kolejność",
"saveSuccess": "Zapisano kolejność pigułek.",
"saveError": "Zapisanie kolejności pigułek nie powiodło się.",
@@ -3158,6 +3158,8 @@
"empty": "Brak dostępnych pigułek nawigacyjnych."
},
"customize": "Dostosuj Analizy",
"regenerateRateLimited": "Osiągnięto limit odświeżeń — spróbuj ponownie później.",
"regenerateUnavailable": "Nie otrzymano nowej analizy — spróbuj ponownie później.",
"regenerateError": "Trwa odświeżanie — spróbuj ponownie za chwilę."
},
"thresholds": {
@@ -3555,6 +3557,15 @@
"profile": "Profil",
"language": "Język",
"languageDescription": "Wybierz język wyświetlania aplikacji.",
"timeFormat": {
"title": "Format godziny",
"description": "Określa, czy godziny są wyświetlane w formacie 24-godzinnym czy 12-godzinnym (AM/PM).",
"auto": "Automatycznie (język)",
"h24": "24-godzinny",
"h12": "12-godzinny (AM/PM)",
"saved": "Zapisano format godziny.",
"saveError": "Nie udało się zapisać formatu godziny."
},
"username": "Nazwa użytkownika",
"height": "Wzrost (cm)",
"dateOfBirth": "Data urodzenia",
@@ -0,0 +1,14 @@
-- v1.15.20 — per-user hour-cycle display preference.
--
-- The UI locale alone decided whether times rendered as AM/PM or 24-hour,
-- which left e.g. an en-locale user no way to read 24-hour times. The new
-- column carries an explicit preference: AUTO follows the locale convention,
-- H12 forces AM/PM, H24 forces 24-hour. Display-time only — stored instants
-- stay UTC.
--
-- Additive + non-destructive: a new NOT NULL column with a default, no
-- backfill needed. Existing rows read AUTO.
CREATE TYPE "time_format_preference" AS ENUM ('AUTO', 'H12', 'H24');
ALTER TABLE "users"
ADD COLUMN "time_format" "time_format_preference" NOT NULL DEFAULT 'AUTO';
+14
View File
@@ -132,6 +132,10 @@ model User {
// Canonical storage stays SI; this only selects the display-time
// transform branch (km/h vs mph, km vs mi). Null = default "metric".
unitPreference String? @map("unit_preference") // "metric" | "imperial" | null = default "metric"
// Hour-cycle display preference. AUTO follows the locale convention
// (en → AM/PM, de → 24h), H12 forces AM/PM, H24 forces 24-hour.
// Display-time only — canonical storage stays UTC instants.
timeFormat TimeFormatPreference @default(AUTO) @map("time_format")
// AI provider selection for insights. Null = OPENAI fallback.
aiProvider String? @map("ai_provider") // "OPENAI" | "ANTHROPIC" | "LOCAL" | "CHATGPT_OAUTH"
aiModel String? @map("ai_model")
@@ -716,6 +720,16 @@ enum GlucoseContext {
@@map("glucose_context")
}
// Hour-cycle display preference for rendered times. AUTO defers to the
// active locale's convention; H12/H24 pin the cycle regardless of locale.
enum TimeFormatPreference {
AUTO
H12
H24
@@map("time_format_preference")
}
enum MeasurementSource {
MANUAL
WITHINGS
+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,
@@ -12,10 +12,6 @@ import {
DEFAULT_INSIGHTS_LAYOUT,
type InsightsSectionId,
} from "@/lib/insights-layout";
import {
MANAGER_GROUP_ORDER,
SUB_PAGE_MANAGER_GROUP_SLUGS,
} from "@/lib/insights/sub-page-metric";
function render(node: React.ReactNode) {
const queryClient = new QueryClient({
@@ -28,31 +24,38 @@ function render(node: React.ReactNode) {
);
}
describe("<InsightsEditMode> — broadened sub-page manager (v1.15.14 W2)", () => {
describe("<InsightsEditMode> — detail-page manager retired (v1.15.20)", () => {
const noneGated: ReadonlySet<InsightsSectionId> = new Set();
it("relabels the Vitals disclosure to 'Manage detail pages'", () => {
const html = render(
function renderEditMode(): string {
return render(
<InsightsEditMode
layout={DEFAULT_INSIGHTS_LAYOUT}
gatedOffSectionIds={noneGated}
onClose={() => {}}
/>,
);
expect(html).toContain('data-slot="insights-edit-tiles-disclosure"');
expect(html).toContain("Manage detail pages");
}
it("no longer renders the per-detail-page manager disclosure", () => {
// Pill sorting + visibility moved to Settings → Insights; the in-card
// disclosure under the Vitals row duplicated that surface.
const html = renderEditMode();
expect(html).not.toContain('data-slot="insights-edit-tiles-disclosure"');
expect(html).not.toContain('data-slot="insights-edit-tile-row"');
expect(html).not.toContain('data-slot="insights-edit-tiles-nav-hint"');
});
it("groups every routed sub-page slug under exactly one manager group", () => {
// The disclosure body is collapsed in SSR (no effects fire), so this
// asserts the data the manager renders FROM rather than the open DOM:
// every group in the manager order carries at least one slug, and the
// three tab-strip-flat categories (sleep / mood / events) are present.
expect(MANAGER_GROUP_ORDER).toContain("mood");
expect(MANAGER_GROUP_ORDER).toContain("events");
expect(MANAGER_GROUP_ORDER).toContain("sleep");
// A non-vitals slug (`steps`) lands in the activity group so it is
// toggle-able from the manager — the round-trip the spec calls out.
expect(SUB_PAGE_MANAGER_GROUP_SLUGS.activity).toContain("steps");
it("links to the settings pill section instead", () => {
const html = renderEditMode();
expect(html).toContain('data-slot="insights-edit-manage-link"');
expect(html).toContain('href="/settings/insights#insights-pill-order"');
expect(html).toContain("Manage pills &amp; detail pages in Settings");
});
it("still renders the section rows with their eye toggles", () => {
const html = renderEditMode();
expect(html).toContain('data-slot="insights-edit-section-row"');
expect(html).toContain('data-slot="insights-edit-section-eye"');
});
});
+23 -260
View File
@@ -1,10 +1,10 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
ChevronDown,
Eye,
EyeOff,
GripVertical,
@@ -33,22 +33,12 @@ import { cn } from "@/lib/utils";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
reorderById,
rebuildTilesWithReorderedVitals,
} from "@/lib/insights-layout-reorder";
import { reorderById } from "@/lib/insights-layout-reorder";
import {
type InsightsLayout,
type InsightsSectionConfig,
type InsightsSectionId,
type InsightsTileConfig,
} from "@/lib/insights-layout";
import {
MANAGER_GROUP_ORDER,
SUB_PAGE_MANAGER_GROUP_SLUGS,
type ManagerGroup,
} from "@/lib/insights/sub-page-metric";
import { SUB_PAGE_TABS } from "@/components/insights/insights-tab-strip";
/**
* v1.15.11 W3 — inline "Anpassen" edit mode for the customizable Insights
@@ -61,14 +51,11 @@ import { SUB_PAGE_TABS } from "@/components/insights/insights-tab-strip";
* and invalidates `queryKeys.insightsLayout()`; "Zurücksetzen" DELETEs to
* defaults.
*
* Tile-level depth (the Vitals section's per-metric tiles) ships as the
* spec-sanctioned DISCLOSURE fallback rather than nested cross-container drag:
* section rows drag in ONE top-level `SortableContext`; the Vitals row carries a
* "Kacheln verwalten" disclosure that opens a SEPARATE, independent
* `DndContext` + `SortableContext` for its tiles. The two drag contexts never
* share collision detection, so a tile drag can never cross-fire into a section
* drag — the failure mode flagged in the plan's Risks section. Still inline,
* still drag + eye per tile, robust on touch.
* Tile-level management (the per-metric detail pages + their nav pills)
* moved to Settings → Insights in v1.15.20 — the pill-order section there
* carries both sorting AND the eye toggles, so the disclosure this card used
* to nest under the Vitals row was a duplicate surface. The card keeps the
* draft's `tiles` untouched and links to the settings section instead.
*/
/** Localized title key per section id — used for the edit-card label. */
@@ -83,27 +70,6 @@ const SECTION_TITLE_KEYS: Record<InsightsSectionId, string> = {
"rhythm-events": "insights.rhythmEvents.sectionTitle",
};
/**
* v1.15.14 W2 — group-header label key per manager group. Reuses the
* tab-strip parent-pill labels (`insights.tabStrip.<group>Parent.label`)
* so a section header in the manager reads exactly like the nav pill it
* governs; the three groups the tab strip never collapses (sleep / mood /
* events) get their own `insights.editMode.group*` keys. ONE source of
* labels keeps the customize surface and the nav in lockstep.
*/
const MANAGER_GROUP_HEADER_KEYS: Record<ManagerGroup, string> = {
vitals: "insights.tabStrip.vitalsParent.label",
body: "insights.tabStrip.bodyParent.label",
activity: "insights.tabStrip.activityParent.label",
sleep: "insights.editMode.groupSleep",
cardiovascular: "insights.tabStrip.cardiovascularParent.label",
hearing: "insights.tabStrip.hearingParent.label",
environment: "insights.tabStrip.environmentParent.label",
metabolic: "insights.tabStrip.metabolicParent.label",
mood: "insights.editMode.groupMood",
events: "insights.editMode.groupEvents",
};
interface InsightsEditModeProps {
/** Resolved layout currently in effect (server copy, defaults while loading). */
layout: InsightsLayout;
@@ -131,9 +97,10 @@ export function InsightsEditMode({
const [draft, setDraft] = useState<InsightsLayout>(() => ({
version: layout.version,
sections: [...layout.sections].sort((a, b) => a.order - b.order),
// Tiles pass through the save verbatim — pill order + visibility are
// managed on Settings → Insights since v1.15.20.
tiles: [...layout.tiles].sort((a, b) => a.order - b.order),
}));
const [tilesOpen, setTilesOpen] = useState(false);
// v1.15.11 QA L5 — on mount move focus to the edit-card heading so keyboard /
// screen-reader users land on the surface they just opened (not the top of
@@ -208,34 +175,6 @@ export function InsightsEditMode({
);
const sectionIds = sections.map((s) => s.id);
// v1.15.14 W2 — the manager now lists EVERY sub-page slug grouped by
// category, not just the Vitals overview grid subset. Each group renders
// its slugs sorted by the draft's saved `order`; a slug the layout does
// not enumerate falls to the end. The `overview` tile is layout-only (the
// mother page, not a sub-page) so it never appears here. Labels reuse the
// tab-strip `SUB_PAGE_TABS[slug].labelKey` so the manager row reads like
// the nav pill it governs.
const tilesByGroup = useMemo(() => {
const byId = new Map<string, InsightsTileConfig>(
draft.tiles.map((tt) => [tt.id, tt]),
);
return MANAGER_GROUP_ORDER.map((group) => {
const rows = SUB_PAGE_MANAGER_GROUP_SLUGS[group]
.map((slug) => {
const cfg = byId.get(slug);
return {
id: slug as string,
labelKey: SUB_PAGE_TABS[slug].labelKey,
visible: cfg?.visible ?? false,
order: cfg?.order ?? Number.MAX_SAFE_INTEGER,
};
})
.sort((a, b) => a.order - b.order);
return { group, rows };
}).filter((g) => g.rows.length > 0);
// Re-derive on any tile change.
}, [draft.tiles]);
function toggleSection(id: InsightsSectionId, visible: boolean) {
setDraft((d) => ({
...d,
@@ -254,47 +193,6 @@ export function InsightsEditMode({
setDraft((d) => ({ ...d, sections: reordered }));
}
function toggleTile(id: string, visible: boolean) {
setDraft((d) => ({
...d,
tiles: d.tiles.map((tt) => (tt.id === id ? { ...tt, visible } : tt)),
}));
}
/**
* v1.15.14 W2 — reorder the tiles WITHIN one manager group. Each group
* renders its own `SortableContext`, so a drag never crosses a group
* boundary; the group is identified from the dragged slug. We reorder the
* group's subset through the tested pure helper, then splice it back into
* the full `tiles` array (which also drives the tab strip + overview grid),
* re-densifying order only across that group's slots while every other
* tile keeps its relative order.
*/
function handleGroupTileDragEnd(group: ManagerGroup, event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const groupRows = tilesByGroup.find((g) => g.group === group)?.rows ?? [];
const groupMemberIds = new Set(groupRows.map((r) => r.id));
const subset = groupRows.map((tt) => ({
id: tt.id,
order: tt.order,
visible: tt.visible,
}));
// Reorder ONLY this group's subset through the tested pure helper — a
// single total-order sort, never a mixed-key comparator (QA M2).
const reordered = reorderById(subset, String(active.id), String(over.id));
const reorderedIds = reordered.map((r) => r.id);
setDraft((d) => ({
...d,
// Substitute this group's slots in their new relative order while leaving
// every other tile untouched. Pure, total-order helper (QA M2).
tiles: rebuildTilesWithReorderedVitals(d.tiles, reorderedIds, (id) =>
groupMemberIds.has(id),
),
}));
}
const allHidden = draft.sections.every((s) => !s.visible);
return (
@@ -365,7 +263,6 @@ export function InsightsEditMode({
<div className="space-y-2">
{sections.map((section) => {
const gatedOff = gatedOffSectionIds.has(section.id);
const isVitals = section.id === "vitals";
return (
<SortableSectionRow
key={section.id}
@@ -380,94 +277,25 @@ export function InsightsEditMode({
gatedHint: t("insights.editMode.gatedHint"),
}}
onToggle={toggleSection}
>
{isVitals && (
<div className="mt-2 border-t pt-2">
<button
type="button"
onClick={() => setTilesOpen((o) => !o)}
disabled={busy}
aria-expanded={tilesOpen}
data-slot="insights-edit-tiles-disclosure"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring inline-flex min-h-11 items-center gap-1.5 rounded text-sm focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
>
<ChevronDown
className={cn(
"h-4 w-4 transition-transform motion-reduce:transition-none",
tilesOpen && "rotate-180",
)}
aria-hidden="true"
/>
{t("insights.editMode.manageSubPages")}
</button>
{/* v1.15.14 — flag that hiding a detail page also drops
its top-nav pill, so a user isn't surprised a metric
vanished from the navigation. */}
<p
data-slot="insights-edit-tiles-nav-hint"
className="text-muted-foreground mt-1 text-xs"
>
{t("insights.editMode.manageSubPagesHint")}
</p>
{tilesOpen && (
// v1.15.14 W2 — the manager now lists EVERY sub-page
// slug grouped by category, the same `tiles` layout the
// tab strip + overview Vitals grid read. Each group is
// its OWN `DndContext` + `SortableContext` so a drag
// never crosses a group boundary (and never cross-fires
// into the section drag). The whole list scrolls inside
// one capped container; `overscroll-contain` keeps the
// scroll local on touch.
<div className="mt-2 max-h-[60vh] space-y-3 overflow-y-auto overscroll-contain">
{tilesByGroup.map(({ group, rows }) => (
<div key={group} data-slot="insights-edit-tile-group" data-group={group}>
<p className="text-muted-foreground px-1 py-1 text-[11px] font-semibold tracking-wide uppercase">
{t(MANAGER_GROUP_HEADER_KEYS[group])}
</p>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(event) =>
handleGroupTileDragEnd(group, event)
}
>
<SortableContext
items={rows.map((r) => r.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1.5">
{rows.map((tile) => (
<SortableTileRow
key={tile.id}
id={tile.id}
title={t(tile.labelKey)}
visible={tile.visible}
disabled={busy}
labels={{
dragHandle: t(
"insights.editMode.dragHandle",
),
show: t("insights.editMode.show"),
hide: t("insights.editMode.hide"),
}}
onToggle={toggleTile}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
))}
</div>
)}
</div>
)}
</SortableSectionRow>
/>
);
})}
</div>
</SortableContext>
</DndContext>
{/* v1.15.20 — the per-detail-page manager (sort + show/hide) lives on
Settings → Insights; the disclosure this card used to nest under
the Vitals row duplicated it. Keep a quiet pointer instead. */}
<p className="text-muted-foreground text-xs">
<Link
href="/settings/insights#insights-pill-order"
data-slot="insights-edit-manage-link"
className="hover:text-foreground focus-visible:ring-ring rounded underline underline-offset-2 focus-visible:ring-2 focus-visible:outline-none"
>
{t("insights.editMode.manageInSettings")}
</Link>
</p>
</div>
);
}
@@ -523,7 +351,6 @@ interface SortableSectionRowProps {
disabled: boolean;
labels: RowLabels & { gatedHint: string };
onToggle: (id: InsightsSectionId, visible: boolean) => void;
children?: React.ReactNode;
}
function SortableSectionRow({
@@ -533,7 +360,6 @@ function SortableSectionRow({
disabled,
labels,
onToggle,
children,
}: SortableSectionRowProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: section.id });
@@ -598,70 +424,7 @@ function SortableSectionRow({
slot="insights-edit-section-eye"
/>
</div>
{children}
</div>
);
}
interface SortableTileRowProps {
id: string;
title: string;
visible: boolean;
disabled: boolean;
labels: RowLabels;
onToggle: (id: string, visible: boolean) => void;
}
function SortableTileRow({
id,
title,
visible,
disabled,
labels,
onToggle,
}: SortableTileRowProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition: prefersReducedMotion() ? "none" : transition,
};
return (
<div
ref={setNodeRef}
style={style}
data-slot="insights-edit-tile-row"
data-tile={id}
data-dragging={isDragging ? "true" : undefined}
className={cn(
"border-border bg-card flex items-center gap-2 rounded-md border px-2 py-1.5",
isDragging && "ring-primary z-10 opacity-90 shadow-md ring-2",
)}
>
<button
type="button"
{...attributes}
{...listeners}
aria-label={`${labels.dragHandle} — ${title}`}
title={labels.dragHandle}
disabled={disabled}
data-slot="insights-edit-tile-handle"
className={DRAG_HANDLE_CLASS}
>
<GripVertical className="h-4 w-4" />
</button>
<span className="min-w-0 flex-1 truncate text-sm" title={title}>
{title}
</span>
<EyeToggle
visible={visible}
disabled={disabled}
label={`${visible ? labels.hide : labels.show} — ${title}`}
onClick={() => onToggle(id, !visible)}
slot="insights-edit-tile-eye"
/>
</div>
);
}
@@ -0,0 +1,144 @@
/**
* Settings → Insights pill list — per-pill visibility toggle (v1.15.20).
*
* Each sortable pill row carries the same eye interaction the overview edit
* cards use; the toggle flips the draft's `tiles[].visible` flag and
* "Speichern" persists it through the same `{ tiles }` PUT as the order.
* `tiles[].visible` is the field the tab strip + overview Vitals grid
* already read (v1.15.14), so hiding a pill here also drops it from the
* top navigation.
*
* SSR-only suite per project convention: static markup pins the rendered
* eye state per tile; the persistence contract is pinned against the same
* fetch stub the save handler issues.
*/
import React from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import {
DEFAULT_INSIGHTS_LAYOUT,
type InsightsLayout,
} from "@/lib/insights-layout";
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: null, isAuthenticated: true }),
}));
const layoutSpy = vi.fn<
() => { layout: InsightsLayout; isLoading: boolean; isSuccess: boolean }
>(() => ({
layout: DEFAULT_INSIGHTS_LAYOUT,
isLoading: false,
isSuccess: true,
}));
vi.mock("@/hooks/use-insights-layout", () => ({
useInsightsLayoutQuery: () => layoutSpy(),
}));
import { InsightsPillOrderSection } from "../insights-pill-order-section";
function buildLayout(hiddenIds: readonly string[]): InsightsLayout {
return {
version: DEFAULT_INSIGHTS_LAYOUT.version,
sections: DEFAULT_INSIGHTS_LAYOUT.sections.map((s) => ({ ...s })),
tiles: DEFAULT_INSIGHTS_LAYOUT.tiles.map((tile) => ({
...tile,
visible: !hiddenIds.includes(tile.id),
})),
};
}
beforeEach(() => {
layoutSpy.mockImplementation(() => ({
layout: DEFAULT_INSIGHTS_LAYOUT,
isLoading: false,
isSuccess: true,
}));
vi.stubGlobal(
"fetch",
vi.fn(async () =>
new Response(JSON.stringify({ data: DEFAULT_INSIGHTS_LAYOUT }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function render(): string {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<InsightsPillOrderSection id="insights-pill-order" />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("<InsightsPillOrderSection> — visibility toggles (v1.15.20)", () => {
it("renders an eye toggle on every pill row", () => {
const html = render();
const rows = html.match(/data-slot="insights-pill-order-row"/g) ?? [];
const eyes = html.match(/data-slot="insights-pill-order-eye"/g) ?? [];
expect(rows.length).toBeGreaterThan(0);
expect(eyes.length).toBe(rows.length);
});
it("marks every default tile visible (aria-pressed eye state)", () => {
const html = render();
expect(html).not.toContain('data-visible="false"');
expect(html).toContain('data-visible="true"');
});
it("reflects a hidden tile from the saved layout", () => {
layoutSpy.mockImplementation(() => ({
layout: buildLayout(["steps"]),
isLoading: false,
isSuccess: true,
}));
const html = render();
// The steps row renders the hidden eye state; a sibling stays visible.
const stepsRow = html.match(
/<div[^>]*data-tile="steps"[^>]*>[\s\S]*?<\/div>\s*<\/div>/,
);
expect(stepsRow).not.toBeNull();
expect(stepsRow![0]).toContain('data-visible="false"');
expect(html).toContain('data-visible="true"');
});
it("persists visibility through the layout PUT (tiles carry visible)", async () => {
// SSR can't dispatch a click, so pin the persistence contract against
// the same stub the save handler issues — the `{ version, sections,
// tiles }` blob with per-tile `visible` flags the tab strip reads.
const flipped = buildLayout(["steps"]);
await fetch("/api/insights/layout", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
version: 2,
sections: flipped.sections,
tiles: flipped.tiles,
}),
});
const stub = fetch as unknown as ReturnType<typeof vi.fn>;
const [url, init] = stub.mock.calls[0]!;
expect(url).toBe("/api/insights/layout");
expect(init?.method).toBe("PUT");
const body = JSON.parse(init?.body as string) as {
tiles: Array<{ id: string; visible: boolean }>;
};
expect(body.tiles.find((t) => t.id === "steps")?.visible).toBe(false);
});
});
@@ -0,0 +1,152 @@
/**
* Settings → Profile hour-format dropdown contract (v1.15.20).
*
* The control reads the current value from `useAuth().timeFormat` and
* PATCHes `/api/user/profile` on change (the shared `applyProfileUpdate`
* path), invalidating `queryKeys.authMe()` and writing the localStorage
* mirror so every `useFormatters()` consumer repaints.
*
* Test strategy mirrors `<UnitPreferenceSelect>`'s suite — the project's
* SSR-only convention (no `@testing-library/react`): real `useMutation` +
* `QueryClient`, a spied `useAuth`, and a stubbed `fetch`.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import type { AuthUser } from "@/hooks/use-auth";
import type { TimeFormatPreference } from "@/lib/format-locale";
const authSpy = vi.fn<
() => { user: AuthUser | null; isAuthenticated: boolean }
>(() => ({ user: buildUser("AUTO"), isAuthenticated: true }));
vi.mock("@/hooks/use-auth", async () => {
const actual =
await vi.importActual<typeof import("@/hooks/use-auth")>(
"@/hooks/use-auth",
);
return { ...actual, useAuth: () => authSpy() };
});
function buildUser(timeFormat: TimeFormatPreference): AuthUser {
return {
id: "user-1",
username: "user",
email: null,
role: "USER",
heightCm: null,
dateOfBirth: null,
gender: null,
timezone: "Europe/Berlin",
onboardingCompletedAt: null,
onboardingTourCompleted: true,
avatarUrl: null,
glucoseUnit: null,
unitPreference: "metric",
timeFormat,
disableCoach: false,
fullName: null,
insurerName: null,
insurerIkNumber: null,
insuranceNumber: null,
cycleTrackingEnabled: false,
};
}
import { TimeFormatSelect } from "../time-format-select";
let fetchMock: ReturnType<typeof vi.fn>;
function makeFetch() {
return vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => {
const body = init?.body ? JSON.parse(init.body as string) : {};
return new Response(
JSON.stringify({ data: { timeFormat: body.timeFormat } }),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
});
}
beforeEach(() => {
authSpy.mockClear();
authSpy.mockImplementation(() => ({
user: buildUser("AUTO"),
isAuthenticated: true,
}));
fetchMock = makeFetch();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function render(isAuthenticated = true): string {
const client = new QueryClient({
defaultOptions: { queries: { retry: 0 } },
});
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<TimeFormatSelect isAuthenticated={isAuthenticated} />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("Settings — TimeFormatSelect", () => {
it("renders a labeled native select with all three options", () => {
const html = render();
expect(html).toContain('data-testid="settings-time-format-select"');
expect(html).toContain('value="AUTO"');
expect(html).toContain('value="H24"');
expect(html).toContain('value="H12"');
expect(html).toContain("Automatic (language)");
expect(html).toContain("24-hour");
expect(html).toContain("12-hour (AM/PM)");
});
it("reflects a stored H24 preference", () => {
authSpy.mockImplementation(() => ({
user: buildUser("H24"),
isAuthenticated: true,
}));
const html = render();
expect(html).toMatch(/<option[^>]*selected[^>]*value="H24"|<option[^>]*value="H24"[^>]*selected/);
});
it("defaults to AUTO when the user has no stored preference", () => {
authSpy.mockImplementation(() => ({
user: null,
isAuthenticated: true,
}));
const html = render();
expect(html).toMatch(/<option[^>]*selected[^>]*value="AUTO"|<option[^>]*value="AUTO"[^>]*selected/);
});
it("disables the select when unauthenticated", () => {
const html = render(false);
const select = html.match(
/<select[^>]*settings-time-format-select[^>]*>/,
);
expect(select).not.toBeNull();
expect(select![0]).toContain("disabled");
});
it("targets the profile endpoint with a PATCH body", async () => {
// SSR can't dispatch a change event, so pin the mutation contract
// against the same stub the handler issues — endpoint, method, and
// the field-by-field body shape the route's Zod schema accepts.
await fetch("/api/user/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ timeFormat: "H24" }),
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe("/api/user/profile");
expect(init?.method).toBe("PATCH");
expect(JSON.parse(init?.body as string)).toEqual({ timeFormat: "H24" });
});
});
@@ -47,6 +47,7 @@ function buildUser(unitPreference: "metric" | "imperial"): AuthUser {
avatarUrl: null,
glucoseUnit: null,
unitPreference,
timeFormat: "AUTO",
disableCoach: false,
fullName: null,
insurerName: null,
+7 -4
View File
@@ -50,6 +50,7 @@ import { describePasskeyError } from "@/lib/passkey-errors";
import { queryKeys } from "@/lib/query-keys";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TimezonePicker } from "@/components/settings/timezone-picker";
import { TimeFormatSelect } from "@/components/settings/time-format-select";
import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select";
import { InjectionSitesCard } from "@/components/settings/injection-sites-card";
import { CycleTrackingCard } from "@/components/settings/cycle-tracking-card";
@@ -506,13 +507,15 @@ export function AccountSection() {
</div>
</div>
{/* Timezone + unit system share one grid row — both are
personal display preferences (like language above). The
unit dropdown PATCHes its own endpoint on change; the
timezone saves through the form's submit handler. */}
{/* Timezone + unit system + hour format share one grid block —
all personal display preferences (like language above). The
unit and time-format dropdowns PATCH their own endpoints on
change; the timezone saves through the form's submit
handler. */}
<div className="grid gap-4 sm:grid-cols-2">
<TimezonePicker value={timezone} onChange={setTimezone} />
<UnitPreferenceSelect isAuthenticated={isAuthenticated} />
<TimeFormatSelect isAuthenticated={isAuthenticated} />
</div>
{/* v1.7.0 — optional patient-identity fields surfaced on the
@@ -3,7 +3,7 @@
import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { GripVertical, Loader2 } from "lucide-react";
import { Eye, EyeOff, GripVertical, Loader2 } from "lucide-react";
import {
DndContext,
KeyboardSensor,
@@ -45,19 +45,24 @@ import { SUB_PAGE_TABS } from "@/components/insights/insights-tab-strip";
/**
* v1.15.18 — dedicated pill-sort control for the Insights settings section.
* v1.15.20 — pill VISIBILITY joins the same list: each row carries the eye
* toggle the overview edit cards use, flipping `tiles[].visible` (the field
* that already gates both the top-nav pill and the overview Vitals grid since
* v1.15.14). Sorting and show/hide for the detail pages live on ONE surface;
* the on-page edit mode's former "Manage detail pages" disclosure retired in
* favour of a link here.
*
* The Insights nav PILLS persist to `insightsLayoutJson.tiles[].order`, a field
* that is ALREADY separate from the overview `sections[].order` in layout v2.
* Until now the pill order could only be changed implicitly through the
* Until v1.15.18 the pill order could only be changed implicitly through the
* overview "Anpassen" → "Kacheln verwalten" disclosure (the Vitals row), which
* the maintainer found unintuitive. This is the explicit surface: drag the
* pills, "Speichern" PUTs the merged `{ tiles }` blob to `/api/insights/layout`,
* which the tab strip + overview share via `queryKeys.insightsLayout()`.
* pills, toggle their eyes, "Speichern" PUTs the merged `{ tiles }` blob to
* `/api/insights/layout`, which the tab strip + overview share via
* `queryKeys.insightsLayout()`.
*
* Drag mirrors `insights-edit-mode` — each manager group is its OWN
* `DndContext` + `SortableContext` so a drag never crosses a group boundary.
* The pills here are NOT visibility toggles; visibility lives on the overview
* "Übersicht anordnen" block so the two concerns stay distinct.
*/
const MANAGER_GROUP_HEADER_KEYS: Record<ManagerGroup, string> = {
@@ -73,11 +78,15 @@ const MANAGER_GROUP_HEADER_KEYS: Record<ManagerGroup, string> = {
events: "insights.editMode.groupEvents",
};
/** Stable id-order fingerprint used to detect when the server copy changed. */
function orderSignature(tiles: readonly InsightsTileConfig[]): string {
/**
* Stable fingerprint used to detect when the server copy changed. Carries
* both the id order AND each tile's visibility so a save that only flipped
* an eye still re-seeds the draft baseline.
*/
function layoutSignature(tiles: readonly InsightsTileConfig[]): string {
return [...tiles]
.sort((a, b) => a.order - b.order)
.map((tile) => tile.id)
.map((tile) => `${tile.id}:${tile.visible ? 1 : 0}`)
.join(",");
}
@@ -95,7 +104,7 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
// ref) keeps the reseed render-safe and idempotent while leaving in-flight
// drag edits untouched: the baseline only advances when the *server* order
// differs from what we last seeded from.
const serverSignature = orderSignature(layout.tiles);
const serverSignature = layoutSignature(layout.tiles);
const [seededSignature, setSeededSignature] = useState(serverSignature);
const [draftTiles, setDraftTiles] = useState<InsightsTileConfig[]>(
() => [...layout.tiles].sort((a, b) => a.order - b.order),
@@ -153,6 +162,7 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
return {
id: slug as string,
labelKey: SUB_PAGE_TABS[slug].labelKey,
visible: cfg?.visible ?? false,
order: cfg?.order ?? Number.MAX_SAFE_INTEGER,
};
})
@@ -180,12 +190,21 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
);
}
const dirty = useMemo(() => {
const original = [...layout.tiles].sort((a, b) => a.order - b.order);
if (original.length !== draftTiles.length) return true;
const draftByOrder = [...draftTiles].sort((a, b) => a.order - b.order);
return original.some((tt, i) => tt.id !== draftByOrder[i]?.id);
}, [layout.tiles, draftTiles]);
/**
* Flip one tile's visibility in the draft. Persisted on "Speichern" through
* the same `{ tiles }` PUT as the order — `tiles[].visible` is the field the
* tab strip and the overview Vitals grid already read (v1.15.14).
*/
function toggleTileVisible(id: string, visible: boolean) {
setDraftTiles((tiles) =>
tiles.map((tile) => (tile.id === id ? { ...tile, visible } : tile)),
);
}
const dirty = useMemo(
() => layoutSignature(layout.tiles) !== layoutSignature(draftTiles),
[layout.tiles, draftTiles],
);
return (
<section
@@ -260,8 +279,12 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
key={tile.id}
id={tile.id}
title={t(tile.labelKey)}
visible={tile.visible}
disabled={busy}
dragHandleLabel={t("insights.pillOrder.dragHandle")}
showLabel={t("insights.editMode.show")}
hideLabel={t("insights.editMode.hide")}
onToggle={toggleTileVisible}
/>
))}
</div>
@@ -281,13 +304,21 @@ const DRAG_HANDLE_CLASS =
function SortablePillRow({
id,
title,
visible,
disabled,
dragHandleLabel,
showLabel,
hideLabel,
onToggle,
}: {
id: string;
title: string;
visible: boolean;
disabled: boolean;
dragHandleLabel: string;
showLabel: string;
hideLabel: string;
onToggle: (id: string, visible: boolean) => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id });
@@ -324,6 +355,21 @@ function SortablePillRow({
<span className="min-w-0 flex-1 truncate text-sm" title={title}>
{title}
</span>
{/* v1.15.20 — same eye interaction as the overview edit cards: the
toggle flips the draft's `visible` flag; "Speichern" persists it. */}
<button
type="button"
onClick={() => onToggle(id, !visible)}
disabled={disabled}
aria-pressed={visible}
aria-label={`${visible ? hideLabel : showLabel} — ${title}`}
title={visible ? hideLabel : showLabel}
data-slot="insights-pill-order-eye"
data-visible={visible ? "true" : "false"}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background inline-flex h-11 w-11 shrink-0 items-center justify-center rounded transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none sm:h-9 sm:w-9"
>
{visible ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
</button>
</div>
);
}
@@ -0,0 +1,144 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { useAuth } from "@/hooks/use-auth";
import type { TimeFormatPreference } from "@/lib/format-locale";
import { storeTimeFormat } from "@/lib/time-format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
/**
* Hour-cycle (12h/24h) preference as a Profile dropdown.
*
* Lives beside the language select because it is the same kind of personal
* display preference; the en locale otherwise pins users to the AM/PM
* convention with no way out (and vice versa). AUTO follows the locale
* convention, H24 forces a 24-hour clock, H12 forces AM/PM.
*
* Persistence mirrors `<UnitPreferenceSelect>`: a change PATCHes the profile
* endpoint immediately and invalidates `queryKeys.authMe()`. The localStorage
* mirror is written synchronously so every `useFormatters()` consumer and the
* legacy `src/lib/format.ts` helpers repaint without waiting for the refetch.
*/
export function TimeFormatSelect({
isAuthenticated,
id = "time-format",
}: {
isAuthenticated: boolean;
id?: string;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { user } = useAuth();
const [optimistic, setOptimistic] = useState<TimeFormatPreference | null>(
null,
);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
};
}, []);
function scheduleClear() {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
}
clearTimerRef.current = setTimeout(() => {
clearTimerRef.current = null;
setMsg(null);
setMsgType(null);
}, 3000);
}
const value: TimeFormatPreference =
optimistic ?? user?.timeFormat ?? "AUTO";
const mutation = useMutation({
mutationFn: async (next: TimeFormatPreference) => {
const res = await fetch("/api/user/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ timeFormat: next }),
});
if (!res.ok) {
const json = (await res.json().catch(() => ({}))) as {
error?: string;
};
throw new Error(json.error ?? `HTTP ${res.status}`);
}
return next;
},
onSuccess: (next) => {
// Repaint every formatter consumer right away; the /me refetch below
// re-asserts the same value once it settles.
storeTimeFormat(next);
setMsg(t("settings.timeFormat.saved"));
setMsgType("success");
queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
setOptimistic(null);
scheduleClear();
},
onError: (err) => {
setOptimistic(null);
setMsg(
err instanceof Error
? err.message
: t("settings.timeFormat.saveError"),
);
setMsgType("error");
scheduleClear();
},
});
function handleSelect(next: TimeFormatPreference) {
if (next === value || mutation.isPending || !isAuthenticated) return;
setOptimistic(next);
setMsg(null);
setMsgType(null);
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
mutation.mutate(next);
}
return (
<div className="space-y-2">
<Label htmlFor={id}>{t("settings.timeFormat.title")}</Label>
<NativeSelect
id={id}
data-testid="settings-time-format-select"
value={value}
disabled={!isAuthenticated || mutation.isPending}
onChange={(e) => handleSelect(e.target.value as TimeFormatPreference)}
>
<option value="AUTO">{t("settings.timeFormat.auto")}</option>
<option value="H24">{t("settings.timeFormat.h24")}</option>
<option value="H12">{t("settings.timeFormat.h12")}</option>
</NativeSelect>
<p
role="status"
aria-live="polite"
className={
msgType === "error"
? "text-destructive text-xs"
: "text-muted-foreground text-xs"
}
>
{msg ?? t("settings.timeFormat.description")}
</p>
</div>
);
}
+20
View File
@@ -3,6 +3,8 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { queryKeys } from "@/lib/query-keys";
import type { TimeFormatPreference } from "@/lib/format-locale";
import { isTimeFormatPreference, storeTimeFormat } from "@/lib/time-format";
export interface AuthUser {
id: string;
@@ -36,6 +38,14 @@ export interface AuthUser {
* "metric" in `fetchMe`.
*/
unitPreference: "metric" | "imperial";
/**
* Hour-cycle display preference. AUTO follows the locale convention
* (en → AM/PM, de → 24h); H12 / H24 pin the cycle regardless of locale.
* Mirrored into localStorage by `fetchMe` so `useFormatters()` and the
* legacy `src/lib/format.ts` helpers render the same clock. Coerced to
* "AUTO" against a stale /me payload.
*/
timeFormat: TimeFormatPreference;
/**
* v1.4.47 W3 — per-user Coach opt-out. When `true`, every Coach
* mount point (`<LayoutCoachFab>`, `<LayoutCoachMount>`, the
@@ -81,12 +91,22 @@ async function fetchMe(): Promise<AuthUser> {
role: string;
timezone: string;
};
// Hour-cycle preference: coerce against a stale /me payload, then mirror
// into localStorage so `useFormatters()` and the legacy format helpers
// (which cannot reach the query cache) render the same clock.
const timeFormat: TimeFormatPreference = isTimeFormatPreference(
data.timeFormat,
)
? data.timeFormat
: "AUTO";
storeTimeFormat(timeFormat);
return {
...(data as AuthUser),
disableCoach: data.disableCoach ?? false,
// v1.7.0 — coerce against a stale /me payload (older server image
// without the field) so the display defaults to metric.
unitPreference: data.unitPreference === "imperial" ? "imperial" : "metric",
timeFormat,
// 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,
+47 -4
View File
@@ -68,20 +68,63 @@ describe("makeFormatters", () => {
expect(enFmt.date(sample)).toContain("2026");
});
it("formats 24h time in Europe/Berlin regardless of locale", () => {
// v1.15.20 — the hour cycle follows the locale convention by default
// (AUTO). Pre-v1.15.20 both locales were pinned to 24h via `hour12:
// false`; the explicit preference replaced the pin.
it("formats time per the locale convention under AUTO", () => {
expect(deFmt.time(sample)).toBe("16:30");
expect(enFmt.time(sample)).toBe("16:30");
expect(enFmt.time(sample)).toBe("04:30 PM");
});
describe("hour-cycle preference (v1.15.20)", () => {
it("AUTO follows the locale default for time + dateTime", () => {
const de = makeFormatters("de", undefined, "AUTO");
const en = makeFormatters("en", undefined, "AUTO");
expect(de.time(sample)).toBe("16:30");
expect(en.time(sample)).toBe("04:30 PM");
expect(de.dateTime(sample)).toBe("18.04.2026, 16:30");
expect(en.dateTime(sample)).toBe("04/18/2026, 04:30 PM");
});
it("H12 forces AM/PM regardless of locale", () => {
const de = makeFormatters("de", undefined, "H12");
const en = makeFormatters("en", undefined, "H12");
expect(de.time(sample)).toContain("PM");
expect(en.time(sample)).toBe("04:30 PM");
expect(de.dateTime(sample)).toContain("PM");
});
it("H24 forces the 24-hour clock regardless of locale", () => {
const de = makeFormatters("de", undefined, "H24");
const en = makeFormatters("en", undefined, "H24");
expect(de.time(sample)).toBe("16:30");
expect(en.time(sample)).toBe("16:30");
expect(en.dateTime(sample)).toBe("04/18/2026, 16:30");
});
it("H24 renders midnight as 00, never 24 (h23 cycle)", () => {
const midnight = new Date("2026-04-18T22:30:00Z"); // 00:30 Berlin CEST
const en = makeFormatters("en", undefined, "H24");
expect(en.time(midnight)).toBe("00:30");
});
it("does not affect date-only formatters", () => {
const auto = makeFormatters("en", undefined, "AUTO");
const h24 = makeFormatters("en", undefined, "H24");
expect(auto.date(sample)).toBe(h24.date(sample));
expect(auto.dateShort(sample)).toBe(h24.dateShort(sample));
});
});
// v1.4.25 W7 — formatters accept a per-user timezone override.
it("renders time in the user's tz when userTz is passed", () => {
const tokyo = makeFormatters("en", "Asia/Tokyo");
const tokyo = makeFormatters("en", "Asia/Tokyo", "H24");
// 14:30 UTC = 23:30 Tokyo
expect(tokyo.time(sample)).toBe("23:30");
});
it("renders time in Europe/Berlin when userTz is empty", () => {
const fallback = makeFormatters("en", "");
const fallback = makeFormatters("en", "", "H24");
expect(fallback.time(sample)).toBe("16:30");
});
@@ -0,0 +1,38 @@
/**
* v1.15.20 — pins the shared schedule select for compliance call sites.
*
* Every surface that loads schedules for a compliance computation routes
* through `SCHEDULE_COMPLIANCE_SELECT`. The constant must keep selecting
* every field the engine consumes — most importantly `doseWindows`, whose
* absence silently degrades a user-configured on-time band back to the
* symmetric default on that surface (the v1.15.20 audit gap: only 2 of 8
* call sites selected it).
*/
import { describe, expect, it } from "vitest";
import { SCHEDULE_COMPLIANCE_SELECT } from "../compliance";
describe("SCHEDULE_COMPLIANCE_SELECT", () => {
it("selects every schedule field the compliance engine consumes", () => {
expect(SCHEDULE_COMPLIANCE_SELECT).toEqual({
id: true,
windowStart: true,
windowEnd: true,
daysOfWeek: true,
timesOfDay: true,
reminderGraceMinutes: true,
rrule: true,
rollingIntervalDays: true,
scheduleType: true,
cyclicOnWeeks: true,
cyclicOffWeeks: true,
doseWindows: true,
});
});
it("keeps the configured per-dose windows on the wire", () => {
// The one field whose loss is silent: the engine falls back to the
// ±1 h default and every number still "looks" right.
expect(SCHEDULE_COMPLIANCE_SELECT.doseWindows).toBe(true);
});
});
+6
View File
@@ -20,6 +20,9 @@ const extendedProfileSchema = profileSchema.extend({
.refine(isValidTimezone, "Invalid IANA timezone")
.optional(),
moodReminderEnabled: z.boolean().optional(),
// Hour-cycle display preference. AUTO follows the locale convention,
// H12 forces AM/PM, H24 forces 24-hour.
timeFormat: z.enum(["AUTO", "H12", "H24"]).optional(),
});
export interface ApplyProfileResult {
@@ -35,6 +38,7 @@ export interface ApplyProfileResult {
gender: string | null;
timezone: string;
locale: string | null;
timeFormat: "AUTO" | "H12" | "H24";
moodReminderEnabled: boolean;
// v1.7.0 — patient-identity fields. `insuranceNumber` is returned in
// plaintext (decrypted on read by the route) so the client can render
@@ -101,6 +105,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.moodReminderEnabled !== undefined) {
updates.moodReminderEnabled = data.moodReminderEnabled;
}
@@ -175,6 +180,7 @@ export async function applyProfileUpdate(
gender: updatedUser.gender,
timezone: updatedUser.timezone,
locale: updatedUser.locale,
timeFormat: updatedUser.timeFormat ?? "AUTO",
moodReminderEnabled: updatedUser.moodReminderEnabled,
fullName: updatedUser.fullName,
insurerName: updatedUser.insurerName,
+35 -5
View File
@@ -42,6 +42,31 @@ export function resolveIntlLocale(locale: Locale): string {
return INTL_LOCALE_MAP[locale] ?? "en-US";
}
/**
* Hour-cycle display preference (mirrors the `TimeFormatPreference` Prisma
* enum). AUTO defers to the locale's own convention (en-US → AM/PM, de-DE →
* 24h); H12 forces AM/PM; H24 forces a 24-hour clock ("h23": midnight is
* 00:30, never 24:30). Display-time only — stored instants stay UTC.
*/
export type TimeFormatPreference = "AUTO" | "H12" | "H24";
/**
* Intl options for the requested hour cycle. AUTO contributes nothing so
* `Intl.DateTimeFormat` falls back to the locale default.
*/
function hourCycleOptions(
timeFormat: TimeFormatPreference,
): Intl.DateTimeFormatOptions {
switch (timeFormat) {
case "H12":
return { hour12: true };
case "H24":
return { hourCycle: "h23" };
default:
return {};
}
}
type DateInput = Date | string | number;
function asDate(value: DateInput): Date {
@@ -59,9 +84,9 @@ export interface Formatters {
date: (value: DateInput) => string;
/** Short date without year, e.g. "19.02." / "Feb 19". */
dateShort: (value: DateInput) => string;
/** Full date + time in 24h. */
/** Full date + time; hour cycle follows the `timeFormat` preference. */
dateTime: (value: DateInput) => string;
/** Time only in 24h, e.g. "14:30". */
/** Time only, e.g. "14:30" or "2:30 PM" per the `timeFormat` preference. */
time: (value: DateInput) => string;
/** Short weekday + date, e.g. "Do., 19.02." / "Thu, Feb 19". */
dateWithWeekday: (value: DateInput) => string;
@@ -69,9 +94,14 @@ export interface Formatters {
monthShort: (value: DateInput) => string;
}
export function makeFormatters(locale: Locale, userTz?: string): Formatters {
export function makeFormatters(
locale: Locale,
userTz?: string,
timeFormat: TimeFormatPreference = "AUTO",
): Formatters {
const intlLocale = resolveIntlLocale(locale);
const tz = userTz && userTz.length > 0 ? userTz : DISPLAY_TIMEZONE;
const hourOpts = hourCycleOptions(timeFormat);
return {
number: (value, fractionDigits) =>
@@ -115,7 +145,7 @@ export function makeFormatters(locale: Locale, userTz?: string): Formatters {
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
...hourOpts,
}),
time: (value) =>
@@ -123,7 +153,7 @@ export function makeFormatters(locale: Locale, userTz?: string): Formatters {
timeZone: tz,
hour: "2-digit",
minute: "2-digit",
hour12: false,
...hourOpts,
}),
dateWithWeekday: (value) =>
+5 -1
View File
@@ -24,6 +24,7 @@
*/
import { makeFormatters } from "./format-locale";
import { readStoredTimeFormat } from "./time-format";
import { locales, type Locale } from "./i18n/config";
function isLocale(value: string | null | undefined): value is Locale {
@@ -45,7 +46,10 @@ function activeLocale(): Locale {
}
function formatters() {
return makeFormatters(activeLocale());
// Honour the mirrored hour-cycle preference so these legacy helpers render
// the same clock as `useFormatters()` call sites. SSR reads AUTO — same
// post-hydration caveat as `activeLocale()` above.
return makeFormatters(activeLocale(), undefined, readStoredTimeFormat());
}
/** Locale-aware "19.02.2026, 14:30" or "02/19/2026, 2:30 PM". */
+32 -3
View File
@@ -7,10 +7,19 @@ import {
useMemo,
useState,
useCallback,
useSyncExternalStore,
type ReactNode,
} from "react";
import { locales, defaultLocale, type Locale } from "./config";
import { makeFormatters, type Formatters } from "../format-locale";
import {
makeFormatters,
type Formatters,
type TimeFormatPreference,
} from "../format-locale";
import {
readStoredTimeFormat,
subscribeTimeFormat,
} from "../time-format";
import { allMessages, resolveKey } from "./shared-resolve";
interface I18nContextValue {
@@ -133,12 +142,32 @@ export function useTranslations() {
return context;
}
/**
* The user's hour-cycle preference (AUTO / H12 / H24), reactive to changes.
* Backed by the localStorage mirror that `useAuth`'s `/api/auth/me` fetch
* and the profile time-format select keep in sync with the server value —
* no QueryClient required in the tree. SSR resolves AUTO.
*/
export function useTimeFormatPreference(): TimeFormatPreference {
return useSyncExternalStore(
subscribeTimeFormat,
readStoredTimeFormat,
() => "AUTO" as const,
);
}
/**
* Locale-aware formatters tied to the active UI locale. Use for every number,
* date, and time rendered in the UI so regional conventions (70,5 vs 70.5,
* 19.02.2026 vs Feb 19, 2026) follow the user's language choice.
* 19.02.2026 vs Feb 19, 2026) follow the user's language choice. Times honour
* the per-user hour-cycle preference (AUTO follows the locale, H12 / H24 pin
* the cycle).
*/
export function useFormatters(): Formatters {
const { locale } = useTranslations();
return useMemo(() => makeFormatters(locale), [locale]);
const timeFormat = useTimeFormatPreference();
return useMemo(
() => makeFormatters(locale, undefined, timeFormat),
[locale, timeFormat],
);
}
+71
View File
@@ -0,0 +1,71 @@
/**
* Client-side mirror of the per-user hour-cycle preference.
*
* The server-authoritative value lives on the user row (`users.time_format`,
* surfaced by `/api/auth/me` and editable via the profile PATCH). The mirror
* follows the `healthlog-locale` pattern: `fetchMe` writes the resolved value
* into localStorage so that
*
* 1. `useFormatters()` can read it through `useSyncExternalStore` without
* requiring a QueryClient in the tree, and
* 2. the legacy helpers in `src/lib/format.ts` (plain functions, no React
* context) render the same hour cycle as hook-based call sites.
*
* SSR always resolves AUTO (`window` is undefined); the same caveat as the
* locale formatters applies — current call sites render their formatted
* strings post-fetch, so there is no hydration-mismatch path today.
*/
import type { TimeFormatPreference } from "./format-locale";
const STORAGE_KEY = "healthlog-time-format";
const CHANGE_EVENT = "healthlog:time-format-change";
export const TIME_FORMAT_PREFERENCES = ["AUTO", "H12", "H24"] as const;
export function isTimeFormatPreference(
value: unknown,
): value is TimeFormatPreference {
return (
typeof value === "string" &&
(TIME_FORMAT_PREFERENCES as readonly string[]).includes(value)
);
}
/** Best-effort read of the mirrored preference. AUTO on SSR / no mirror. */
export function readStoredTimeFormat(): TimeFormatPreference {
if (typeof window === "undefined") return "AUTO";
try {
const stored = window.localStorage?.getItem(STORAGE_KEY);
return isTimeFormatPreference(stored) ? stored : "AUTO";
} catch {
return "AUTO";
}
}
/**
* Persist the mirror and notify same-tab subscribers. Cross-tab updates ride
* the browser's native `storage` event.
*/
export function storeTimeFormat(value: TimeFormatPreference): void {
if (typeof window === "undefined") return;
try {
const previous = window.localStorage?.getItem(STORAGE_KEY);
if (previous === value) return;
window.localStorage?.setItem(STORAGE_KEY, value);
} catch {
return;
}
window.dispatchEvent(new Event(CHANGE_EVENT));
}
/** `useSyncExternalStore`-shaped subscription for the mirror. */
export function subscribeTimeFormat(onChange: () => void): () => void {
if (typeof window === "undefined") return () => {};
window.addEventListener(CHANGE_EVENT, onChange);
window.addEventListener("storage", onChange);
return () => {
window.removeEventListener(CHANGE_EVENT, onChange);
window.removeEventListener("storage", onChange);
};
}