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:
Marc-André Bombeck committed 2026-06-18 19:19:58 +02:00
commit 56b6d5b998
21 files changed
+408 -260

No files matched your search

+12 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+26 -1
View File
@@ -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
+7
View File
@@ -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>
);
+34 -1
View File
@@ -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
+12 -4
View File
@@ -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];
+4 -7
View File
@@ -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);
}
/**
+143
View File
@@ -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;
+8 -117
View File
@@ -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,
+2 -11
View File
@@ -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",
+7 -5
View File
@@ -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);
}
/**