diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 85602e835..81b9333fa 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/messages/de.json b/messages/de.json index 4026a4aa4..1bd0337a4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index ac85b895c..12ea7fd8a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 07da460d5..173fa8525 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index d6020b9c1..362d7cd9d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 72394ccb9..eb842daf4 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 632887aa3..65f77219c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/prisma/migrations/0138_v11520_time_format/migration.sql b/prisma/migrations/0138_v11520_time_format/migration.sql new file mode 100644 index 000000000..27506091e --- /dev/null +++ b/prisma/migrations/0138_v11520_time_format/migration.sql @@ -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'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4ced5f92e..dbd24fb9e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 22b43748f..429d331d8 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -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 diff --git a/src/app/api/user/profile/__tests__/route.test.ts b/src/app/api/user/profile/__tests__/route.test.ts index 82b9c1b4b..26607d210 100644 --- a/src/app/api/user/profile/__tests__/route.test.ts +++ b/src/app/api/user/profile/__tests__/route.test.ts @@ -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(); + }); }); diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts index 1722858a5..cf799b456 100644 --- a/src/app/api/user/profile/route.ts +++ b/src/app/api/user/profile/route.ts @@ -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, diff --git a/src/components/insights/__tests__/insights-edit-mode-manager.test.tsx b/src/components/insights/__tests__/insights-edit-mode-manager.test.tsx index 1c389a778..b3f9c531c 100644 --- a/src/components/insights/__tests__/insights-edit-mode-manager.test.tsx +++ b/src/components/insights/__tests__/insights-edit-mode-manager.test.tsx @@ -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(" — broadened sub-page manager (v1.15.14 W2)", () => { +describe(" — detail-page manager retired (v1.15.20)", () => { const noneGated: ReadonlySet = new Set(); - it("relabels the Vitals disclosure to 'Manage detail pages'", () => { - const html = render( + function renderEditMode(): string { + return render( {}} />, ); - 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 & 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"'); }); }); diff --git a/src/components/insights/insights-edit-mode.tsx b/src/components/insights/insights-edit-mode.tsx index 6fe2599ec..de0491978 100644 --- a/src/components/insights/insights-edit-mode.tsx +++ b/src/components/insights/insights-edit-mode.tsx @@ -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 = { "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.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 = { - 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(() => ({ 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( - 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({
{sections.map((section) => { const gatedOff = gatedOffSectionIds.has(section.id); - const isVitals = section.id === "vitals"; return ( - {isVitals && ( -
- - {/* 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. */} -

- {t("insights.editMode.manageSubPagesHint")} -

- {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. -
- {tilesByGroup.map(({ group, rows }) => ( -
-

- {t(MANAGER_GROUP_HEADER_KEYS[group])} -

- - handleGroupTileDragEnd(group, event) - } - > - r.id)} - strategy={verticalListSortingStrategy} - > -
- {rows.map((tile) => ( - - ))} -
-
-
-
- ))} -
- )} -
- )} -
+ /> ); })}
+ + {/* 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. */} +

+ + {t("insights.editMode.manageInSettings")} + +

); } @@ -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" /> - {children} ); } -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 ( -
- - - {title} - - onToggle(id, !visible)} - slot="insights-edit-tile-eye" - /> -
- ); -} diff --git a/src/components/settings/__tests__/insights-pill-order-visibility.test.tsx b/src/components/settings/__tests__/insights-pill-order-visibility.test.tsx new file mode 100644 index 000000000..1b0d65a64 --- /dev/null +++ b/src/components/settings/__tests__/insights-pill-order-visibility.test.tsx @@ -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( + + + + + , + ); +} + +describe(" — 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( + /]*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; + 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); + }); +}); diff --git a/src/components/settings/__tests__/time-format-select.test.tsx b/src/components/settings/__tests__/time-format-select.test.tsx new file mode 100644 index 000000000..8398feecf --- /dev/null +++ b/src/components/settings/__tests__/time-format-select.test.tsx @@ -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 ``'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( + "@/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; + +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( + + + + + , + ); +} + +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(/]*selected[^>]*value="H24"|]*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(/]*selected[^>]*value="AUTO"|]*value="AUTO"[^>]*selected/); + }); + + it("disables the select when unauthenticated", () => { + const html = render(false); + const select = html.match( + /]*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" }); + }); +}); diff --git a/src/components/settings/__tests__/unit-preference-select.test.tsx b/src/components/settings/__tests__/unit-preference-select.test.tsx index 6e6583eef..6c01150ee 100644 --- a/src/components/settings/__tests__/unit-preference-select.test.tsx +++ b/src/components/settings/__tests__/unit-preference-select.test.tsx @@ -47,6 +47,7 @@ function buildUser(unitPreference: "metric" | "imperial"): AuthUser { avatarUrl: null, glucoseUnit: null, unitPreference, + timeFormat: "AUTO", disableCoach: false, fullName: null, insurerName: null, diff --git a/src/components/settings/account-section.tsx b/src/components/settings/account-section.tsx index 117dfbc23..6f905d144 100644 --- a/src/components/settings/account-section.tsx +++ b/src/components/settings/account-section.tsx @@ -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() { - {/* 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. */}
+
{/* v1.7.0 — optional patient-identity fields surfaced on the diff --git a/src/components/settings/insights-pill-order-section.tsx b/src/components/settings/insights-pill-order-section.tsx index 20ab000fd..f6add9fad 100644 --- a/src/components/settings/insights-pill-order-section.tsx +++ b/src/components/settings/insights-pill-order-section.tsx @@ -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 = { @@ -73,11 +78,15 @@ const MANAGER_GROUP_HEADER_KEYS: Record = { 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( () => [...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 (
))} @@ -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({ {title} + {/* v1.15.20 — same eye interaction as the overview edit cards: the + toggle flips the draft's `visible` flag; "Speichern" persists it. */} + ); } diff --git a/src/components/settings/time-format-select.tsx b/src/components/settings/time-format-select.tsx new file mode 100644 index 000000000..67edd7da3 --- /dev/null +++ b/src/components/settings/time-format-select.tsx @@ -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 ``: 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( + null, + ); + const [msg, setMsg] = useState(null); + const [msgType, setMsgType] = useState<"success" | "error" | null>(null); + const clearTimerRef = useRef | 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 ( +
+ + handleSelect(e.target.value as TimeFormatPreference)} + > + + + + +

+ {msg ?? t("settings.timeFormat.description")} +

+
+ ); +} diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index cab9c0341..27a4f8ad7 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -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 (``, ``, the @@ -81,12 +91,22 @@ async function fetchMe(): Promise { 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, diff --git a/src/lib/__tests__/format-locale.test.ts b/src/lib/__tests__/format-locale.test.ts index 2289ec75a..e8f727b0c 100644 --- a/src/lib/__tests__/format-locale.test.ts +++ b/src/lib/__tests__/format-locale.test.ts @@ -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"); }); diff --git a/src/lib/analytics/__tests__/schedule-compliance-select.test.ts b/src/lib/analytics/__tests__/schedule-compliance-select.test.ts new file mode 100644 index 000000000..40f603ba2 --- /dev/null +++ b/src/lib/analytics/__tests__/schedule-compliance-select.test.ts @@ -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); + }); +}); diff --git a/src/lib/auth/profile-update.ts b/src/lib/auth/profile-update.ts index e2901c223..9c2eea443 100644 --- a/src/lib/auth/profile-update.ts +++ b/src/lib/auth/profile-update.ts @@ -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, diff --git a/src/lib/format-locale.ts b/src/lib/format-locale.ts index 6de1643df..b57bb591c 100644 --- a/src/lib/format-locale.ts +++ b/src/lib/format-locale.ts @@ -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) => diff --git a/src/lib/format.ts b/src/lib/format.ts index aa4633746..021b90af9 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -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". */ diff --git a/src/lib/i18n/context.tsx b/src/lib/i18n/context.tsx index 06d80c3fa..2158e2405 100644 --- a/src/lib/i18n/context.tsx +++ b/src/lib/i18n/context.tsx @@ -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], + ); } diff --git a/src/lib/time-format.ts b/src/lib/time-format.ts new file mode 100644 index 000000000..f9863d9c7 --- /dev/null +++ b/src/lib/time-format.ts @@ -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); + }; +}