Merge the app-wide UI audit into release/v1.42.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-08 18:03:26 +02:00
commit d0e2238b65
85 files changed
+1457 -616

No files matched your search

+36
View File
@@ -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
View File
@@ -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 &lt;Name&gt; — 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.",
+5 -5
View File
@@ -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>{" "}
+40 -18
View File
@@ -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>
+5 -8
View File
@@ -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 && (
+3 -1
View File
@@ -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
+12
View File
@@ -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
+6 -2
View File
@@ -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={[
{
+1 -1
View File
@@ -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>
);
}
+4 -2
View File
@@ -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" />
+3 -1
View File
@@ -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>
);
}
+6 -1
View File
@@ -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")}
+6 -1
View File
@@ -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}
+1 -1
View File
@@ -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
+2 -3
View File
@@ -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 />
);
}
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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&apos;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&apos;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&apos;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"/,
);
});
});
+11
View File
@@ -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");
});
});
+2 -2
View File
@@ -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
}
/>
+12 -3
View File
@@ -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" },
+191 -179
View File
@@ -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 />
+8 -1
View File
@@ -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}
+1 -1
View File
@@ -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}>
+9 -7
View File
@@ -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>
);
}
+39 -34
View File
@@ -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;
}
+7 -1
View File
@@ -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>
+1 -1
View File
@@ -144,7 +144,7 @@ export function BiomarkerForm({
{onCancel ? (
<Button
type="button"
variant="ghost"
variant="outline"
onClick={onCancel}
disabled={submitting}
>
+1 -1
View File
@@ -348,7 +348,7 @@ export function LabForm({
{onCancel ? (
<Button
type="button"
variant="ghost"
variant="outline"
onClick={onCancel}
disabled={submitting}
>
+12 -9
View File
@@ -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 (
<>
+135 -22
View File
@@ -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",
)}
+1 -1
View File
@@ -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>
+5 -4
View File
@@ -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;
+1 -1
View File
@@ -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}
>
+4 -2
View File
@@ -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">
+36 -30
View File
@@ -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();
});
});
+4 -4
View File
@@ -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",
},
},
+3
View File
@@ -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 ? (
+18
View File
@@ -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"
+4 -2
View File
@@ -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",
+13 -1
View File
@@ -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();
});
});
+17
View File
@@ -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
View File
@@ -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?:\/\//);
}
}
});
});
+6 -1
View File
@@ -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;
}