diff --git a/e2e/sidebar-expand-no-scrollbar.spec.ts b/e2e/sidebar-expand-no-scrollbar.spec.ts index 71b8d73e8..cb136e1df 100644 --- a/e2e/sidebar-expand-no-scrollbar.spec.ts +++ b/e2e/sidebar-expand-no-scrollbar.spec.ts @@ -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); + } }); }); diff --git a/messages/de.json b/messages/de.json index a65aa5e32..ee6731cd2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": "Welches Medikament eingenommen?", "cancelButton": "Abbrechen", - "askMedicationName": "Bitte Medikament angeben, z.B. genommen Ramipril.", + "askMedicationName": "Bitte Medikament angeben, z. B. genommen Ramipril.", "medicationNotFoundExact": "Medikament nicht gefunden. Bitte Namen exakt wie in HealthLog senden.", "helpHeader": "Verfügbare Befehle:", "helpBody": "/help — Diese Hilfe anzeigen\n/add — Einnahme erfassen\n/start — Bot starten\n\nTextbefehle:\ngenommen <Name> — Einnahme bestätigen\n\nÜber die Buttons in Erinnerungen:\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.", diff --git a/messages/en.json b/messages/en.json index 941d5f2b7..06a2e9b36 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", diff --git a/messages/es.json b/messages/es.json index 1178466e9..ddf55c374 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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.", diff --git a/messages/fr.json b/messages/fr.json index ee8fa291a..c1ff33246 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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.", diff --git a/messages/it.json b/messages/it.json index 6f6c924f0..d6cd9c4f4 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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.", diff --git a/messages/ko.json b/messages/ko.json index 381dd916d..56aec5aa9 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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": "증상을 선택하세요.", diff --git a/messages/pl.json b/messages/pl.json index dc1e0bfcd..f0a04ad32 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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.", diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index 45d3e0e4e..9d039b5e6 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -60,7 +60,7 @@ function Section({

{title}

-
+
{children}
@@ -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 {" "} @@ -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 @@ -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 @@ -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 {" "} diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx index 57d1d4f8a..6c0fe99b0 100644 --- a/src/app/achievements/page.tsx +++ b/src/app/achievements/page.tsx @@ -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 = { function formatMetric( format: AchievementProgress["format"], value: number, - t: (key: string, params?: Record) => string, + i18n: Pick, "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["t"]; + tCount: ReturnType["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" } >
@@ -191,7 +194,7 @@ function AchievementCard({ achievement, t }: AchievementCardProps) {

{t(achievement.titleKey)}

-

+

{t(achievement.descriptionKey)}

@@ -215,8 +218,16 @@ function AchievementCard({ achievement, t }: AchievementCardProps) {

{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, + ), })}

{/* 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, + ), })} />
@@ -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")}

- {summary?.earnedPoints ?? 0} + {fmt.integer(summary?.earnedPoints ?? 0)} - / {summary?.totalPoints ?? 0} + / {fmt.integer(summary?.totalPoints ?? 0)}

{t(summary.nextAchievement.titleKey)}

-

+

{t(summary.nextAchievement.descriptionKey)}

@@ -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}%)

@@ -449,6 +470,7 @@ export default function AchievementsPage() { key={achievement.id} achievement={achievement} t={t} + tCount={tCount} /> ))}
diff --git a/src/app/auth/register/page.tsx b/src/app/auth/register/page.tsx index 8b1bc9c34..e8cb2498f 100644 --- a/src/app/auth/register/page.tsx +++ b/src/app/auth/register/page.tsx @@ -175,14 +175,11 @@ export default function RegisterPage() {
-
-

- {t("auth.createAccount")} -

-

- {t("auth.newAccount")} -

-
+ {/* The title says it; a subtitle repeating "new account" said it + twice. */} +

+ {t("auth.createAccount")} +

{inviteToken && ( diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx index bda8faa42..32f0e0ce8 100644 --- a/src/app/coach/conversations/page.tsx +++ b/src/app/coach/conversations/page.tsx @@ -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", )} > } /> - + ); } diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx index 74101bde9..081748ee1 100644 --- a/src/app/insights/pulse/page.tsx +++ b/src/app/insights/pulse/page.tsx @@ -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={ } > - - + // The shell owns the page gutter; a second one here doubled it. + ); } diff --git a/src/app/mood/page-client.tsx b/src/app/mood/page-client.tsx index f86962bc5..05e41c7f3 100644 --- a/src/app/mood/page-client.tsx +++ b/src/app/mood/page-client.tsx @@ -126,7 +126,7 @@ export default function MoodPageClient() { } >
@@ -94,7 +94,7 @@ function Section({
{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 @@ -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 {" "} @@ -386,13 +386,14 @@ export default function PrivacyPage() { bodyEn={ <>

- 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{" "} github.com/MBombeck/HealthLog {" "} @@ -406,7 +407,7 @@ export default function PrivacyPage() {

The controller under Art. 4 (7) GDPR is the operator of this - instance (an individual based in Germany). Self- hosted + instance (an individual based in Germany). Self-hosted deployments controlled by a different operator are governed by that operator's own privacy policy; this document applies only to this instance. @@ -454,7 +455,7 @@ export default function PrivacyPage() {

  • 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.
  • @@ -462,7 +463,7 @@ export default function PrivacyPage() { 2.2 Manuell erfasste Gesundheitsdaten

    - 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() {

    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.

    @@ -598,25 +599,24 @@ export default function PrivacyPage() {

    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.

    2.5 Medikamente

    • - 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).
    • Einnahme-Ereignisse: geplante Zeit, tatsächliche Zeit, Status @@ -716,8 +716,8 @@ export default function PrivacyPage() { X-Device-Id {" "} - -Header — für Mehrgeräte-Synchronisation und Miss- - brauchsschutz. + -Header — für Mehrgeräte-Synchronisation und + Missbrauchsschutz.
    • Apple-Push-Notification-Service-(APNs)-Geräte-Token und @@ -759,8 +759,8 @@ export default function PrivacyPage() {
      • - Keine Werbe-Identifier, Fingerprints oder anwendungs- - übergreifenden Tracker. + Keine Werbe-Identifier, Fingerprints oder + anwendungsübergreifenden Tracker.
      • Keine Zahlungsdaten (es gibt keinen Bezahltarif).
      • @@ -938,11 +938,11 @@ export default function PrivacyPage() {

      - Write access is requested for a subset (body mass, blood- - pressure systolic / diastolic, blood glucose) so that manual - entries made inside the iOS app can flow back into HealthKit. - The user controls both directions in the iOS Health app's - permission surface and may revoke at any time. + Write access is requested for a subset (body mass, + blood-pressure systolic / diastolic, blood glucose) so that + manual entries made inside the iOS app can flow back into + HealthKit. The user controls both directions in the iOS Health + app's permission surface and may revoke at any time.

      2.4 Withings sync (optional) @@ -1057,8 +1057,8 @@ export default function PrivacyPage() {

      • - 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{" "} X-Device-Id {" "} @@ -1135,16 +1135,15 @@ export default function PrivacyPage() { Authentifizierungsdaten {" "} — 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).
      • Gesundheitsdaten {" "} — 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).
      • @@ -1420,8 +1419,8 @@ export default function PrivacyPage() { Kein HealthLog-iCloud-Speicher {" "} - — 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() { Widerspruch und automatisierte Entscheidungen (Art. 21–22) {" "} - — 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.
      • @@ -1959,7 +1958,7 @@ export default function PrivacyPage() { {" "} mbombeck@gmail.com @@ -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 {" "} @@ -2008,7 +2007,7 @@ export default function PrivacyPage() { {" "} mbombeck@gmail.com @@ -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 {" "} diff --git a/src/components/admin/login-overview-section.tsx b/src/components/admin/login-overview-section.tsx index d601f396d..819798e2c 100644 --- a/src/components/admin/login-overview-section.tsx +++ b/src/components/admin/login-overview-section.tsx @@ -328,7 +328,10 @@ export function LoginOverviewSection() { {/* Toolbar row: per-page + export */}
        - + {t("admin.section.auditLog.perPage")} setType(v as IllnessType)}> - + @@ -228,12 +228,14 @@ export function NewEpisodeSheet({ />
        - + - + diff --git a/src/components/insights/__tests__/insights-tab-strip.test.tsx b/src/components/insights/__tests__/insights-tab-strip.test.tsx index 317c3d942..fed757976 100644 --- a/src/components/insights/__tests__/insights-tab-strip.test.tsx +++ b/src/components/insights/__tests__/insights-tab-strip.test.tsx @@ -618,3 +618,23 @@ describe(" — pill row clip box (v1.16.8)", () => { expect(scroller).toContain("-mx-1"); }); }); + +describe(" — 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(); + for (const slot of [ + "insights-tab-strip-scroll-left", + "insights-tab-strip-scroll-right", + ]) { + const tag = html.match( + new RegExp(`]*data-slot="${slot}"[^>]*>`), + ); + expect(tag?.[0]).toBeDefined(); + expect(tag?.[0]).toContain("invisible"); + expect(tag?.[0]).toContain('tabindex="-1"'); + } + }); +}); diff --git a/src/components/insights/__tests__/metric-target-source-name.test.ts b/src/components/insights/__tests__/metric-target-source-name.test.ts new file mode 100644 index 000000000..e8077ac9c --- /dev/null +++ b/src/components/insights/__tests__/metric-target-source-name.test.ts @@ -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"); + }); +}); diff --git a/src/components/insights/__tests__/sleep-overview-loading.test.tsx b/src/components/insights/__tests__/sleep-overview-loading.test.tsx new file mode 100644 index 000000000..0e6164dd7 --- /dev/null +++ b/src/components/insights/__tests__/sleep-overview-loading.test.tsx @@ -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: () =>
        , +})); + +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( + + + , + ); + expect(html).not.toContain(en.insights.sleep.stages.unavailable); + expect(html).toContain('data-slot="duration-chart"'); + }); +}); diff --git a/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx index 0abdb5b3c..6ea7b026f 100644 --- a/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx +++ b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx @@ -88,6 +88,9 @@ describe("", () => { const html = render(, "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", () => { diff --git a/src/components/insights/derived/__tests__/coach-read-strip-placeholder.test.tsx b/src/components/insights/derived/__tests__/coach-read-strip-placeholder.test.tsx new file mode 100644 index 000000000..ab5660e09 --- /dev/null +++ b/src/components/insights/derived/__tests__/coach-read-strip-placeholder.test.tsx @@ -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( + + + + + , + ); + expect(markup).toContain('data-slot="coach-read-strip"'); + expect(markup).toContain('data-state="loading"'); + expect(markup).toContain('aria-busy="true"'); + }); +}); diff --git a/src/components/insights/derived/coach-read-strip.tsx b/src/components/insights/derived/coach-read-strip.tsx index 9fc2688e6..844ba9528 100644 --- a/src/components/insights/derived/coach-read-strip.tsx +++ b/src/components/insights/derived/coach-read-strip.tsx @@ -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( @@ -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 ( + + + + + + + + + ); + } const fmt = (value: number): string => new Intl.NumberFormat(locale, { diff --git a/src/components/insights/derived/delta-sparkline.tsx b/src/components/insights/derived/delta-sparkline.tsx index 9736cbd67..2e7462786 100644 --- a/src/components/insights/derived/delta-sparkline.tsx +++ b/src/components/insights/derived/delta-sparkline.tsx @@ -62,7 +62,14 @@ export function DeltaSparkline({ const hasComparison = data.some((point) => point.b !== undefined); return ( - + {/* 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. */} + diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index 60c6a2109..9e023d7eb 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -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={} + statStrip={} > @@ -538,32 +539,3 @@ export function HealthKitMetricPage({ ); } - -/** - * v1.12.7 — layout-stable loading shell for the stat strip slot. Mirrors - * the loaded `` 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 ( - - ); -} diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index 9bbcf005c..3e8c9f920 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -822,23 +822,27 @@ function InsightsTabStripImpl({ )} >
        - {pillsOverflow && ( - - )} + {/* 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. */} +
        - {pillsOverflow && ( - - )} + {/* 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 diff --git a/src/components/insights/metric-stat-strip.tsx b/src/components/insights/metric-stat-strip.tsx index 229b8d7a0..d9682e215 100644 --- a/src/components/insights/metric-stat-strip.tsx +++ b/src/components/insights/metric-stat-strip.tsx @@ -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 & { * 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 ; + } + // 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({ ); } + +/** + * 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 ( + + ); +} diff --git a/src/components/insights/metric-target-summary.tsx b/src/components/insights/metric-target-summary.tsx index 845b1b8d6..513e2bfdd 100644 --- a/src/components/insights/metric-target-summary.tsx +++ b/src/components/insights/metric-target-summary.tsx @@ -421,12 +421,18 @@ function TargetReferencePanel({ rel="noopener noreferrer" className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs" > - {t("targets.sourceLabel", { source: target.source })} + + {t("targets.sourceLabel", { + source: sourceName(target.source, t), + })} +
        @@ -434,3 +440,19 @@ function TargetReferencePanel({ ); } + +/** + * 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 = { + "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; +} diff --git a/src/components/insights/sleep-overview.tsx b/src/components/insights/sleep-overview.tsx index 58117e271..dadd2e58c 100644 --- a/src/components/insights/sleep-overview.tsx +++ b/src/components/insights/sleep-overview.tsx @@ -122,7 +122,13 @@ export function SleepOverview() { return (
        - {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 ? ( + + ) : sleepStages ? ( ) : ( diff --git a/src/components/insights/sleep-stage-stacked-bar.tsx b/src/components/insights/sleep-stage-stacked-bar.tsx index ccf2e94c0..7bb21701d 100644 --- a/src/components/insights/sleep-stage-stacked-bar.tsx +++ b/src/components/insights/sleep-stage-stacked-bar.tsx @@ -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 })} ))}
        diff --git a/src/components/labs/biomarker-form.tsx b/src/components/labs/biomarker-form.tsx index 51503c242..036e04642 100644 --- a/src/components/labs/biomarker-form.tsx +++ b/src/components/labs/biomarker-form.tsx @@ -144,7 +144,7 @@ export function BiomarkerForm({ {onCancel ? ( ) : null; diff --git a/src/components/records/allergy-form.tsx b/src/components/records/allergy-form.tsx index 77544005b..296b102d8 100644 --- a/src/components/records/allergy-form.tsx +++ b/src/components/records/allergy-form.tsx @@ -128,7 +128,7 @@ export function AllergyForm({ {onCancel ? (
        ); } diff --git a/src/components/settings/access/grant-invite-card.tsx b/src/components/settings/access/grant-invite-card.tsx index d9202302c..f563440ae 100644 --- a/src/components/settings/access/grant-invite-card.tsx +++ b/src/components/settings/access/grant-invite-card.tsx @@ -164,14 +164,7 @@ export function GrantInviteCard() {
        void submit(event)} className="space-y-3">
        @@ -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. */}

        - {t("recordSharing.invite.identifierHint")} + {t("recordSharing.invite.needsAccount")}

        diff --git a/src/components/settings/environment-section.tsx b/src/components/settings/environment-section.tsx index e607b1542..69c27566e 100644 --- a/src/components/settings/environment-section.tsx +++ b/src/components/settings/environment-section.tsx @@ -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 && (

        {t("settings.sections.environment.home.since", { - date: home.since.slice(0, 10), + date: fmt.dateShortSmartCalendar(home.since.slice(0, 10)), })}

        )} @@ -393,7 +395,8 @@ export function EnvironmentSection() { className="flex items-center justify-between gap-2 p-2" > - {tr.label} · {tr.startDate} – {tr.endDate} + {tr.label} · {fmt.dateShortSmartCalendar(tr.startDate)} –{" "} + {fmt.dateShortSmartCalendar(tr.endDate)} {t("settings.sections.environment.backfill.description")}

        -
        +
        +
        + {data && ( +

        + {t("settings.sections.environment.storedDays", { + count: data.context.days, + })} +

        + )} + {data?.lastFetchFailure && ( +

        + {tCount( + "settings.sections.environment.fetchFailed", + data.lastFetchFailure.failures, + { + count: data.lastFetchFailure.failures, + when: formatDateTime(data.lastFetchFailure.lastFailedAt), + }, + )} +

        + )} + {/* The action row closes the card; the status lines sit above it. */} + -
        - {data && ( -

        - {t("settings.sections.environment.storedDays", { - count: data.context.days, - })} -

        - )} - {data?.lastFetchFailure && ( -

        - {tCount( - "settings.sections.environment.fetchFailed", - data.lastFetchFailure.failures, - { - count: data.lastFetchFailure.failures, - when: formatDateTime(data.lastFetchFailure.lastFailedAt), - }, - )} -

        - )} + {/* Air quality, pollen and UV (v1.42) */} @@ -636,7 +642,7 @@ export function EnvironmentSection() {

        {t("settings.sections.environment.airQuality.latest", { - date: data.latestDay.date, + date: fmt.dateShortSmartCalendar(data.latestDay.date), })}

        @@ -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. */}
          {( diff --git a/src/components/symptoms/symptom-edit-sheets.tsx b/src/components/symptoms/symptom-edit-sheets.tsx index 3a1ec5076..9a542c6f4 100644 --- a/src/components/symptoms/symptom-edit-sheets.tsx +++ b/src/components/symptoms/symptom-edit-sheets.tsx @@ -85,7 +85,7 @@ export function EditSymptomEventSheet({ title={t("symptoms.section.editEventTitle", { symptom: label })} footer={ <> -