diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b66a81c6c..e9dfaa516 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -83586,6 +83586,24 @@ components: - weight additionalProperties: false - type: "null" + device: + description: STRAIN only — present when no computed strain score exists in the window and the score is served from the + device's own DAY_STRAIN instead. `score` is then `value` as a 0–100 share of `scaleMax`. Absent when the + computed proxy is the source. + anyOf: + - type: object + properties: + value: + type: number + description: The device's latest day strain on its own scale. + scaleMax: + type: number + description: Top of the device scale (21 for WHOOP day strain). + required: + - value + - scaleMax + additionalProperties: false + - type: "null" required: - score - band diff --git a/e2e/delegated-writes.spec.ts b/e2e/delegated-writes.spec.ts index 68150fffc..5c7cfa565 100644 --- a/e2e/delegated-writes.spec.ts +++ b/e2e/delegated-writes.spec.ts @@ -221,7 +221,14 @@ test.describe("delegated writes", () => { // reads "Add") and then for a `value` input (the form opens on blood // pressure, which has three). Both were wrong from the day they were // written and nobody found out, because the whole file was skipping. - await page.locator('[data-slot="measurement-add"]').click(); + // The owner's list may be empty, in which case its empty state carries + // the add action and the header drops its own. + await page + .locator( + '[data-slot="measurement-add"], [data-slot="measurement-add-first"]', + ) + .first() + .click(); await page.locator("#sys").fill("124"); await page.locator("#dia").fill("78"); await page.getByRole("button", { name: /^save$/i }).click(); diff --git a/e2e/dialog-horizontal-overflow.spec.ts b/e2e/dialog-horizontal-overflow.spec.ts index 54267f858..95302437e 100644 --- a/e2e/dialog-horizontal-overflow.spec.ts +++ b/e2e/dialog-horizontal-overflow.spec.ts @@ -70,13 +70,15 @@ const DIALOGS: DialogCase[] = [ { name: "measurements add", path: "/measurements", - trigger: /^add$/i, + // While a list is empty the header drops its add button and the empty + // state carries the action, so either one opens the dialog. + trigger: /^(add|add your first measurement)$/i, ready: '[data-slot="date-time-field"]', }, { name: "mood add", path: "/mood", - trigger: /^add$/i, + trigger: /^(add|log your first mood)$/i, // The mood sheet opens on the five-face quick check; the timestamp row // (and everything else) appears once a face is picked. Measuring the // quick check alone would measure a sheet that has almost nothing in it. @@ -92,7 +94,7 @@ const DIALOGS: DialogCase[] = [ { name: "labs add", path: "/labs", - trigger: /^add$/i, + trigger: /^(add|add your first result)$/i, ready: '[data-slot="date-time-field"]', }, // The labs sheet's footer holds three buttons (Cancel, Save & add another, @@ -101,7 +103,7 @@ const DIALOGS: DialogCase[] = [ { name: "labs add (German)", path: "/labs", - trigger: /^hinzufügen$/i, + trigger: /^(hinzufügen|ersten wert erfassen)$/i, ready: '[data-slot="date-time-field"]', locale: "de", }, diff --git a/e2e/measurement-flow.spec.ts b/e2e/measurement-flow.spec.ts index 28b48c145..62e4d2137 100644 --- a/e2e/measurement-flow.spec.ts +++ b/e2e/measurement-flow.spec.ts @@ -135,7 +135,13 @@ test.describe("add measurement flow", () => { // v1.5 phase-5: the menu items now have distinct labels — the // measurement entry says "Measurement" / "Messung" instead of "Add", // so we can target it directly. - await page.getByRole("menuitem", { name: /measurement|messung/i }).click(); + // The desktop rail opens a dropdown, the phone shell the capture sheet + // the bottom nav's plus opens; the measurement entry is in either. + await page + .getByRole("menuitem", { name: /measurement|messung/i }) + .or(page.getByRole("button", { name: /^(measurement|messung)\b/i })) + .first() + .click(); // Form should be visible inside the dialog. Switch type to WEIGHT // (combo-box defaults to BLOOD_PRESSURE). diff --git a/e2e/mood-timestamp.spec.ts b/e2e/mood-timestamp.spec.ts index 28daa3df8..f157aa674 100644 --- a/e2e/mood-timestamp.spec.ts +++ b/e2e/mood-timestamp.spec.ts @@ -32,7 +32,9 @@ test.describe("mood timestamp capture", () => { }); await page.goto("/mood", { waitUntil: "domcontentloaded" }); - await page.getByRole("button", { name: "Add", exact: true }).click(); + // The list is mocked empty, so its empty state carries the add action + // (the header drops its own while there is nothing to list). + await page.getByRole("button", { name: "Log your first mood" }).click(); await page.getByRole("radio", { name: "Amazing" }).click(); const details = page.getByRole("button", { name: "Note & details" }); diff --git a/e2e/onboarding-tour-passthrough.spec.ts b/e2e/onboarding-tour-passthrough.spec.ts index 003bd5e01..433797e8b 100644 --- a/e2e/onboarding-tour-passthrough.spec.ts +++ b/e2e/onboarding-tour-passthrough.spec.ts @@ -141,7 +141,10 @@ test.describe("v1.4.33 F2 — onboarding tour passes clicks through to the page" // so a naive "dim everything outside the spotlight rect" overlay // covers it. The fix makes the entire tour layer pointer-events: // none — every dim panel and the spotlight ring are visual only. - const quickAddButton = page.locator('[data-tour-id="dashboard-quick-add"]'); + // One add button per shell carries the tour id; the other is hidden. + const quickAddButton = page + .locator('[data-tour-id="dashboard-quick-add"]') + .filter({ visible: true }); await expect(quickAddButton).toBeVisible(); // Forensic guard — at the quick-add button's centre, every element @@ -153,9 +156,11 @@ test.describe("v1.4.33 F2 — onboarding tour passes clicks through to the page" // the stack is blocking, instead of just observing the symptom // ("button click did not toggle aria-expanded"). const tourBlockers = await page.evaluate(() => { - const btn = document.querySelector( - '[data-tour-id="dashboard-quick-add"]', - ); + const btn = [ + ...document.querySelectorAll( + '[data-tour-id="dashboard-quick-add"]', + ), + ].find((el) => el.getClientRects().length > 0); if (!btn) return ["no-button"]; const rect = btn.getBoundingClientRect(); const x = rect.left + rect.width / 2; @@ -192,7 +197,14 @@ test.describe("v1.4.33 F2 — onboarding tour passes clicks through to the page" await quickAddButton.click(); await expect(quickAddButton).toHaveAttribute("aria-expanded", "true"); - await expect(page.getByRole("menuitem").first()).toBeVisible(); + // The desktop rail opens a dropdown; the phone shell opens the capture + // sheet the bottom nav's plus opens. Either proves the click landed. + await expect( + page + .getByRole("menuitem") + .or(page.locator('[role="dialog"][data-state="open"]')) + .first(), + ).toBeVisible(); // The tour stays mounted — the click went to the underlying button, // not anything in the tour layer. diff --git a/e2e/vaccinations.spec.ts b/e2e/vaccinations.spec.ts index fbf2f2199..40b43f27c 100644 --- a/e2e/vaccinations.spec.ts +++ b/e2e/vaccinations.spec.ts @@ -84,11 +84,19 @@ async function captureDose( booster: "confirm" | "decline" | "none"; }, ): Promise { - // The header Add renders whether or not the list is empty, so clicking it - // works in both states. click() auto-waits for the button to be actionable, - // which avoids racing an instant visibility read against the client render - // (an empty-state-only branch times out once a record already exists). - await page.locator('[data-slot="vaccination-add"]').first().click(); + // While the Impfpass is empty its empty state carries the add action and + // the header drops its own, so wait for the list to settle (a record or + // the empty state) and click whichever add control is then on screen. + await expect( + page.locator('[data-vaccination-id], [data-slot="empty-state"]').first(), + ).toBeVisible(); + await page + .locator( + '[data-slot="vaccination-add"], [data-slot="vaccination-add-empty"]', + ) + .filter({ visible: true }) + .first() + .click(); await expect(page.locator('[data-slot="vaccination-form"]')).toBeVisible(); @@ -321,7 +329,12 @@ test.describe("vaccinations", () => { ).toBeVisible({ timeout: 15_000 }); await expectNoSeriousAxe(page); - await page.locator('[data-slot="vaccination-add"]').first().click(); + await page + .locator( + '[data-slot="vaccination-add"], [data-slot="vaccination-add-empty"]', + ) + .last() + .click(); await expect(page.locator('[data-slot="vaccination-form"]')).toBeVisible(); await expectNoSeriousAxe(page); }); diff --git a/messages/de.json b/messages/de.json index ee6731cd2..3b6b8a461 100644 --- a/messages/de.json +++ b/messages/de.json @@ -49,7 +49,9 @@ "dismiss": "Schließen", "moreInfo": "Mehr Informationen", "durationHoursMinutes": "{hours} Std. {minutes} Min.", - "durationMinutes": "{minutes} Min." + "durationMinutes": "{minutes} Min.", + "durationClockMinutes": "{time} Min.", + "durationClockHours": "{time} Std." }, "apiErrors": { "measurement": { @@ -540,6 +542,11 @@ "errorRateLimit": "Zu viele Versuche. Warte ein paar Minuten und versuche es erneut.", "errorFailed": "Das hat nicht geklappt. Versuche es gleich noch einmal.", "errorOffline": "Das hat den Server nicht erreicht. Prüfe deine Verbindung und versuche es erneut." + }, + "inviteInvalid": { + "title": "Einladung ungültig", + "description": "Dieser Einladungslink ist unvollständig oder falsch kopiert. Bitte die Person, die dich eingeladen hat, um einen neuen Link.", + "toLogin": "Zur Anmeldung" } }, "dashboard": { @@ -2031,7 +2038,6 @@ "bestand": "Bestand", "verlauf": "Verlauf", "injektion": "Injektion", - "api": "API", "erweitert": "Erweitert", "wirkung": "Wirkung" }, @@ -2503,7 +2509,8 @@ "moodHeatmapEmpty": "Stimmungs-Heatmap, noch keine Tage erfasst.", "complianceHeatmap": "Heatmap zur Medikamenteneinnahme von {start} bis {end}, {days} aktive Tage.", "complianceHeatmapEmpty": "Heatmap zur Medikamenteneinnahme, noch keine aktiven Tage." - } + }, + "medicationsEmptyAction": "Medikament anlegen" }, "chart": { "overlay": { @@ -4340,7 +4347,9 @@ "method": "Ein nächtlicher 0–100-Belastungs-Proxy aus deiner herzfrequenzgewichteten Aktivitätslast (ein TRIMP-ähnliches Trainingsimpuls-Modell). Höher heißt ein anstrengenderer Tag.", "anchorPersonal": "An deine eigene jüngste Trainingstagslast angelehnt — bewertet relativ zu deiner typischen Anstrengung, sodass ein für dich wirklich harter Tag hoch ausfällt.", "anchorPopulation": "Gegen einen allgemeinen Referenzwert bewertet, während du noch Trainingsverlauf aufbaust; sobald genug vorhanden ist, wechselt die Bewertung auf deine eigene typische Anstrengung.", - "description": "Ein nächtlicher Aktivitätslast-Proxy aus deiner herzfrequenzgewichteten Anstrengung, relativ zu deiner typischen Anstrengung." + "description": "Ein nächtlicher Aktivitätslast-Proxy aus deiner herzfrequenzgewichteten Anstrengung, relativ zu deiner typischen Anstrengung.", + "deviceCaption": "Tagesbelastung deines Geräts: {value} von {max}", + "deviceSource": "Bis HealthLog nachts einen eigenen Wert berechnet hat, zeigt die Karte die Tagesbelastung deines Geräts, umgerechnet auf 0–100." }, "WRIST_TEMPERATURE_BASELINE": { "method": "Deine typische nächtliche Handgelenkstemperatur ist der Median deiner nächtlichen Messwerte im Zeitraum, ± einer robusten Streuung (skalierte mittlere absolute Abweichung). Sie folgt DEINEN Werten — letzte Nacht wird als Abweichung von deiner eigenen Baseline gezeigt, nicht von einer Bevölkerungsnorm.", @@ -4717,8 +4726,8 @@ "emptyCta": "Gerät verbinden" }, "intradayPulse": { - "title": "Der Verlauf deines Tages", - "caption": "Dein Puls über den Tag, gemittelt in 10-Minuten-Schritten.", + "title": "Puls über den Tag", + "caption": "Gemittelt in 10-Minuten-Schritten.", "hourlyNote": "Stündlicher Durchschnitt für diesen Tag — aktuelle Tage zeigen das volle 10-Minuten-Detail.", "empty": "Für diesen Tag noch nicht genug Pulswerte, um den Tagesverlauf zu zeigen.", "coverageOne": "Basierend auf 1 Messung über {hours}h.", @@ -6007,9 +6016,9 @@ "botToken": "Bot-Token", "chatId": "Chat-ID", "enableNotifications": "Benachrichtigungen aktivieren", - "telegramStep1": "1. Erstelle einen Bot über @BotFather in Telegram und kopiere den Token.", - "telegramStep2": "2. Sende /start an deinen Bot, um den Chat zu aktivieren.", - "telegramStep3": "3. Finde deine Chat-ID über @userinfobot oder die Bot-API.", + "telegramStep1": "Erstelle einen Bot über @BotFather in Telegram und kopiere den Token.", + "telegramStep2": "Sende /start an deinen Bot, um den Chat zu aktivieren.", + "telegramStep3": "Finde deine Chat-ID über @userinfobot oder die Bot-API.", "ntfy": "ntfy", "ntfyDescription": "Benachrichtigungen über ntfy (self-hosted oder ntfy.sh).", "ntfyEnable": "ntfy aktivieren", @@ -6367,8 +6376,8 @@ "whoop": "WHOOP", "whoopDescription": "Verbinde dein WHOOP-Band, um Erholung, Schlaf, Belastung und Workouts direkt in HealthLog zu synchronisieren.", "whoopCredentials": "API-Zugangsdaten", - "whoopClientId": "Client ID", - "whoopClientSecret": "Client Secret", + "whoopClientId": "Client-ID", + "whoopClientSecret": "Client-Secret", "whoopCredentialsSaved": "Zugangsdaten gespeichert", "whoopCredentialsSavedPlaceholder": "Gespeichert — neu eingeben zum Ersetzen", "whoopCredentialsSavedPlaceholderSecret": "Gespeichert — neu eingeben zum Ersetzen", @@ -6780,8 +6789,8 @@ "polarDescription": "Verbinde dein Polar-Konto, um Nightly-Recharge-Erholung, Schlafphasen, tägliche Aktivität und Trainingseinheiten zu synchronisieren.", "polarViewData": "Schlaf & Erholung ansehen", "polarCredentials": "API-Zugangsdaten", - "polarClientId": "Client ID", - "polarClientSecret": "Client Secret", + "polarClientId": "Client-ID", + "polarClientSecret": "Client-Secret", "polarCredentialsSaved": "Zugangsdaten gespeichert", "polarCredentialsSavedPlaceholder": "Gespeichert — neu eingeben zum Ersetzen", "polarSaveCredentials": "Speichern", @@ -6809,8 +6818,8 @@ "ouraDescription": "Verbinde dein Oura-Konto, um Readiness, Schlafphasen, Herzfrequenzvariabilität und tägliche Aktivität zu synchronisieren.", "ouraViewData": "Schlaf & Erholung ansehen", "ouraCredentials": "API-Zugangsdaten", - "ouraClientId": "Client ID", - "ouraClientSecret": "Client Secret", + "ouraClientId": "Client-ID", + "ouraClientSecret": "Client-Secret", "ouraCredentialsSaved": "Zugangsdaten gespeichert", "ouraCredentialsSavedPlaceholder": "Gespeichert — neu eingeben zum Ersetzen", "ouraSaveCredentials": "Speichern", @@ -8047,14 +8056,6 @@ "title": "Einnahme-Legende", "description": "Dokumentiere 300 eingenommene Medikamente." }, - "overIntake1": { - "title": "Doppelt genommen", - "description": "Nimm mindestens einmal mehr ein als vorgesehen." - }, - "skippedIntake1": { - "title": "Pause eingelegt", - "description": "Überspringe mindestens eine geplante Einnahme." - }, "passkeyCreated1": { "title": "Passkey gesetzt", "description": "Erstelle deinen ersten Passkey." @@ -8719,7 +8720,11 @@ }, "course": "Phase {n}", "courseRange": "{start} bis {end}", - "courseSince": "seit {start}" + "courseSince": "seit {start}", + "unavailable": { + "title": "Link abgelaufen oder ungültig", + "description": "Dieser Freigabelink ist abgelaufen, wurde zurückgezogen oder ist unvollständig. Bitte die Person, die ihn geteilt hat, um einen neuen Link." + } }, "cycle": { "symptomCategory": { @@ -9803,7 +9808,7 @@ } }, "illness": { - "title": "Krankheitstagebuch", + "title": "Krankheit", "subtitle": "Ein rückblickendes Protokoll deiner Krankheits- und Beschwerde-Episoden.", "settings": { "viewDescription": "Wähle, ob Episoden als Karten oder als kompakte Liste erscheinen.", @@ -12260,7 +12265,8 @@ "fewer": "Weniger anzeigen", "note": "Zusammengestellt aus deinen Einträgen, ohne Bewertung. Jedes Datum öffnet den ganzen Tag.", "report": "Arztbericht {date} bis heute" - } + }, + "travel": "Unterwegs" }, "timeline": { "title": "Zeitleiste", @@ -12494,5 +12500,12 @@ "title": "Lebensereignis löschen?", "body": "Titel und Notiz sind danach nicht mehr da." } + }, + "aboutPage": { + "eyebrow": "Info", + "title": "Über HealthLog", + "lastUpdated": "Zuletzt aktualisiert: {date}", + "project": "Projekt", + "credits": "Danksagungen" } } diff --git a/messages/en.json b/messages/en.json index 06a2e9b36..7d80357c5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -49,7 +49,9 @@ "dismiss": "Dismiss", "moreInfo": "More information", "durationHoursMinutes": "{hours}h {minutes}m", - "durationMinutes": "{minutes}m" + "durationMinutes": "{minutes}m", + "durationClockMinutes": "{time} min", + "durationClockHours": "{time} h" }, "apiErrors": { "measurement": { @@ -540,6 +542,11 @@ "errorRateLimit": "Too many attempts. Wait a few minutes and try again.", "errorFailed": "That did not work. Try again in a moment.", "errorOffline": "That did not reach the server. Check your connection and try again." + }, + "inviteInvalid": { + "title": "Invitation invalid", + "description": "This invitation link is incomplete or was copied wrongly. Ask the person who invited you for a new link.", + "toLogin": "Go to sign in" } }, "dashboard": { @@ -2031,7 +2038,6 @@ "bestand": "Supply", "verlauf": "History", "injektion": "Injection", - "api": "API", "erweitert": "Advanced", "wirkung": "Effect" }, @@ -2503,7 +2509,8 @@ "moodHeatmapEmpty": "Mood heatmap, no days logged yet.", "complianceHeatmap": "Medication compliance heatmap from {start} to {end}, {days} active days.", "complianceHeatmapEmpty": "Medication compliance heatmap, no active days yet." - } + }, + "medicationsEmptyAction": "Add a medication" }, "chart": { "overlay": { @@ -4340,7 +4347,9 @@ "method": "A nightly 0–100 strain proxy from your heart-rate-weighted activity load (a TRIMP-style training-impulse model). Higher means a harder day.", "anchorPersonal": "Anchored to your own recent training-day load — scored relative to your typical effort, so a genuinely hard day for you reads high.", "anchorPopulation": "Scored against a general reference while you build training history; once you have enough it switches to your own typical effort.", - "description": "A nightly activity-load proxy from your heart-rate-weighted effort, relative to your typical effort." + "description": "A nightly activity-load proxy from your heart-rate-weighted effort, relative to your typical effort.", + "deviceCaption": "Your device's day strain: {value} of {max}", + "deviceSource": "Until HealthLog has computed its own value overnight, the card shows your device's day strain, rescaled to 0–100." }, "WRIST_TEMPERATURE_BASELINE": { "method": "Your typical overnight wrist temperature is the median of your nightly readings over the window, ± a robust spread (median absolute deviation, scaled). It tracks YOUR readings — last night is shown as a deviation from your own baseline, not a population norm.", @@ -4717,8 +4726,8 @@ "emptyCta": "Connect a device" }, "intradayPulse": { - "title": "Your day's shape", - "caption": "Your heart rate through the day, averaged in 10-minute steps.", + "title": "Pulse through the day", + "caption": "Averaged in 10-minute steps.", "hourlyNote": "Showing this day's hourly average — recent days show the full 10-minute detail.", "empty": "Not enough heart-rate readings for this day to show its shape yet.", "coverageOne": "Based on 1 reading across {hours}h.", @@ -6007,9 +6016,9 @@ "botToken": "Bot Token", "chatId": "Chat ID", "enableNotifications": "Enable notifications", - "telegramStep1": "1. Create a bot via @BotFather in Telegram and copy the token.", - "telegramStep2": "2. Send /start to your bot to activate the chat.", - "telegramStep3": "3. Find your Chat ID via @userinfobot or the Bot API.", + "telegramStep1": "Create a bot via @BotFather in Telegram and copy the token.", + "telegramStep2": "Send /start to your bot to activate the chat.", + "telegramStep3": "Find your Chat ID via @userinfobot or the Bot API.", "ntfy": "ntfy", "ntfyDescription": "Notifications via ntfy (self-hosted or ntfy.sh).", "ntfyEnable": "Enable ntfy", @@ -8047,14 +8056,6 @@ "title": "Intake legend", "description": "Record 300 taken medication events." }, - "overIntake1": { - "title": "Double take", - "description": "Take medication at least once more than planned." - }, - "skippedIntake1": { - "title": "Stepped back", - "description": "Skip at least one planned intake." - }, "passkeyCreated1": { "title": "Passkey set up", "description": "Create your first passkey." @@ -8719,7 +8720,11 @@ }, "course": "Course {n}", "courseRange": "{start} to {end}", - "courseSince": "since {start}" + "courseSince": "since {start}", + "unavailable": { + "title": "Link expired or invalid", + "description": "This share link has expired, was revoked, or is incomplete. Ask the person who shared it for a new link." + } }, "cycle": { "symptomCategory": { @@ -9803,7 +9808,7 @@ } }, "illness": { - "title": "Illness journal", + "title": "Illness", "subtitle": "A retrospective log of your illness and condition episodes.", "settings": { "viewDescription": "Choose whether episodes show as cards or as a compact list.", @@ -12260,7 +12265,8 @@ "fewer": "Show fewer", "note": "Compiled from your entries, without judgement. Every date opens the whole day.", "report": "Doctor report from {date} to today" - } + }, + "travel": "Away" }, "timeline": { "title": "Timeline", @@ -12494,5 +12500,12 @@ "title": "Delete this life event?", "body": "Its title and note will be gone." } + }, + "aboutPage": { + "eyebrow": "About", + "title": "About HealthLog", + "lastUpdated": "Last updated: {date}", + "project": "Project", + "credits": "Credits" } } diff --git a/messages/es.json b/messages/es.json index ddf55c374..95ebf5cc3 100644 --- a/messages/es.json +++ b/messages/es.json @@ -49,7 +49,9 @@ "dismiss": "Descartar", "moreInfo": "Más información", "durationHoursMinutes": "{hours} h {minutes} min", - "durationMinutes": "{minutes} min" + "durationMinutes": "{minutes} min", + "durationClockMinutes": "{time} min", + "durationClockHours": "{time} h" }, "apiErrors": { "measurement": { @@ -540,6 +542,11 @@ "errorRateLimit": "Demasiados intentos. Espera unos minutos y vuelve a intentarlo.", "errorFailed": "No ha funcionado. Vuelve a intentarlo en un momento.", "errorOffline": "No se llegó al servidor. Comprueba tu conexión y vuelve a intentarlo." + }, + "inviteInvalid": { + "title": "Invitación no válida", + "description": "Este enlace de invitación está incompleto o se copió mal. Pide un enlace nuevo a la persona que te invitó.", + "toLogin": "Ir a iniciar sesión" } }, "dashboard": { @@ -2031,7 +2038,6 @@ "bestand": "Existencias", "verlauf": "Historial", "injektion": "Inyección", - "api": "API", "erweitert": "Avanzado", "wirkung": "Efecto" }, @@ -2503,7 +2509,8 @@ "moodHeatmapEmpty": "Mapa de calor de estado de ánimo, aún no hay días registrados.", "complianceHeatmap": "Mapa de calor de cumplimiento de medicación de {start} a {end}, {days} días activos.", "complianceHeatmapEmpty": "Mapa de calor de cumplimiento de medicación, aún no hay días activos." - } + }, + "medicationsEmptyAction": "Añadir un medicamento" }, "chart": { "overlay": { @@ -4340,7 +4347,9 @@ "method": "Un indicador nocturno de esfuerzo de 0 a 100 a partir de tu carga de actividad ponderada por frecuencia cardíaca (un modelo de impulso de entrenamiento tipo TRIMP). Más alto significa un día más exigente.", "anchorPersonal": "Anclado a tu propia carga reciente de días de entrenamiento: se evalúa en relación con tu esfuerzo habitual, de modo que un día realmente duro para ti aparece alto.", "anchorPopulation": "Se evalúa frente a una referencia general mientras acumulas historial de entrenamiento; cuando tengas suficiente, pasa a tu propio esfuerzo habitual.", - "description": "Un indicador nocturno de carga de actividad a partir de tu esfuerzo ponderado por frecuencia cardíaca, relativo a tu esfuerzo habitual." + "description": "Un indicador nocturno de carga de actividad a partir de tu esfuerzo ponderado por frecuencia cardíaca, relativo a tu esfuerzo habitual.", + "deviceCaption": "Esfuerzo diario de tu dispositivo: {value} de {max}", + "deviceSource": "Hasta que HealthLog calcule su propio valor durante la noche, la tarjeta muestra el esfuerzo diario de tu dispositivo, convertido a 0–100." }, "WRIST_TEMPERATURE_BASELINE": { "method": "Tu temperatura típica de muñeca durante la noche es la mediana de tus lecturas nocturnas en el periodo, ± una dispersión robusta (desviación absoluta de la mediana, escalada). Sigue TUS lecturas: la noche pasada se muestra como una desviación respecto a tu propia base, no a una norma poblacional.", @@ -4717,8 +4726,8 @@ "emptyCta": "Conectar un dispositivo" }, "intradayPulse": { - "title": "La forma de tu día", - "caption": "Tu frecuencia cardíaca a lo largo del día, promediada en tramos de 10 minutos.", + "title": "Pulso a lo largo del día", + "caption": "Promediado en intervalos de 10 minutos.", "hourlyNote": "Mostrando el promedio por hora de este día — los días recientes muestran el detalle completo de 10 minutos.", "empty": "Aún no hay suficientes lecturas para este día para mostrar su forma.", "coverageOne": "Basado en 1 lectura a lo largo de {hours}h.", @@ -6007,9 +6016,9 @@ "botToken": "Token del bot", "chatId": "ID del chat", "enableNotifications": "Activar notificaciones", - "telegramStep1": "1. Crea un bot con @BotFather en Telegram y copia el token.", - "telegramStep2": "2. Envía /start a tu bot para activar el chat.", - "telegramStep3": "3. Obtén tu ID del chat con @userinfobot o la API del bot.", + "telegramStep1": "Crea un bot con @BotFather en Telegram y copia el token.", + "telegramStep2": "Envía /start a tu bot para activar el chat.", + "telegramStep3": "Obtén tu ID del chat con @userinfobot o la API del bot.", "ntfy": "ntfy", "ntfyDescription": "Notificaciones a través de ntfy (autoalojado o ntfy.sh).", "ntfyEnable": "Activar ntfy", @@ -8047,14 +8056,6 @@ "title": "Leyenda de las tomas", "description": "Registra 300 tomas de medicación." }, - "overIntake1": { - "title": "Dosis doble", - "description": "Toma la medicación al menos una vez más de lo previsto." - }, - "skippedIntake1": { - "title": "Un paso atrás", - "description": "Omite al menos una toma planificada." - }, "passkeyCreated1": { "title": "Clave de acceso creada", "description": "Crea tu primera clave de acceso." @@ -8719,7 +8720,11 @@ }, "course": "Tratamiento {n}", "courseRange": "del {start} al {end}", - "courseSince": "desde el {start}" + "courseSince": "desde el {start}", + "unavailable": { + "title": "Enlace caducado o no válido", + "description": "Este enlace para compartir ha caducado, se ha revocado o está incompleto. Pide un enlace nuevo a la persona que lo compartió." + } }, "cycle": { "symptomCategory": { @@ -9803,7 +9808,7 @@ } }, "illness": { - "title": "Diario de enfermedad", + "title": "Enfermedad", "subtitle": "Un registro retrospectivo de tus episodios de enfermedad y dolencias.", "settings": { "viewDescription": "Elige si los episodios se muestran como tarjetas o como lista compacta.", @@ -12260,7 +12265,8 @@ "fewer": "Mostrar menos", "note": "Reunido a partir de tus entradas, sin valoración. Cada fecha abre el día completo.", "report": "Informe médico del {date} hasta hoy" - } + }, + "travel": "De viaje" }, "timeline": { "title": "Cronología", @@ -12494,5 +12500,12 @@ "title": "¿Eliminar este acontecimiento vital?", "body": "Su título y su nota desaparecerán." } + }, + "aboutPage": { + "eyebrow": "Información", + "title": "Acerca de HealthLog", + "lastUpdated": "Última actualización: {date}", + "project": "Proyecto", + "credits": "Créditos" } } diff --git a/messages/fr.json b/messages/fr.json index c1ff33246..9f6a7f002 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -49,7 +49,9 @@ "dismiss": "Ignorer", "moreInfo": "Plus d'informations", "durationHoursMinutes": "{hours} h {minutes} min", - "durationMinutes": "{minutes} min" + "durationMinutes": "{minutes} min", + "durationClockMinutes": "{time} min", + "durationClockHours": "{time} h" }, "apiErrors": { "measurement": { @@ -540,6 +542,11 @@ "errorRateLimit": "Trop de tentatives. Attendez quelques minutes puis réessayez.", "errorFailed": "Cela n'a pas fonctionné. Réessayez dans un instant.", "errorOffline": "Le serveur n'a pas été atteint. Vérifiez votre connexion et réessayez." + }, + "inviteInvalid": { + "title": "Invitation invalide", + "description": "Ce lien d’invitation est incomplet ou a été mal copié. Demandez un nouveau lien à la personne qui vous a invité.", + "toLogin": "Aller à la connexion" } }, "dashboard": { @@ -2031,7 +2038,6 @@ "bestand": "Stock", "verlauf": "Historique", "injektion": "Injection", - "api": "API", "erweitert": "Avancé", "wirkung": "Effet" }, @@ -2503,7 +2509,8 @@ "moodHeatmapEmpty": "Carte thermique de l'humeur, aucun jour enregistré pour l'instant.", "complianceHeatmap": "Carte thermique d'observance des médicaments du {start} au {end}, {days} jours actifs.", "complianceHeatmapEmpty": "Carte thermique d'observance des médicaments, aucun jour actif pour l'instant." - } + }, + "medicationsEmptyAction": "Ajouter un médicament" }, "chart": { "overlay": { @@ -4340,7 +4347,9 @@ "method": "Un indice d’effort nocturne de 0 à 100 à partir de votre charge d’activité pondérée par la fréquence cardiaque (un modèle d’impulsion d’entraînement de type TRIMP). Plus élevé signifie une journée plus exigeante.", "anchorPersonal": "Calé sur votre propre charge récente des jours d’entraînement — évalué par rapport à votre effort habituel, de sorte qu’une journée vraiment difficile pour vous ressort élevée.", "anchorPopulation": "Évalué par rapport à une référence générale pendant que vous constituez votre historique d’entraînement ; dès que vous en avez assez, le calcul passe à votre propre effort habituel.", - "description": "Un indice de charge d’activité nocturne à partir de votre effort pondéré par la fréquence cardiaque, relatif à votre effort habituel." + "description": "Un indice de charge d’activité nocturne à partir de votre effort pondéré par la fréquence cardiaque, relatif à votre effort habituel.", + "deviceCaption": "Effort du jour de votre appareil : {value} sur {max}", + "deviceSource": "Tant que HealthLog n’a pas calculé sa propre valeur pendant la nuit, la carte affiche l’effort du jour de votre appareil, ramené sur 0–100." }, "WRIST_TEMPERATURE_BASELINE": { "method": "Votre température nocturne typique au poignet est la médiane de vos relevés nocturnes sur la période, ± une dispersion robuste (écart absolu médian, mis à l’échelle). Elle suit VOS relevés — la nuit dernière est affichée comme un écart par rapport à votre propre référence, pas à une norme de population.", @@ -4717,8 +4726,8 @@ "emptyCta": "Connecter un appareil" }, "intradayPulse": { - "title": "Le rythme de votre journée", - "caption": "Votre fréquence cardiaque au fil de la journée, moyennée par tranches de 10 minutes.", + "title": "Pouls au fil de la journée", + "caption": "Moyenné par tranches de 10 minutes.", "hourlyNote": "Moyenne horaire affichée pour cette journée — les jours récents affichent le détail complet par tranches de 10 minutes.", "empty": "Pas encore assez de mesures pour cette journée pour en afficher le rythme.", "coverageOne": "Basé sur 1 mesure sur {hours}h.", @@ -6007,9 +6016,9 @@ "botToken": "Jeton du bot", "chatId": "ID du chat", "enableNotifications": "Activer les notifications", - "telegramStep1": "1. Créez un bot via @BotFather dans Telegram et copiez le jeton.", - "telegramStep2": "2. Envoyez /start à votre bot pour activer le chat.", - "telegramStep3": "3. Trouvez votre ID de chat via @userinfobot ou l’API du bot.", + "telegramStep1": "Créez un bot via @BotFather dans Telegram et copiez le jeton.", + "telegramStep2": "Envoyez /start à votre bot pour activer le chat.", + "telegramStep3": "Trouvez votre ID de chat via @userinfobot ou l’API du bot.", "ntfy": "ntfy", "ntfyDescription": "Notifications via ntfy (auto-hébergé ou ntfy.sh).", "ntfyEnable": "Activer ntfy", @@ -8047,14 +8056,6 @@ "title": "Légende des prises", "description": "Enregistrez 300 prises de médicaments." }, - "overIntake1": { - "title": "Double dose", - "description": "Prenez votre médicament au moins une fois de plus que prévu." - }, - "skippedIntake1": { - "title": "Pause prise", - "description": "Sautez au moins une prise prévue." - }, "passkeyCreated1": { "title": "Clé d’accès configurée", "description": "Créez votre première clé d’accès." @@ -8719,7 +8720,11 @@ }, "course": "Traitement {n}", "courseRange": "du {start} au {end}", - "courseSince": "depuis le {start}" + "courseSince": "depuis le {start}", + "unavailable": { + "title": "Lien expiré ou invalide", + "description": "Ce lien de partage a expiré, a été révoqué ou est incomplet. Demandez un nouveau lien à la personne qui l’a partagé." + } }, "cycle": { "symptomCategory": { @@ -9803,7 +9808,7 @@ } }, "illness": { - "title": "Journal de maladie", + "title": "Maladie", "subtitle": "Un suivi rétrospectif de vos épisodes de maladie et d'affections.", "settings": { "viewDescription": "Choisis si les épisodes s'affichent en cartes ou en liste compacte.", @@ -12260,7 +12265,8 @@ "fewer": "Afficher moins", "note": "Établi à partir de vos entrées, sans évaluation. Chaque date ouvre la journée entière.", "report": "Rapport médical du {date} à aujourd’hui" - } + }, + "travel": "En déplacement" }, "timeline": { "title": "Chronologie", @@ -12494,5 +12500,12 @@ "title": "Supprimer cet événement de vie ?", "body": "Son titre et sa note disparaîtront." } + }, + "aboutPage": { + "eyebrow": "À propos", + "title": "À propos de HealthLog", + "lastUpdated": "Dernière mise à jour : {date}", + "project": "Projet", + "credits": "Crédits" } } diff --git a/messages/it.json b/messages/it.json index d6cd9c4f4..bb050da28 100644 --- a/messages/it.json +++ b/messages/it.json @@ -49,7 +49,9 @@ "dismiss": "Ignora", "moreInfo": "Maggiori informazioni", "durationHoursMinutes": "{hours} h {minutes} min", - "durationMinutes": "{minutes} min" + "durationMinutes": "{minutes} min", + "durationClockMinutes": "{time} min", + "durationClockHours": "{time} h" }, "apiErrors": { "measurement": { @@ -540,6 +542,11 @@ "errorRateLimit": "Troppi tentativi. Attendi qualche minuto e riprova.", "errorFailed": "Non ha funzionato. Riprova tra un momento.", "errorOffline": "Il server non è stato raggiunto. Controlla la connessione e riprova." + }, + "inviteInvalid": { + "title": "Invito non valido", + "description": "Questo link di invito è incompleto o è stato copiato male. Chiedi un nuovo link alla persona che ti ha invitato.", + "toLogin": "Vai all’accesso" } }, "dashboard": { @@ -2031,7 +2038,6 @@ "bestand": "Scorta", "verlauf": "Cronologia", "injektion": "Iniezione", - "api": "API", "erweitert": "Avanzate", "wirkung": "Effetto" }, @@ -2503,7 +2509,8 @@ "moodHeatmapEmpty": "Mappa di calore dell'umore, nessun giorno ancora registrato.", "complianceHeatmap": "Mappa di calore dell'aderenza ai farmaci dal {start} al {end}, {days} giorni attivi.", "complianceHeatmapEmpty": "Mappa di calore dell'aderenza ai farmaci, nessun giorno attivo." - } + }, + "medicationsEmptyAction": "Aggiungi un farmaco" }, "chart": { "overlay": { @@ -4340,7 +4347,9 @@ "method": "Un indicatore notturno di sforzo da 0 a 100 dal tuo carico di attività pesato sulla frequenza cardiaca (un modello di impulso d’allenamento tipo TRIMP). Più alto significa una giornata più impegnativa.", "anchorPersonal": "Ancorato al tuo carico recente nei giorni di allenamento — valutato rispetto al tuo sforzo abituale, così che una giornata davvero dura per te risulti alta.", "anchorPopulation": "Valutato rispetto a un riferimento generale mentre costruisci lo storico di allenamento; quando ne hai abbastanza passa al tuo sforzo abituale.", - "description": "Un indicatore notturno di carico di attività dal tuo sforzo pesato sulla frequenza cardiaca, relativo al tuo sforzo abituale." + "description": "Un indicatore notturno di carico di attività dal tuo sforzo pesato sulla frequenza cardiaca, relativo al tuo sforzo abituale.", + "deviceCaption": "Sforzo giornaliero del tuo dispositivo: {value} su {max}", + "deviceSource": "Finché HealthLog non ha calcolato un proprio valore durante la notte, la scheda mostra lo sforzo giornaliero del tuo dispositivo, riportato su 0–100." }, "WRIST_TEMPERATURE_BASELINE": { "method": "La tua tipica temperatura notturna al polso è la mediana delle tue letture notturne nel periodo, ± una dispersione robusta (deviazione assoluta mediana, scalata). Segue le TUE letture — la notte scorsa è mostrata come scostamento dalla tua baseline, non da una norma di popolazione.", @@ -4717,8 +4726,8 @@ "emptyCta": "Collega un dispositivo" }, "intradayPulse": { - "title": "L'andamento della tua giornata", - "caption": "La tua frequenza cardiaca durante il giorno, mediata in intervalli di 10 minuti.", + "title": "Battito nel corso della giornata", + "caption": "Media su intervalli di 10 minuti.", "hourlyNote": "Media oraria per questo giorno — i giorni recenti mostrano il dettaglio completo a 10 minuti.", "empty": "Non ci sono ancora abbastanza misurazioni per questo giorno per mostrarne l'andamento.", "coverageOne": "Basato su 1 misurazione in {hours}h.", @@ -6007,9 +6016,9 @@ "botToken": "Token del bot", "chatId": "ID chat", "enableNotifications": "Attiva le notifiche", - "telegramStep1": "1. Crea un bot tramite @BotFather su Telegram e copia il token.", - "telegramStep2": "2. Invia /start al tuo bot per attivare la chat.", - "telegramStep3": "3. Trova il tuo ID chat tramite @userinfobot o l’API del bot.", + "telegramStep1": "Crea un bot tramite @BotFather su Telegram e copia il token.", + "telegramStep2": "Invia /start al tuo bot per attivare la chat.", + "telegramStep3": "Trova il tuo ID chat tramite @userinfobot o l’API del bot.", "ntfy": "ntfy", "ntfyDescription": "Notifiche tramite ntfy (self-hosted o ntfy.sh).", "ntfyEnable": "Attiva ntfy", @@ -8047,14 +8056,6 @@ "title": "Leggenda delle assunzioni", "description": "Registra 300 assunzioni di farmaci." }, - "overIntake1": { - "title": "Doppia dose", - "description": "Assumi il farmaco almeno una volta in più del previsto." - }, - "skippedIntake1": { - "title": "Pausa presa", - "description": "Salta almeno un’assunzione pianificata." - }, "passkeyCreated1": { "title": "Passkey configurata", "description": "Crea la tua prima passkey." @@ -8719,7 +8720,11 @@ }, "course": "Ciclo {n}", "courseRange": "dal {start} al {end}", - "courseSince": "dal {start}" + "courseSince": "dal {start}", + "unavailable": { + "title": "Link scaduto o non valido", + "description": "Questo link di condivisione è scaduto, è stato revocato o è incompleto. Chiedi un nuovo link alla persona che lo ha condiviso." + } }, "cycle": { "symptomCategory": { @@ -9803,7 +9808,7 @@ } }, "illness": { - "title": "Diario delle malattie", + "title": "Malattia", "subtitle": "Un registro retrospettivo dei tuoi episodi di malattia e disturbi.", "settings": { "viewDescription": "Scegli se gli episodi appaiono come schede o come elenco compatto.", @@ -12260,7 +12265,8 @@ "fewer": "Mostra meno", "note": "Raccolto dalle tue voci, senza valutazioni. Ogni data apre l’intera giornata.", "report": "Referto medico dal {date} a oggi" - } + }, + "travel": "In viaggio" }, "timeline": { "title": "Cronologia", @@ -12494,5 +12500,12 @@ "title": "Eliminare questo evento di vita?", "body": "Titolo e nota non ci saranno più." } + }, + "aboutPage": { + "eyebrow": "Informazioni", + "title": "Informazioni su HealthLog", + "lastUpdated": "Ultimo aggiornamento: {date}", + "project": "Progetto", + "credits": "Riconoscimenti" } } diff --git a/messages/ko.json b/messages/ko.json index 56aec5aa9..12d9c8af9 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -49,7 +49,9 @@ "dismiss": "닫기", "moreInfo": "추가 정보", "durationHoursMinutes": "{hours}시간 {minutes}분", - "durationMinutes": "{minutes}분" + "durationMinutes": "{minutes}분", + "durationClockMinutes": "{time}분", + "durationClockHours": "{time}시간" }, "apiErrors": { "measurement": { @@ -540,6 +542,11 @@ "errorRateLimit": "시도 횟수가 너무 많습니다. 몇 분 후 다시 시도하세요.", "errorFailed": "처리하지 못했습니다. 잠시 후 다시 시도하세요.", "errorOffline": "서버에 연결하지 못했습니다. 연결을 확인하고 다시 시도하세요." + }, + "inviteInvalid": { + "title": "유효하지 않은 초대예요", + "description": "이 초대 링크가 불완전하거나 잘못 복사되었어요. 초대한 분께 새 링크를 요청해 주세요.", + "toLogin": "로그인으로 이동" } }, "dashboard": { @@ -2031,7 +2038,6 @@ "bestand": "재고", "verlauf": "기록", "injektion": "주사", - "api": "API", "erweitert": "고급", "wirkung": "효과" }, @@ -2503,7 +2509,8 @@ "days90Label": "90일", "days90Title": "최근 90일", "daysAllLabel": "전체", - "daysAllTitle": "전체 기간" + "daysAllTitle": "전체 기간", + "medicationsEmptyAction": "약 추가하기" }, "chart": { "overlay": { @@ -4340,7 +4347,9 @@ "method": "심박수로 가중한 활동 부하(TRIMP 방식 훈련 자극 모델)로 매일 밤 내는 0–100 부하 지표예요. 높을수록 힘든 하루였다는 뜻이에요.", "anchorPersonal": "최근 운동한 날의 부하를 기준으로 삼아요 — 평소 강도와 비교해 점수를 매기니 나에게 정말 힘든 날이 높게 나와요.", "anchorPopulation": "운동 기록이 쌓이는 동안에는 일반 기준으로 점수를 매기고, 충분히 모이면 내 평소 강도 기준으로 바뀌어요.", - "description": "심박수로 가중한 운동 강도를 평소 강도와 비교해 매일 밤 계산하는 활동 부하 지표예요." + "description": "심박수로 가중한 운동 강도를 평소 강도와 비교해 매일 밤 계산하는 활동 부하 지표예요.", + "deviceCaption": "기기의 일일 부하: {max} 중 {value}", + "deviceSource": "HealthLog가 밤사이 자체 값을 계산하기 전까지는 기기의 일일 부하를 0–100으로 환산해 보여 줘요." }, "WRIST_TEMPERATURE_BASELINE": { "method": "밤사이 손목 온도의 일반 범위는 해당 기간 야간 측정값의 중앙값에 견고한 산포(중앙절대편차를 보정한 값)를 더하고 뺀 구간이에요. 내 측정값을 따라가기 때문에, 지난밤은 인구 평균이 아니라 내 기준값에서 얼마나 벗어났는지로 보여줘요.", @@ -4717,8 +4726,8 @@ "emptyCta": "기기 연결" }, "intradayPulse": { - "title": "하루의 흐름", - "caption": "10분 단위로 평균 낸 하루 동안의 심박수예요.", + "title": "하루 동안의 맥박", + "caption": "10분 단위로 평균했어요.", "hourlyNote": "이 날짜는 시간별 평균으로 보여줘요 — 최근 며칠은 10분 단위까지 자세히 볼 수 있어요.", "empty": "이 날짜는 심박수 측정값이 적어서 아직 흐름을 보여줄 수 없어요.", "coverageOne": "{hours}h 동안 모은 측정값 1개를 기준으로 했어요.", @@ -6007,9 +6016,9 @@ "botToken": "봇 토큰", "chatId": "채팅 ID", "enableNotifications": "알림 켜기", - "telegramStep1": "1. Telegram에서 @BotFather로 봇을 만들고 토큰을 복사하세요.", - "telegramStep2": "2. 봇에게 /start를 보내 채팅을 활성화하세요.", - "telegramStep3": "3. @userinfobot이나 Bot API로 채팅 ID를 확인하세요.", + "telegramStep1": "Telegram에서 @BotFather로 봇을 만들고 토큰을 복사하세요.", + "telegramStep2": "봇에게 /start를 보내 채팅을 활성화하세요.", + "telegramStep3": "@userinfobot이나 Bot API로 채팅 ID를 확인하세요.", "ntfy": "ntfy", "ntfyDescription": "ntfy(셀프 호스팅 또는 ntfy.sh)로 보내는 알림이에요.", "ntfyEnable": "ntfy 켜기", @@ -8047,14 +8056,6 @@ "title": "복용 전설", "description": "복용 기록 300회를 남겨요." }, - "overIntake1": { - "title": "한 번 더", - "description": "계획보다 한 번 이상 더 복용해요." - }, - "skippedIntake1": { - "title": "한 걸음 물러서기", - "description": "계획된 복용을 한 번 이상 건너뛰어요." - }, "passkeyCreated1": { "title": "패스키 등록", "description": "첫 패스키를 만들어요." @@ -8719,7 +8720,11 @@ }, "course": "복용 기간 {n}", "courseRange": "{start}부터 {end}까지", - "courseSince": "{start}부터" + "courseSince": "{start}부터", + "unavailable": { + "title": "링크가 만료되었거나 유효하지 않아요", + "description": "이 공유 링크는 만료되었거나 취소되었거나 불완전해요. 공유한 분께 새 링크를 요청해 주세요." + } }, "cycle": { "symptomCategory": { @@ -9803,7 +9808,7 @@ } }, "illness": { - "title": "아픈 날 일지", + "title": "아픈 날", "subtitle": "아픈 날과 컨디션 에피소드를 돌아보는 기록이에요.", "settings": { "viewDescription": "에피소드를 카드로 볼지 간단한 목록으로 볼지 선택하세요.", @@ -12260,7 +12265,8 @@ "fewer": "줄여 보기", "note": "평가 없이 기록에서 모았어요. 날짜를 누르면 그날 전체가 열려요.", "report": "{date}부터 오늘까지 진료 보고서" - } + }, + "travel": "여행 중" }, "timeline": { "title": "타임라인", @@ -12494,5 +12500,12 @@ "title": "이 삶의 사건을 삭제할까요?", "body": "제목과 메모가 사라져요." } + }, + "aboutPage": { + "eyebrow": "정보", + "title": "HealthLog 정보", + "lastUpdated": "마지막 업데이트: {date}", + "project": "프로젝트", + "credits": "크레딧" } } diff --git a/messages/pl.json b/messages/pl.json index f0a04ad32..554000b27 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -49,7 +49,9 @@ "dismiss": "Odrzuć", "moreInfo": "Więcej informacji", "durationHoursMinutes": "{hours} godz. {minutes} min", - "durationMinutes": "{minutes} min" + "durationMinutes": "{minutes} min", + "durationClockMinutes": "{time} min", + "durationClockHours": "{time} godz." }, "apiErrors": { "measurement": { @@ -540,6 +542,11 @@ "errorRateLimit": "Zbyt wiele prób. Odczekaj kilka minut i spróbuj ponownie.", "errorFailed": "Nie udało się. Spróbuj ponownie za chwilę.", "errorOffline": "Nie udało się połączyć z serwerem. Sprawdź połączenie i spróbuj ponownie." + }, + "inviteInvalid": { + "title": "Nieprawidłowe zaproszenie", + "description": "Ten link z zaproszeniem jest niekompletny lub został źle skopiowany. Poproś osobę, która cię zaprosiła, o nowy link.", + "toLogin": "Przejdź do logowania" } }, "dashboard": { @@ -2031,7 +2038,6 @@ "bestand": "Zapas", "verlauf": "Historia", "injektion": "Iniekcja", - "api": "API", "erweitert": "Zaawansowane", "wirkung": "Działanie" }, @@ -2503,7 +2509,8 @@ "moodHeatmapEmpty": "Mapa cieplna nastroju, brak zarejestrowanych dni.", "complianceHeatmap": "Mapa cieplna przyjmowania leków od {start} do {end}, {days} aktywnych dni.", "complianceHeatmapEmpty": "Mapa cieplna przyjmowania leków, brak aktywnych dni." - } + }, + "medicationsEmptyAction": "Dodaj lek" }, "chart": { "overlay": { @@ -4340,7 +4347,9 @@ "method": "Nocny wskaźnik obciążenia 0–100 na podstawie Twojego obciążenia aktywnością ważonego tętnem (model impulsu treningowego typu TRIMP). Wyższy oznacza cięższy dzień.", "anchorPersonal": "Zakotwiczony w Twoim własnym niedawnym obciążeniu z dni treningowych — oceniany względem Twojego typowego wysiłku, więc naprawdę ciężki dzień dla Ciebie wypada wysoko.", "anchorPopulation": "Oceniany względem ogólnego punktu odniesienia, gdy dopiero budujesz historię treningów; gdy zbierzesz jej wystarczająco, przełącza się na Twój własny typowy wysiłek.", - "description": "Nocny wskaźnik obciążenia aktywnością z Twojego wysiłku ważonego tętnem, względem Twojego typowego wysiłku." + "description": "Nocny wskaźnik obciążenia aktywnością z Twojego wysiłku ważonego tętnem, względem Twojego typowego wysiłku.", + "deviceCaption": "Dzienne obciążenie z Twojego urządzenia: {value} z {max}", + "deviceSource": "Dopóki HealthLog nie obliczy w nocy własnej wartości, karta pokazuje dzienne obciążenie z Twojego urządzenia, przeliczone na 0–100." }, "WRIST_TEMPERATURE_BASELINE": { "method": "Twoja typowa nocna temperatura nadgarstka to mediana Twoich nocnych odczytów w danym okresie, ± solidny rozrzut (przeskalowane medianowe odchylenie bezwzględne). Śledzi TWOJE odczyty — ostatnia noc jest pokazana jako odchylenie od Twojej własnej bazy, a nie od normy populacyjnej.", @@ -4717,8 +4726,8 @@ "emptyCta": "Połącz urządzenie" }, "intradayPulse": { - "title": "Przebieg Twojego dnia", - "caption": "Twoje tętno w ciągu dnia, uśrednione w 10-minutowych odcinkach.", + "title": "Tętno w ciągu dnia", + "caption": "Uśrednione w krokach co 10 minut.", "hourlyNote": "Pokazano średnią godzinową dla tego dnia — ostatnie dni pokazują pełny, 10-minutowy szczegół.", "empty": "Za mało pomiarów z tego dnia, aby pokazać jego przebieg.", "coverageOne": "Na podstawie 1 pomiaru w ciągu {hours}h.", @@ -6007,9 +6016,9 @@ "botToken": "Token bota", "chatId": "ID czatu", "enableNotifications": "Włącz powiadomienia", - "telegramStep1": "1. Utwórz bota przez @BotFather w Telegramie i skopiuj token.", - "telegramStep2": "2. Wyślij /start do swojego bota, aby aktywować czat.", - "telegramStep3": "3. Znajdź swoje ID czatu przez @userinfobot lub API bota.", + "telegramStep1": "Utwórz bota przez @BotFather w Telegramie i skopiuj token.", + "telegramStep2": "Wyślij /start do swojego bota, aby aktywować czat.", + "telegramStep3": "Znajdź swoje ID czatu przez @userinfobot lub API bota.", "ntfy": "ntfy", "ntfyDescription": "Powiadomienia przez ntfy (własny serwer lub ntfy.sh).", "ntfyEnable": "Włącz ntfy", @@ -8047,14 +8056,6 @@ "title": "Legenda przyjęć", "description": "Zarejestruj 300 przyjęć leków." }, - "overIntake1": { - "title": "Podwójna dawka", - "description": "Przyjmij lek co najmniej raz więcej niż zaplanowano." - }, - "skippedIntake1": { - "title": "Krok wstecz", - "description": "Pomiń co najmniej jedno zaplanowane przyjęcie." - }, "passkeyCreated1": { "title": "Klucz dostępu ustawiony", "description": "Utwórz swój pierwszy klucz dostępu." @@ -8719,7 +8720,11 @@ }, "course": "Kuracja {n}", "courseRange": "od {start} do {end}", - "courseSince": "od {start}" + "courseSince": "od {start}", + "unavailable": { + "title": "Link wygasł lub jest nieprawidłowy", + "description": "Ten link udostępniania wygasł, został cofnięty lub jest niekompletny. Poproś osobę, która go udostępniła, o nowy link." + } }, "cycle": { "symptomCategory": { @@ -9803,7 +9808,7 @@ } }, "illness": { - "title": "Dziennik chorób", + "title": "Choroba", "subtitle": "Retrospektywny zapis Twoich epizodów chorób i dolegliwości.", "settings": { "viewDescription": "Wybierz, czy epizody mają być wyświetlane jako karty czy zwarta lista.", @@ -12260,7 +12265,8 @@ "fewer": "Pokaż mniej", "note": "Zestawione z Twoich wpisów, bez oceny. Każda data otwiera cały dzień.", "report": "Raport lekarski od {date} do dziś" - } + }, + "travel": "W podróży" }, "timeline": { "title": "Oś czasu", @@ -12494,5 +12500,12 @@ "title": "Usunąć to wydarzenie życiowe?", "body": "Tytuł i notatka znikną." } + }, + "aboutPage": { + "eyebrow": "Informacje", + "title": "O HealthLog", + "lastUpdated": "Ostatnia aktualizacja: {date}", + "project": "Projekt", + "credits": "Podziękowania" } } diff --git a/src/__tests__/derived-wellness-value-contract.test.ts b/src/__tests__/derived-wellness-value-contract.test.ts index 9034c98da..d58de8d62 100644 --- a/src/__tests__/derived-wellness-value-contract.test.ts +++ b/src/__tests__/derived-wellness-value-contract.test.ts @@ -112,4 +112,29 @@ describe("published WellnessScoreValue", () => { Object.keys(derived.value as unknown as Record).sort(), ); }); + + it("accepts the strain value served from the device's day strain", async () => { + findMany.mockImplementation(async (args: { where: { type: string } }) => + args.where.type === "DAY_STRAIN" + ? [ + { + value: 12.6, + measuredAt: new Date("2026-06-01T04:00:00Z"), + source: "WHOOP", + }, + ] + : [], + ); + const derived = await computeWellnessScore("STRAIN_SCORE", "u1", PROFILE, { + now: NOW, + }); + expect(derived.status).toBe("ok"); + if (derived.status !== "ok") return; + const parsed = wellnessScoreValue.safeParse(derived.value); + expect(parsed.error?.issues ?? []).toEqual([]); + expect(Object.keys(wellnessScoreValue.shape).sort()).toEqual( + Object.keys(derived.value as unknown as Record).sort(), + ); + findMany.mockReset(); + }); }); diff --git a/src/__tests__/session-surface-guard.test.ts b/src/__tests__/session-surface-guard.test.ts index ff563f791..b06ac7b19 100644 --- a/src/__tests__/session-surface-guard.test.ts +++ b/src/__tests__/session-surface-guard.test.ts @@ -236,6 +236,9 @@ describe("S2 — the URL-token authentication set is frozen", () => { "app/c/[token]/d/[id]/route.ts": "serves a shared document's bytes", "app/c/[token]/fhir/route.ts": "serves the shared record as a FHIR bundle", "app/c/[token]/report.pdf/route.ts": "serves the shared record as a PDF", + // The segment's not-found page: the copy a dead link shows. It reads no + // token and resolves nothing; the page above decides, and answers 404. + "app/c/[token]/not-found.tsx": "renders the dead-link copy; reads no token", // The passphrase gate for the same token. Anonymous by design; mints the // token-scoped unlock cookie the view above checks. "app/api/c/[token]/unlock/route.ts": "verifies the share-link passphrase", @@ -256,7 +259,7 @@ describe("S2 — the URL-token authentication set is frozen", () => { // Listed because it is a URL-token surface, with the reason being that // it authenticates nothing. "app/invite/[token]/page.tsx": - "validates the invite token's shape and redirects; no lookup", + "validates the invite token's shape and redirects, or says the link is invalid; no lookup", // v1.42 (#959) — the managed-profile handover link. The landing is the // invite landing's twin: a shape check on the `hlp_` segment and a // redirect, no lookup (the proxy normally answers it at the edge). @@ -305,6 +308,7 @@ describe("S2 — the URL-token authentication set is frozen", () => { "app/api/withings/webhook/[token]/route.ts", "app/c/[token]/d/[id]/route.ts", "app/c/[token]/fhir/route.ts", + "app/c/[token]/not-found.tsx", "app/c/[token]/page.tsx", "app/c/[token]/report.pdf/route.ts", "app/claim/[token]/page.tsx", diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index 9d039b5e6..cbfe211f5 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -1,6 +1,10 @@ import type { Metadata } from "next"; import Link from "next/link"; +import { resolveIntlLocale } from "@/lib/format-locale"; +import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; + /** * v1.4.27 B3 — Public about / credits page. * @@ -67,7 +71,17 @@ function Section({ ); } -export default function AboutPage() { +export default async function AboutPage() { + // The headings, the date and the sign-in link follow the reader's locale; + // the body is the attribution text the licences ask for and stays as + // written. + const locale = await resolveServerLocale(); + const { t } = getServerTranslator(locale); + const lastUpdated = new Intl.DateTimeFormat(resolveIntlLocale(locale), { + dateStyle: "medium", + timeZone: "UTC", + }).format(new Date(`${LAST_UPDATED}T12:00:00Z`)); + return (
@@ -82,7 +96,7 @@ export default function AboutPage() { href="/auth/login" className="text-muted-foreground hover:text-foreground inline-flex min-h-11 items-center text-sm" > - Sign in + {t("auth.login")}
@@ -99,17 +113,19 @@ export default function AboutPage() { >

- About + {t("aboutPage.eyebrow")}

- About HealthLog + {t("aboutPage.title")}

- Last updated: {LAST_UPDATED} +

-
+

HealthLog is an open-source, self-hostable personal-health-tracking application. The source code lives at{" "} @@ -127,7 +143,7 @@ export default function AboutPage() {

-
+

HealthLog stands on a number of open-source libraries and public data sources. The list below covers the third-party assets that ship diff --git a/src/app/achievements/__tests__/page.test.tsx b/src/app/achievements/__tests__/page.test.tsx index e29878045..5f11c7a5b 100644 --- a/src/app/achievements/__tests__/page.test.tsx +++ b/src/app/achievements/__tests__/page.test.tsx @@ -132,8 +132,6 @@ const mockData = { ] satisfies AchievementProgress[], metrics: { totalTakenIntakes: 1, - overIntakeCount: 0, - skippedIntakeCount: 0, bmiGreenStreak: 0, bpGreenStreak: 0, pulseGreenStreak: 0, diff --git a/src/app/admin/[section]/renderer.tsx b/src/app/admin/[section]/renderer.tsx index 89cb04f39..d927a353e 100644 --- a/src/app/admin/[section]/renderer.tsx +++ b/src/app/admin/[section]/renderer.tsx @@ -36,6 +36,8 @@ import { ModuleAvailabilitySection } from "@/components/admin/module-availabilit import { RemindersSection } from "@/components/admin/reminders-section"; import { ServicesSection } from "@/components/admin/services-section"; import { SystemStatusSection } from "@/components/admin/system-status-section"; +import { RevealWhenSettled } from "@/components/ui/reveal-when-settled"; +import { Skeleton } from "@/components/ui/skeleton"; import { MeasurementMaintenanceCard } from "@/components/admin/measurement-maintenance-card"; import { InviteTokensSection } from "@/components/admin/invite-tokens-section"; import { UserManagementSection } from "@/components/admin/user-management-section"; @@ -59,11 +61,18 @@ export function AdminSectionRenderer({ switch (slug) { case "system-status": return ( - - - {/* v1.42 — the table maintenance after the tombstone purge. */} - - + // The status cards grow as their reads land (host metrics, workers, + // queues); revealed together so the maintenance card below is not + // pushed down the page three times while they load. + } + > + + + {/* v1.42 — the table maintenance after the tombstone purge. */} + + + ); case "general": return ( diff --git a/src/app/api/integrations/healthkit/__tests__/route.test.ts b/src/app/api/integrations/healthkit/__tests__/route.test.ts index fd6b34138..f029d6a7f 100644 --- a/src/app/api/integrations/healthkit/__tests__/route.test.ts +++ b/src/app/api/integrations/healthkit/__tests__/route.test.ts @@ -287,6 +287,40 @@ describe("GET /api/integrations/healthkit — sync health", () => { expect((await read()).syncHealth.verdict).toBe("pending_first_sync"); }); + // The column only moves on a live batch. Rows that reached the account any + // other way (the export.zip import, a batch before the column existed) left + // it null, and the card said "waiting for first data" beside a four-digit + // record count. Arrival is a data question: any Apple Health row settles it. + it("is not pending when the per-type ledger records an arrival", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + healthKitConfigJson: null, + healthKitLastSyncedAt: null, + } as never); + vi.mocked(prisma.healthKitTypeSync.findMany).mockResolvedValue([ + { + type: "PULSE", + lastReceivedAt: ago(2 * DAY), + lastTrigger: null, + lastNewSampleAt: null, + }, + ] as never); + expect((await read()).syncHealth.verdict).toBe("fresh"); + }); + + it("is not pending when Apple Health rows exist without any sync stamp", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + healthKitConfigJson: null, + healthKitLastSyncedAt: null, + } as never); + vi.mocked(listLiveMeasurementTypes).mockResolvedValue(["PULSE"]); + vi.mocked(prisma.$queryRaw).mockResolvedValue([ + { type: "PULSE", last_seen: ago(DAY) }, + ] as never); + const body = await read(); + expect(body.syncHealth.verdict).toBe("fresh"); + expect(body.metricFreshness.map((e) => e.stale)).toEqual([false]); + }); + it("flags one quiet metric inside an otherwise healthy connection", async () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ healthKitConfigJson: null, diff --git a/src/app/api/integrations/healthkit/route.ts b/src/app/api/integrations/healthkit/route.ts index e104168ca..886e489ea 100644 --- a/src/app/api/integrations/healthkit/route.ts +++ b/src/app/api/integrations/healthkit/route.ts @@ -23,6 +23,7 @@ import { auditLog } from "@/lib/auth/audit"; import { getAppleHealthSyncProgress } from "@/lib/integrations/apple-health-progress"; import { getHealthKitTypeArrivals, + newestAppleHealthDataAt, withHealthKitArrivals, } from "@/lib/integrations/healthkit-type-sync"; import { getSourceFreshness } from "@/lib/integrations/metric-freshness"; @@ -162,21 +163,9 @@ export const GET = apiHandler(async () => { // three-week-dead pipe rendered the same green as one that delivered an hour // ago. Fail-soft on the per-metric read — an honesty signal is never worth // 500-ing the config the iOS client needs. - // No `connected` hint: Apple Health has nothing to connect or disconnect, so - // "never delivered" resolves to `pending_first_sync`, not `disconnected`. - const syncHealth = resolveSyncVerdict({ - configured: true, - lastDataAt: lastSyncedAt, - legacyLastSyncedAt: lastSyncedAt, - cadence: PUSH_CADENCE, - }); // #1173 — when each type last ARRIVED and under which trigger, beside when // its newest sample was taken. Fail-soft to null, which leaves the entries // without arrival fields rather than reporting every type as never received. - const [samples, arrivals] = await Promise.all([ - getSourceFreshness(user.id, "APPLE_HEALTH").catch(() => []), - getHealthKitTypeArrivals(user.id).catch(() => null), - ]); // Issue #778 — a first-run backfill used to be invisible from the web: the // card said "last sync 2 minutes ago" while giving no sense of how much had // arrived or how far back it reached. These two figures (row count + oldest @@ -184,9 +173,23 @@ export const GET = apiHandler(async () => { // progress; the phone's queue and throttle state stay on the phone and are // not guessed at. Fail-soft for the same reason the freshness read is — // a progress line is never worth 500-ing the config the iOS client needs. - const syncProgress = await getAppleHealthSyncProgress(user.id).catch( - () => null, - ); + const [samples, arrivals, syncProgress] = await Promise.all([ + getSourceFreshness(user.id, "APPLE_HEALTH").catch(() => []), + getHealthKitTypeArrivals(user.id).catch(() => null), + getAppleHealthSyncProgress(user.id).catch(() => null), + ]); + // No `connected` hint: Apple Health has nothing to connect or disconnect, so + // "never delivered" resolves to `pending_first_sync`, not `disconnected`. + // "Delivered" is read off the data, not only off the user column: that + // column moves on a live batch alone, so rows from the export.zip import + // left it null and the card said "waiting for first data" beside a + // four-digit record count. + const syncHealth = resolveSyncVerdict({ + configured: true, + lastDataAt: newestAppleHealthDataAt(lastSyncedAt, arrivals, samples), + legacyLastSyncedAt: lastSyncedAt, + cadence: PUSH_CADENCE, + }); return apiSuccess({ entries, diff --git a/src/app/c/[token]/not-found.tsx b/src/app/c/[token]/not-found.tsx new file mode 100644 index 000000000..9aa8e7138 --- /dev/null +++ b/src/app/c/[token]/not-found.tsx @@ -0,0 +1,40 @@ +import { LinkIcon } from "lucide-react"; + +import { resolveShareViewLocale } from "@/lib/clinician-share/request-locale"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; + +/** + * What a practice sees when a shared link no longer opens: expired, revoked, + * mistyped. The page still answers 404 for every failure class alike (the + * share page's `notFound()`), so a probe learns nothing from it; what changes + * is the copy. The app's own 404 said "this page does not exist" and offered + * a dashboard the reader has no account for. This one says what happened in + * the reader's terms and what to do, and links nowhere. + */ +export default async function ClinicianShareNotFound() { + const locale = await resolveShareViewLocale(); + const { t } = getServerTranslator(locale); + + return ( +

+ +
+

+ {t("clinicianView.unavailable.title")} +

+

+ {t("clinicianView.unavailable.description")} +

+
+
+ ); +} diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx index 32f0e0ce8..c12927c9c 100644 --- a/src/app/coach/conversations/page.tsx +++ b/src/app/coach/conversations/page.tsx @@ -331,7 +331,7 @@ export default function CoachConversationsPage() { // ~50px on notched devices; restored to match exactly. v1.42 — the // shell now takes that inset above the top bar at every width, so // the desktop height subtracts it too, and `/coach/plans` matches. - className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-top,0px)-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem-env(safe-area-inset-top,0px))]" + className="bg-background shell-desktop:h-[calc(100dvh-4rem-env(safe-area-inset-top,0px))] -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-top,0px)-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6" > diff --git a/src/app/coach/plans/page.tsx b/src/app/coach/plans/page.tsx index b02fddcdc..4df3e06b7 100644 --- a/src/app/coach/plans/page.tsx +++ b/src/app/coach/plans/page.tsx @@ -296,7 +296,7 @@ export default function CoachPlansPage() { // mobile-only BottomNav band). The status-bar inset of an installed // app comes off as well, at every width: the shell takes it above // the top bar (`shell-safe-area`). - className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-top,0px)-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem-env(safe-area-inset-top,0px))]" + className="bg-background shell-desktop:h-[calc(100dvh-4rem-env(safe-area-inset-top,0px))] -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-top,0px)-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6" > diff --git a/src/app/globals.css b/src/app/globals.css index 63361cc9f..9f8b8e3b3 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -58,6 +58,30 @@ } } +/* + * `shell-desktop:` / `shell-mobile:` — which app shell the viewport gets. + * The desktop rail (sidebar, no bottom nav) from `md` up, EXCEPT a touch + * screen at most 520 px tall: a phone held sideways is 844–932 px wide but + * only 390–430 px tall, and the rail plus the top bar left it a sliver of + * content and no thumb-reachable navigation. It keeps the phone shell (top + * bar, bottom nav, capture button) instead. Everything that switches + * between the two shells keys on these, never on a bare `md:`, so the bars + * cannot disagree about which shell is showing. + */ +@custom-variant shell-desktop { + @media (min-width: 48rem) and (pointer: fine), + (min-width: 48rem) and (pointer: none), + (min-width: 48rem) and (min-height: 521px) { + @slot; + } +} + +@custom-variant shell-mobile { + @media (max-width: 47.98rem), (pointer: coarse) and (max-height: 520px) { + @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 @@ -82,6 +106,9 @@ --color-chart-1: var(--chart-1); --color-ring: var(--ring); --color-input: var(--input); + --color-switch-off: var(--switch-off); + --color-primary-disabled: var(--primary-disabled); + --color-primary-disabled-foreground: var(--primary-disabled-foreground); --color-input-focus: var(--input-focus); --color-border: var(--border); --color-destructive: var(--destructive); @@ -268,6 +295,19 @@ --destructive: #ff5555; --border: #44475a; --input: #44475a; + /* An off switch's track, and a disabled primary button. Dark keeps the + * look these had (input at 80 %, primary at half strength). */ + --switch-off: color-mix(in oklab, var(--input) 80%, transparent); + --primary-disabled: color-mix( + in oklab, + var(--primary) 50%, + var(--background) + ); + --primary-disabled-foreground: color-mix( + in oklab, + var(--primary-foreground) 70%, + var(--primary-disabled) + ); /* Text-field focus border: a neutral, foreground-based tone, no colour * ring. ~5.2:1 on --background, ~6:1 on --card. */ --input-focus: #9a9b9d; @@ -399,6 +439,15 @@ --success-foreground: #ffffff; --border: oklch(0.885 0.008 300); --input: oklch(0.87 0.009 300); + /* An off switch's track: the input grey sat at ~1.4:1 on the card, so an + * off switch all but vanished. ~3.5:1 on --card clears the 3:1 floor for + * a control boundary. */ + --switch-off: oklch(0.62 0.012 295); + /* A disabled primary button: opacity-50 left white text on a washed + * violet at ~2:1. A pale violet fill with a dark violet label (~5.5:1) + * still reads as the primary action, and as unavailable. */ + --primary-disabled: oklch(0.88 0.035 290); + --primary-disabled-foreground: oklch(0.44 0.06 290); /* Text-field focus border, neutral: ~4.2:1 on --background. */ --input-focus: oklch(0.56 0.012 295); --ring: oklch(0.48 0.012 290); /* neutral: no purple focus frames anywhere */ diff --git a/src/app/insights/active-energy/page.tsx b/src/app/insights/active-energy/page.tsx index 933931442..8bc6d5249 100644 --- a/src/app/insights/active-energy/page.tsx +++ b/src/app/insights/active-energy/page.tsx @@ -22,7 +22,7 @@ export default function InsightsActiveEnergyPage() { chartKey="activeEnergy" i18nPrefix="insights.activeEnergy" explainerMetric="activeEnergy" - color="var(--warning)" + color="var(--chart-5)" unit="kcal" yAxisUnit="kcal" emptyStateIcon={} diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx index 1a5782055..b8e921f9d 100644 --- a/src/app/insights/blood-pressure/page.tsx +++ b/src/app/insights/blood-pressure/page.tsx @@ -60,7 +60,7 @@ export default function InsightsBlutdruckPage() { // v1.12.8 — chart-reactive metric statistics. Blood pressure tracks BOTH // series at once: the single chart reports per-type visible-range stats and // each strip column reads its own half. - const { statsByType, onVisibleStats } = useChartDomainStats(); + const { statsByType, statsSettled, onVisibleStats } = useChartDomainStats(); const bpTargets = useBpTargetBand(); if (isEmpty) { @@ -101,10 +101,10 @@ export default function InsightsBlutdruckPage() { { min: bpTargets.diastolic.min, max: bpTargets.diastolic.max, - color: "var(--info)", + color: "var(--chart-4)", opacity: 0.21, label: t("charts.diastolic"), - textColor: "var(--info)", + textColor: "var(--chart-4)", lineOpacity: 0.24, }, ] @@ -142,6 +142,7 @@ export default function InsightsBlutdruckPage() { seriesLabel: t("charts.systolic"), icon: ArrowUpRight, windowStats: statsByType?.BLOOD_PRESSURE_SYS ?? null, + windowPending: !statsSettled, }, { dataKey: "dia", @@ -151,6 +152,7 @@ export default function InsightsBlutdruckPage() { seriesLabel: t("charts.diastolic"), icon: ArrowDownRight, windowStats: statsByType?.BLOOD_PRESSURE_DIA ?? null, + windowPending: !statsSettled, }, ]} /> @@ -170,7 +172,7 @@ export default function InsightsBlutdruckPage() { types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]} title={t("charts.bloodPressure")} titleIcon={HeartPulse} - colors={["var(--chart-3)", "var(--info)"]} + colors={["var(--chart-3)", "var(--chart-4)"]} unit="mmHg" yAxisUnit="mmHg" targetZones={bpTargetZones} diff --git a/src/app/insights/bmi/page.tsx b/src/app/insights/bmi/page.tsx index cdc5ea3c6..f9692a6b6 100644 --- a/src/app/insights/bmi/page.tsx +++ b/src/app/insights/bmi/page.tsx @@ -86,7 +86,6 @@ export default function InsightsBmiPage() { icon={} title={t("insights.bmiEmptyTitle")} description={t("insights.bmiEmptyDescription")} - ctaSize="lg" action={ + ) : undefined + } + /> ) : !hasData ? (
{t("charts.noData")} diff --git a/src/components/cycle/log-day-sheet.tsx b/src/components/cycle/log-day-sheet.tsx index b15617d7a..94a273ec6 100644 --- a/src/components/cycle/log-day-sheet.tsx +++ b/src/components/cycle/log-day-sheet.tsx @@ -1465,7 +1465,7 @@ export function SymptomRow({ onClick={() => setConfirmRemove(true)} disabled={deleteCustom.isPending} aria-label={t("cycle.symptom.custom.remove", { label })} - className="text-muted-foreground hover:text-destructive hover:bg-accent focus-visible:ring-ring/50 grid size-11 shrink-0 place-items-center rounded-md transition-colors focus-visible:ring-2 focus-visible:outline-none sm:size-9" + className="text-muted-foreground hover:text-foreground hover:bg-accent focus-visible:ring-ring/50 grid size-11 shrink-0 place-items-center rounded-md transition-colors focus-visible:ring-2 focus-visible:outline-none sm:size-9" >
@@ -1072,7 +1075,6 @@ export function DocumentsView() { icon={} title={t("documents.empty.title")} description={t("documents.empty.description")} - ctaSize="lg" action={ canManageDocuments ? ( - {addButton} + {/* The empty state carries the add action while there is no + visit. */} + {!list.isPending && !list.isError && empty ? null : addButton} } /> @@ -113,7 +115,6 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) { title={t("encounters.emptyTitle")} description={t("encounters.emptyDescription")} action={addButton ?? undefined} - ctaSize="lg" /> ) : (
diff --git a/src/components/illness/episode-menu.tsx b/src/components/illness/episode-menu.tsx index e938f9336..6cfa233c5 100644 --- a/src/components/illness/episode-menu.tsx +++ b/src/components/illness/episode-menu.tsx @@ -106,7 +106,6 @@ export function EpisodeMenu({ ) : null} { e.preventDefault(); setConfirmOpen(true); diff --git a/src/components/illness/illness-episode-detail.tsx b/src/components/illness/illness-episode-detail.tsx index 6ce6e29cf..91e36a332 100644 --- a/src/components/illness/illness-episode-detail.tsx +++ b/src/components/illness/illness-episode-detail.tsx @@ -21,6 +21,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { PageHeader } from "@/components/ui/page-header"; import { Skeleton } from "@/components/ui/skeleton"; +import { RevealWhenSettled } from "@/components/ui/reveal-when-settled"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { bodySiteText } from "@/components/encounters/encounter-labels"; import { canReadBodySites } from "@/hooks/use-body-sites"; @@ -154,49 +155,56 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) { } /> - {episode?.note ? ( - - -

- {t("illness.detail.note")} -

-

- {episode.note} -

-
-
- ) : null} + {/* Every card below has a data-shaped height (the day timeline above + all). Revealed together once the reads settle, so none of them + pushes the rest down while the page loads. */} + } + > + {episode?.note ? ( + + +

+ {t("illness.detail.note")} +

+

+ {episode.note} +

+
+
+ ) : null} - {/* The per-day timeline lives only on the detail surface — the list - rows stay clean summaries, so navigating here reveals genuinely new - content (today's logged symptoms / impact / fever). */} - {episode ? ( - setLogOpen(true)} - /> - ) : null} + {/* The per-day timeline lives only on the detail surface — the list + rows stay clean summaries, so navigating here reveals genuinely new + content (today's logged symptoms / impact / fever). */} + {episode ? ( + setLogOpen(true)} + /> + ) : null} - {/* v1.40 — the person's own symptoms filed against this episode or - logged while it ran. Renders nothing when there are none. */} - {episode ? : null} + {/* v1.40 — the person's own symptoms filed against this episode or + logged while it ran. Renders nothing when there are none. */} + {episode ? : null} - {isLoading ? ( - - ) : ( - - )} + {isLoading ? ( + + ) : ( + + )} - {/* Condition ⇄ documents linking, condition side: the episode's - linked documents plus link/upload entries into the vault. Renders - nothing when the documents module is off for this account. */} - {episode ? : null} + {/* Condition ⇄ documents linking, condition side: the episode's + linked documents plus link/upload entries into the vault. Renders + nothing when the documents module is off for this account. */} + {episode ? : null} - {/* Condition ⇄ visits linking, condition side ONLY. A visit never - suggests an episode: an episode spans weeks and a visit is a point, - so the reverse match would be wrong often enough to train the person - to ignore every suggestion in the product. */} - {episode ? : null} + {/* Condition ⇄ visits linking, condition side ONLY. A visit never + suggests an episode: an episode spans weeks and a visit is a point, + so the reverse match would be wrong often enough to train the person + to ignore every suggestion in the product. */} + {episode ? : null} +
{episode ? ( <> diff --git a/src/components/illness/illness-view.tsx b/src/components/illness/illness-view.tsx index 0ef5b8f1f..4133f5ecf 100644 --- a/src/components/illness/illness-view.tsx +++ b/src/components/illness/illness-view.tsx @@ -432,7 +432,9 @@ export function IllnessView() { )} {/* v1.18.6 (MOD-02) — the add button reads "hinzufügen" like every other module, not the bespoke "neue Episode". */} - {canAddEpisode && ( + {/* Hidden while there is no episode: the empty state below + carries the add action then. */} + {canAddEpisode && (isLoading || isError || hasEpisodes) && ( ` @@ -64,7 +64,6 @@ export function MetricEmptyState({ icon={icon} title={title} description={description} - ctaSize="lg" action={cta} /> diff --git a/src/components/insights/metric-stat-strip.tsx b/src/components/insights/metric-stat-strip.tsx index d9682e215..63d68803c 100644 --- a/src/components/insights/metric-stat-strip.tsx +++ b/src/components/insights/metric-stat-strip.tsx @@ -118,6 +118,13 @@ export interface MetricStatSeries { * `h1 → h2` outline under `SubPageShell` stays nested with no skipped level. */ titleAs?: "h2"; + /** + * The chart this strip reads has not reported its window yet. The cells + * hold their place instead of painting the full-range summary: the chart + * opens on a narrower window, so the all-time numbers would be replaced a + * beat later and every value would jump. + */ + windowPending?: boolean; } type MetricStatStripProps = Partial & { @@ -165,6 +172,7 @@ function SeriesBlock({ medianLabel, description, titleAs, + windowPending = false, }: MetricStatSeries) { const { t } = useTranslations(); const fmt = useFormatters(); @@ -181,6 +189,7 @@ function SeriesBlock({ // chart's range tab is the single, visible selector for both. const windowed = windowStats != null && windowStats.count > 0; const source = windowed ? windowStats : summary; + const holding = windowPending && !windowed; const format = (value: number | null): string => value === null ? "—" : `${fmt.number(value, fractionDigits)} ${unit}`; @@ -205,6 +214,7 @@ function SeriesBlock({ data-slot="metric-stat-series" data-series={dataKey} data-windowed={windowed ? "true" : undefined} + data-window-pending={holding ? "true" : undefined} className="space-y-1.5" > {seriesLabel ? ( @@ -228,9 +238,13 @@ function SeriesBlock({

{cell.label}

-

- {format(cell.value)} -

+ {holding ? ( + + ) : ( +

+ {format(cell.value)} +

+ )}
))} @@ -249,6 +263,7 @@ export function MetricStatStrip({ medianLabel, description, titleAs, + windowPending, series, groupLabel, pending = false, @@ -318,6 +333,7 @@ export function MetricStatStrip({ medianLabel={medianLabel} description={description} titleAs={titleAs} + windowPending={windowPending} /> diff --git a/src/components/insights/mood/mood-better-days.tsx b/src/components/insights/mood/mood-better-days.tsx index 07b507214..55738c513 100644 --- a/src/components/insights/mood/mood-better-days.tsx +++ b/src/components/insights/mood/mood-better-days.tsx @@ -2,7 +2,7 @@ import { ArrowDownRight, ArrowUpRight } from "lucide-react"; -import { useTranslations } from "@/lib/i18n/context"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { moodTagIcon } from "@/components/mood/mood-tag-icons"; import { cn } from "@/lib/utils"; import type { MoodInfluenceConfidence } from "./mood-tag-influence"; @@ -70,6 +70,7 @@ export function MoodBetterDays({ factors: MoodBetterDayFactor[]; }) { const { t } = useTranslations(); + const nf = useFormatters(); if (factors.length === 0) return null; return ( @@ -108,9 +109,9 @@ export function MoodBetterDays({ // metric factors show the correlation coefficient. const effectText = factor.source === "tag" && factor.delta != null - ? `${factor.delta >= 0 ? "+" : ""}${factor.delta.toFixed(1)}` + ? `${factor.delta >= 0 ? "+" : ""}${nf.number(factor.delta, 1)}` : factor.r != null - ? `r ${factor.r.toFixed(2)}` + ? `r ${nf.number(factor.r, 2)}` : ""; return ( diff --git a/src/components/insights/mood/mood-context-comparison.tsx b/src/components/insights/mood/mood-context-comparison.tsx index 3df6ff9ba..4bcf8a63e 100644 --- a/src/components/insights/mood/mood-context-comparison.tsx +++ b/src/components/insights/mood/mood-context-comparison.tsx @@ -16,7 +16,7 @@ * them; a second threshold in the presentation layer would be a second place * to get the statistics wrong. */ -import { useTranslations } from "@/lib/i18n/context"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { contextValueLabelKey } from "@/lib/mood/context-vocabulary"; import type { ContextComparisonRow } from "@/lib/insights/mood-context-crosstab"; @@ -26,6 +26,7 @@ export function MoodContextComparison({ rows: ContextComparisonRow[]; }) { const { t } = useTranslations(); + const nf = useFormatters(); if (rows.length === 0) return null; return ( @@ -50,8 +51,8 @@ export function MoodContextComparison({ {t("insights.mood.contextComparison.statement", { label, direction, - withAvg: row.withAvg.toFixed(1), - withoutAvg: row.withoutAvg.toFixed(1), + withAvg: nf.number(row.withAvg, 1), + withoutAvg: nf.number(row.withoutAvg, 1), })}

{/* The counts ride with the statement, in the same block a diff --git a/src/components/insights/mood/mood-correlation-cards.tsx b/src/components/insights/mood/mood-correlation-cards.tsx index 804bf52ff..36c07db54 100644 --- a/src/components/insights/mood/mood-correlation-cards.tsx +++ b/src/components/insights/mood/mood-correlation-cards.tsx @@ -8,7 +8,7 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { TileHeader } from "@/components/insights/tile-header"; import { EmptyState } from "@/components/ui/empty-state"; import { Skeleton } from "@/components/ui/skeleton"; -import { useTranslations } from "@/lib/i18n/context"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; import type { CorrelationResult } from "@/lib/analytics/correlations"; import { MoodExplainerIcon } from "./mood-explainer-icon"; @@ -106,6 +106,7 @@ function MoodCorrelationCard({ className?: string; }) { const { t } = useTranslations(); + const nf = useFormatters(); const hasResult = data.result != null; // Three different silences, and the reader deserves to know which one they @@ -164,7 +165,7 @@ function MoodCorrelationCard({ label={t("insights.mood.correlation.sourceLabel")} detail={t("insights.mood.correlation.source", { n: data.n, - r: data.result.r.toFixed(2), + r: nf.number(data.result.r, 2), })} /> @@ -179,7 +180,7 @@ function MoodCorrelationCard({ label={t("insights.mood.correlation.sourceLabel")} detail={t("insights.mood.correlation.source", { n: data.n, - r: data.result.r.toFixed(2), + r: nf.number(data.result.r, 2), })} /> ) : null} diff --git a/src/components/insights/mood/mood-discovered-relations.tsx b/src/components/insights/mood/mood-discovered-relations.tsx index 53bca5e81..4a9adf235 100644 --- a/src/components/insights/mood/mood-discovered-relations.tsx +++ b/src/components/insights/mood/mood-discovered-relations.tsx @@ -1,6 +1,6 @@ "use client"; -import { useTranslations } from "@/lib/i18n/context"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { MoodExplainerIcon } from "./mood-explainer-icon"; import { PatternDismissButton, @@ -94,6 +94,7 @@ export function MoodDiscoveredRelations({ pairsTested: number; }) { const { t } = useTranslations(); + const nf = useFormatters(); const dismissal = usePatternDismissalOverrides(); if (pairs.length === 0) return null; @@ -169,8 +170,8 @@ export function MoodDiscoveredRelations({ label={t("insights.mood.discovery.statLabel")} detail={t("insights.mood.discovery.stat", { n: pair.n, - r: pair.r.toFixed(2), - q: pair.qValue.toFixed(3), + r: nf.number(pair.r, 2), + q: nf.number(pair.qValue, 3), })} /> diff --git a/src/components/insights/mood/mood-factor-metric-crosstab.tsx b/src/components/insights/mood/mood-factor-metric-crosstab.tsx index 65d1b5a4e..6cd598af6 100644 --- a/src/components/insights/mood/mood-factor-metric-crosstab.tsx +++ b/src/components/insights/mood/mood-factor-metric-crosstab.tsx @@ -1,6 +1,7 @@ "use client"; -import { useTranslations } from "@/lib/i18n/context"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import type { Formatters } from "@/lib/format-locale"; import { useUnitDisplay } from "@/hooks/use-unit-display"; import { moodTagIcon } from "@/components/mood/mood-tag-icons"; import { cn } from "@/lib/utils"; @@ -84,8 +85,12 @@ const CONFIDENCE_CLASS: Record = { }; /** Whole numbers for steps; one decimal otherwise. */ -function fmt(value: number, display: MoodFactorCrosstabDisplay): string { - return display === "steps" ? Math.round(value).toString() : value.toFixed(1); +function formatValue( + value: number, + display: MoodFactorCrosstabDisplay, + nf: Formatters, +): string { + return display === "steps" ? nf.integer(value) : nf.number(value, 1); } export function MoodFactorMetricCrosstab({ @@ -94,6 +99,7 @@ export function MoodFactorMetricCrosstab({ rows: MoodFactorMetricCrosstabRow[]; }) { const { t } = useTranslations(); + const nf = useFormatters(); const unitDisplay = useUnitDisplay(); const dismissal = usePatternDismissalOverrides(); if (rows.length === 0) return null; @@ -143,7 +149,7 @@ export function MoodFactorMetricCrosstab({ // green/red would imply a health verdict the data doesn't support. // The sign prefix + `data-direction` carry the direction. const up = row.delta >= 0; - const deltaText = `${up ? "+" : ""}${fmt(conv(row.delta), row.display)} ${unit}`; + const deltaText = `${up ? "+" : ""}${formatValue(conv(row.delta), row.display, nf)} ${unit}`; return (
  • Math.max(m, row.count), 0) || 1; return ( @@ -74,7 +75,7 @@ export function MoodStructuredTagBreakdown({ style={{ color: colorForScore(row.avgScore) }} title={t("insights.mood.tagAvgMood")} > - {row.avgScore.toFixed(1)} + {nf.number(row.avgScore, 1)}
  • ); diff --git a/src/components/insights/mood/mood-tag-breakdown.tsx b/src/components/insights/mood/mood-tag-breakdown.tsx index 2e4c3d5bc..53e63793e 100644 --- a/src/components/insights/mood/mood-tag-breakdown.tsx +++ b/src/components/insights/mood/mood-tag-breakdown.tsx @@ -1,6 +1,6 @@ "use client"; -import { useTranslations } from "@/lib/i18n/context"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; /** * v1.8.5 — tag / trigger breakdown. @@ -30,6 +30,7 @@ function colorForScore(score: number): string { export function MoodTagBreakdown({ tags }: { tags: MoodTagRow[] }) { const { t } = useTranslations(); + const nf = useFormatters(); const maxCount = tags.reduce((m, row) => Math.max(m, row.count), 0) || 1; return ( @@ -61,7 +62,7 @@ export function MoodTagBreakdown({ tags }: { tags: MoodTagRow[] }) { style={{ color: colorForScore(row.avgScore) }} title={t("insights.mood.tagAvgMood")} > - {row.avgScore.toFixed(1)} + {nf.number(row.avgScore, 1)} ))} diff --git a/src/components/insights/mood/mood-tag-influence.tsx b/src/components/insights/mood/mood-tag-influence.tsx index 619c2d699..4db56866a 100644 --- a/src/components/insights/mood/mood-tag-influence.tsx +++ b/src/components/insights/mood/mood-tag-influence.tsx @@ -1,6 +1,6 @@ "use client"; -import { useTranslations } from "@/lib/i18n/context"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { moodTagIcon } from "@/components/mood/mood-tag-icons"; import { cn } from "@/lib/utils"; @@ -62,6 +62,7 @@ function deltaColor(delta: number): string { export function MoodTagInfluence({ rows }: { rows: MoodTagInfluenceRow[] }) { const { t } = useTranslations(); + const nf = useFormatters(); if (rows.length === 0) return null; return ( @@ -71,7 +72,7 @@ export function MoodTagInfluence({ rows }: { rows: MoodTagInfluenceRow[] }) { const Icon = row.labelKey ? moodTagIcon(row.icon) : null; const label = row.label ?? (row.labelKey ? t(row.labelKey) : row.tag); const up = row.delta >= 0; - const deltaText = `${up ? "+" : ""}${row.delta.toFixed(1)}`; + const deltaText = `${up ? "+" : ""}${nf.number(row.delta, 1)}`; return (
  • {t("insights.mood.influence.detail", { - withAvg: row.withAvg.toFixed(1), - withoutAvg: row.withoutAvg.toFixed(1), + withAvg: nf.number(row.withAvg, 1), + withoutAvg: nf.number(row.withoutAvg, 1), withDays: row.withDays, })}

    diff --git a/src/components/insights/mood/mood-tag-metric-crosstab.tsx b/src/components/insights/mood/mood-tag-metric-crosstab.tsx index 9263fbaee..60d926a38 100644 --- a/src/components/insights/mood/mood-tag-metric-crosstab.tsx +++ b/src/components/insights/mood/mood-tag-metric-crosstab.tsx @@ -1,6 +1,7 @@ "use client"; -import { useTranslations } from "@/lib/i18n/context"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import type { Formatters } from "@/lib/format-locale"; import { moodTagIcon } from "@/components/mood/mood-tag-icons"; import { cn } from "@/lib/utils"; import type { MoodInfluenceConfidence } from "./mood-tag-influence"; @@ -92,8 +93,12 @@ const CONFIDENCE_CLASS: Record = { }; /** One decimal for hours/score, whole numbers for kcal. */ -function fmt(value: number, display: MoodCrosstabDisplay): string { - return display === "kcal" ? Math.round(value).toString() : value.toFixed(1); +function formatValue( + value: number, + display: MoodCrosstabDisplay, + nf: Formatters, +): string { + return display === "kcal" ? nf.integer(value) : nf.number(value, 1); } export function MoodTagMetricCrosstab({ @@ -102,6 +107,7 @@ export function MoodTagMetricCrosstab({ rows: MoodTagMetricCrosstabRow[]; }) { const { t } = useTranslations(); + const nf = useFormatters(); const dismissal = usePatternDismissalOverrides(); if (rows.length === 0) return null; @@ -135,7 +141,7 @@ export function MoodTagMetricCrosstab({ ); const unit = t(UNIT_KEY[row.display]); const up = row.delta >= 0; - const deltaText = `${up ? "+" : ""}${fmt(row.delta, row.display)} ${unit}`; + const deltaText = `${up ? "+" : ""}${formatValue(row.delta, row.display, nf)} ${unit}`; return (
  • ({ bucket: row.bucket, @@ -125,7 +126,7 @@ export function MoodTimeOfDayChart({ return [t("insights.mood.weekdayNoData"), ""]; } return [ - `${payload.avgScore.toFixed(1)} (${payload.count})`, + `${nf.number(payload.avgScore, 1)} (${payload.count})`, t("insights.mood.timeOfDay.title"), ]; }} diff --git a/src/components/insights/mood/mood-weekday-chart.tsx b/src/components/insights/mood/mood-weekday-chart.tsx index a0d69225f..ef53ee877 100644 --- a/src/components/insights/mood/mood-weekday-chart.tsx +++ b/src/components/insights/mood/mood-weekday-chart.tsx @@ -12,7 +12,7 @@ import { YAxis, } from "recharts"; -import { useTranslations } from "@/lib/i18n/context"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; /** * v1.8.5 — average mood per weekday (Monday-aligned). @@ -50,6 +50,7 @@ function colorForScore(score: number): string { export function MoodWeekdayChart({ weekday }: { weekday: MoodWeekdayRow[] }) { const { t } = useTranslations(); + const nf = useFormatters(); const data = weekday.map((row) => ({ weekday: row.weekday, @@ -134,7 +135,7 @@ export function MoodWeekdayChart({ weekday }: { weekday: MoodWeekdayRow[] }) { return [t("insights.mood.weekdayNoData"), ""]; } return [ - `${payload.avgScore.toFixed(1)} (${payload.count})`, + `${nf.number(payload.avgScore, 1)} (${payload.count})`, t("insights.mood.weekdayTitle"), ]; }} diff --git a/src/components/insights/recovery/recovery-metric-block.tsx b/src/components/insights/recovery/recovery-metric-block.tsx index 262771972..6e7cc7aab 100644 --- a/src/components/insights/recovery/recovery-metric-block.tsx +++ b/src/components/insights/recovery/recovery-metric-block.tsx @@ -67,7 +67,7 @@ export function RecoveryMetricBlock({ }: RecoveryMetricBlockProps) { const { user, isAuthenticated } = useAuth(); const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated); - const { statsByType, onVisibleStats } = useChartDomainStats(); + const { statsByType, statsSettled, onVisibleStats } = useChartDomainStats(); return (
    @@ -86,6 +86,7 @@ export function RecoveryMetricBlock({ titleAs="h2" description={explainer} windowStats={statsByType?.[type] ?? null} + windowPending={!statsSettled} /> 0) { - return `${h}h ${m.toString().padStart(2, "0")}m ${s.toString().padStart(2, "0")}s`; - } - return `${m}m ${s.toString().padStart(2, "0")}s`; +/** + * A workout's length as a clock reading with its unit, the way a sports + * watch shows it: "38:00 Min." / "1:02:05 Std." in German, "38:00 min" / + * "1:02:05 h" in English. The old "38m 00s" was English in every locale. + */ +export function formatDuration( + seconds: number, + t: (key: string, params?: Record) => string, +): string { + const total = Math.max(0, Math.round(seconds)); + const h = Math.floor(total / 3600); + const m = Math.floor((total % 3600) / 60); + const s = total % 60; + const ss = s.toString().padStart(2, "0"); + return h > 0 + ? t("common.durationClockHours", { + time: `${h}:${m.toString().padStart(2, "0")}:${ss}`, + }) + : t("common.durationClockMinutes", { time: `${m}:${ss}` }); } /** Compact "34 min" style duration for the sport-average comparison line. */ @@ -95,7 +106,7 @@ export function formatPace( } /** - * "m:ss /km" from a seconds-per-unit value. The splits table passes "km" + * "m:ss min/km" from a seconds-per-unit value. The splits table passes "km" * explicitly: its rows are kilometre segments cut server-side, so their * pace is per kilometre whatever the preference. */ @@ -107,7 +118,7 @@ export function formatPaceSeconds(secPerUnit: number, unit: string): string { m += 1; s = 0; } - return `${m}:${s.toString().padStart(2, "0")} /${unit}`; + return `${m}:${s.toString().padStart(2, "0")} min/${unit}`; } export function formatDateRange( diff --git a/src/components/insights/workout-detail/header.tsx b/src/components/insights/workout-detail/header.tsx index 4a54a9bea..1e4200f0b 100644 --- a/src/components/insights/workout-detail/header.tsx +++ b/src/components/insights/workout-detail/header.tsx @@ -1,6 +1,7 @@ "use client"; import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context"; +import { MEASUREMENT_SOURCE_LIST_LABEL_KEYS } from "@/lib/i18n/source-labels"; import { iconForSport } from "@/lib/workouts/sport-icons"; import type { WorkoutDetailPayload } from "@/hooks/use-workouts"; import { useUnitDisplay } from "@/hooks/use-unit-display"; @@ -48,7 +49,15 @@ export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) { {renderSportIconBadge(workout.sportType)}

    - {workout.source} + {MEASUREMENT_SOURCE_LIST_LABEL_KEYS[ + workout.source as keyof typeof MEASUREMENT_SOURCE_LIST_LABEL_KEYS + ] + ? t( + MEASUREMENT_SOURCE_LIST_LABEL_KEYS[ + workout.source as keyof typeof MEASUREMENT_SOURCE_LIST_LABEL_KEYS + ], + ) + : workout.source}

    {sportName} @@ -65,7 +74,7 @@ export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {

    - {formatDuration(workout.durationSec)} + {formatDuration(workout.durationSec, t)} {workout.distanceM != null ? ( diff --git a/src/components/insights/workout-detail/stats.tsx b/src/components/insights/workout-detail/stats.tsx index 8d2ab065a..36002d3bd 100644 --- a/src/components/insights/workout-detail/stats.tsx +++ b/src/components/insights/workout-detail/stats.tsx @@ -93,7 +93,7 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) { tiles.push({ icon: , label: t("insights.workouts.detail.statsDuration"), - value: formatDuration(workout.durationSec), + value: formatDuration(workout.durationSec, t), }); if (workout.distanceM != null && workout.distanceM > 0) { diff --git a/src/components/insights/workout-list.tsx b/src/components/insights/workout-list.tsx index c39431619..913a19846 100644 --- a/src/components/insights/workout-list.tsx +++ b/src/components/insights/workout-list.tsx @@ -6,6 +6,7 @@ import { HeartPulse, Loader2, Map as MapIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache"; +import { formatDurationMinutes } from "@/lib/i18n/duration"; import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context"; import { hourCycleOptions, @@ -29,13 +30,6 @@ import { formatDistance } from "@/components/insights/workout-detail/format"; * lands here. */ -function formatDuration(seconds: number): string { - const h = Math.floor(seconds / 3600); - const m = Math.floor((seconds % 3600) / 60); - if (h > 0) return `${h}h ${m.toString().padStart(2, "0")}m`; - return `${m}m`; -} - function formatEnergy(kcal: number, locale: string): string { return getNumberFormat(locale, { maximumFractionDigits: 0, @@ -143,7 +137,9 @@ export function WorkoutList({ /> ) : null} - + {/* Wraps rather than truncates, so the day it opens + stays readable beside the stats column. */} + {formatDate(workout.startedAt, locale, timeFormat)} @@ -151,7 +147,10 @@ export function WorkoutList({
    - {formatDuration(workout.durationSec)} + {formatDurationMinutes( + Math.round(workout.durationSec / 60), + t, + )} {workout.distanceM != null ? ( diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index 668ca4813..934bb138a 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -1,7 +1,7 @@ "use client"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; -import { useMemo } from "react"; +import { useMemo, useEffect } from "react"; import Link from "next/link"; import { useQuery } from "@tanstack/react-query"; import { ChevronRight, FlaskConical } from "lucide-react"; @@ -136,7 +136,14 @@ const MOBILE_CELL = { notLinked: "max-lg:col-span-full max-lg:row-start-3", } as const; -export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { +export function LabList({ + onAddFirst, + onEmptyChange, +}: { + onAddFirst?: () => void; + /** Told whether the list is empty, so the page can drop its header add. */ + onEmptyChange?: (empty: boolean) => void; +} = {}) { const { t } = useTranslations(); const labNumber = useLabNumber(); const labDate = useLabDate(); @@ -196,6 +203,11 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { const shown = data?.results?.length ?? 0; const truncated = total > shown; + const listEmpty = !isLoading && !isError && groups.length === 0; + useEffect(() => { + onEmptyChange?.(listEmpty); + }, [listEmpty, onEmptyChange]); + if (isLoading) { return (
    diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index 35dc5b618..913e34cc2 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -458,7 +458,7 @@ export function AuthShell({ error after the user already filled in a form. */} {demoMode ? : null} -
    +
    {/* `min-w-0` lets the content column shrink below its children's intrinsic min width (e.g. the measurements table); without it @@ -497,7 +497,7 @@ export function AuthShell({ // for all of them at once instead of asking each new // absolutely-positioned child to remember its own wrapper. className={cn( - "relative flex-1 overflow-y-auto pb-[calc(4rem+env(safe-area-inset-bottom,0px))] md:pb-0", + "shell-desktop:pb-0 relative flex-1 overflow-y-auto pb-[calc(4rem+env(safe-area-inset-bottom,0px))]", fullBleed ? "flex flex-col" : "[scrollbar-gutter:stable]", )} > diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx index 82470be9a..fa1a3bbf4 100644 --- a/src/components/layout/bottom-nav.tsx +++ b/src/components/layout/bottom-nav.tsx @@ -208,7 +208,7 @@ export function BottomNav() { aria-label={t("nav.mobileNavigation")} data-slot="bottom-nav" data-keyboard-hide="" - className="bg-card border-border fixed bottom-0 left-0 z-50 min-h-16 w-full border-t pr-[env(safe-area-inset-right)] pb-[env(safe-area-inset-bottom)] pl-[env(safe-area-inset-left)] backdrop-blur-md md:hidden" + className="bg-card border-border shell-desktop:hidden fixed bottom-0 left-0 z-50 min-h-16 w-full border-t pr-[env(safe-area-inset-right)] pb-[env(safe-area-inset-bottom)] pl-[env(safe-area-inset-left)] backdrop-blur-md" >
    {primaryLeft.map(renderPrimary)} @@ -274,7 +274,7 @@ export function BottomNav() { // The sheet auto-sizes to its content; padding-bottom respects // the iOS safe area so the inner buttons stay reachable above // the home-indicator on iPhone-X-class devices. - className="mx-auto max-w-md rounded-t-xl pb-[calc(env(safe-area-inset-bottom)+1rem)] md:hidden" + className="shell-desktop:hidden mx-auto max-w-md rounded-t-xl pb-[calc(env(safe-area-inset-bottom)+1rem)]" data-testid="bottom-nav-more-sheet" > diff --git a/src/components/layout/module-disabled-notice.tsx b/src/components/layout/module-disabled-notice.tsx index 84e5a35aa..3fb4373e0 100644 --- a/src/components/layout/module-disabled-notice.tsx +++ b/src/components/layout/module-disabled-notice.tsx @@ -88,7 +88,6 @@ export function ModuleDisabledNotice({ icon={icon} title={t(copy.title, { module: moduleName })} description={t(copy.description, { module: moduleName })} - ctaSize="lg" action={state === "disabled" ? action : undefined} />
    diff --git a/src/components/layout/shared-record-unavailable.tsx b/src/components/layout/shared-record-unavailable.tsx index aa9775faf..9e22ee107 100644 --- a/src/components/layout/shared-record-unavailable.tsx +++ b/src/components/layout/shared-record-unavailable.tsx @@ -36,7 +36,6 @@ export function SharedRecordUnavailable() { icon={} title={t("recordSharing.unavailable.title")} description={t("recordSharing.unavailable.description")} - ctaSize="lg" action={ ) : onAddFirst && canAddMeasurement ? ( - @@ -1420,7 +1438,7 @@ export function MeasurementList({ className="bg-card border-border data-[state=selected]:border-primary/60 data-[state=selected]:bg-primary/5" >
    -
    +
    {/* v1.15.13 — multi-select checkbox in a 44px tap target; absent for synthetic grouped rows. */} {!isGrouped && canManage && ( @@ -1457,7 +1475,7 @@ export function MeasurementList({ )} -
    +
    {/* v1.22 — metadata badges sit on the scale token `text-xs` (12 px, the mobile legibility baseline) instead of an arbitrary per-site @@ -1485,23 +1503,14 @@ export function MeasurementList({ their native decimal precision so "78.4 kg" no longer truncates to "78". v1.11.5 — sleep rows render TIME ASLEEP. */} - {isSleep ? ( - formatDurationMinutes(m.value, t) - ) : ( - <> - {isGrouped - ? fmt.integer(m.value) - : fmt.number( - rowDisplay(m).value, - rawDisplayFractionDigits(m.type), - )}{" "} - {measurementUnitLabel( - m.type, - isGrouped ? m.unit : rowDisplay(m).unit, - t, - )} - - )} + {isSleep + ? formatDurationMinutes(m.value, t) + : isGrouped + ? fmt.integer(m.value) + : fmt.number( + rowDisplay(m).value, + rawDisplayFractionDigits(m.type), + )} {isSleep ? ( @@ -1514,19 +1523,45 @@ export function MeasurementList({ ) )} -

    + {/* The meta line wraps rather than truncates: + cut to "vor 9 Stunden…" it hid the day it + opens and the source beside it. */} +

    {/* v1.4.43 QoL (L8) — see desktop counterpart at the same `measuredAt` site. Relative under 24 h, absolute older. */} + {/* The unit is meta, and on a phone a long + one ("Atemzüge/min") beside the value pushed + the row's controls; it leads the meta line + instead. Sleep reads as a duration. */} + {isSleep ? null : ( + + {measurementUnitLabel( + m.type, + isGrouped ? m.unit : rowDisplay(m).unit, + t, + )} + {" · "} + + )} {m.dayKey !== undefined ? ( - + {formatDateOrRelative(m.measuredAt, t)} ) : ( - + {formatDateOrRelative(m.measuredAt, t)} )} @@ -1770,7 +1805,6 @@ export function MeasurementList({ setEditDeleteDialogOpen(true)} > diff --git a/src/components/medications/detail/medication-detail-tabs.tsx b/src/components/medications/detail/medication-detail-tabs.tsx index 63a134774..48afd80b7 100644 --- a/src/components/medications/detail/medication-detail-tabs.tsx +++ b/src/components/medications/detail/medication-detail-tabs.tsx @@ -193,7 +193,6 @@ const TAB_SLUGS = [ "bestand", "verlauf", "injektion", - "api", "erweitert", ] as const; type TabSlug = (typeof TAB_SLUGS)[number]; @@ -208,9 +207,8 @@ const OWNER_ONLY_TABS: ReadonlySet = new Set([ // Schedule times are a MANAGE route, but the tab also carries the reminder // and notification settings, which resolve the caller. "zeitplan", - // Per-medication tokens: a credential surface, never delegated. - "api", - // Phase config, export and the token-adjacent rows resolve the caller. + // Phase config, export and the per-medication tokens (a credential + // surface, never delegated) resolve the caller. "erweitert", ]); @@ -230,6 +228,9 @@ const MANAGE_TABS: ReadonlySet = new Set(["bestand"]); */ const LEGACY_TAB_SLUGS: Record = { erinnerung: "zeitplan", + // The API tab folded back into Erweitert in v1.42: two tabs for the + // rarely-opened owner settings made a strip that could not fit a phone. + api: "erweitert", }; function snapshotToWizardPayload( @@ -524,7 +525,9 @@ export function MedicationDetailTabs({ data-slot="medication-detail-tabs" > {availableTabs.map((slug) => ( @@ -871,33 +874,9 @@ export function MedicationDetailTabs({ )} - {/* API — the external-ingest surface in its own tab: per-medication - tokens + the drug-coding identifiers external systems key on. - Pulled out of Erweitert so the destructive zone and the token - management stop sharing one surface. */} - - -

    - -
    -
    - -
    - - - - {/* ERWEITERT — Lifecycle → Daten → Gefahrenzone. The external-API - group moved to its own API tab. */} + {/* ERWEITERT — Lifecycle → Daten → external API → Gefahrenzone. + The API tab folded back in here (v1.42); its group stays + collapsed like the other two, and the danger zone stays last. */} + +
    + +
    +
    + +
    +
    + onDelete(intake.id as string)} - className="text-destructive focus:text-destructive" data-slot="ledger-delete" >
    - - {refusal ? ( -

    - {t(refusal)} +

    + {/* The sentence the whole feature exists for. Foreground, in + the body, because it is content and not a meta + description. */} +

    + {t("settings.sections.score.threeAxes")}

    - ) : null} - {analyticsQuery.isError ? ( - void analyticsQuery.refetch()} - /> - ) : null} + {/* v1.38 — the breadth sentence. Three areas of health used to + be the price of a score, and the write refused anything + narrower; it is a recommendation now and one pillar saves. + The recommendation stays on the surface anyway, because a + one-area score is honest but thin and a person choosing + what counts should know that before they save, not after + the card tells them what it rests on. Content, so + foreground and in the body beside the sentence it + qualifies — not a muted footnote under the rows. */} +

    + {t("settings.sections.score.recommendation")} +

    - {configQuery.isLoading ? ( -
    - - - - -
    - ) : ( - // One flat list of topics separated by hairline dividers, the - // way Anamnese lists its facts. The group headings are gone; the - // registry order (domain, then pillar) is preserved by flattening - // the groups in order, and each row keeps its `data-domain` for - // tests and e2e. -
    - {groups.flatMap((group) => - group.rows.map((row) => ( - toggle(row.id, next)} - /> - )), - )} -
    - )} - - {omitted.length > 0 ? ( -

    - {t("settings.sections.score.omitted", { - count: omitted.length, - })}{" "} - + {t("settings.sections.score.presets.label")} + +

    + {t("settings.sections.score.presets.all")} + + +
    - {/* Bottom-right, in its own row, like every other settings card - that saves a form. */} - - - - - )} -
    + + {configQuery.isLoading ? ( +
    + + + + +
    + ) : ( + // One flat list of topics separated by hairline dividers, the + // way Anamnese lists its facts. The group headings are gone; the + // registry order (domain, then pillar) is preserved by flattening + // the groups in order, and each row keeps its `data-domain` for + // tests and e2e. +
    + {groups.flatMap((group) => + group.rows.map((row) => ( + toggle(row.id, next)} + /> + )), + )} +
    + )} + + {omitted.length > 0 ? ( +

    + {t("settings.sections.score.omitted", { + count: omitted.length, + })}{" "} + + {t("settings.sections.score.omittedLink")} + +

    + ) : null} +
    + + {/* Bottom-right, in its own row, like every other settings card + that saves a form. */} + + + + + )} +
    + ); } diff --git a/src/components/settings/security-section/passkey-list-section.tsx b/src/components/settings/security-section/passkey-list-section.tsx index ed6cd4b7b..94fa4b4cf 100644 --- a/src/components/settings/security-section/passkey-list-section.tsx +++ b/src/components/settings/security-section/passkey-list-section.tsx @@ -299,7 +299,7 @@ export function PasskeyListSection({ type="button" variant="ghost" size="icon" - className="text-destructive min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0" + className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0" disabled={remove.isPending} aria-label={t("settings.deletePasskey")} > diff --git a/src/components/settings/security-section/security-keys-card.tsx b/src/components/settings/security-section/security-keys-card.tsx index 7dcb374f2..9d487d5b0 100644 --- a/src/components/settings/security-section/security-keys-card.tsx +++ b/src/components/settings/security-section/security-keys-card.tsx @@ -293,7 +293,7 @@ export function SecurityKeysCard({ type="button" variant="ghost" size="icon" - className="text-destructive min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0" + className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0" disabled={remove.isPending} aria-label={t("settings.security.keys.remove")} > diff --git a/src/components/settings/sharing-section.tsx b/src/components/settings/sharing-section.tsx index c05674421..c39fe0b96 100644 --- a/src/components/settings/sharing-section.tsx +++ b/src/components/settings/sharing-section.tsx @@ -242,7 +242,7 @@ function ShareLinksCard() {
    {action ? (
    ` / `asChild` `` to the 44 px tap floor. - // The selectors target the wrapper's immediate child so - // existing CTAs that pass a plain `
    @@ -135,7 +119,7 @@ function EmptyState({ role="status" aria-live="polite" className={cn( - "border-border bg-card rounded-lg border border-dashed", + "border-border bg-card rounded-xl border border-dashed", className, )} {...props} diff --git a/src/components/ui/page-header.tsx b/src/components/ui/page-header.tsx index 1efd6b16a..7b52036e7 100644 --- a/src/components/ui/page-header.tsx +++ b/src/components/ui/page-header.tsx @@ -27,9 +27,9 @@ import { cn } from "@/lib/utils"; * they drifted apart from every module page in the first place. */ /** - * A one-word German title ("Krankheitstagebuch") is wider than the column a - * phone leaves beside the actions, and without a break opportunity it ran - * underneath them. `hyphens-auto` breaks it by the page's `lang`; the + * A long one-word German title (the illness page was once + * "Krankheitstagebuch") is wider than the column a phone leaves beside the + * actions, and without a break opportunity it ran underneath them. `hyphens-auto` breaks it by the page's `lang`; the * `wrap-break-word` floor covers a word the hyphenation dictionary does not. */ const HEADING_CLASS = diff --git a/src/components/ui/reveal-when-settled.tsx b/src/components/ui/reveal-when-settled.tsx new file mode 100644 index 000000000..ac98626ea --- /dev/null +++ b/src/components/ui/reveal-when-settled.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useEffect, useState, type ReactNode } from "react"; +import { useIsFetching, useQueryClient } from "@tanstack/react-query"; + +/** + * Holds a page body out of layout until its first reads have settled, then + * shows it in one step. + * + * Some bodies stack several cards whose final height depends on data — a + * day-by-day timeline, a paginated list, a card that turns out empty. Each + * resolving on its own pushed everything beneath it down the page a few + * times in the first few hundred milliseconds (CLS well above 0.1 at + * desktop width). A skeleton cannot know a list's real length; this does + * not need to. + * + * The children mount immediately, so their queries start at once; they sit + * in a `hidden` container (out of layout, so nothing in it can shift) while + * the `fallback` holds the place. The body reveals when no query is in + * flight for two frames running, or after `maxWaitMs` at the latest, so a + * slow or polling read elsewhere in the app can delay it but never strand + * it. Revealing is latched: a later background refetch does not hide the + * body again. + */ +export function RevealWhenSettled({ + children, + fallback, + maxWaitMs = 2000, + className = "space-y-6", +}: { + children: ReactNode; + /** The body's own rhythm: the wrapper is the one child of the page's + * stack, so the gap between the cards it holds is set here. */ + className?: string; + /** The placeholder painted until the body reveals. */ + fallback: ReactNode; + maxWaitMs?: number; +}) { + const queryClient = useQueryClient(); + const fetching = useIsFetching(); + const [revealed, setRevealed] = useState(false); + + useEffect(() => { + if (revealed || fetching > 0) return; + // Two frames: the children's query observers subscribe in their own + // effects, so a count of zero on the first pass can simply mean nothing + // has started yet. + let second = 0; + const first = requestAnimationFrame(() => { + second = requestAnimationFrame(() => { + if (queryClient.isFetching() === 0) setRevealed(true); + }); + }); + return () => { + cancelAnimationFrame(first); + cancelAnimationFrame(second); + }; + }, [fetching, revealed, queryClient]); + + useEffect(() => { + if (revealed) return; + const timer = setTimeout(() => setRevealed(true), maxWaitMs); + return () => clearTimeout(timer); + }, [revealed, maxWaitMs]); + + return ( + <> + {revealed ? null : fallback} +
    + {children} +
    + + ); +} diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx index daff86a60..cef45ddef 100644 --- a/src/components/ui/switch.tsx +++ b/src/components/ui/switch.tsx @@ -25,7 +25,7 @@ function Switch({ // without disturbing layout. v1.4.43 W10 design-M1 — `-13px` (not // `-12px`) takes the 18.4 px track + 26 px padding to 44.4 px and // closes the 1.6 px vertical short fall to spec-strict 2.5.5. - "peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none before:absolute before:inset-[-13px] before:content-[''] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6", + "peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-switch-off focus-visible:border-ring focus-visible:ring-ring/50 group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none before:absolute before:inset-[-13px] before:content-[''] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6", className, )} {...props} diff --git a/src/components/vaccinations/vaccinations-view.tsx b/src/components/vaccinations/vaccinations-view.tsx index 31e3493b8..af9c4ced8 100644 --- a/src/components/vaccinations/vaccinations-view.tsx +++ b/src/components/vaccinations/vaccinations-view.tsx @@ -104,7 +104,11 @@ export function VaccinationsView() { } description={t("vaccinations.subtitle")} - actions={addButton} + // The empty state carries the add action while the Impfpass is + // empty; the header offers it once there is a list to add to. + actions={ + !isLoading && !isError && records.length === 0 ? null : addButton + } /> setOpen(true)} className={cn( - "text-muted-foreground hover:text-destructive relative size-10", + "text-muted-foreground hover:text-foreground relative size-10", "before:absolute before:-inset-1.5 before:content-['']", )} > diff --git a/src/hooks/use-chart-domain-stats.ts b/src/hooks/use-chart-domain-stats.ts index a243d0b65..1d59e44a5 100644 --- a/src/hooks/use-chart-domain-stats.ts +++ b/src/hooks/use-chart-domain-stats.ts @@ -31,6 +31,12 @@ export interface ChartDomainStats { * falls back to the full-range summary for that series. */ statsByType: Record | null; + /** + * False until the chart has settled and reported once (even an empty + * window). The strip holds its cells until then rather than painting the + * full-range summary and swapping to the chart's window a beat later. + */ + statsSettled: boolean; /** Stable callback handed to ``. */ onVisibleStats: (stats: Record | null) => void; } @@ -40,10 +46,12 @@ export function useChartDomainStats(): ChartDomainStats { string, MetricWindowStats > | null>(null); + const [statsSettled, setStatsSettled] = useState(false); const lastRef = useRef("null"); const onVisibleStats = useCallback( (stats: Record | null) => { + setStatsSettled(true); // Cheap shallow signature so an identical window (a re-render that // recomputes the same slice) doesn't re-set state and re-render the // strip. @@ -57,6 +65,7 @@ export function useChartDomainStats(): ChartDomainStats { return { statsByType, + statsSettled, onVisibleStats, }; } diff --git a/src/lib/__tests__/achievements-no-insults.test.ts b/src/lib/__tests__/achievements-no-insults.test.ts index 9c6e30423..38c8e4419 100644 --- a/src/lib/__tests__/achievements-no-insults.test.ts +++ b/src/lib/__tests__/achievements-no-insults.test.ts @@ -41,17 +41,14 @@ describe("achievement titles", () => { expect(offending).toEqual([]); }); + // The two badges those titles belonged to rewarded a doubled and a + // skipped dose. They are gone; their keys must not drift back in. test.each([ ["en", enMessages as unknown as MessagesShape], ["de", deMessages as unknown as MessagesShape], - ] as const)( - "%s overIntake1/skippedIntake1 are translated, not the literal English shipped before", - (_locale, bundle) => { - const badges = bundle.achievements?.badges ?? {}; - expect(badges.overIntake1?.title).toBeDefined(); - expect(badges.skippedIntake1?.title).toBeDefined(); - expect(badges.overIntake1?.title).not.toBe("Idiot"); - expect(badges.skippedIntake1?.title).not.toBe("Lazy Boy"); - }, - ); + ] as const)("%s ships no dosing badge copy", (_locale, bundle) => { + const badges = bundle.achievements?.badges ?? {}; + expect(badges).not.toHaveProperty("overIntake1"); + expect(badges).not.toHaveProperty("skippedIntake1"); + }); }); diff --git a/src/lib/gamification/__tests__/achievements.test.ts b/src/lib/gamification/__tests__/achievements.test.ts index 215f22bac..b42dcfdd0 100644 --- a/src/lib/gamification/__tests__/achievements.test.ts +++ b/src/lib/gamification/__tests__/achievements.test.ts @@ -13,8 +13,6 @@ import { const FULL_METRICS: AchievementMetrics = { totalTakenIntakes: 1000, - overIntakeCount: 1000, - skippedIntakeCount: 1000, bmiGreenStreak: 1000, bpGreenStreak: 1000, pulseGreenStreak: 1000, @@ -79,8 +77,8 @@ describe("gamification achievements", () => { ).toBe(3); }); - it("ships the expanded definition list (63 total — v1.4.18 + v1.16.1 care routine)", () => { - expect(ACHIEVEMENT_DEFINITIONS).toHaveLength(63); + it("ships the expanded definition list (61 total — v1.4.18 + v1.16.1 care routine, minus the two dosing badges)", () => { + expect(ACHIEVEMENT_DEFINITIONS).toHaveLength(61); }); it("includes mood and hidden categories in the render order", () => { @@ -91,8 +89,8 @@ describe("gamification achievements", () => { it("evaluates unlocked achievements and points when all metrics maxed", () => { const result = evaluateAchievementsWithCompletionDates(FULL_METRICS, {}); - expect(result.summary.unlockedCount).toBe(63); - expect(result.summary.totalCount).toBe(63); + expect(result.summary.unlockedCount).toBe(61); + expect(result.summary.totalCount).toBe(61); expect(result.summary.nextAchievement).toBeNull(); expect(result.summary.earnedPoints).toBe(result.summary.totalPoints); }); @@ -227,3 +225,27 @@ describe("bridgeFrozenStreakGaps (Rest Mode streak-freeze)", () => { expect(calculateLongestStreak(bridged)).toBe(1); }); }); + +describe("dosing is never rewarded", () => { + // A badge for a dose taken twice or a dose skipped rewards over- or + // under-dosing. Neither behaviour is an achievement. + it("ships no badge for a doubled or a skipped dose", () => { + const ids = ACHIEVEMENT_DEFINITIONS.map((d) => d.id); + expect(ids).not.toContain("over-intake-1"); + expect(ids).not.toContain("skipped-intake-1"); + const metrics = ACHIEVEMENT_DEFINITIONS.map((d) => String(d.metric)); + expect(metrics).not.toContain("overIntakeCount"); + expect(metrics).not.toContain("skippedIntakeCount"); + }); + + it("ignores an unlock row persisted for a retired badge", () => { + const result = evaluateAchievementsWithCompletionDates(ZERO_METRICS, { + "over-intake-1": new Date("2026-03-01T08:00:00Z"), + "skipped-intake-1": new Date("2026-03-02T08:00:00Z"), + }); + const ids = result.achievements.map((a) => a.id); + expect(ids).not.toContain("over-intake-1"); + expect(ids).not.toContain("skipped-intake-1"); + expect(result.summary.unlockedCount).toBe(0); + }); +}); diff --git a/src/lib/gamification/achievements-result.ts b/src/lib/gamification/achievements-result.ts index be9578b71..1685916c1 100644 --- a/src/lib/gamification/achievements-result.ts +++ b/src/lib/gamification/achievements-result.ts @@ -370,115 +370,6 @@ function getOnTimePerfectDaySeries( return toDaySeries(perfectDays); } -function parseDaysOfWeek(daysOfWeek: string | null): Set | null { - if (!daysOfWeek) return null; - - const values = daysOfWeek - .split(",") - .map((value) => Number(value.trim())) - .filter((value) => Number.isInteger(value) && value >= 0 && value <= 6); - - return values.length > 0 ? new Set(values) : null; -} - -function getExpectedIntakesForDay( - schedules: MedicationScheduleRecord[], - dayKey: string, -): number { - if (schedules.length === 0) return 0; - if (!/^\d{4}-\d{2}-\d{2}$/.test(dayKey)) return 0; - - const day = dayKeyToDate(dayKey).getUTCDay(); - let expected = 0; - - for (const schedule of schedules) { - const allowedDays = parseDaysOfWeek(schedule.daysOfWeek); - if (!allowedDays || allowedDays.has(day)) { - expected += 1; - } - } - - return expected; -} - -function getIntakeIssueMetrics( - intakeEvents: IntakeEventRecord[], - schedulesByMedicationId: Map, - tz: string, -) { - const skippedIntakeDates = intakeEvents - .filter((event) => event.skipped) - .map((event) => event.scheduledFor) - .sort((a, b) => a.getTime() - b.getTime()); - - const eventsByMedicationDay = new Map< - string, - Map - >(); - - for (const event of intakeEvents) { - if (!event.medicationId || Number.isNaN(event.scheduledFor.getTime())) { - continue; - } - - const dayKey = userDayKey(event.scheduledFor, tz); - const byDay = eventsByMedicationDay.get(event.medicationId) ?? new Map(); - const list = byDay.get(dayKey) ?? []; - list.push(event); - byDay.set(dayKey, list); - eventsByMedicationDay.set(event.medicationId, byDay); - } - - let overIntakeCount = 0; - const overIntakeDates: Date[] = []; - - for (const [medicationId, dayMap] of eventsByMedicationDay.entries()) { - const schedules = schedulesByMedicationId.get(medicationId) ?? []; - - for (const [dayKey, events] of dayMap.entries()) { - const expectedCount = getExpectedIntakesForDay(schedules, dayKey); - - if (expectedCount <= 0) { - continue; - } - - const takenEvents = events - .filter((event) => !event.skipped && event.takenAt !== null) - .sort((a, b) => { - const aTime = (a.takenAt ?? a.scheduledFor).getTime(); - const bTime = (b.takenAt ?? b.scheduledFor).getTime(); - return aTime - bTime; - }); - - const excessCount = Math.max(0, takenEvents.length - expectedCount); - if (excessCount === 0) { - continue; - } - - overIntakeCount += excessCount; - - for ( - let index = takenEvents.length - excessCount; - index < takenEvents.length; - index++ - ) { - const event = takenEvents[index]; - if (!event) continue; - overIntakeDates.push(event.takenAt ?? event.scheduledFor); - } - } - } - - overIntakeDates.sort((a, b) => a.getTime() - b.getTime()); - - return { - skippedIntakeCount: skippedIntakeDates.length, - skippedIntakeDates, - overIntakeCount, - overIntakeDates, - }; -} - function getCompliance80DaySeries( intakeEvents: IntakeEventRecord[], startDate: Date, @@ -748,12 +639,6 @@ export async function buildAchievementsResult( const schedulesByMedicationId = new Map( medications.map((med) => [med.id, med.schedules]), ); - const intakeIssueMetrics = getIntakeIssueMetrics( - intakeEvents, - schedulesByMedicationId, - tz, - ); - const takenIntakeDates = intakeEvents .filter((event) => !event.skipped && event.takenAt !== null) .map((event) => event.takenAt as Date); @@ -850,8 +735,6 @@ export async function buildAchievementsResult( const metrics = { totalTakenIntakes: takenIntakeDates.length, - overIntakeCount: intakeIssueMetrics.overIntakeCount, - skippedIntakeCount: intakeIssueMetrics.skippedIntakeCount, // v1.18.1 P4 — every day-streak runs through `freeze(...)` so an active // or past illness episode bridges (does not break) the run across the // days the user was unwell. @@ -917,24 +800,6 @@ export async function buildAchievementsResult( continue; } - if (definition.metric === "overIntakeCount") { - const completedAt = findCountCompletionDate( - intakeIssueMetrics.overIntakeDates, - definition.target, - ); - if (completedAt) completionDates[definition.id] = completedAt; - continue; - } - - if (definition.metric === "skippedIntakeCount") { - const completedAt = findCountCompletionDate( - intakeIssueMetrics.skippedIntakeDates, - definition.target, - ); - if (completedAt) completionDates[definition.id] = completedAt; - continue; - } - if (definition.metric === "loginDayStreak") { const completedAt = findStreakCompletionDate( loginDaySeries.dayKeys, diff --git a/src/lib/gamification/achievements.ts b/src/lib/gamification/achievements.ts index 1e2862333..a526fa146 100644 --- a/src/lib/gamification/achievements.ts +++ b/src/lib/gamification/achievements.ts @@ -2,8 +2,6 @@ export const GAMIFICATION_ROLLOUT_AT = new Date("2026-02-20T00:00:00.000Z"); export type AchievementMetricKey = | "totalTakenIntakes" - | "overIntakeCount" - | "skippedIntakeCount" | "bmiGreenStreak" | "bpGreenStreak" | "pulseGreenStreak" @@ -117,8 +115,6 @@ export interface EarnabilityFlags { function categoryForMetric(metric: AchievementMetricKey): AchievementCategory { switch (metric) { case "totalTakenIntakes": - case "overIntakeCount": - case "skippedIntakeCount": case "onTimePerfectDayStreak": case "compliance80DayStreak": case "missFreeDayStreak": @@ -172,8 +168,6 @@ export const ACHIEVEMENT_CATEGORY_ORDER: readonly AchievementCategory[] = [ export interface AchievementMetrics { totalTakenIntakes: number; - overIntakeCount: number; - skippedIntakeCount: number; bmiGreenStreak: number; bpGreenStreak: number; pulseGreenStreak: number; @@ -319,26 +313,6 @@ export const ACHIEVEMENT_DEFINITIONS: AchievementDefinition[] = [ tDescription: "achievements.badges.intakeTotal300.description", format: "count", }), - define({ - id: "over-intake-1", - metric: "overIntakeCount", - target: 1, - points: 0, - icon: "AlertTriangle", - tTitle: "achievements.badges.overIntake1.title", - tDescription: "achievements.badges.overIntake1.description", - format: "count", - }), - define({ - id: "skipped-intake-1", - metric: "skippedIntakeCount", - target: 1, - points: 0, - icon: "SkipForward", - tTitle: "achievements.badges.skippedIntake1.title", - tDescription: "achievements.badges.skippedIntake1.description", - format: "count", - }), define({ id: "passkey-created-1", metric: "passkeyCreatedCount", @@ -897,8 +871,6 @@ function isEarnable( ): boolean { switch (metric) { case "totalTakenIntakes": - case "overIntakeCount": - case "skippedIntakeCount": case "onTimePerfectDayStreak": case "compliance80DayStreak": case "missFreeDayStreak": diff --git a/src/lib/insights/derived/__tests__/wellness-scores.test.ts b/src/lib/insights/derived/__tests__/wellness-scores.test.ts index e7106d71a..6340f98b0 100644 --- a/src/lib/insights/derived/__tests__/wellness-scores.test.ts +++ b/src/lib/insights/derived/__tests__/wellness-scores.test.ts @@ -313,3 +313,76 @@ describe("computeWellnessScore", () => { expect(where.source).toBe("COMPUTED"); }); }); + +describe("computeWellnessScore — strain reads the device's day strain", () => { + // `/insights/recovery` charts the band's own DAY_STRAIN. The strain page + // must not call the same account "not enough data": with no computed proxy + // in the window it serves the device's day strain, on the device's scale. + function byType(rows: Record) { + findMany.mockImplementation( + async (args: { where: { type: string } }) => rows[args.where.type] ?? [], + ); + } + + it("falls back to DAY_STRAIN when no computed strain score exists", async () => { + byType({ + STRAIN_SCORE: [], + DAY_STRAIN: [ + { + value: 10.5, + measuredAt: new Date("2026-06-02T04:00:00Z"), + source: "WHOOP", + }, + { + value: 14.7, + measuredAt: new Date("2026-06-01T04:00:00Z"), + source: "WHOOP", + }, + ], + }); + const r = await computeWellnessScore("STRAIN_SCORE", "u1", PROFILE, { + now: NOW, + }); + expect(r.status).toBe("ok"); + if (r.status !== "ok") return; + const v = r.value as WellnessScoreValue; + expect(v.device).toEqual({ value: 10.5, scaleMax: 21 }); + expect(v.score).toBe(50); + expect(v.daysInWindow).toBe(2); + expect(r.provenance.inputs).toEqual(["DAY_STRAIN"]); + }); + + it("keeps the computed proxy when one exists", async () => { + byType({ + STRAIN_SCORE: [ + { + value: 64, + measuredAt: new Date("2026-06-01T12:00:00Z"), + source: "COMPUTED", + }, + ], + DAY_STRAIN: [ + { + value: 10.5, + measuredAt: new Date("2026-06-02T04:00:00Z"), + source: "WHOOP", + }, + ], + }); + const r = await computeWellnessScore("STRAIN_SCORE", "u1", PROFILE, { + now: NOW, + }); + expect(r.status).toBe("ok"); + if (r.status !== "ok") return; + expect(r.value.score).toBe(64); + expect(r.value.device ?? null).toBeNull(); + }); + + it("stays insufficient with neither", async () => { + byType({}); + const r = await computeWellnessScore("STRAIN_SCORE", "u1", PROFILE, { + now: NOW, + }); + expect(r.status).toBe("insufficient"); + }); +}); diff --git a/src/lib/insights/derived/wellness-scores.ts b/src/lib/insights/derived/wellness-scores.ts index 4e0830d4b..e41f66f0e 100644 --- a/src/lib/insights/derived/wellness-scores.ts +++ b/src/lib/insights/derived/wellness-scores.ts @@ -68,8 +68,24 @@ export interface WellnessScoreValue { * non-breaking). */ components?: ReadinessComponent[] | null; + /** + * STRAIN only — set when the score is the device's own day strain rather + * than the server's computed proxy: no proxy row exists in the window, but + * the band delivered `DAY_STRAIN` (WHOOP's 0–21 scale). `value` is the + * device's latest reading on its own scale; `score` is that reading as a + * share of `scaleMax`, so the ring and the band keep their 0–100 contract. + * Absent whenever the computed proxy is the source. + */ + device?: { value: number; scaleMax: number } | null; } +/** + * The device-native day-strain scale. WHOOP reports cycle strain on 0–21; + * the ingest validation (`DAY_STRAIN: { min: 0, max: 21 }`) pins the same + * bound, so a stored reading can never exceed it. + */ +export const DEVICE_STRAIN_SCALE_MAX = 21; + /** The three persisted score types this engine serves. */ export const WELLNESS_SCORE_TYPES = { RECOVERY_SCORE: "RECOVERY_SCORE", @@ -176,6 +192,26 @@ export async function computeWellnessScore( ) : rawRows; + // STRAIN has a second, device-native source: the band's own DAY_STRAIN, + // which `/insights/recovery` charts. Without a computed proxy in the window + // the strain page reads that same series instead of reporting "not enough + // data" for an account the recovery page shows weeks of strain for. + if (rows.length === 0 && type === "STRAIN_SCORE") { + const deviceRows = await prisma.measurement.findMany({ + where: { + userId, + type: "DAY_STRAIN", + deletedAt: null, + measuredAt: { gte: cutoff, lte: now }, + }, + select: { value: true, measuredAt: true, source: true }, + orderBy: { measuredAt: "desc" }, + }); + if (deviceRows.length > 0) { + return buildDeviceStrain(deviceRows, windowDays, computedAt); + } + } + if (rows.length === 0) { return buildInsufficient({ coverage: { @@ -251,6 +287,7 @@ export async function computeWellnessScore( .reverse(), anchor, components, + device: null, }, coverage: { requiredInputs: 1, @@ -270,3 +307,55 @@ export async function computeWellnessScore( }, }); } + +/** A device day-strain reading as a 0–100 share of the device scale. */ +function deviceStrainShare(value: number): number { + const clamped = Math.min(Math.max(value, 0), DEVICE_STRAIN_SCALE_MAX); + return Math.round((clamped / DEVICE_STRAIN_SCALE_MAX) * 100); +} + +function buildDeviceStrain( + rows: { value: number; measuredAt: Date }[], + windowDays: number, + computedAt: string, +): Derived { + const latest = rows[0]; + const score = deviceStrainShare(latest.value); + const prior = rows.slice(1).map((r) => deviceStrainShare(r.value)); + const trendDelta = + prior.length > 0 + ? Math.round(score - prior.reduce((s, v) => s + v, 0) / prior.length) + : null; + return buildOk({ + value: { + score, + band: bandWellnessScore("STRAIN_SCORE", score), + trendDelta, + daysInWindow: rows.length, + asOf: latest.measuredAt.toISOString(), + series: rows + .slice(0, SPARKLINE_MAX_POINTS) + .map((r) => deviceStrainShare(r.value)) + .reverse(), + anchor: null, + components: null, + device: { + value: Math.round(latest.value * 10) / 10, + scaleMax: DEVICE_STRAIN_SCALE_MAX, + }, + }, + coverage: { + requiredInputs: 1, + presentInputs: 1, + historyDays: rows.length, + missing: [], + }, + confidence: { score: 90, band: "high" }, + provenance: { + inputs: ["DAY_STRAIN"], + source: "DAY", + windowDays, + computedAt, + }, + }); +} diff --git a/src/lib/integrations/healthkit-type-sync.ts b/src/lib/integrations/healthkit-type-sync.ts index 1945b8131..20570d0b0 100644 --- a/src/lib/integrations/healthkit-type-sync.ts +++ b/src/lib/integrations/healthkit-type-sync.ts @@ -131,6 +131,32 @@ export async function getHealthKitTypeArrivals( return out; } +/** + * The newest instant Apple Health data is known to have reached the account: + * the live-batch stamp, the per-type arrival ledger, or — for rows that came + * any other way, such as the export.zip import — the newest stored sample. + * Null only when none of the three holds anything, which is the one case the + * status may honestly call "waiting for first data". + */ +export function newestAppleHealthDataAt( + lastSyncedAt: string | null, + arrivals: Map | null, + samples: readonly { lastSeenAt: string }[], +): string | null { + let newest: number | null = null; + const consider = (value: string | null | undefined) => { + if (!value) return; + const ms = Date.parse(value); + if (Number.isFinite(ms) && (newest === null || ms > newest)) newest = ms; + }; + consider(lastSyncedAt); + for (const arrival of arrivals?.values() ?? []) { + consider(arrival.lastReceivedAt); + } + for (const sample of samples) consider(sample.lastSeenAt); + return newest === null ? null : new Date(newest).toISOString(); +} + /** A freshness entry with the arrival facts beside the newest sample. */ export interface HealthKitFreshnessEntry extends MetricFreshnessEntry { /** When a live sync last carried this type; null when none has. */ diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 3ba65922d..b35e7febb 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -254,6 +254,20 @@ export const wellnessScoreValue = z .describe( "RECOVERY only, and only when the canonical value is the server's computed proxy — a device-native recovery percentage is not our blend and carries no decomposition.", ), + device: z + .object({ + value: z + .number() + .describe("The device's latest day strain on its own scale."), + scaleMax: z + .number() + .describe("Top of the device scale (21 for WHOOP day strain)."), + }) + .nullable() + .optional() + .describe( + "STRAIN only — present when no computed strain score exists in the window and the score is served from the device's own DAY_STRAIN instead. `score` is then `value` as a 0–100 share of `scaleMax`. Absent when the computed proxy is the source.", + ), }) .meta({ id: "WellnessScoreValue" });