mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
merge(W3): product clarity and coherence
End the AI dead-end — onboarding gains a skippable provider card and the Coach/Insights/Briefing surfaces explain what the AI does and why a provider is needed instead of a bare error. Extract buildDashboardBands so band/target math is one server-side source the page fallback reuses. Surface Rest Mode on the score card, restore the module-hub intro, link the glucose panel to Labs, and route the remaining day-key copies through the canonical helper.
This commit is contained in:
commit
56b6d5b998
21 files changed
+408
-260
No files matched your search
+12
-4
@@ -414,7 +414,7 @@
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Messungen",
|
||||
"subtitle": "Gewicht, Blutdruck, Puls und mehr",
|
||||
"subtitle": "Werte erfassen und die vollständige Historie einsehen. Trends und Auswertungen findest du unter Auswertungen.",
|
||||
"addMeasurement": "Hinzufügen",
|
||||
"editMeasurement": "Messwert bearbeiten",
|
||||
"type": "Typ",
|
||||
@@ -1892,7 +1892,8 @@
|
||||
"bmiEmptyAction": "Account-Einstellungen öffnen",
|
||||
"noAnalysisYet": "Noch keine Einschätzung vorhanden.",
|
||||
"assessmentPreparing": "Einschätzung wird vorbereitet…",
|
||||
"noProviderConfigured": "Einschätzung nicht verfügbar.",
|
||||
"noProviderConfigured": "Verbinde einen KI-Anbieter, um hier eine Einschätzung zu deinen Werten zu sehen.",
|
||||
"noProviderAction": "Zu den KI-Einstellungen",
|
||||
"startAnalysis": "Einschätzung starten",
|
||||
"refreshAnalysis": "Einschätzung aktualisieren",
|
||||
"keyTakeaway": "Das Wichtigste",
|
||||
@@ -1965,6 +1966,7 @@
|
||||
"progressAria": "Gesundheitsscore-Fortschritt",
|
||||
"deltaVsLastWeek": "{delta} im Vergleich zur Vorwoche",
|
||||
"deltaUnavailable": "Noch keine Historie — schau nächste Woche wieder vorbei.",
|
||||
"restModePaused": "Während einer Krankheitsphase pausiert — dein Score wird heute nicht bewertet.",
|
||||
"componentBp": "Blutdruck",
|
||||
"componentWeight": "Gewicht",
|
||||
"componentMood": "Stimmung",
|
||||
@@ -2007,7 +2009,7 @@
|
||||
"emptyAction": "Briefing erstellen",
|
||||
"loadingLabel": "Briefing wird zusammengestellt",
|
||||
"noProviderTitle": "Kein KI-Anbieter verbunden",
|
||||
"noProviderDescription": "Verbinde einen KI-Anbieter in den Einstellungen, um tägliche Briefings zu erhalten.",
|
||||
"noProviderDescription": "Das Tagesbriefing fasst deine Werte in Klartext zusammen. Dafür braucht HealthLog einen KI-Anbieter — nutze deinen eigenen Schlüssel (OpenAI, Anthropic) oder ein lokales Modell. Deine Daten bleiben dabei bei dir.",
|
||||
"noProviderAction": "Zu den KI-Einstellungen"
|
||||
},
|
||||
"trendsRow": {
|
||||
@@ -2072,6 +2074,8 @@
|
||||
"refusalInjection": "In deiner Nachricht stehen Anweisungen, die meine Vorgaben überschreiben sollen. Ich bin der HealthLog-Coach und fasse ausschließlich deine eigenen Gesundheitsdaten zusammen — formuliere deine Frage bitte in diesem Rahmen neu.",
|
||||
"errorBudget": "Du hast das heutige Coach-Limit erreicht. Das Budget setzt sich um Mitternacht (UTC) zurück.",
|
||||
"errorProvider": "Der Coach konnte gerade keinen Auswertungs-Anbieter erreichen. Bitte versuche es in einem Moment erneut.",
|
||||
"errorNoProvider": "Der Coach liest deine erfassten Gesundheitsdaten und beantwortet Fragen dazu (keine medizinische Beratung). Dafür braucht HealthLog einen KI-Anbieter — verbinde deinen eigenen Schlüssel oder ein lokales Modell.",
|
||||
"errorNoProviderAction": "KI-Anbieter verbinden",
|
||||
"errorNetwork": "Keine Internetverbindung — versuche es erneut, sobald du online bist.",
|
||||
"prefsSaveError": "Coach-Einstellungen konnten nicht gespeichert werden. Bitte versuche es erneut.",
|
||||
"errorCredentialExpired": "Deine Verbindung zum KI-Anbieter ist abgelaufen. Stelle sie in den Einstellungen wieder her, damit der Coach weiterläuft.",
|
||||
@@ -2709,6 +2713,7 @@
|
||||
"title": "Glukose-Überblick",
|
||||
"subtitle": "Ein Blick auf deine letzten {days} Tage, aus {count} Messungen.",
|
||||
"spotCaveat": "Das sind Schätzungen aus einzelnen Stichproben-Messungen, kein Profil eines kontinuierlichen Sensors – lies sie als Richtung, nicht als klinisches Urteil.",
|
||||
"labsLink": "Laborergebnisse? HbA1c und Nüchternglukose aus einer Blutabnahme trägst du unter Labor ein.",
|
||||
"learningTitle": "Lerne deine Glukose noch kennen",
|
||||
"learningBody": "Bisher sind das {count} über {days}. Mit etwas mehr Verlauf wird das Bild klarer – mess weiter oder lass es ein verbundenes Gerät ergänzen.",
|
||||
"learningBodyEmpty": "Sobald ein paar Messungen vorliegen – von dir erfasst oder von einem Gerät synchronisiert – nehmen Zeit-im-Zielbereich, Durchschnitt und Schwankung hier Gestalt an.",
|
||||
@@ -3623,7 +3628,10 @@
|
||||
"learning": "Manche Einblicke – Glukose, Schlafrhythmus, Blutdrucktrends, Zyklus – schärfen sich in den ersten ein bis zwei Wochen, sobald sich deine Daten ansammeln. Leere Kacheln heißen jetzt nur, dass wir deine Basislinie noch lernen.",
|
||||
"connectCta": "Gerät verbinden",
|
||||
"logCta": "Ersten Wert eintragen",
|
||||
"importCta": "Bestehende Daten importieren"
|
||||
"importCta": "Bestehende Daten importieren",
|
||||
"aiTitle": "KI-Einblicke aktivieren (optional)",
|
||||
"aiBody": "Coach, Tagesbriefing und Einschätzungen fassen deine Werte in Klartext zusammen. Dafür verbindest du deinen eigenen KI-Schlüssel (OpenAI, Anthropic) oder ein lokales Modell — deine Daten bleiben bei dir.",
|
||||
"aiCta": "KI-Anbieter verbinden"
|
||||
},
|
||||
"welcomeBack": {
|
||||
"title": "Willkommen zurück",
|
||||
|
||||
+12
-4
@@ -414,7 +414,7 @@
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Measurements",
|
||||
"subtitle": "Weight, blood pressure, pulse and more",
|
||||
"subtitle": "Log readings and browse the full history. Trends and analysis live under Insights.",
|
||||
"addMeasurement": "Add",
|
||||
"editMeasurement": "Edit measurement",
|
||||
"type": "Type",
|
||||
@@ -1892,7 +1892,8 @@
|
||||
"bmiEmptyTitle": "Set your height to see BMI",
|
||||
"bmiEmptyDescription": "BMI needs your height. Add it under Settings → Account.",
|
||||
"bmiEmptyAction": "Open account settings",
|
||||
"noProviderConfigured": "Assessment unavailable.",
|
||||
"noProviderConfigured": "Connect an AI provider to see an assessment of your readings here.",
|
||||
"noProviderAction": "Open AI settings",
|
||||
"startAnalysis": "Start assessment",
|
||||
"refreshAnalysis": "Refresh assessment",
|
||||
"keyTakeaway": "Key Takeaway",
|
||||
@@ -1965,6 +1966,7 @@
|
||||
"progressAria": "Health Score progress",
|
||||
"deltaVsLastWeek": "{delta} vs last week",
|
||||
"deltaUnavailable": "No history yet — check back next week.",
|
||||
"restModePaused": "Paused during illness — your score isn't being judged today.",
|
||||
"componentBp": "BP",
|
||||
"componentWeight": "Weight",
|
||||
"componentMood": "Mood",
|
||||
@@ -2007,7 +2009,7 @@
|
||||
"emptyAction": "Generate briefing",
|
||||
"loadingLabel": "Synthesising today's briefing",
|
||||
"noProviderTitle": "No AI provider connected",
|
||||
"noProviderDescription": "Connect an AI provider in settings to receive daily briefings.",
|
||||
"noProviderDescription": "The daily briefing turns your readings into plain-language summary. HealthLog needs an AI provider for that — use your own key (OpenAI, Anthropic) or a local model. Your data stays with you.",
|
||||
"noProviderAction": "Open AI settings"
|
||||
},
|
||||
"trendsRow": {
|
||||
@@ -2072,6 +2074,8 @@
|
||||
"refusalInjection": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data — please rephrase your question in those terms.",
|
||||
"errorBudget": "You've reached today's Coach usage limit. The budget refreshes at midnight UTC.",
|
||||
"errorProvider": "The Coach could not reach an Insights provider just now. Please try again in a moment.",
|
||||
"errorNoProvider": "The Coach reads your logged health data and answers questions about it (not medical advice). HealthLog needs an AI provider for that — connect your own key or a local model.",
|
||||
"errorNoProviderAction": "Connect an AI provider",
|
||||
"errorNetwork": "No internet connection — try again once you're back online.",
|
||||
"prefsSaveError": "Couldn't save your Coach preferences. Please try again.",
|
||||
"errorCredentialExpired": "Your AI provider connection has expired. Reconnect it in Settings to keep the Coach running.",
|
||||
@@ -2709,6 +2713,7 @@
|
||||
"title": "Glucose overview",
|
||||
"subtitle": "A read on your last {days} days, from {count} readings.",
|
||||
"spotCaveat": "These are spot-reading estimates from individual measurements, not a continuous-monitor profile — read them as a direction of travel rather than a clinical verdict.",
|
||||
"labsLink": "Lab results? Log a blood-draw HbA1c or fasting glucose under Labs.",
|
||||
"learningTitle": "Still learning your glucose",
|
||||
"learningBody": "So far that's {count} over {days}. A little more history and a fuller picture comes into focus — keep logging, or let a connected device fill it in.",
|
||||
"learningBodyEmpty": "Once a handful of readings come in — logged by you or synced from a device — your time-in-range, average, and variability take shape here.",
|
||||
@@ -3623,7 +3628,10 @@
|
||||
"learning": "Some insights — glucose, sleep rhythm, blood-pressure trends, cycle — sharpen over the first week or two as your data builds up. Empty tiles now just mean we're still learning your baseline.",
|
||||
"connectCta": "Connect a device",
|
||||
"logCta": "Log your first measurement",
|
||||
"importCta": "Import existing history"
|
||||
"importCta": "Import existing history",
|
||||
"aiTitle": "Turn on AI Insights (optional)",
|
||||
"aiBody": "Coach, the daily briefing and assessments turn your readings into plain language. For that you connect your own AI key (OpenAI, Anthropic) or a local model — your data stays with you.",
|
||||
"aiCta": "Connect an AI provider"
|
||||
},
|
||||
"welcomeBack": {
|
||||
"title": "Welcome back",
|
||||
|
||||
+12
-4
@@ -414,7 +414,7 @@
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Mediciones",
|
||||
"subtitle": "Peso, tensión arterial, pulso y más",
|
||||
"subtitle": "Registra lecturas y consulta el historial completo. Las tendencias y los análisis están en Análisis.",
|
||||
"addMeasurement": "Añadir",
|
||||
"editMeasurement": "Editar medición",
|
||||
"type": "Tipo",
|
||||
@@ -1892,7 +1892,8 @@
|
||||
"bmiEmptyTitle": "Añade tu altura para calcular el IMC",
|
||||
"bmiEmptyDescription": "El IMC necesita tu altura. Guárdala en Ajustes → Cuenta.",
|
||||
"bmiEmptyAction": "Abrir ajustes de cuenta",
|
||||
"noProviderConfigured": "Valoración no disponible.",
|
||||
"noProviderConfigured": "Conecta un proveedor de IA para ver aquí una valoración de tus lecturas.",
|
||||
"noProviderAction": "Abrir ajustes de IA",
|
||||
"startAnalysis": "Iniciar valoración",
|
||||
"refreshAnalysis": "Actualizar valoración",
|
||||
"keyTakeaway": "Lo más importante",
|
||||
@@ -1965,6 +1966,7 @@
|
||||
"progressAria": "Progreso de la puntuación de salud",
|
||||
"deltaVsLastWeek": "{delta} respecto a la semana anterior",
|
||||
"deltaUnavailable": "Aún no hay historial — vuelve la semana que viene.",
|
||||
"restModePaused": "En pausa durante la enfermedad — hoy tu puntuación no se evalúa.",
|
||||
"componentBp": "PA",
|
||||
"componentWeight": "Peso",
|
||||
"componentMood": "Estado de ánimo",
|
||||
@@ -2007,7 +2009,7 @@
|
||||
"emptyAction": "Generar 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.",
|
||||
"noProviderDescription": "El briefing diario resume tus lecturas en lenguaje claro. HealthLog necesita un proveedor de IA para ello: usa tu propia clave (OpenAI, Anthropic) o un modelo local. Tus datos siguen siendo tuyos.",
|
||||
"noProviderAction": "Abrir ajustes de IA"
|
||||
},
|
||||
"trendsRow": {
|
||||
@@ -2072,6 +2074,8 @@
|
||||
"refusalInjection": "Tu mensaje contiene instrucciones que pretenden sobrescribir mis indicaciones. Soy el coach de HealthLog y solo resumo tus propios datos de salud — reformula tu pregunta dentro de ese marco.",
|
||||
"errorBudget": "Has alcanzado el límite diario del coach. El cupo se reinicia a medianoche (UTC).",
|
||||
"errorProvider": "El coach no ha podido contactar con un proveedor de análisis ahora mismo. Inténtalo en un momento.",
|
||||
"errorNoProvider": "El coach lee tus datos de salud registrados y responde preguntas sobre ellos (no es consejo médico). HealthLog necesita un proveedor de IA para ello: conecta tu propia clave o un modelo local.",
|
||||
"errorNoProviderAction": "Conectar un proveedor de IA",
|
||||
"errorNetwork": "Sin conexión a Internet — inténtalo de nuevo cuando vuelvas a estar en línea.",
|
||||
"prefsSaveError": "No se pudieron guardar tus preferencias del Coach. Inténtalo de nuevo.",
|
||||
"errorCredentialExpired": "Tu conexión con el proveedor de IA ha caducado. Vuelve a conectarla en Ajustes para que el Coach siga funcionando.",
|
||||
@@ -2709,6 +2713,7 @@
|
||||
"title": "Resumen de glucosa",
|
||||
"subtitle": "Una lectura de tus últimos {days} días, a partir de {count} mediciones.",
|
||||
"spotCaveat": "Son estimaciones a partir de mediciones puntuales, no un perfil de un sensor continuo: léelas como una tendencia, no como un veredicto clínico.",
|
||||
"labsLink": "¿Resultados de laboratorio? Registra una HbA1c o glucosa en ayunas de una analítica en Laboratorio.",
|
||||
"learningTitle": "Aún conociendo tu glucosa",
|
||||
"learningBody": "De momento son {count} en {days}. Con algo más de historial el panorama se aclara: sigue registrando o deja que lo complete un dispositivo conectado.",
|
||||
"learningBodyEmpty": "En cuanto lleguen unas cuantas mediciones —registradas por ti o sincronizadas desde un dispositivo— tu tiempo en rango, promedio y variabilidad tomarán forma aquí.",
|
||||
@@ -3623,7 +3628,10 @@
|
||||
"learning": "Algunas conclusiones —glucosa, ritmo de sueño, tendencias de presión arterial, ciclo— se afinan durante la primera semana o dos a medida que se acumulan tus datos. Las casillas vacías ahora solo significan que aún estamos aprendiendo tu línea base.",
|
||||
"connectCta": "Conectar un dispositivo",
|
||||
"logCta": "Registra tu primera medición",
|
||||
"importCta": "Importar historial existente"
|
||||
"importCta": "Importar historial existente",
|
||||
"aiTitle": "Activar Insights con IA (opcional)",
|
||||
"aiBody": "El coach, el briefing diario y las valoraciones convierten tus lecturas en lenguaje claro. Para ello conectas tu propia clave de IA (OpenAI, Anthropic) o un modelo local: tus datos siguen siendo tuyos.",
|
||||
"aiCta": "Conectar un proveedor de IA"
|
||||
},
|
||||
"welcomeBack": {
|
||||
"title": "Bienvenido de nuevo",
|
||||
|
||||
+12
-4
@@ -414,7 +414,7 @@
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Mesures",
|
||||
"subtitle": "Poids, tension artérielle, pouls et plus",
|
||||
"subtitle": "Saisis tes mesures et consulte l'historique complet. Les tendances et analyses sont dans Analyses.",
|
||||
"addMeasurement": "Ajouter",
|
||||
"editMeasurement": "Modifier la mesure",
|
||||
"type": "Type",
|
||||
@@ -1892,7 +1892,8 @@
|
||||
"bmiEmptyTitle": "Ajoute ta taille pour l’IMC",
|
||||
"bmiEmptyDescription": "L’IMC nécessite ta taille. Renseigne-la dans Paramètres → Compte.",
|
||||
"bmiEmptyAction": "Ouvrir les paramètres du compte",
|
||||
"noProviderConfigured": "Évaluation indisponible.",
|
||||
"noProviderConfigured": "Connecte un fournisseur d'IA pour voir ici une évaluation de tes mesures.",
|
||||
"noProviderAction": "Ouvrir les réglages IA",
|
||||
"startAnalysis": "Lancer l’évaluation",
|
||||
"refreshAnalysis": "Actualiser l’évaluation",
|
||||
"keyTakeaway": "L’essentiel",
|
||||
@@ -1965,6 +1966,7 @@
|
||||
"progressAria": "Progression du score de santé",
|
||||
"deltaVsLastWeek": "{delta} par rapport à la semaine précédente",
|
||||
"deltaUnavailable": "Pas encore d’historique — reviens la semaine prochaine.",
|
||||
"restModePaused": "En pause pendant la maladie — ton score n'est pas évalué aujourd'hui.",
|
||||
"componentBp": "TA",
|
||||
"componentWeight": "Poids",
|
||||
"componentMood": "Humeur",
|
||||
@@ -2007,7 +2009,7 @@
|
||||
"emptyAction": "Générer le 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.",
|
||||
"noProviderDescription": "Le briefing quotidien résume tes mesures en langage clair. HealthLog a besoin d'un fournisseur d'IA pour cela : utilise ta propre clé (OpenAI, Anthropic) ou un modèle local. Tes données restent chez toi.",
|
||||
"noProviderAction": "Ouvrir les réglages IA"
|
||||
},
|
||||
"trendsRow": {
|
||||
@@ -2072,6 +2074,8 @@
|
||||
"refusalInjection": "Ton message contient des instructions qui cherchent à remplacer mes consignes. Je suis le coach HealthLog et je ne résume que tes propres données de santé — reformule ta question dans ce cadre.",
|
||||
"errorBudget": "Tu as atteint la limite quotidienne du coach. Le quota se réinitialise à minuit (UTC).",
|
||||
"errorProvider": "Le coach n’a pas pu joindre un fournisseur d’analyse maintenant. Réessaie dans un instant.",
|
||||
"errorNoProvider": "Le coach lit tes données de santé enregistrées et répond aux questions à leur sujet (pas un avis médical). HealthLog a besoin d'un fournisseur d'IA pour cela : connecte ta propre clé ou un modèle local.",
|
||||
"errorNoProviderAction": "Connecter un fournisseur d'IA",
|
||||
"errorNetwork": "Pas de connexion Internet — réessaie une fois reconnecté.",
|
||||
"prefsSaveError": "Impossible d’enregistrer vos préférences du Coach. Réessayez.",
|
||||
"errorCredentialExpired": "Ta connexion au fournisseur d'IA a expiré. Reconnecte-la dans les Réglages pour que le Coach continue de fonctionner.",
|
||||
@@ -2709,6 +2713,7 @@
|
||||
"title": "Aperçu du glucose",
|
||||
"subtitle": "Un point sur vos {days} derniers jours, à partir de {count} mesures.",
|
||||
"spotCaveat": "Ce sont des estimations issues de mesures ponctuelles, pas le profil d’un capteur continu : lisez-les comme une tendance, non comme un verdict clinique.",
|
||||
"labsLink": "Résultats de laboratoire ? Saisis une HbA1c ou une glycémie à jeun issue d'une prise de sang dans Laboratoire.",
|
||||
"learningTitle": "Votre glucose se découvre encore",
|
||||
"learningBody": "Pour l’instant, cela fait {count} sur {days}. Avec un peu plus d’historique, le tableau se précise : continuez à enregistrer ou laissez un appareil connecté compléter.",
|
||||
"learningBodyEmpty": "Dès que quelques mesures arrivent — saisies par vous ou synchronisées depuis un appareil — votre temps dans la cible, votre moyenne et votre variabilité prennent forme ici.",
|
||||
@@ -3623,7 +3628,10 @@
|
||||
"learning": "Certaines analyses — glycémie, rythme de sommeil, tendances de tension, cycle — s'affinent au cours de la première semaine ou deux à mesure que vos données s'accumulent. Des tuiles vides signifient simplement que nous apprenons encore votre référence.",
|
||||
"connectCta": "Connecter un appareil",
|
||||
"logCta": "Enregistrez votre première mesure",
|
||||
"importCta": "Importer un historique existant"
|
||||
"importCta": "Importer un historique existant",
|
||||
"aiTitle": "Activer les Insights IA (facultatif)",
|
||||
"aiBody": "Le coach, le briefing quotidien et les évaluations traduisent tes mesures en langage clair. Pour cela, connecte ta propre clé IA (OpenAI, Anthropic) ou un modèle local — tes données restent chez toi.",
|
||||
"aiCta": "Connecter un fournisseur d'IA"
|
||||
},
|
||||
"welcomeBack": {
|
||||
"title": "Bon retour",
|
||||
|
||||
+12
-4
@@ -414,7 +414,7 @@
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Misurazioni",
|
||||
"subtitle": "Peso, pressione arteriosa, pulsazioni e altro",
|
||||
"subtitle": "Registra le misurazioni e consulta lo storico completo. Tendenze e analisi sono in Analisi.",
|
||||
"addMeasurement": "Aggiungi",
|
||||
"editMeasurement": "Modifica misurazione",
|
||||
"type": "Tipo",
|
||||
@@ -1892,7 +1892,8 @@
|
||||
"bmiEmptyTitle": "Aggiungi l’altezza per il BMI",
|
||||
"bmiEmptyDescription": "Per il BMI serve la tua altezza. Salvala in Impostazioni → Account.",
|
||||
"bmiEmptyAction": "Apri impostazioni account",
|
||||
"noProviderConfigured": "Valutazione non disponibile.",
|
||||
"noProviderConfigured": "Collega un provider IA per vedere qui una valutazione delle tue misurazioni.",
|
||||
"noProviderAction": "Apri impostazioni IA",
|
||||
"startAnalysis": "Avvia valutazione",
|
||||
"refreshAnalysis": "Aggiorna valutazione",
|
||||
"keyTakeaway": "Ciò che conta",
|
||||
@@ -1965,6 +1966,7 @@
|
||||
"progressAria": "Avanzamento del punteggio di salute",
|
||||
"deltaVsLastWeek": "{delta} rispetto alla settimana scorsa",
|
||||
"deltaUnavailable": "Ancora nessuno storico — ripassa la prossima settimana.",
|
||||
"restModePaused": "In pausa durante la malattia — oggi il tuo punteggio non viene valutato.",
|
||||
"componentBp": "PA",
|
||||
"componentWeight": "Peso",
|
||||
"componentMood": "Umore",
|
||||
@@ -2007,7 +2009,7 @@
|
||||
"emptyAction": "Genera briefing",
|
||||
"loadingLabel": "Preparazione del briefing",
|
||||
"noProviderTitle": "Nessun provider IA collegato",
|
||||
"noProviderDescription": "Collega un provider IA nelle impostazioni per ricevere briefing giornalieri.",
|
||||
"noProviderDescription": "Il briefing giornaliero riassume le tue misurazioni in linguaggio chiaro. HealthLog ha bisogno di un provider IA per questo: usa la tua chiave (OpenAI, Anthropic) o un modello locale. I tuoi dati restano tuoi.",
|
||||
"noProviderAction": "Apri impostazioni IA"
|
||||
},
|
||||
"trendsRow": {
|
||||
@@ -2072,6 +2074,8 @@
|
||||
"refusalInjection": "Nel tuo messaggio ci sono istruzioni che cercano di sovrascrivere le mie indicazioni. Sono il coach di HealthLog e riassumo solo i tuoi dati di salute — riformula la domanda in questo perimetro.",
|
||||
"errorBudget": "Hai raggiunto il limite giornaliero del coach. Il budget si reimposta a mezzanotte (UTC).",
|
||||
"errorProvider": "Il coach non è riuscito a contattare un provider di analisi ora. Riprova fra poco.",
|
||||
"errorNoProvider": "Il coach legge i tuoi dati di salute registrati e risponde a domande su di essi (non è consulenza medica). HealthLog ha bisogno di un provider IA per questo: collega la tua chiave o un modello locale.",
|
||||
"errorNoProviderAction": "Collega un provider IA",
|
||||
"errorNetwork": "Nessuna connessione Internet — riprova quando sei di nuovo online.",
|
||||
"prefsSaveError": "Impossibile salvare le preferenze del Coach. Riprova.",
|
||||
"errorCredentialExpired": "La connessione al provider di IA è scaduta. Riconnettila nelle Impostazioni per mantenere attivo il Coach.",
|
||||
@@ -2709,6 +2713,7 @@
|
||||
"title": "Panoramica della glicemia",
|
||||
"subtitle": "Una lettura dei tuoi ultimi {days} giorni, da {count} misurazioni.",
|
||||
"spotCaveat": "Sono stime da singole misurazioni puntuali, non il profilo di un sensore continuo: leggile come una tendenza, non come un verdetto clinico.",
|
||||
"labsLink": "Risultati di laboratorio? Registra una HbA1c o la glicemia a digiuno da un prelievo in Laboratorio.",
|
||||
"learningTitle": "Stiamo ancora imparando la tua glicemia",
|
||||
"learningBody": "Per ora sono {count} su {days}. Con un po’ più di storico il quadro si chiarisce: continua a registrare o lascia che lo completi un dispositivo connesso.",
|
||||
"learningBodyEmpty": "Appena arrivano alcune misurazioni — registrate da te o sincronizzate da un dispositivo — il tempo nel range, la media e la variabilità prendono forma qui.",
|
||||
@@ -3623,7 +3628,10 @@
|
||||
"learning": "Alcune analisi — glicemia, ritmo del sonno, tendenze della pressione, ciclo — si affinano nella prima settimana o due man mano che i tuoi dati si accumulano. Le caselle vuote ora indicano solo che stiamo ancora imparando il tuo riferimento.",
|
||||
"connectCta": "Collega un dispositivo",
|
||||
"logCta": "Registra la tua prima misurazione",
|
||||
"importCta": "Importa la cronologia esistente"
|
||||
"importCta": "Importa la cronologia esistente",
|
||||
"aiTitle": "Attiva gli Insight IA (facoltativo)",
|
||||
"aiBody": "Il coach, il briefing giornaliero e le valutazioni trasformano le tue misurazioni in linguaggio chiaro. Per farlo colleghi la tua chiave IA (OpenAI, Anthropic) o un modello locale: i tuoi dati restano tuoi.",
|
||||
"aiCta": "Collega un provider IA"
|
||||
},
|
||||
"welcomeBack": {
|
||||
"title": "Bentornato",
|
||||
|
||||
+12
-4
@@ -414,7 +414,7 @@
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Pomiary",
|
||||
"subtitle": "Waga, ciśnienie tętnicze, tętno i więcej",
|
||||
"subtitle": "Zapisuj pomiary i przeglądaj pełną historię. Trendy i analizy znajdziesz w Analizach.",
|
||||
"addMeasurement": "Dodaj",
|
||||
"editMeasurement": "Edytuj pomiar",
|
||||
"type": "Typ",
|
||||
@@ -1892,7 +1892,8 @@
|
||||
"bmiEmptyTitle": "Dodaj wzrost, aby obliczyć BMI",
|
||||
"bmiEmptyDescription": "BMI wymaga twojego wzrostu. Zapisz go w Ustawieniach → Konto.",
|
||||
"bmiEmptyAction": "Otwórz ustawienia konta",
|
||||
"noProviderConfigured": "Ocena niedostępna.",
|
||||
"noProviderConfigured": "Połącz dostawcę AI, aby zobaczyć tutaj ocenę swoich pomiarów.",
|
||||
"noProviderAction": "Otwórz ustawienia AI",
|
||||
"startAnalysis": "Uruchom ocenę",
|
||||
"refreshAnalysis": "Odśwież ocenę",
|
||||
"keyTakeaway": "Najważniejsze",
|
||||
@@ -1965,6 +1966,7 @@
|
||||
"progressAria": "Postęp wyniku zdrowia",
|
||||
"deltaVsLastWeek": "{delta} w porównaniu z ubiegłym tygodniem",
|
||||
"deltaUnavailable": "Brak historii — zajrzyj w przyszłym tygodniu.",
|
||||
"restModePaused": "Wstrzymane podczas choroby — Twój wynik nie jest dziś oceniany.",
|
||||
"componentBp": "RR",
|
||||
"componentWeight": "Waga",
|
||||
"componentMood": "Nastrój",
|
||||
@@ -2007,7 +2009,7 @@
|
||||
"emptyAction": "Wygeneruj briefing",
|
||||
"loadingLabel": "Przygotowanie briefingu",
|
||||
"noProviderTitle": "Brak połączonego dostawcy AI",
|
||||
"noProviderDescription": "Połącz dostawcę AI w ustawieniach, aby otrzymywać codzienne briefingi.",
|
||||
"noProviderDescription": "Codzienny briefing podsumowuje Twoje pomiary prostym językiem. HealthLog potrzebuje do tego dostawcy AI — użyj własnego klucza (OpenAI, Anthropic) lub modelu lokalnego. Twoje dane pozostają u Ciebie.",
|
||||
"noProviderAction": "Otwórz ustawienia AI"
|
||||
},
|
||||
"trendsRow": {
|
||||
@@ -2072,6 +2074,8 @@
|
||||
"refusalInjection": "Twoja wiadomość zawiera instrukcje, które próbują nadpisać moje wytyczne. Jestem coachem HealthLog i podsumowuję wyłącznie twoje własne dane zdrowotne — przeformułuj pytanie w tych ramach.",
|
||||
"errorBudget": "Osiągnięto dzienny limit coacha. Limit resetuje się o północy (UTC).",
|
||||
"errorProvider": "Coach nie mógł teraz skontaktować się z dostawcą analizy. Spróbuj za chwilę.",
|
||||
"errorNoProvider": "Coach czyta Twoje zapisane dane zdrowotne i odpowiada na pytania o nie (to nie porada medyczna). HealthLog potrzebuje do tego dostawcy AI — połącz własny klucz lub model lokalny.",
|
||||
"errorNoProviderAction": "Połącz dostawcę AI",
|
||||
"errorNetwork": "Brak połączenia z Internetem — spróbuj ponownie, gdy wrócisz online.",
|
||||
"prefsSaveError": "Nie udało się zapisać ustawień Coacha. Spróbuj ponownie.",
|
||||
"errorCredentialExpired": "Połączenie z dostawcą AI wygasło. Połącz je ponownie w Ustawieniach, aby Coach działał dalej.",
|
||||
@@ -2709,6 +2713,7 @@
|
||||
"title": "Przegląd glukozy",
|
||||
"subtitle": "Spojrzenie na ostatnie {days} dni, na podstawie {count} pomiarów.",
|
||||
"spotCaveat": "To szacunki z pojedynczych pomiarów punktowych, a nie profil z czujnika ciągłego — traktuj je jako kierunek, nie jako diagnozę.",
|
||||
"labsLink": "Wyniki laboratoryjne? HbA1c lub glukozę na czczo z pobrania krwi zapisz w sekcji Laboratorium.",
|
||||
"learningTitle": "Wciąż poznajemy Twoją glukozę",
|
||||
"learningBody": "Na razie to {count} w ciągu {days}. Przy nieco dłuższej historii obraz się wyostrza — rejestruj dalej lub pozwól uzupełnić to podłączonemu urządzeniu.",
|
||||
"learningBodyEmpty": "Gdy pojawi się kilka pomiarów — wpisanych przez Ciebie lub zsynchronizowanych z urządzenia — czas w zakresie, średnia i zmienność nabiorą tu kształtu.",
|
||||
@@ -3623,7 +3628,10 @@
|
||||
"learning": "Niektóre wnioski — glukoza, rytm snu, trendy ciśnienia, cykl — wyostrzają się w ciągu pierwszego tygodnia lub dwóch, w miarę gromadzenia danych. Puste kafelki oznaczają teraz tylko, że wciąż poznajemy Twój punkt odniesienia.",
|
||||
"connectCta": "Połącz urządzenie",
|
||||
"logCta": "Zapisz pierwszy pomiar",
|
||||
"importCta": "Importuj istniejącą historię"
|
||||
"importCta": "Importuj istniejącą historię",
|
||||
"aiTitle": "Włącz Insighty AI (opcjonalnie)",
|
||||
"aiBody": "Coach, codzienny briefing i oceny zamieniają Twoje pomiary w prosty język. W tym celu łączysz własny klucz AI (OpenAI, Anthropic) lub model lokalny — Twoje dane pozostają u Ciebie.",
|
||||
"aiCta": "Połącz dostawcę AI"
|
||||
},
|
||||
"welcomeBack": {
|
||||
"title": "Witaj ponownie",
|
||||
|
||||
+22
-84
@@ -106,18 +106,7 @@ import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import { mergeSlimAndThickAnalytics } from "@/lib/analytics/merge-slim-thick";
|
||||
import { isWindowSufficient } from "@/lib/analytics/window-confidence";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import {
|
||||
buildTrafficLightBands,
|
||||
buildTrafficRange,
|
||||
buildWeightBandsFromHeight,
|
||||
buildWeightRangeFromHeight,
|
||||
getBodyFatTargetRange,
|
||||
} from "@/lib/analytics/value-bands";
|
||||
import {
|
||||
getAgeFromDateOfBirth,
|
||||
getPersonalizedPulseTarget,
|
||||
} from "@/lib/analytics/pulse-targets";
|
||||
import { buildDashboardBands } from "@/lib/dashboard/bands";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
// v1.18.6 — the pure first-paint / skeleton-reservation gates moved to
|
||||
@@ -537,34 +526,22 @@ export default function DashboardPage() {
|
||||
const serverBands = snapshotEnabled
|
||||
? snapshotQuery.data?.targetBands
|
||||
: undefined;
|
||||
const bpTargets = snapshotEnabled
|
||||
? (serverBands?.bpTargets ?? null)
|
||||
: user?.dateOfBirth != null
|
||||
? getBpTargets(new Date(user.dateOfBirth))
|
||||
: null;
|
||||
const bodyFatRange = snapshotEnabled
|
||||
? (serverBands?.bodyFatRange ?? getBodyFatTargetRange(user?.gender))
|
||||
: getBodyFatTargetRange(user?.gender);
|
||||
const weightRange = snapshotEnabled
|
||||
? (serverBands?.weightRange ?? null)
|
||||
: user?.heightCm
|
||||
? buildWeightRangeFromHeight(user.heightCm)
|
||||
: null;
|
||||
const weightBands = snapshotEnabled
|
||||
? (serverBands?.weightBands ?? undefined)
|
||||
: user?.heightCm
|
||||
? buildWeightBandsFromHeight(user.heightCm, {
|
||||
lowerBound: 30,
|
||||
upperBound: 250,
|
||||
})
|
||||
: undefined;
|
||||
// The pulse target range / bands carry no i18n — read them server-side
|
||||
// under snapshot mode; recompute from the profile otherwise.
|
||||
const pulseAge = getAgeFromDateOfBirth(user?.dateOfBirth ?? null);
|
||||
const pulseTarget = getPersonalizedPulseTarget(
|
||||
pulseAge,
|
||||
(user?.gender as "MALE" | "FEMALE" | null | undefined) ?? null,
|
||||
);
|
||||
// Resolve the bands through the SAME `buildDashboardBands` helper the
|
||||
// server snapshot uses, so the snapshot path and the client fallback
|
||||
// are byte-identical by construction (no second inline copy of the
|
||||
// band math to drift). Always compute the client copy from the profile
|
||||
// — it is pure + cheap — so a still-loading snapshot frame keeps real
|
||||
// bands rather than blank charts; `serverBands ?? clientBands` then
|
||||
// prefers the authoritative server numbers the moment they arrive.
|
||||
const clientBands = buildDashboardBands({
|
||||
dateOfBirth: user?.dateOfBirth ? new Date(user.dateOfBirth) : null,
|
||||
gender: (user?.gender as "MALE" | "FEMALE" | null | undefined) ?? null,
|
||||
heightCm: user?.heightCm ?? null,
|
||||
});
|
||||
const bands = serverBands ?? clientBands;
|
||||
const bpTargets = bands.bpTargets;
|
||||
const weightRange = bands.weightRange;
|
||||
const weightBands = bands.weightBands ?? undefined;
|
||||
const bpTargetZones = bpTargets
|
||||
? [
|
||||
{
|
||||
@@ -587,50 +564,11 @@ export default function DashboardPage() {
|
||||
},
|
||||
]
|
||||
: undefined;
|
||||
const bpSysRange = snapshotEnabled
|
||||
? (serverBands?.bpSysRange ?? null)
|
||||
: bpTargets
|
||||
? buildTrafficRange(bpTargets.sysLow, bpTargets.sysHigh)
|
||||
: null;
|
||||
const bpDiaRange = snapshotEnabled
|
||||
? (serverBands?.bpDiaRange ?? null)
|
||||
: bpTargets
|
||||
? buildTrafficRange(bpTargets.diaLow, bpTargets.diaHigh)
|
||||
: null;
|
||||
const pulseDisplayRange = serverBands?.pulseDisplayRange ?? {
|
||||
greenMin: pulseTarget.greenMin,
|
||||
greenMax: pulseTarget.greenMax,
|
||||
orangeMin: pulseTarget.orangeMin,
|
||||
orangeMax: pulseTarget.orangeMax,
|
||||
};
|
||||
const pulseBands = serverBands?.pulseBands ?? [
|
||||
{ min: 30, max: pulseTarget.orangeMin, color: "#ff5555", opacity: 0.16 },
|
||||
{
|
||||
min: pulseTarget.orangeMin,
|
||||
max: pulseTarget.greenMin,
|
||||
color: "#ffb86c",
|
||||
opacity: 0.18,
|
||||
},
|
||||
{
|
||||
min: pulseTarget.greenMin,
|
||||
max: pulseTarget.greenMax,
|
||||
color: "#50fa7b",
|
||||
opacity: 0.2,
|
||||
},
|
||||
{
|
||||
min: pulseTarget.greenMax,
|
||||
max: pulseTarget.orangeMax,
|
||||
color: "#ffb86c",
|
||||
opacity: 0.18,
|
||||
},
|
||||
{ min: pulseTarget.orangeMax, max: 220, color: "#ff5555", opacity: 0.16 },
|
||||
].filter((band) => band.max > band.min);
|
||||
const bodyFatBands =
|
||||
serverBands?.bodyFatBands ??
|
||||
buildTrafficLightBands(bodyFatRange.min, bodyFatRange.max, {
|
||||
lowerBound: 2,
|
||||
upperBound: 55,
|
||||
});
|
||||
const bpSysRange = bands.bpSysRange;
|
||||
const bpDiaRange = bands.bpDiaRange;
|
||||
const pulseDisplayRange = bands.pulseDisplayRange;
|
||||
const pulseBands = bands.pulseBands;
|
||||
const bodyFatBands = bands.bodyFatBands;
|
||||
|
||||
// v1.18.6 — ONE batched fetch for every visible non-sleep chart series
|
||||
// (audit finding #2). Pre-fix, each `<HealthChart>` fired its own
|
||||
|
||||
@@ -80,7 +80,11 @@ describe("<InsightStatusCard>", () => {
|
||||
text="ignored when hasProvider is false"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Assessment unavailable.");
|
||||
// v1.18.6 — the no-provider tile is a guided-setup explainer + a
|
||||
// Settings link, not a bare "unavailable" line.
|
||||
expect(html).toContain("Connect an AI provider");
|
||||
expect(html).toContain("Open AI settings");
|
||||
expect(html).toContain("/settings/ai");
|
||||
expect(html).not.toContain("ignored when hasProvider is false");
|
||||
});
|
||||
|
||||
|
||||
@@ -662,13 +662,20 @@ describe("errorCodeToI18nKey", () => {
|
||||
for (const code of [
|
||||
"coach.provider.unavailable",
|
||||
"coach.provider.empty",
|
||||
"coach.provider.none",
|
||||
"coach.stream",
|
||||
]) {
|
||||
expect(errorCodeToI18nKey(code)).toBe("insights.coach.errorProvider");
|
||||
}
|
||||
});
|
||||
|
||||
// v1.18.6 — a "no provider configured anywhere" turn is a setup gap,
|
||||
// not a transient failure, so it carries its own guided-setup copy.
|
||||
it("maps the no-provider case to its dedicated guided-setup copy", () => {
|
||||
expect(errorCodeToI18nKey("coach.provider.none")).toBe(
|
||||
"insights.coach.errorNoProvider",
|
||||
);
|
||||
});
|
||||
|
||||
// v1.4.43 QoL (M6) — `coach.network` no longer collapses to the
|
||||
// generic provider copy; the user needs the actionable offline hint.
|
||||
it("maps coach.network to the dedicated offline key", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -168,9 +169,14 @@ export function errorCodeToI18nKey(code: string): string {
|
||||
// (auth-class failure). The next action is "reconnect", not "try
|
||||
// again later", so it carries its own copy pointing at Settings.
|
||||
return "insights.coach.errorCredentialExpired";
|
||||
case "coach.provider.none":
|
||||
// v1.18.6 — no AI provider is configured anywhere, so "try again
|
||||
// in a moment" is the wrong instruction (it will 422 forever). Send
|
||||
// the user to guided setup with a one-line explainer + a Settings
|
||||
// link rendered alongside the bubble copy.
|
||||
return "insights.coach.errorNoProvider";
|
||||
case "coach.provider.unavailable":
|
||||
case "coach.provider.empty":
|
||||
case "coach.provider.none":
|
||||
case "coach.stream":
|
||||
return "insights.coach.errorProvider";
|
||||
default:
|
||||
@@ -613,6 +619,19 @@ function ChatBubble({
|
||||
{safeError && content && (
|
||||
<p className="text-warning/90 text-xs">{safeError}</p>
|
||||
)}
|
||||
{/* v1.18.6 — a "no provider configured anywhere" turn is a
|
||||
setup gap, not a transient failure: surface a direct link to
|
||||
Settings → AI so the Coach guides the user into BYOK / local
|
||||
setup instead of inviting an endless retry. */}
|
||||
{errorCode === "coach.provider.none" && (
|
||||
<Link
|
||||
href="/settings/ai"
|
||||
data-slot="coach-no-provider-cta"
|
||||
className="text-primary text-xs font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("insights.coach.errorNoProviderAction")}
|
||||
</Link>
|
||||
)}
|
||||
{/* v1.12.0 — collapse the whole provenance block ("what was
|
||||
included") behind one disclosure, collapsed by default. The
|
||||
source chips used to render fully expanded above the
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Activity, Droplet, TrendingUp } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Activity, Droplet, FlaskConical, TrendingUp } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -113,6 +114,9 @@ export function GlucoseClinicalPanel() {
|
||||
}
|
||||
caveat={t("insights.bloodGlucose.clinical.spotCaveat")}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<LabsCrossLink />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -225,11 +229,34 @@ export function GlucoseClinicalPanel() {
|
||||
byContext={byContext}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<LabsCrossLink />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.18.6 — signpost the measurement-vs-lab mental model. This panel
|
||||
* estimates A1c / GMI from spot or CGM readings over time; a lab-ordered
|
||||
* HbA1c or fasting-glucose result is a dated panel value that lives under
|
||||
* Labs. The cross-link keeps the two surfaces referencing each other so a
|
||||
* user knows which door to use rather than treating them as rivals.
|
||||
*/
|
||||
function LabsCrossLink() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<Link
|
||||
href="/labs"
|
||||
data-slot="glucose-labs-link"
|
||||
className="text-muted-foreground hover:text-foreground border-border/60 flex items-start gap-1.5 border-t pt-3 text-xs leading-relaxed underline-offset-4 hover:underline"
|
||||
>
|
||||
<FlaskConical className="mt-0.5 size-3 shrink-0" aria-hidden="true" />
|
||||
<span>{t("insights.bloodGlucose.clinical.labsLink")}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({
|
||||
icon,
|
||||
label,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { ArrowDown, ArrowUp, ChevronDown, Minus } from "lucide-react";
|
||||
import { ArrowDown, ArrowUp, ChevronDown, Minus, Moon } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HealthScoreDeltaExplainer } from "./health-score-delta-explainer";
|
||||
@@ -87,6 +87,15 @@ export interface HealthScoreCardProps {
|
||||
* four-row layout for the common (mood-enabled) case.
|
||||
*/
|
||||
moodEnabled?: boolean;
|
||||
/**
|
||||
* v1.18.6 — Rest Mode annotation. When an illness episode is active the
|
||||
* server suppresses (never penalises) the score; the card itself then
|
||||
* carries an explicit "paused during illness — not being judged today"
|
||||
* line so a frozen/held number reads as intentional rather than as a
|
||||
* silent drop. Value-free: the card only needs to know that it is
|
||||
* active, not the episode details. Default off (the common case).
|
||||
*/
|
||||
restModeActive?: boolean;
|
||||
}
|
||||
|
||||
type ComponentKey = keyof HealthScoreCardProps["components"];
|
||||
@@ -174,6 +183,7 @@ export function HealthScoreCard({
|
||||
onAskCoach: _onAskCoach,
|
||||
initiallyExpanded = false,
|
||||
moodEnabled = true,
|
||||
restModeActive = false,
|
||||
}: HealthScoreCardProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
// The asOf timestamps render under the source pill as a tooltip
|
||||
@@ -404,6 +414,21 @@ export function HealthScoreCard({
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* v1.18.6 — Rest Mode legibility. While an illness episode is
|
||||
active the server suppresses (never penalises) the score, so
|
||||
the card states plainly that the number is paused and not being
|
||||
judged today. Without this the held score reads as a silent
|
||||
decline. */}
|
||||
{restModeActive && (
|
||||
<p
|
||||
data-slot="health-score-card-rest-mode"
|
||||
className="text-muted-foreground inline-flex items-start gap-1.5 text-[11px] leading-relaxed"
|
||||
>
|
||||
<Moon className="mt-0.5 size-3 shrink-0" aria-hidden="true" />
|
||||
<span>{t("insights.healthScore.restModePaused")}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul
|
||||
data-slot="health-score-card-components"
|
||||
// v1.8.5 W4a — the always-visible "Zusammensetzung" breakdown is
|
||||
|
||||
@@ -90,6 +90,12 @@ interface HeroStripProps {
|
||||
band: HealthScoreCardProps["band"];
|
||||
components: HealthScoreCardProps["components"];
|
||||
delta: HealthScoreCardProps["delta"];
|
||||
/**
|
||||
* v1.18.6 — value-free Rest Mode annotation off the analytics
|
||||
* payload. When present + active the score card states it is paused
|
||||
* during illness (suppressed, not penalised).
|
||||
*/
|
||||
restMode?: { active: boolean } | null;
|
||||
} | null;
|
||||
/**
|
||||
* v1.16.8 — true while the analytics payload that carries
|
||||
@@ -346,6 +352,7 @@ export function HeroStrip({
|
||||
components={healthScore.components}
|
||||
delta={healthScore.delta}
|
||||
moodEnabled={moodEnabled}
|
||||
restModeActive={healthScore.restMode?.active ?? false}
|
||||
onAskCoach={
|
||||
// v1.4.37 W5 — short-circuit the prop drilling even
|
||||
// though `HealthScoreCard` retired its inline button in
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -144,14 +145,21 @@ export function InsightStatusCard({
|
||||
|
||||
if (!hasProvider) {
|
||||
return (
|
||||
<Card className="gap-1.5 py-4 opacity-60 md:py-5">
|
||||
<Card className="gap-1.5 py-4 opacity-80 md:py-5">
|
||||
<CardHeader className="pb-1">
|
||||
<TileHeader icon={nodeIcon(icon)} title={title} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="flex flex-col gap-1.5">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.noProviderConfigured")}
|
||||
</p>
|
||||
<Link
|
||||
href="/settings/ai"
|
||||
data-slot="insight-status-no-provider-cta"
|
||||
className="text-primary text-sm font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("insights.noProviderAction")}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { CheckCircle2, FileUp, PlusCircle, Plug } from "lucide-react";
|
||||
import {
|
||||
CheckCircle2,
|
||||
FileUp,
|
||||
PlusCircle,
|
||||
Plug,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -51,6 +57,33 @@ export function DoneScreen() {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* v1.18.6 — surface the AI Insights / Coach / briefing setup at
|
||||
the highest-intent moment. The flagship feature needs a BYOK or
|
||||
local provider that the rest of onboarding never mentions, so a
|
||||
fresh user otherwise discovers it as a cold error. This card is
|
||||
skippable by construction — it is an optional deep-link, not a
|
||||
gate. */}
|
||||
<div className="border-border/60 bg-muted/30 mx-auto flex w-full max-w-md flex-col items-center gap-2 rounded-lg border p-4 text-center">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="from-dracula-purple to-dracula-pink flex size-9 items-center justify-center rounded-full bg-gradient-to-br"
|
||||
>
|
||||
<Sparkles className="text-background size-4" />
|
||||
</span>
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("onboarding.done.aiTitle")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{t("onboarding.done.aiBody")}
|
||||
</p>
|
||||
<Link
|
||||
href="/settings/ai"
|
||||
className="text-primary text-sm font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("onboarding.done.aiCta")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full max-w-xs flex-col gap-2">
|
||||
<Button asChild size="lg">
|
||||
<Link
|
||||
|
||||
@@ -118,21 +118,29 @@ export function ModulesSection() {
|
||||
aria-labelledby="settings-section-modules-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
|
||||
<header>
|
||||
{/* v1.18.6 — restore a short intro line so a new user understands
|
||||
that this page drives the nav, tiles and assessments they see.
|
||||
(Dropped in v1.18.1 D0 for top alignment; the orientation cost
|
||||
outweighed the alignment.) */}
|
||||
<header className="space-y-1">
|
||||
<h1 id="settings-section-modules-title" className="sr-only">
|
||||
{t("settings.sections.modules.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{t("settings.sections.modules.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Toggleable modules. v1.18.1 (D2) — the explanatory line above the
|
||||
first card dropped; the per-row labels carry the meaning. */}
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Blocks}
|
||||
title={t("settings.sections.modules.toggleable.title")}
|
||||
className="mb-2"
|
||||
/>
|
||||
{/* v1.18.6 — restore the one-line "what this section does" hint. */}
|
||||
<p className="text-muted-foreground mb-3 pl-7 text-sm leading-relaxed">
|
||||
{t("settings.sections.modules.toggleable.description")}
|
||||
</p>
|
||||
<div className="divide-border divide-y pl-7">
|
||||
{(Object.keys(MODULE_REGISTRY) as ModuleKey[]).map((key) => {
|
||||
const def = MODULE_REGISTRY[key];
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type CoachDataCluster,
|
||||
} from "@/lib/validations/coach-prefs";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
import {
|
||||
computeGlucoseClinicalMetrics,
|
||||
@@ -223,15 +224,11 @@ function windowToDays(window: CoachScopeWindow): number {
|
||||
* "last night" answer has to use the user's clock, not UTC. Up to
|
||||
* v1.4.24 the day-key was UTC, so a 23:50 Pacific/Auckland reading
|
||||
* landed in the next UTC day's bucket and the Coach couldn't pair it
|
||||
* with the user's mental model.
|
||||
* with the user's mental model. Delegates to the canonical
|
||||
* `userDayKey` so every day-bucket surface stays byte-aligned.
|
||||
*/
|
||||
function tzDayKey(date: Date, tz: string): string {
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(date);
|
||||
return userDayKey(date, tz);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Dashboard band / target math — the single source of truth.
|
||||
*
|
||||
* Pure projection over a user's profile facts (DOB / gender / height):
|
||||
* no DB read, no clock, no network, no `server-only` dep. Every building
|
||||
* block it composes (`@/lib/analytics/{value-bands,bp-targets,pulse-targets}`)
|
||||
* is itself client-safe, so this leaf imports cleanly into both the
|
||||
* server-side snapshot builder (`@/lib/dashboard/snapshot`) and the
|
||||
* `"use client"` dashboard page fallback (`src/app/page.tsx`).
|
||||
*
|
||||
* Before v1.18.6 the band construction lived inline in two places — the
|
||||
* snapshot builder and the page's snapshot-disabled fallback — so a
|
||||
* threshold change had to be mirrored by hand or the two surfaces would
|
||||
* grade the same metric differently. Both now call `buildDashboardBands`,
|
||||
* so the band/target math lives in exactly one place. `buildTargetBands`
|
||||
* is kept as an alias for the snapshot-era import sites.
|
||||
*/
|
||||
import { getBpTargets, type BpTargets } from "@/lib/analytics/bp-targets";
|
||||
import {
|
||||
buildTrafficLightBands,
|
||||
buildTrafficRange,
|
||||
buildWeightBandsFromHeight,
|
||||
buildWeightRangeFromHeight,
|
||||
getBodyFatTargetRange,
|
||||
type ValueBand,
|
||||
type TrafficRange,
|
||||
} from "@/lib/analytics/value-bands";
|
||||
import {
|
||||
getAgeFromDateOfBirth,
|
||||
getPersonalizedPulseTarget,
|
||||
} from "@/lib/analytics/pulse-targets";
|
||||
|
||||
export interface DashboardTargetBands {
|
||||
/** Personalised BP target numbers (null when no DOB). */
|
||||
bpTargets: BpTargets | null;
|
||||
/** Systolic traffic range (null when no DOB). */
|
||||
bpSysRange: TrafficRange | null;
|
||||
/** Diastolic traffic range (null when no DOB). */
|
||||
bpDiaRange: TrafficRange | null;
|
||||
/** Resting-pulse display range (always present — AHA fallback). */
|
||||
pulseDisplayRange: {
|
||||
greenMin: number;
|
||||
greenMax: number;
|
||||
orangeMin: number;
|
||||
orangeMax: number;
|
||||
};
|
||||
/** Resting-pulse chart bands (always present — AHA fallback). */
|
||||
pulseBands: ValueBand[];
|
||||
/** Body-fat target range (gender-aware; always present). */
|
||||
bodyFatRange: { min: number; max: number };
|
||||
/** Body-fat chart bands (always present). */
|
||||
bodyFatBands: ValueBand[];
|
||||
/** Weight traffic range (null when no height). */
|
||||
weightRange: TrafficRange | null;
|
||||
/** Weight chart bands (null when no height). */
|
||||
weightBands: ValueBand[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the band / target math from a user's profile facts. Pure; no
|
||||
* DB read. The snapshot builder and the page fallback both call this so
|
||||
* they produce byte-identical numbers.
|
||||
*/
|
||||
export function buildDashboardBands(profile: {
|
||||
dateOfBirth: Date | null;
|
||||
gender: "MALE" | "FEMALE" | null;
|
||||
heightCm: number | null;
|
||||
}): DashboardTargetBands {
|
||||
const bpTargets = profile.dateOfBirth
|
||||
? getBpTargets(profile.dateOfBirth)
|
||||
: null;
|
||||
const pulseAge = getAgeFromDateOfBirth(profile.dateOfBirth);
|
||||
const pulseTarget = getPersonalizedPulseTarget(pulseAge, profile.gender);
|
||||
const bodyFatRange = getBodyFatTargetRange(profile.gender);
|
||||
const weightRange = profile.heightCm
|
||||
? buildWeightRangeFromHeight(profile.heightCm)
|
||||
: null;
|
||||
const weightBands = profile.heightCm
|
||||
? buildWeightBandsFromHeight(profile.heightCm, {
|
||||
lowerBound: 30,
|
||||
upperBound: 250,
|
||||
})
|
||||
: null;
|
||||
const bpSysRange = bpTargets
|
||||
? buildTrafficRange(bpTargets.sysLow, bpTargets.sysHigh)
|
||||
: null;
|
||||
const bpDiaRange = bpTargets
|
||||
? buildTrafficRange(bpTargets.diaLow, bpTargets.diaHigh)
|
||||
: null;
|
||||
const pulseBands = [
|
||||
{ min: 30, max: pulseTarget.orangeMin, color: "#ff5555", opacity: 0.16 },
|
||||
{
|
||||
min: pulseTarget.orangeMin,
|
||||
max: pulseTarget.greenMin,
|
||||
color: "#ffb86c",
|
||||
opacity: 0.18,
|
||||
},
|
||||
{
|
||||
min: pulseTarget.greenMin,
|
||||
max: pulseTarget.greenMax,
|
||||
color: "#50fa7b",
|
||||
opacity: 0.2,
|
||||
},
|
||||
{
|
||||
min: pulseTarget.greenMax,
|
||||
max: pulseTarget.orangeMax,
|
||||
color: "#ffb86c",
|
||||
opacity: 0.18,
|
||||
},
|
||||
{ min: pulseTarget.orangeMax, max: 220, color: "#ff5555", opacity: 0.16 },
|
||||
].filter((band) => band.max > band.min);
|
||||
const bodyFatBands = buildTrafficLightBands(
|
||||
bodyFatRange.min,
|
||||
bodyFatRange.max,
|
||||
{
|
||||
lowerBound: 2,
|
||||
upperBound: 55,
|
||||
},
|
||||
);
|
||||
return {
|
||||
bpTargets,
|
||||
bpSysRange,
|
||||
bpDiaRange,
|
||||
pulseDisplayRange: {
|
||||
greenMin: pulseTarget.greenMin,
|
||||
greenMax: pulseTarget.greenMax,
|
||||
orangeMin: pulseTarget.orangeMin,
|
||||
orangeMax: pulseTarget.orangeMax,
|
||||
},
|
||||
pulseBands,
|
||||
bodyFatRange,
|
||||
bodyFatBands,
|
||||
weightRange,
|
||||
weightBands,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot-era alias. The snapshot builder and its parity test still
|
||||
* import `buildTargetBands`; keep the name pointing at the canonical
|
||||
* builder so neither has to change.
|
||||
*/
|
||||
export const buildTargetBands = buildDashboardBands;
|
||||
@@ -45,20 +45,7 @@ import {
|
||||
type DataPoint,
|
||||
type DataSummary,
|
||||
} from "@/lib/analytics/trends";
|
||||
import { getBpTargets, type BpTargets } from "@/lib/analytics/bp-targets";
|
||||
import {
|
||||
buildTrafficLightBands,
|
||||
buildTrafficRange,
|
||||
buildWeightBandsFromHeight,
|
||||
buildWeightRangeFromHeight,
|
||||
getBodyFatTargetRange,
|
||||
type ValueBand,
|
||||
type TrafficRange,
|
||||
} from "@/lib/analytics/value-bands";
|
||||
import {
|
||||
getAgeFromDateOfBirth,
|
||||
getPersonalizedPulseTarget,
|
||||
} from "@/lib/analytics/pulse-targets";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import {
|
||||
computeGlucoseClinicalMetrics,
|
||||
GLUCOSE_PANEL_WINDOW_DAYS,
|
||||
@@ -90,6 +77,11 @@ import {
|
||||
type MedsTodayBlock,
|
||||
} from "@/lib/dashboard/meds-today";
|
||||
import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fast-path";
|
||||
import {
|
||||
buildDashboardBands,
|
||||
buildTargetBands,
|
||||
type DashboardTargetBands,
|
||||
} from "@/lib/dashboard/bands";
|
||||
import type {
|
||||
HealthScoreBand,
|
||||
RestModeAnnotation,
|
||||
@@ -225,31 +217,8 @@ export interface DashboardSnapshotUser {
|
||||
* personalisation, no height → weight band), matching the client's
|
||||
* previous null-guards exactly.
|
||||
*/
|
||||
export interface DashboardTargetBands {
|
||||
/** Personalised BP target numbers (null when no DOB). */
|
||||
bpTargets: BpTargets | null;
|
||||
/** Systolic traffic range (null when no DOB). */
|
||||
bpSysRange: TrafficRange | null;
|
||||
/** Diastolic traffic range (null when no DOB). */
|
||||
bpDiaRange: TrafficRange | null;
|
||||
/** Resting-pulse display range (always present — AHA fallback). */
|
||||
pulseDisplayRange: {
|
||||
greenMin: number;
|
||||
greenMax: number;
|
||||
orangeMin: number;
|
||||
orangeMax: number;
|
||||
};
|
||||
/** Resting-pulse chart bands (always present — AHA fallback). */
|
||||
pulseBands: ValueBand[];
|
||||
/** Body-fat target range (gender-aware; always present). */
|
||||
bodyFatRange: { min: number; max: number };
|
||||
/** Body-fat chart bands (always present). */
|
||||
bodyFatBands: ValueBand[];
|
||||
/** Weight traffic range (null when no height). */
|
||||
weightRange: TrafficRange | null;
|
||||
/** Weight chart bands (null when no height). */
|
||||
weightBands: ValueBand[] | null;
|
||||
}
|
||||
export { buildDashboardBands, buildTargetBands };
|
||||
export type { DashboardTargetBands };
|
||||
|
||||
export interface DashboardSnapshotMoodEntry {
|
||||
date: string;
|
||||
@@ -661,84 +630,6 @@ async function buildExtras(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.18.6 — resolve the band / target math server-side from the same
|
||||
* helpers the client used. Pure projection over the profile facts; no DB
|
||||
* read. Mirrors the exact construction `page.tsx` ran (same helper calls,
|
||||
* same literal pulse-band colours/opacities, same null-guards) so the
|
||||
* snapshot path and the legacy client-compute path produce byte-identical
|
||||
* numbers.
|
||||
*/
|
||||
export function buildTargetBands(profile: {
|
||||
dateOfBirth: Date | null;
|
||||
gender: "MALE" | "FEMALE" | null;
|
||||
heightCm: number | null;
|
||||
}): DashboardTargetBands {
|
||||
const bpTargets = profile.dateOfBirth
|
||||
? getBpTargets(profile.dateOfBirth)
|
||||
: null;
|
||||
const pulseAge = getAgeFromDateOfBirth(profile.dateOfBirth);
|
||||
const pulseTarget = getPersonalizedPulseTarget(pulseAge, profile.gender);
|
||||
const bodyFatRange = getBodyFatTargetRange(profile.gender);
|
||||
const weightRange = profile.heightCm
|
||||
? buildWeightRangeFromHeight(profile.heightCm)
|
||||
: null;
|
||||
const weightBands = profile.heightCm
|
||||
? buildWeightBandsFromHeight(profile.heightCm, {
|
||||
lowerBound: 30,
|
||||
upperBound: 250,
|
||||
})
|
||||
: null;
|
||||
const bpSysRange = bpTargets
|
||||
? buildTrafficRange(bpTargets.sysLow, bpTargets.sysHigh)
|
||||
: null;
|
||||
const bpDiaRange = bpTargets
|
||||
? buildTrafficRange(bpTargets.diaLow, bpTargets.diaHigh)
|
||||
: null;
|
||||
const pulseBands = [
|
||||
{ min: 30, max: pulseTarget.orangeMin, color: "#ff5555", opacity: 0.16 },
|
||||
{
|
||||
min: pulseTarget.orangeMin,
|
||||
max: pulseTarget.greenMin,
|
||||
color: "#ffb86c",
|
||||
opacity: 0.18,
|
||||
},
|
||||
{
|
||||
min: pulseTarget.greenMin,
|
||||
max: pulseTarget.greenMax,
|
||||
color: "#50fa7b",
|
||||
opacity: 0.2,
|
||||
},
|
||||
{
|
||||
min: pulseTarget.greenMax,
|
||||
max: pulseTarget.orangeMax,
|
||||
color: "#ffb86c",
|
||||
opacity: 0.18,
|
||||
},
|
||||
{ min: pulseTarget.orangeMax, max: 220, color: "#ff5555", opacity: 0.16 },
|
||||
].filter((band) => band.max > band.min);
|
||||
const bodyFatBands = buildTrafficLightBands(bodyFatRange.min, bodyFatRange.max, {
|
||||
lowerBound: 2,
|
||||
upperBound: 55,
|
||||
});
|
||||
return {
|
||||
bpTargets,
|
||||
bpSysRange,
|
||||
bpDiaRange,
|
||||
pulseDisplayRange: {
|
||||
greenMin: pulseTarget.greenMin,
|
||||
greenMax: pulseTarget.greenMax,
|
||||
orangeMin: pulseTarget.orangeMin,
|
||||
orangeMax: pulseTarget.orangeMax,
|
||||
},
|
||||
pulseBands,
|
||||
bodyFatRange,
|
||||
bodyFatBands,
|
||||
weightRange,
|
||||
weightBands,
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse + validate the cached `dailyBriefing` block; null when unusable. */
|
||||
function parseCachedBriefing(
|
||||
cachedText: string | null,
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H,
|
||||
} from "@/lib/jobs/coach-nudge-thresholds";
|
||||
import { buildWeightRangeFromHeight } from "@/lib/analytics/value-bands";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import type { DashboardSnapshot } from "@/lib/dashboard/snapshot";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
@@ -93,16 +94,6 @@ export interface DashboardVerdict {
|
||||
cta: DashboardVerdictCta | null;
|
||||
}
|
||||
|
||||
/** YYYY-MM-DD key of `d`'s calendar day in `tz`. */
|
||||
function dayKeyInTz(d: Date, tz: string): string {
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
/** HH:mm wall-clock of `d` in `tz`. */
|
||||
function hmInTz(d: Date, tz: string): string {
|
||||
const parts = new Intl.DateTimeFormat("en-GB", {
|
||||
@@ -176,7 +167,7 @@ export function resolveDashboardVerdict(
|
||||
if (
|
||||
untilDue >= 0 &&
|
||||
untilDue <= DOSE_UPCOMING_WINDOW_MS &&
|
||||
dayKeyInTz(new Date(dueMs), tz) === dayKeyInTz(now, tz)
|
||||
userDayKey(new Date(dueMs), tz) === userDayKey(now, tz)
|
||||
) {
|
||||
return {
|
||||
variant: "doseUpcoming",
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
* legacy date string (`effectiveMoodTz`).
|
||||
*/
|
||||
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
export const DEFAULT_TIMEZONE = "Europe/Berlin";
|
||||
|
||||
/**
|
||||
@@ -35,14 +37,14 @@ export const DEFAULT_TIMEZONE = "Europe/Berlin";
|
||||
* timezone at write time. Used by `POST /api/mood-entries` and the
|
||||
* `PUT /api/mood-entries/:id` route when `moodLoggedAt` changes.
|
||||
*
|
||||
* The Swedish locale ("sv-SE") is the historical choice for ISO 8601
|
||||
* day output via `Intl.DateTimeFormat`; we keep it so the new path
|
||||
* produces byte-identical strings to the legacy `toBerlinDate()` for
|
||||
* Europe/Berlin users (the only path tested under the v1.4.24 suite).
|
||||
* Delegates to the canonical `userDayKey` so the mood day-bucket key
|
||||
* stays byte-identical to every other day-bucket surface. The ISO
|
||||
* YYYY-MM-DD shape is unchanged from the legacy sv-SE formatter; the
|
||||
* empty-tz fallback to Europe/Berlin is preserved here.
|
||||
*/
|
||||
export function moodDateKey(date: Date, tz: string): string {
|
||||
const safe = tz && tz.length > 0 ? tz : DEFAULT_TIMEZONE;
|
||||
return new Intl.DateTimeFormat("sv-SE", { timeZone: safe }).format(date);
|
||||
return userDayKey(date, safe);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user