Merge the second UI round into release/v1.42.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-08 20:05:44 +02:00
commit 8fc467f8c5
169 files changed
+1981 -981

No files matched your search

+18
View File
@@ -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
+8 -1
View File
@@ -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();
+6 -4
View File
@@ -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",
},
+7 -1
View File
@@ -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).
+3 -1
View File
@@ -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" });
+17 -5
View File
@@ -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<HTMLElement>(
'[data-tour-id="dashboard-quick-add"]',
);
const btn = [
...document.querySelectorAll<HTMLElement>(
'[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.
+19 -6
View File
@@ -84,11 +84,19 @@ async function captureDose(
booster: "confirm" | "decline" | "none";
},
): Promise<void> {
// 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);
});
+39 -26
View File
@@ -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"
}
}
+33 -20
View File
@@ -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"
}
}
+33 -20
View File
@@ -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"
}
}
+33 -20
View File
@@ -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"
}
}
+33 -20
View File
@@ -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"
}
}
+33 -20
View File
@@ -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": "크레딧"
}
}
+33 -20
View File
@@ -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"
}
}
@@ -112,4 +112,29 @@ describe("published WellnessScoreValue", () => {
Object.keys(derived.value as unknown as Record<string, unknown>).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<string, unknown>).sort(),
);
findMany.mockReset();
});
});
+5 -1
View File
@@ -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",
+23 -7
View File
@@ -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 (
<div className="bg-background text-foreground min-h-dvh">
<header className="border-border/60 bg-background/80 sticky top-0 z-10 border-b pt-[env(safe-area-inset-top)] backdrop-blur">
@@ -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")}
</Link>
</div>
</header>
@@ -99,17 +113,19 @@ export default function AboutPage() {
>
<div className="space-y-3">
<p className="text-muted-foreground text-xs tracking-wider uppercase">
About
{t("aboutPage.eyebrow")}
</p>
<h1 className="text-3xl font-bold tracking-tight md:text-4xl">
About HealthLog
{t("aboutPage.title")}
</h1>
<p className="text-muted-foreground text-sm">
Last updated: {LAST_UPDATED}
<time dateTime={LAST_UPDATED}>
{t("aboutPage.lastUpdated", { date: lastUpdated })}
</time>
</p>
</div>
<Section id="project" title="Project">
<Section id="project" title={t("aboutPage.project")}>
<p>
HealthLog is an open-source, self-hostable personal-health-tracking
application. The source code lives at{" "}
@@ -127,7 +143,7 @@ export default function AboutPage() {
</p>
</Section>
<Section id="credits" title="Credits">
<Section id="credits" title={t("aboutPage.credits")}>
<p>
HealthLog stands on a number of open-source libraries and public
data sources. The list below covers the third-party assets that ship
@@ -132,8 +132,6 @@ const mockData = {
] satisfies AchievementProgress[],
metrics: {
totalTakenIntakes: 1,
overIntakeCount: 0,
skippedIntakeCount: 0,
bmiGreenStreak: 0,
bpGreenStreak: 0,
pulseGreenStreak: 0,
+14 -5
View File
@@ -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 (
<SectionFrame>
<SystemStatusSection />
{/* v1.42 — the table maintenance after the tombstone purge. */}
<MeasurementMaintenanceCard />
</SectionFrame>
// 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.
<RevealWhenSettled
fallback={<Skeleton className="h-[40rem] w-full rounded-xl" />}
>
<SectionFrame>
<SystemStatusSection />
{/* v1.42 — the table maintenance after the tombstone purge. */}
<MeasurementMaintenanceCard />
</SectionFrame>
</RevealWhenSettled>
);
case "general":
return (
@@ -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,
+18 -15
View File
@@ -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,
+40
View File
@@ -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 (
<main
id="main-content"
data-slot="clinician-share-unavailable"
className="bg-background text-foreground flex min-h-dvh flex-col items-center justify-center gap-6 px-4 py-12 pt-[calc(env(safe-area-inset-top)+3rem)]"
>
<div
aria-hidden="true"
className="bg-muted text-muted-foreground flex size-14 items-center justify-center rounded-xl"
>
<LinkIcon className="size-6" />
</div>
<div className="max-w-md space-y-2 text-center">
<h1 className="text-2xl font-bold tracking-tight">
{t("clinicianView.unavailable.title")}
</h1>
<p className="text-foreground text-sm">
{t("clinicianView.unavailable.description")}
</p>
</div>
</main>
);
}
+1 -1
View File
@@ -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"
>
<CoachConversationsBody readOnly={!coach.available} />
</div>
+1 -1
View File
@@ -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"
>
<CoachPlansBody readOnly={!coach.available} />
</div>
+49
View File
@@ -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 */
+1 -1
View File
@@ -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={<Flame className="size-6" />}
+6 -4
View File
@@ -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}
+1 -2
View File
@@ -86,7 +86,6 @@ export default function InsightsBmiPage() {
icon={<Ruler className="size-6" />}
title={t("insights.bmiEmptyTitle")}
description={t("insights.bmiEmptyDescription")}
ctaSize="lg"
action={
<Button size="sm" asChild>
<Link href="/settings/account">
@@ -117,7 +116,7 @@ export default function InsightsBmiPage() {
chartKey="bmi"
types={["WEIGHT"]}
title={t("targets.bmi")}
colors={["var(--dracula-yellow)"]}
colors={["var(--chart-5)"]}
unit="kg/m²"
valueMode="bmi"
valueBands={BMI_BANDS}
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsBodyFatPage() {
chartKey="bodyFat"
i18nPrefix="insights.bodyFat"
explainerMetric="bodyFat"
color="var(--warning)"
color="var(--chart-5)"
unit="%"
statIcon={Percent}
emptyStateIcon={<Percent className="size-6" />}
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsKoerperwasserPage() {
chartKey="totalBodyWater"
i18nPrefix="insights.totalBodyWater"
explainerMetric="bodyWater"
color="var(--info)"
color="var(--chart-4)"
emptyStateIcon={<Droplet className="size-6" />}
emptyStateCtaType={null}
captureType="TOTAL_BODY_WATER"
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsKnochenmassePage() {
chartKey="boneMass"
i18nPrefix="insights.boneMass"
explainerMetric="boneMass"
color="var(--warning)"
color="var(--chart-5)"
emptyStateIcon={<Bone className="size-6" />}
emptyStateCtaType={null}
captureType="BONE_MASS"
@@ -20,7 +20,7 @@ export default function InsightsBreathingDisturbancesPage() {
chartKey="breathingDisturbances"
i18nPrefix="insights.breathingDisturbances"
explainerMetric="breathingDisturbances"
color="var(--info)"
color="var(--chart-4)"
unitKey="insights.units.breathingEvents"
emptyStateIcon={<Wind className="size-6" />}
emptyStateCtaType={null}
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsTageslichtPage() {
chartKey="timeInDaylight"
i18nPrefix="insights.timeInDaylight"
explainerMetric="daylight"
color="var(--success)"
color="var(--chart-2)"
unit="min"
yAxisUnit="min"
emptyStateIcon={<Sun className="size-6" />}
@@ -21,7 +21,7 @@ export default function InsightsDoppelstandphasePage() {
chartKey="walkingDoubleSupport"
i18nPrefix="insights.walkingDoubleSupport"
explainerMetric="doubleSupportTime"
color="var(--success)"
color="var(--chart-2)"
unit="%"
yAxisUnit="%"
emptyStateIcon={<Footprints className="size-6" />}
+1 -1
View File
@@ -19,7 +19,7 @@ export default function InsightsFallsPage() {
chartKey="fallCount"
i18nPrefix="insights.falls"
explainerMetric="falls"
color="var(--destructive)"
color="var(--chart-3)"
unitKey="insights.units.falls"
emptyStateIcon={<PersonStanding className="size-6" />}
emptyStateCtaType={null}
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsFettmassePage() {
chartKey="fatMass"
i18nPrefix="insights.fatMass"
explainerMetric="fatMass"
color="var(--warning)"
color="var(--chart-5)"
emptyStateIcon={<Droplets className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any fat mass yet — what does this metric tell me about my health, and how do I improve it?"
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsStockwerkePage() {
chartKey="flightsClimbed"
i18nPrefix="insights.flightsClimbed"
explainerMetric="flightsClimbed"
color="var(--success)"
color="var(--chart-2)"
unitKey="insights.units.flights"
emptyStateIcon={<TrendingUp className="size-6" />}
emptyStateCtaType={null}
+1 -1
View File
@@ -22,7 +22,7 @@ export default function InsightsGripStrengthPage() {
insightMetric="GRIP_STRENGTH"
chartKey="gripStrength"
i18nPrefix="insights.gripStrength"
color="var(--success)"
color="var(--chart-2)"
statIcon={Dumbbell}
emptyStateIcon={<Dumbbell className="size-6" />}
emptyStateCtaType="GRIP_STRENGTH"
-1
View File
@@ -199,7 +199,6 @@ export default function InsightsMedikamentePage() {
icon={<Pill className="size-6" />}
title={t("insights.emptyState.medication.title")}
description={t("insights.emptyState.medication.description")}
ctaSize="lg"
action={
<Button size="sm" asChild>
<Link href="/medications">
+1 -1
View File
@@ -23,7 +23,7 @@ export default function InsightsOxygenSaturationPage() {
chartKey="oxygenSaturation"
i18nPrefix="insights.oxygenSaturation"
explainerMetric="oxygenSaturation"
color="var(--info)"
color="var(--chart-4)"
unit="%"
yAxisUnit="%"
valueBands={[
-2
View File
@@ -408,7 +408,6 @@ export default function InsightsPageClient() {
icon={<TrendingUp className="size-6" />}
title={t("insights.emptyTitle")}
description={t("insights.emptyDescription")}
ctaSize="lg"
action={
<Button size="sm" asChild>
<Link href="/measurements">
@@ -624,7 +623,6 @@ export default function InsightsPageClient() {
icon={<SlidersHorizontal className="size-6" />}
title={t("insights.editMode.emptyTitle")}
description={t("insights.editMode.emptyDescription")}
ctaSize="lg"
action={
<Button size="sm" asChild>
<Link href="/settings/layout/insights">
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsPainPage() {
insightMetric="PAIN_NRS"
chartKey="painNrs"
i18nPrefix="insights.pain"
color="var(--warning)"
color="var(--chart-5)"
unit=""
yAxisUnit="/10"
statIcon={Activity}
@@ -21,7 +21,7 @@ export default function InsightsPulswellengeschwindigkeitPage() {
chartKey="pulseWaveVelocity"
i18nPrefix="insights.pulseWaveVelocity"
explainerMetric="pulseWaveVelocity"
color="var(--destructive)"
color="var(--chart-3)"
unit="m/s"
yAxisUnit="m/s"
emptyStateIcon={<Activity className="size-6" />}
+4 -3
View File
@@ -88,7 +88,7 @@ export default function InsightsPulsPage() {
// v1.12.8 — visible-range stats shared between the pulse chart and the
// strip (the VO2 chart-row below keeps its own full-range read).
const { statsByType, onVisibleStats } = useChartDomainStats();
const { statsByType, statsSettled, onVisibleStats } = useChartDomainStats();
// v1.4.27 F17 — gate the sub-page on at least one pulse observation.
// Brand-new accounts (no manual logs, no Apple-Health upload yet)
@@ -133,6 +133,7 @@ export default function InsightsPulsPage() {
seriesLabel={t("insights.pulseSectionTitle")}
icon={Heart}
windowStats={statsByType?.PULSE ?? null}
windowPending={!statsSettled}
/>
}
coachReadStrip={
@@ -156,8 +157,8 @@ export default function InsightsPulsPage() {
titleIcon={Heart}
colors={
hasRestingHr
? ["var(--success)", "var(--destructive)"]
: ["var(--success)"]
? ["var(--chart-2)", "var(--chart-3)"]
: ["var(--chart-2)"]
}
unit="bpm"
compareBaseline={compareBaseline}
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsAtemfrequenzPage() {
chartKey="respiratoryRate"
i18nPrefix="insights.respiratoryRate"
explainerMetric="respiratoryRate"
color="var(--info)"
color="var(--chart-4)"
unitKey="insights.units.respiratoryRate"
emptyStateIcon={<Wind className="size-6" />}
emptyStateCtaType={null}
+1 -1
View File
@@ -25,7 +25,7 @@ export default function InsightsRestingHrPage() {
chartKey="restingHr"
i18nPrefix="insights.restingHr"
explainerMetric="restingHr"
color="var(--destructive)"
color="var(--chart-3)"
unit="bpm"
yAxisUnit="bpm"
emptyStateIcon={<Heart className="size-6" />}
+1 -1
View File
@@ -20,7 +20,7 @@ export default function InsightsSixMinuteWalkPage() {
chartKey="sixMinuteWalkDistance"
i18nPrefix="insights.sixMinuteWalk"
explainerMetric="sixMinuteWalk"
color="var(--success)"
color="var(--chart-2)"
unit="m"
yAxisUnit="m"
emptyStateIcon={<Footprints className="size-6" />}
+2 -2
View File
@@ -29,7 +29,7 @@ export default function InsightsHauttemperaturPage() {
chartKey="skinTemperature"
i18nPrefix="insights.skinTemperature"
explainerMetric="skinTemperature"
color="var(--warning)"
color="var(--chart-5)"
emptyStateIcon={<Thermometer className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any skin temperature yet — what does this metric tell me about my health, and how do I improve it?"
@@ -38,7 +38,7 @@ export default function InsightsHauttemperaturPage() {
type="BODY_TEMPERATURE_DEVIATION"
title={t("measurements.typeBodyTemperatureDeviation")}
icon={ThermometerSnowflake}
color="var(--info)"
color="var(--chart-4)"
fractionDigits={2}
sectionTitle={t("insights.bodyTempDeviation.title")}
sectionIcon={ThermometerSnowflake}
+1 -1
View File
@@ -20,7 +20,7 @@ export default function InsightsStairAscentSpeedPage() {
chartKey="stairAscentSpeed"
i18nPrefix="insights.stairAscentSpeed"
explainerMetric="stairAscentSpeed"
color="var(--success)"
color="var(--chart-2)"
unit="m/s"
yAxisUnit="m/s"
emptyStateIcon={<Gauge className="size-6" />}
@@ -20,7 +20,7 @@ export default function InsightsStairDescentSpeedPage() {
chartKey="stairDescentSpeed"
i18nPrefix="insights.stairDescentSpeed"
explainerMetric="stairDescentSpeed"
color="var(--success)"
color="var(--chart-2)"
unit="m/s"
yAxisUnit="m/s"
emptyStateIcon={<Gauge className="size-6" />}
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsSchrittlaengePage() {
chartKey="walkingStepLength"
i18nPrefix="insights.walkingStepLength"
explainerMetric="stepLength"
color="var(--success)"
color="var(--chart-2)"
unit="m"
yAxisUnit="m"
emptyStateIcon={<Footprints className="size-6" />}
+1 -1
View File
@@ -26,7 +26,7 @@ export default function InsightsStepsPage() {
i18nPrefix="insights.steps"
explainerMetric="steps"
statIcon={Footprints}
color="var(--success)"
color="var(--chart-2)"
unitKey="insights.units.steps"
emptyStateIcon={<Footprints className="size-6" />}
emptyStateCtaType={null}
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsViszeralfettPage() {
chartKey="visceralFat"
i18nPrefix="insights.visceralFat"
explainerMetric="visceralFat"
color="var(--warning)"
color="var(--chart-5)"
unit=""
yAxisUnit=""
emptyStateIcon={<Droplets className="size-6" />}
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsWaistPage() {
insightMetric="WAIST_CIRCUMFERENCE"
chartKey="waistCircumference"
i18nPrefix="insights.waist"
color="var(--info)"
color="var(--chart-4)"
statIcon={Ruler}
emptyStateIcon={<Ruler className="size-6" />}
emptyStateCtaType="WAIST_CIRCUMFERENCE"
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsGangasymmetriePage() {
chartKey="walkingAsymmetry"
i18nPrefix="insights.walkingAsymmetry"
explainerMetric="walkingAsymmetry"
color="var(--success)"
color="var(--chart-2)"
unit="%"
yAxisUnit="%"
emptyStateIcon={<Footprints className="size-6" />}
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsGehstreckePage() {
chartKey="walkingRunningDistance"
i18nPrefix="insights.walkingRunningDistance"
explainerMetric="walkingDistance"
color="var(--success)"
color="var(--chart-2)"
emptyStateIcon={<Footprints className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any walking and running distance yet — what does this metric tell me about my health, and how do I improve it?"
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsGehpulsPage() {
chartKey="walkingHeartRateAverage"
i18nPrefix="insights.walkingHeartRateAverage"
explainerMetric="walkingHeartRate"
color="var(--destructive)"
color="var(--chart-3)"
unit="bpm"
yAxisUnit="bpm"
emptyStateIcon={<HeartPulse className="size-6" />}
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsGehgeschwindigkeitPage() {
chartKey="walkingSpeed"
i18nPrefix="insights.walkingSpeed"
explainerMetric="walkingSpeed"
color="var(--success)"
color="var(--chart-2)"
emptyStateIcon={<Gauge className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any walking speed yet — what does this metric tell me about my health, and how do I improve it?"
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsGangstabilitaetPage() {
chartKey="walkingSteadiness"
i18nPrefix="insights.walkingSteadiness"
explainerMetric="walkingSteadiness"
color="var(--success)"
color="var(--chart-2)"
unit="%"
yAxisUnit="%"
emptyStateIcon={<Gauge className="size-6" />}
+2 -1
View File
@@ -109,7 +109,7 @@ export default function InsightsGewichtPage() {
: weightSummary;
// v1.12.8 — visible-range stats shared between the chart and the strip.
const { statsByType, onVisibleStats } = useChartDomainStats();
const { statsByType, statsSettled, onVisibleStats } = useChartDomainStats();
if (isEmpty) {
return (
@@ -181,6 +181,7 @@ export default function InsightsGewichtPage() {
seriesLabel={t("insights.weightSectionTitle")}
icon={Scale}
windowStats={statsByType?.WEIGHT ?? null}
windowPending={!statsSettled}
/>
}
coachReadStrip={<CoachReadStrip metricType="WEIGHT" unit={weightUnit} />}
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsWristTemperaturePage() {
chartKey="wristTemperature"
i18nPrefix="insights.wristTemperature"
explainerMetric="wristTemperature"
color="var(--warning)"
color="var(--chart-5)"
emptyStateIcon={<Thermometer className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any wrist-temperature data yet — what does this metric tell me about my health, and how do I improve it?"
+17 -5
View File
@@ -24,6 +24,11 @@ vi.mock("next/navigation", () => ({
redirect: (href: string) => redirectMock(href),
}));
vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: async () => "en",
}));
import { renderToStaticMarkup } from "react-dom/server";
import InviteLandingPage from "../page";
beforeEach(() => {
@@ -56,9 +61,10 @@ describe("<InviteLandingPage> universal-link landing", () => {
expect(url.searchParams.get("invite")).toBe(VALID_TOKEN);
});
it("drops a malformed token to plain register (no enumeration oracle)", async () => {
// Wrong prefix, wrong length, and outright garbage all land on the
// identical target — the page never reveals whether a token exists.
it("names a malformed link as invalid instead of opening registration", async () => {
// Wrong prefix, wrong length, and outright garbage all render the same
// invalid-invite state. The shape is public, so this reveals nothing
// about which well-formed tokens exist; those still go on to register.
for (const bad of [
"not-a-token",
`hlk_${"a".repeat(64)}`,
@@ -66,8 +72,14 @@ describe("<InviteLandingPage> universal-link landing", () => {
`hlv_${"G".repeat(64)}`,
]) {
redirectMock.mockClear();
const href = await runRedirect(bad);
expect(href).toBe("/auth/register");
const node = await InviteLandingPage({
params: Promise.resolve({ token: bad }),
});
expect(redirectMock).not.toHaveBeenCalled();
const html = renderToStaticMarkup(node);
expect(html).toContain('data-slot="invite-invalid"');
expect(html).toContain("Invitation invalid");
expect(html).not.toContain("/auth/register");
}
});
});
+40 -3
View File
@@ -1,6 +1,10 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { MailX } from "lucide-react";
import { looksLikeInviteToken } from "@/lib/auth/invite-token";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { getServerTranslator } from "@/lib/i18n/server-translator";
/**
* v1.17.0 — invite universal-link landing (iOS #16).
@@ -41,7 +45,40 @@ export default async function InviteLandingPage({
redirect(`/auth/register?invite=${encodeURIComponent(token)}`);
}
// Malformed token: behave exactly like a visitor with no invite. No DB
// hit, no leak of whether any token exists.
redirect("/auth/register");
// Malformed token: say so. This used to fall through to the plain
// registration form, which on an open instance invited a stranger to sign
// up and on a closed one refused them without saying why. The shape check
// is public knowledge and needs no database, so naming a malformed link as
// invalid leaks nothing about which tokens exist: a well-formed unknown
// token still goes on to register, where the signup decides.
const locale = await resolveServerLocale();
const { t } = getServerTranslator(locale);
return (
<main
id="main-content"
data-slot="invite-invalid"
className="bg-background text-foreground flex min-h-dvh flex-col items-center justify-center gap-6 px-4 py-12 pt-[calc(env(safe-area-inset-top)+3rem)]"
>
<div
aria-hidden="true"
className="bg-muted text-muted-foreground flex size-14 items-center justify-center rounded-xl"
>
<MailX className="size-6" />
</div>
<div className="max-w-md space-y-2 text-center">
<h1 className="text-2xl font-bold tracking-tight">
{t("auth.inviteInvalid.title")}
</h1>
<p className="text-foreground text-sm">
{t("auth.inviteInvalid.description")}
</p>
</div>
<Link
href="/auth/login"
className="border-border hover:bg-accent inline-flex min-h-11 items-center justify-center rounded-md border px-4 text-sm font-medium transition-colors"
>
{t("auth.inviteInvalid.toLogin")}
</Link>
</main>
);
}
+8 -2
View File
@@ -58,6 +58,7 @@ export default function LabsPage() {
const queryClient = useQueryClient();
const { t } = useTranslations();
const [dialogOpen, setDialogOpen] = useState(false);
const [listEmpty, setListEmpty] = useState(false);
// Sticky-footer slot element for the add-result sheet (filled by the
// ResponsiveSheet `footer` ref so the form portals its action row there).
const [addFooterEl, setAddFooterEl] = useState<HTMLDivElement | null>(null);
@@ -185,7 +186,9 @@ export default function LabsPage() {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : canAddLab ? (
) : canAddLab && !listEmpty ? (
// Hidden while the list is empty: its empty state carries the
// manual add then. The scan choice above has no such twin.
<Button
onClick={() => setDialogOpen(true)}
className="min-h-11 sm:min-h-9"
@@ -263,7 +266,10 @@ export default function LabsPage() {
/>
) : null}
<LabList onAddFirst={() => setDialogOpen(true)} />
<LabList
onAddFirst={() => setDialogOpen(true)}
onEmptyChange={setListEmpty}
/>
</div>
);
}
+21 -8
View File
@@ -17,6 +17,8 @@ import { PageHeader } from "@/components/ui/page-header";
import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { RevealWhenSettled } from "@/components/ui/reveal-when-settled";
import { Skeleton } from "@/components/ui/skeleton";
import { Plus } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "@/lib/i18n/context";
@@ -65,6 +67,9 @@ export default function MeasurementsPage() {
? `${addParam}\u0000${returnToParam ?? ""}`
: null;
const [dialogOpen, setDialogOpen] = useState(() => initialAdd != null);
// The list's empty state carries the add action while there is nothing
// to list, so the header drops its own then.
const [listEmpty, setListEmpty] = useState(false);
const [defaultType, setDefaultType] = useState<string | undefined>(
() => initialAdd ?? undefined,
);
@@ -149,7 +154,7 @@ export default function MeasurementsPage() {
// admits, so this is the header action a delegate keeps. A read-only
// delegate loses it entirely rather than meeting a disabled one.
actions={
canAddMeasurement ? (
canAddMeasurement && !listEmpty ? (
<Button
data-slot="measurement-add"
className="min-h-11 sm:min-h-9"
@@ -203,14 +208,22 @@ export default function MeasurementsPage() {
/>
</ResponsiveSheet>
<MeasurementList
onAddFirst={() => {
setReturnTo(null);
setDialogOpen(true);
}}
/>
{/* The list's height is the data's; the custom metrics sit below it.
Revealed together once both reads settle, so neither pushes the
other down the page while it loads. */}
<RevealWhenSettled
fallback={<Skeleton className="h-[28rem] w-full rounded-xl" />}
>
<MeasurementList
onEmptyChange={setListEmpty}
onAddFirst={() => {
setReturnTo(null);
setDialogOpen(true);
}}
/>
<CustomMetricList />
<CustomMetricList />
</RevealWhenSettled>
</div>
);
}
+5 -1
View File
@@ -521,6 +521,8 @@ export default function MedicationsPageClient() {
),
});
const listEmpty = !isLoading && !isError && !medications?.length;
return (
<div className="space-y-6">
<PullToRefreshIndicator {...pull} />
@@ -575,7 +577,9 @@ export default function MedicationsPageClient() {
button's responsive tap-target floor (`min-h-11 sm:min-h-9`) so
both primary "add" entry points clear the WCAG 2.5.5 44px mobile
minimum identically. */}
{canAddMedication && (
{/* While the list is empty its empty state carries the one
add action; a second one in the header said it twice. */}
{canAddMedication && !listEmpty && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="min-h-11 sm:min-h-9">
+8 -2
View File
@@ -28,6 +28,9 @@ export default function MoodPageClient() {
const router = useRouter();
const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = useState(false);
// The list's empty state carries the add action while there is nothing
// to list, so the header drops its own then.
const [listEmpty, setListEmpty] = useState(false);
// v1.4.27 R4 RC2 — DOM-ref handle the form portals its action row
// into. The ref lives on the `<ResponsiveSheet>` footer slot so the
// Sheet branch can sticky-pin Save / Cancel above the keyboard.
@@ -109,7 +112,7 @@ export default function MoodPageClient() {
)}
{/* A mood entry is a MANAGE create: a WRITE delegate gets no add
path here, a guardian or a MANAGE delegate does. */}
{canAddMood && (
{canAddMood && !listEmpty && (
<Button
onClick={() => setDialogOpen(true)}
className="min-h-11 sm:min-h-9"
@@ -136,7 +139,10 @@ export default function MoodPageClient() {
/>
</ResponsiveSheet>
<MoodList onAddFirst={() => setDialogOpen(true)} />
<MoodList
onAddFirst={() => setDialogOpen(true)}
onEmptyChange={setListEmpty}
/>
{/* 2026-07-17 UX/IA audit M9 — mood tracking, the mental-wellbeing
screeners, and the mood insights page form one mental-health
+45 -22
View File
@@ -20,9 +20,10 @@ import PrivacyPage from "../page";
* enumerated verbatim by identifier. Drift from the iOS source
* (`HealthKitWireConverter.swift` + `HealthKitService.swift`)
* would be a compliance hole.
* 4. The numbered section headings are all present (English form, so
* the scannable spine matches the in-app `Settings → Privacy`
* link target and the table of contents above each section).
* 4. The numbered section headings are all present, German first with
* the English title beside it, so the scannable spine still matches
* the in-app `Settings → Privacy` link target and the table of
* contents.
* 5. v1.4.40 SB-3 additions — consent receipt endpoint, TLS 1.3,
* operator email + deletion-route disclosure, retention windows.
*/
@@ -46,31 +47,53 @@ describe("<PrivacyPage>", () => {
it("advertises the policy version + last-updated date", () => {
const html = render();
expect(html).toContain(`Policy version ${POLICY_VERSION}`);
expect(html).toContain(`Last updated: ${LAST_UPDATED}`);
expect(html).toContain(`Stand: ${LAST_UPDATED}`);
// Each language writes the date its own way, on its own line.
expect(html).toContain("Stand: 11. August 2026");
expect(html).toContain("Last updated: 11 August 2026");
expect(html).toContain(`dateTime="${LAST_UPDATED}"`);
expect(html).toContain('data-slot="privacy-last-updated"');
});
it("renders the numbered section headings", () => {
const html = render();
// 11 numbered sections — H2 headings carry the English form as the
// canonical scan spine, with the German title appearing inline in
// each section body. The TOC at the top mirrors both.
const expectedHeadings = [
"1. Overview",
"2. Data we collect",
"3. Why we collect each category",
"4. Third-party sub-processors",
"5. Data storage and retention",
"6. Your rights (GDPR Art. 15-22, DSGVO)",
"7. Medical-device boundary (EU MDR 2017/745, MDCG 2021-24)",
"8. Apple App Store privacy categories",
"9. Children",
"10. Changes to this policy",
"11. Contact",
// 11 numbered sections. The H2 leads in German, like the body under it,
// and carries the English title after it so the English table of
// contents and the in-app link target still scan the same spine.
const expectedHeadings: Array<[string, string]> = [
["1. Überblick", "Overview"],
["2. Erhobene Daten", "Data we collect"],
["3. Zwecke der Verarbeitung", "Why we collect each category"],
[
"4. Auftragsverarbeiter und Drittanbieter",
"Third-party sub-processors",
],
[
"5. Speicherung, Speicherdauer und Verschlüsselung",
"Data storage and retention",
],
[
"6. Ihre Rechte (DSGVO Art. 15–22)",
"Your rights (GDPR Art. 15-22, DSGVO)",
],
[
"7. Medizinprodukte-Grenze (EU-MDR 2017/745, MDCG 2021-24)",
"Medical-device boundary (EU MDR 2017/745, MDCG 2021-24)",
],
[
"8. Apple-App-Store-Datenschutzkategorien",
"Apple App Store privacy categories",
],
["9. Kinder", "Children"],
["10. Änderungen dieser Erklärung", "Changes to this policy"],
["11. Kontakt", "Contact"],
];
for (const heading of expectedHeadings) {
expect(html, `missing H2 ${JSON.stringify(heading)}`).toContain(heading);
for (const [de, en] of expectedHeadings) {
expect(html, `missing H2 ${JSON.stringify(de)}`).toContain(
`<span lang="de">${de.replaceAll("&", "&amp;")}</span>`,
);
expect(html, `missing English title ${JSON.stringify(en)}`).toContain(
` / ${en}`,
);
}
});
+43 -20
View File
@@ -38,6 +38,16 @@ export const dynamic = "force-dynamic";
const POLICY_VERSION = "1.37.12";
const LAST_UPDATED = "2026-08-11";
const LAST_UPDATED_DATE = new Date(`${LAST_UPDATED}T12:00:00Z`);
// The date as each language writes it, on the line that language reads.
const LAST_UPDATED_DE = new Intl.DateTimeFormat("de-DE", {
dateStyle: "long",
timeZone: "UTC",
}).format(LAST_UPDATED_DATE);
const LAST_UPDATED_EN = new Intl.DateTimeFormat("en-GB", {
dateStyle: "long",
timeZone: "UTC",
}).format(LAST_UPDATED_DATE);
export const metadata: Metadata = {
title: "Datenschutzerklärung / Privacy Policy — HealthLog",
@@ -67,8 +77,14 @@ function Section({
}: SectionProps) {
return (
<section id={id} className="scroll-mt-28 space-y-4">
{/* German first, like the body under it; the English title rides
along so the scan spine still matches the English table of
contents and the in-app link target. */}
<h2 className="text-xl font-semibold tracking-tight md:text-2xl">
{`${numberLabel}. ${titleEn}`}
<span lang="de">{`${numberLabel}. ${titleDe}`}</span>
<span className="text-muted-foreground" lang="en">
{` / ${titleEn}`}
</span>
</h2>
<div
className="text-foreground space-y-3 text-sm leading-relaxed md:text-base"
@@ -76,7 +92,7 @@ function Section({
lang="de"
>
<p className="text-foreground/80 text-xs font-medium tracking-wider uppercase">
Deutsch — {titleDe}
Deutsch
</p>
{bodyDe}
</div>
@@ -166,7 +182,7 @@ function SubProcessor({
rel="noopener noreferrer"
className="text-primary underline underline-offset-2"
>
Privacy policy / Datenschutzerklärung
Datenschutzerklärung / Privacy policy
</a>
</p>
</li>
@@ -191,7 +207,7 @@ export default function PrivacyPage() {
href="/auth/login"
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 items-center text-sm"
>
Sign in
<span lang="de">Anmelden</span>
</Link>
</div>
</header>
@@ -212,13 +228,20 @@ export default function PrivacyPage() {
Policy version {POLICY_VERSION}
</p>
<h1 className="text-3xl font-bold tracking-tight md:text-4xl">
Datenschutzerklärung / Privacy Policy
<span lang="de">Datenschutzerklärung</span>{" "}
<span className="text-muted-foreground" lang="en">
/ Privacy Policy
</span>
</h1>
<p
className="text-muted-foreground text-sm"
data-slot="privacy-last-updated"
>
Last updated: {LAST_UPDATED} · Stand: {LAST_UPDATED}
<time dateTime={LAST_UPDATED}>
<span lang="de">Stand: {LAST_UPDATED_DE}</span>
{" · "}
<span lang="en">Last updated: {LAST_UPDATED_EN}</span>
</time>
</p>
<p className="text-muted-foreground text-sm" lang="de">
Diese Erklärung ist deutsch-englisch geführt. Jeder Abschnitt
@@ -253,7 +276,7 @@ export default function PrivacyPage() {
className="hover:text-foreground hover:underline"
href="#intro"
>
1. Overview / Überblick
1. Überblick / Overview
</a>
</li>
<li>
@@ -261,7 +284,7 @@ export default function PrivacyPage() {
className="hover:text-foreground hover:underline"
href="#data-we-collect"
>
2. Data we collect / Erhobene Daten
2. Erhobene Daten / Data we collect
</a>
</li>
<li>
@@ -269,7 +292,7 @@ export default function PrivacyPage() {
className="hover:text-foreground hover:underline"
href="#purpose"
>
3. Why we collect each category / Zwecke der Verarbeitung
3. Zwecke der Verarbeitung / Why we collect each category
</a>
</li>
<li>
@@ -277,7 +300,7 @@ export default function PrivacyPage() {
className="hover:text-foreground hover:underline"
href="#sub-processors"
>
4. Third-party sub-processors / Auftragsverarbeiter
4. Auftragsverarbeiter / Third-party sub-processors
</a>
</li>
<li>
@@ -285,8 +308,8 @@ export default function PrivacyPage() {
className="hover:text-foreground hover:underline"
href="#storage"
>
5. Data storage and retention / Speicherung &amp;
Speicherdauer
5. Speicherung &amp; Speicherdauer / Data storage and
retention
</a>
</li>
<li>
@@ -294,7 +317,7 @@ export default function PrivacyPage() {
className="hover:text-foreground hover:underline"
href="#rights"
>
6. Your rights (GDPR Art. 15-22, DSGVO) / Ihre Rechte
6. Ihre Rechte / Your rights (GDPR Art. 15-22, DSGVO)
</a>
</li>
<li>
@@ -302,8 +325,8 @@ export default function PrivacyPage() {
className="hover:text-foreground hover:underline"
href="#medical-boundary"
>
7. Medical-device boundary (EU MDR 2017/745, MDCG 2021-24) /
Medizinprodukte-Grenze
7. Medizinprodukte-Grenze / Medical-device boundary (EU MDR
2017/745, MDCG 2021-24)
</a>
</li>
<li>
@@ -311,8 +334,8 @@ export default function PrivacyPage() {
className="hover:text-foreground hover:underline"
href="#apple-categories"
>
8. Apple App Store privacy categories / Apple-App-Store-
Datenschutzkategorien
8. Apple-App-Store- Datenschutzkategorien / Apple App Store
privacy categories
</a>
</li>
<li>
@@ -320,7 +343,7 @@ export default function PrivacyPage() {
className="hover:text-foreground hover:underline"
href="#children"
>
9. Children / Kinder
9. Kinder / Children
</a>
</li>
<li>
@@ -328,7 +351,7 @@ export default function PrivacyPage() {
className="hover:text-foreground hover:underline"
href="#changes"
>
10. Changes to this policy / Änderungen
10. Änderungen / Changes to this policy
</a>
</li>
<li>
@@ -336,7 +359,7 @@ export default function PrivacyPage() {
className="hover:text-foreground hover:underline"
href="#contact"
>
11. Contact / Kontakt
11. Kontakt / Contact
</a>
</li>
</ol>
@@ -411,7 +411,7 @@ export function InviteTokensSection() {
type="button"
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
className="text-muted-foreground hover:text-foreground"
aria-label={t("admin.invites.revokeAria")}
disabled={revoke.isPending}
onClick={() => setRevokeTarget(invite)}
@@ -479,7 +479,7 @@ export function InviteTokensSection() {
type="button"
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
className="text-muted-foreground hover:text-foreground"
disabled={revoke.isPending}
onClick={() => setRevokeTarget(invite)}
>
@@ -254,7 +254,7 @@ export function UserManagementSection() {
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive min-h-11 min-w-11 px-2 text-xs"
className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 px-2 text-xs"
onClick={() => setLogoutTarget(u)}
disabled={u.id === currentUserId}
title={
+44 -2
View File
@@ -48,11 +48,51 @@ import { useTranslations } from "@/lib/i18n/context";
const SLOW_HINT_DELAY_MS = 3_000;
/**
* The footer a loaded `<HealthChart>` paints under its plot: the row of day
* dots and its caption on a day-linked chart, and the collapsed "show as
* table" disclosure. A loading shell without it was ~100 px shorter than the
* card it turned into (CLS on every metric page, the BMI page most visibly),
* so the shells reserve the same boxes: `h-3` rug, `mt-1` caption (two lines
* on a phone, one from `sm`), and the disclosure's `mt-3 border-t pt-3` frame
* around its `min-h-11` button.
*/
export function ChartFooterReserve({
dayLinks = false,
dataTable = false,
}: {
dayLinks?: boolean;
dataTable?: boolean;
}) {
if (!dayLinks && !dataTable) return null;
return (
<div aria-hidden="true" data-slot="chart-footer-reserve">
{dayLinks ? (
<>
<div className="h-3" />
<div className="mt-1 h-9 sm:h-4" />
</>
) : null}
{dataTable ? (
<div className="border-border mt-3 border-t pt-3">
<div className="h-11" />
</div>
) : null}
</div>
);
}
export function ChartSkeleton({
className,
mini = false,
dayLinks = false,
dataTable = false,
}: {
className?: string;
/** Reserve the day-dot row + caption a day-linked chart paints. */
dayLinks?: boolean;
/** Reserve the collapsed data-table disclosure. */
dataTable?: boolean;
/**
* When true, render the compact variant used inside the trends-row
* chart slot (140 px band, lighter chrome). Defaults to false for
@@ -106,8 +146,9 @@ export function ChartSkeleton({
{/* Header row — title + range tabs match the real chart's
`mb-4 flex flex-col gap-2 sm:flex-row` chrome. */}
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<Skeleton className="h-4 w-32" />
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-32" />
{/* The range tabs are 44 px tap targets, so the row is too. */}
<div className="flex h-11 items-center gap-2">
<Skeleton className="h-6 w-12" />
<Skeleton className="h-6 w-12" />
<Skeleton className="h-6 w-12" />
@@ -118,6 +159,7 @@ export function ChartSkeleton({
{/* Chart band — mirrors the same `h-[var(--chart-height,…)]`
contract `health-chart.tsx` paints at line 1087-1089. */}
<Skeleton className="h-[var(--chart-height,240px)] w-full md:h-[var(--chart-height-md,280px)]" />
<ChartFooterReserve dayLinks={dayLinks} dataTable={dataTable} />
{showSlowHint && (
<p
+34 -8
View File
@@ -29,20 +29,46 @@ import { importWithRetry } from "@/lib/retry-import";
* to ONE error card with a reload affordance instead of bubbling to the
* route-level `error.tsx`.
*/
const HealthChartLazy = dynamic(
() =>
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
(mod) => ({ default: mod.HealthChart }),
),
{ ssr: false, loading: () => <ChartSkeleton /> },
);
const loadHealthChart = () =>
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
(mod) => ({ default: mod.HealthChart }),
);
// One lazy boundary per footer shape: `next/dynamic`'s loading slot gets no
// props, so the shell that must match a day-linked chart with its data table
// is its own boundary. All four resolve the same chunk.
const HealthChartLazy = dynamic(loadHealthChart, {
ssr: false,
loading: () => <ChartSkeleton />,
});
const HealthChartLazyDay = dynamic(loadHealthChart, {
ssr: false,
loading: () => <ChartSkeleton dayLinks />,
});
const HealthChartLazyTable = dynamic(loadHealthChart, {
ssr: false,
loading: () => <ChartSkeleton dataTable />,
});
const HealthChartLazyDayTable = dynamic(loadHealthChart, {
ssr: false,
loading: () => <ChartSkeleton dayLinks dataTable />,
});
export function HealthChartDynamic(
props: ComponentProps<typeof HealthChartLazy>,
): ReactElement {
const Lazy = props.mini
? HealthChartLazy
: props.dayLinks
? props.showDataTable
? HealthChartLazyDayTable
: HealthChartLazyDay
: props.showDataTable
? HealthChartLazyTable
: HealthChartLazy;
return (
<ChartErrorBoundary>
<HealthChartLazy {...props} />
<Lazy {...props} />
</ChartErrorBoundary>
);
}
+16 -4
View File
@@ -68,6 +68,7 @@ import { bucketCaptionKey } from "@/lib/charts/bucket-caption";
import type { MeasurementType } from "@/generated/prisma/client";
import { ChartOverlayControls } from "./chart-overlay-controls";
import { ChartDataTable, type ChartDataTableColumn } from "./chart-data-table";
import { ChartFooterReserve } from "./chart-skeleton";
import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs";
import { useViewportWidth } from "@/hooks/use-viewport-width";
import { computeTickPositions } from "@/lib/charts/x-axis-density";
@@ -1309,10 +1310,13 @@ export function HealthChart({
// Report the visible-range stats up to the sub-page so the shared
// `<MetricStatStrip>` can read them. Effect (not render-time call) so the
// parent state update never fires during this component's render.
// Only once the read has settled: a report during the initial fetch would
// tell the strip "no window" and it would paint the full-range summary
// first. A failed read settles too, so the strip never waits forever.
useEffect(() => {
if (mini) return;
onVisibleStats?.(visibleStatsByType);
}, [mini, visibleStatsByType, onVisibleStats]);
if (mini || isLoading) return;
onVisibleStats?.(isError ? null : visibleStatsByType);
}, [mini, isLoading, isError, visibleStatsByType, onVisibleStats]);
// v1.4.16 phase B8 — comparison overlay.
//
@@ -1942,7 +1946,15 @@ export function HealthChart({
// card height never jumps when the data lands — the dashboard's
// shared-reveal overlay and every insights mount stay
// layout-shift-free.
<Skeleton className={`w-full ${chartHeightClass}`} />
<>
<Skeleton className={`w-full ${chartHeightClass}`} />
{mini ? null : (
<ChartFooterReserve
dayLinks={dayLinksActive}
dataTable={showDataTable}
/>
)}
</>
) : isError ? (
// v1.16.8 — a failed query paints as an ERROR with a retry
// affordance, not as the "no data in this range" empty state.
@@ -32,6 +32,7 @@
* `findAllByType` reconciliation; the dashboard's `next/dynamic` for
* the wrapper itself stays intact.
*/
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useMemo, useState, useRef } from "react";
import { queryKeys } from "@/lib/query-keys";
@@ -50,6 +51,8 @@ import { ComplianceInfoTip } from "@/components/medications/card-parts/complianc
import { TileHeader } from "@/components/insights/tile-header";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/ui/empty-state";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useAuth } from "@/hooks/use-auth";
import {
useDateFormatPreference,
@@ -276,6 +279,22 @@ export function MedicationComplianceChart({
enabled: isAuthenticated,
});
// An empty compliance window means one of two things: no dose fell in it,
// or there is no medication at all. The second is not "no data in the
// chosen range" — it is a first step, so the tile offers it. The list
// read is the medications page's own cell (usually warm already).
const complianceEmpty = !isLoading && !isError && (data?.length ?? 0) === 0;
const medicationList = useQuery({
queryKey: queryKeys.medications(),
queryFn: () => apiGet<{ id: string }[]>("/api/medications"),
enabled: isAuthenticated && complianceEmpty,
});
const noMedications =
complianceEmpty &&
medicationList.isSuccess &&
(medicationList.data?.length ?? 0) === 0;
const { canWriteDomain } = useRecordCapabilities();
// v1.16.0 — report the settled query to the dashboard's shared
// reveal gate (see `onDataReady` prop doc).
//
@@ -533,6 +552,24 @@ export function MedicationComplianceChart({
actionContext={displayTitle}
onAction={() => void refetch()}
/>
) : !hasData && noMedications ? (
<EmptyState
variant="plain"
size="compact"
data-slot="medication-compliance-no-medications"
className="flex h-48 items-center justify-center"
icon={<Pill className="size-4" />}
title={t("medications.emptyTitle")}
action={
canWriteDomain("medications") ? (
<Button asChild size="sm">
<Link href="/medications/new">
{t("charts.medicationsEmptyAction")}
</Link>
</Button>
) : undefined
}
/>
) : !hasData ? (
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
{t("charts.noData")}
+1 -1
View File
@@ -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"
>
<X className="size-4" aria-hidden="true" />
</button>
@@ -146,6 +146,6 @@ describe("<RecentWorkoutsTile>", () => {
expect(html).toContain("Running");
expect(html).toContain("Cycling");
expect(html).toContain("30m");
expect(html).toContain("1h 00m");
expect(html).toContain("1h 0m");
});
});
+24 -2
View File
@@ -10,7 +10,7 @@
* Extracted from the dashboard page; the page owns the open-state the
* dropdown items set via `onQuickEntry`.
*/
import { useMemo } from "react";
import { useMemo, useState } from "react";
import Link from "next/link";
import {
Activity,
@@ -37,6 +37,7 @@ import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { hourInTz } from "@/lib/tz/format";
import {
CAPTURE_KIND_ORDER,
CapturePicker,
visibleCaptureKinds,
} from "@/components/layout/capture-picker";
import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets";
@@ -49,6 +50,7 @@ export function DashboardHeader({
const { t } = useTranslations();
const { user } = useAuth();
const mounted = useMounted();
const [captureOpen, setCaptureOpen] = useState(false);
// The quick-add offers four kinds, and each one is asked about its own
// section: a reading under `measurements`, a dose under `medications`, a
// mood entry under `mind`, a workout in one's own record only. The coarse `canAdd` this used to gate the whole
@@ -149,6 +151,26 @@ export function DashboardHeader({
</Link>
</Button>
)}
{/* On the phone shell the add button opens the same capture
picker sheet the bottom nav's plus opens: one way to add a
thing, not a dropdown here and a sheet one thumb below. */}
{canAddAnything && (
<Button
size="default"
className="shell-desktop:hidden min-h-11"
onClick={() => setCaptureOpen(true)}
aria-haspopup="dialog"
aria-expanded={captureOpen}
data-slot="dashboard-quick-add-capture"
data-tour-id="dashboard-quick-add"
>
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
)}
{canAddAnything && (
<CapturePicker open={captureOpen} onOpenChange={setCaptureOpen} />
)}
{canAddAnything && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -166,7 +188,7 @@ export function DashboardHeader({
+ label keep the same visual contract. */}
<Button
size="default"
className="min-h-11 sm:min-h-9"
className="shell-mobile:hidden min-h-11 sm:min-h-9"
data-tour-id="dashboard-quick-add"
>
<Plus className="h-4 w-4" />
@@ -3,6 +3,7 @@
import Link from "next/link";
import { Activity } from "lucide-react";
import { formatDurationMinutes } from "@/lib/i18n/duration";
import { useTranslations } from "@/lib/i18n/context";
import { useWorkouts, type WorkoutListEntry } from "@/hooks/use-workouts";
import { cn } from "@/lib/utils";
@@ -31,13 +32,6 @@ import { iconForSport } from "@/lib/workouts/sport-icons";
* round-trip per session.
*/
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 formatDayLabel(iso: string, locale: string): string {
const d = new Date(iso);
return getDateTimeFormat(locale, {
@@ -154,7 +148,10 @@ export function RecentWorkoutsTile() {
{formatDayLabel(workout.startedAt, locale)}
</span>
<span className="ml-1 shrink-0 text-xs font-medium tabular-nums">
{formatDuration(workout.durationSec)}
{formatDurationMinutes(
Math.round(workout.durationSec / 60),
t,
)}
</span>
</Link>
</li>
+1 -1
View File
@@ -73,7 +73,7 @@ export function DeleteButton({
<Button
variant="ghost"
size="icon-lg"
className={`text-destructive ${className}`}
className={`text-muted-foreground hover:text-foreground ${className}`}
aria-label={confirmLabel ?? t("common.delete")}
title={triggerTitle ?? confirmLabel ?? t("common.delete")}
>
@@ -82,7 +82,7 @@ export function SelectionActionBar({
// an `sm:` drop sat the bar behind the still-visible nav 640-767px.
// z-30 keeps the bar above page content without needing to beat the
// (now-cleared) nav's z-50.
className="bg-card border-border sticky bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] z-30 mt-2 flex items-center justify-between gap-2 rounded-lg border p-2 shadow-lg md:bottom-4"
className="bg-card border-border shell-desktop:bottom-4 sticky bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] z-30 mt-2 flex items-center justify-between gap-2 rounded-lg border p-2 shadow-lg"
>
<div className="flex items-center gap-1">
<Button
+31 -4
View File
@@ -161,6 +161,31 @@ const RUNNING_COLOR: Record<DayRunningKind, string> = {
lifeEvent: "var(--chart-2)",
};
/**
* The server names a few records by a sentinel rather than by text it would
* have to translate: a cycle is titled "cycle", a trip "travel", a cycle
* day carries its flow level. They are worded here, in the reader's
* language; every other title is the record's own text.
*/
function dayTitle(title: string, t: (key: string) => string): string {
if (title === "cycle") return t("nav.cycle");
if (title === "travel") return t("day.travel");
return title;
}
const FLOW_LEVELS = new Set(["NONE", "SPOTTING", "LIGHT", "MEDIUM", "HEAVY"]);
function dayMeta(
kind: string,
meta: string,
t: (key: string) => string,
): string {
if (kind === "cycleDayLog" && FLOW_LEVELS.has(meta)) {
return `${t("cycle.flow.label")}: ${t(`cycle.flow.${meta}`)}`;
}
return meta;
}
export function DayRunning({ items }: { items: readonly DayRunningItem[] }) {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -192,7 +217,7 @@ export function DayRunning({ items }: { items: readonly DayRunningItem[] }) {
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{item.title}
{dayTitle(item.title, t)}
</span>
<span className="text-muted-foreground block truncate text-xs">
{parts.join(" · ")}
@@ -431,14 +456,16 @@ export function DayEvents({ events }: { events: readonly DayEvent[] }) {
data-slot="day-event-link"
className="focus-visible:ring-ring/50 block text-sm leading-5 font-medium after:absolute after:inset-0 after:rounded-md after:content-[''] focus-visible:outline-none focus-visible:after:ring-[3px] focus-visible:after:ring-inherit"
>
{event.title}
{dayTitle(event.title, t)}
</Link>
) : (
<p className="text-sm leading-5 font-medium">{event.title}</p>
<p className="text-sm leading-5 font-medium">
{dayTitle(event.title, t)}
</p>
)}
{event.meta ? (
<p className="text-muted-foreground mt-0.5 text-xs">
{event.meta}
{dayMeta(event.kind, event.meta, t)}
</p>
) : null}
{event.note ? (
+1 -1
View File
@@ -276,7 +276,7 @@ export function DayView({
data-slot="day-body"
className={cn(
"flex min-h-0 flex-1 flex-col overflow-x-clip overflow-y-auto overscroll-contain",
compact ? "gap-6 px-4 pt-0.5 pb-4" : "gap-6 px-6 pt-5 pb-6",
compact ? "gap-6 px-4 pt-0.5 pb-4" : "gap-6 px-6 pt-4 pb-6",
)}
>
{/* What the day holds, as meta under the header. The line keeps its
+10 -8
View File
@@ -984,13 +984,16 @@ export function DocumentsView() {
<>
{/* Renders only with a connected Paperless-ngx or Papra. */}
<ImportFromSourceButton className="min-h-11 sm:min-h-9" />
<Button
className="min-h-11 sm:min-h-9"
onClick={() => uploadInputRef.current?.click()}
>
<Upload className="size-4" aria-hidden />
{t("documents.pageUpload")}
</Button>
{/* An empty vault's empty state carries the upload. */}
{showEmpty && !isFiltered ? null : (
<Button
className="min-h-11 sm:min-h-9"
onClick={() => uploadInputRef.current?.click()}
>
<Upload className="size-4" aria-hidden />
{t("documents.pageUpload")}
</Button>
)}
</>
) : null}
</div>
@@ -1072,7 +1075,6 @@ export function DocumentsView() {
icon={<FolderOpen className="size-6" aria-hidden />}
title={t("documents.empty.title")}
description={t("documents.empty.description")}
ctaSize="lg"
action={
canManageDocuments ? (
<Button
@@ -86,7 +86,12 @@ export function ProceduresSection({ enabled = true }: { enabled?: boolean }) {
titleId="procedures-section-title"
title={t("encounters.procedures.title")}
description={t("encounters.procedures.description")}
actions={addButton}
// The empty state carries the add action while there is none.
actions={
!list.isPending && !list.isError && total === 0 && !filtered
? null
: addButton
}
/>
{list.isError ? (
@@ -106,7 +111,6 @@ export function ProceduresSection({ enabled = true }: { enabled?: boolean }) {
title={t("encounters.procedures.emptyTitle")}
description={t("encounters.procedures.emptyDescription")}
action={addButton ?? undefined}
ctaSize="lg"
/>
) : (
<div className="space-y-4">
+3 -2
View File
@@ -91,7 +91,9 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
{t("practitioners.linkLabel")}
</Link>
</Button>
{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"
/>
) : (
<div className="space-y-6">
-1
View File
@@ -106,7 +106,6 @@ export function EpisodeMenu({
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onSelect={(e) => {
e.preventDefault();
setConfirmOpen(true);
@@ -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 ? (
<Card>
<CardContent>
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("illness.detail.note")}
</p>
<p className="text-foreground mt-1 text-sm whitespace-pre-wrap">
{episode.note}
</p>
</CardContent>
</Card>
) : 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. */}
<RevealWhenSettled
fallback={<Skeleton className="h-96 w-full rounded-xl" />}
>
{episode?.note ? (
<Card>
<CardContent>
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("illness.detail.note")}
</p>
<p className="text-foreground mt-1 text-sm whitespace-pre-wrap">
{episode.note}
</p>
</CardContent>
</Card>
) : 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 ? (
<IllnessDayTimeline
episodeId={episode.id}
onLogDay={() => 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 ? (
<IllnessDayTimeline
episodeId={episode.id}
onLogDay={() => 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 ? <EpisodeSymptomsCard episode={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 ? <EpisodeSymptomsCard episode={episode} /> : null}
{isLoading ? (
<Skeleton className="h-40 w-full" />
) : (
<IllnessCorrelationCard episodeId={episodeId} />
)}
{isLoading ? (
<Skeleton className="h-40 w-full" />
) : (
<IllnessCorrelationCard episodeId={episodeId} />
)}
{/* 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 ? <EpisodeDocumentsCard episodeId={episode.id} /> : 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 ? <EpisodeDocumentsCard episodeId={episode.id} /> : 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 ? <EpisodeVisitsCard episodeId={episode.id} /> : 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 ? <EpisodeVisitsCard episodeId={episode.id} /> : null}
</RevealWhenSettled>
{episode ? (
<>
+3 -2
View File
@@ -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) && (
<Button
onClick={() => setNewOpen(true)}
className="min-h-11 sm:min-h-9"
@@ -500,7 +502,6 @@ export function IllnessView() {
icon={<Stethoscope className="size-6" />}
title={t("illness.empty.title")}
description={t("illness.empty.body")}
ctaSize="lg"
action={
canAddEpisode ? (
<Button onClick={() => setNewOpen(true)}>
@@ -50,6 +50,15 @@ vi.mock("@/hooks/use-insights-layout-prefs", () => ({
// The coach-read strip paints nothing until it is client-mounted (React
// #418). Force the mounted branch so the band sentence reaches the markup.
vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true }));
// The chart is stubbed, so it never reports its window; settle the strip so
// it paints the summary this guard reads.
vi.mock("@/hooks/use-chart-domain-stats", () => ({
useChartDomainStats: () => ({
statsByType: null,
statsSettled: true,
onVisibleStats: () => {},
}),
}));
const analyticsMock = vi.fn();
vi.mock("@/hooks/use-insights-analytics", () => ({
@@ -81,8 +81,10 @@ describe("<LayoutCoachFab>", () => {
expect(html).toContain(
"bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)]",
);
// The bottom offset follows the shell, not a width: a phone held
// sideways keeps the bottom nav, so the FAB keeps clearing it there.
expect(html).toContain(
"md:bottom-[calc(env(safe-area-inset-bottom,0px)+2rem)]",
"shell-desktop:bottom-[calc(env(safe-area-inset-bottom,0px)+2rem)]",
);
expect(html).toContain('data-keyboard-hide=""');
expect(html).not.toContain("md:bottom-6");
@@ -198,3 +198,30 @@ describe("<MetricStatStrip>", () => {
});
});
});
describe("<MetricStatStrip> waits for the chart's window", () => {
// The chart opens on a 30-day window. Painting the all-time summary until
// the chart reports, then swapping to the window, made every number jump
// a beat after the page loaded.
it("holds the cells while the chart's window is still pending", () => {
const html = render(
<MetricStatStrip summary={populated} unit="bpm" windowPending />,
);
expect(html).toContain('data-stat="min"');
expect(html).toContain('data-window-pending="true"');
expect(html).not.toContain("58.0 bpm");
expect(html).not.toContain("88.0 bpm");
});
it("paints the window's numbers once the chart reported", () => {
const html = render(
<MetricStatStrip
summary={populated}
unit="bpm"
windowStats={{ count: 5, min: 60, max: 80, median: 70, mean: 70 }}
/>,
);
expect(html).toContain("60.0 bpm");
expect(html).not.toContain("58.0 bpm");
});
});
@@ -100,8 +100,8 @@ describe("<WorkoutDetailHeader>", () => {
it("renders the sport label, source, and duration summary", () => {
const html = render(<WorkoutDetailHeader workout={FIXTURE} />);
expect(html).toContain("Running");
expect(html).toContain("APPLE_HEALTH");
expect(html).toContain("30m 00s");
expect(html).toContain("Apple Health");
expect(html).toContain("30:00 min");
expect(html).toContain("5.20 km");
});
});
@@ -180,7 +180,7 @@ describe("<WorkoutDetailStats>", () => {
);
// 5200 m = 3.23 mi; 1800 s over it = 9:17 per mile; 12.5 m = 41.0 ft.
expect(html).toContain("3.23 mi");
expect(html).toContain("9:17 /mi");
expect(html).toContain("9:17 min/mi");
expect(html).toContain("41.0 ft");
// The sport-average line: 5800 m = 3.60 mi.
expect(html).toContain("3.60 mi");
@@ -196,7 +196,7 @@ describe("<WorkoutDetailStats>", () => {
it("keeps kilometres, minutes per km and metres for a metric reader", () => {
const html = render(<WorkoutDetailStats workout={FIXTURE} />);
expect(html).toContain("5.20 km");
expect(html).toContain("5:46 /km");
expect(html).toContain("5:46 min/km");
expect(html).toContain("12.5 m");
});
@@ -365,7 +365,7 @@ describe("<WorkoutDetailSplits>", () => {
expectSingleSectionHeading(html, "Splits");
expect(html).toContain('data-slot="workout-detail-splits"');
expect(html).toContain("5:00");
expect(html).toContain("4:48 /km");
expect(html).toContain("4:48 min/km");
});
});
@@ -340,7 +340,6 @@ function HistoryRow({
{t("insights.coach.frame.rename")}
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
data-slot="coach-history-delete"
onSelect={() => onDelete(c.id)}
>
@@ -3,6 +3,7 @@
import { Sparkles } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { resolveIntlLocale } from "@/lib/format-locale";
import { useDerivedMetric } from "./use-derived-metric";
import { Skeleton } from "@/components/ui/skeleton";
import { QueryErrorRow } from "@/components/ui/query-error-row";
@@ -109,7 +110,7 @@ export function CompositeScoreAnatomy({
metric,
className,
}: CompositeScoreAnatomyProps) {
const { t } = useTranslations();
const { t, locale } = useTranslations();
const query = useDerivedMetric<
SleepScoreValue | ReadinessValue | WellnessScoreValue
@@ -135,7 +136,15 @@ export function CompositeScoreAnatomy({
// the displayed framing honest per-score. `null` (RECOVERY / STRESS, or no
// cache row yet) renders nothing.
let anchorLine: string | null = null;
if (metric === "STRAIN_SCORE" && data?.status === "ok" && data.value) {
// Strain served from the device's own day strain (no computed proxy in the
// window): the card says so, and the course below charts that same series.
const deviceStrain =
metric === "STRAIN_SCORE" && data?.status === "ok" && data.value
? ((data.value as WellnessScoreValue).device ?? null)
: null;
if (deviceStrain) {
anchorLine = t("insights.derived.composite.STRAIN_SCORE.deviceSource");
} else if (metric === "STRAIN_SCORE" && data?.status === "ok" && data.value) {
const anchor = (data.value as WellnessScoreValue).anchor;
if (anchor === "personal") {
anchorLine = t("insights.derived.composite.STRAIN_SCORE.anchorPersonal");
@@ -150,7 +159,11 @@ export function CompositeScoreAnatomy({
// above the method in warning colour is gone: it repeated on every score
// page what the method and the page explainer already say, and read as an
// alarm. The method copy closes the card on its own.
const method = (
// Served from the device, the proxy's method does not describe the number
// on the card; the device line does, on its own.
const method = deviceStrain ? (
anchorLine
) : (
<>
{t(meta.methodKey)}
{anchorLine ? <span className="mt-1 block">{anchorLine}</span> : null}
@@ -238,7 +251,15 @@ export function CompositeScoreAnatomy({
// READINESS component keys verbatim. STRESS / STRAIN stay row-less.
const v = data.value as WellnessScoreValue;
score = v.score;
caption = t(`insights.derived.scoreRing.band.${v.band}`);
caption = v.device
? t("insights.derived.composite.STRAIN_SCORE.deviceCaption", {
value: v.device.value.toLocaleString(resolveIntlLocale(locale), {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}),
max: v.device.scaleMax,
})
: t(`insights.derived.scoreRing.band.${v.band}`);
if (metric === "RECOVERY_SCORE" && v.components) {
contributors = v.components
.map((c) => ({
@@ -275,7 +296,10 @@ export function CompositeScoreAnatomy({
data.status !== "ok" ? null : metric === "RECOVERY_SCORE" ||
metric === "STRESS_SCORE" ||
metric === "STRAIN_SCORE" ? (
<ScoreHistoryChart type={metric} hue={METRIC_HUE[metric]} />
<ScoreHistoryChart
type={deviceStrain ? "DAY_STRAIN" : metric}
hue={METRIC_HUE[metric]}
/>
) : series && series.length >= 2 ? (
<ScoreHistoryCard
series={series}
@@ -33,8 +33,9 @@ export function ScoreHistoryChart({
type,
hue,
}: {
/** The stored measurement type the score is written as. */
type: "RECOVERY_SCORE" | "STRESS_SCORE" | "STRAIN_SCORE";
/** The stored measurement type the score is written as — or, for a strain
* score served from the device, the device's own `DAY_STRAIN`. */
type: "RECOVERY_SCORE" | "STRESS_SCORE" | "STRAIN_SCORE" | "DAY_STRAIN";
hue: RingHue;
}) {
const { t } = useTranslations();
@@ -266,7 +266,7 @@ export function HealthKitMetricPage({
// v1.12.8 — shared visible-range state. The chart reports the per-type
// Min / Max / Median / Mean for the data under its active range tab; the
// stat strip reads it back for this page's single series.
const { statsByType, onVisibleStats } = useChartDomainStats();
const { statsByType, statsSettled, onVisibleStats } = useChartDomainStats();
// v1.17.0 — fallback-type swap. When the primary type has no rows but the
// declared fallback does (HRV: SDNN empty, RMSSD present), key the chart,
@@ -446,6 +446,7 @@ export function HealthKitMetricPage({
}
icon={statIcon}
windowStats={statsByType?.[effectiveType] ?? null}
windowPending={!statsSettled}
medianLabel={statMedianLabel}
/>
}
@@ -86,6 +86,9 @@ const AXIS_TICKS = [0, 360, 720, 1080, 1440];
* navigator still works from there, so "the day before" stays one tap away.
* Absent, the view opens on today and keeps tracking it.
*/
/** Fewest present buckets that make a course worth drawing across 24 h. */
const MIN_BUCKETS_FOR_COURSE = 3;
export function IntradayPulseChart({
userTimezone,
initialDateKey,
@@ -194,13 +197,10 @@ export function IntradayPulseChart({
return (
<div
data-slot="intraday-pulse-chart"
// `.metric-accent` — the tension layer's identity edge on the CARD
// shell only (`--tile-stress`, the wellness vocabulary's autonomic
// hue, per the plan's §3.6 hue-family call); the chart inside stays
// untouched. The same hue marks the `tension_window` rail card, so
// the two S11 surfaces read as one family.
className="bg-card metric-accent space-y-1.5 rounded-xl border p-4 md:p-6"
style={{ "--tile-hue": "var(--tile-stress)" } as React.CSSProperties}
// A plain card, like every other chart card and the day view it
// opens into. The coloured identity edge it used to carry read as a
// warning stripe beside a workout.
className="bg-card space-y-1.5 rounded-xl border p-4 md:p-6"
>
<TileHeader
icon={Activity}
@@ -258,7 +258,10 @@ export function IntradayPulseChart({
onRetry={() => refetch()}
className="border-0 bg-transparent shadow-none"
/>
) : chartData.length === 0 ? (
) : (data?.series?.length ?? 0) < MIN_BUCKETS_FOR_COURSE ? (
// A lone reading or two on a 24-hour axis drew a dot in an empty
// field and called it the course of the day. Below a handful of
// buckets the card says there is not enough for a course.
<div className="text-muted-foreground flex h-44 items-center justify-center rounded-lg border border-dashed text-sm">
{t("insights.intradayPulse.empty")}
</div>
+1 -1
View File
@@ -246,7 +246,7 @@ export function LayoutCoachFab() {
// phone held sideways (844 px, past `md`) has its notch on one
// side, and an installed iPad its home indicator along the bottom.
"fixed right-[calc(env(safe-area-inset-right,0px)+1.5rem)] z-40 size-14 rounded-full shadow-lg md:right-[calc(env(safe-area-inset-right,0px)+2rem)]",
"bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] md:bottom-[calc(env(safe-area-inset-bottom,0px)+2rem)]",
"shell-desktop:bottom-[calc(env(safe-area-inset-bottom,0px)+2rem)] bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)]",
// Dark glyph on the purple/pink gradient — white sat at
// ≈2.3:1 against the gradient midpoint; the background token
// reads ≈6.5:1.
@@ -11,7 +11,7 @@ import { EmptyState } from "@/components/ui/empty-state";
*
* Pre-fix, every sub-page repeated the same five-line incantation:
*
* <EmptyState icon={…} title={…} description={…} ctaSize="lg" action={…} />
* <EmptyState icon={…} title={…} description={…} action={…} />
* <CoachLaunchButton prefill={…} />
*
* The action slot was always a `<Button asChild><Link href="…">…</Link></Button>`
@@ -64,7 +64,6 @@ export function MetricEmptyState({
icon={icon}
title={title}
description={description}
ctaSize="lg"
action={cta}
/>
<CoachLaunchButton prefill={coachPrefill ?? undefined} />
Loaded 100 of 169 files, more files were not shown because too many files have changed in this diff. Show more