mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge the app-wide UI audit into release/v1.42.0
This commit is contained in:
commit
d0e2238b65
85 files changed
+1457
-616
No files matched your search
@@ -133,7 +133,43 @@ test.describe("sidebar width transition", () => {
|
||||
});
|
||||
|
||||
// With room to spare there is no edge: the default rail is unchanged.
|
||||
// No native scrollbar in the rail (a classic one with arrow buttons
|
||||
// read as a foreign body); a fade marks the edge that has more.
|
||||
expect(
|
||||
await nav.evaluate((el) => getComputedStyle(el).scrollbarWidth),
|
||||
).toBe("none");
|
||||
expect(
|
||||
await nav.evaluate(
|
||||
(el) => (el as HTMLElement).offsetWidth - el.clientWidth,
|
||||
),
|
||||
).toBe(0);
|
||||
await expect(nav).toHaveAttribute("data-fade-top", "true");
|
||||
await expect(nav).not.toHaveAttribute("data-fade-bottom", "true");
|
||||
await nav.evaluate((el) => {
|
||||
el.scrollTop = 0;
|
||||
});
|
||||
await expect(nav).toHaveAttribute("data-fade-bottom", "true");
|
||||
await expect(nav).not.toHaveAttribute("data-fade-top", "true");
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 1400 });
|
||||
await expect(footer).not.toHaveAttribute("data-nav-overflows", "true");
|
||||
await expect(nav).not.toHaveAttribute("data-fade-bottom", "true");
|
||||
});
|
||||
|
||||
test("a 1366 × 768 window fits the rail without scrolling, collapsed and expanded", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1366, height: 768 });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
const nav = page.locator("aside nav").first();
|
||||
const toggle = page.locator('[data-slot="sidebar-collapse-toggle"]');
|
||||
await expect(nav).toBeVisible();
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await expect
|
||||
.poll(() => nav.evaluate((el) => el.scrollHeight - el.clientHeight))
|
||||
.toBeLessThanOrEqual(0);
|
||||
await toggle.click();
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
});
|
||||
});
|
||||
+21
-12
@@ -463,7 +463,6 @@
|
||||
"signOut": "Abmelden und Einladung annehmen"
|
||||
},
|
||||
"createAccount": "Konto erstellen",
|
||||
"newAccount": "Neues Konto erstellen",
|
||||
"backToPasskey": "Zurück zu Passkey",
|
||||
"passkeySetupLater": "Passkeys kannst du jederzeit in den Einstellungen einrichten.",
|
||||
"passwordMinLength": "Min. 12 Zeichen",
|
||||
@@ -583,7 +582,6 @@
|
||||
"quickAddLifeEvent": "Lebensereignis erfassen",
|
||||
"customizeDashboard": "Dashboard anpassen",
|
||||
"medicationIntakeQuickAdd": {
|
||||
"sheetTitle": "Medikamenteneinnahme erfassen",
|
||||
"sheetDescription": "Dosis, Zeitpunkt und Medikament in wenigen Tipps protokollieren.",
|
||||
"medicationLabel": "Medikament",
|
||||
"medicationPlaceholder": "Medikament auswählen",
|
||||
@@ -764,7 +762,7 @@
|
||||
"valueWithUnit": "Wert ({unit})",
|
||||
"timestamp": "Zeitpunkt",
|
||||
"timestampBackdateHint": "Datum und Uhrzeit anpassen, um einen vergangenen Wert zu erfassen.",
|
||||
"notesPlaceholder": "z.B. nach dem Essen",
|
||||
"notesPlaceholder": "z. B. nach dem Essen",
|
||||
"formReset": "Formular zurücksetzen",
|
||||
"saveError": "Fehler beim Speichern",
|
||||
"duplicateTimestamp": "Zum gewählten Zeitpunkt existiert bereits ein Messwert dieses Typs.",
|
||||
@@ -895,7 +893,7 @@
|
||||
"factorNo": "Nein",
|
||||
"factorRatingOption": "{label}: {value} von {max}",
|
||||
"tags": "Tags",
|
||||
"tagsPlaceholder": "z.B. Sport, Arbeit, Familie",
|
||||
"tagsPlaceholder": "z. B. Sport, Arbeit, Familie",
|
||||
"tagsHelp": "Kommagetrennt eingeben",
|
||||
"timestamp": "Zeitpunkt",
|
||||
"date": "Datum",
|
||||
@@ -2409,6 +2407,7 @@
|
||||
"days7Label": "7T",
|
||||
"days7Title": "Letzte 7 Tage",
|
||||
"days30Label": "30T",
|
||||
"daysNLabel": "{days}T",
|
||||
"days30Title": "Letzte 30 Tage",
|
||||
"days90Label": "90T",
|
||||
"days90Title": "Letzte 90 Tage",
|
||||
@@ -4743,6 +4742,12 @@
|
||||
"settingsBody": "HealthLog funktioniert vollständig ohne KI. Verbinde unten einen Anbieter, wenn du ein tägliches Briefing, kurze Notizen zu deinen Werten und den Coach möchtest.",
|
||||
"action": "KI einrichten",
|
||||
"dismiss": "Nicht jetzt"
|
||||
},
|
||||
"bmiHeight": {
|
||||
"coachPrefill": "Ich habe meine Körpergröße noch nicht eingetragen — warum ist der BMI wichtig, und was sollte ich wissen, bevor ich sie eintrage?"
|
||||
},
|
||||
"medicationsEmpty": {
|
||||
"coachPrefill": "Ich habe noch keine Medikamente angelegt — was sollte ich wissen, bevor ich hier anfange, meine Therapietreue zu verfolgen?"
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
@@ -6554,7 +6559,7 @@
|
||||
"createFailed": "Das Token konnte nicht erstellt werden. Bitte versuche es erneut."
|
||||
},
|
||||
"tokenMintMovedDescription": "Tokens werden dort ausgestellt, wo sie gelten: oben für Messwerte und Dokumente, oder im API-Endpoint eines Medikaments für genau dieses Medikament.",
|
||||
"tokenNamePlaceholder": "Token-Name (z.B. iPhone Shortcut)",
|
||||
"tokenNamePlaceholder": "Token-Name (z. B. iPhone Shortcut)",
|
||||
"tokenCreated": "Token erstellt — kopiere ihn jetzt! Er wird nicht erneut angezeigt.",
|
||||
"tokenCopy": "Token kopieren",
|
||||
"tokenCopied": "Token in die Zwischenablage kopiert",
|
||||
@@ -8017,7 +8022,9 @@
|
||||
"noneUnlockedYet": "Noch keine Erfolge freigeschaltet.",
|
||||
"pointsValue": "{points} Punkte",
|
||||
"metricCount": "{count}",
|
||||
"metricDays": "{count} Tage",
|
||||
"metricDaysOne": "{count} Tag",
|
||||
"metricDaysFew": "{count} Tage",
|
||||
"metricDaysOther": "{count} Tage",
|
||||
"metricPercent": "{count}%",
|
||||
"badges": {
|
||||
"intakeTotal1": {
|
||||
@@ -8497,6 +8504,10 @@
|
||||
"currentValue": "Aktuell: {value} {unit}",
|
||||
"targetRangeValue": "Zielbereich: {min}–{max} {unit}",
|
||||
"sourceLabel": "Quelle: {source}",
|
||||
"sourceNames": {
|
||||
"moodEntries": "Stimmungseinträge",
|
||||
"sevenDays": "7 Tage"
|
||||
},
|
||||
"inTarget": "Im Zielbereich",
|
||||
"aboveTarget": "{delta} {unit} über Zielbereich",
|
||||
"belowTarget": "{delta} {unit} unter Zielbereich",
|
||||
@@ -8588,7 +8599,7 @@
|
||||
"noActiveMedications": "Keine aktiven Medikamente gefunden.",
|
||||
"whichMedication": "<b>Welches Medikament eingenommen?</b>",
|
||||
"cancelButton": "Abbrechen",
|
||||
"askMedicationName": "Bitte Medikament angeben, z.B. <b>genommen Ramipril</b>.",
|
||||
"askMedicationName": "Bitte Medikament angeben, z. B. <b>genommen Ramipril</b>.",
|
||||
"medicationNotFoundExact": "Medikament nicht gefunden. Bitte Namen exakt wie in HealthLog senden.",
|
||||
"helpHeader": "<b>Verfügbare Befehle:</b>",
|
||||
"helpBody": "/help — Diese Hilfe anzeigen\n/add — Einnahme erfassen\n/start — Bot starten\n\n<b>Textbefehle:</b>\ngenommen <Name> — Einnahme bestätigen\n\n<b>Über die Buttons in Erinnerungen:</b>\n• Genommen — Einnahme bestätigen\n• 🕐 1h / 🕐 3h — Erinnerung verschieben\n• ⏭ Überspringen — Einnahme überspringen\n• ✓ Bestätigen — Verpasste Einnahme bestätigen",
|
||||
@@ -9931,7 +9942,7 @@
|
||||
"loadError": "Diese Episode konnte nicht geladen werden.",
|
||||
"backToList": "Zurück zum Journal",
|
||||
"detail": {
|
||||
"note": "Note"
|
||||
"note": "Notiz"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Episode bearbeiten",
|
||||
@@ -10747,7 +10758,7 @@
|
||||
},
|
||||
"filter": {
|
||||
"searchLabel": "Dokumente durchsuchen",
|
||||
"searchPlaceholder": "Titel oder Dateiname suchen…",
|
||||
"searchPlaceholder": "Titel, Dateiname",
|
||||
"typeGroup": "Dokumenttyp",
|
||||
"conditionGroup": "Erkrankung",
|
||||
"procedureGroup": "Eingriffe und Operationen",
|
||||
@@ -12036,8 +12047,7 @@
|
||||
"title": "Praxen und Ärzte",
|
||||
"description": "Dein eigenes Adressbuch — einmal tippen, überall auswählen.",
|
||||
"linkLabel": "Adressbuch",
|
||||
"add": "Eintrag",
|
||||
"searchPlaceholder": "Name, Praxis oder Fachrichtung suchen",
|
||||
"searchPlaceholder": "Name, Praxis oder Fach suchen",
|
||||
"loadError": "Adressbuch konnte nicht geladen werden.",
|
||||
"emptyTitle": "Noch kein Eintrag",
|
||||
"emptyDescription": "Lege eine Praxis an, damit du sie beim Termin auswählen kannst.",
|
||||
@@ -12133,7 +12143,6 @@
|
||||
"unreadableLabel": "Unlesbares Symptom",
|
||||
"intensityPill": "{value}/10",
|
||||
"entry": {
|
||||
"sheetTitle": "Symptom erfassen",
|
||||
"sheetDescription": "Eines deiner eigenen Symptome, wie stark und wann.",
|
||||
"symptom": "Symptom",
|
||||
"symptomRequired": "Wähle ein Symptom.",
|
||||
|
||||
+14
-5
@@ -463,7 +463,6 @@
|
||||
"signOut": "Sign out and accept the invitation"
|
||||
},
|
||||
"createAccount": "Create account",
|
||||
"newAccount": "Create new account",
|
||||
"backToPasskey": "Back to Passkey",
|
||||
"passkeySetupLater": "You can set up passkeys at any time in Settings.",
|
||||
"passwordMinLength": "Min. 12 characters",
|
||||
@@ -583,7 +582,6 @@
|
||||
"quickAddLifeEvent": "Log life event",
|
||||
"customizeDashboard": "Customize dashboard",
|
||||
"medicationIntakeQuickAdd": {
|
||||
"sheetTitle": "Log medication intake",
|
||||
"sheetDescription": "Record dose, time, and medication in a few taps.",
|
||||
"medicationLabel": "Medication",
|
||||
"medicationPlaceholder": "Select a medication",
|
||||
@@ -2409,6 +2407,7 @@
|
||||
"days7Label": "7d",
|
||||
"days7Title": "Last 7 days",
|
||||
"days30Label": "30d",
|
||||
"daysNLabel": "{days}d",
|
||||
"days30Title": "Last 30 days",
|
||||
"days90Label": "90d",
|
||||
"days90Title": "Last 90 days",
|
||||
@@ -4743,6 +4742,12 @@
|
||||
"settingsBody": "HealthLog works fully without AI. Connect a provider below if you want a daily briefing, short notes on your readings and the Coach.",
|
||||
"action": "Set up AI",
|
||||
"dismiss": "Not now"
|
||||
},
|
||||
"bmiHeight": {
|
||||
"coachPrefill": "I haven’t set my height yet — why does BMI matter, and what should I know before I configure it?"
|
||||
},
|
||||
"medicationsEmpty": {
|
||||
"coachPrefill": "I haven’t added any medications yet — what should I know before I start tracking medication compliance here?"
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
@@ -8017,7 +8022,9 @@
|
||||
"noneUnlockedYet": "No achievements unlocked yet.",
|
||||
"pointsValue": "{points} points",
|
||||
"metricCount": "{count}",
|
||||
"metricDays": "{count} days",
|
||||
"metricDaysOne": "{count} day",
|
||||
"metricDaysFew": "{count} days",
|
||||
"metricDaysOther": "{count} days",
|
||||
"metricPercent": "{count}%",
|
||||
"badges": {
|
||||
"intakeTotal1": {
|
||||
@@ -8497,6 +8504,10 @@
|
||||
"currentValue": "Current: {value} {unit}",
|
||||
"targetRangeValue": "Target: {min}–{max} {unit}",
|
||||
"sourceLabel": "Source: {source}",
|
||||
"sourceNames": {
|
||||
"moodEntries": "Mood entries",
|
||||
"sevenDays": "7 days"
|
||||
},
|
||||
"inTarget": "Within target range",
|
||||
"aboveTarget": "{delta} {unit} above target",
|
||||
"belowTarget": "{delta} {unit} below target",
|
||||
@@ -12036,7 +12047,6 @@
|
||||
"title": "Practices and doctors",
|
||||
"description": "Your own address book — type a practice once, pick it everywhere.",
|
||||
"linkLabel": "Address book",
|
||||
"add": "Entry",
|
||||
"searchPlaceholder": "Search name, practice or specialty",
|
||||
"loadError": "The address book could not be loaded.",
|
||||
"emptyTitle": "No entry yet",
|
||||
@@ -12133,7 +12143,6 @@
|
||||
"unreadableLabel": "Unreadable symptom",
|
||||
"intensityPill": "{value}/10",
|
||||
"entry": {
|
||||
"sheetTitle": "Log symptom",
|
||||
"sheetDescription": "One of your own symptoms, how strong and when.",
|
||||
"symptom": "Symptom",
|
||||
"symptomRequired": "Choose a symptom.",
|
||||
|
||||
+15
-6
@@ -463,7 +463,6 @@
|
||||
"signOut": "Cerrar sesión y aceptar la invitación"
|
||||
},
|
||||
"createAccount": "Crear cuenta",
|
||||
"newAccount": "Crear cuenta nueva",
|
||||
"backToPasskey": "Volver a passkey",
|
||||
"passkeySetupLater": "Puedes configurar passkeys más tarde en los ajustes.",
|
||||
"passwordMinLength": "Mín. 12 caracteres",
|
||||
@@ -583,7 +582,6 @@
|
||||
"quickAddLifeEvent": "Registrar acontecimiento vital",
|
||||
"customizeDashboard": "Personalizar panel",
|
||||
"medicationIntakeQuickAdd": {
|
||||
"sheetTitle": "Registrar toma de medicación",
|
||||
"sheetDescription": "Registra dosis, hora y medicamento en unos pocos toques.",
|
||||
"medicationLabel": "Medicamento",
|
||||
"medicationPlaceholder": "Selecciona un medicamento",
|
||||
@@ -2409,6 +2407,7 @@
|
||||
"days7Label": "7 d",
|
||||
"days7Title": "Últimos 7 días",
|
||||
"days30Label": "30 d",
|
||||
"daysNLabel": "{days} d",
|
||||
"days30Title": "Últimos 30 días",
|
||||
"days90Label": "90 d",
|
||||
"days90Title": "Últimos 90 días",
|
||||
@@ -4743,6 +4742,12 @@
|
||||
"settingsBody": "HealthLog funciona por completo sin IA. Conecta un proveedor abajo si quieres un resumen diario, notas breves sobre tus mediciones y el Coach.",
|
||||
"action": "Configurar IA",
|
||||
"dismiss": "Ahora no"
|
||||
},
|
||||
"bmiHeight": {
|
||||
"coachPrefill": "Todavía no he indicado mi altura: ¿por qué importa el IMC y qué debería saber antes de configurarla?"
|
||||
},
|
||||
"medicationsEmpty": {
|
||||
"coachPrefill": "Todavía no he añadido medicamentos: ¿qué debería saber antes de empezar a seguir aquí la adherencia?"
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
@@ -8017,7 +8022,9 @@
|
||||
"noneUnlockedYet": "Aún no has desbloqueado ningún logro.",
|
||||
"pointsValue": "{points} puntos",
|
||||
"metricCount": "{count}",
|
||||
"metricDays": "{count} días",
|
||||
"metricDaysOne": "{count} día",
|
||||
"metricDaysFew": "{count} días",
|
||||
"metricDaysOther": "{count} días",
|
||||
"metricPercent": "{count}%",
|
||||
"badges": {
|
||||
"intakeTotal1": {
|
||||
@@ -8497,6 +8504,10 @@
|
||||
"currentValue": "Actual: {value} {unit}",
|
||||
"targetRangeValue": "Rango objetivo: {min}–{max} {unit}",
|
||||
"sourceLabel": "Fuente: {source}",
|
||||
"sourceNames": {
|
||||
"moodEntries": "Entradas de ánimo",
|
||||
"sevenDays": "7 días"
|
||||
},
|
||||
"inTarget": "En rango objetivo",
|
||||
"aboveTarget": "{delta} {unit} por encima del rango",
|
||||
"belowTarget": "{delta} {unit} por debajo del rango",
|
||||
@@ -9931,7 +9942,7 @@
|
||||
"loadError": "No se pudo cargar este episodio.",
|
||||
"backToList": "Volver al diario",
|
||||
"detail": {
|
||||
"note": "Note"
|
||||
"note": "Nota"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Editar episodio",
|
||||
@@ -12036,7 +12047,6 @@
|
||||
"title": "Consultas y médicos",
|
||||
"description": "Tu propia agenda: escribe una consulta una vez y elígela en todas partes.",
|
||||
"linkLabel": "Agenda",
|
||||
"add": "Entrada",
|
||||
"searchPlaceholder": "Buscar nombre, consulta o especialidad",
|
||||
"loadError": "No se pudo cargar la agenda.",
|
||||
"emptyTitle": "Todavía no hay entradas",
|
||||
@@ -12133,7 +12143,6 @@
|
||||
"unreadableLabel": "Síntoma ilegible",
|
||||
"intensityPill": "{value}/10",
|
||||
"entry": {
|
||||
"sheetTitle": "Registrar síntoma",
|
||||
"sheetDescription": "Uno de tus propios síntomas, su intensidad y cuándo.",
|
||||
"symptom": "Síntoma",
|
||||
"symptomRequired": "Elige un síntoma.",
|
||||
|
||||
+14
-5
@@ -463,7 +463,6 @@
|
||||
"signOut": "Se déconnecter et accepter l'invitation"
|
||||
},
|
||||
"createAccount": "Créer un compte",
|
||||
"newAccount": "Créer un nouveau compte",
|
||||
"backToPasskey": "Revenir à la passkey",
|
||||
"passkeySetupLater": "Tu peux configurer les passkeys plus tard dans les paramètres.",
|
||||
"passwordMinLength": "Min. 12 caractères",
|
||||
@@ -583,7 +582,6 @@
|
||||
"quickAddLifeEvent": "Enregistrer un événement de vie",
|
||||
"customizeDashboard": "Personnaliser le tableau de bord",
|
||||
"medicationIntakeQuickAdd": {
|
||||
"sheetTitle": "Saisir une prise de médicament",
|
||||
"sheetDescription": "Enregistre la dose, l’heure et le médicament en quelques touches.",
|
||||
"medicationLabel": "Médicament",
|
||||
"medicationPlaceholder": "Sélectionner un médicament",
|
||||
@@ -2409,6 +2407,7 @@
|
||||
"days7Label": "7 j",
|
||||
"days7Title": "7 derniers jours",
|
||||
"days30Label": "30 j",
|
||||
"daysNLabel": "{days} j",
|
||||
"days30Title": "30 derniers jours",
|
||||
"days90Label": "90 j",
|
||||
"days90Title": "90 derniers jours",
|
||||
@@ -4743,6 +4742,12 @@
|
||||
"settingsBody": "HealthLog fonctionne entièrement sans IA. Connectez un fournisseur ci-dessous si vous voulez un briefing quotidien, de courtes notes sur vos mesures et le Coach.",
|
||||
"action": "Configurer l'IA",
|
||||
"dismiss": "Pas maintenant"
|
||||
},
|
||||
"bmiHeight": {
|
||||
"coachPrefill": "Je n’ai pas encore indiqué ma taille — pourquoi l’IMC compte-t-il, et que dois-je savoir avant de la renseigner ?"
|
||||
},
|
||||
"medicationsEmpty": {
|
||||
"coachPrefill": "Je n’ai encore ajouté aucun médicament — que dois-je savoir avant de suivre ici l’observance ?"
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
@@ -8017,7 +8022,9 @@
|
||||
"noneUnlockedYet": "Aucune réussite débloquée pour l’instant.",
|
||||
"pointsValue": "{points} points",
|
||||
"metricCount": "{count}",
|
||||
"metricDays": "{count} jours",
|
||||
"metricDaysOne": "{count} jour",
|
||||
"metricDaysFew": "{count} jours",
|
||||
"metricDaysOther": "{count} jours",
|
||||
"metricPercent": "{count}%",
|
||||
"badges": {
|
||||
"intakeTotal1": {
|
||||
@@ -8497,6 +8504,10 @@
|
||||
"currentValue": "Actuel : {value} {unit}",
|
||||
"targetRangeValue": "Plage cible : {min}–{max} {unit}",
|
||||
"sourceLabel": "Source : {source}",
|
||||
"sourceNames": {
|
||||
"moodEntries": "Entrées d’humeur",
|
||||
"sevenDays": "7 jours"
|
||||
},
|
||||
"inTarget": "Dans la plage cible",
|
||||
"aboveTarget": "{delta} {unit} au-dessus de la plage",
|
||||
"belowTarget": "{delta} {unit} en dessous de la plage",
|
||||
@@ -12036,7 +12047,6 @@
|
||||
"title": "Cabinets et médecins",
|
||||
"description": "Votre carnet d'adresses : saisissez un cabinet une fois, choisissez-le partout.",
|
||||
"linkLabel": "Carnet d'adresses",
|
||||
"add": "Entrée",
|
||||
"searchPlaceholder": "Rechercher un nom, un cabinet ou une spécialité",
|
||||
"loadError": "Impossible de charger le carnet d'adresses.",
|
||||
"emptyTitle": "Aucune entrée",
|
||||
@@ -12133,7 +12143,6 @@
|
||||
"unreadableLabel": "Symptôme illisible",
|
||||
"intensityPill": "{value}/10",
|
||||
"entry": {
|
||||
"sheetTitle": "Noter un symptôme",
|
||||
"sheetDescription": "L'un de tes propres symptômes, son intensité et quand.",
|
||||
"symptom": "Symptôme",
|
||||
"symptomRequired": "Choisis un symptôme.",
|
||||
|
||||
+15
-6
@@ -463,7 +463,6 @@
|
||||
"signOut": "Esci e accetta l'invito"
|
||||
},
|
||||
"createAccount": "Crea account",
|
||||
"newAccount": "Crea un nuovo account",
|
||||
"backToPasskey": "Torna a passkey",
|
||||
"passkeySetupLater": "Puoi configurare le passkey più tardi dalle impostazioni.",
|
||||
"passwordMinLength": "Min. 12 caratteri",
|
||||
@@ -583,7 +582,6 @@
|
||||
"quickAddLifeEvent": "Registra evento di vita",
|
||||
"customizeDashboard": "Personalizza dashboard",
|
||||
"medicationIntakeQuickAdd": {
|
||||
"sheetTitle": "Registra assunzione del farmaco",
|
||||
"sheetDescription": "Registra dose, orario e farmaco con pochi tap.",
|
||||
"medicationLabel": "Farmaco",
|
||||
"medicationPlaceholder": "Seleziona un farmaco",
|
||||
@@ -2409,6 +2407,7 @@
|
||||
"days7Label": "7 g",
|
||||
"days7Title": "Ultimi 7 giorni",
|
||||
"days30Label": "30 g",
|
||||
"daysNLabel": "{days} g",
|
||||
"days30Title": "Ultimi 30 giorni",
|
||||
"days90Label": "90 g",
|
||||
"days90Title": "Ultimi 90 giorni",
|
||||
@@ -4743,6 +4742,12 @@
|
||||
"settingsBody": "HealthLog funziona completamente senza IA. Collega un fornitore qui sotto se vuoi un riepilogo giornaliero, brevi note sulle tue misurazioni e il Coach.",
|
||||
"action": "Configura IA",
|
||||
"dismiss": "Non ora"
|
||||
},
|
||||
"bmiHeight": {
|
||||
"coachPrefill": "Non ho ancora inserito la mia altezza: perché conta il BMI e cosa dovrei sapere prima di impostarla?"
|
||||
},
|
||||
"medicationsEmpty": {
|
||||
"coachPrefill": "Non ho ancora aggiunto farmaci: cosa dovrei sapere prima di iniziare a monitorare qui l’aderenza?"
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
@@ -8017,7 +8022,9 @@
|
||||
"noneUnlockedYet": "Nessun obiettivo ancora sbloccato.",
|
||||
"pointsValue": "{points} punti",
|
||||
"metricCount": "{count}",
|
||||
"metricDays": "{count} giorni",
|
||||
"metricDaysOne": "{count} giorno",
|
||||
"metricDaysFew": "{count} giorni",
|
||||
"metricDaysOther": "{count} giorni",
|
||||
"metricPercent": "{count}%",
|
||||
"badges": {
|
||||
"intakeTotal1": {
|
||||
@@ -8497,6 +8504,10 @@
|
||||
"currentValue": "Attuale: {value} {unit}",
|
||||
"targetRangeValue": "Range obiettivo: {min}–{max} {unit}",
|
||||
"sourceLabel": "Fonte: {source}",
|
||||
"sourceNames": {
|
||||
"moodEntries": "Voci dell’umore",
|
||||
"sevenDays": "7 giorni"
|
||||
},
|
||||
"inTarget": "Nel range obiettivo",
|
||||
"aboveTarget": "{delta} {unit} sopra il range",
|
||||
"belowTarget": "{delta} {unit} sotto il range",
|
||||
@@ -9931,7 +9942,7 @@
|
||||
"loadError": "Impossibile caricare questo episodio.",
|
||||
"backToList": "Torna al diario",
|
||||
"detail": {
|
||||
"note": "Note"
|
||||
"note": "Nota"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Modifica episodio",
|
||||
@@ -12036,7 +12047,6 @@
|
||||
"title": "Studi e medici",
|
||||
"description": "La tua rubrica: scrivi uno studio una volta e selezionalo ovunque.",
|
||||
"linkLabel": "Rubrica",
|
||||
"add": "Voce",
|
||||
"searchPlaceholder": "Cerca nome, studio o specializzazione",
|
||||
"loadError": "Impossibile caricare la rubrica.",
|
||||
"emptyTitle": "Nessuna voce",
|
||||
@@ -12133,7 +12143,6 @@
|
||||
"unreadableLabel": "Sintomo illeggibile",
|
||||
"intensityPill": "{value}/10",
|
||||
"entry": {
|
||||
"sheetTitle": "Registra sintomo",
|
||||
"sheetDescription": "Uno dei tuoi sintomi, quanto forte e quando.",
|
||||
"symptom": "Sintomo",
|
||||
"symptomRequired": "Scegli un sintomo.",
|
||||
|
||||
+14
-5
@@ -463,7 +463,6 @@
|
||||
"signOut": "로그아웃하고 초대 수락하기"
|
||||
},
|
||||
"createAccount": "계정 만들기",
|
||||
"newAccount": "새 계정 만들기",
|
||||
"backToPasskey": "패스키로 돌아가기",
|
||||
"passkeySetupLater": "패스키는 설정에서 언제든 등록할 수 있어요.",
|
||||
"passwordMinLength": "최소 12자",
|
||||
@@ -583,7 +582,6 @@
|
||||
"quickAddLifeEvent": "삶의 사건 기록",
|
||||
"customizeDashboard": "대시보드 맞춤 설정",
|
||||
"medicationIntakeQuickAdd": {
|
||||
"sheetTitle": "복용 기록",
|
||||
"sheetDescription": "몇 번의 탭으로 약과 용량, 시각을 기록해요.",
|
||||
"medicationLabel": "약",
|
||||
"medicationPlaceholder": "약 선택",
|
||||
@@ -2500,6 +2498,7 @@
|
||||
"days7Label": "7일",
|
||||
"days7Title": "최근 7일",
|
||||
"days30Label": "30일",
|
||||
"daysNLabel": "{days}일",
|
||||
"days30Title": "최근 30일",
|
||||
"days90Label": "90일",
|
||||
"days90Title": "최근 90일",
|
||||
@@ -4743,6 +4742,12 @@
|
||||
"settingsBody": "HealthLog는 AI 없이도 모든 기능이 작동합니다. 일일 브리핑, 측정값에 대한 짧은 메모, 코치를 원하시면 아래에서 제공자를 연결하세요.",
|
||||
"action": "AI 설정",
|
||||
"dismiss": "나중에"
|
||||
},
|
||||
"bmiHeight": {
|
||||
"coachPrefill": "아직 키를 입력하지 않았어요. BMI가 왜 중요하고, 입력하기 전에 무엇을 알아야 하나요?"
|
||||
},
|
||||
"medicationsEmpty": {
|
||||
"coachPrefill": "아직 약을 추가하지 않았어요. 여기서 복약 순응도를 기록하기 전에 무엇을 알아야 하나요?"
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
@@ -8017,7 +8022,9 @@
|
||||
"noneUnlockedYet": "아직 달성한 업적이 없어요.",
|
||||
"pointsValue": "{points}포인트",
|
||||
"metricCount": "{count}",
|
||||
"metricDays": "{count}일",
|
||||
"metricDaysOne": "{count}일",
|
||||
"metricDaysFew": "{count}일",
|
||||
"metricDaysOther": "{count}일",
|
||||
"metricPercent": "{count}%",
|
||||
"badges": {
|
||||
"intakeTotal1": {
|
||||
@@ -8497,6 +8504,10 @@
|
||||
"currentValue": "현재: {value} {unit}",
|
||||
"targetRangeValue": "목표: {min}–{max} {unit}",
|
||||
"sourceLabel": "출처: {source}",
|
||||
"sourceNames": {
|
||||
"moodEntries": "기분 기록",
|
||||
"sevenDays": "7일"
|
||||
},
|
||||
"inTarget": "목표 범위 안",
|
||||
"aboveTarget": "목표보다 {delta} {unit} 높음",
|
||||
"belowTarget": "목표보다 {delta} {unit} 낮음",
|
||||
@@ -12036,7 +12047,6 @@
|
||||
"title": "병원과 의사",
|
||||
"description": "나만의 주소록이에요 — 병원을 한 번만 입력하면 어디서든 골라 쓸 수 있어요.",
|
||||
"linkLabel": "주소록",
|
||||
"add": "항목",
|
||||
"searchPlaceholder": "이름, 병원, 진료과 검색",
|
||||
"loadError": "주소록을 불러오지 못했어요.",
|
||||
"emptyTitle": "아직 항목이 없어요",
|
||||
@@ -12133,7 +12143,6 @@
|
||||
"unreadableLabel": "읽을 수 없는 증상",
|
||||
"intensityPill": "{value}/10",
|
||||
"entry": {
|
||||
"sheetTitle": "증상 기록",
|
||||
"sheetDescription": "직접 정한 증상과 강도, 시각을 기록합니다.",
|
||||
"symptom": "증상",
|
||||
"symptomRequired": "증상을 선택하세요.",
|
||||
|
||||
+15
-6
@@ -463,7 +463,6 @@
|
||||
"signOut": "Wyloguj się i przyjmij zaproszenie"
|
||||
},
|
||||
"createAccount": "Utwórz konto",
|
||||
"newAccount": "Utwórz nowe konto",
|
||||
"backToPasskey": "Wróć do passkey",
|
||||
"passkeySetupLater": "Passkey możesz skonfigurować później w ustawieniach.",
|
||||
"passwordMinLength": "Min. 12 znaków",
|
||||
@@ -583,7 +582,6 @@
|
||||
"quickAddLifeEvent": "Zapisz wydarzenie życiowe",
|
||||
"customizeDashboard": "Dostosuj pulpit",
|
||||
"medicationIntakeQuickAdd": {
|
||||
"sheetTitle": "Zarejestruj przyjęcie leku",
|
||||
"sheetDescription": "Zapisz dawkę, godzinę i lek w kilku stuknięciach.",
|
||||
"medicationLabel": "Lek",
|
||||
"medicationPlaceholder": "Wybierz lek",
|
||||
@@ -2409,6 +2407,7 @@
|
||||
"days7Label": "7 dni",
|
||||
"days7Title": "Ostatnie 7 dni",
|
||||
"days30Label": "30 dni",
|
||||
"daysNLabel": "{days} dni",
|
||||
"days30Title": "Ostatnie 30 dni",
|
||||
"days90Label": "90 dni",
|
||||
"days90Title": "Ostatnie 90 dni",
|
||||
@@ -4743,6 +4742,12 @@
|
||||
"settingsBody": "HealthLog działa w pełni bez AI. Podłącz dostawcę poniżej, jeśli chcesz codzienne podsumowanie, krótkie notatki o pomiarach i Coacha.",
|
||||
"action": "Skonfiguruj AI",
|
||||
"dismiss": "Nie teraz"
|
||||
},
|
||||
"bmiHeight": {
|
||||
"coachPrefill": "Nie podałem jeszcze wzrostu — dlaczego BMI ma znaczenie i co powinienem wiedzieć, zanim go ustawię?"
|
||||
},
|
||||
"medicationsEmpty": {
|
||||
"coachPrefill": "Nie dodałem jeszcze żadnych leków — co powinienem wiedzieć, zanim zacznę tu śledzić przestrzeganie zaleceń?"
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
@@ -8017,7 +8022,9 @@
|
||||
"noneUnlockedYet": "Nie odblokowano jeszcze żadnego osiągnięcia.",
|
||||
"pointsValue": "{points} punktów",
|
||||
"metricCount": "{count}",
|
||||
"metricDays": "{count} dni",
|
||||
"metricDaysOne": "{count} dzień",
|
||||
"metricDaysFew": "{count} dni",
|
||||
"metricDaysOther": "{count} dni",
|
||||
"metricPercent": "{count}%",
|
||||
"badges": {
|
||||
"intakeTotal1": {
|
||||
@@ -8497,6 +8504,10 @@
|
||||
"currentValue": "Aktualnie: {value} {unit}",
|
||||
"targetRangeValue": "Zakres docelowy: {min}–{max} {unit}",
|
||||
"sourceLabel": "Źródło: {source}",
|
||||
"sourceNames": {
|
||||
"moodEntries": "Wpisy nastroju",
|
||||
"sevenDays": "7 dni"
|
||||
},
|
||||
"inTarget": "W zakresie docelowym",
|
||||
"aboveTarget": "{delta} {unit} powyżej zakresu",
|
||||
"belowTarget": "{delta} {unit} poniżej zakresu",
|
||||
@@ -9931,7 +9942,7 @@
|
||||
"loadError": "Nie udało się wczytać tego epizodu.",
|
||||
"backToList": "Powrót do dziennika",
|
||||
"detail": {
|
||||
"note": "Note"
|
||||
"note": "Notatka"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Edytuj epizod",
|
||||
@@ -12036,7 +12047,6 @@
|
||||
"title": "Gabinety i lekarze",
|
||||
"description": "Twoja własna książka adresowa — wpisz gabinet raz, wybieraj go wszędzie.",
|
||||
"linkLabel": "Książka adresowa",
|
||||
"add": "Wpis",
|
||||
"searchPlaceholder": "Szukaj nazwiska, gabinetu lub specjalizacji",
|
||||
"loadError": "Nie udało się wczytać książki adresowej.",
|
||||
"emptyTitle": "Brak wpisów",
|
||||
@@ -12133,7 +12143,6 @@
|
||||
"unreadableLabel": "Nieczytelny objaw",
|
||||
"intensityPill": "{value}/10",
|
||||
"entry": {
|
||||
"sheetTitle": "Zapisz objaw",
|
||||
"sheetDescription": "Jeden z twoich objawów, jak silny i kiedy.",
|
||||
"symptom": "Objaw",
|
||||
"symptomRequired": "Wybierz objaw.",
|
||||
|
||||
@@ -60,7 +60,7 @@ function Section({
|
||||
<h2 className="text-xl font-semibold tracking-tight md:text-2xl">
|
||||
{title}
|
||||
</h2>
|
||||
<div className="text-muted-foreground space-y-3 text-sm leading-relaxed md:text-base">
|
||||
<div className="text-foreground space-y-3 text-sm leading-relaxed md:text-base">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
@@ -117,7 +117,7 @@ export default function AboutPage() {
|
||||
href="https://github.com/MBombeck/HealthLog"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
className="text-primary underline underline-offset-2"
|
||||
>
|
||||
github.com/MBombeck/HealthLog
|
||||
</a>{" "}
|
||||
@@ -145,7 +145,7 @@ export default function AboutPage() {
|
||||
href="https://www.maxmind.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
className="text-primary underline underline-offset-2"
|
||||
>
|
||||
www.maxmind.com
|
||||
</a>
|
||||
@@ -164,7 +164,7 @@ export default function AboutPage() {
|
||||
href="https://www.maxmind.com/en/geolite/eula"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
className="text-primary underline underline-offset-2"
|
||||
>
|
||||
GeoLite End User License Agreement
|
||||
</a>
|
||||
@@ -173,7 +173,7 @@ export default function AboutPage() {
|
||||
href="https://creativecommons.org/licenses/by-sa/4.0/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
className="text-primary underline underline-offset-2"
|
||||
>
|
||||
Creative Commons Attribution-ShareAlike 4.0 International License
|
||||
</a>{" "}
|
||||
|
||||
@@ -35,7 +35,7 @@ import { PageHeader } from "@/components/ui/page-header";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { useAchievementsQuery } from "@/lib/queries/use-achievements-query";
|
||||
import {
|
||||
@@ -81,12 +81,13 @@ const CATEGORY_LABEL_KEY: Record<AchievementCategory, string> = {
|
||||
function formatMetric(
|
||||
format: AchievementProgress["format"],
|
||||
value: number,
|
||||
t: (key: string, params?: Record<string, string | number>) => string,
|
||||
i18n: Pick<ReturnType<typeof useTranslations>, "t" | "tCount">,
|
||||
) {
|
||||
if (format === "percent")
|
||||
return t("achievements.metricPercent", { count: value });
|
||||
if (format === "days") return t("achievements.metricDays", { count: value });
|
||||
return t("achievements.metricCount", { count: value });
|
||||
return i18n.t("achievements.metricPercent", { count: value });
|
||||
// "1 Tag", not "1 Tage": the day count takes the plural form.
|
||||
if (format === "days") return i18n.tCount("achievements.metricDays", value);
|
||||
return i18n.t("achievements.metricCount", { count: value });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,9 +128,11 @@ export function groupByCategory(
|
||||
interface AchievementCardProps {
|
||||
achievement: AchievementProgress;
|
||||
t: ReturnType<typeof useTranslations>["t"];
|
||||
tCount: ReturnType<typeof useTranslations>["tCount"];
|
||||
}
|
||||
|
||||
function AchievementCard({ achievement, t }: AchievementCardProps) {
|
||||
function AchievementCard({ achievement, t, tCount }: AchievementCardProps) {
|
||||
const i18n = { t, tCount };
|
||||
const Icon = iconMap[achievement.icon] ?? Star;
|
||||
const unlocked = achievement.unlocked;
|
||||
|
||||
@@ -175,7 +178,7 @@ function AchievementCard({ achievement, t }: AchievementCardProps) {
|
||||
className={
|
||||
unlocked
|
||||
? "border-primary/30 from-primary/8 to-primary/0 rounded-xl border bg-gradient-to-br p-3"
|
||||
: "border-border bg-card/50 rounded-xl border p-3 opacity-70"
|
||||
: "border-border bg-card/50 rounded-xl border p-3"
|
||||
}
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
@@ -191,7 +194,7 @@ function AchievementCard({ achievement, t }: AchievementCardProps) {
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold">{t(achievement.titleKey)}</h3>
|
||||
<p className="text-muted-foreground line-clamp-2 text-xs">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(achievement.descriptionKey)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -215,8 +218,16 @@ function AchievementCard({ achievement, t }: AchievementCardProps) {
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("achievements.criterionHint", {
|
||||
current: formatMetric(achievement.format, achievement.current, t),
|
||||
target: formatMetric(achievement.format, achievement.target, t),
|
||||
current: formatMetric(
|
||||
achievement.format,
|
||||
achievement.current,
|
||||
i18n,
|
||||
),
|
||||
target: formatMetric(
|
||||
achievement.format,
|
||||
achievement.target,
|
||||
i18n,
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
{/* v1.4.33 IW9 — aria-label so the bar carries an accessible name. */}
|
||||
@@ -224,8 +235,16 @@ function AchievementCard({ achievement, t }: AchievementCardProps) {
|
||||
value={achievement.progressPercent}
|
||||
className="h-1.5"
|
||||
aria-label={t("achievements.criterionHint", {
|
||||
current: formatMetric(achievement.format, achievement.current, t),
|
||||
target: formatMetric(achievement.format, achievement.target, t),
|
||||
current: formatMetric(
|
||||
achievement.format,
|
||||
achievement.current,
|
||||
i18n,
|
||||
),
|
||||
target: formatMetric(
|
||||
achievement.format,
|
||||
achievement.target,
|
||||
i18n,
|
||||
),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
@@ -259,7 +278,9 @@ function AchievementCard({ achievement, t }: AchievementCardProps) {
|
||||
|
||||
export default function AchievementsPage() {
|
||||
const { user, isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const { t, tCount } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const i18n = { t, tCount };
|
||||
|
||||
// v1.18.0 — the achievements module gate. When the account has the
|
||||
// module turned off the whole page disappears: an empty state stands in
|
||||
@@ -335,9 +356,9 @@ export default function AchievementsPage() {
|
||||
{t("achievements.points")}
|
||||
</p>
|
||||
<p className="text-4xl leading-none font-bold md:text-5xl">
|
||||
{summary?.earnedPoints ?? 0}
|
||||
{fmt.integer(summary?.earnedPoints ?? 0)}
|
||||
<span className="text-muted-foreground ml-2 text-base font-medium">
|
||||
/ {summary?.totalPoints ?? 0}
|
||||
/ {fmt.integer(summary?.totalPoints ?? 0)}
|
||||
</span>
|
||||
</p>
|
||||
<Progress
|
||||
@@ -373,7 +394,7 @@ export default function AchievementsPage() {
|
||||
<p className="text-base font-semibold">
|
||||
{t(summary.nextAchievement.titleKey)}
|
||||
</p>
|
||||
<p className="text-muted-foreground line-clamp-2 text-xs">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(summary.nextAchievement.descriptionKey)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
@@ -381,13 +402,13 @@ export default function AchievementsPage() {
|
||||
{formatMetric(
|
||||
summary.nextAchievement.format,
|
||||
summary.nextAchievement.current,
|
||||
t,
|
||||
i18n,
|
||||
)}{" "}
|
||||
/{" "}
|
||||
{formatMetric(
|
||||
summary.nextAchievement.format,
|
||||
summary.nextAchievement.target,
|
||||
t,
|
||||
i18n,
|
||||
)}{" "}
|
||||
({summary.nextAchievement.progressPercent}%)
|
||||
</p>
|
||||
@@ -449,6 +470,7 @@ export default function AchievementsPage() {
|
||||
key={achievement.id}
|
||||
achievement={achievement}
|
||||
t={t}
|
||||
tCount={tCount}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -175,14 +175,11 @@ export default function RegisterPage() {
|
||||
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
<Logo className="text-primary" size={28} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-tight">
|
||||
{t("auth.createAccount")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{t("auth.newAccount")}
|
||||
</p>
|
||||
</div>
|
||||
{/* The title says it; a subtitle repeating "new account" said it
|
||||
twice. */}
|
||||
<h1 className="text-xl font-bold tracking-tight">
|
||||
{t("auth.createAccount")}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{inviteToken && (
|
||||
|
||||
@@ -219,7 +219,9 @@ function CoachConversationsBody({ readOnly }: { readOnly: boolean }) {
|
||||
className={cn(
|
||||
"group relative flex flex-wrap items-center gap-1.5 rounded-xl px-3 py-2.5",
|
||||
"text-sm transition-colors",
|
||||
"text-muted-foreground hover:bg-muted/40 hover:text-foreground",
|
||||
// The title is the reader's own content: foreground,
|
||||
// with the timestamp below as the muted meta line.
|
||||
"text-foreground hover:bg-muted/40",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -46,6 +46,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* `pointer-coarse:` — a finger is the primary pointer. The control
|
||||
* primitives (Button, tab triggers, the sidebar rail rows) grow their hit
|
||||
* area to the 44 px touch floor under it, so a phone or a touch tablet gets
|
||||
* reachable targets while a mouse keeps the compact desktop density.
|
||||
*/
|
||||
@custom-variant pointer-coarse {
|
||||
@media (pointer: coarse) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/* Extra-small breakpoint (30rem / 480px) for the narrowest phones — the
|
||||
default scale starts at sm (640px), which leaves a gap between an iPhone SE
|
||||
|
||||
@@ -46,8 +46,11 @@ export default function InsightsBlutdruckPage() {
|
||||
const { t } = useTranslations();
|
||||
const { compareBaseline } = useInsightsLayoutPrefs(user != null);
|
||||
|
||||
const { data: analytics, isEmpty } =
|
||||
useInsightsAnalytics("BLOOD_PRESSURE_SYS");
|
||||
const {
|
||||
data: analytics,
|
||||
isEmpty,
|
||||
isLoading: analyticsLoading,
|
||||
} = useInsightsAnalytics("BLOOD_PRESSURE_SYS");
|
||||
// v1.12.4 — blood pressure is two series, so it stacks two stat strips
|
||||
// (systolic / diastolic) rather than the single-series strip the other
|
||||
// metrics carry. Both summaries ride the same `summaries` slice.
|
||||
@@ -128,6 +131,7 @@ export default function InsightsBlutdruckPage() {
|
||||
// brushing the single chart reports per-type windowed stats and each
|
||||
// column reads its own half.
|
||||
<MetricStatStrip
|
||||
pending={analyticsLoading}
|
||||
groupLabel={t("insights.bloodPressureSectionTitle")}
|
||||
series={[
|
||||
{
|
||||
|
||||
@@ -95,7 +95,7 @@ export default function InsightsBmiPage() {
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CoachLaunchButton prefill="I haven't set my height yet — why does BMI matter, and what should I know before I configure it?" />
|
||||
<CoachLaunchButton prefill={t("insights.bmiHeight.coachPrefill")} />
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,8 +122,10 @@ function CatalogRowSkeleton() {
|
||||
data-slot="catalog-row-skeleton"
|
||||
className="flex items-start justify-between gap-3 py-2"
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
{/* Sized to the loaded row (a 22 px name-and-badge line over a 16 px
|
||||
source line), so the groups below do not move when it lands. */}
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<Skeleton className="h-5.5 w-32" />
|
||||
<Skeleton className="h-3 w-48" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-20 shrink-0" />
|
||||
|
||||
@@ -208,7 +208,9 @@ export default function InsightsMedikamentePage() {
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CoachLaunchButton prefill="I haven't added any medications yet — what should I know before I start tracking medication compliance here?" />
|
||||
<CoachLaunchButton
|
||||
prefill={t("insights.medicationsEmpty.coachPrefill")}
|
||||
/>
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,7 +63,11 @@ export default function InsightsPulsPage() {
|
||||
const { t } = useTranslations();
|
||||
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
|
||||
|
||||
const { data: analytics, isEmpty } = useInsightsAnalytics("PULSE");
|
||||
const {
|
||||
data: analytics,
|
||||
isEmpty,
|
||||
isLoading: analyticsLoading,
|
||||
} = useInsightsAnalytics("PULSE");
|
||||
// VO₂ max rides on the same `/api/analytics` bundle; we only need the
|
||||
// sample count to decide whether to surface the cross-link to the
|
||||
// dedicated cardio-fitness page.
|
||||
@@ -123,6 +127,7 @@ export default function InsightsPulsPage() {
|
||||
explainerMetric="pulse"
|
||||
statStrip={
|
||||
<MetricStatStrip
|
||||
pending={analyticsLoading}
|
||||
summary={pulseSummary}
|
||||
unit="bpm"
|
||||
seriesLabel={t("insights.pulseSectionTitle")}
|
||||
|
||||
@@ -48,7 +48,11 @@ export default function InsightsGewichtPage() {
|
||||
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
|
||||
const unitDisplay = useUnitDisplay();
|
||||
|
||||
const { data: analytics, isEmpty } = useInsightsAnalytics("WEIGHT");
|
||||
const {
|
||||
data: analytics,
|
||||
isEmpty,
|
||||
isLoading: analyticsLoading,
|
||||
} = useInsightsAnalytics("WEIGHT");
|
||||
const weightSummary = analytics?.summaries?.WEIGHT ?? null;
|
||||
|
||||
// v1.34 — the user's own weight target, read through the shared
|
||||
@@ -170,6 +174,7 @@ export default function InsightsGewichtPage() {
|
||||
explainerMetric="weight"
|
||||
statStrip={
|
||||
<MetricStatStrip
|
||||
pending={analyticsLoading}
|
||||
summary={displaySummary}
|
||||
unit={weightUnit}
|
||||
fractionDigits={weightTransform.decimals}
|
||||
|
||||
@@ -182,7 +182,7 @@ export default function MeasurementsPage() {
|
||||
setReturnTo(null);
|
||||
}
|
||||
}}
|
||||
title={t("measurements.addMeasurement")}
|
||||
title={t("dashboard.quickAddMeasurement")}
|
||||
footer={<div ref={setFooterEl} className="flex w-full" />}
|
||||
>
|
||||
<MeasurementForm
|
||||
|
||||
@@ -44,8 +44,7 @@ export default function MentalWellbeingPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
<MentalWellbeing />
|
||||
</div>
|
||||
// The shell owns the page gutter; a second one here doubled it.
|
||||
<MentalWellbeing />
|
||||
);
|
||||
}
|
||||
@@ -126,7 +126,7 @@ export default function MoodPageClient() {
|
||||
<ResponsiveSheet
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
title={t("mood.addEntry")}
|
||||
title={t("dashboard.quickAddMood")}
|
||||
footer={<div ref={setFooterEl} className="flex w-full" />}
|
||||
>
|
||||
<MoodForm
|
||||
|
||||
+11
-1
@@ -28,7 +28,11 @@ import {
|
||||
TrendingUp,
|
||||
Wind,
|
||||
} from "lucide-react";
|
||||
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
import {
|
||||
convertGlucose,
|
||||
glucoseFractionDigits,
|
||||
resolveGlucoseUnit,
|
||||
} from "@/lib/glucose";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
resolveDashboardLayout,
|
||||
@@ -1039,6 +1043,7 @@ export default function DashboardPageClient({
|
||||
node: (
|
||||
<TrendCard
|
||||
key="bp-sys"
|
||||
fractionDigits={0}
|
||||
label={t("dashboard.bloodPressureSysShort")}
|
||||
latest={sys?.latest ?? null}
|
||||
unit="mmHg"
|
||||
@@ -1080,6 +1085,7 @@ export default function DashboardPageClient({
|
||||
node: (
|
||||
<TrendCard
|
||||
key="bp-dia"
|
||||
fractionDigits={0}
|
||||
label={t("dashboard.bloodPressureDiaShort")}
|
||||
latest={dia?.latest ?? null}
|
||||
unit="mmHg"
|
||||
@@ -1121,6 +1127,7 @@ export default function DashboardPageClient({
|
||||
node: (
|
||||
<TrendCard
|
||||
key="pulse"
|
||||
fractionDigits={0}
|
||||
label={t("dashboard.pulseShort")}
|
||||
latest={pulseTileSummary?.latest ?? null}
|
||||
unit="bpm"
|
||||
@@ -1243,6 +1250,7 @@ export default function DashboardPageClient({
|
||||
node: (
|
||||
<TrendCard
|
||||
key="steps"
|
||||
fractionDigits={0}
|
||||
label={t("dashboard.stepsShort") ?? "Steps"}
|
||||
latest={stepsSummary?.latest ?? null}
|
||||
unit=""
|
||||
@@ -1303,6 +1311,7 @@ export default function DashboardPageClient({
|
||||
node: (
|
||||
<TrendCard
|
||||
key="hrv"
|
||||
fractionDigits={0}
|
||||
// Name the measure when the RMSSD series is the one on show.
|
||||
// Without it a ring / strap reading (typically 20-60 ms) sits
|
||||
// under the same label as an SDNN one and reads as a collapse
|
||||
@@ -1631,6 +1640,7 @@ export default function DashboardPageClient({
|
||||
: null
|
||||
}
|
||||
unit={displayGlucoseUnit}
|
||||
fractionDigits={glucoseFractionDigits(displayGlucoseUnit)}
|
||||
avg7={
|
||||
s.avg7 != null
|
||||
? convertGlucose(s.avg7, displayGlucoseUnit)
|
||||
|
||||
+45
-46
@@ -71,7 +71,7 @@ function Section({
|
||||
{`${numberLabel}. ${titleEn}`}
|
||||
</h2>
|
||||
<div
|
||||
className="text-muted-foreground space-y-3 text-sm leading-relaxed md:text-base"
|
||||
className="text-foreground space-y-3 text-sm leading-relaxed md:text-base"
|
||||
data-slot="privacy-section-de"
|
||||
lang="de"
|
||||
>
|
||||
@@ -94,7 +94,7 @@ function Section({
|
||||
</span>
|
||||
</summary>
|
||||
<div
|
||||
className="text-muted-foreground space-y-3 px-3 pb-3 text-sm leading-relaxed md:text-base"
|
||||
className="text-foreground space-y-3 px-3 pb-3 text-sm leading-relaxed md:text-base"
|
||||
lang="en"
|
||||
>
|
||||
{bodyEn}
|
||||
@@ -164,7 +164,7 @@ function SubProcessor({
|
||||
href={policyUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
className="text-primary underline underline-offset-2"
|
||||
>
|
||||
Privacy policy / Datenschutzerklärung
|
||||
</a>
|
||||
@@ -358,7 +358,7 @@ export default function PrivacyPage() {
|
||||
href="https://github.com/MBombeck/HealthLog"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
className="text-primary underline underline-offset-2"
|
||||
>
|
||||
github.com/MBombeck/HealthLog
|
||||
</a>{" "}
|
||||
@@ -386,13 +386,14 @@ export default function PrivacyPage() {
|
||||
bodyEn={
|
||||
<>
|
||||
<p>
|
||||
HealthLog is an open-source, self-hostable personal-health-
|
||||
tracking application. Source code is published at{" "}
|
||||
HealthLog is an open-source, self-hostable
|
||||
personal-health-tracking application. Source code is published
|
||||
at{" "}
|
||||
<a
|
||||
href="https://github.com/MBombeck/HealthLog"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
className="text-primary underline underline-offset-2"
|
||||
>
|
||||
github.com/MBombeck/HealthLog
|
||||
</a>{" "}
|
||||
@@ -406,7 +407,7 @@ export default function PrivacyPage() {
|
||||
</p>
|
||||
<p>
|
||||
The controller under Art. 4 (7) GDPR is the operator of this
|
||||
instance (an individual based in Germany). Self- hosted
|
||||
instance (an individual based in Germany). Self-hosted
|
||||
deployments controlled by a different operator are governed by
|
||||
that operator's own privacy policy; this document applies
|
||||
only to this instance.
|
||||
@@ -454,7 +455,7 @@ export default function PrivacyPage() {
|
||||
</li>
|
||||
<li>
|
||||
Profil-Metadaten: Sprache, Zeitzone, Geburtsdatum (optional),
|
||||
biologisches Geschlecht (optional), Körper- größe (optional) —
|
||||
biologisches Geschlecht (optional), Körpergröße (optional) —
|
||||
zur Berechnung altersgerechter Zielbereiche und des BMI.
|
||||
</li>
|
||||
</ul>
|
||||
@@ -462,7 +463,7 @@ export default function PrivacyPage() {
|
||||
2.2 Manuell erfasste Gesundheitsdaten
|
||||
</h3>
|
||||
<p>
|
||||
Jeder in den App-Formularen eingegebene Wert, einschließ- lich
|
||||
Jeder in den App-Formularen eingegebene Wert, einschließlich
|
||||
Zeitstempel, optionaler Notiz und Metrik-Typ. Die vollständige
|
||||
Aufzählung der unterstützten Metriken steht im OpenAPI-Schema;
|
||||
die nutzerrelevante Teilmenge umfasst Körpergewicht, Körperfett,
|
||||
@@ -587,9 +588,9 @@ export default function PrivacyPage() {
|
||||
</ul>
|
||||
<p>
|
||||
Schreibzugriff wird für eine Teilmenge angefordert (Körpermasse,
|
||||
Blutdruck systolisch / diastolisch, Blut- zucker), damit
|
||||
manuelle Einträge in der iOS-App zurück nach HealthKit fließen
|
||||
können. Der Nutzer steuert beide Richtungen über die
|
||||
Blutdruck systolisch / diastolisch, Blutzucker), damit manuelle
|
||||
Einträge in der iOS-App zurück nach HealthKit fließen können.
|
||||
Der Nutzer steuert beide Richtungen über die
|
||||
Berechtigungsoberfläche der iOS- Health-App und kann jederzeit
|
||||
widerrufen.
|
||||
</p>
|
||||
@@ -598,25 +599,24 @@ export default function PrivacyPage() {
|
||||
</h3>
|
||||
<p>
|
||||
Verbindet der Nutzer ein Withings-Konto, speichert HealthLog die
|
||||
OAuth-Refresh- und -Access-Token (spalten- weise verschlüsselt)
|
||||
OAuth-Refresh- und -Access-Token (spaltenweise verschlüsselt)
|
||||
sowie die Withings-Nutzerkennung. Webhook-getriebene
|
||||
Folgesynchronisierungen ziehen Körper- gewicht, Fettanteil,
|
||||
Folgesynchronisierungen ziehen Körpergewicht, Fettanteil,
|
||||
fettfreie Masse, Muskelmasse, Knochenmasse, Wassergehalt,
|
||||
Magermasse, Hydration, Blutdruck (systolisch / diastolisch) und
|
||||
Puls, Blut- zucker, Körpertemperatur (Basal- und
|
||||
Hauttemperatur), Aktivitätssummen (Schritte, Distanz, aktive
|
||||
Kalorien), Schlafsitzungen mit Pro-Stadien-Segmenten (Awake /
|
||||
Light / Deep / REM) sowie SpO₂ und Herzfrequenzvariabilität,
|
||||
soweit vorhanden.
|
||||
Puls, Blutzucker, Körpertemperatur (Basal- und Hauttemperatur),
|
||||
Aktivitätssummen (Schritte, Distanz, aktive Kalorien),
|
||||
Schlafsitzungen mit Pro-Stadien-Segmenten (Awake / Light / Deep
|
||||
/ REM) sowie SpO₂ und Herzfrequenzvariabilität, soweit
|
||||
vorhanden.
|
||||
</p>
|
||||
<h3 className="text-foreground pt-2 text-base font-semibold">
|
||||
2.5 Medikamente
|
||||
</h3>
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
<li>
|
||||
Aktive Verordnungen: Wirkstoffname, Stärke, Verabreich-
|
||||
ungsweg, Einnahmeplan, Behandlungsklasse (Standard oder
|
||||
GLP-1).
|
||||
Aktive Verordnungen: Wirkstoffname, Stärke, Verabreichungsweg,
|
||||
Einnahmeplan, Behandlungsklasse (Standard oder GLP-1).
|
||||
</li>
|
||||
<li>
|
||||
Einnahme-Ereignisse: geplante Zeit, tatsächliche Zeit, Status
|
||||
@@ -716,8 +716,8 @@ export default function PrivacyPage() {
|
||||
<code className="bg-muted rounded px-1 py-0.5 text-xs">
|
||||
X-Device-Id
|
||||
</code>{" "}
|
||||
-Header — für Mehrgeräte-Synchronisation und Miss-
|
||||
brauchsschutz.
|
||||
-Header — für Mehrgeräte-Synchronisation und
|
||||
Missbrauchsschutz.
|
||||
</li>
|
||||
<li>
|
||||
Apple-Push-Notification-Service-(APNs)-Geräte-Token und
|
||||
@@ -759,8 +759,8 @@ export default function PrivacyPage() {
|
||||
</h3>
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
<li>
|
||||
Keine Werbe-Identifier, Fingerprints oder anwendungs-
|
||||
übergreifenden Tracker.
|
||||
Keine Werbe-Identifier, Fingerprints oder
|
||||
anwendungsübergreifenden Tracker.
|
||||
</li>
|
||||
<li>Keine Zahlungsdaten (es gibt keinen Bezahltarif).</li>
|
||||
<li>
|
||||
@@ -938,11 +938,11 @@ export default function PrivacyPage() {
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Write access is requested for a subset (body mass, blood-
|
||||
pressure systolic / diastolic, blood glucose) so that manual
|
||||
entries made inside the iOS app can flow back into HealthKit.
|
||||
The user controls both directions in the iOS Health app's
|
||||
permission surface and may revoke at any time.
|
||||
Write access is requested for a subset (body mass,
|
||||
blood-pressure systolic / diastolic, blood glucose) so that
|
||||
manual entries made inside the iOS app can flow back into
|
||||
HealthKit. The user controls both directions in the iOS Health
|
||||
app's permission surface and may revoke at any time.
|
||||
</p>
|
||||
<h3 className="text-foreground pt-2 text-base font-semibold">
|
||||
2.4 Withings sync (optional)
|
||||
@@ -1057,8 +1057,8 @@ export default function PrivacyPage() {
|
||||
</h3>
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
<li>
|
||||
A device identifier (random UUID generated client- side,
|
||||
stored in the iOS Keychain) sent as the{" "}
|
||||
A device identifier (random UUID generated client-side, stored
|
||||
in the iOS Keychain) sent as the{" "}
|
||||
<code className="bg-muted rounded px-1 py-0.5 text-xs">
|
||||
X-Device-Id
|
||||
</code>{" "}
|
||||
@@ -1135,16 +1135,15 @@ export default function PrivacyPage() {
|
||||
Authentifizierungsdaten
|
||||
</span>{" "}
|
||||
— Identifikation des Nutzers und Sicherung der Sitzung.
|
||||
Rechtsgrundlage: Art. 6 Abs. 1 lit. b DSGVO (Vertrags-
|
||||
erfüllung).
|
||||
Rechtsgrundlage: Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung).
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground font-medium">
|
||||
Gesundheitsdaten
|
||||
</span>{" "}
|
||||
— Darstellung von Verläufen, Berechnung der Zielwerte- Treue,
|
||||
Erzeugung des Coach-Snapshots auf Abruf. Rechts- grundlage: Art.
|
||||
9 Abs. 2 lit. a DSGVO (ausdrückliche Einwilligung für Daten
|
||||
Erzeugung des Coach-Snapshots auf Abruf. Rechtsgrundlage: Art. 9
|
||||
Abs. 2 lit. a DSGVO (ausdrückliche Einwilligung für Daten
|
||||
besonderer Kategorien).
|
||||
</li>
|
||||
<li>
|
||||
@@ -1420,8 +1419,8 @@ export default function PrivacyPage() {
|
||||
<span className="text-foreground font-medium">
|
||||
Kein HealthLog-iCloud-Speicher
|
||||
</span>{" "}
|
||||
— HealthLog speichert Server-Datenbank, lokale Gesundheits-
|
||||
caches, Offline-Outbox, ECG- und Workout-Payloads,
|
||||
— HealthLog speichert Server-Datenbank, lokale
|
||||
Gesundheits-Caches, Offline-Outbox, ECG- und Workout-Payloads,
|
||||
KI-Transkript-Cache, Medikamentenplaner-Datenbank sowie Widget-
|
||||
und Watch-Snapshots weder in iCloud noch in CloudKit. Lokale
|
||||
gesundheitsbezogene Speicher liegen in geschütztem App- oder
|
||||
@@ -1587,10 +1586,10 @@ export default function PrivacyPage() {
|
||||
<span className="text-foreground font-medium">
|
||||
Widerspruch und automatisierte Entscheidungen (Art. 21–22)
|
||||
</span>{" "}
|
||||
— der KI-Coach trifft keine automatisierten Entscheid- ungen im
|
||||
— der KI-Coach trifft keine automatisierten Entscheidungen im
|
||||
Sinne von Art. 22 DSGVO. Er erzeugt Texte zum Nachlesen; ohne
|
||||
ausdrückliche Bestätigung wird keine Aktion ausgeführt. Siehe
|
||||
Abschnitt 7 zur Medizinpro- dukte-Grenze.
|
||||
Abschnitt 7 zur Medizinprodukte-Grenze.
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground font-medium">
|
||||
@@ -1959,7 +1958,7 @@ export default function PrivacyPage() {
|
||||
</span>{" "}
|
||||
<a
|
||||
href="mailto:mbombeck@gmail.com"
|
||||
className="text-primary hover:underline"
|
||||
className="text-primary underline underline-offset-2"
|
||||
>
|
||||
mbombeck@gmail.com
|
||||
</a>
|
||||
@@ -1980,7 +1979,7 @@ export default function PrivacyPage() {
|
||||
href="https://github.com/MBombeck/HealthLog/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
className="text-primary underline underline-offset-2"
|
||||
>
|
||||
github.com/MBombeck/HealthLog/issues
|
||||
</a>{" "}
|
||||
@@ -2008,7 +2007,7 @@ export default function PrivacyPage() {
|
||||
</span>{" "}
|
||||
<a
|
||||
href="mailto:mbombeck@gmail.com"
|
||||
className="text-primary hover:underline"
|
||||
className="text-primary underline underline-offset-2"
|
||||
>
|
||||
mbombeck@gmail.com
|
||||
</a>
|
||||
@@ -2028,7 +2027,7 @@ export default function PrivacyPage() {
|
||||
href="https://github.com/MBombeck/HealthLog/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
className="text-primary underline underline-offset-2"
|
||||
>
|
||||
github.com/MBombeck/HealthLog/issues
|
||||
</a>{" "}
|
||||
|
||||
@@ -328,7 +328,10 @@ export function LoginOverviewSection() {
|
||||
{/* Toolbar row: per-page + export */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
<span
|
||||
id="login-overview-per-page"
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
{t("admin.section.auditLog.perPage")}
|
||||
</span>
|
||||
<Select
|
||||
@@ -338,7 +341,10 @@ export function LoginOverviewSection() {
|
||||
setPerPage(Number(v) as PerPageValue);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-20">
|
||||
<SelectTrigger
|
||||
className="h-8 w-20"
|
||||
aria-labelledby="login-overview-per-page"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* A claim link that no longer works still leaves the page with a way on:
|
||||
* the card offers the sign-in page instead of ending in a dead end.
|
||||
*/
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ replace: () => {}, refresh: () => {}, push: () => {} }),
|
||||
}));
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ user: null }),
|
||||
clearCachesForSessionEnd: () => {},
|
||||
}));
|
||||
vi.mock("@/lib/queries/use-profile-claim", () => ({
|
||||
useProfileClaimPreview: () => ({ data: undefined, error: null }),
|
||||
useClaimProfile: () => ({ mutate: () => {}, isPending: false }),
|
||||
}));
|
||||
|
||||
import { ClaimProfile } from "../claim-profile";
|
||||
|
||||
describe("ClaimProfile — invalid link", () => {
|
||||
it("offers the sign-in page", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<ClaimProfile token={null} />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect(html).toContain('data-testid="claim-invalid"');
|
||||
expect(html).toMatch(
|
||||
/data-slot="claim-to-login"[^>]*href="\/auth\/login"|href="\/auth\/login"[^>]*data-slot="claim-to-login"/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { KeyRound, Loader2, LogOut } from "lucide-react";
|
||||
@@ -124,6 +125,16 @@ export function ClaimProfile({ token }: { token: string | null }) {
|
||||
title={t("auth.claim.invalid")}
|
||||
description={t("auth.claim.invalidHint")}
|
||||
/>
|
||||
{/* A dead link is not a dead end: the way on is the sign-in page. */}
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="mt-8 min-h-11 w-full"
|
||||
data-slot="claim-to-login"
|
||||
>
|
||||
<Link href="/auth/login">{t("auth.login")}</Link>
|
||||
</Button>
|
||||
</ClaimCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { Activity } from "lucide-react";
|
||||
|
||||
import { TrendCard } from "../trend-card";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* A whole-number metric reads as a whole number on the tile: "121 mmHg",
|
||||
* not "121,0 mmHg", and "9.240" steps rather than "9.240,0".
|
||||
*/
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="de">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const base = {
|
||||
label: "Schritte",
|
||||
latest: 9240,
|
||||
unit: "",
|
||||
avg7: 9535.14,
|
||||
avg30: 9100,
|
||||
slope30: null,
|
||||
icon: Activity,
|
||||
};
|
||||
|
||||
describe("<TrendCard> fractionDigits", () => {
|
||||
it("keeps one decimal by default", () => {
|
||||
const html = render(<TrendCard {...base} />);
|
||||
expect(html).toContain("9.240,0");
|
||||
});
|
||||
|
||||
it("prints whole numbers, averages and deltas at 0", () => {
|
||||
const html = render(
|
||||
<TrendCard {...base} fractionDigits={0} trend7Delta={-1383.2} />,
|
||||
);
|
||||
expect(html).toContain("9.240");
|
||||
expect(html).not.toContain("9.240,0");
|
||||
expect(html).toContain("9.535");
|
||||
expect(html).not.toContain("9.535,1");
|
||||
expect(html).toContain("−1.383");
|
||||
expect(html).not.toContain("−1.383,2");
|
||||
});
|
||||
|
||||
it("reads a sub-step delta as zero at 0 decimals", () => {
|
||||
const html = render(
|
||||
<TrendCard {...base} fractionDigits={0} trend7Delta={0.3} />,
|
||||
);
|
||||
expect(html).toContain("±0");
|
||||
});
|
||||
});
|
||||
@@ -76,7 +76,7 @@ import {
|
||||
type MetricWindowStats,
|
||||
} from "@/lib/charts/window-stats";
|
||||
import { shouldFireDataReady } from "@/lib/charts/data-ready-latch";
|
||||
import { axisUnitSuffix } from "@/lib/charts/axis-unit";
|
||||
import { axisTickUnitSuffix } from "@/lib/charts/axis-unit";
|
||||
import { chartPointDayKey } from "@/components/day/chart-day";
|
||||
import {
|
||||
ChartDayFooter,
|
||||
@@ -2078,7 +2078,7 @@ export function HealthChart({
|
||||
}
|
||||
unit={
|
||||
showYAxisUnit
|
||||
? axisUnitSuffix(yAxisUnit ?? unit)
|
||||
? axisTickUnitSuffix(yAxisUnit ?? unit)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -101,6 +101,13 @@ interface TrendCardProps {
|
||||
* before (no reserved space, no layout shift).
|
||||
*/
|
||||
valueAdornment?: React.ReactNode;
|
||||
/**
|
||||
* Decimal places for the headline, the averages and the deltas. Defaults
|
||||
* to 1. A whole-number metric (steps, mmHg, bpm, mg/dL) passes 0 so the
|
||||
* tile reads "121 mmHg", not "121,0 mmHg" — the precision its own
|
||||
* insights page uses.
|
||||
*/
|
||||
fractionDigits?: number;
|
||||
}
|
||||
|
||||
export function TrendCard({
|
||||
@@ -123,6 +130,7 @@ export function TrendCard({
|
||||
staleDays = null,
|
||||
emptyHint = null,
|
||||
valueAdornment = null,
|
||||
fractionDigits = 1,
|
||||
}: TrendCardProps) {
|
||||
const { t, tCount } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
@@ -152,7 +160,7 @@ export function TrendCard({
|
||||
const arrowSentiment = getTrendSentiment(primarySignal, directionSentiment);
|
||||
const trendColor = sentimentColorClass(arrowSentiment);
|
||||
|
||||
const formatValue = (value: number) => fmt.number(value, 1);
|
||||
const formatValue = (value: number) => fmt.number(value, fractionDigits);
|
||||
|
||||
// v1.4.15 Fix 4 — the 7-day delta now shares the arrow's colour so
|
||||
// both elements communicate the same sentiment for the same signal.
|
||||
@@ -171,9 +179,10 @@ export function TrendCard({
|
||||
const comparisonDeltaColor = sentimentColorClass(comparisonSentiment);
|
||||
|
||||
const formatDelta = (value: number): string => {
|
||||
if (Math.abs(value) < 0.05) return `±0`;
|
||||
// Below half a step of the shown precision the delta prints as zero.
|
||||
if (Math.abs(value) < 0.5 * 10 ** -fractionDigits) return `±0`;
|
||||
const sign = value > 0 ? "+" : "−";
|
||||
return `${sign}${fmt.number(Math.abs(value), 1)}`;
|
||||
return `${sign}${fmt.number(Math.abs(value), fractionDigits)}`;
|
||||
};
|
||||
|
||||
// The label flips from "7d" / "7T" (mean) to "7d trend" / "7T-Trend"
|
||||
|
||||
@@ -114,7 +114,7 @@ export function CustomMetricEntryForm({
|
||||
{onCancel ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
>
|
||||
|
||||
@@ -137,7 +137,7 @@ export function CustomMetricForm({
|
||||
{onCancel ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
>
|
||||
|
||||
@@ -97,6 +97,18 @@ describe("<CycleCalendar>", () => {
|
||||
expect(html).toContain('role="columnheader"');
|
||||
});
|
||||
|
||||
it("nests headers and day cells in ARIA rows (grid > row > cell)", () => {
|
||||
const html = render(
|
||||
<CycleCalendar days={[]} today={today} onSelectDay={() => {}} />,
|
||||
);
|
||||
// A header row plus at least four week rows; a header or cell directly
|
||||
// under the grid is an invalid ARIA tree.
|
||||
expect(html.match(/role="row"/g)?.length ?? 0).toBeGreaterThanOrEqual(5);
|
||||
expect(html).not.toMatch(
|
||||
/role="grid"[^>]*>\s*<div[^>]*role="(columnheader|gridcell)"/,
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes the flow level on a logged period day as a stable data-attr", () => {
|
||||
const days = [
|
||||
{ ...dayBase("2026-06-10"), isPeriodLogged: true, flow: "HEAVY" },
|
||||
|
||||
@@ -236,204 +236,216 @@ export function CycleCalendar({
|
||||
aria-label={monthLabel}
|
||||
className="grid grid-cols-7 gap-1"
|
||||
>
|
||||
{weekdays.map((wk, i) => (
|
||||
<div
|
||||
key={i}
|
||||
role="columnheader"
|
||||
className="text-muted-foreground text-2xs pb-1 text-center font-medium uppercase"
|
||||
>
|
||||
{wk}
|
||||
</div>
|
||||
))}
|
||||
{/* Rows are `display: contents`: they give the grid its ARIA rows
|
||||
(grid > row > columnheader | gridcell) without touching the
|
||||
seven-column CSS grid the cells lay out in. */}
|
||||
<div role="row" className="contents">
|
||||
{weekdays.map((wk, i) => (
|
||||
<div
|
||||
key={i}
|
||||
role="columnheader"
|
||||
className="text-muted-foreground text-2xs pb-1 text-center font-medium uppercase"
|
||||
>
|
||||
{wk}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{grid.map((cell, i) => {
|
||||
if (!cell) return <div key={`empty-${i}`} aria-hidden="true" />;
|
||||
const date = ymd(cell);
|
||||
const info = byDate.get(date);
|
||||
const isToday = date === today;
|
||||
// A CONFIRMED-ovulation day is the predicted-ovulation day that also
|
||||
// matches the prediction's retrospective estimate. It supersedes the
|
||||
// predicted dot with the distinct light oval (Apple's idiom).
|
||||
const isConfirmedOvulation =
|
||||
!!info?.isPredictedOvulation &&
|
||||
confirmedOvulation != null &&
|
||||
date === confirmedOvulation;
|
||||
const isPredictedOvulationDot =
|
||||
!!info?.isPredictedOvulation && !isConfirmedOvulation;
|
||||
{Array.from({ length: Math.ceil(grid.length / 7) }, (_, r) => (
|
||||
<div key={`row-${r}`} role="row" className="contents">
|
||||
{grid.slice(r * 7, r * 7 + 7).map((cell, j) => {
|
||||
const i = r * 7 + j;
|
||||
if (!cell) return <div key={`empty-${i}`} aria-hidden="true" />;
|
||||
const date = ymd(cell);
|
||||
const info = byDate.get(date);
|
||||
const isToday = date === today;
|
||||
// A CONFIRMED-ovulation day is the predicted-ovulation day that also
|
||||
// matches the prediction's retrospective estimate. It supersedes the
|
||||
// predicted dot with the distinct light oval (Apple's idiom).
|
||||
const isConfirmedOvulation =
|
||||
!!info?.isPredictedOvulation &&
|
||||
confirmedOvulation != null &&
|
||||
date === confirmedOvulation;
|
||||
const isPredictedOvulationDot =
|
||||
!!info?.isPredictedOvulation && !isConfirmedOvulation;
|
||||
|
||||
const markers: string[] = [];
|
||||
if (info?.isPeriodLogged) {
|
||||
// Restate the flow grade in the aria text so the shading is never
|
||||
// colour-only (the legend covers it; the cell names it too).
|
||||
const flowKey =
|
||||
info.flow && info.flow !== "NONE"
|
||||
? `cycle.calendar.flow${info.flow.charAt(0)}${info.flow.slice(1).toLowerCase()}`
|
||||
: null;
|
||||
markers.push(
|
||||
flowKey
|
||||
? `${t("cycle.calendar.legendPeriod")} (${t(flowKey)})`
|
||||
: t("cycle.calendar.legendPeriod"),
|
||||
);
|
||||
}
|
||||
if (info?.isPredictedPeriod)
|
||||
markers.push(t("cycle.calendar.legendPredicted"));
|
||||
if (info?.isFertileWindow)
|
||||
markers.push(t("cycle.calendar.legendFertile"));
|
||||
if (isConfirmedOvulation)
|
||||
markers.push(t("cycle.calendar.legendOvulationConfirmed"));
|
||||
else if (isPredictedOvulationDot)
|
||||
markers.push(t("cycle.calendar.legendOvulation"));
|
||||
if (info?.hasSymptoms)
|
||||
markers.push(t("cycle.calendar.legendSymptoms"));
|
||||
const hasIntercourse = !!info?.sexualActivity;
|
||||
if (hasIntercourse)
|
||||
markers.push(t("cycle.calendar.legendIntercourse"));
|
||||
const others = info ? otherEntryLabels(info, t) : [];
|
||||
markers.push(...others);
|
||||
const markers: string[] = [];
|
||||
if (info?.isPeriodLogged) {
|
||||
// Restate the flow grade in the aria text so the shading is never
|
||||
// colour-only (the legend covers it; the cell names it too).
|
||||
const flowKey =
|
||||
info.flow && info.flow !== "NONE"
|
||||
? `cycle.calendar.flow${info.flow.charAt(0)}${info.flow.slice(1).toLowerCase()}`
|
||||
: null;
|
||||
markers.push(
|
||||
flowKey
|
||||
? `${t("cycle.calendar.legendPeriod")} (${t(flowKey)})`
|
||||
: t("cycle.calendar.legendPeriod"),
|
||||
);
|
||||
}
|
||||
if (info?.isPredictedPeriod)
|
||||
markers.push(t("cycle.calendar.legendPredicted"));
|
||||
if (info?.isFertileWindow)
|
||||
markers.push(t("cycle.calendar.legendFertile"));
|
||||
if (isConfirmedOvulation)
|
||||
markers.push(t("cycle.calendar.legendOvulationConfirmed"));
|
||||
else if (isPredictedOvulationDot)
|
||||
markers.push(t("cycle.calendar.legendOvulation"));
|
||||
if (info?.hasSymptoms)
|
||||
markers.push(t("cycle.calendar.legendSymptoms"));
|
||||
const hasIntercourse = !!info?.sexualActivity;
|
||||
if (hasIntercourse)
|
||||
markers.push(t("cycle.calendar.legendIntercourse"));
|
||||
const others = info ? otherEntryLabels(info, t) : [];
|
||||
markers.push(...others);
|
||||
|
||||
const aria = `${date}${markers.length ? `, ${markers.join(", ")}` : ""}`;
|
||||
const flowLevel =
|
||||
info?.isPeriodLogged && info.flow && info.flow !== "NONE"
|
||||
? info.flow
|
||||
: info?.isPeriodLogged
|
||||
? "UNGRADED"
|
||||
: undefined;
|
||||
const aria = `${date}${markers.length ? `, ${markers.join(", ")}` : ""}`;
|
||||
const flowLevel =
|
||||
info?.isPeriodLogged && info.flow && info.flow !== "NONE"
|
||||
? info.flow
|
||||
: info?.isPeriodLogged
|
||||
? "UNGRADED"
|
||||
: undefined;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{/* Fertile window: a soft full-cell band (calm fill, not a hard
|
||||
const content = (
|
||||
<>
|
||||
{/* Fertile window: a soft full-cell band (calm fill, not a hard
|
||||
ring) so the window reads as a continuous range across days. */}
|
||||
{info?.isFertileWindow ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0.5 rounded-md opacity-15"
|
||||
style={{ backgroundColor: FERTILE_HUE }}
|
||||
/>
|
||||
) : null}
|
||||
{info?.isFertileWindow ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0.5 rounded-md opacity-15"
|
||||
style={{ backgroundColor: FERTILE_HUE }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Logged-period filled pip behind the number — shaded by flow on
|
||||
{/* Logged-period filled pip behind the number — shaded by flow on
|
||||
the single-hue opacity ladder (SPOTTING→HEAVY). */}
|
||||
{info?.isPeriodLogged ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-1 rounded-md"
|
||||
style={{
|
||||
backgroundColor: FLOW_HUE,
|
||||
opacity: flowOpacity(info.flow),
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{info?.isPeriodLogged ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-1 rounded-md"
|
||||
style={{
|
||||
backgroundColor: FLOW_HUE,
|
||||
opacity: flowOpacity(info.flow),
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Confirmed-ovulation oval: a restrained light oval ringed in the
|
||||
{/* Confirmed-ovulation oval: a restrained light oval ringed in the
|
||||
ovulatory hue, deliberately distinct from the predicted dot. */}
|
||||
{isConfirmedOvulation ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-1 inset-y-2 rounded-full ring-2 ring-inset"
|
||||
style={
|
||||
{
|
||||
backgroundColor: OVULATION_HUE,
|
||||
opacity: 0.22,
|
||||
"--tw-ring-color": OVULATION_HUE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{isConfirmedOvulation ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-1 inset-y-2 rounded-full ring-2 ring-inset"
|
||||
style={
|
||||
{
|
||||
backgroundColor: OVULATION_HUE,
|
||||
opacity: 0.22,
|
||||
"--tw-ring-color": OVULATION_HUE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<span className={cn("relative z-10", isToday && "text-primary")}>
|
||||
{cell.getDate()}
|
||||
</span>
|
||||
<span
|
||||
className={cn("relative z-10", isToday && "text-primary")}
|
||||
>
|
||||
{cell.getDate()}
|
||||
</span>
|
||||
|
||||
{/* Predicted-period day: a soft FILL behind the number (like a
|
||||
{/* Predicted-period day: a soft FILL behind the number (like a
|
||||
logged period day, but lighter) with a dashed outline so it
|
||||
still reads as predicted, not confirmed — replaces the old
|
||||
hatched underline, which looked unfinished. */}
|
||||
{info?.isPredictedPeriod && !info?.isPeriodLogged ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-1 rounded-md border border-dashed"
|
||||
style={{
|
||||
backgroundColor: FLOW_HUE,
|
||||
opacity: 0.3,
|
||||
borderColor: FLOW_HUE,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{info?.isPredictedPeriod && !info?.isPeriodLogged ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-1 rounded-md border border-dashed"
|
||||
style={{
|
||||
backgroundColor: FLOW_HUE,
|
||||
opacity: 0.3,
|
||||
borderColor: FLOW_HUE,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Marker row: predicted-ovulation + symptom dots. The confirmed
|
||||
{/* Marker row: predicted-ovulation + symptom dots. The confirmed
|
||||
oval lives behind the number, so it needs no row dot. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="relative z-10 mt-0.5 flex h-2 items-center gap-0.5"
|
||||
>
|
||||
{isPredictedOvulationDot ? (
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full"
|
||||
style={{ backgroundColor: OVULATION_HUE }}
|
||||
/>
|
||||
) : null}
|
||||
{info?.hasSymptoms ? (
|
||||
<span className="bg-muted-foreground/70 h-1 w-1 rounded-full" />
|
||||
) : null}
|
||||
{hasIntercourse ? (
|
||||
<span
|
||||
data-slot="cycle-calendar-intercourse"
|
||||
className="mx-px size-2 rotate-45"
|
||||
style={{ backgroundColor: INTERCOURSE_HUE }}
|
||||
/>
|
||||
) : null}
|
||||
{others.length > 0 ? (
|
||||
<span
|
||||
data-slot="cycle-calendar-other"
|
||||
className="border-muted-foreground size-1.5 rounded-full border"
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
aria-hidden="true"
|
||||
className="relative z-10 mt-0.5 flex h-2 items-center gap-0.5"
|
||||
>
|
||||
{isPredictedOvulationDot ? (
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full"
|
||||
style={{ backgroundColor: OVULATION_HUE }}
|
||||
/>
|
||||
) : null}
|
||||
{info?.hasSymptoms ? (
|
||||
<span className="bg-muted-foreground/70 h-1 w-1 rounded-full" />
|
||||
) : null}
|
||||
{hasIntercourse ? (
|
||||
<span
|
||||
data-slot="cycle-calendar-intercourse"
|
||||
className="mx-px size-2 rotate-45"
|
||||
style={{ backgroundColor: INTERCOURSE_HUE }}
|
||||
/>
|
||||
) : null}
|
||||
{others.length > 0 ? (
|
||||
<span
|
||||
data-slot="cycle-calendar-other"
|
||||
className="border-muted-foreground size-1.5 rounded-full border"
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
const cellClass = cn(
|
||||
"relative flex aspect-square min-h-11 flex-col items-center justify-center rounded-lg text-sm transition-colors",
|
||||
isToday && "font-semibold",
|
||||
);
|
||||
const cellAttrs = {
|
||||
role: "gridcell" as const,
|
||||
"aria-label": aria,
|
||||
"aria-current": isToday ? ("date" as const) : undefined,
|
||||
"data-flow-level": flowLevel,
|
||||
"data-fertile": info?.isFertileWindow ? "true" : undefined,
|
||||
"data-predicted":
|
||||
info?.isPredictedPeriod && !info?.isPeriodLogged
|
||||
? "true"
|
||||
: undefined,
|
||||
"data-ovulation": isConfirmedOvulation
|
||||
? "confirmed"
|
||||
: isPredictedOvulationDot
|
||||
? "predicted"
|
||||
: undefined,
|
||||
"data-intercourse": hasIntercourse ? "true" : undefined,
|
||||
"data-other-entries": others.length > 0 ? "true" : undefined,
|
||||
};
|
||||
const cellClass = cn(
|
||||
"relative flex aspect-square min-h-11 flex-col items-center justify-center rounded-lg text-sm transition-colors",
|
||||
isToday && "font-semibold",
|
||||
);
|
||||
const cellAttrs = {
|
||||
role: "gridcell" as const,
|
||||
"aria-label": aria,
|
||||
"aria-current": isToday ? ("date" as const) : undefined,
|
||||
"data-flow-level": flowLevel,
|
||||
"data-fertile": info?.isFertileWindow ? "true" : undefined,
|
||||
"data-predicted":
|
||||
info?.isPredictedPeriod && !info?.isPeriodLogged
|
||||
? "true"
|
||||
: undefined,
|
||||
"data-ovulation": isConfirmedOvulation
|
||||
? "confirmed"
|
||||
: isPredictedOvulationDot
|
||||
? "predicted"
|
||||
: undefined,
|
||||
"data-intercourse": hasIntercourse ? "true" : undefined,
|
||||
"data-other-entries": others.length > 0 ? "true" : undefined,
|
||||
};
|
||||
|
||||
return onSelectDay ? (
|
||||
<button
|
||||
key={date}
|
||||
type="button"
|
||||
{...cellAttrs}
|
||||
onClick={() => onSelectDay(date)}
|
||||
className={cn(
|
||||
cellClass,
|
||||
"hover:bg-accent focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
<div key={date} {...cellAttrs} className={cellClass}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
return onSelectDay ? (
|
||||
<button
|
||||
key={date}
|
||||
type="button"
|
||||
{...cellAttrs}
|
||||
onClick={() => onSelectDay(date)}
|
||||
className={cn(
|
||||
cellClass,
|
||||
"hover:bg-accent focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
<div key={date} {...cellAttrs} className={cellClass}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<CalendarLegend />
|
||||
|
||||
@@ -271,7 +271,14 @@ export function CycleView() {
|
||||
days: verdict.overdueDays,
|
||||
})
|
||||
: t("cycle.ring.periodOverdue")
|
||||
: t("cycle.ring.caption")}
|
||||
: // The ring shows the bare number; the caption under it
|
||||
// reads as a whole phrase ("Tag 9 deines Zyklus"), not
|
||||
// a fragment hanging off the ring.
|
||||
verdict?.dayOfCycle != null
|
||||
? t("cycle.insightsSummary.currentDay", {
|
||||
day: verdict.dayOfCycle,
|
||||
})
|
||||
: t("cycle.ring.caption")}
|
||||
</p>
|
||||
) : null}
|
||||
{/* Log CTA — first period when no cycle exists yet, next period
|
||||
|
||||
@@ -183,6 +183,10 @@ describe("<DashboardHeader> — every entry follows its module, as the capture p
|
||||
`${kind} ${JSON.stringify(modules)}`,
|
||||
).toBe(picker.includes(kind));
|
||||
}
|
||||
// Same order as the picker, so the two ways in never disagree on
|
||||
// where an entry sits.
|
||||
const positions = picker.map((kind) => html.indexOf(label[kind]));
|
||||
expect(positions).toEqual([...positions].sort((x, y) => x - y));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -190,12 +190,6 @@ export function DashboardHeader({
|
||||
{t("dashboard.quickAddMeasurement")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canAddMood && (
|
||||
<DropdownMenuItem onClick={() => onQuickEntry("mood")}>
|
||||
<Waves className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddMood")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* v1.4.37 W7b — third quick-add row: medication intake.
|
||||
Same Sheet-on-mobile / Dialog-on-desktop primitive as
|
||||
the other two; the menu label is a self-contained
|
||||
@@ -209,6 +203,12 @@ export function DashboardHeader({
|
||||
{t("dashboard.quickAddMedicationIntake")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canAddMood && (
|
||||
<DropdownMenuItem onClick={() => onQuickEntry("mood")}>
|
||||
<Waves className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddMood")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canAddSymptom && (
|
||||
<DropdownMenuItem onClick={() => onQuickEntry("symptom")}>
|
||||
<Stethoscope className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
|
||||
@@ -203,7 +203,7 @@ export function QuickEntrySheets({
|
||||
<ResponsiveSheet
|
||||
open={openSheet === "measurement"}
|
||||
onOpenChange={handleQuickEntryOpenChange}
|
||||
title={t("measurements.addMeasurement")}
|
||||
title={t("dashboard.quickAddMeasurement")}
|
||||
footer={<div ref={setMeasurementFooterEl} className="flex w-full" />}
|
||||
>
|
||||
<MeasurementForm
|
||||
@@ -215,7 +215,7 @@ export function QuickEntrySheets({
|
||||
<ResponsiveSheet
|
||||
open={openSheet === "mood"}
|
||||
onOpenChange={handleQuickEntryOpenChange}
|
||||
title={t("mood.addEntry")}
|
||||
title={t("dashboard.quickAddMood")}
|
||||
footer={<div ref={setMoodFooterEl} className="flex w-full" />}
|
||||
>
|
||||
<MoodForm
|
||||
@@ -231,7 +231,7 @@ export function QuickEntrySheets({
|
||||
<ResponsiveSheet
|
||||
open={openSheet === "medicationIntake"}
|
||||
onOpenChange={handleQuickEntryOpenChange}
|
||||
title={t("dashboard.medicationIntakeQuickAdd.sheetTitle")}
|
||||
title={t("dashboard.quickAddMedicationIntake")}
|
||||
description={t("dashboard.medicationIntakeQuickAdd.sheetDescription")}
|
||||
footer={
|
||||
<div ref={setMedicationIntakeFooterEl} className="flex w-full" />
|
||||
@@ -249,7 +249,7 @@ export function QuickEntrySheets({
|
||||
<ResponsiveSheet
|
||||
open={openSheet === "symptom"}
|
||||
onOpenChange={handleQuickEntryOpenChange}
|
||||
title={t("symptoms.entry.sheetTitle")}
|
||||
title={t("dashboard.quickAddSymptom")}
|
||||
description={t("symptoms.entry.sheetDescription")}
|
||||
footer={<div ref={setSymptomFooterEl} className="flex w-full" />}
|
||||
>
|
||||
@@ -267,7 +267,7 @@ export function QuickEntrySheets({
|
||||
<ResponsiveSheet
|
||||
open={openSheet === "workout"}
|
||||
onOpenChange={handleQuickEntryOpenChange}
|
||||
title={t("insights.workouts.manual.sheetTitle")}
|
||||
title={t("dashboard.quickAddWorkout")}
|
||||
description={t("insights.workouts.manual.sheetDescription")}
|
||||
footer={<div ref={setWorkoutFooterEl} className="flex w-full" />}
|
||||
>
|
||||
@@ -285,7 +285,7 @@ export function QuickEntrySheets({
|
||||
<ResponsiveSheet
|
||||
open={openSheet === "lifeEvent"}
|
||||
onOpenChange={handleQuickEntryOpenChange}
|
||||
title={t("lifeEvents.title")}
|
||||
title={t("dashboard.quickAddLifeEvent")}
|
||||
description={t("lifeEvents.subtitle")}
|
||||
footer={<div ref={setLifeEventFooterEl} className="flex w-full" />}
|
||||
>
|
||||
|
||||
@@ -86,7 +86,8 @@ function DayLogRow({ log }: { log: IllnessDayLogDTO }) {
|
||||
) : null}
|
||||
|
||||
{log.note ? (
|
||||
<p className="text-muted-foreground text-sm whitespace-pre-wrap">
|
||||
// User-written text reads in the content colour, never muted.
|
||||
<p className="text-foreground text-sm whitespace-pre-wrap">
|
||||
{log.note}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -255,7 +255,7 @@ export function LogDaySheet({
|
||||
description={t("illness.sheet.description")}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={upsert.isPending}>
|
||||
|
||||
@@ -178,7 +178,7 @@ export function NewEpisodeSheet({
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -203,9 +203,9 @@ export function NewEpisodeSheet({
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("illness.new.type")}</Label>
|
||||
<Label htmlFor="illness-type">{t("illness.new.type")}</Label>
|
||||
<Select value={type} onValueChange={(v) => setType(v as IllnessType)}>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger id="illness-type" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -228,12 +228,14 @@ export function NewEpisodeSheet({
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("illness.new.lifecycle")}</Label>
|
||||
<Label htmlFor="illness-lifecycle">
|
||||
{t("illness.new.lifecycle")}
|
||||
</Label>
|
||||
<Select
|
||||
value={lifecycle}
|
||||
onValueChange={(v) => setLifecycle(v as IllnessLifecycle)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger id="illness-lifecycle" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -248,9 +250,9 @@ export function NewEpisodeSheet({
|
||||
|
||||
{showParent ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("illness.new.parent")}</Label>
|
||||
<Label htmlFor="illness-parent">{t("illness.new.parent")}</Label>
|
||||
<Select value={parentId} onValueChange={setParentId}>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger id="illness-parent" className="w-full">
|
||||
<SelectValue placeholder={t("illness.new.parentNone")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -618,3 +618,23 @@ describe("<InsightsTabStrip> — pill row clip box (v1.16.8)", () => {
|
||||
expect(scroller).toContain("-mx-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<InsightsTabStrip> — scroll arrows hold their slot", () => {
|
||||
it("renders both arrows before the row is measured, hidden but in place", () => {
|
||||
// Mounting the arrows only once the row measured as overflowing shifted
|
||||
// every pill sideways right after first paint (a layout shift on every
|
||||
// insights page). They render from the start and only hide.
|
||||
const html = render(<InsightsTabStrip />);
|
||||
for (const slot of [
|
||||
"insights-tab-strip-scroll-left",
|
||||
"insights-tab-strip-scroll-right",
|
||||
]) {
|
||||
const tag = html.match(
|
||||
new RegExp(`<button[^>]*data-slot="${slot}"[^>]*>`),
|
||||
);
|
||||
expect(tag?.[0]).toBeDefined();
|
||||
expect(tag?.[0]).toContain("invisible");
|
||||
expect(tag?.[0]).toContain('tabindex="-1"');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { sourceName } from "../metric-target-summary";
|
||||
|
||||
describe("sourceName", () => {
|
||||
const t = (key: string) => `t:${key}`;
|
||||
it("names the plain-description sources in the reader's language", () => {
|
||||
expect(sourceName("Mood entries", t)).toBe(
|
||||
"t:targets.sourceNames.moodEntries",
|
||||
);
|
||||
expect(sourceName("7-day", t)).toBe("t:targets.sourceNames.sevenDays");
|
||||
});
|
||||
it("leaves a citation as it is", () => {
|
||||
expect(sourceName("ESH 2023", t)).toBe("ESH 2023");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* While the analytics read is in flight the stage chart holds its place with
|
||||
* a skeleton. Painting the "no stage data yet" card first said something
|
||||
* untrue and pushed the duration chart down when the bar replaced it.
|
||||
*/
|
||||
|
||||
const state = { isLoading: true, data: undefined as unknown };
|
||||
|
||||
vi.mock("@/lib/queries/use-analytics-query", () => ({
|
||||
useAnalyticsQuery: () => state,
|
||||
}));
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ isAuthenticated: true, user: { timezone: "UTC" } }),
|
||||
}));
|
||||
vi.mock("@/hooks/use-insights-layout-prefs", () => ({
|
||||
useInsightsLayoutPrefs: () => ({ compareBaseline: "none" }),
|
||||
}));
|
||||
vi.mock("../sleep-duration-chart", () => ({
|
||||
SleepDurationChart: () => <div data-slot="duration-chart" />,
|
||||
}));
|
||||
|
||||
import { SleepOverview } from "../sleep-overview";
|
||||
import en from "../../../../messages/en.json";
|
||||
|
||||
describe("SleepOverview — loading", () => {
|
||||
it("paints a skeleton, not the 'no stage data' card, while loading", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<SleepOverview />
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(html).not.toContain(en.insights.sleep.stages.unavailable);
|
||||
expect(html).toContain('data-slot="duration-chart"');
|
||||
});
|
||||
});
|
||||
@@ -88,6 +88,9 @@ describe("<SleepStageStackedBar>", () => {
|
||||
const html = render(<SleepStageStackedBar breakdown={breakdown} />, "de");
|
||||
expect(html).toContain("Phasen-Verteilung");
|
||||
expect(html).toContain("Letzte 7 Nächte");
|
||||
// The window toggle speaks the app's range vocabulary, not "7d".
|
||||
expect(html).toContain(">7T<");
|
||||
expect(html).not.toContain(">7d<");
|
||||
});
|
||||
|
||||
it("does not crash when the breakdown carries unknown stage keys", () => {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* The strip sits above the chart on every metric sub-page. While its read is
|
||||
* on the way it holds its place with a card of the same anatomy, so the chart
|
||||
* and everything below it do not jump down when the read lands.
|
||||
*/
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { timezone: "UTC", dateOfBirth: null, gender: null },
|
||||
isAuthenticated: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true }));
|
||||
|
||||
import { CoachReadStrip } from "../coach-read-strip";
|
||||
|
||||
describe("CoachReadStrip — placeholder while the read loads", () => {
|
||||
it("paints the card frame and title before the read lands", () => {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, enabled: false } },
|
||||
});
|
||||
const markup = renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<CoachReadStrip metricType="WEIGHT" unit="kg" />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect(markup).toContain('data-slot="coach-read-strip"');
|
||||
expect(markup).toContain('data-state="loading"');
|
||||
expect(markup).toContain('aria-busy="true"');
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
import { metricFractionDigits } from "@/lib/measurements/value-domain";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import type { CoachReadStripData } from "@/lib/insights/derived/coach-read-shape";
|
||||
|
||||
@@ -78,7 +79,7 @@ export function CoachReadStrip({
|
||||
const transformed = unitDisplay.isTransformed(metricType);
|
||||
const resolvedUnit = transformed ? unitDisplay.unitFor(metricType) : unit;
|
||||
|
||||
const { data } = useQuery({
|
||||
const { data, isError } = useQuery({
|
||||
queryKey: queryKeys.insightsCoachRead(metricType, locale),
|
||||
queryFn: () =>
|
||||
apiGet<CoachReadStripData>(
|
||||
@@ -88,10 +89,34 @@ export function CoachReadStrip({
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Match the rest of the query-dependent insights chrome: don't paint a
|
||||
// branch during SSR / hydration (React #418) and don't paint until the
|
||||
// read lands.
|
||||
if (!mounted || !data) return null;
|
||||
// Don't paint a data branch during SSR / hydration (React #418). While the
|
||||
// read is on its way the strip holds its place with a card of the same
|
||||
// shape: it lands above the chart, and popping in later pushed the chart
|
||||
// and everything under it down the page. A landed read always paints (the
|
||||
// baseline line degrades to the "learning" copy), so the placeholder only
|
||||
// ever gives way to a card of its own height; a failed read drops it.
|
||||
if (!mounted || !data) {
|
||||
if (!isAuthenticated || isError) return null;
|
||||
return (
|
||||
<Card
|
||||
data-slot="coach-read-strip"
|
||||
data-state="loading"
|
||||
aria-busy="true"
|
||||
className="gap-2 py-3 md:py-4"
|
||||
>
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={Sparkles}
|
||||
title={t("insights.coach.readStrip.label")}
|
||||
titleAs="h2"
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-5.5 w-3/4" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const fmt = (value: number): string =>
|
||||
new Intl.NumberFormat(locale, {
|
||||
|
||||
@@ -62,7 +62,14 @@ export function DeltaSparkline({
|
||||
const hasComparison = data.some((point) => point.b !== undefined);
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data} margin={{ top: 2, right: 0, bottom: 2, left: 0 }}>
|
||||
{/* A sparkline is decoration inside an aria-hidden wrapper: without
|
||||
the accessibility layer Recharts puts a focusable surface in it,
|
||||
and a focus stop nobody can perceive is an axe failure. */}
|
||||
<AreaChart
|
||||
data={data}
|
||||
margin={{ top: 2, right: 0, bottom: 2, left: 0 }}
|
||||
accessibilityLayer={false}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={fillId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={strokeVar} stopOpacity={0.28} />
|
||||
|
||||
@@ -17,13 +17,14 @@ import type { ChartOverlayKey } from "@/lib/dashboard-layout";
|
||||
import { metricFractionDigits } from "@/lib/measurements/value-domain";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
|
||||
import { MetricStatusCard } from "@/components/insights/metric-status-card";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
|
||||
import {
|
||||
MetricStatStrip,
|
||||
MetricStatStripSkeleton,
|
||||
} from "@/components/insights/metric-stat-strip";
|
||||
import { CoachReadStrip } from "@/components/insights/derived/coach-read-strip";
|
||||
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
|
||||
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
|
||||
@@ -371,7 +372,7 @@ export function HealthKitMetricPage({
|
||||
description={description}
|
||||
explainerMetric={explainerMetric}
|
||||
explainerParams={explainerParams}
|
||||
statStrip={<StatStripSkeleton />}
|
||||
statStrip={<MetricStatStripSkeleton />}
|
||||
>
|
||||
<ChartSkeleton />
|
||||
</SubPageShell>
|
||||
@@ -538,32 +539,3 @@ export function HealthKitMetricPage({
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.12.7 — layout-stable loading shell for the stat strip slot. Mirrors
|
||||
* the loaded `<MetricStatStrip>` card chrome (denser `py-3` rhythm, one
|
||||
* header row + a four-up grid) so the page does not jump when the analytics
|
||||
* read lands. Decorative — hidden from assistive tech; the chart skeleton
|
||||
* below it carries the `aria-busy` announcement.
|
||||
*/
|
||||
function StatStripSkeleton() {
|
||||
return (
|
||||
<Card
|
||||
data-slot="metric-stat-strip-skeleton"
|
||||
aria-hidden="true"
|
||||
className="gap-2 py-3 md:py-4"
|
||||
>
|
||||
<CardContent className="space-y-3">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-2 sm:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="min-h-[44px] space-y-1">
|
||||
<Skeleton className="h-3 w-12" />
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -822,23 +822,27 @@ function InsightsTabStripImpl({
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{pillsOverflow && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollPills(-1)}
|
||||
disabled={!canScrollLeft}
|
||||
aria-label={t("insights.pillScrollLeft")}
|
||||
data-slot="insights-tab-strip-scroll-left"
|
||||
className={cn(
|
||||
"hidden h-11 w-9 shrink-0 items-center justify-center rounded-full sm:inline-flex",
|
||||
"text-muted-foreground hover:text-foreground hover:bg-accent transition-colors",
|
||||
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
"disabled:cursor-not-allowed disabled:opacity-30",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
{/* Both arrows always hold their slot (sm+) and only hide while the
|
||||
row fits: mounting them once the row measured as overflowing
|
||||
shifted every pill sideways a moment after first paint. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollPills(-1)}
|
||||
disabled={!canScrollLeft}
|
||||
aria-hidden={pillsOverflow ? undefined : true}
|
||||
tabIndex={pillsOverflow ? undefined : -1}
|
||||
aria-label={t("insights.pillScrollLeft")}
|
||||
data-slot="insights-tab-strip-scroll-left"
|
||||
className={cn(
|
||||
"hidden h-11 w-9 shrink-0 items-center justify-center rounded-full sm:inline-flex",
|
||||
!pillsOverflow && "invisible",
|
||||
"text-muted-foreground hover:text-foreground hover:bg-accent transition-colors",
|
||||
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
"disabled:cursor-not-allowed disabled:opacity-30",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
onScroll={updateScrollAffordance}
|
||||
@@ -971,23 +975,24 @@ function InsightsTabStripImpl({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{pillsOverflow && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollPills(1)}
|
||||
disabled={!canScrollRight}
|
||||
aria-label={t("insights.pillScrollRight")}
|
||||
data-slot="insights-tab-strip-scroll-right"
|
||||
className={cn(
|
||||
"hidden h-11 w-9 shrink-0 items-center justify-center rounded-full sm:inline-flex",
|
||||
"text-muted-foreground hover:text-foreground hover:bg-accent transition-colors",
|
||||
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
"disabled:cursor-not-allowed disabled:opacity-30",
|
||||
)}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollPills(1)}
|
||||
disabled={!canScrollRight}
|
||||
aria-hidden={pillsOverflow ? undefined : true}
|
||||
tabIndex={pillsOverflow ? undefined : -1}
|
||||
aria-label={t("insights.pillScrollRight")}
|
||||
data-slot="insights-tab-strip-scroll-right"
|
||||
className={cn(
|
||||
"hidden h-11 w-9 shrink-0 items-center justify-center rounded-full sm:inline-flex",
|
||||
!pillsOverflow && "invisible",
|
||||
"text-muted-foreground hover:text-foreground hover:bg-accent transition-colors",
|
||||
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
"disabled:cursor-not-allowed disabled:opacity-30",
|
||||
)}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
{/* v1.4.27 MB7 / CF-72 — right-edge fade. The gradient
|
||||
absolute-positions over the rightmost ~24 px of the strip
|
||||
so the last visible pill softly fades into the background
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Sigma } from "lucide-react";
|
||||
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import type { MetricWindowStats } from "@/lib/charts/window-stats";
|
||||
@@ -139,6 +140,13 @@ type MetricStatStripProps = Partial<MetricStatSeries> & {
|
||||
* Single-series mode derives the label from `seriesLabel` instead.
|
||||
*/
|
||||
groupLabel?: string;
|
||||
/**
|
||||
* The read behind the summary is still in flight. The strip then holds its
|
||||
* place with a card of the same anatomy instead of painting nothing: it
|
||||
* sits at the top of the sub-page, so arriving later pushed the read strip,
|
||||
* the chart and everything below it down the page.
|
||||
*/
|
||||
pending?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -243,9 +251,14 @@ export function MetricStatStrip({
|
||||
titleAs,
|
||||
series,
|
||||
groupLabel,
|
||||
pending = false,
|
||||
}: MetricStatStripProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
if (pending) {
|
||||
return <MetricStatStripSkeleton seriesCount={series?.length ?? 1} />;
|
||||
}
|
||||
|
||||
// v1.12.7 — multi-series mode: ONE Card, columns side by side. The card
|
||||
// self-gates to nothing only when EVERY series is empty so a one-sided
|
||||
// metric (e.g. only systolic logged) still paints its half.
|
||||
@@ -310,3 +323,49 @@ export function MetricStatStrip({
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout-stable loading shell for the stat strip: the loaded card's chrome
|
||||
* (compact rhythm, a header row, a four-up grid per series, two series side
|
||||
* by side from `md`) so the page does not jump when the read lands.
|
||||
* Decorative — hidden from assistive tech; the chart skeleton below carries
|
||||
* the busy announcement.
|
||||
*/
|
||||
export function MetricStatStripSkeleton({
|
||||
seriesCount = 1,
|
||||
}: {
|
||||
seriesCount?: number;
|
||||
}) {
|
||||
return (
|
||||
<Card
|
||||
data-slot="metric-stat-strip-skeleton"
|
||||
aria-hidden="true"
|
||||
className="gap-2 py-3 md:py-4"
|
||||
>
|
||||
<CardContent>
|
||||
<div
|
||||
className={
|
||||
seriesCount > 1
|
||||
? "grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{Array.from({ length: Math.max(1, seriesCount) }).map((_, s) => (
|
||||
<div key={s} className="space-y-1.5">
|
||||
{/* h-6: the TileHeader row is one 24 px line. */}
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-2 sm:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((__, i) => (
|
||||
<div key={i} className="min-h-[44px] space-y-1">
|
||||
<Skeleton className="h-3 w-12" />
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -421,12 +421,18 @@ function TargetReferencePanel({
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
|
||||
>
|
||||
<span>{t("targets.sourceLabel", { source: target.source })}</span>
|
||||
<span>
|
||||
{t("targets.sourceLabel", {
|
||||
source: sourceName(target.source, t),
|
||||
})}
|
||||
</span>
|
||||
<ExternalLink className="size-3" aria-hidden="true" />
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("targets.sourceLabel", { source: target.source })}
|
||||
{t("targets.sourceLabel", {
|
||||
source: sourceName(target.source, t),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -434,3 +440,19 @@ function TargetReferencePanel({
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Two target sources are plain descriptions rather than a citation ("Mood
|
||||
* entries", "7-day"); the server sends them in English, so they are named in
|
||||
* the reader's language here. Every other source is a citation (ESH 2023,
|
||||
* WHO, AASM/SRS) and reads as it is.
|
||||
*/
|
||||
const SOURCE_NAME_KEYS: Record<string, string> = {
|
||||
"Mood entries": "targets.sourceNames.moodEntries",
|
||||
"7-day": "targets.sourceNames.sevenDays",
|
||||
};
|
||||
|
||||
export function sourceName(source: string, t: (key: string) => string): string {
|
||||
const key = SOURCE_NAME_KEYS[source];
|
||||
return key ? t(key) : source;
|
||||
}
|
||||
@@ -122,7 +122,13 @@ export function SleepOverview() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{sleepStages ? (
|
||||
{/* While the read is in flight the bar's own skeleton holds its
|
||||
place: the "stages unavailable" card is a statement about the data,
|
||||
and painting it before the data arrived both said something untrue
|
||||
and pushed the duration chart down when the bar replaced it. */}
|
||||
{isLoading ? (
|
||||
<ChartSkeleton />
|
||||
) : sleepStages ? (
|
||||
<SleepStageStackedBar breakdown={sleepStages} />
|
||||
) : (
|
||||
<Card>
|
||||
|
||||
@@ -396,7 +396,9 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) {
|
||||
aria-pressed={windowDays === w}
|
||||
data-slot={`sleep-stage-window-${w}`}
|
||||
>
|
||||
{w}d
|
||||
{/* The page's other range controls read "7T / 30T"; this
|
||||
one printed an English "7d" beside them. */}
|
||||
{t("charts.daysNLabel", { days: w })}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -144,7 +144,7 @@ export function BiomarkerForm({
|
||||
{onCancel ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
>
|
||||
|
||||
@@ -348,7 +348,7 @@ export function LabForm({
|
||||
{onCancel ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
>
|
||||
|
||||
@@ -263,18 +263,21 @@ export function CapturePicker({
|
||||
];
|
||||
const options = allOptions.filter((opt) => offered.includes(opt.kind));
|
||||
|
||||
// One title per kind, the same verb phrase the dashboard's add menu
|
||||
// shows, so the sheet names what is being logged wherever it opens from.
|
||||
const formTitleByKind: Record<CaptureKind, string> = {
|
||||
measurement: t("measurements.addMeasurement"),
|
||||
medication: t("nav.capture.medication"),
|
||||
mood: t("mood.addEntry"),
|
||||
symptom: t("symptoms.entry.sheetTitle"),
|
||||
workout: t("insights.workouts.manual.sheetTitle"),
|
||||
lifeEvent: t("lifeEvents.title"),
|
||||
measurement: t("dashboard.quickAddMeasurement"),
|
||||
medication: t("dashboard.quickAddMedicationIntake"),
|
||||
mood: t("dashboard.quickAddMood"),
|
||||
symptom: t("dashboard.quickAddSymptom"),
|
||||
workout: t("dashboard.quickAddWorkout"),
|
||||
lifeEvent: t("dashboard.quickAddLifeEvent"),
|
||||
};
|
||||
// `openKind === null` keeps the form sheet closed (the title is unread
|
||||
// then); the mood label is the harmless default, matching the prior
|
||||
// ternary's else branch.
|
||||
const formTitle = openKind ? formTitleByKind[openKind] : t("mood.addEntry");
|
||||
// then); the mood label is the harmless default.
|
||||
const formTitle = openKind
|
||||
? formTitleByKind[openKind]
|
||||
: t("dashboard.quickAddMood");
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -248,7 +248,12 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className="border-sidebar-border border-t p-3">
|
||||
<div
|
||||
className={cn(
|
||||
"border-sidebar-border border-t p-3",
|
||||
"[@media(max-height:940px)]:p-2",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
@@ -311,24 +316,110 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the nav list is taller than the room the rail leaves it, i.e.
|
||||
* whether it scrolls. Re-measured when the nav (viewport height) or the list
|
||||
* (entries arriving as modules resolve) changes size.
|
||||
* Height-based density for the rail. A 13-inch laptop at 100 % zoom leaves
|
||||
* the browser roughly 700–860 px, and the full destination list plus the
|
||||
* footer rows did not fit: the list scrolled, and on a classic-scrollbar
|
||||
* platform (Windows) the rail grew a native scrollbar with arrow buttons.
|
||||
* On short windows the rows close their gaps first, then step their height
|
||||
* down; tall windows keep the roomy default. Desktop-only by construction —
|
||||
* the rail is hidden below `md`, so the phone layout never sees these steps.
|
||||
*
|
||||
* Steps (rows are full-width, so the click target is the whole row):
|
||||
* - default: 40 px rows, 4 px apart
|
||||
* - max-height 940 px: gaps close (40 px pitch), list and user padding tighten
|
||||
* - max-height 820 px: 36 px rows
|
||||
* - max-height 740 px: 32 px rows — the one step under 40 px, taken only
|
||||
* where the alternative is rows hidden behind a scroll edge
|
||||
*/
|
||||
function useNavOverflows(navRef: React.RefObject<HTMLElement | null>): boolean {
|
||||
const [overflows, setOverflows] = useState(false);
|
||||
// Disjoint height ranges, not nested max-heights: two arbitrary media
|
||||
// variants on one property cascade in stylesheet order, not by how narrow
|
||||
// the query is, so a nested pair let the 36 px step win on a 720 px window.
|
||||
// The height steps apply to a mouse only. Under a finger (a touch tablet,
|
||||
// a phone held sideways past the md breakpoint) the rows keep the 44 px
|
||||
// touch floor and the list scrolls behind its fades instead.
|
||||
const RAIL_ROW_HEIGHT =
|
||||
"min-h-10 pointer-coarse:min-h-11 [@media(hover:hover)_and_(pointer:fine)_and_(740px<height<=820px)]:min-h-9 [@media(hover:hover)_and_(pointer:fine)_and_(height<=740px)]:min-h-8";
|
||||
const RAIL_LIST_GAP = "space-y-1 [@media(max-height:940px)]:space-y-0";
|
||||
const RAIL_SHORT_PY = "[@media(max-height:940px)]:py-1.5";
|
||||
|
||||
// Literal class strings: Tailwind only generates what it can read whole.
|
||||
const RAIL_SHORT_TOUCH_COLUMN =
|
||||
"[@media(pointer:coarse)_and_(height<=520px)]:overflow-y-auto [@media(pointer:coarse)_and_(height<=520px)]:[scrollbar-width:none] [@media(pointer:coarse)_and_(height<=520px)]:[&::-webkit-scrollbar]:hidden [@media(pointer:coarse)_and_(height<=520px)]:[&>*]:shrink-0";
|
||||
const RAIL_SHORT_TOUCH_NAV =
|
||||
"[@media(pointer:coarse)_and_(height<=520px)]:flex-none [@media(pointer:coarse)_and_(height<=520px)]:overflow-visible";
|
||||
|
||||
/** Rail row, collapsed (icon only) or expanded (icon + label). */
|
||||
function railRowClass(collapsed: boolean): string {
|
||||
return cn(
|
||||
"flex items-center rounded-lg transition-colors",
|
||||
RAIL_ROW_HEIGHT,
|
||||
collapsed
|
||||
? "justify-center px-2.5"
|
||||
: "gap-3 px-3 py-1.5 text-sm font-medium",
|
||||
);
|
||||
}
|
||||
|
||||
/** Height of the fade at a scroll edge that has more content beyond it. */
|
||||
const RAIL_FADE = "1.5rem";
|
||||
|
||||
/**
|
||||
* The fade mask for the list's scroll edges: a top fade while there is
|
||||
* content above, a bottom fade while there is content below. A mask paints
|
||||
* alpha only, so it reads the same in light and dark and never moves layout.
|
||||
*/
|
||||
function railFadeMask(
|
||||
top: boolean,
|
||||
bottom: boolean,
|
||||
): React.CSSProperties | undefined {
|
||||
if (!top && !bottom) return undefined;
|
||||
const image = `linear-gradient(to bottom, ${top ? "transparent" : "#000"} 0, #000 ${top ? RAIL_FADE : "0px"}, #000 calc(100% - ${bottom ? RAIL_FADE : "0px"}), ${bottom ? "transparent" : "#000"} 100%)`;
|
||||
return { maskImage: image, WebkitMaskImage: image };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the nav list is taller than the room the rail leaves it, i.e.
|
||||
* whether it scrolls, and which of its edges have more content beyond them.
|
||||
* Re-measured when the nav (viewport height) or the list (entries arriving
|
||||
* as modules resolve) changes size, and on scroll.
|
||||
*/
|
||||
function useNavOverflows(navRef: React.RefObject<HTMLElement | null>): {
|
||||
overflows: boolean;
|
||||
moreAbove: boolean;
|
||||
moreBelow: boolean;
|
||||
} {
|
||||
const [edges, setEdges] = useState({
|
||||
overflows: false,
|
||||
moreAbove: false,
|
||||
moreBelow: false,
|
||||
});
|
||||
useEffect(() => {
|
||||
const nav = navRef.current;
|
||||
if (!nav || typeof ResizeObserver === "undefined") return;
|
||||
// Strict: any overflow, the same test that gives the list a scrollbar.
|
||||
const update = () => setOverflows(nav.scrollHeight > nav.clientHeight);
|
||||
// Strict: any overflow, the same test that makes the list scroll.
|
||||
const update = () => {
|
||||
const overflows = nav.scrollHeight > nav.clientHeight;
|
||||
const moreAbove = overflows && nav.scrollTop > 0.5;
|
||||
const moreBelow =
|
||||
overflows && nav.scrollTop + nav.clientHeight < nav.scrollHeight - 0.5;
|
||||
setEdges((prev) =>
|
||||
prev.overflows === overflows &&
|
||||
prev.moreAbove === moreAbove &&
|
||||
prev.moreBelow === moreBelow
|
||||
? prev
|
||||
: { overflows, moreAbove, moreBelow },
|
||||
);
|
||||
};
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(nav);
|
||||
if (nav.firstElementChild) observer.observe(nav.firstElementChild);
|
||||
nav.addEventListener("scroll", update, { passive: true });
|
||||
update();
|
||||
return () => observer.disconnect();
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
nav.removeEventListener("scroll", update);
|
||||
};
|
||||
}, [navRef]);
|
||||
return overflows;
|
||||
return edges;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -361,8 +452,8 @@ export function SidebarCollapseToggle({
|
||||
aria-expanded={!collapsed}
|
||||
data-slot="sidebar-collapse-toggle"
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground hover:bg-accent focus-visible:ring-ring/50 flex w-full items-center rounded-lg text-sm font-medium transition-colors outline-none focus-visible:ring-[3px]",
|
||||
collapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2.5",
|
||||
railRowClass(collapsed),
|
||||
"text-muted-foreground hover:text-foreground hover:bg-accent focus-visible:ring-ring/50 w-full text-sm font-medium outline-none focus-visible:ring-[3px]",
|
||||
)}
|
||||
>
|
||||
<Icon aria-hidden="true" className="size-4 shrink-0" />
|
||||
@@ -448,7 +539,11 @@ export function SidebarNav() {
|
||||
const collapsed = mounted ? (collapsedPref ?? tabletOrBelow) : false;
|
||||
|
||||
const navRef = useRef<HTMLElement | null>(null);
|
||||
const navOverflows = useNavOverflows(navRef);
|
||||
const {
|
||||
overflows: navOverflows,
|
||||
moreAbove: navMoreAbove,
|
||||
moreBelow: navMoreBelow,
|
||||
} = useNavOverflows(navRef);
|
||||
|
||||
function toggleCollapsed() {
|
||||
const next = !collapsed;
|
||||
@@ -504,7 +599,7 @@ export function SidebarNav() {
|
||||
data-tour-id={tourId}
|
||||
data-slot={isSettings ? "nav-settings-link" : undefined}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-lg p-2.5 transition-colors",
|
||||
railRowClass(true),
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-accent",
|
||||
@@ -527,7 +622,7 @@ export function SidebarNav() {
|
||||
data-tour-id={tourId}
|
||||
data-slot={isSettings ? "nav-settings-link" : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
railRowClass(false),
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-accent",
|
||||
@@ -549,7 +644,7 @@ export function SidebarNav() {
|
||||
href="/admin"
|
||||
aria-current={onAdminPage ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-lg p-2.5 transition-colors",
|
||||
railRowClass(true),
|
||||
onAdminPage
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-accent",
|
||||
@@ -569,7 +664,7 @@ export function SidebarNav() {
|
||||
href="/admin"
|
||||
aria-current={onAdminPage ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
railRowClass(false),
|
||||
onAdminPage
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-accent",
|
||||
@@ -603,6 +698,11 @@ export function SidebarNav() {
|
||||
data-slot="sidebar-column"
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col",
|
||||
// A phone held sideways crosses the md breakpoint at ~390 px of
|
||||
// height: the fixed footer and user rows left the list three
|
||||
// rows. There the whole column scrolls as one (no native bar),
|
||||
// so every entry stays reachable at the 44 px touch floor.
|
||||
RAIL_SHORT_TOUCH_COLUMN,
|
||||
!collapsed && SIDEBAR_EXPANDED_CONTENT_WIDTH,
|
||||
)}
|
||||
>
|
||||
@@ -637,12 +737,24 @@ export function SidebarNav() {
|
||||
<nav
|
||||
ref={navRef}
|
||||
aria-label={t("nav.mainNavigation")}
|
||||
// The list scrolls without a native scrollbar (a classic
|
||||
// scrollbar with arrow buttons read as a foreign body in the
|
||||
// rail); fades at the edges say there is more, and focus keeps
|
||||
// the focused row clear of them.
|
||||
className={cn(
|
||||
"flex-1 overflow-y-auto",
|
||||
"flex-1 scroll-py-6 [scrollbar-width:none] overflow-y-auto [&::-webkit-scrollbar]:hidden",
|
||||
RAIL_SHORT_TOUCH_NAV,
|
||||
collapsed ? "p-1.5" : "p-3",
|
||||
RAIL_SHORT_PY,
|
||||
)}
|
||||
style={railFadeMask(navMoreAbove, navMoreBelow)}
|
||||
data-fade-top={navMoreAbove ? "true" : undefined}
|
||||
data-fade-bottom={navMoreBelow ? "true" : undefined}
|
||||
onFocus={(event) => {
|
||||
event.target.scrollIntoView?.({ block: "nearest" });
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className={RAIL_LIST_GAP}>
|
||||
{visibleNavItems.map((item) => {
|
||||
const isActive = isNavDestinationActive(
|
||||
item.href,
|
||||
@@ -661,7 +773,7 @@ export function SidebarNav() {
|
||||
data-tour-id={item.tourId}
|
||||
{...(item.href === "/medications" ? medsIntent : {})}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-lg p-2.5 transition-colors",
|
||||
railRowClass(true),
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-accent",
|
||||
@@ -685,7 +797,7 @@ export function SidebarNav() {
|
||||
data-tour-id={item.tourId}
|
||||
{...(item.href === "/medications" ? medsIntent : {})}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
railRowClass(false),
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-accent",
|
||||
@@ -714,7 +826,8 @@ export function SidebarNav() {
|
||||
data-slot="sidebar-footer"
|
||||
data-nav-overflows={navOverflows ? "true" : undefined}
|
||||
className={cn(
|
||||
"space-y-1 pb-1",
|
||||
RAIL_LIST_GAP,
|
||||
"pb-1",
|
||||
collapsed ? "px-1.5" : "px-3",
|
||||
navOverflows && "border-sidebar-border border-t pt-1",
|
||||
)}
|
||||
|
||||
@@ -97,7 +97,7 @@ export function TopBar() {
|
||||
// inside its own 4rem, a 59 px inset left the bar a 5 px content box.
|
||||
>
|
||||
{/* Mobile logo */}
|
||||
<Link href="/" className="flex items-center gap-2 md:hidden">
|
||||
<Link href="/" className="flex min-h-11 items-center gap-2 md:hidden">
|
||||
<Logo className="text-primary" size={20} />
|
||||
<span className="font-bold tracking-tight">HealthLog</span>
|
||||
</Link>
|
||||
|
||||
@@ -480,7 +480,6 @@ export function VorsorgeSection({
|
||||
? "measurementReminders.form.editTitle"
|
||||
: "measurementReminders.form.createTitle",
|
||||
)}
|
||||
description={t("measurementReminders.sectionDescription")}
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" variant="outline" onClick={closeSheet}>
|
||||
|
||||
@@ -131,4 +131,11 @@ describe("MeasurementList — Sys/Dia disambiguation badge (mobile list)", () =>
|
||||
expect(countOccurrences(mobile, ">Sys<")).toBe(1);
|
||||
expect(countOccurrences(mobile, ">Dia<")).toBe(1);
|
||||
});
|
||||
|
||||
it("names the type on every mobile row, not only blood pressure", () => {
|
||||
vi.mocked(useIsMobile).mockReturnValue(true);
|
||||
expect(
|
||||
countOccurrences(render("en"), 'data-slot="measurement-row-type"'),
|
||||
).toBe(baseMeasurements.length);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
MEASUREMENT_TYPE_LABEL_KEYS,
|
||||
MEASUREMENT_TYPE_ICONS,
|
||||
MEASUREMENT_TYPE_COLORS,
|
||||
measurementUnitLabel,
|
||||
} from "../measurement-list-meta";
|
||||
import enMessages from "../../../../messages/en.json";
|
||||
import deMessages from "../../../../messages/de.json";
|
||||
@@ -41,3 +42,22 @@ describe("measurement-list-meta", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("measurementUnitLabel", () => {
|
||||
const t = (key: string) => `t:${key}`;
|
||||
it("shows the localised word for count-shaped source units", () => {
|
||||
expect(measurementUnitLabel("FLIGHTS_CLIMBED", "count", t)).toBe(
|
||||
"t:insights.units.flights",
|
||||
);
|
||||
expect(measurementUnitLabel("RESPIRATORY_RATE", "count/min", t)).toBe(
|
||||
"t:insights.units.respiratoryRate",
|
||||
);
|
||||
});
|
||||
it("passes every other unit through", () => {
|
||||
expect(measurementUnitLabel("WEIGHT", "kg", t)).toBe("kg");
|
||||
});
|
||||
it("drops the score and rating tokens", () => {
|
||||
expect(measurementUnitLabel("RECOVERY_SCORE", "score", t)).toBe("");
|
||||
expect(measurementUnitLabel("VISCERAL_FAT", "rating", t)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -300,7 +300,9 @@ function UnitLabel({ htmlFor, label }: { htmlFor: string; label: string }) {
|
||||
const name = match ? match[1] : label;
|
||||
const unit = match ? match[2] : null;
|
||||
return (
|
||||
<Label htmlFor={htmlFor} className="flex items-baseline gap-1">
|
||||
// `after:-ml-1` pulls the label's colon back against the unit: the colon
|
||||
// is a flex item of its own here, and the row gap set it apart.
|
||||
<Label htmlFor={htmlFor} className="flex items-baseline gap-1 after:-ml-1">
|
||||
<span className="truncate">{name}</span>
|
||||
{unit ? (
|
||||
<span className="text-muted-foreground shrink-0 text-xs font-normal">
|
||||
@@ -773,8 +775,8 @@ export function MeasurementForm({
|
||||
htmlFor="notes"
|
||||
label={
|
||||
<>
|
||||
{t("measurements.notes")}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
{t("measurements.notes")}
|
||||
<span className="text-muted-foreground ml-1 font-normal">
|
||||
({t("common.optional")})
|
||||
</span>
|
||||
</>
|
||||
|
||||
@@ -270,127 +270,158 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
|
||||
};
|
||||
|
||||
export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
|
||||
WEIGHT: "bg-chart-1/20 text-chart-1",
|
||||
BLOOD_PRESSURE_SYS: "bg-chart-3/20 text-chart-3",
|
||||
BLOOD_PRESSURE_DIA: "bg-chart-3/20 text-chart-3",
|
||||
PULSE: "bg-chart-5/20 text-chart-5",
|
||||
BODY_FAT: "bg-chart-4/20 text-chart-4",
|
||||
SLEEP_DURATION: "bg-chart-2/20 text-chart-2",
|
||||
ACTIVITY_STEPS: "bg-chart-2/20 text-chart-2",
|
||||
BLOOD_GLUCOSE: "bg-chart-3/20 text-chart-3",
|
||||
TOTAL_BODY_WATER: "bg-chart-2/20 text-chart-2",
|
||||
BONE_MASS: "bg-chart-4/20 text-chart-4",
|
||||
OXYGEN_SATURATION: "bg-chart-5/20 text-chart-5",
|
||||
WEIGHT: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
BLOOD_PRESSURE_SYS: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
BLOOD_PRESSURE_DIA: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
PULSE: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
|
||||
BODY_FAT: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
|
||||
SLEEP_DURATION: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
ACTIVITY_STEPS: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
BLOOD_GLUCOSE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
TOTAL_BODY_WATER: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
BONE_MASS: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
|
||||
OXYGEN_SATURATION: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
|
||||
// ── v1.4.23 Apple Health additions ──
|
||||
HEART_RATE_VARIABILITY: "bg-chart-5/20 text-chart-5",
|
||||
RESTING_HEART_RATE: "bg-chart-3/20 text-chart-3",
|
||||
ACTIVE_ENERGY_BURNED: "bg-chart-4/20 text-chart-4",
|
||||
FLIGHTS_CLIMBED: "bg-chart-2/20 text-chart-2",
|
||||
WALKING_RUNNING_DISTANCE: "bg-chart-2/20 text-chart-2",
|
||||
VO2_MAX: "bg-chart-1/20 text-chart-1",
|
||||
BODY_TEMPERATURE: "bg-chart-4/20 text-chart-4",
|
||||
HEART_RATE_VARIABILITY: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
|
||||
RESTING_HEART_RATE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
ACTIVE_ENERGY_BURNED: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
|
||||
FLIGHTS_CLIMBED: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
WALKING_RUNNING_DISTANCE: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
VO2_MAX: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
BODY_TEMPERATURE: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
|
||||
// ── v1.4.25 W5d Withings full coverage ──
|
||||
// chart-1 (mass), chart-3 (cardio), chart-4 (fat/temp), chart-5
|
||||
// (pulse-derived) — extend the existing color-family conventions.
|
||||
FAT_FREE_MASS: "bg-chart-1/20 text-chart-1",
|
||||
FAT_MASS: "bg-chart-4/20 text-chart-4",
|
||||
MUSCLE_MASS: "bg-chart-1/20 text-chart-1",
|
||||
SKIN_TEMPERATURE: "bg-chart-4/20 text-chart-4",
|
||||
PULSE_WAVE_VELOCITY: "bg-chart-5/20 text-chart-5",
|
||||
VASCULAR_AGE: "bg-chart-3/20 text-chart-3",
|
||||
VISCERAL_FAT: "bg-chart-4/20 text-chart-4",
|
||||
FAT_FREE_MASS: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
FAT_MASS: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
|
||||
MUSCLE_MASS: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
SKIN_TEMPERATURE: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
|
||||
PULSE_WAVE_VELOCITY: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
|
||||
VASCULAR_AGE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
VISCERAL_FAT: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
|
||||
// ── v1.4.25 W8d Apple Health server-prep ──
|
||||
// chart-5 (pulse / sound family) carries audio exposure; chart-2
|
||||
// (activity / daylight family) carries time-in-daylight so the
|
||||
// existing palette conventions hold.
|
||||
AUDIO_EXPOSURE_ENV: "bg-chart-5/20 text-chart-5",
|
||||
AUDIO_EXPOSURE_HEADPHONE: "bg-chart-5/20 text-chart-5",
|
||||
TIME_IN_DAYLIGHT: "bg-chart-2/20 text-chart-2",
|
||||
AUDIO_EXPOSURE_ENV: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
|
||||
AUDIO_EXPOSURE_HEADPHONE: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
|
||||
TIME_IN_DAYLIGHT: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
// ── v1.4.30 R-F T1.4 + T1.5 ──
|
||||
WALKING_STEADINESS: "bg-chart-2/20 text-chart-2",
|
||||
AUDIO_EXPOSURE_EVENT: "bg-chart-5/20 text-chart-5",
|
||||
WALKING_STEADINESS: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
AUDIO_EXPOSURE_EVENT: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
|
||||
// ── v1.5.5 iOS-coord additions ──
|
||||
// Reuse the existing palette families: chart-5 (cardio/pulse),
|
||||
// chart-1 (mass), chart-2 (activity/gait).
|
||||
RESPIRATORY_RATE: "bg-chart-5/20 text-chart-5",
|
||||
BODY_MASS_INDEX: "bg-chart-1/20 text-chart-1",
|
||||
LEAN_BODY_MASS: "bg-chart-1/20 text-chart-1",
|
||||
WALKING_HEART_RATE_AVERAGE: "bg-chart-3/20 text-chart-3",
|
||||
WALKING_ASYMMETRY: "bg-chart-2/20 text-chart-2",
|
||||
WALKING_DOUBLE_SUPPORT: "bg-chart-2/20 text-chart-2",
|
||||
RESPIRATORY_RATE: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
|
||||
BODY_MASS_INDEX: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
LEAN_BODY_MASS: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
WALKING_HEART_RATE_AVERAGE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
WALKING_ASYMMETRY: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
WALKING_DOUBLE_SUPPORT: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
// ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ──
|
||||
// Stay in chart-2 (Dracula green) — the entire Mobility cluster
|
||||
// (steadiness + asymmetry + double-support + step length + speed)
|
||||
// shares the activity-family colour so the gait cards read as one
|
||||
// visual group on Insights.
|
||||
WALKING_STEP_LENGTH: "bg-chart-2/20 text-chart-2",
|
||||
WALKING_SPEED: "bg-chart-2/20 text-chart-2",
|
||||
WALKING_STEP_LENGTH: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
WALKING_SPEED: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
// ── v1.10.0 — additive HealthKit signals (WX-A) ──
|
||||
// chart-3 (cardio), chart-4 (temp), chart-2 (activity/gait family),
|
||||
// chart-5 (sleep-breathing, shares the SpO2 family).
|
||||
CARDIO_RECOVERY: "bg-chart-3/20 text-chart-3",
|
||||
WRIST_TEMPERATURE: "bg-chart-4/20 text-chart-4",
|
||||
FALL_COUNT: "bg-chart-2/20 text-chart-2",
|
||||
SIX_MINUTE_WALK_DISTANCE: "bg-chart-2/20 text-chart-2",
|
||||
STAIR_ASCENT_SPEED: "bg-chart-2/20 text-chart-2",
|
||||
STAIR_DESCENT_SPEED: "bg-chart-2/20 text-chart-2",
|
||||
BREATHING_DISTURBANCES: "bg-chart-5/20 text-chart-5",
|
||||
CARDIO_RECOVERY: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
WRIST_TEMPERATURE: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
|
||||
FALL_COUNT: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
SIX_MINUTE_WALK_DISTANCE: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
STAIR_ASCENT_SPEED: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
STAIR_DESCENT_SPEED: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
BREATHING_DISTURBANCES: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
|
||||
// ── v1.10.0 — categorical events (WX-B) ──
|
||||
// chart-3 (cardio family) carries the rhythm + heart-rate events;
|
||||
// chart-2 (activity/mobility) carries the steadiness event; chart-5
|
||||
// (respiratory/pulse family) carries the breathing event.
|
||||
IRREGULAR_RHYTHM_NOTIFICATION: "bg-chart-3/20 text-chart-3",
|
||||
HIGH_HEART_RATE_EVENT: "bg-chart-3/20 text-chart-3",
|
||||
LOW_HEART_RATE_EVENT: "bg-chart-3/20 text-chart-3",
|
||||
WALKING_STEADINESS_EVENT: "bg-chart-2/20 text-chart-2",
|
||||
BREATHING_DISTURBANCE_EVENT: "bg-chart-5/20 text-chart-5",
|
||||
IRREGULAR_RHYTHM_NOTIFICATION:
|
||||
"bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
HIGH_HEART_RATE_EVENT: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
LOW_HEART_RATE_EVENT: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
WALKING_STEADINESS_EVENT: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
BREATHING_DISTURBANCE_EVENT: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
|
||||
// ── v1.10.0 — computed scores (WX-C) ──
|
||||
// chart-1 (Dracula purple) marks the server-derived composites as their
|
||||
// own visual group, distinct from the raw-signal families above.
|
||||
RECOVERY_SCORE: "bg-chart-1/20 text-chart-1",
|
||||
STRESS_SCORE: "bg-chart-1/20 text-chart-1",
|
||||
STRAIN_SCORE: "bg-chart-1/20 text-chart-1",
|
||||
RECOVERY_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
STRESS_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
STRAIN_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
// ── v1.11.0 — WHOOP-native score classes ──
|
||||
// chart-1 (Dracula purple) for the strain composites (same group as the
|
||||
// WX-C scores); chart-2 (sleep/activity family) for the sleep-quality set
|
||||
// and energy; chart-5 (cardio/pulse family) for RMSSD HRV.
|
||||
HRV_RMSSD: "bg-chart-5/20 text-chart-5",
|
||||
DAY_STRAIN: "bg-chart-1/20 text-chart-1",
|
||||
WORKOUT_STRAIN: "bg-chart-1/20 text-chart-1",
|
||||
SLEEP_PERFORMANCE: "bg-chart-2/20 text-chart-2",
|
||||
SLEEP_EFFICIENCY: "bg-chart-2/20 text-chart-2",
|
||||
SLEEP_CONSISTENCY: "bg-chart-2/20 text-chart-2",
|
||||
SLEEP_NEED: "bg-chart-2/20 text-chart-2",
|
||||
ENERGY_EXPENDITURE_KJ: "bg-chart-4/20 text-chart-4",
|
||||
HRV_RMSSD: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
|
||||
DAY_STRAIN: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
WORKOUT_STRAIN: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
SLEEP_PERFORMANCE: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
SLEEP_EFFICIENCY: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
SLEEP_CONSISTENCY: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
SLEEP_NEED: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
ENERGY_EXPENDITURE_KJ: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
|
||||
// ── v1.12.8 — WHOOP cycle + sleep coverage completion ──
|
||||
// chart-3 (cardio family) for the daily-aggregate heart-rate pair;
|
||||
// chart-2 (sleep/activity family) for the per-night disturbance count.
|
||||
AVERAGE_HEART_RATE: "bg-chart-3/20 text-chart-3",
|
||||
MAX_HEART_RATE: "bg-chart-3/20 text-chart-3",
|
||||
SLEEP_DISTURBANCE_COUNT: "bg-chart-2/20 text-chart-2",
|
||||
AVERAGE_HEART_RATE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
MAX_HEART_RATE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
SLEEP_DISTURBANCE_COUNT: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
// ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ──
|
||||
// chart-1 (strain/score family) for both, matching DAY_STRAIN.
|
||||
ANS_CHARGE: "bg-chart-1/20 text-chart-1",
|
||||
CARDIO_LOAD: "bg-chart-1/20 text-chart-1",
|
||||
ANS_CHARGE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
CARDIO_LOAD: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
// ── v1.17.1 — Oura coverage completion ──
|
||||
// chart-2 (sleep family) for the Sleep Score; chart-5 (metabolic/thermal)
|
||||
// for the body-temperature deviation.
|
||||
SLEEP_SCORE: "bg-chart-2/20 text-chart-2",
|
||||
BODY_TEMPERATURE_DEVIATION: "bg-chart-5/20 text-chart-5",
|
||||
SLEEP_SCORE: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
BODY_TEMPERATURE_DEVIATION: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20",
|
||||
// ── v1.19.0 — Oura resilience ──
|
||||
// chart-1 (Dracula purple) marks the derived recovery / score composites as
|
||||
// their own visual group, matching RECOVERY_SCORE / DAY_STRAIN.
|
||||
RESILIENCE: "bg-chart-1/20 text-chart-1",
|
||||
RESILIENCE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
// ── v1.25 — clinical-signals wave ──
|
||||
// chart-1 (composite/score family) for the screener totals + grip strength;
|
||||
// chart-3 (cardio/alert family) for pain; chart-4 (body family) for waist.
|
||||
PHQ9_SCORE: "bg-chart-1/20 text-chart-1",
|
||||
GAD7_SCORE: "bg-chart-1/20 text-chart-1",
|
||||
PHQ9_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
GAD7_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
// ── v1.27.9 — screening scores join the composite/score colour family ──
|
||||
WHO5_SCORE: "bg-chart-1/20 text-chart-1",
|
||||
SCI_SCORE: "bg-chart-1/20 text-chart-1",
|
||||
GRIP_STRENGTH: "bg-chart-1/20 text-chart-1",
|
||||
PAIN_NRS: "bg-chart-3/20 text-chart-3",
|
||||
WAIST_CIRCUMFERENCE: "bg-chart-4/20 text-chart-4",
|
||||
WAIST_TO_HEIGHT: "bg-chart-4/20 text-chart-4",
|
||||
WHO5_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
SCI_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
GRIP_STRENGTH: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
PAIN_NRS: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
WAIST_CIRCUMFERENCE: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
|
||||
WAIST_TO_HEIGHT: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20",
|
||||
};
|
||||
|
||||
/**
|
||||
* Count-shaped types whose stored unit is a source token ("count",
|
||||
* "count/min", "steps") rather than a word a reader should see. The list
|
||||
* shows the same localised unit the type's insights page uses, so a row
|
||||
* reads "13 Etagen", not "13 count".
|
||||
*/
|
||||
export const MEASUREMENT_UNIT_LABEL_KEYS: Partial<Record<string, string>> = {
|
||||
ACTIVITY_STEPS: "insights.units.steps",
|
||||
FLIGHTS_CLIMBED: "insights.units.flights",
|
||||
FALL_COUNT: "insights.units.falls",
|
||||
BREATHING_DISTURBANCES: "insights.units.breathingEvents",
|
||||
RESPIRATORY_RATE: "insights.units.respiratoryRate",
|
||||
};
|
||||
|
||||
/** The unit a list row shows for `type`: the localised word, else `unit`. */
|
||||
export function measurementUnitLabel(
|
||||
type: string,
|
||||
unit: string,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
const key = MEASUREMENT_UNIT_LABEL_KEYS[type];
|
||||
if (key) return t(key);
|
||||
// A score or a rating is a bare number; "72 score" reads as a stray
|
||||
// English token next to the German label, not as a unit.
|
||||
if (UNITLESS_TOKENS.has(unit)) return "";
|
||||
return unit;
|
||||
}
|
||||
|
||||
const UNITLESS_TOKENS: ReadonlySet<string> = new Set(["score", "rating"]);
|
||||
@@ -104,6 +104,7 @@ import {
|
||||
MEASUREMENT_TYPE_LABEL_KEYS as TYPE_LABEL_KEYS,
|
||||
MEASUREMENT_TYPE_ICONS as TYPE_ICONS,
|
||||
MEASUREMENT_TYPE_COLORS as TYPE_COLORS,
|
||||
measurementUnitLabel,
|
||||
} from "./measurement-list-meta";
|
||||
import {
|
||||
measurementListFiltersToSearch,
|
||||
@@ -1262,7 +1263,11 @@ export function MeasurementList({
|
||||
rowDisplay(m).value,
|
||||
rawDisplayFractionDigits(m.type),
|
||||
)}{" "}
|
||||
{isGrouped ? m.unit : rowDisplay(m).unit}
|
||||
{measurementUnitLabel(
|
||||
m.type,
|
||||
isGrouped ? m.unit : rowDisplay(m).unit,
|
||||
t,
|
||||
)}
|
||||
{isGrouped && (
|
||||
<span className="text-muted-foreground ml-2 text-xs font-normal">
|
||||
{t("measurements.dailyTotalCaption", {
|
||||
@@ -1457,15 +1462,19 @@ export function MeasurementList({
|
||||
token `text-xs` (12 px, the mobile legibility
|
||||
baseline) instead of an arbitrary per-site
|
||||
size, so the row tracks the type scale. */}
|
||||
{(m.type === "BLOOD_PRESSURE_SYS" ||
|
||||
m.type === "BLOOD_PRESSURE_DIA") && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="mr-1.5 h-5 px-1 text-xs"
|
||||
>
|
||||
{t(TYPE_LABEL_KEYS[m.type])}
|
||||
</Badge>
|
||||
)}
|
||||
{/* The type names the row: without it two
|
||||
readings in the same unit ("55 ms" HRV and
|
||||
"63 ms" RMSSD) could not be told apart on a
|
||||
phone, where the desktop table's type column
|
||||
does not exist. */}
|
||||
<p
|
||||
className="truncate text-xs font-medium"
|
||||
data-slot="measurement-row-type"
|
||||
>
|
||||
{TYPE_LABEL_KEYS[m.type]
|
||||
? t(TYPE_LABEL_KEYS[m.type])
|
||||
: m.type}
|
||||
</p>
|
||||
<span
|
||||
className="font-semibold tabular-nums"
|
||||
data-slot="measurement-row-value"
|
||||
@@ -1486,7 +1495,11 @@ export function MeasurementList({
|
||||
rowDisplay(m).value,
|
||||
rawDisplayFractionDigits(m.type),
|
||||
)}{" "}
|
||||
{isGrouped ? m.unit : rowDisplay(m).unit}
|
||||
{measurementUnitLabel(
|
||||
m.type,
|
||||
isGrouped ? m.unit : rowDisplay(m).unit,
|
||||
t,
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -353,6 +353,7 @@ export function MoodForm({
|
||||
doubled heading. */}
|
||||
<Label
|
||||
id="mood-level-label"
|
||||
noColon
|
||||
className="text-muted-foreground text-sm font-normal"
|
||||
>
|
||||
{t("mood.heroQuestion")}
|
||||
@@ -479,8 +480,8 @@ export function MoodForm({
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label htmlFor="tags">
|
||||
{t("mood.tags")}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
{t("mood.tags")}
|
||||
<span className="text-muted-foreground ml-1 font-normal">
|
||||
({t("common.optional")})
|
||||
</span>
|
||||
</Label>
|
||||
@@ -554,8 +555,8 @@ export function MoodForm({
|
||||
htmlFor="mood-note"
|
||||
label={
|
||||
<>
|
||||
{t("mood.note")}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
{t("mood.note")}
|
||||
<span className="text-muted-foreground ml-1 font-normal">
|
||||
({t("common.optional")})
|
||||
</span>
|
||||
</>
|
||||
|
||||
@@ -98,7 +98,7 @@ export function PractitionerList({ enabled = true }: { enabled?: boolean }) {
|
||||
onClick={() => openSheet("new")}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
{t("practitioners.add")}
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ export function AllergyForm({
|
||||
{onCancel ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
>
|
||||
|
||||
@@ -114,7 +114,7 @@ export function FamilyHistoryForm({
|
||||
{onCancel ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
>
|
||||
|
||||
@@ -130,7 +130,9 @@ export function ProfileSummaryPage() {
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="container mx-auto max-w-3xl space-y-6 px-4 py-6">
|
||||
// The shell owns the page frame (gutter, width, top space) and the
|
||||
// <main> landmark; this page only sets its rhythm.
|
||||
<div className="space-y-6">
|
||||
<PageHeader title={t("settings.sections.anamnesis.title")} />
|
||||
{summary.isPending ? (
|
||||
<p className="text-muted-foreground text-sm" aria-live="polite">
|
||||
@@ -141,6 +143,6 @@ export function ProfileSummaryPage() {
|
||||
<QueryErrorCard onRetry={() => void summary.refetch()} />
|
||||
) : null}
|
||||
{summary.data ? <ProfileSummary summary={summary.data} /> : null}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -164,14 +164,7 @@ export function GrantInviteCard() {
|
||||
<SettingsCardHeader
|
||||
icon={UserPlus}
|
||||
title={t("recordSharing.invite.title")}
|
||||
// A sharing invitation carries no link and nothing is e-mailed: the
|
||||
// person accepts it from inside their own session. Said here, at the
|
||||
// top of the card, so an inviter never goes looking for a link to
|
||||
// send — the invite-link surface is Admin → Invites, and it is a
|
||||
// different thing entirely.
|
||||
description={`${t("recordSharing.invite.description")} ${t(
|
||||
"recordSharing.invite.needsAccount",
|
||||
)}`}
|
||||
description={t("recordSharing.invite.description")}
|
||||
/>
|
||||
<form onSubmit={(event) => void submit(event)} className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
@@ -187,8 +180,14 @@ export function GrantInviteCard() {
|
||||
autoComplete="off"
|
||||
maxLength={255}
|
||||
/>
|
||||
{/* A sharing invitation carries no link and nothing is e-mailed:
|
||||
the person accepts it from inside their own session. Said right
|
||||
under the field that names them, so an inviter never goes
|
||||
looking for a link to send (the invite-link surface is Admin →
|
||||
Invites, a different thing entirely), and said once — the card
|
||||
description stays one sentence. */}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("recordSharing.invite.identifierHint")}
|
||||
{t("recordSharing.invite.needsAccount")}
|
||||
</p>
|
||||
</div>
|
||||
<fieldset className="space-y-1.5" data-slot="grant-invite-access">
|
||||
|
||||
@@ -21,6 +21,7 @@ import { toast } from "sonner";
|
||||
import { CloudSun, MapPin, Plane, Trash2, Wind } from "lucide-react";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DateField } from "@/components/ui/date-field";
|
||||
@@ -30,7 +31,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
apiDelete,
|
||||
apiGet,
|
||||
@@ -122,6 +123,7 @@ function envValue(value: number | null, unit: string, digits = 0): string {
|
||||
|
||||
export function EnvironmentSection() {
|
||||
const { t, tCount } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
// Date defaults are the user's own days. The UTC day was tomorrow for
|
||||
@@ -306,7 +308,7 @@ export function EnvironmentSection() {
|
||||
{home.since && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.environment.home.since", {
|
||||
date: home.since.slice(0, 10),
|
||||
date: fmt.dateShortSmartCalendar(home.since.slice(0, 10)),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
@@ -393,7 +395,8 @@ export function EnvironmentSection() {
|
||||
className="flex items-center justify-between gap-2 p-2"
|
||||
>
|
||||
<span className="text-sm">
|
||||
{tr.label} · {tr.startDate} – {tr.endDate}
|
||||
{tr.label} · {fmt.dateShortSmartCalendar(tr.startDate)} –{" "}
|
||||
{fmt.dateShortSmartCalendar(tr.endDate)}
|
||||
</span>
|
||||
<ConfirmButton
|
||||
slot="travel-remove"
|
||||
@@ -531,7 +534,7 @@ export function EnvironmentSection() {
|
||||
<p className="text-sm leading-relaxed">
|
||||
{t("settings.sections.environment.backfill.description")}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="env-backfill-start">
|
||||
{t("settings.sections.environment.startDate")}
|
||||
@@ -552,6 +555,32 @@ export function EnvironmentSection() {
|
||||
onChange={setBackfillEnd}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{data && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.environment.storedDays", {
|
||||
count: data.context.days,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{data?.lastFetchFailure && (
|
||||
<p
|
||||
role="status"
|
||||
data-testid="environment-fetch-failed"
|
||||
className="text-destructive text-xs"
|
||||
>
|
||||
{tCount(
|
||||
"settings.sections.environment.fetchFailed",
|
||||
data.lastFetchFailure.failures,
|
||||
{
|
||||
count: data.lastFetchFailure.failures,
|
||||
when: formatDateTime(data.lastFetchFailure.lastFailedAt),
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{/* The action row closes the card; the status lines sit above it. */}
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
@@ -566,30 +595,7 @@ export function EnvironmentSection() {
|
||||
>
|
||||
{t("settings.sections.environment.backfill.run")}
|
||||
</Button>
|
||||
</div>
|
||||
{data && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.environment.storedDays", {
|
||||
count: data.context.days,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{data?.lastFetchFailure && (
|
||||
<p
|
||||
role="status"
|
||||
data-testid="environment-fetch-failed"
|
||||
className="text-destructive mt-1 text-xs"
|
||||
>
|
||||
{tCount(
|
||||
"settings.sections.environment.fetchFailed",
|
||||
data.lastFetchFailure.failures,
|
||||
{
|
||||
count: data.lastFetchFailure.failures,
|
||||
when: formatDateTime(data.lastFetchFailure.lastFailedAt),
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Air quality, pollen and UV (v1.42) */}
|
||||
@@ -636,7 +642,7 @@ export function EnvironmentSection() {
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">
|
||||
{t("settings.sections.environment.airQuality.latest", {
|
||||
date: data.latestDay.date,
|
||||
date: fmt.dateShortSmartCalendar(data.latestDay.date),
|
||||
})}
|
||||
</p>
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
|
||||
@@ -700,7 +706,7 @@ export function EnvironmentSection() {
|
||||
{/* Attribution (CC BY 4.0 for Open-Meteo; Copernicus notice for the
|
||||
air-quality values), one line per source. */}
|
||||
<ul
|
||||
className="text-muted-foreground space-y-0.5 pl-1 text-xs"
|
||||
className="text-muted-foreground space-y-0.5 text-xs"
|
||||
aria-label={t("settings.sections.environment.airQuality.attribution")}
|
||||
>
|
||||
{(
|
||||
|
||||
@@ -85,7 +85,7 @@ export function EditSymptomEventSheet({
|
||||
title={t("symptoms.section.editEventTitle", { symptom: label })}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={update.isPending}>
|
||||
@@ -128,8 +128,8 @@ export function EditSymptomEventSheet({
|
||||
htmlFor="symptom-event-note"
|
||||
label={
|
||||
<>
|
||||
{t("symptoms.entry.note")}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
{t("symptoms.entry.note")}
|
||||
<span className="text-muted-foreground ml-1 font-normal">
|
||||
({t("common.optional")})
|
||||
</span>
|
||||
</>
|
||||
@@ -194,7 +194,7 @@ export function EditSymptomDefinitionSheet({
|
||||
title={t("symptoms.section.editSymptomTitle")}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={update.isPending}>
|
||||
|
||||
@@ -347,8 +347,8 @@ export function SymptomEntryForm({
|
||||
htmlFor={`${formId}-episode`}
|
||||
label={
|
||||
<>
|
||||
{t("symptoms.entry.during")}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
{t("symptoms.entry.during")}
|
||||
<span className="text-muted-foreground ml-1 font-normal">
|
||||
({t("common.optional")})
|
||||
</span>
|
||||
</>
|
||||
@@ -376,8 +376,8 @@ export function SymptomEntryForm({
|
||||
htmlFor={`${formId}-note`}
|
||||
label={
|
||||
<>
|
||||
{t("symptoms.entry.note")}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
{t("symptoms.entry.note")}
|
||||
<span className="text-muted-foreground ml-1 font-normal">
|
||||
({t("common.optional")})
|
||||
</span>
|
||||
</>
|
||||
|
||||
@@ -46,4 +46,13 @@ describe("<EmptyState>", () => {
|
||||
expect(card).toContain("border-dashed");
|
||||
expect(plain).not.toContain("border-dashed");
|
||||
});
|
||||
|
||||
it("insets the card variant's content from its border, not the plain one", () => {
|
||||
const card = renderToStaticMarkup(<EmptyState title="leer" />);
|
||||
const plain = renderToStaticMarkup(
|
||||
<EmptyState title="leer" variant="plain" />,
|
||||
);
|
||||
expect(card).toMatch(/class="[^"]*\bpx-4\b/);
|
||||
expect(plain).not.toMatch(/\bpx-4\b/);
|
||||
});
|
||||
});
|
||||
@@ -105,7 +105,7 @@ vi.mock("@/components/ui/dialog", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
import { ResponsiveSheet } from "../responsive-sheet";
|
||||
import { keepKeyboardDownOnOpen, ResponsiveSheet } from "../responsive-sheet";
|
||||
|
||||
describe("<ResponsiveSheet>", () => {
|
||||
it("renders the Dialog branch on `md+` viewports", () => {
|
||||
@@ -324,3 +324,34 @@ describe("<ResponsiveSheet>", () => {
|
||||
expect(onOpenChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("keepKeyboardDownOnOpen (phone branch open focus)", () => {
|
||||
function setup(activeInside: boolean) {
|
||||
const focus = vi.fn();
|
||||
const preventDefault = vi.fn();
|
||||
const active = { id: "active" };
|
||||
const content = {
|
||||
focus,
|
||||
contains: (node: unknown) => activeInside && node === active,
|
||||
};
|
||||
vi.stubGlobal("document", { activeElement: active });
|
||||
keepKeyboardDownOnOpen({
|
||||
preventDefault,
|
||||
currentTarget: content,
|
||||
} as unknown as Event);
|
||||
vi.unstubAllGlobals();
|
||||
return { focus, preventDefault };
|
||||
}
|
||||
|
||||
it("focuses the sheet itself instead of its first field", () => {
|
||||
const { focus, preventDefault } = setup(false);
|
||||
expect(preventDefault).toHaveBeenCalledOnce();
|
||||
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
});
|
||||
|
||||
it("leaves a field the form focused on purpose alone", () => {
|
||||
const { focus, preventDefault } = setup(true);
|
||||
expect(preventDefault).toHaveBeenCalledOnce();
|
||||
expect(focus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -46,13 +46,13 @@ const buttonVariants = cva(
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2 has-[>svg]:px-3",
|
||||
default: "h-10 px-4 py-2 has-[>svg]:px-3 pointer-coarse:min-h-11",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5 pointer-coarse:min-h-11 pointer-coarse:min-w-11",
|
||||
lg: "h-11 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-10",
|
||||
icon: "size-10 pointer-coarse:min-h-11 pointer-coarse:min-w-11",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-sm": "size-8 pointer-coarse:min-h-11 pointer-coarse:min-w-11",
|
||||
"icon-lg": "size-11",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -64,6 +64,9 @@ function EmptyState({
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center text-center",
|
||||
size === "compact" ? "gap-2 py-4" : "gap-3 py-8",
|
||||
// The card variant paints a border, so its content insets from it:
|
||||
// a full-width phone CTA otherwise ran edge to edge into the frame.
|
||||
variant === "card" && "px-4",
|
||||
)}
|
||||
>
|
||||
{icon ? (
|
||||
|
||||
@@ -131,6 +131,23 @@ const CONTENT_WIDTH_CLASS: Record<
|
||||
* the form's chrome is unchanged because both branches expose the
|
||||
* same header / body / footer slot shape.
|
||||
*/
|
||||
/**
|
||||
* Phone branch only: on open, focus the sheet itself instead of its first
|
||||
* field. Radix's default lands on the first tabbable element, and when that
|
||||
* is a text-like field (a date, a name) the phone raises its keyboard over a
|
||||
* sheet the person has not read yet, and the sheet jumps up under it. Focus
|
||||
* still moves into the sheet (the content is focusable and traps focus), a
|
||||
* field a form focuses on purpose (`autoFocus`, a mount effect) keeps it, and
|
||||
* the desktop dialog keeps the default.
|
||||
*/
|
||||
export function keepKeyboardDownOnOpen(event: Event): void {
|
||||
event.preventDefault();
|
||||
const content = event.currentTarget as HTMLElement | null;
|
||||
if (content && !content.contains(document.activeElement)) {
|
||||
content.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function ResponsiveSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -153,6 +170,7 @@ export function ResponsiveSheet({
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
onCloseAutoFocus={onCloseAutoFocus}
|
||||
onOpenAutoFocus={keepKeyboardDownOnOpen}
|
||||
side="bottom"
|
||||
showCloseButton={showCloseButton}
|
||||
data-slot="responsive-sheet-content"
|
||||
|
||||
@@ -47,8 +47,10 @@ function Tabs({
|
||||
// Padding sits at `p-1` (4 px) so the 3 px focus-visible ring
|
||||
// (`focus-visible:ring-[3px]`) on triggers stays inside the
|
||||
// `overflow-y-hidden` clamp without clipping the bottom of the ring.
|
||||
// Under a coarse pointer the strip sizes to its triggers, which take the
|
||||
// 44 px touch floor; a mouse keeps the compact 36 px strip.
|
||||
const tabsListVariants = cva(
|
||||
"rounded-lg p-1 group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit max-w-full items-center justify-center overflow-x-auto overflow-y-hidden touch-pan-x scrollbar-thin group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col group-data-[orientation=vertical]/tabs:overflow-x-visible group-data-[orientation=vertical]/tabs:overflow-y-visible",
|
||||
"rounded-lg p-1 group-data-[orientation=horizontal]/tabs:h-9 pointer-coarse:group-data-[orientation=horizontal]/tabs:h-auto data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit max-w-full items-center justify-center overflow-x-auto overflow-y-hidden touch-pan-x scrollbar-thin group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col group-data-[orientation=vertical]/tabs:overflow-x-visible group-data-[orientation=vertical]/tabs:overflow-y-visible",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -86,7 +88,7 @@ function TabsTrigger({
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/75 hover:text-foreground dark:text-tab-inactive dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/75 hover:text-foreground dark:text-tab-inactive dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none pointer-coarse:min-h-11 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
|
||||
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
stripComments,
|
||||
walkSourceFiles,
|
||||
} from "@/__tests__/helpers/source-files";
|
||||
import { axisUnitSuffix } from "../axis-unit";
|
||||
import { axisTickUnitSuffix, axisUnitSuffix } from "../axis-unit";
|
||||
|
||||
const COMPONENTS = join(process.cwd(), "src", "components");
|
||||
|
||||
@@ -54,3 +54,15 @@ describe("axis unit suffix", () => {
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("axisTickUnitSuffix", () => {
|
||||
it("keeps a short unit on the ticks", () => {
|
||||
expect(axisTickUnitSuffix("bpm")).toBe(axisUnitSuffix("bpm"));
|
||||
expect(axisTickUnitSuffix("mg/dL")).toBe(axisUnitSuffix("mg/dL"));
|
||||
});
|
||||
it("keeps a word-length unit off the ticks, where it was clipped", () => {
|
||||
expect(axisTickUnitSuffix("Schritte")).toBeUndefined();
|
||||
expect(axisTickUnitSuffix("Atemzüge/min")).toBeUndefined();
|
||||
expect(axisTickUnitSuffix("mL/(kg·min)")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -13,3 +13,20 @@ export function axisUnitSuffix(
|
||||
): string | undefined {
|
||||
return unit ? ` ${unit}` : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The longest unit a tick label carries. A word-length unit ("Schritte",
|
||||
* "Atemzüge/min", "mL/(kg·min)") made every tick wider than the fixed y-axis
|
||||
* gutter, and the label was clipped at its left edge (".744 Schritte"). Such
|
||||
* a unit stays off the ticks; the stat strip and the tile above the chart
|
||||
* already name it.
|
||||
*/
|
||||
export const MAX_TICK_UNIT_LENGTH = 5;
|
||||
|
||||
/** The tick suffix for `unit`, or none when the unit is too long to fit. */
|
||||
export function axisTickUnitSuffix(
|
||||
unit: string | null | undefined,
|
||||
): string | undefined {
|
||||
if (!unit || unit.length > MAX_TICK_UNIT_LENGTH) return undefined;
|
||||
return axisUnitSuffix(unit);
|
||||
}
|
||||
+6
-1
@@ -27,6 +27,11 @@ export function mmolToMgdl(mmol: number): number {
|
||||
return Math.round(mmol * MGDL_PER_MMOL);
|
||||
}
|
||||
|
||||
/** Decimal places a glucose value is shown at: whole mg/dL, tenths of mmol/L. */
|
||||
export function glucoseFractionDigits(unit: GlucoseUnit): number {
|
||||
return unit === "mmol/L" ? 1 : 0;
|
||||
}
|
||||
|
||||
export function convertGlucose(value: number, to: GlucoseUnit): number {
|
||||
return to === "mmol/L" ? mgdlToMmol(value) : Math.round(value);
|
||||
}
|
||||
@@ -80,7 +85,7 @@ export function glucoseBandParams(
|
||||
unit: GlucoseUnit,
|
||||
formatNumber: (value: number, fractionDigits: number) => string,
|
||||
): Record<string, string> {
|
||||
const digits = unit === "mmol/L" ? 1 : 0;
|
||||
const digits = glucoseFractionDigits(unit);
|
||||
const v = (point: GlucoseCutPoint) =>
|
||||
formatNumber(unit === "mmol/L" ? point.mmol : point.mgdl, digits);
|
||||
return {
|
||||
|
||||
@@ -110,4 +110,16 @@ describe("getIntegrationCallbackUrls", () => {
|
||||
expect(urls.withings).toBe("http://health.example/api/withings/callback");
|
||||
expect(urls.whoop).toBe("http://health.example/api/whoop/callback");
|
||||
});
|
||||
|
||||
it("reports null rather than 'undefined/api/…' when the app URL is unset", () => {
|
||||
// A resolver that splices the unset variable into its template would
|
||||
// otherwise hand the card "undefined/api/polar/callback" to show.
|
||||
const urls = getIntegrationCallbackUrls();
|
||||
for (const provider of INTEGRATION_CALLBACK_PROVIDERS) {
|
||||
expect(urls[provider] ?? "", provider).not.toMatch(/^undefined/);
|
||||
if (urls[provider] !== null) {
|
||||
expect(urls[provider], provider).toMatch(/^https?:\/\//);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -59,7 +59,12 @@ const RESOLVERS: Record<IntegrationCallbackProvider, () => string> = {
|
||||
|
||||
function resolve(provider: IntegrationCallbackProvider): string | null {
|
||||
try {
|
||||
return RESOLVERS[provider]();
|
||||
const url = RESOLVERS[provider]();
|
||||
// Some resolvers splice an unset NEXT_PUBLIC_APP_URL into the template
|
||||
// and hand back "undefined/api/…/callback". That is not a URL anyone can
|
||||
// paste into a vendor form; report it as not configured so the card
|
||||
// shows the path instead.
|
||||
return /^https?:\/\//i.test(url) ? url : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user