mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
Merge the second UI round into release/v1.42.0
This commit is contained in:
commit
8fc467f8c5
169 files changed
+1981
-981
No files matched your search
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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?"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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?"
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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?"
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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?"
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("&", "&")}</span>`,
|
||||
);
|
||||
expect(html, `missing English title ${JSON.stringify(en)}`).toContain(
|
||||
` / ${en}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+43
-20
@@ -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 &
|
||||
Speicherdauer
|
||||
5. Speicherung & 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={
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in new issue
Block a user