diff --git a/CHANGELOG.md b/CHANGELOG.md index 399a2a5e6..58f6dac65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,47 @@ ## [Unreleased] +## [1.30.0] — 2026-07-18 — Quality pass + +A broad correctness, performance, and polish release, plus two new features. Nothing new is required of you; existing data is untouched. + +### Added + +- **Document summaries.** An uploaded document can get a short plain-language summary, generated once at upload and stored encrypted. It runs only when the external-AI document reading you already opted into is on, and never touches older documents. +- **Efficacy chart, readable.** The medication efficacy chart scales its axis to the data (a real trend no longer looks flat), gains a “% since start” view, and the history tab reaches your full intake history, not just the last 90 days. +- **A metric catalog.** A quiet “All metrics” view lists everything HealthLog can track and which device supplies each — so a metric you don’t have data for stays out of your daily views but is still discoverable in one place. + +### Fixed — correctness + +- **The number shown matches reality.** The workouts and measurements lists now lead with the most recent entries (a long history no longer hid recent days), and a metric recorded by two devices on the same day is counted once, matching the dashboard — instead of being summed twice. +- **Strava workouts carry their real sport** (cycling, running, …) — like WHOOP already did — with a history backfill, and back-to-back rides no longer collapse into one. +- **Personal records and correlations** no longer double-count multi-source days or mix per-reading and daily-total values. + +### Fixed — medical safety + +- The hypoglycemia alert now uses your own glucose unit; the doctor-report PDF states your own timezone; the crisis card offers Austrian and Swiss lines, not only German ones; and resting-pulse labels drop clinical-verdict wording for calmer, neutral framing. + +### Fixed — the daily view + +- The Today view is faster (its heaviest reads are now cached instead of recomputed on every refresh) and updates immediately after you log a measurement, mood, or water — not only medication. +- The intraday heart-rate chart no longer draws a continuous shape across gaps in sparse data, and shows how many readings it’s based on. A failed load shows a retry instead of an empty state. + +### Fixed — onboarding, dates, consistency, accessibility + +- Connecting a device no longer dead-ends on a fresh account, and the setup steps (including the callback URL) are shown up front. +- A date from a previous year now shows the year, everywhere it’s displayed. +- Contrast, chart colours in the light theme, and a handful of duplicated components were tidied; keyboard access and headings improved on several surfaces; the insights and settings menus are grouped more sensibly. + +### Changed + +- **A fuller backup.** The full backup now also includes lab results, illness episodes, allergies, family history, workouts, and a documents list. What isn’t included (raw document files, workout GPS/heart-rate detail) is stated plainly. + +### Performance + +- The Today digest and the insights cards read through the pre-aggregated tier instead of scanning raw rows; batch imports write in one pass. + +Two migrations (0250, 0251). No breaking changes. + ## [1.29.5] — 2026-07-17 ### Security diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a25fdae25..2172699f1 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.29.5 + version: 1.30.0 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -23987,6 +23987,10 @@ components: anyOf: - type: string - type: "null" + nextDueMedicationId: + anyOf: + - type: string + - type: "null" required: - activeCount - scheduledToday @@ -23995,12 +23999,14 @@ components: - nextDueAt - nextDueOverdue - nextDueMedicationName + - nextDueMedicationId additionalProperties: false description: "Today's medication block: active-medication count, today-window tally (scheduled / taken / skipped), and the earliest next-due slot across active medications. `nextDueOverdue: true` marks an OPEN overdue slot - (anchor passed, still inside its catch-up band). Staleness contract: the snapshot is cache-served, so a - `nextDueAt` in the past with `nextDueOverdue: false` means the anchor passed after the snapshot was built — - render the plain day summary, never an overdue state." + (anchor passed, still inside its catch-up band). `nextDueMedicationId` deep-links a consumer straight to the + overdue medication's card. Staleness contract: the snapshot is cache-served, so a `nextDueAt` in the past + with `nextDueOverdue: false` means the anchor passed after the snapshot was built — render the plain day + summary, never an overdue state." healthScore: anyOf: - type: object diff --git a/e2e/utils/mock-dashboard-snapshot.ts b/e2e/utils/mock-dashboard-snapshot.ts index 1edbc1e6a..a01771183 100644 --- a/e2e/utils/mock-dashboard-snapshot.ts +++ b/e2e/utils/mock-dashboard-snapshot.ts @@ -279,6 +279,7 @@ export function buildMockSnapshot( nextDueAt: null, nextDueOverdue: false, nextDueMedicationName: null, + nextDueMedicationId: null, }, healthScore: options.briefing ? { score: 84, band: "green", delta: -2, restMode: null } diff --git a/eslint.config.mjs b/eslint.config.mjs index 0f6b64259..9d66c3ee6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -77,6 +77,54 @@ const eslintConfig = defineConfig([ "healthlog/spacing-scale": "error", }, }, + // v1.28.17 — every recharts-rendering component funnels through the + // shared `chart-runtime.ts` barrel (see its file header): pointing N + // `next/dynamic` boundaries at N different modules mints a separate + // ~312 KB recharts chunk per boundary, a regression the project already + // paid for once. Direct `from "recharts"` imports outside the barrel's + // own static import graph previously only tripped the bundle-budget + // gate at PR-CI; this rule catches the same mistake at local lint, + // mirroring how `healthlog/safe-fetch-required` scopes its own + // wrapper-internals exemption by file list. + { + files: ["src/**/*.{ts,tsx}"], + ignores: [ + "src/components/charts/chart-runtime.ts", + "src/components/admin/host-metrics-chart.tsx", + "src/components/charts/health-chart.tsx", + "src/components/charts/medication-compliance-chart.tsx", + "src/components/charts/mood-chart.tsx", + "src/components/charts/nutrient-daily-bar-chart.tsx", + "src/components/charts/scatter-correlation-chart.tsx", + "src/components/custom-metrics/custom-metric-chart.tsx", + "src/components/cycle/bbt-chart.tsx", + "src/components/insights/derived/delta-sparkline.tsx", + "src/components/insights/intraday-pulse-chart.tsx", + "src/components/insights/mood/mood-distribution-chart.tsx", + "src/components/insights/mood/mood-time-of-day-chart.tsx", + "src/components/insights/mood/mood-weekday-chart.tsx", + "src/components/insights/sleep-stage-stacked-bar.tsx", + "src/components/labs/lab-biomarker-chart.tsx", + "src/components/medications/detail/efficacy/efficacy-chart.tsx", + "src/components/medications/dose-strength-curve.tsx", + "src/components/medications/drug-level-chart.tsx", + "src/components/mental-health/assessment-history-chart.tsx", + ], + rules: { + "no-restricted-imports": [ + "error", + { + paths: [ + { + name: "recharts", + message: + "Import chart components through @/components/charts/chart-runtime (the shared dynamic-import barrel), not recharts directly — a second static import mints a duplicate recharts chunk. Add the component to chart-runtime.ts's export list if it's a new chart.", + }, + ], + }, + ], + }, + }, ]); export default eslintConfig; diff --git a/knip.json b/knip.json index 90d913d63..01aeeff49 100644 --- a/knip.json +++ b/knip.json @@ -47,7 +47,6 @@ ".next/**", "**/__mocks__/**", "e2e/setup/test-helpers.ts", - "src/components/charts/compliance-line-chart.tsx", "src/lib/logging/index.ts" ], "ignoreDependencies": [ diff --git a/messages/de.json b/messages/de.json index 32ecf86db..6a3bb382f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -91,7 +91,7 @@ "colShare": "Anteil", "footerDisclaimer1": "Dieser Bericht wurde automatisch aus selbst erfassten Daten generiert und dient ausschließlich zur Unterstützung", "footerDisclaimer2": "des Arzt-Patienten-Gesprächs. Er ersetzt keine ärztliche Diagnose. Korrelationen implizieren keine Kausalität.", - "footerSource": "Quelle: HealthLog | Zeitzone: Europe/Berlin | Erstellt: {timestamp}", + "footerSource": "Quelle: HealthLog | Zeitzone: {timezone} | Erstellt: {timestamp}", "typeWeight": "Körpergewicht", "typeBpSys": "Systolischer Blutdruck", "typeBpDia": "Diastolischer Blutdruck", @@ -405,7 +405,6 @@ "recentWorkouts": { "title": "Letzte Workouts", "viewAll": "Alle anzeigen", - "loading": "Workouts werden geladen…", "empty": { "title": "Noch keine Workouts", "cta": "Öffne die Apple-Health-App auf dem iPhone, damit Workouts hier erscheinen." @@ -587,6 +586,22 @@ "typeWho5Score": "WHO-5-Wert", "typeSciScore": "SCI-Wert" }, + "metricCatalog": { + "title": "Was HealthLog erfassen kann", + "description": "Alle Messwert-Bereiche, die HealthLog unterstützt — auch die, für die noch keine Daten vorliegen — und was sie liefert.", + "statusPresent": "Erfasst", + "statusAbsent": "Noch nicht erfasst", + "statusModuleOff": "Aus", + "connectCta": "Quelle verbinden", + "viewCta": "Ansehen", + "ecg": { + "source": "Einkanal-EKG-Streifen von einer kompatiblen Uhr — aktuell Withings ScanWatch — werden automatisch synchronisiert." + }, + "watchdog": { + "title": "Automatische Überwachung", + "description": "HealthLog beobachtet außerdem still im Hintergrund — Benachrichtigungen zu unregelmäßigem Rhythmus und Atemstörungen von einer verbundenen Uhr sowie Warnungen bei Abweichungen von deiner Baseline — und zeigt eine Karte nur, wenn etwas auffällt. Keine Karte ist ein gutes Zeichen, kein fehlendes Feature." + } + }, "mood": { "title": "Stimmung", "subtitle": "Stimmungsverlauf und Einträge", @@ -2476,7 +2491,8 @@ "documentsAttachedOne": "1 Dokument angehängt", "documentsAttachedOther": "{count} Dokumente angehängt", "railBadge": "Dokument-Chat (abgeschottet)", - "uploadError": "Upload fehlgeschlagen. Bitte versuche es erneut." + "uploadError": "Upload fehlgeschlagen. Bitte versuche es erneut.", + "notFoundError": "Dieser Dokumentlink ist nicht mehr gültig – es wird ein normaler Chat gestartet." } }, "relativeJustNow": "gerade eben", @@ -2487,6 +2503,7 @@ "relativeDaysAgoOne": "vor {count} Tag", "relativeDaysAgoOther": "vor {count} Tagen", "navOverview": "Übersicht", + "navCatalog": "Alle Messwerte", "navBloodPressure": "Blutdruck", "navWeight": "Gewicht", "navPulse": "Puls", @@ -2517,9 +2534,9 @@ "label": "Aktivität", "header": "Aktivität & Mobilität" }, - "cardiovascularParent": { - "label": "Herz-Kreislauf", - "header": "Herz-Kreislauf-Marker" + "heartParent": { + "label": "Herz", + "header": "Herzgesundheit" }, "hearingParent": { "label": "Gehör", @@ -3879,6 +3896,8 @@ "caption": "Dein Puls über den Tag, 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.", + "coverageMany": "Basierend auf {count} Messungen über {hours}h.", "baseline": "Ruhe", "today": "Heute", "previousDay": "Vorheriger Tag", @@ -4016,7 +4035,7 @@ }, "source": { "title": "Quelle verbinden", - "body": "Werte automatisch übernehmen oder weiter manuell eintragen. Hier wird nichts für dich eingerichtet – jede Karte zeigt nur, wo du sie verbindest.", + "body": "Werte automatisch übernehmen oder weiter manuell eintragen. Hier wird nichts für dich eingerichtet – jede Karte zeigt nur, wo du sie verbindest. Geräte-Quellen brauchen eine kostenlose Entwickler-App auf deinem eigenen Konto (etwa 5 Minuten); die Anleitung auf jeder Karte führt dich durch.", "recommended": "Empfohlen", "manual": { "title": "Manuelle Eingabe", @@ -4024,12 +4043,14 @@ }, "withings": { "title": "Withings", - "body": "Waagen, Blutdruckmesser und Tracker von Withings synchronisieren automatisch.", - "cta": "Withings verbinden" + "body": "Waagen, Blutdruckmesser und Tracker von Withings synchronisieren automatisch, sobald verbunden.", + "cta": "Withings verbinden", + "setupCta": "In den Einstellungen einrichten", + "setupHint": "Braucht zuerst deine eigene, kostenlose Withings-Entwickler-App – etwa 5 Minuten, einmalig." }, "more": { "title": "Weitere Quellen", - "body": "Verbinde eine davon in den Einstellungen, wann immer du bereit bist.", + "body": "Verbinde eine davon in den Einstellungen, wann immer du bereit bist – die meisten brauchen zuerst eine kostenlose Entwickler-App auf deinem eigenen Konto.", "appleHealth": "Apple Health", "whoop": "WHOOP", "oura": "Oura", @@ -4171,7 +4192,16 @@ "disableError": "Forschungsmodus konnte nicht deaktiviert werden. Bitte erneut versuchen." }, "shell": { - "sectionsNav": "Einstellungs-Bereiche" + "sectionsNav": "Einstellungs-Bereiche", + "groups": { + "account": "Konto", + "yourData": "Deine Daten", + "display": "Anzeige", + "healthProfile": "Gesundheitsprofil", + "ai": "KI", + "connectivity": "Konnektivität", + "system": "System" + } }, "sections": { "account": { @@ -4356,7 +4386,8 @@ }, "fullBackup": { "title": "Voll-Backup", - "description": "Eine einzige JSON-Datei mit allen Daten — gleiches Format wie das wöchentliche Auto-Backup. Praktisch zum Selbst-Wiederherstellen via Admin-Upload.", + "description": "Alle deine Daten in einer JSON-Datei — Messwerte, Medikamente, Stimmung, Zyklus, Laborwerte, Krankheitsepisoden, Allergien, Familienanamnese und Workouts. Gleiches Format wie das wöchentliche Auto-Backup.", + "scopeNote": "Die Originaldateien der Dokumente sowie GPS-Routen und Einzelmesswerte von Workouts sind nicht enthalten — lade diese separat aus dem Dokumententresor bzw. dem Workout-Export herunter.", "encryptToggle": "Mit einer Passphrase verschlüsseln", "passphrasePlaceholder": "Passphrase (mind. 12 Zeichen)", "passphraseHelp": "Leitet den Archivschlüssel per Argon2id ab und versiegelt ihn mit AES-256-GCM. Speichere die Datei mit der Endung .hlx.", @@ -5389,6 +5420,12 @@ "ouraDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der", "nightscoutDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der", "integrationCredentialsHint": "Wo du diese Zugangsdaten findest und wie du deine eigene App registrierst, steht in der Einrichtungsanleitung oben.", + "integrationRedirectGuide": { + "intro": "Die Verbindung läuft über deine eigene {provider}-Entwickler-App – etwa 5 Minuten Einrichtung:", + "step1": "Erstelle eine App im {provider}-Entwicklerportal.", + "step2": "Setze deren Weiterleitungs-/Callback-URL auf:", + "step3": "Trage die dort erhaltene Client-ID und das Client-Secret oben ein." + }, "security": { "sessionsTitle": "Aktive Sitzungen", "sessionsDescription": "Geräte und Browser, die aktuell bei deinem Konto angemeldet sind.", @@ -6029,6 +6066,7 @@ "colAction": "Aktion", "colDuration": "Dauer", "colTraceId": "Trace", + "viewDetails": "Details anzeigen für {action} um {timestamp}", "showing": "{count} von bis zu {bufferMax} gepufferten Events angezeigt", "empty": "Puffer ist leer", "emptyDescription": "Es passen noch keine Events zu den Filtern. Nach etwas Traffic auf Aktualisieren tippen.", @@ -6649,9 +6687,9 @@ "hypertensionGrade1": "Hypertonie Grad 1", "hypertensionGrade2": "Hypertonie Grad 2", "hypertensionGrade3": "Hypertonie Grad 3", - "bradycardia": "Bradykardie", + "belowTypicalRange": "Unter dem typischen Bereich", "elevated": "Erhöht", - "tachycardia": "Tachykardie", + "aboveTypicalRange": "Über dem typischen Bereich", "significantlyLow": "Deutlich niedrig", "slightlyLow": "Leicht niedrig", "onTarget": "Im Zielbereich", @@ -7898,14 +7936,14 @@ "bpLowDoctor": "Zwei aktuelle Messungen hatten einen systolischen Wert unter {systolic} mmHg. Wenn dir schwindelig oder schummrig ist, setz oder leg dich hin und kontaktiere deine Ärztin oder deinen Arzt, wenn es anhält.", "bpLowEmergency": "Zwei aktuelle Messungen hatten einen systolischen Wert unter {systolic} mmHg und du hast Beschwerden angegeben. Bei Ohnmachtsgefühl, Verwirrtheit oder kalter, feuchter Haut sofort den Notruf wählen.", "glucoseLowTitle": "Niedriger Blutzucker", - "glucoseLowDoctor": "Zwei aktuelle Messungen lagen unter 70 mg/dL ({value} mg/dL). Nimm schnell wirkende Kohlenhydrate, miss in 15 Minuten erneut und kontaktiere deine Ärztin oder deinen Arzt, wenn die Tiefs anhalten.", - "glucoseLowEmergency": "Zwei aktuelle Messungen lagen unter 70 mg/dL ({value} mg/dL) und du hast Beschwerden angegeben. Behandle jetzt mit schnellem Zucker; wenn das nicht geht oder dir sehr unwohl ist, wähle den Notruf.", + "glucoseLowDoctor": "Zwei aktuelle Messungen lagen unter {threshold} {unit} ({value} {unit}). Nimm schnell wirkende Kohlenhydrate, miss in 15 Minuten erneut und kontaktiere deine Ärztin oder deinen Arzt, wenn die Tiefs anhalten.", + "glucoseLowEmergency": "Zwei aktuelle Messungen lagen unter {threshold} {unit} ({value} {unit}) und du hast Beschwerden angegeben. Behandle jetzt mit schnellem Zucker; wenn das nicht geht oder dir sehr unwohl ist, wähle den Notruf.", "glucoseLowSevereTitle": "Sehr niedriger Blutzucker", - "glucoseLowSevereDoctor": "Zwei aktuelle Messungen lagen unter 54 mg/dL ({value} mg/dL). Behandle sofort mit schnell wirkenden Kohlenhydraten, miss in 15 Minuten erneut und kontaktiere deine Ärztin oder deinen Arzt.", - "glucoseLowSevereEmergency": "Zwei aktuelle Messungen lagen unter 54 mg/dL ({value} mg/dL) und du hast Beschwerden angegeben. Behandle jetzt; wenn du nichts bei dir behalten kannst oder dir sehr unwohl ist, wähle den Notruf.", + "glucoseLowSevereDoctor": "Zwei aktuelle Messungen lagen unter {threshold} {unit} ({value} {unit}). Behandle sofort mit schnell wirkenden Kohlenhydraten, miss in 15 Minuten erneut und kontaktiere deine Ärztin oder deinen Arzt.", + "glucoseLowSevereEmergency": "Zwei aktuelle Messungen lagen unter {threshold} {unit} ({value} {unit}) und du hast Beschwerden angegeben. Behandle jetzt; wenn du nichts bei dir behalten kannst oder dir sehr unwohl ist, wähle den Notruf.", "glucoseHighTitle": "Sehr hoher Blutzucker", - "glucoseHighDoctor": "Zwei aktuelle Messungen lagen bei {value} mg/dL oder höher. Miss erneut, trink ausreichend und kontaktiere möglichst bald deine Ärztin oder deinen Arzt, wenn es so hoch bleibt.", - "glucoseHighEmergency": "Zwei aktuelle Messungen lagen bei {value} mg/dL oder höher und du hast Beschwerden angegeben. Bei Erbrechen, schneller Atmung oder Verwirrtheit sofort den Notruf wählen." + "glucoseHighDoctor": "Zwei aktuelle Messungen lagen bei {value} {unit} oder höher. Miss erneut, trink ausreichend und kontaktiere möglichst bald deine Ärztin oder deinen Arzt, wenn es so hoch bleibt.", + "glucoseHighEmergency": "Zwei aktuelle Messungen lagen bei {value} {unit} oder höher und du hast Beschwerden angegeben. Bei Erbrechen, schneller Atmung oder Verwirrtheit sofort den Notruf wählen." } }, "version": { @@ -8042,6 +8080,12 @@ "nummerGegenKummer": { "name": "Nummer gegen Kummer (für junge Menschen)" }, + "telefonSeelsorgeAt": { + "name": "Telefonseelsorge Österreich (rund um die Uhr, kostenlos, vertraulich)" + }, + "dargeboteneHand": { + "name": "Die Dargebotene Hand (rund um die Uhr, kostenlos, vertraulich)" + }, "krisenchat": { "name": "krisenchat (24/7-Chat, junge Menschen)" }, @@ -8295,7 +8339,9 @@ "openLabel": "{title} öffnen", "attachmentBadge": "Nur Download", "uploading": "Wird hochgeladen…", - "dismissFailed": "Fehlgeschlagenen Upload ausblenden" + "dismissFailed": "Fehlgeschlagenen Upload ausblenden", + "processing": "Wird verarbeitet…", + "ready": "Bereit" }, "error": { "fileTooLarge": "Diese Datei ist größer als das Limit von {maxSize}.", diff --git a/messages/en.json b/messages/en.json index 33e1b8d63..2edd6c69f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -91,7 +91,7 @@ "colShare": "Share", "footerDisclaimer1": "This report was generated automatically from self-tracked data and is intended to support", "footerDisclaimer2": "the doctor–patient conversation. It does not replace a medical diagnosis. Correlations do not imply causation.", - "footerSource": "Source: HealthLog | Timezone: Europe/Berlin | Created: {timestamp}", + "footerSource": "Source: HealthLog | Timezone: {timezone} | Created: {timestamp}", "typeWeight": "Body weight", "typeBpSys": "Systolic blood pressure", "typeBpDia": "Diastolic blood pressure", @@ -405,7 +405,6 @@ "recentWorkouts": { "title": "Recent workouts", "viewAll": "View all", - "loading": "Loading workouts…", "empty": { "title": "No workouts yet", "cta": "Open Apple Health on your iPhone to sync workouts here." @@ -587,6 +586,22 @@ "typeWho5Score": "WHO-5 score", "typeSciScore": "SCI score" }, + "metricCatalog": { + "title": "What HealthLog can track", + "description": "Every metric domain HealthLog supports, whether or not you have data for it yet — and what would supply it.", + "statusPresent": "Tracked", + "statusAbsent": "Not tracked yet", + "statusModuleOff": "Off", + "connectCta": "Connect a source", + "viewCta": "View", + "ecg": { + "source": "Single-lead ECG strips from a compatible watch — Withings ScanWatch today — synced automatically." + }, + "watchdog": { + "title": "Automatic screening", + "description": "HealthLog also watches quietly in the background — irregular-rhythm and breathing-disturbance notifications from a connected watch, plus baseline-drift alerts — and surfaces a card only when something stands out. No card showing is a good sign, not a missing feature." + } + }, "mood": { "title": "Mood", "subtitle": "Mood tracker and entries", @@ -2476,7 +2491,8 @@ "documentsAttachedOne": "1 document attached", "documentsAttachedOther": "{count} documents attached", "railBadge": "Fenced document chat", - "uploadError": "Upload failed. Please try again." + "uploadError": "Upload failed. Please try again.", + "notFoundError": "That document link is no longer valid — starting a normal chat instead." } }, "relativeJustNow": "just now", @@ -2487,6 +2503,7 @@ "relativeDaysAgoOne": "{count} day ago", "relativeDaysAgoOther": "{count} days ago", "navOverview": "Overview", + "navCatalog": "All metrics", "navBloodPressure": "Blood Pressure", "navWeight": "Weight", "navPulse": "Pulse", @@ -2517,9 +2534,9 @@ "label": "Activity", "header": "Activity & mobility" }, - "cardiovascularParent": { - "label": "Cardiovascular", - "header": "Cardiovascular markers" + "heartParent": { + "label": "Heart", + "header": "Heart health" }, "hearingParent": { "label": "Hearing", @@ -3879,6 +3896,8 @@ "caption": "Your heart rate through the day, 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.", + "coverageMany": "Based on {count} readings across {hours}h.", "baseline": "Resting", "today": "Today", "previousDay": "Previous day", @@ -4016,7 +4035,7 @@ }, "source": { "title": "Connect a source", - "body": "Bring in measurements automatically, or stay on manual entry. Nothing here is configured for you — each card just shows where to connect it.", + "body": "Bring in measurements automatically, or stay on manual entry. Nothing here is configured for you — each card just shows where to connect it. Device sources need a free developer app registered on your own account (about 5 minutes); the setup guide on each card walks through it.", "recommended": "Recommended", "manual": { "title": "Manual entry", @@ -4024,12 +4043,14 @@ }, "withings": { "title": "Withings", - "body": "Scales, blood-pressure cuffs and trackers from Withings sync automatically.", - "cta": "Connect Withings" + "body": "Scales, blood-pressure cuffs and trackers from Withings sync automatically once connected.", + "cta": "Connect Withings", + "setupCta": "Set up in Settings", + "setupHint": "Needs your own free Withings developer app first — about 5 minutes, one time." }, "more": { "title": "More sources", - "body": "Connect any of these in Settings whenever you're ready.", + "body": "Connect any of these in Settings whenever you're ready — most need a free developer app registered on your own account first.", "appleHealth": "Apple Health", "whoop": "WHOOP", "oura": "Oura", @@ -4171,7 +4192,16 @@ "disableError": "Could not disable Research Mode. Please try again." }, "shell": { - "sectionsNav": "Settings sections" + "sectionsNav": "Settings sections", + "groups": { + "account": "Account", + "yourData": "Your data", + "display": "Display", + "healthProfile": "Health profile", + "ai": "AI", + "connectivity": "Connectivity", + "system": "System" + } }, "sections": { "account": { @@ -4356,7 +4386,8 @@ }, "fullBackup": { "title": "Full backup", - "description": "Single JSON file with everything — same shape as the weekly auto-backup. Useful for self-restore via admin upload.", + "description": "Every record you own in one JSON file — measurements, medications, mood, cycle, labs, illness episodes, allergies, family history, and workouts. Same shape as the weekly auto-backup.", + "scopeNote": "Original document files and workout GPS/sample data are not included — download those separately from the vault or the workout export.", "encryptToggle": "Encrypt with a passphrase", "passphrasePlaceholder": "Passphrase (min. 12 characters)", "passphraseHelp": "Derives the archive key with Argon2id and seals it with AES-256-GCM. Save the file with a .hlx extension.", @@ -5389,6 +5420,12 @@ "ouraDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the", "nightscoutDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the", "integrationCredentialsHint": "Where to find these credentials and how to register your own app is covered in the setup guide above.", + "integrationRedirectGuide": { + "intro": "This connects through your own {provider} developer app — about 5 minutes to set up:", + "step1": "Create an app in the {provider} developer portal.", + "step2": "Set its redirect / callback URL to:", + "step3": "Paste the client ID and client secret it gives you above." + }, "security": { "sessionsTitle": "Active sessions", "sessionsDescription": "Devices and browsers currently signed in to your account.", @@ -6029,6 +6066,7 @@ "colAction": "Action", "colDuration": "Duration", "colTraceId": "Trace", + "viewDetails": "View details for {action} at {timestamp}", "showing": "Showing {count} of up to {bufferMax} buffered events", "empty": "Buffer is empty", "emptyDescription": "No events match the current filters yet. Hit refresh after triggering some traffic.", @@ -6649,9 +6687,9 @@ "hypertensionGrade1": "Hypertension Grade 1", "hypertensionGrade2": "Hypertension Grade 2", "hypertensionGrade3": "Hypertension Grade 3", - "bradycardia": "Bradycardia", + "belowTypicalRange": "Below typical range", "elevated": "Elevated", - "tachycardia": "Tachycardia", + "aboveTypicalRange": "Above typical range", "significantlyLow": "Significantly low", "slightlyLow": "Slightly low", "onTarget": "On target", @@ -7898,14 +7936,14 @@ "bpLowDoctor": "Two recent readings had a systolic below {systolic} mmHg. If you feel dizzy or faint, sit or lie down, and contact your doctor if it continues.", "bpLowEmergency": "Two recent readings had a systolic below {systolic} mmHg and you noted symptoms. If you feel faint, confused, or have cold clammy skin, call emergency services now.", "glucoseLowTitle": "Low blood sugar", - "glucoseLowDoctor": "Two recent readings were below 70 mg/dL ({value} mg/dL). Take fast-acting carbohydrate, re-check in 15 minutes, and contact your doctor if lows continue.", - "glucoseLowEmergency": "Two recent readings were below 70 mg/dL ({value} mg/dL) and you noted symptoms. Treat now with fast-acting sugar; if you cannot, or you feel very unwell, call emergency services.", + "glucoseLowDoctor": "Two recent readings were below {threshold} {unit} ({value} {unit}). Take fast-acting carbohydrate, re-check in 15 minutes, and contact your doctor if lows continue.", + "glucoseLowEmergency": "Two recent readings were below {threshold} {unit} ({value} {unit}) and you noted symptoms. Treat now with fast-acting sugar; if you cannot, or you feel very unwell, call emergency services.", "glucoseLowSevereTitle": "Very low blood sugar", - "glucoseLowSevereDoctor": "Two recent readings were below 54 mg/dL ({value} mg/dL). Treat immediately with fast-acting carbohydrate, re-check in 15 minutes, and contact your doctor.", - "glucoseLowSevereEmergency": "Two recent readings were below 54 mg/dL ({value} mg/dL) and you noted symptoms. Treat now; if you cannot keep food down or feel very unwell, call emergency services.", + "glucoseLowSevereDoctor": "Two recent readings were below {threshold} {unit} ({value} {unit}). Treat immediately with fast-acting carbohydrate, re-check in 15 minutes, and contact your doctor.", + "glucoseLowSevereEmergency": "Two recent readings were below {threshold} {unit} ({value} {unit}) and you noted symptoms. Treat now; if you cannot keep food down or feel very unwell, call emergency services.", "glucoseHighTitle": "Very high blood sugar", - "glucoseHighDoctor": "Two recent readings were {value} mg/dL or higher. Re-check, hydrate, and contact your doctor as soon as possible if it stays this high.", - "glucoseHighEmergency": "Two recent readings were {value} mg/dL or higher and you noted symptoms. If you have vomiting, fast breathing, or confusion, call emergency services now." + "glucoseHighDoctor": "Two recent readings were {value} {unit} or higher. Re-check, hydrate, and contact your doctor as soon as possible if it stays this high.", + "glucoseHighEmergency": "Two recent readings were {value} {unit} or higher and you noted symptoms. If you have vomiting, fast breathing, or confusion, call emergency services now." } }, "version": { @@ -8042,6 +8080,12 @@ "nummerGegenKummer": { "name": "Nummer gegen Kummer (for young people)" }, + "telefonSeelsorgeAt": { + "name": "Telefonseelsorge Österreich (24/7, free, confidential)" + }, + "dargeboteneHand": { + "name": "Die Dargebotene Hand (24/7, free, confidential)" + }, "krisenchat": { "name": "krisenchat (24/7 chat, young people)" }, @@ -8295,7 +8339,9 @@ "openLabel": "Open {title}", "attachmentBadge": "Download only", "uploading": "Uploading…", - "dismissFailed": "Dismiss failed upload" + "dismissFailed": "Dismiss failed upload", + "processing": "Processing…", + "ready": "Ready" }, "error": { "fileTooLarge": "This file is larger than the {maxSize} limit.", diff --git a/messages/es.json b/messages/es.json index bd5e14013..fdc46ff71 100644 --- a/messages/es.json +++ b/messages/es.json @@ -91,7 +91,7 @@ "colShare": "Porcentaje", "footerDisclaimer1": "Este informe se generó automáticamente a partir de datos autorregistrados y está destinado a apoyar", "footerDisclaimer2": "la conversación entre médico y paciente. No sustituye un diagnóstico médico. Las correlaciones no implican causalidad.", - "footerSource": "Fuente: HealthLog | Zona horaria: Europe/Berlin | Creado: {timestamp}", + "footerSource": "Fuente: HealthLog | Zona horaria: {timezone} | Creado: {timestamp}", "typeWeight": "Peso corporal", "typeBpSys": "Tensión arterial sistólica", "typeBpDia": "Tensión arterial diastólica", @@ -405,7 +405,6 @@ "recentWorkouts": { "title": "Entrenamientos recientes", "viewAll": "Ver todos", - "loading": "Cargando entrenamientos…", "empty": { "title": "Aún no hay entrenamientos", "cta": "Abre la app Apple Salud en el iPhone para sincronizar los entrenamientos." @@ -587,6 +586,22 @@ "typeWho5Score": "Puntuación WHO-5", "typeSciScore": "Puntuación SCI" }, + "metricCatalog": { + "title": "Lo que HealthLog puede registrar", + "description": "Todos los ámbitos de métricas que admite HealthLog, tengas datos o no todavía, y qué los proporciona.", + "statusPresent": "Registrado", + "statusAbsent": "Aún sin registrar", + "statusModuleOff": "Desactivado", + "connectCta": "Conectar una fuente", + "viewCta": "Ver", + "ecg": { + "source": "Tiras de ECG de una derivación desde un reloj compatible — hoy, Withings ScanWatch — sincronizadas automáticamente." + }, + "watchdog": { + "title": "Supervisión automática", + "description": "HealthLog también vigila en segundo plano — notificaciones de ritmo irregular y alteraciones respiratorias de un reloj conectado, además de alertas de desviación de tu valor de referencia — y solo muestra una tarjeta cuando algo destaca. Que no aparezca ninguna tarjeta es una buena señal, no una función ausente." + } + }, "mood": { "title": "Estado de ánimo", "subtitle": "Tendencia y registros de estado de ánimo", @@ -2476,7 +2491,8 @@ "documentsAttachedOne": "1 documento adjunto", "documentsAttachedOther": "{count} documentos adjuntos", "railBadge": "Chat de documento aislado", - "uploadError": "Error al subir. Inténtalo de nuevo." + "uploadError": "Error al subir. Inténtalo de nuevo.", + "notFoundError": "Ese enlace de documento ya no es válido: se iniciará un chat normal." } }, "relativeJustNow": "ahora mismo", @@ -2487,6 +2503,7 @@ "relativeDaysAgoOne": "hace {count} día", "relativeDaysAgoOther": "hace {count} días", "navOverview": "Resumen", + "navCatalog": "Todas las métricas", "navBloodPressure": "Presión arterial", "navWeight": "Peso", "navPulse": "Pulso", @@ -2517,9 +2534,9 @@ "label": "Actividad", "header": "Actividad y movilidad" }, - "cardiovascularParent": { - "label": "Cardiovascular", - "header": "Marcadores cardiovasculares" + "heartParent": { + "label": "Corazón", + "header": "Salud cardíaca" }, "hearingParent": { "label": "Audición", @@ -3879,6 +3896,8 @@ "caption": "Tu frecuencia cardíaca a lo largo del día, promediada en tramos 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.", + "coverageMany": "Basado en {count} lecturas a lo largo de {hours}h.", "baseline": "Reposo", "today": "Hoy", "previousDay": "Día anterior", @@ -4016,7 +4035,7 @@ }, "source": { "title": "Conectar una fuente", - "body": "Importa las mediciones automáticamente o sigue con la entrada manual. Aquí no se configura nada por ti: cada tarjeta solo muestra dónde conectarla.", + "body": "Importa las mediciones automáticamente o sigue con la entrada manual. Aquí no se configura nada por ti: cada tarjeta solo muestra dónde conectarla. Las fuentes de dispositivo necesitan una app de desarrollador gratuita en tu propia cuenta (unos 5 minutos); la guía de cada tarjeta te lo explica.", "recommended": "Recomendado", "manual": { "title": "Entrada manual", @@ -4024,12 +4043,14 @@ }, "withings": { "title": "Withings", - "body": "Las básculas, los tensiómetros y los trackers de Withings se sincronizan automáticamente.", - "cta": "Conectar Withings" + "body": "Las básculas, los tensiómetros y los trackers de Withings se sincronizan automáticamente una vez conectados.", + "cta": "Conectar Withings", + "setupCta": "Configurar en Ajustes", + "setupHint": "Necesita primero tu propia app de desarrollador de Withings, gratuita — unos 5 minutos, una sola vez." }, "more": { "title": "Más fuentes", - "body": "Conecta cualquiera de ellas en Ajustes cuando quieras.", + "body": "Conecta cualquiera de ellas en Ajustes cuando quieras — la mayoría necesita antes una app de desarrollador gratuita en tu propia cuenta.", "appleHealth": "Apple Health", "whoop": "WHOOP", "oura": "Oura", @@ -4171,7 +4192,16 @@ "disableError": "No se pudo desactivar el modo Investigación. Inténtalo de nuevo." }, "shell": { - "sectionsNav": "Secciones de ajustes" + "sectionsNav": "Secciones de ajustes", + "groups": { + "account": "Cuenta", + "yourData": "Tus datos", + "display": "Visualización", + "healthProfile": "Perfil de salud", + "ai": "IA", + "connectivity": "Conectividad", + "system": "Sistema" + } }, "sections": { "account": { @@ -4356,7 +4386,8 @@ }, "fullBackup": { "title": "Copia completa", - "description": "Un único archivo JSON con todo — el mismo formato que la copia automática semanal. Útil para restaurar tú mismo mediante la carga de administrador.", + "description": "Todos tus datos en un único archivo JSON: mediciones, medicamentos, estado de ánimo, ciclo, analíticas, episodios de enfermedad, alergias, antecedentes familiares y entrenamientos. Mismo formato que la copia automática semanal.", + "scopeNote": "Los archivos originales de los documentos y los datos de rutas GPS/muestras de los entrenamientos no están incluidos — descárgalos por separado desde el archivo de documentos o la exportación de entrenamientos.", "encryptToggle": "Cifrar con una contraseña", "passphrasePlaceholder": "Contraseña (mín. 12 caracteres)", "passphraseHelp": "Deriva la clave del archivo con Argon2id y lo sella con AES-256-GCM. Guarda el archivo con la extensión .hlx.", @@ -5389,6 +5420,12 @@ "ouraDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la", "nightscoutDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la", "integrationCredentialsHint": "Dónde encontrar estas credenciales y cómo registrar tu propia aplicación se explica en la guía de configuración de arriba.", + "integrationRedirectGuide": { + "intro": "Esto se conecta mediante tu propia app de desarrollador de {provider} — unos 5 minutos de configuración:", + "step1": "Crea una app en el portal de desarrolladores de {provider}.", + "step2": "Configura su URL de redirección/callback en:", + "step3": "Pega arriba el ID de cliente y el secreto de cliente que te da." + }, "security": { "sessionsTitle": "Sesiones activas", "sessionsDescription": "Dispositivos y navegadores con sesión iniciada en tu cuenta.", @@ -6029,6 +6066,7 @@ "colAction": "Acción", "colDuration": "Duración", "colTraceId": "Trace", + "viewDetails": "Ver detalles de {action} a las {timestamp}", "showing": "Mostrando {count} de hasta {bufferMax} eventos en búfer", "empty": "El búfer está vacío", "emptyDescription": "Aún no hay eventos que coincidan con los filtros actuales. Pulsa actualizar tras generar algo de tráfico.", @@ -6649,9 +6687,9 @@ "hypertensionGrade1": "Hipertensión grado 1", "hypertensionGrade2": "Hipertensión grado 2", "hypertensionGrade3": "Hipertensión grado 3", - "bradycardia": "Bradicardia", + "belowTypicalRange": "Por debajo del rango típico", "elevated": "Elevado", - "tachycardia": "Taquicardia", + "aboveTypicalRange": "Por encima del rango típico", "significantlyLow": "Significativamente bajo", "slightlyLow": "Ligeramente bajo", "onTarget": "En rango objetivo", @@ -7898,14 +7936,14 @@ "bpLowDoctor": "Dos lecturas recientes tuvieron una sistólica por debajo de {systolic} mmHg. Si te mareas o te sientes débil, siéntate o recuéstate y contacta a tu médico si continúa.", "bpLowEmergency": "Dos lecturas recientes tuvieron una sistólica por debajo de {systolic} mmHg e indicaste síntomas. Si te sientes a punto de desmayar, confuso o con piel fría y húmeda, llama a emergencias ya.", "glucoseLowTitle": "Glucosa baja", - "glucoseLowDoctor": "Dos lecturas recientes estuvieron por debajo de 70 mg/dL ({value} mg/dL). Toma carbohidratos de acción rápida, vuelve a medir en 15 minutos y contacta a tu médico si los bajones continúan.", - "glucoseLowEmergency": "Dos lecturas recientes estuvieron por debajo de 70 mg/dL ({value} mg/dL) e indicaste síntomas. Trátalo ahora con azúcar rápida; si no puedes o te sientes muy mal, llama a emergencias.", + "glucoseLowDoctor": "Dos lecturas recientes estuvieron por debajo de {threshold} {unit} ({value} {unit}). Toma carbohidratos de acción rápida, vuelve a medir en 15 minutos y contacta a tu médico si los bajones continúan.", + "glucoseLowEmergency": "Dos lecturas recientes estuvieron por debajo de {threshold} {unit} ({value} {unit}) e indicaste síntomas. Trátalo ahora con azúcar rápida; si no puedes o te sientes muy mal, llama a emergencias.", "glucoseLowSevereTitle": "Glucosa muy baja", - "glucoseLowSevereDoctor": "Dos lecturas recientes estuvieron por debajo de 54 mg/dL ({value} mg/dL). Trátalo de inmediato con carbohidratos de acción rápida, vuelve a medir en 15 minutos y contacta a tu médico.", - "glucoseLowSevereEmergency": "Dos lecturas recientes estuvieron por debajo de 54 mg/dL ({value} mg/dL) e indicaste síntomas. Trátalo ahora; si no retienes alimentos o te sientes muy mal, llama a emergencias.", + "glucoseLowSevereDoctor": "Dos lecturas recientes estuvieron por debajo de {threshold} {unit} ({value} {unit}). Trátalo de inmediato con carbohidratos de acción rápida, vuelve a medir en 15 minutos y contacta a tu médico.", + "glucoseLowSevereEmergency": "Dos lecturas recientes estuvieron por debajo de {threshold} {unit} ({value} {unit}) e indicaste síntomas. Trátalo ahora; si no retienes alimentos o te sientes muy mal, llama a emergencias.", "glucoseHighTitle": "Glucosa muy alta", - "glucoseHighDoctor": "Dos lecturas recientes fueron de {value} mg/dL o más. Vuelve a medir, hidrátate y contacta a tu médico cuanto antes si sigue así de alta.", - "glucoseHighEmergency": "Dos lecturas recientes fueron de {value} mg/dL o más e indicaste síntomas. Si tienes vómitos, respiración rápida o confusión, llama a emergencias ya." + "glucoseHighDoctor": "Dos lecturas recientes fueron de {value} {unit} o más. Vuelve a medir, hidrátate y contacta a tu médico cuanto antes si sigue así de alta.", + "glucoseHighEmergency": "Dos lecturas recientes fueron de {value} {unit} o más e indicaste síntomas. Si tienes vómitos, respiración rápida o confusión, llama a emergencias ya." } }, "version": { @@ -8042,6 +8080,12 @@ "nummerGegenKummer": { "name": "Nummer gegen Kummer (para jóvenes)" }, + "telefonSeelsorgeAt": { + "name": "Telefonseelsorge Österreich (24/7, gratuito y confidencial)" + }, + "dargeboteneHand": { + "name": "Die Dargebotene Hand (24/7, gratuito y confidencial)" + }, "krisenchat": { "name": "krisenchat (chat 24/7, para jóvenes)" }, @@ -8295,7 +8339,9 @@ "openLabel": "Abrir {title}", "attachmentBadge": "Solo descarga", "uploading": "Subiendo…", - "dismissFailed": "Descartar subida fallida" + "dismissFailed": "Descartar subida fallida", + "processing": "Procesando…", + "ready": "Listo" }, "error": { "fileTooLarge": "Este archivo supera el límite de {maxSize}.", diff --git a/messages/fr.json b/messages/fr.json index 3ccd752e1..a476e572d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -91,7 +91,7 @@ "colShare": "Part", "footerDisclaimer1": "Ce rapport a été généré automatiquement à partir de données auto-déclarées et vise à soutenir", "footerDisclaimer2": "la conversation médecin-patient. Il ne remplace pas un diagnostic médical. Les corrélations n'impliquent pas la causalité.", - "footerSource": "Source : HealthLog | Fuseau horaire : Europe/Berlin | Créé : {timestamp}", + "footerSource": "Source : HealthLog | Fuseau horaire : {timezone} | Créé : {timestamp}", "typeWeight": "Poids corporel", "typeBpSys": "Tension artérielle systolique", "typeBpDia": "Tension artérielle diastolique", @@ -405,7 +405,6 @@ "recentWorkouts": { "title": "Entraînements récents", "viewAll": "Tout voir", - "loading": "Chargement des entraînements…", "empty": { "title": "Aucun entraînement", "cta": "Ouvre l'app Apple Santé sur ton iPhone pour synchroniser les entraînements." @@ -587,6 +586,22 @@ "typeWho5Score": "Score WHO-5", "typeSciScore": "Score SCI" }, + "metricCatalog": { + "title": "Ce que HealthLog peut suivre", + "description": "Tous les domaines de mesures pris en charge par HealthLog, que tu aies déjà des données ou non — et ce qui les fournirait.", + "statusPresent": "Suivi", + "statusAbsent": "Pas encore suivi", + "statusModuleOff": "Désactivé", + "connectCta": "Connecter une source", + "viewCta": "Voir", + "ecg": { + "source": "Tracés ECG à une dérivation depuis une montre compatible — Withings ScanWatch aujourd'hui — synchronisés automatiquement." + }, + "watchdog": { + "title": "Surveillance automatique", + "description": "HealthLog surveille aussi discrètement en arrière-plan — notifications de rythme irrégulier et de perturbations respiratoires envoyées par une montre connectée, ainsi que des alertes d'écart par rapport à ta valeur de référence — et n'affiche une carte que si quelque chose se démarque. L'absence de carte est bon signe, pas une fonctionnalité manquante." + } + }, "mood": { "title": "Humeur", "subtitle": "Tendance et entrées d’humeur", @@ -2476,7 +2491,8 @@ "documentsAttachedOne": "1 document joint", "documentsAttachedOther": "{count} documents joints", "railBadge": "Chat de document cloisonné", - "uploadError": "Échec du téléversement. Veuillez réessayer." + "uploadError": "Échec du téléversement. Veuillez réessayer.", + "notFoundError": "Ce lien de document n'est plus valide : une conversation normale démarre à la place." } }, "relativeJustNow": "à l’instant", @@ -2487,6 +2503,7 @@ "relativeDaysAgoOne": "il y a {count} jour", "relativeDaysAgoOther": "il y a {count} jours", "navOverview": "Vue d'ensemble", + "navCatalog": "Toutes les mesures", "navBloodPressure": "Tension artérielle", "navWeight": "Poids", "navPulse": "Pouls", @@ -2517,9 +2534,9 @@ "label": "Activité", "header": "Activité et mobilité" }, - "cardiovascularParent": { - "label": "Cardiovasculaire", - "header": "Marqueurs cardiovasculaires" + "heartParent": { + "label": "Cœur", + "header": "Santé cardiaque" }, "hearingParent": { "label": "Audition", @@ -3879,6 +3896,8 @@ "caption": "Votre fréquence cardiaque au fil de la journée, moyennée 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.", + "coverageMany": "Basé sur {count} mesures sur {hours}h.", "baseline": "Repos", "today": "Aujourd'hui", "previousDay": "Jour précédent", @@ -4016,7 +4035,7 @@ }, "source": { "title": "Connecter une source", - "body": "Importez les mesures automatiquement ou conservez la saisie manuelle. Rien n'est configuré à votre place : chaque carte indique simplement où la connecter.", + "body": "Importez les mesures automatiquement ou conservez la saisie manuelle. Rien n'est configuré à votre place : chaque carte indique simplement où la connecter. Les sources d'appareil nécessitent une application développeur gratuite sur votre propre compte (environ 5 minutes) ; le guide de chaque carte vous accompagne.", "recommended": "Recommandé", "manual": { "title": "Saisie manuelle", @@ -4024,12 +4043,14 @@ }, "withings": { "title": "Withings", - "body": "Balances, tensiomètres et trackers Withings se synchronisent automatiquement.", - "cta": "Connecter Withings" + "body": "Balances, tensiomètres et trackers Withings se synchronisent automatiquement une fois connectés.", + "cta": "Connecter Withings", + "setupCta": "Configurer dans les Réglages", + "setupHint": "Nécessite d'abord votre propre application développeur Withings, gratuite — environ 5 minutes, une seule fois." }, "more": { "title": "Autres sources", - "body": "Connectez l'une d'elles dans les Réglages quand vous le souhaitez.", + "body": "Connectez l'une d'elles dans les Réglages quand vous le souhaitez — la plupart nécessitent d'abord une application développeur gratuite sur votre propre compte.", "appleHealth": "Apple Health", "whoop": "WHOOP", "oura": "Oura", @@ -4171,7 +4192,16 @@ "disableError": "Impossible de désactiver le mode Recherche. Veuillez réessayer." }, "shell": { - "sectionsNav": "Sections des réglages" + "sectionsNav": "Sections des réglages", + "groups": { + "account": "Compte", + "yourData": "Vos données", + "display": "Affichage", + "healthProfile": "Profil de santé", + "ai": "IA", + "connectivity": "Connectivité", + "system": "Système" + } }, "sections": { "account": { @@ -4356,7 +4386,8 @@ }, "fullBackup": { "title": "Sauvegarde complète", - "description": "Un seul fichier JSON contenant tout — même format que la sauvegarde automatique hebdomadaire. Utile pour une restauration personnelle via le téléversement administrateur.", + "description": "Toutes vos données dans un seul fichier JSON : mesures, médicaments, humeur, cycle, analyses de laboratoire, épisodes de maladie, allergies, antécédents familiaux et entraînements. Même format que la sauvegarde automatique hebdomadaire.", + "scopeNote": "Les fichiers originaux des documents ainsi que les tracés GPS/données d'échantillons des entraînements ne sont pas inclus — téléchargez-les séparément depuis le coffre de documents ou l'export des entraînements.", "encryptToggle": "Chiffrer avec une phrase secrète", "passphrasePlaceholder": "Phrase secrète (12 caractères min.)", "passphraseHelp": "Dérive la clé de l'archive avec Argon2id et la scelle avec AES-256-GCM. Enregistrez le fichier avec l'extension .hlx.", @@ -5389,6 +5420,12 @@ "ouraDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le", "nightscoutDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le", "integrationCredentialsHint": "Où trouver ces identifiants et comment enregistrer ta propre application est expliqué dans le guide de configuration ci-dessus.", + "integrationRedirectGuide": { + "intro": "Cette connexion passe par votre propre application développeur {provider} — environ 5 minutes de configuration :", + "step1": "Créez une application dans le portail développeur {provider}.", + "step2": "Réglez son URL de redirection/callback sur :", + "step3": "Collez ci-dessus l'ID client et le secret client fournis." + }, "security": { "sessionsTitle": "Sessions actives", "sessionsDescription": "Appareils et navigateurs actuellement connectés à votre compte.", @@ -6029,6 +6066,7 @@ "colAction": "Action", "colDuration": "Durée", "colTraceId": "Trace", + "viewDetails": "Voir les détails de {action} à {timestamp}", "showing": "Affichage de {count} sur jusqu'à {bufferMax} événements en mémoire tampon", "empty": "Le tampon est vide", "emptyDescription": "Aucun événement ne correspond encore aux filtres actuels. Cliquez sur Actualiser après avoir généré du trafic.", @@ -6649,9 +6687,9 @@ "hypertensionGrade1": "Hypertension grade 1", "hypertensionGrade2": "Hypertension grade 2", "hypertensionGrade3": "Hypertension grade 3", - "bradycardia": "Bradycardie", + "belowTypicalRange": "En dessous de la plage habituelle", "elevated": "Élevé", - "tachycardia": "Tachycardie", + "aboveTypicalRange": "Au-dessus de la plage habituelle", "significantlyLow": "Nettement bas", "slightlyLow": "Légèrement bas", "onTarget": "Dans la plage cible", @@ -7898,14 +7936,14 @@ "bpLowDoctor": "Deux mesures récentes avaient une systolique inférieure à {systolic} mmHg. Si tu te sens étourdi ou faible, assieds-toi ou allonge-toi et contacte ton médecin si cela persiste.", "bpLowEmergency": "Deux mesures récentes avaient une systolique inférieure à {systolic} mmHg et tu as signalé des symptômes. Si tu te sens près de t'évanouir, confus ou as la peau froide et moite, appelle les secours immédiatement.", "glucoseLowTitle": "Glycémie basse", - "glucoseLowDoctor": "Deux mesures récentes étaient en dessous de 70 mg/dL ({value} mg/dL). Prends des glucides à action rapide, remesure dans 15 minutes et contacte ton médecin si les baisses persistent.", - "glucoseLowEmergency": "Deux mesures récentes étaient en dessous de 70 mg/dL ({value} mg/dL) et tu as signalé des symptômes. Traite maintenant avec du sucre rapide ; si tu ne peux pas ou te sens très mal, appelle les secours.", + "glucoseLowDoctor": "Deux mesures récentes étaient en dessous de {threshold} {unit} ({value} {unit}). Prends des glucides à action rapide, remesure dans 15 minutes et contacte ton médecin si les baisses persistent.", + "glucoseLowEmergency": "Deux mesures récentes étaient en dessous de {threshold} {unit} ({value} {unit}) et tu as signalé des symptômes. Traite maintenant avec du sucre rapide ; si tu ne peux pas ou te sens très mal, appelle les secours.", "glucoseLowSevereTitle": "Glycémie très basse", - "glucoseLowSevereDoctor": "Deux mesures récentes étaient en dessous de 54 mg/dL ({value} mg/dL). Traite immédiatement avec des glucides à action rapide, remesure dans 15 minutes et contacte ton médecin.", - "glucoseLowSevereEmergency": "Deux mesures récentes étaient en dessous de 54 mg/dL ({value} mg/dL) et tu as signalé des symptômes. Traite maintenant ; si tu ne gardes pas la nourriture ou te sens très mal, appelle les secours.", + "glucoseLowSevereDoctor": "Deux mesures récentes étaient en dessous de {threshold} {unit} ({value} {unit}). Traite immédiatement avec des glucides à action rapide, remesure dans 15 minutes et contacte ton médecin.", + "glucoseLowSevereEmergency": "Deux mesures récentes étaient en dessous de {threshold} {unit} ({value} {unit}) et tu as signalé des symptômes. Traite maintenant ; si tu ne gardes pas la nourriture ou te sens très mal, appelle les secours.", "glucoseHighTitle": "Glycémie très élevée", - "glucoseHighDoctor": "Deux mesures récentes étaient à {value} mg/dL ou plus. Remesure, hydrate-toi et contacte ton médecin dès que possible si cela reste aussi élevé.", - "glucoseHighEmergency": "Deux mesures récentes étaient à {value} mg/dL ou plus et tu as signalé des symptômes. En cas de vomissements, de respiration rapide ou de confusion, appelle les secours immédiatement." + "glucoseHighDoctor": "Deux mesures récentes étaient à {value} {unit} ou plus. Remesure, hydrate-toi et contacte ton médecin dès que possible si cela reste aussi élevé.", + "glucoseHighEmergency": "Deux mesures récentes étaient à {value} {unit} ou plus et tu as signalé des symptômes. En cas de vomissements, de respiration rapide ou de confusion, appelle les secours immédiatement." } }, "version": { @@ -8042,6 +8080,12 @@ "nummerGegenKummer": { "name": "Nummer gegen Kummer (pour les jeunes)" }, + "telefonSeelsorgeAt": { + "name": "Telefonseelsorge Österreich (24h/24, 7j/7, gratuit et confidentiel)" + }, + "dargeboteneHand": { + "name": "Die Dargebotene Hand (24h/24, 7j/7, gratuit et confidentiel)" + }, "krisenchat": { "name": "krisenchat (chat 24h/24, 7j/7, pour les jeunes)" }, @@ -8295,7 +8339,9 @@ "openLabel": "Ouvrir {title}", "attachmentBadge": "Téléchargement uniquement", "uploading": "Téléversement…", - "dismissFailed": "Ignorer le téléversement échoué" + "dismissFailed": "Ignorer le téléversement échoué", + "processing": "Traitement en cours…", + "ready": "Prêt" }, "error": { "fileTooLarge": "Ce fichier dépasse la limite de {maxSize}.", diff --git a/messages/it.json b/messages/it.json index c8c05e877..074ac3ad0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -91,7 +91,7 @@ "colShare": "Quota", "footerDisclaimer1": "Questo rapporto è stato generato automaticamente da dati auto-registrati ed è destinato a supportare", "footerDisclaimer2": "il dialogo medico-paziente. Non sostituisce una diagnosi medica. Le correlazioni non implicano causalità.", - "footerSource": "Fonte: HealthLog | Fuso orario: Europe/Berlin | Creato: {timestamp}", + "footerSource": "Fonte: HealthLog | Fuso orario: {timezone} | Creato: {timestamp}", "typeWeight": "Peso corporeo", "typeBpSys": "Pressione arteriosa sistolica", "typeBpDia": "Pressione arteriosa diastolica", @@ -405,7 +405,6 @@ "recentWorkouts": { "title": "Allenamenti recenti", "viewAll": "Vedi tutti", - "loading": "Caricamento allenamenti…", "empty": { "title": "Nessun allenamento", "cta": "Apri l'app Salute di Apple sull'iPhone per sincronizzare gli allenamenti." @@ -587,6 +586,22 @@ "typeWho5Score": "Punteggio WHO-5", "typeSciScore": "Punteggio SCI" }, + "metricCatalog": { + "title": "Cosa può monitorare HealthLog", + "description": "Tutti gli ambiti di metriche supportati da HealthLog, che tu abbia già dei dati oppure no — e cosa li fornirebbe.", + "statusPresent": "Monitorato", + "statusAbsent": "Non ancora monitorato", + "statusModuleOff": "Disattivato", + "connectCta": "Collega una fonte", + "viewCta": "Visualizza", + "ecg": { + "source": "Tracciati ECG a una derivazione da un orologio compatibile — oggi Withings ScanWatch — sincronizzati automaticamente." + }, + "watchdog": { + "title": "Monitoraggio automatico", + "description": "HealthLog osserva anche silenziosamente in background — notifiche di ritmo irregolare e disturbi respiratori da un orologio connesso, oltre ad avvisi di scostamento dalla tua base di riferimento — mostrando una scheda solo quando qualcosa emerge. Nessuna scheda visibile è un buon segno, non una funzione mancante." + } + }, "mood": { "title": "Umore", "subtitle": "Andamento e voci dell’umore", @@ -2476,7 +2491,8 @@ "documentsAttachedOne": "1 documento allegato", "documentsAttachedOther": "{count} documenti allegati", "railBadge": "Chat documento isolata", - "uploadError": "Caricamento non riuscito. Riprova." + "uploadError": "Caricamento non riuscito. Riprova.", + "notFoundError": "Quel link al documento non è più valido: verrà avviata una chat normale." } }, "relativeJustNow": "proprio ora", @@ -2487,6 +2503,7 @@ "relativeDaysAgoOne": "{count} giorno fa", "relativeDaysAgoOther": "{count} giorni fa", "navOverview": "Panoramica", + "navCatalog": "Tutte le metriche", "navBloodPressure": "Pressione arteriosa", "navWeight": "Peso", "navPulse": "Polso", @@ -2517,9 +2534,9 @@ "label": "Attività", "header": "Attività e mobilità" }, - "cardiovascularParent": { - "label": "Cardiovascolare", - "header": "Marcatori cardiovascolari" + "heartParent": { + "label": "Cuore", + "header": "Salute del cuore" }, "hearingParent": { "label": "Udito", @@ -3879,6 +3896,8 @@ "caption": "La tua frequenza cardiaca durante il giorno, mediata in 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.", + "coverageMany": "Basato su {count} misurazioni in {hours}h.", "baseline": "Riposo", "today": "Oggi", "previousDay": "Giorno precedente", @@ -4016,7 +4035,7 @@ }, "source": { "title": "Connetti una sorgente", - "body": "Importa le misurazioni in automatico oppure resta sull'inserimento manuale. Qui non viene configurato nulla al posto tuo: ogni scheda mostra solo dove collegarla.", + "body": "Importa le misurazioni in automatico oppure resta sull'inserimento manuale. Qui non viene configurato nulla al posto tuo: ogni scheda mostra solo dove collegarla. Le fonti su dispositivo richiedono un'app sviluppatore gratuita sul tuo account (circa 5 minuti); la guida su ogni scheda ti accompagna.", "recommended": "Consigliato", "manual": { "title": "Inserimento manuale", @@ -4024,12 +4043,14 @@ }, "withings": { "title": "Withings", - "body": "Bilance, misuratori di pressione e tracker Withings si sincronizzano automaticamente.", - "cta": "Connetti Withings" + "body": "Bilance, misuratori di pressione e tracker Withings si sincronizzano automaticamente una volta collegati.", + "cta": "Connetti Withings", + "setupCta": "Configura nelle Impostazioni", + "setupHint": "Richiede prima la tua app sviluppatore Withings gratuita — circa 5 minuti, una tantum." }, "more": { "title": "Altre fonti", - "body": "Collega una di queste nelle Impostazioni quando vuoi.", + "body": "Collega una di queste nelle Impostazioni quando vuoi — la maggior parte richiede prima un'app sviluppatore gratuita sul tuo account.", "appleHealth": "Apple Health", "whoop": "WHOOP", "oura": "Oura", @@ -4171,7 +4192,16 @@ "disableError": "Impossibile disattivare la modalità Ricerca. Riprova." }, "shell": { - "sectionsNav": "Sezioni delle impostazioni" + "sectionsNav": "Sezioni delle impostazioni", + "groups": { + "account": "Account", + "yourData": "I tuoi dati", + "display": "Visualizzazione", + "healthProfile": "Profilo sanitario", + "ai": "IA", + "connectivity": "Connettività", + "system": "Sistema" + } }, "sections": { "account": { @@ -4356,7 +4386,8 @@ }, "fullBackup": { "title": "Backup completo", - "description": "Un unico file JSON con tutto — stesso formato del backup automatico settimanale. Utile per il ripristino autonomo tramite il caricamento da amministratore.", + "description": "Tutti i tuoi dati in un unico file JSON: misurazioni, farmaci, umore, ciclo, esami di laboratorio, episodi di malattia, allergie, anamnesi familiare e allenamenti. Stesso formato del backup automatico settimanale.", + "scopeNote": "I file originali dei documenti e i dati di percorso GPS/campionamento degli allenamenti non sono inclusi — scaricali separatamente dall'archivio documenti o dall'esportazione degli allenamenti.", "encryptToggle": "Cifra con una passphrase", "passphrasePlaceholder": "Passphrase (min. 12 caratteri)", "passphraseHelp": "Deriva la chiave dell'archivio con Argon2id e lo sigilla con AES-256-GCM. Salva il file con estensione .hlx.", @@ -5389,6 +5420,12 @@ "ouraDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la", "nightscoutDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la", "integrationCredentialsHint": "Dove trovare queste credenziali e come registrare la tua app è spiegato nella guida alla configurazione qui sopra.", + "integrationRedirectGuide": { + "intro": "Questa connessione passa dalla tua app sviluppatore {provider} — circa 5 minuti di configurazione:", + "step1": "Crea un'app nel portale sviluppatori {provider}.", + "step2": "Imposta il suo URL di reindirizzamento/callback su:", + "step3": "Incolla qui sopra l'ID client e il client secret ricevuti." + }, "security": { "sessionsTitle": "Sessioni attive", "sessionsDescription": "Dispositivi e browser attualmente connessi al tuo account.", @@ -6029,6 +6066,7 @@ "colAction": "Azione", "colDuration": "Durata", "colTraceId": "Trace", + "viewDetails": "Visualizza i dettagli di {action} alle {timestamp}", "showing": "Visualizzati {count} di un massimo di {bufferMax} eventi nel buffer", "empty": "Il buffer è vuoto", "emptyDescription": "Nessun evento corrisponde ancora ai filtri attuali. Premi Aggiorna dopo aver generato un po' di traffico.", @@ -6649,9 +6687,9 @@ "hypertensionGrade1": "Ipertensione grado 1", "hypertensionGrade2": "Ipertensione grado 2", "hypertensionGrade3": "Ipertensione grado 3", - "bradycardia": "Bradicardia", + "belowTypicalRange": "Sotto il range tipico", "elevated": "Elevato", - "tachycardia": "Tachicardia", + "aboveTypicalRange": "Sopra il range tipico", "significantlyLow": "Significativamente basso", "slightlyLow": "Lievemente basso", "onTarget": "Nel range obiettivo", @@ -7898,14 +7936,14 @@ "bpLowDoctor": "Due misurazioni recenti avevano una sistolica sotto {systolic} mmHg. Se ti senti stordito o debole, siediti o sdraiati e contatta il medico se continua.", "bpLowEmergency": "Due misurazioni recenti avevano una sistolica sotto {systolic} mmHg e hai segnalato sintomi. Se senti che stai per svenire, sei confuso o hai la pelle fredda e sudata, chiama subito i soccorsi.", "glucoseLowTitle": "Glicemia bassa", - "glucoseLowDoctor": "Due misurazioni recenti erano sotto 70 mg/dL ({value} mg/dL). Assumi carboidrati ad azione rapida, rimisura tra 15 minuti e contatta il medico se i cali continuano.", - "glucoseLowEmergency": "Due misurazioni recenti erano sotto 70 mg/dL ({value} mg/dL) e hai segnalato sintomi. Tratta subito con zucchero rapido; se non puoi o ti senti molto male, chiama i soccorsi.", + "glucoseLowDoctor": "Due misurazioni recenti erano sotto {threshold} {unit} ({value} {unit}). Assumi carboidrati ad azione rapida, rimisura tra 15 minuti e contatta il medico se i cali continuano.", + "glucoseLowEmergency": "Due misurazioni recenti erano sotto {threshold} {unit} ({value} {unit}) e hai segnalato sintomi. Tratta subito con zucchero rapido; se non puoi o ti senti molto male, chiama i soccorsi.", "glucoseLowSevereTitle": "Glicemia molto bassa", - "glucoseLowSevereDoctor": "Due misurazioni recenti erano sotto 54 mg/dL ({value} mg/dL). Tratta subito con carboidrati ad azione rapida, rimisura tra 15 minuti e contatta il medico.", - "glucoseLowSevereEmergency": "Due misurazioni recenti erano sotto 54 mg/dL ({value} mg/dL) e hai segnalato sintomi. Tratta subito; se non trattieni il cibo o ti senti molto male, chiama i soccorsi.", + "glucoseLowSevereDoctor": "Due misurazioni recenti erano sotto {threshold} {unit} ({value} {unit}). Tratta subito con carboidrati ad azione rapida, rimisura tra 15 minuti e contatta il medico.", + "glucoseLowSevereEmergency": "Due misurazioni recenti erano sotto {threshold} {unit} ({value} {unit}) e hai segnalato sintomi. Tratta subito; se non trattieni il cibo o ti senti molto male, chiama i soccorsi.", "glucoseHighTitle": "Glicemia molto alta", - "glucoseHighDoctor": "Due misurazioni recenti erano di {value} mg/dL o superiori. Rimisura, idratati e contatta il medico al più presto se resta così alta.", - "glucoseHighEmergency": "Due misurazioni recenti erano di {value} mg/dL o superiori e hai segnalato sintomi. In caso di vomito, respiro affannoso o confusione, chiama subito i soccorsi." + "glucoseHighDoctor": "Due misurazioni recenti erano di {value} {unit} o superiori. Rimisura, idratati e contatta il medico al più presto se resta così alta.", + "glucoseHighEmergency": "Due misurazioni recenti erano di {value} {unit} o superiori e hai segnalato sintomi. In caso di vomito, respiro affannoso o confusione, chiama subito i soccorsi." } }, "version": { @@ -8042,6 +8080,12 @@ "nummerGegenKummer": { "name": "Nummer gegen Kummer (per i giovani)" }, + "telefonSeelsorgeAt": { + "name": "Telefonseelsorge Österreich (24/7, gratuito e riservato)" + }, + "dargeboteneHand": { + "name": "Die Dargebotene Hand (24/7, gratuito e riservato)" + }, "krisenchat": { "name": "krisenchat (chat 24/7, per i giovani)" }, @@ -8295,7 +8339,9 @@ "openLabel": "Apri {title}", "attachmentBadge": "Solo download", "uploading": "Caricamento…", - "dismissFailed": "Ignora caricamento non riuscito" + "dismissFailed": "Ignora caricamento non riuscito", + "processing": "Elaborazione…", + "ready": "Pronto" }, "error": { "fileTooLarge": "Questo file supera il limite di {maxSize}.", diff --git a/messages/pl.json b/messages/pl.json index 5951c89df..07a0c8c46 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -91,7 +91,7 @@ "colShare": "Udział", "footerDisclaimer1": "Ten raport został wygenerowany automatycznie z danych samodzielnie rejestrowanych i ma na celu wspierać", "footerDisclaimer2": "rozmowę lekarza z pacjentem. Nie zastępuje diagnozy medycznej. Korelacje nie oznaczają związku przyczynowego.", - "footerSource": "Źródło: HealthLog | Strefa czasowa: Europe/Berlin | Utworzono: {timestamp}", + "footerSource": "Źródło: HealthLog | Strefa czasowa: {timezone} | Utworzono: {timestamp}", "typeWeight": "Masa ciała", "typeBpSys": "Ciśnienie skurczowe", "typeBpDia": "Ciśnienie rozkurczowe", @@ -405,7 +405,6 @@ "recentWorkouts": { "title": "Ostatnie treningi", "viewAll": "Zobacz wszystkie", - "loading": "Wczytywanie treningów…", "empty": { "title": "Brak treningów", "cta": "Otwórz aplikację Apple Zdrowie na iPhonie, aby zsynchronizować treningi." @@ -587,6 +586,22 @@ "typeWho5Score": "Wynik WHO-5", "typeSciScore": "Wynik SCI" }, + "metricCatalog": { + "title": "Co HealthLog potrafi śledzić", + "description": "Wszystkie obszary metryk obsługiwane przez HealthLog — również te, dla których nie masz jeszcze danych — oraz co je dostarcza.", + "statusPresent": "Śledzone", + "statusAbsent": "Jeszcze nieśledzone", + "statusModuleOff": "Wyłączone", + "connectCta": "Połącz źródło", + "viewCta": "Zobacz", + "ecg": { + "source": "Jednoodprowadzeniowe zapisy EKG z kompatybilnego zegarka — obecnie Withings ScanWatch — synchronizowane automatycznie." + }, + "watchdog": { + "title": "Automatyczny nadzór", + "description": "HealthLog dyskretnie obserwuje też dane w tle — powiadomienia o nieregularnym rytmie i zaburzeniach oddychania z podłączonego zegarka, a także alerty o odchyleniu od Twojej wartości bazowej — i pokazuje kartę tylko wtedy, gdy coś zwraca uwagę. Brak karty to dobry znak, a nie brakująca funkcja." + } + }, "mood": { "title": "Nastrój", "subtitle": "Trend i wpisy nastroju", @@ -2476,7 +2491,8 @@ "documentsAttachedOne": "1 załączony dokument", "documentsAttachedOther": "Załączone dokumenty: {count}", "railBadge": "Odizolowany czat dokumentu", - "uploadError": "Przesyłanie nie powiodło się. Spróbuj ponownie." + "uploadError": "Przesyłanie nie powiodło się. Spróbuj ponownie.", + "notFoundError": "Ten link do dokumentu jest już nieprawidłowy – zamiast tego rozpoczynamy zwykły czat." } }, "relativeJustNow": "przed chwilą", @@ -2487,6 +2503,7 @@ "relativeDaysAgoOne": "{count} dzień temu", "relativeDaysAgoOther": "{count} dni temu", "navOverview": "Przegląd", + "navCatalog": "Wszystkie metryki", "navBloodPressure": "Ciśnienie krwi", "navWeight": "Waga", "navPulse": "Tętno", @@ -2517,9 +2534,9 @@ "label": "Aktywność", "header": "Aktywność i mobilność" }, - "cardiovascularParent": { - "label": "Sercowo-naczyniowe", - "header": "Markery sercowo-naczyniowe" + "heartParent": { + "label": "Serce", + "header": "Zdrowie serca" }, "hearingParent": { "label": "Słuch", @@ -3879,6 +3896,8 @@ "caption": "Twoje tętno w ciągu dnia, uśrednione w 10-minutowych odcinkach.", "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.", + "coverageMany": "Na podstawie {count} pomiarów w ciągu {hours}h.", "baseline": "Spoczynek", "today": "Dziś", "previousDay": "Poprzedni dzień", @@ -4016,7 +4035,7 @@ }, "source": { "title": "Połącz źródło", - "body": "Pobieraj pomiary automatycznie lub zostań przy wpisywaniu ręcznym. Nic nie jest tu konfigurowane za Ciebie — każda karta pokazuje tylko, gdzie to połączyć.", + "body": "Pobieraj pomiary automatycznie lub zostań przy wpisywaniu ręcznym. Nic nie jest tu konfigurowane za Ciebie — każda karta pokazuje tylko, gdzie to połączyć. Źródła sprzętowe wymagają darmowej aplikacji deweloperskiej na Twoim własnym koncie (około 5 minut); przewodnik na każdej karcie prowadzi krok po kroku.", "recommended": "Polecane", "manual": { "title": "Wpis ręczny", @@ -4024,12 +4043,14 @@ }, "withings": { "title": "Withings", - "body": "Wagi, ciśnieniomierze i trackery Withings synchronizują się automatycznie.", - "cta": "Połącz Withings" + "body": "Wagi, ciśnieniomierze i trackery Withings synchronizują się automatycznie po połączeniu.", + "cta": "Połącz Withings", + "setupCta": "Skonfiguruj w Ustawieniach", + "setupHint": "Najpierw wymaga własnej, darmowej aplikacji deweloperskiej Withings — około 5 minut, jednorazowo." }, "more": { "title": "Więcej źródeł", - "body": "Połącz dowolne z nich w Ustawieniach, gdy zechcesz.", + "body": "Połącz dowolne z nich w Ustawieniach, gdy zechcesz — większość wymaga najpierw darmowej aplikacji deweloperskiej na Twoim własnym koncie.", "appleHealth": "Apple Health", "whoop": "WHOOP", "oura": "Oura", @@ -4171,7 +4192,16 @@ "disableError": "Nie udało się wyłączyć trybu Badawczego. Spróbuj ponownie." }, "shell": { - "sectionsNav": "Sekcje ustawień" + "sectionsNav": "Sekcje ustawień", + "groups": { + "account": "Konto", + "yourData": "Twoje dane", + "display": "Wyświetlanie", + "healthProfile": "Profil zdrowotny", + "ai": "AI", + "connectivity": "Łączność", + "system": "System" + } }, "sections": { "account": { @@ -4356,7 +4386,8 @@ }, "fullBackup": { "title": "Pełna kopia zapasowa", - "description": "Pojedynczy plik JSON ze wszystkim — taki sam format jak cotygodniowa automatyczna kopia. Przydatny do samodzielnego przywracania przez przesłanie w panelu administratora.", + "description": "Wszystkie Twoje dane w jednym pliku JSON — pomiary, leki, nastrój, cykl, wyniki badań laboratoryjnych, epizody chorobowe, alergie, wywiad rodzinny i treningi. Taki sam format jak cotygodniowa automatyczna kopia.", + "scopeNote": "Oryginalne pliki dokumentów oraz dane tras GPS/próbek treningów nie są uwzględnione — pobierz je osobno z archiwum dokumentów lub eksportu treningów.", "encryptToggle": "Zaszyfruj hasłem-frazą", "passphrasePlaceholder": "Hasło-fraza (min. 12 znaków)", "passphraseHelp": "Wyprowadza klucz archiwum za pomocą Argon2id i pieczętuje je AES-256-GCM. Zapisz plik z rozszerzeniem .hlx.", @@ -5389,6 +5420,12 @@ "ouraDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z", "nightscoutDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z", "integrationCredentialsHint": "Gdzie znaleźć te dane logowania i jak zarejestrować własną aplikację, opisano w przewodniku konfiguracji powyżej.", + "integrationRedirectGuide": { + "intro": "To połączenie działa przez Twoją własną aplikację deweloperską {provider} — około 5 minut konfiguracji:", + "step1": "Utwórz aplikację w portalu deweloperskim {provider}.", + "step2": "Ustaw jej URL przekierowania/callback na:", + "step3": "Wklej powyżej otrzymany identyfikator klienta i sekret klienta." + }, "security": { "sessionsTitle": "Aktywne sesje", "sessionsDescription": "Urządzenia i przeglądarki obecnie zalogowane na Twoje konto.", @@ -6029,6 +6066,7 @@ "colAction": "Akcja", "colDuration": "Czas trwania", "colTraceId": "Trace", + "viewDetails": "Zobacz szczegóły dla {action} o {timestamp}", "showing": "Wyświetlono {count} z maksymalnie {bufferMax} buforowanych zdarzeń", "empty": "Bufor jest pusty", "emptyDescription": "Żadne zdarzenie nie pasuje jeszcze do bieżących filtrów. Kliknij Odśwież po wygenerowaniu ruchu.", @@ -6649,9 +6687,9 @@ "hypertensionGrade1": "Nadciśnienie 1 stopnia", "hypertensionGrade2": "Nadciśnienie 2 stopnia", "hypertensionGrade3": "Nadciśnienie 3 stopnia", - "bradycardia": "Bradykardia", + "belowTypicalRange": "Poniżej typowego zakresu", "elevated": "Podwyższone", - "tachycardia": "Tachykardia", + "aboveTypicalRange": "Powyżej typowego zakresu", "significantlyLow": "Znacząco niskie", "slightlyLow": "Lekko niskie", "onTarget": "W zakresie docelowym", @@ -7898,14 +7936,14 @@ "bpLowDoctor": "Dwa ostatnie pomiary miały ciśnienie skurczowe poniżej {systolic} mmHg. Jeśli kręci ci się w głowie lub czujesz osłabienie, usiądź lub połóż się i skontaktuj się z lekarzem, jeśli to się utrzymuje.", "bpLowEmergency": "Dwa ostatnie pomiary miały ciśnienie skurczowe poniżej {systolic} mmHg i zgłosiłeś objawy. Jeśli czujesz, że możesz zemdleć, jesteś zdezorientowany lub masz zimną, lepką skórę, natychmiast zadzwoń po pomoc.", "glucoseLowTitle": "Niski poziom cukru", - "glucoseLowDoctor": "Dwa ostatnie pomiary były poniżej 70 mg/dL ({value} mg/dL). Przyjmij szybko działające węglowodany, zmierz ponownie za 15 minut i skontaktuj się z lekarzem, jeśli spadki się utrzymują.", - "glucoseLowEmergency": "Dwa ostatnie pomiary były poniżej 70 mg/dL ({value} mg/dL) i zgłosiłeś objawy. Lecz to teraz szybkim cukrem; jeśli nie możesz lub czujesz się bardzo źle, zadzwoń po pomoc.", + "glucoseLowDoctor": "Dwa ostatnie pomiary były poniżej {threshold} {unit} ({value} {unit}). Przyjmij szybko działające węglowodany, zmierz ponownie za 15 minut i skontaktuj się z lekarzem, jeśli spadki się utrzymują.", + "glucoseLowEmergency": "Dwa ostatnie pomiary były poniżej {threshold} {unit} ({value} {unit}) i zgłosiłeś objawy. Lecz to teraz szybkim cukrem; jeśli nie możesz lub czujesz się bardzo źle, zadzwoń po pomoc.", "glucoseLowSevereTitle": "Bardzo niski poziom cukru", - "glucoseLowSevereDoctor": "Dwa ostatnie pomiary były poniżej 54 mg/dL ({value} mg/dL). Lecz natychmiast szybko działającymi węglowodanami, zmierz ponownie za 15 minut i skontaktuj się z lekarzem.", - "glucoseLowSevereEmergency": "Dwa ostatnie pomiary były poniżej 54 mg/dL ({value} mg/dL) i zgłosiłeś objawy. Lecz to teraz; jeśli nie utrzymujesz pokarmu lub czujesz się bardzo źle, zadzwoń po pomoc.", + "glucoseLowSevereDoctor": "Dwa ostatnie pomiary były poniżej {threshold} {unit} ({value} {unit}). Lecz natychmiast szybko działającymi węglowodanami, zmierz ponownie za 15 minut i skontaktuj się z lekarzem.", + "glucoseLowSevereEmergency": "Dwa ostatnie pomiary były poniżej {threshold} {unit} ({value} {unit}) i zgłosiłeś objawy. Lecz to teraz; jeśli nie utrzymujesz pokarmu lub czujesz się bardzo źle, zadzwoń po pomoc.", "glucoseHighTitle": "Bardzo wysoki poziom cukru", - "glucoseHighDoctor": "Dwa ostatnie pomiary wyniosły {value} mg/dL lub więcej. Zmierz ponownie, nawodnij się i jak najszybciej skontaktuj się z lekarzem, jeśli pozostaje tak wysoki.", - "glucoseHighEmergency": "Dwa ostatnie pomiary wyniosły {value} mg/dL lub więcej i zgłosiłeś objawy. W razie wymiotów, przyspieszonego oddechu lub dezorientacji natychmiast zadzwoń po pomoc." + "glucoseHighDoctor": "Dwa ostatnie pomiary wyniosły {value} {unit} lub więcej. Zmierz ponownie, nawodnij się i jak najszybciej skontaktuj się z lekarzem, jeśli pozostaje tak wysoki.", + "glucoseHighEmergency": "Dwa ostatnie pomiary wyniosły {value} {unit} lub więcej i zgłosiłeś objawy. W razie wymiotów, przyspieszonego oddechu lub dezorientacji natychmiast zadzwoń po pomoc." } }, "version": { @@ -8042,6 +8080,12 @@ "nummerGegenKummer": { "name": "Nummer gegen Kummer (dla młodych ludzi)" }, + "telefonSeelsorgeAt": { + "name": "Telefonseelsorge Österreich (całodobowo, bezpłatnie i poufnie)" + }, + "dargeboteneHand": { + "name": "Die Dargebotene Hand (całodobowo, bezpłatnie i poufnie)" + }, "krisenchat": { "name": "krisenchat (czat całodobowy, dla młodych ludzi)" }, @@ -8295,7 +8339,9 @@ "openLabel": "Otwórz {title}", "attachmentBadge": "Tylko do pobrania", "uploading": "Przesyłanie…", - "dismissFailed": "Odrzuć nieudane przesyłanie" + "dismissFailed": "Odrzuć nieudane przesyłanie", + "processing": "Przetwarzanie…", + "ready": "Gotowe" }, "error": { "fileTooLarge": "Ten plik przekracza limit {maxSize}.", diff --git a/package.json b/package.json index 371f4d0b9..4efe4e7e5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.29.5", + "version": "1.30.0", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/prisma/migrations/0251_backfill_strava_workout_sport/migration.sql b/prisma/migrations/0251_backfill_strava_workout_sport/migration.sql new file mode 100644 index 000000000..0ccf55cae --- /dev/null +++ b/prisma/migrations/0251_backfill_strava_workout_sport/migration.sql @@ -0,0 +1,103 @@ +-- v1.29.x — backfill non-canonical Strava `Workout.sport_type` values. +-- +-- Root cause (the same class the 0247 WHOOP migration fixed): Strava was the +-- second workout integration that never normalised its sport to HealthLog's +-- canonical `WorkoutSportType` enum (see `workoutSportTypeEnum` in +-- src/lib/validations/workout.ts). `mapActivity()` in +-- src/lib/strava/client.ts wrote Strava's raw PascalCase `sport_type` (or +-- the legacy `type`, e.g. "Run", "Ride", "VirtualRide", "TrailRun") — or the +-- literal fallback "workout" when neither field was present — straight into +-- `Workout.sport_type`. Existing Strava rows therefore carry values the +-- `/insights/workouts` list can't map to an icon/label, AND the read-time +-- canonical picker in src/lib/sources/pick-canonical-workout.ts treats any +-- two non-canonical ("generic") rows as sport-compatible — a same-source +-- brick session (a Strava "Ride" ending 15:00 immediately followed by a +-- Strava "Run" starting 15:03) could cluster into one and silently drop a +-- leg. `mapStravaSportType()` (src/lib/strava/sport-map.ts) fixes the write +-- path going forward; this migration re-derives every EXISTING Strava +-- `Workout` row from the same table so historical data and the live mapper +-- never drift apart. +-- +-- The WHEN arms below were generated FROM `STRAVA_SPORT_TABLE` in +-- src/lib/strava/sport-map.ts (one JS-side script run against that exact +-- table, not hand-transcribed). The match expression lower-cases the stored +-- column and strips every character outside `[a-zA-Z0-9]`, mirroring +-- `normaliseStravaSportKey()` in sport-map.ts exactly, so "TrailRun", +-- "trailrun", and any stray-separator variant all resolve to the same key — +-- and so the pre-fix literal fallback "workout" resolves to the same row as +-- Strava's own "Workout" catch-all. +-- +-- Anything neither arm recognises falls to `ELSE 'other'` — same fallback +-- `mapStravaSportType()` uses, never leaves a row on a raw Strava label. +-- +-- Idempotent: the WHERE clause excludes rows whose `sport_type` is ALREADY +-- one of the 20 canonical values, so a re-run touches zero rows once the +-- first run has completed. Safe to re-run after a partial failure. +UPDATE "workouts" +SET "sport_type" = CASE + lower(regexp_replace(trim("sport_type"), '[^a-zA-Z0-9]+', '', 'g')) + WHEN 'alpineski' THEN 'other' + WHEN 'backcountryski' THEN 'other' + WHEN 'badminton' THEN 'tennis' + WHEN 'basketball' THEN 'basketball' + WHEN 'canoeing' THEN 'rowing' + WHEN 'cricket' THEN 'other' + WHEN 'crossfit' THEN 'crossTraining' + WHEN 'dance' THEN 'dance' + WHEN 'ebikeride' THEN 'cycling' + WHEN 'elliptical' THEN 'elliptical' + WHEN 'emountainbikeride' THEN 'cycling' + WHEN 'golf' THEN 'golf' + WHEN 'gravelride' THEN 'cycling' + WHEN 'handcycle' THEN 'cycling' + WHEN 'highintensityintervaltraining' THEN 'hiit' + WHEN 'hike' THEN 'hiking' + WHEN 'iceskate' THEN 'other' + WHEN 'inlineskate' THEN 'other' + WHEN 'kayaking' THEN 'rowing' + WHEN 'kitesurf' THEN 'other' + WHEN 'mountainbikeride' THEN 'cycling' + WHEN 'nordicski' THEN 'other' + WHEN 'padel' THEN 'tennis' + WHEN 'physicaltherapy' THEN 'mindAndBody' + WHEN 'pickleball' THEN 'tennis' + WHEN 'pilates' THEN 'mindAndBody' + WHEN 'racquetball' THEN 'tennis' + WHEN 'ride' THEN 'cycling' + WHEN 'rockclimbing' THEN 'crossTraining' + WHEN 'rollerski' THEN 'other' + WHEN 'rowing' THEN 'rowing' + WHEN 'run' THEN 'running' + WHEN 'sail' THEN 'other' + WHEN 'skateboard' THEN 'other' + WHEN 'snowboard' THEN 'other' + WHEN 'snowshoe' THEN 'hiking' + WHEN 'soccer' THEN 'soccer' + WHEN 'squash' THEN 'tennis' + WHEN 'stairstepper' THEN 'stairClimber' + WHEN 'standuppaddling' THEN 'rowing' + WHEN 'surfing' THEN 'other' + WHEN 'swim' THEN 'swimming' + WHEN 'tabletennis' THEN 'tennis' + WHEN 'tennis' THEN 'tennis' + WHEN 'trailrun' THEN 'running' + WHEN 'velomobile' THEN 'cycling' + WHEN 'virtualride' THEN 'cycling' + WHEN 'virtualrow' THEN 'rowing' + WHEN 'virtualrun' THEN 'running' + WHEN 'volleyball' THEN 'other' + WHEN 'walk' THEN 'walking' + WHEN 'weighttraining' THEN 'strength' + WHEN 'wheelchair' THEN 'other' + WHEN 'windsurf' THEN 'other' + WHEN 'workout' THEN 'other' + WHEN 'yoga' THEN 'yoga' + ELSE 'other' +END +WHERE "source" = 'STRAVA' + AND "sport_type" NOT IN ( + 'walking', 'running', 'cycling', 'hiking', 'swimming', 'rowing', + 'elliptical', 'stairClimber', 'yoga', 'mindAndBody', 'strength', 'hiit', + 'dance', 'golf', 'tennis', 'basketball', 'soccer', 'crossTraining', + 'mixedCardio', 'other' + ); diff --git a/public/sw.js b/public/sw.js index d91e5e259..a5ab32619 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.29.5"; + /* @sw-version-fallback */ "v1.30.0"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 3b6d6cfb1..81c99dd63 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -57,6 +57,12 @@ export const dynamic = "force-dynamic"; interface RestoreResponse { restored: true; + // v1.28 — `summary` reports every domain PRESENT in the uploaded file + // (informational — it now includes labs/illness/allergies/family-history/ + // workouts/documents counts too). `cleared` below is the narrower, + // authoritative list of what this restore actually wiped + recreated — + // those newer domains are export-only for now and are NOT touched by a + // restore, so they are deliberately absent from `cleared`. summary: BackupSummary; cleared: { measurements: number; diff --git a/src/app/api/admin/backups/upload/route.ts b/src/app/api/admin/backups/upload/route.ts index 9457cd99d..28012e04b 100644 --- a/src/app/api/admin/backups/upload/route.ts +++ b/src/app/api/admin/backups/upload/route.ts @@ -218,6 +218,14 @@ export const POST = apiHandler(async (request: NextRequest) => { moodEntries: summary.moodEntries, cycles: summary.cycles, cycleDayLogs: summary.cycleDayLogs, + labResults: summary.labResults, + biomarkers: summary.biomarkers, + illnessEpisodes: summary.illnessEpisodes, + illnessDayLogs: summary.illnessDayLogs, + allergies: summary.allergies, + familyHistory: summary.familyHistory, + workouts: summary.workouts, + documents: summary.documents, }, }, }); diff --git a/src/app/api/consent/ai/route.ts b/src/app/api/consent/ai/route.ts index 8679be132..630a7aa86 100644 --- a/src/app/api/consent/ai/route.ts +++ b/src/app/api/consent/ai/route.ts @@ -9,6 +9,7 @@ */ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { apiError, apiSuccess, @@ -56,10 +57,13 @@ export const POST = apiHandler(async (request: NextRequest) => { // reconstruct "who minted this and when" even if the receipts table // is rebuilt from a backup. Fire-and-forget; audit failures must // never bubble into a user-facing 500. - auditLog("consent.ai.grant", { - userId: user.id, - details: { kind, receiptId: receipt.id }, - }).catch(() => {}); + fireAndForget( + auditLog("consent.ai.grant", { + userId: user.id, + details: { kind, receiptId: receipt.id }, + }), + { action: "consent.ai.audit" }, + ); annotate({ action: { name: "consent.ai.grant" }, diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index 956f61e50..f442c3e15 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -7,7 +7,7 @@ * action name, and rate-limit wiring. The full DB round-trip lives in * the integration suite (`tests/integration/export-per-type.test.ts`). */ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { NextRequest } from "next/server"; vi.mock("@/lib/db", () => ({ @@ -20,6 +20,15 @@ vi.mock("@/lib/db", () => ({ cycleProfile: { findUnique: vi.fn() }, menstrualCycle: { findMany: vi.fn() }, cycleDayLog: { findMany: vi.fn() }, + // v1.28 backup-completeness — the records section the full-backup + // helper now also reads (`buildRecordsBackupSection`). + labResult: { findMany: vi.fn() }, + biomarker: { findMany: vi.fn() }, + illnessEpisode: { findMany: vi.fn() }, + allergy: { findMany: vi.fn() }, + familyHistoryEntry: { findMany: vi.fn() }, + workout: { findMany: vi.fn() }, + inboundDocument: { findMany: vi.fn() }, user: { findUnique: vi.fn() }, auditLog: { create: vi.fn() }, }, @@ -63,6 +72,13 @@ beforeEach(() => { vi.resetAllMocks(); }); +afterEach(() => { + // The v1.28 records-domain end-to-end test stubs ENCRYPTION_KEY to + // exercise the real crypto path; unstub unconditionally (harmless no-op + // for every other test) so a stub never leaks past its own test. + vi.unstubAllEnvs(); +}); + describe("GET /api/export/measurements", () => { it("rejects unauthenticated requests with 401", async () => { vi.mocked(getSession).mockResolvedValue(null); @@ -385,6 +401,15 @@ describe("GET /api/export/full-backup", () => { vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never); vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never); vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.biomarker.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.allergy.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([] as never); const { GET } = await import("../full-backup/route"); const res = await GET(mkReq("http://localhost/api/export/full-backup")); @@ -402,6 +427,20 @@ describe("GET /api/export/full-backup", () => { medications: [], intakeEvents: [], moodEntries: [], + // v1.28 backup-completeness — the newer domains round-trip to empty + // arrays (not undefined/missing) when the account has no records, + // and the manifest still discloses the two deliberate exclusions. + labResults: [], + biomarkers: [], + illnessEpisodes: [], + allergies: [], + familyHistory: [], + workouts: [], + documents: [], + manifest: { + documents: { included: "metadata-only" }, + workouts: { included: "summary-only" }, + }, }); expect(typeof json.exportedAt).toBe("string"); expect(auditLog).toHaveBeenCalledWith( @@ -409,4 +448,81 @@ describe("GET /api/export/full-backup", () => { expect.objectContaining({ userId: "user-1" }), ); }); + + it("includes decrypted v1.28 records-domain data end-to-end", async () => { + vi.stubEnv("ENCRYPTION_KEYS", ""); + vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", ""); + vi.stubEnv("ENCRYPTION_KEY", "a".repeat(64)); + + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 9, + resetAt: Date.now() + 3600_000, + }); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never); + vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never); + + const { encryptToBytes } = await import("@/lib/ai/coach/bytes-codec"); + vi.mocked(prisma.labResult.findMany).mockResolvedValue([ + { + panel: null, + analyte: "HbA1c", + value: 5.4, + valueText: null, + unit: "%", + referenceLow: null, + referenceHigh: null, + takenAt: new Date("2026-04-01T09:00:00.000Z"), + source: "MANUAL", + biomarker: null, + noteEncrypted: null, + }, + ] as never); + vi.mocked(prisma.biomarker.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([ + { + id: "ep-1", + label: "Migraine flare", + type: "CHRONIC", + lifecycle: "FLARE", + onsetAt: new Date("2026-04-10T00:00:00.000Z"), + resolvedAt: null, + parentConditionId: "ep-parent", + noteEncrypted: encryptToBytes("Triggered by travel."), + createdAt: new Date("2026-04-10T00:00:00.000Z"), + updatedAt: new Date("2026-04-10T00:00:00.000Z"), + dayLogs: [], + }, + ] as never); + vi.mocked(prisma.allergy.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([] as never); + + const { GET } = await import("../full-backup/route"); + const res = await GET(mkReq("http://localhost/api/export/full-backup")); + expect(res.status).toBe(200); + const json = await res.json(); + + expect(json.labResults).toEqual([ + expect.objectContaining({ analyte: "HbA1c", value: 5.4 }), + ]); + // The flare's parent link + decrypted note round-trip in the JSON — + // never the ciphertext. + expect(json.illnessEpisodes[0].parentConditionId).toBe("ep-parent"); + expect(json.illnessEpisodes[0].note).toBe("Triggered by travel."); + // The raw ciphertext column never leaves the server — only the + // decrypted `note` field is present on the DTO. + expect(json.illnessEpisodes[0]).not.toHaveProperty("noteEncrypted"); + }); }); diff --git a/src/app/api/export/__tests__/soft-delete-filter.test.ts b/src/app/api/export/__tests__/soft-delete-filter.test.ts index ae3d95f6d..05b05118e 100644 --- a/src/app/api/export/__tests__/soft-delete-filter.test.ts +++ b/src/app/api/export/__tests__/soft-delete-filter.test.ts @@ -42,6 +42,18 @@ vi.mock("@/lib/db", () => ({ auditLog: { findMany: vi.fn() }, userHealthProfile: { findUnique: vi.fn() }, illnessEpisode: { findMany: vi.fn() }, + // v1.28 backup-completeness — the cycle + records sections the + // full-backup builder now also reads (`buildCycleBackupSection` / + // `buildRecordsBackupSection`). + cycleProfile: { findUnique: vi.fn() }, + menstrualCycle: { findMany: vi.fn() }, + cycleDayLog: { findMany: vi.fn() }, + labResult: { findMany: vi.fn() }, + biomarker: { findMany: vi.fn() }, + allergy: { findMany: vi.fn() }, + familyHistoryEntry: { findMany: vi.fn() }, + workout: { findMany: vi.fn() }, + inboundDocument: { findMany: vi.fn() }, }, })); @@ -125,6 +137,18 @@ beforeEach(() => { null as never, ); vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never); + // v1.28 backup-completeness — the full-backup route now also reads the + // cycle + records sections; let them resolve to empty so the route + // completes instead of rejecting on an unmocked delegate. + vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never); + vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.biomarker.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.allergy.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([] as never); }); describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => { @@ -148,6 +172,48 @@ describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => { ); }); + it("/api/export/full-backup scopes every v1.28 records domain to userId + deletedAt: null", async () => { + const { GET } = await import("../full-backup/route"); + await GET(mkReq("http://localhost/api/export/full-backup")); + + expect(prisma.labResult.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: "user-1", deletedAt: null }, + }), + ); + expect(prisma.illnessEpisode.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: "user-1", deletedAt: null }, + }), + ); + expect(prisma.allergy.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: "user-1", deletedAt: null }, + }), + ); + expect(prisma.familyHistoryEntry.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: "user-1", deletedAt: null }, + }), + ); + expect(prisma.inboundDocument.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: "user-1", deletedAt: null }, + }), + ); + // Biomarker + Workout carry no soft-delete tombstone — userId-only scope. + expect(prisma.biomarker.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { userId: "user-1" } }), + ); + expect(prisma.workout.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { userId: "user-1" } }), + ); + // The document read must never select the encrypted content blob. + const documentCall = vi.mocked(prisma.inboundDocument.findMany).mock + .calls[0][0] as { select?: Record }; + expect(documentCall.select).not.toHaveProperty("contentEncrypted"); + }); + it("/api/export (legacy) scopes the measurement read to deletedAt: null", async () => { const { GET } = await import("../route"); await GET(mkReq("http://localhost/api/export?type=measurements")); diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index ca3e57174..075776e11 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -17,6 +17,15 @@ vi.mock("@/lib/db", () => ({ cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) }, + // v1.28 backup-completeness — the records section the shared payload + // builder now also reads (`buildRecordsBackupSection`). + labResult: { findMany: vi.fn().mockResolvedValue([]) }, + biomarker: { findMany: vi.fn().mockResolvedValue([]) }, + illnessEpisode: { findMany: vi.fn().mockResolvedValue([]) }, + allergy: { findMany: vi.fn().mockResolvedValue([]) }, + familyHistoryEntry: { findMany: vi.fn().mockResolvedValue([]) }, + workout: { findMany: vi.fn().mockResolvedValue([]) }, + inboundDocument: { findMany: vi.fn().mockResolvedValue([]) }, session: { findUnique: vi.fn() }, webauthnMfaCredential: { count: vi.fn() }, }, @@ -77,6 +86,18 @@ describe("POST /api/export/encrypted", () => { const payload = JSON.parse(plaintext); expect(payload).toHaveProperty("schemaVersion"); expect(payload).toHaveProperty("measurements"); + // v1.28 backup-completeness — the encrypted archive carries the same + // records domains as the plaintext export. + expect(payload).toHaveProperty("labResults"); + expect(payload).toHaveProperty("illnessEpisodes"); + expect(payload).toHaveProperty("allergies"); + expect(payload).toHaveProperty("familyHistory"); + expect(payload).toHaveProperty("workouts"); + expect(payload).toHaveProperty("documents"); + expect(payload.manifest).toMatchObject({ + documents: { included: "metadata-only" }, + workouts: { included: "summary-only" }, + }); }); it("blocks an MFA account without a fresh second factor (401 step-up)", async () => { diff --git a/src/app/api/export/encrypted/route.ts b/src/app/api/export/encrypted/route.ts index 99193bf76..69c8b5806 100644 --- a/src/app/api/export/encrypted/route.ts +++ b/src/app/api/export/encrypted/route.ts @@ -2,9 +2,10 @@ * POST /api/export/encrypted * * v1.23 — passphrase-encrypted variant of the full-backup export. Returns the - * same restore-compatible payload as `GET /api/export/full-backup`, but sealed - * into an `HLX1` archive (Argon2id-derived key + AES-256-GCM) under a passphrase - * the caller supplies in the request body. The binary archive is returned as + * same payload as `GET /api/export/full-backup` (see that route's doc comment + * for which domains restore recreates vs. export-only), sealed into an + * `HLX1` archive (Argon2id-derived key + AES-256-GCM) under a passphrase the + * caller supplies in the request body. The binary archive is returned as * `application/octet-stream`. * * SECURITY: @@ -107,6 +108,14 @@ export const POST = apiHandler(async (request: NextRequest) => { export_mood_count: counts.moodEntries, export_cycle_count: counts.cycles, export_cycle_day_log_count: counts.cycleDayLogs, + export_lab_result_count: counts.labResults, + export_biomarker_count: counts.biomarkers, + export_illness_episode_count: counts.illnessEpisodes, + export_illness_day_log_count: counts.illnessDayLogs, + export_allergy_count: counts.allergies, + export_family_history_count: counts.familyHistory, + export_workout_count: counts.workouts, + export_document_count: counts.documents, export_archive_bytes: archive.byteLength, }, }); diff --git a/src/app/api/export/full-backup/route.ts b/src/app/api/export/full-backup/route.ts index aad5fd565..f5fe21918 100644 --- a/src/app/api/export/full-backup/route.ts +++ b/src/app/api/export/full-backup/route.ts @@ -11,6 +11,16 @@ * `.json` so the browser writes a `.json` file even though the route * segment doesn't carry the extension. * + * Backup-completeness — the payload also carries lab results, the + * biomarker catalog, illness episodes (incl. flares/exacerbations) with + * their day-logs, allergies, family history, and workout summaries. These + * domains are EXPORT-ONLY today: `POST /api/admin/backups/[id]/restore` + * only re-creates the original measurements/medications/mood/cycle + * domains, so restoring a snapshot does not (yet) recreate these sections + * — see `src/lib/export/records-backup.ts`. Document original files and + * workout GPS/sample time series are never included; the payload's + * `manifest` field discloses both, and the export UI states this too. + * * Auth: cookie session OR Bearer token (`requireAuth`). * Rate-limit: shared `export:` bucket (10/h). * Audit: `user.export.full-backup` with the row counts. @@ -34,7 +44,9 @@ export const GET = apiHandler(async (request: NextRequest) => { } // v1.23 — the payload builder is shared with the passphrase-encrypted - // export route so both emit the byte-for-byte same restore-compatible shape. + // export route so both emit the byte-for-byte same shape (the measurement/ + // medication/mood/cycle domains are restore-compatible; the newer records + // domains are export-only for now — see the module doc comment above). const { payload, counts } = await buildFullBackupPayload(prisma, user.id); await auditLog("user.export.full-backup", { @@ -51,6 +63,14 @@ export const GET = apiHandler(async (request: NextRequest) => { export_mood_count: counts.moodEntries, export_cycle_count: counts.cycles, export_cycle_day_log_count: counts.cycleDayLogs, + export_lab_result_count: counts.labResults, + export_biomarker_count: counts.biomarkers, + export_illness_episode_count: counts.illnessEpisodes, + export_illness_day_log_count: counts.illnessDayLogs, + export_allergy_count: counts.allergies, + export_family_history_count: counts.familyHistory, + export_workout_count: counts.workouts, + export_document_count: counts.documents, }, }); diff --git a/src/app/api/fitbit/connect/__tests__/route.test.ts b/src/app/api/fitbit/connect/__tests__/route.test.ts new file mode 100644 index 000000000..aa71ee8a4 --- /dev/null +++ b/src/app/api/fitbit/connect/__tests__/route.test.ts @@ -0,0 +1,115 @@ +/** + * GET /api/fitbit/connect + * + * Pins: happy path mints a nonce + PKCE pair and redirects to the Fitbit + * consent screen; rate-limit and no-credentials both redirect back to + * `/settings/integrations?fitbit=error&reason=` rather than returning + * JSON — a browser-navigation entry point should never surface an unstyled + * JSON page. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u-1" } })), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { fitbitOAuthState: { create: vi.fn() } }, +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), +})); + +vi.mock("@/lib/fitbit/credentials", () => ({ + getUserFitbitCredentials: vi.fn(), +})); + +vi.mock("@/lib/fitbit/client", () => ({ + getAuthorizationUrl: vi.fn( + () => "https://www.fitbit.com/oauth2/authorize?state=nonce-stub", + ), + generatePkcePair: vi.fn(() => ({ + verifier: "verifier-stub", + challenge: "challenge-stub", + })), +})); + +vi.mock("@/lib/fitbit/oauth-state", () => ({ + FITBIT_OAUTH_STATE_COOKIE: "fitbit_oauth_state", + FITBIT_OAUTH_STATE_TTL_MS: 10 * 60 * 1000, + mintFitbitOAuthStateNonce: vi.fn(() => "nonce-stub"), +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: vi.fn(() => ({ setError: vi.fn() })), +})); + +vi.mock("@/lib/auth/secure-cookie", () => ({ + shouldEmitSecureCookie: () => true, +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { getUserFitbitCredentials } from "@/lib/fitbit/credentials"; + +function connectRequest(): NextRequest { + return new NextRequest("http://localhost/api/fitbit/connect", { + method: "GET", + }); +} + +beforeEach(() => { + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 9, + resetAt: Date.now() + 60_000, + } as never); + vi.mocked(prisma.fitbitOAuthState.create).mockReset(); + vi.mocked(prisma.fitbitOAuthState.create).mockResolvedValue({} as never); + vi.mocked(getUserFitbitCredentials).mockResolvedValue({ + clientId: "client-id", + clientSecret: "client-secret", + } as never); +}); + +describe("GET /api/fitbit/connect", () => { + it("redirects to the Fitbit authorize URL on the happy path", async () => { + const response = await GET(connectRequest()); + expect(response.status).toBe(307); + expect(response.headers.get("location")).toContain( + "www.fitbit.com/oauth2/authorize", + ); + expect(prisma.fitbitOAuthState.create).toHaveBeenCalledTimes(1); + }); + + it("redirects to reason=rate_limited when the bucket is exhausted", async () => { + vi.mocked(checkRateLimit).mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + } as never); + + const response = await GET(connectRequest()); + const location = response.headers.get("location") ?? ""; + expect(location).toContain("/settings/integrations"); + expect(location).toContain("fitbit=error"); + expect(location).toContain("reason=rate_limited"); + expect(prisma.fitbitOAuthState.create).not.toHaveBeenCalled(); + }); + + it("redirects to reason=nocreds when no per-user credentials are stored", async () => { + vi.mocked(getUserFitbitCredentials).mockResolvedValueOnce(null); + + const response = await GET(connectRequest()); + const location = response.headers.get("location") ?? ""; + expect(location).toContain("/settings/integrations"); + expect(location).toContain("fitbit=error"); + expect(location).toContain("reason=nocreds"); + expect(prisma.fitbitOAuthState.create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/fitbit/connect/route.ts b/src/app/api/fitbit/connect/route.ts index cf1e33ae6..bae44e08a 100644 --- a/src/app/api/fitbit/connect/route.ts +++ b/src/app/api/fitbit/connect/route.ts @@ -1,5 +1,4 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiError } from "@/lib/api-response"; import { prisma } from "@/lib/db"; import { annotate, getEvent } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -32,7 +31,8 @@ import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; * Rate-limited per user (10 calls / 60 s) so a logged-in session can't spam * ledger rows for the full 10-min TTL window. Both the rate-limit and * create-failure paths redirect (not JSON) because the entry point is a browser - * navigation — a 429 envelope would surface as a blank page. + * navigation — a 429 envelope would surface as a blank page. The + * no-credentials branch redirects too (v1.29.x), matching Withings/WHOOP. */ const CONNECT_RATE_LIMIT = 10; const CONNECT_WINDOW_MS = 60_000; @@ -58,9 +58,9 @@ export const GET = apiHandler(async (req: NextRequest) => { const creds = await getUserFitbitCredentials(user.id); if (!creds) { - return apiError( - "Please configure your Fitbit OAuth Client ID and Client Secret in Settings first.", - 400, + annotate({ action: { name: "fitbit.connect.no_credentials" } }); + return NextResponse.redirect( + new URL("/settings/integrations?fitbit=error&reason=nocreds", req.url), ); } diff --git a/src/app/api/insights/cards/__tests__/route.test.ts b/src/app/api/insights/cards/__tests__/route.test.ts index 8b25d71d8..e4a162f53 100644 --- a/src/app/api/insights/cards/__tests__/route.test.ts +++ b/src/app/api/insights/cards/__tests__/route.test.ts @@ -11,6 +11,12 @@ vi.mock("@/lib/db", () => ({ // back to the all-on default so existing assertions ride // through unchanged. appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + // v1.28 perf — PULSE now reads through the DAY-rollup read-swap + // (`probeRollupCoverage` + `readDayMeanSeries`). An empty coverage + // probe sends every test down the live-fallback branch, which itself + // reads via the already-mocked `measurement.findMany` — no rollup + // rows needed for these tests. + $queryRaw: vi.fn().mockResolvedValue([]), }, })); @@ -69,6 +75,10 @@ beforeEach(() => { aiProvider: null, } as never); vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); + // v1.28 perf — empty coverage probe routes PULSE through the + // live-fallback branch of `readDayMeanSeries`, which reads via the + // already-mocked `measurement.findMany` above. + vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never); }); const callGet = GET as unknown as (req: NextRequest) => Promise; @@ -165,6 +175,56 @@ describe("GET /api/insights/cards", () => { expect(body.data.some((c) => c.severity === "alert")).toBe(true); }); + it("reads PULSE through the day-rollup read-swap, never the manual raw-row query (perf)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + + // 27 quiet days + 3 outlier days, one raw sample per day — a large + // enough quiet cluster that the 3 outliers still clear the z-score > 2 + // threshold once folded into day means by the live-fallback branch of + // `readDayMeanSeries` (a small n dilutes the mean/stddev too much for + // the outliers to stand out). + const pulseRows: Array<{ value: number; measuredAt: Date }> = []; + const now = new Date(); + for (let i = 0; i < 30; i++) { + const at = new Date(now.getTime() - i * 86_400_000); + pulseRows.push({ value: i < 3 ? 300 : 68, measuredAt: at }); + } + + vi.mocked(prisma.measurement.findMany).mockImplementation((async (args: { + where?: { type?: unknown }; + }) => { + // `readDayMeanSeries`'s live-fallback probes a single `type` + // equality filter; the manual WEIGHT/BP query below probes an + // `{ in: [...] }` list — the two calls are distinguishable by shape. + if (args?.where?.type === "PULSE") return pulseRows; + return []; + }) as never); + + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: Array<{ title: string; summary: string }>; + }; + expect( + body.data.some((c) => c.summary.includes("unusual pulse readings")), + ).toBe(true); + + // The bounded manual-measurements query (WEIGHT / BP) must never ask + // for PULSE — a dense wearable account's raw PULSE rows only ever + // reach this route through the day-mean rollup read. + const manualCall = vi + .mocked(prisma.measurement.findMany) + .mock.calls.find( + (call) => + typeof (call[0] as { where?: { type?: { in?: unknown[] } } })?.where + ?.type === "object", + ); + expect(manualCall).toBeDefined(); + const typeIn = (manualCall?.[0] as { where: { type: { in: string[] } } }) + .where.type.in; + expect(typeIn).not.toContain("PULSE"); + }); + it("does NOT flag a fully-adherent weekly injectable as low compliance (#214 regression)", async () => { // Pre-fix this path used a naive `schedules.length × 7` denominator, so a // weekly Monday injectable with its one dose taken computed ~14% and fired diff --git a/src/app/api/insights/cards/route.ts b/src/app/api/insights/cards/route.ts index 6c592bf2d..6525c661f 100644 --- a/src/app/api/insights/cards/route.ts +++ b/src/app/api/insights/cards/route.ts @@ -13,6 +13,8 @@ import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; import { summarize, type DataPoint } from "@/lib/analytics/trends"; +import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; +import { readDayMeanSeries } from "@/lib/insights/derived/baseline"; import { getBpTargets } from "@/lib/analytics/bp-targets"; import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target"; import { @@ -72,43 +74,66 @@ export const GET = apiHandler(async () => { await requireAssistantSurface("insightStatus"); annotate({ action: { name: "insights.cards" } }); - const ninetyDaysAgo = new Date(Date.now() - 90 * 86_400_000); + const NINETY_DAY_WINDOW = 90; + const ninetyDaysAgo = new Date(Date.now() - NINETY_DAY_WINDOW * 86_400_000); - const [dbUser, allMeasurements, medications] = await Promise.all([ - prisma.user.findUnique({ - where: { id: user.id }, - select: { heightCm: true, dateOfBirth: true, aiProvider: true }, - }), - prisma.measurement.findMany({ - where: { - userId: user.id, - type: { - in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"], + const [dbUser, manualMeasurements, medications, rollupCoverage] = + await Promise.all([ + prisma.user.findUnique({ + where: { id: user.id }, + select: { heightCm: true, dateOfBirth: true, aiProvider: true }, + }), + // WEIGHT + BP are manual entries (low volume, and BP needs exact- + // timestamp pairing below for `bpPctInTarget`), so a bounded raw read + // stays cheap. PULSE is excluded here — a continuously-synced wearable + // account can carry 100k+ raw rows in the window; it reads through the + // rollup tier below instead. + prisma.measurement.findMany({ + where: { + userId: user.id, + type: { in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"] }, + measuredAt: { gte: ninetyDaysAgo }, + deletedAt: null, }, - measuredAt: { gte: ninetyDaysAgo }, - deletedAt: null, - }, - orderBy: { measuredAt: "asc" }, - select: { type: true, value: true, measuredAt: true }, - }), - prisma.medication.findMany({ - // v1.16.11 — as-needed (PRN) medications never surface a compliance - // rate (no expected doses). - where: { userId: user.id, active: true, asNeeded: false }, - include: { - // Schedules through the shared compliance select so the configured - // per-dose windows reach the engine like every other surface. - schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, - // v1.16.3 — archived schedule eras for era-aware expected counts. - scheduleRevisions: { orderBy: { validFrom: "asc" } }, - // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. - pauseEras: { select: { pausedAt: true, resumedAt: true } }, - }, - }), - ]); + orderBy: { measuredAt: "asc" }, + select: { type: true, value: true, measuredAt: true }, + }), + prisma.medication.findMany({ + // v1.16.11 — as-needed (PRN) medications never surface a compliance + // rate (no expected doses). + where: { userId: user.id, active: true, asNeeded: false }, + include: { + // Schedules through the shared compliance select so the configured + // per-dose windows reach the engine like every other surface. + schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, + // v1.16.3 — archived schedule eras for era-aware expected counts. + scheduleRevisions: { orderBy: { validFrom: "asc" } }, + // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. + pauseEras: { select: { pausedAt: true, resumedAt: true } }, + }, + }), + probeRollupCoverage(user.id), + ]); + + // v1.28 perf — PULSE reads the DAY-rollup tier (read-swap; falls back to a + // bounded per-day-grouped live read on a coverage miss) instead of pulling + // every raw sample into JS. `pulseAvg30` / `pulseAnomalyCount` derive from + // the resulting day-mean series, so a dense wearable account's anomaly + // count reflects daily outliers rather than every noisy raw sample. + const { points: pulseDayMeans } = await readDayMeanSeries( + user.id, + "PULSE", + NINETY_DAY_WINDOW, + new Date(), + rollupCoverage, + ); + const pulseData: DataPoint[] = pulseDayMeans.map((p) => ({ + date: new Date(`${p.day}T00:00:00.000Z`), + value: p.mean, + })); const byType = (t: MeasurementType): DataPoint[] => - allMeasurements + manualMeasurements .filter((m) => m.type === t) .map((m) => ({ date: m.measuredAt, value: m.value })); @@ -119,11 +144,11 @@ export const GET = apiHandler(async () => { "WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", - "PULSE", ] as MeasurementType[]) { const data = byType(t); if (data.length > 0) summaries[t] = summarize(data); } + if (pulseData.length > 0) summaries.PULSE = summarize(pulseData); let bmi: number | null = null; if (dbUser?.heightCm && summaries.WEIGHT?.latest) { diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index 4256e6274..e741f1607 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -38,11 +38,15 @@ import { type NamedSeries, } from "@/lib/insights/correlation-discovery"; import { + buildMeasurementDailySeries, fetchComplianceSeries, fetchEnvironmentSeries, fetchLabDraws, fetchSymptomSeries, + toDailyMeans, + type MeasurementSeriesRow, } from "@/lib/insights/correlation-channel-series"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; export const dynamic = "force-dynamic"; @@ -58,25 +62,6 @@ function tzDayKey(at: Date, tz: string): string { return `${year}-${mm}-${dd}`; } -/** Collapse rows to per-day means keyed in the user's tz. */ -function toDailyMeans( - rows: Array<{ value: number; at: Date }>, - tz: string, -): DailySeriesPoint[] { - const byDay = new Map(); - for (const r of rows) { - if (!Number.isFinite(r.value)) continue; - const day = tzDayKey(r.at, tz); - const acc = byDay.get(day) ?? { sum: 0, count: 0 }; - acc.sum += r.value; - acc.count += 1; - byDay.set(day, acc); - } - return [...byDay.entries()] - .map(([day, acc]) => ({ day, value: acc.sum / acc.count })) - .sort((a, b) => (a.day < b.day ? -1 : 1)); -} - export const GET = apiHandler(async () => { const { user } = await requireAuth(); @@ -112,7 +97,11 @@ export const GET = apiHandler(async () => { DISCOVERY_OUTCOMES, ) as MeasurementType[]; - const [measurements, moodEntries] = await Promise.all([ + // v1.29.6 — `source` + `deviceType` + `sleepStage` feed + // `buildMeasurementDailySeries`'s per-type grain resolution below (source + // collapse for cumulative types, per-night reconstruction for sleep); the + // rest of the channels never look at them. + const [measurements, moodEntries, priorityJson] = await Promise.all([ prisma.measurement.findMany({ where: { userId: user.id, @@ -122,7 +111,14 @@ export const GET = apiHandler(async () => { }, orderBy: { measuredAt: "asc" }, take: 20000, - select: { type: true, value: true, measuredAt: true }, + select: { + type: true, + value: true, + measuredAt: true, + source: true, + deviceType: true, + sleepStage: true, + }, }), prisma.moodEntry.findMany({ where: { userId: user.id, deletedAt: null, moodLoggedAt: { gte: since } }, @@ -130,15 +126,19 @@ export const GET = apiHandler(async () => { take: 5000, select: { score: true, moodLoggedAt: true }, }), + loadUserSourcePriority(user.id), ]); - const measurementsByType = new Map< - string, - Array<{ value: number; at: Date }> - >(); + const measurementsByType = new Map(); for (const m of measurements) { const list = measurementsByType.get(m.type) ?? []; - list.push({ value: m.value, at: m.measuredAt }); + list.push({ + value: m.value, + at: m.measuredAt, + source: m.source, + deviceType: m.deviceType, + sleepStage: m.sleepStage, + }); measurementsByType.set(m.type, list); } @@ -167,7 +167,12 @@ export const GET = apiHandler(async () => { const points = (key: string): DailySeriesPoint[] => key === "MOOD" ? moodDaily - : toDailyMeans(measurementsByType.get(key) ?? [], tz); + : buildMeasurementDailySeries( + key as MeasurementType, + measurementsByType.get(key) ?? [], + tz, + priorityJson, + ); const series: NamedSeries[] = []; for (const key of DISCOVERY_BEHAVIOURS) { diff --git a/src/app/api/labs/ocr/commit/route.ts b/src/app/api/labs/ocr/commit/route.ts index 50a3dae50..4f7decad4 100644 --- a/src/app/api/labs/ocr/commit/route.ts +++ b/src/app/api/labs/ocr/commit/route.ts @@ -11,6 +11,7 @@ * `data` object is built field-by-field (no mass assignment). */ import { NextRequest } from "next/server"; +import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { @@ -210,7 +211,9 @@ async function commitOcrRows(request: NextRequest) { // A lab panel just landed — resolve any "annual blood panel" reminders now // rather than waiting on the cron. Fire-and-forget. if (inserted.length > 0) { - void enqueueReminderSatisfy(user.id).catch(() => {}); + fireAndForget(enqueueReminderSatisfy(user.id), { + action: "reminder.satisfy.enqueue", + }); } return apiSuccess({ inserted, skipped }); diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index 0d2ea94c7..1bab6a5fb 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -1,4 +1,5 @@ import { NextRequest } from "next/server"; +import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { @@ -271,7 +272,9 @@ async function postLabResult(request: NextRequest) { // v1.18.1 (D2) — eventful Lab↔Vorsorge satisfaction. A lab panel just // landed; resolve the user's free-text "annual blood panel" reminders // now rather than waiting on the 15-min cron. Fire-and-forget. - void enqueueReminderSatisfy(user.id).catch(() => {}); + fireAndForget(enqueueReminderSatisfy(user.id), { + action: "reminder.satisfy.enqueue", + }); return apiSuccess(serialiseLabResult(created, biomarker), 201); } diff --git a/src/app/api/measurements/__tests__/group-by-day.test.ts b/src/app/api/measurements/__tests__/group-by-day.test.ts index 3d193d221..d22a10677 100644 --- a/src/app/api/measurements/__tests__/group-by-day.test.ts +++ b/src/app/api/measurements/__tests__/group-by-day.test.ts @@ -27,6 +27,11 @@ vi.mock("@/lib/db", () => ({ measurementRollup: { findMany: vi.fn(), }, + // v1.29.6 — the groupBy=day branch now runs its raw rows through + // `pickCanonicalSourceRows`, which loads the user's source-priority + // blob via `loadUserSourcePriority` (prisma.user.findUnique). `null` + // resolves to the default ladders. + user: { findUnique: vi.fn() }, // v1.4.43 W6 — validation-failed paths write an audit breadcrumb. auditLog: { create: vi.fn().mockResolvedValue({}) }, $queryRaw: vi.fn(), @@ -75,6 +80,9 @@ beforeEach(() => { // write; the route swallows rejections so the test only needs a // resolved mock to keep the catch-block silent. vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + // v1.29.6 — null source-priority blob → default rank ladders for the + // groupBy=day canonical-source collapse. + vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never); }); describe("GET /api/measurements — groupBy=day (W7c collapsed list)", () => { @@ -165,6 +173,80 @@ describe("GET /api/measurements — groupBy=day (W7c collapsed list)", () => { expect(day15.measuredAt).toContain("2026-05-15T10:00:00"); }); + // v1.29.6 — the raw-row scan must read the MOST RECENT window before + // bucketing, mirroring the workouts-list ordering fix (05f80ad37): the + // canonical/day-bucket picker doesn't reorder its input, but the + // underlying `orderBy: asc` + `take: limit` sliced the OLDEST page of + // rows before the day-bucketing loop ran — on a long history the most + // recent days never appeared at all. + it("scans the raw rows newest-first before bucketing", async () => { + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + + await GET(getRequest("type=ACTIVITY_STEPS&groupBy=day")); + + const call = vi.mocked(prisma.measurement.findMany).mock.calls[0]?.[0] as { + orderBy?: { measuredAt?: "asc" | "desc" }; + }; + expect(call.orderBy).toEqual({ measuredAt: "desc" }); + }); + + // v1.29.6 — a day with both an Apple Health AND a Withings write for the + // same cumulative metric must collapse to the ladder-canonical source + // before summing, exactly like the dashboard rollup tile + // (`collapseRollupRowsBySource`) does. Without the collapse, 9000 + // Apple-Health steps + 8800 Withings steps summed to a fabricated 17800 — + // a number the tile never showed. + it("collapses cross-source rows to the ladder-canonical source before summing", async () => { + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { + id: "m-apple", + type: "ACTIVITY_STEPS", + value: 9000, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-05-15T08:00:00.000Z"), + notes: null, + deviceType: null, + }, + { + id: "m-withings", + type: "ACTIVITY_STEPS", + value: 8800, + unit: "steps", + source: "WITHINGS", + measuredAt: new Date("2026-05-15T09:00:00.000Z"), + notes: null, + deviceType: null, + }, + ] as never); + + const res = await GET( + getRequest( + "type=ACTIVITY_STEPS&groupBy=day&from=2026-05-15T00:00:00Z&to=2026-05-16T00:00:00Z", + ), + ); + expect(res.status).toBe(200); + const json = (await res.json()) as { + data: { + measurements: Array<{ + dayKey: string; + value: number; + source: string; + sampleCount: number; + }>; + meta: { droppedDuplicates?: number }; + }; + }; + expect(json.data.measurements.length).toBe(1); + const [day] = json.data.measurements; + // APPLE_HEALTH beats WITHINGS on the default `steps` ladder — the + // Withings row drops out of the sum entirely rather than adding on top. + expect(day.value).toBe(9000); + expect(day.source).toBe("APPLE_HEALTH"); + expect(day.sampleCount).toBe(1); + expect(json.data.meta.droppedDuplicates).toBe(1); + }); + it("falls back to the legacy findMany path when groupBy=day is set on a non-cumulative type", async () => { vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 24e4e9131..0a49e997f 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -34,6 +34,7 @@ * contract. */ import { NextRequest } from "next/server"; +import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { z } from "zod/v4"; import { prisma } from "@/lib/db"; @@ -682,7 +683,9 @@ async function postBatch(request: NextRequest): Promise { // v1.18.1 — eventful Vorsorge satisfaction. A matching reading just // landed; resolve the user's reminders now rather than waiting on the // 15-min cron. Fire-and-forget — the cron reconciles on enqueue miss. - void enqueueReminderSatisfy(user.id).catch(() => {}); + fireAndForget(enqueueReminderSatisfy(user.id), { + action: "reminder.satisfy.enqueue", + }); try { await enqueuePrDetection(user.id, { silent }); await auditLog("personal_records.detection_enqueued", { diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 50ec6ebd8..6aaf88a23 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -1,6 +1,7 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, @@ -21,6 +22,8 @@ import { type AggregateGrain, } from "@/lib/measurements/range-aggregation"; import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping"; +import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum"; +import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority"; import { canonicalDailyTimestamp, dayKeyForUserTz, @@ -214,9 +217,17 @@ export const GET = apiHandler(async (request: NextRequest) => { user.timezone && user.timezone.length > 0 ? user.timezone : "Europe/Berlin"; + // v1.29.6 — DESC + take so a long history returns the MOST RECENT + // window of raw samples for bucketing. The previous `orderBy: asc` + // + `take: limit` sliced the OLDEST page of rows before the + // day-bucketing loop ran below — on an account with more than + // `limit` cumulative samples in its history, the buckets were built + // entirely from year-old rows and the recent days silently never + // appeared. Mirrors the workouts-list ordering fix (05f80ad37): + // re-establish "most recent" as the page the caller actually gets. const rows = await prisma.measurement.findMany({ where, - orderBy: { measuredAt: "asc" }, + orderBy: { measuredAt: "desc" }, // Per-sample row scan within the requested window — bounded by // the existing `limit` cap (default 100, max 5000) so a wide // multi-year window can still walk the whole range when the @@ -230,9 +241,34 @@ export const GET = apiHandler(async (request: NextRequest) => { source: true, measuredAt: true, notes: true, + deviceType: true, }, }); + // v1.29.6 — collapse overlapping sources to the ladder-canonical + // reading per day BEFORE summing. Without this, a day with both an + // Apple Health AND a Withings write for the same cumulative metric + // (e.g. steps) summed BOTH sources into one inflated total — a day + // the dashboard tile (which reads through the source-collapsed + // rollup, `collapseRollupRowsBySource` in + // `src/lib/rollups/measurement-read.ts`) showed correctly. Uses the + // same `metricKeyForType` ladder lookup the rollup collapse uses, so + // this list and the tile agree on which source wins. A type with no + // ladder (e.g. TIME_IN_DAYLIGHT, FALL_COUNT) has nothing to collapse + // against — every row passes through unchanged, matching the + // pass-through fallback used at the other `pickCanonicalSourceRows` + // call sites. + const metricKey = metricKeyForType(type as MeasurementType); + const priorityJson = await loadUserSourcePriority(user.id); + const canonicalRows = metricKey + ? pickCanonicalSourceRows( + rows.map((r) => ({ ...r, type: r.type as MeasurementType })), + metricKey, + priorityJson, + (d) => dayKeyForUserTz(d, tz), + ).canonicalRows + : rows; + // Group by the user's calendar day, sum values, keep a representative // unit/source from the bucket's first row so the rendered row carries // a sensible label even when the bucket mixes sources. @@ -245,7 +281,7 @@ export const GET = apiHandler(async (request: NextRequest) => { source: string; }; const buckets = new Map(); - for (const row of rows) { + for (const row of canonicalRows) { const key = dayKeyForUserTz(row.measuredAt, tz); const slot = buckets.get(key); if (!slot) { @@ -293,6 +329,7 @@ export const GET = apiHandler(async (request: NextRequest) => { type, groupBy: "day", rowsScanned: rows.length, + droppedDuplicates: rows.length - canonicalRows.length, }, }); return apiSuccess({ @@ -302,6 +339,7 @@ export const GET = apiHandler(async (request: NextRequest) => { limit, offset: 0, groupBy: "day", + droppedDuplicates: rows.length - canonicalRows.length, }, }); } @@ -783,7 +821,9 @@ async function postMeasurement(request: NextRequest) { // v1.18.1 — eventful Vorsorge satisfaction. Resolve the user's // reminders against the just-landed readings now. Fire-and-forget. - void enqueueReminderSatisfy(user.id).catch(() => {}); + fireAndForget(enqueueReminderSatisfy(user.id), { + action: "reminder.satisfy.enqueue", + }); // v1.18.6 — absolute clinical safety-floor check on the just-written // readings (confirm-gated, module-gated, never diagnoses). The combined @@ -791,19 +831,22 @@ async function postMeasurement(request: NextRequest) { // The transient per-entry `symptomsPresent` flag (ORed across the batch) // lifts a confirmed breach to the symptom-coupled emergency copy. // Fire-and-forget — a notification failure never fails the write. - void runSafetyFloorCheck({ - userId: user.id, - written: results.map((r) => ({ - type: r.type, - value: r.value, - measuredAt: r.measuredAt, - glucoseContext: r.glucoseContext, - })), - symptomsPresent: parsed.data.measurements.some( - (m) => m.symptomsPresent === true, - ), - timezone: user.timezone ?? undefined, - }).catch(() => {}); + fireAndForget( + runSafetyFloorCheck({ + userId: user.id, + written: results.map((r) => ({ + type: r.type, + value: r.value, + measuredAt: r.measuredAt, + glucoseContext: r.glucoseContext, + })), + symptomsPresent: parsed.data.measurements.some( + (m) => m.symptomsPresent === true, + ), + timezone: user.timezone ?? undefined, + }), + { action: "measurement.safety_floor.check" }, + ); // v1.5.0 — refresh the persistent rollup table for every distinct // (type, day) the batch touched so the next analytics / coach read @@ -930,7 +973,9 @@ async function postMeasurement(request: NextRequest) { // v1.18.1 — eventful Vorsorge satisfaction. Resolve the user's reminders // against the just-landed reading now. Fire-and-forget. - void enqueueReminderSatisfy(user.id).catch(() => {}); + fireAndForget(enqueueReminderSatisfy(user.id), { + action: "reminder.satisfy.enqueue", + }); // v1.18.6 — absolute clinical safety-floor check (confirm-gated, // module-gated, never diagnoses). A single-entry POST carries only one arm @@ -938,19 +983,22 @@ async function postMeasurement(request: NextRequest) { // batch path; a lone glucose reading is evaluated here. The transient // `symptomsPresent` flag lifts a confirmed breach to the emergency copy. // Fire-and-forget — a notification failure never fails the write. - void runSafetyFloorCheck({ - userId: user.id, - written: [ - { - type: measurement.type, - value: measurement.value, - measuredAt: measurement.measuredAt, - glucoseContext: measurement.glucoseContext, - }, - ], - symptomsPresent, - timezone: user.timezone ?? undefined, - }).catch(() => {}); + fireAndForget( + runSafetyFloorCheck({ + userId: user.id, + written: [ + { + type: measurement.type, + value: measurement.value, + measuredAt: measurement.measuredAt, + glucoseContext: measurement.glucoseContext, + }, + ], + symptomsPresent, + timezone: user.timezone ?? undefined, + }), + { action: "measurement.safety_floor.check" }, + ); // v1.5.0 — refresh the persistent rollup row for the affected // (type, day) tuple. Runs inline so the next read of the diff --git a/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts index e5e3a00a2..3154ea718 100644 --- a/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts @@ -3,6 +3,10 @@ * /api/medications/[id]/intake/import. Preserves the * `medication.intake.import.invalid_format` errorCode meta so the CSV * import client UI can branch on the prefix semantics. + * + * v1.29 perf fix — the per-row `findUnique` duplicate probe was replaced by + * one indexed `findMany` existence read over every row's idempotencyKey + * before the write loop. `create` + `consumeForIntake` stay per-row. */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; @@ -13,7 +17,7 @@ vi.mock("@/lib/db", () => ({ findUnique: vi.fn(), }, medicationIntakeEvent: { - findUnique: vi.fn(), + findMany: vi.fn(), create: vi.fn(), }, auditLog: { create: vi.fn() }, @@ -75,6 +79,12 @@ function postReq(body: unknown): NextRequest { const ROUTE_CTX = { params: Promise.resolve({ id: "m1" }) }; +/** Mirrors the route's own dedup-key derivation for a zaehler-less row. */ +function keyFor(medId: string, datum: string, uhrzeit: string): string { + const takenAt = new Date(`${datum}T${uhrzeit}`); + return `import-${medId}-${takenAt.getTime()}`; +} + beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); @@ -172,10 +182,9 @@ describe("POST /api/medications/[id]/intake/import — 422 multi-issue (v1.4.43 describe("POST /api/medications/[id]/intake/import — inventory consumption", () => { beforeEach(() => { - // Fresh imports: no pre-existing dedup row, and create echoes an id. - vi.mocked(prisma.medicationIntakeEvent.findUnique).mockResolvedValue( - null as never, - ); + // Fresh imports: the batched existence read finds no prior rows, and + // create echoes an id. + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([]); let n = 0; vi.mocked(prisma.medicationIntakeEvent.create).mockImplementation( (async () => ({ id: `evt-${++n}`, takenAt: new Date() })) as never, @@ -200,16 +209,24 @@ describe("POST /api/medications/[id]/intake/import — inventory consumption", ( expect(call?.userId).toBe("user-1"); expect(call?.eventId).toBe("evt-1"); + // The three-row import reads existence in ONE indexed query, not one + // findUnique probe per row. + expect(prisma.medicationIntakeEvent.findMany).toHaveBeenCalledTimes(1); + const findManyArg = vi.mocked(prisma.medicationIntakeEvent.findMany).mock + .calls[0]?.[0] as { where: { idempotencyKey: { in: string[] } } }; + expect(findManyArg.where.idempotencyKey.in).toHaveLength(3); + // (#22) — the whole import queues exactly ONE cross-device sync // fan-out, not one per imported row. expect(queueMedicationIntakeSync).toHaveBeenCalledTimes(1); }); it("does not consume for duplicate (already-imported) rows", async () => { - // First entry is a duplicate (existing dedup row), second is fresh. - vi.mocked(prisma.medicationIntakeEvent.findUnique) - .mockResolvedValueOnce({ id: "existing" } as never) - .mockResolvedValueOnce(null as never); + // First entry's key is already present — the batched existence read + // reports it, the second entry's key is absent (fresh). + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + { idempotencyKey: keyFor("m1", "2026-01-01", "07:00:00") }, + ] as never); const res = await POST( postReq([ { datum: "2026-01-01", uhrzeit: "07:00:00" }, diff --git a/src/app/api/medications/[id]/intake/import/route.ts b/src/app/api/medications/[id]/intake/import/route.ts index 75fa97360..6059f98ff 100644 --- a/src/app/api/medications/[id]/intake/import/route.ts +++ b/src/app/api/medications/[id]/intake/import/route.ts @@ -99,6 +99,20 @@ export const POST = apiHandler( // so the rollup pass fires once per day rather than once per row. const touchedDays = new Set(); + // Perf — a CSV import can carry up to 1000 rows; the pre-fix loop ran a + // `findUnique` duplicate probe before every single `create`, up to 2000 + // sequential round trips. Resolve `takenAt` + the dedup key for every + // row first, then read every key's existence in ONE indexed query + // (`idempotencyKey` carries its own unique index) before the write + // loop. `create` + `consumeForIntake` (inventory decrement) stay + // per-row — each has a real side effect keyed off the freshly-created + // event's id, so they aren't safely batchable without a deeper change + // to the inventory-consumption path. + interface PreparedIntake { + takenAt: Date; + idempotencyKey: string; + } + const prepared: PreparedIntake[] = []; for (const entry of entries) { // Parse as local datetime first; if invalid, fall back to CET offset. let takenAt = new Date(`${entry.datum}T${entry.uhrzeit}`); @@ -115,10 +129,26 @@ export const POST = apiHandler( ? `import-${id}-${String(entry.zaehler)}` : `import-${id}-${takenAt.getTime()}`; - const existing = await prisma.medicationIntakeEvent.findUnique({ - where: { idempotencyKey }, - }); - if (existing) { + prepared.push({ takenAt, idempotencyKey }); + } + + const existingRows = + prepared.length > 0 + ? await prisma.medicationIntakeEvent.findMany({ + where: { + idempotencyKey: { in: prepared.map((p) => p.idempotencyKey) }, + }, + select: { idempotencyKey: true }, + }) + : []; + const existingKeys = new Set( + existingRows + .map((r) => r.idempotencyKey) + .filter((k): k is string => k !== null), + ); + + for (const { takenAt, idempotencyKey } of prepared) { + if (existingKeys.has(idempotencyKey)) { skippedDuplicates++; continue; } diff --git a/src/app/api/mood-entries/bulk/__tests__/route.test.ts b/src/app/api/mood-entries/bulk/__tests__/route.test.ts index 8742b60d8..00a815a76 100644 --- a/src/app/api/mood-entries/bulk/__tests__/route.test.ts +++ b/src/app/api/mood-entries/bulk/__tests__/route.test.ts @@ -1,6 +1,14 @@ /** * v1.4.43 W6 — multi-issue 422 envelope on POST /api/mood-entries/bulk. * Preserves the `mood.bulk.invalid` errorCode meta passthrough. + * + * v1.29 perf fix — the per-entry `findUnique` probe was replaced by one + * batched `findMany` existence read (a single indexed OR query); every + * entry still resolves its own "inserted" / "duplicate" state through the + * exact key its dedup contract uses (externalId when carried, else the + * legacy date+moodLoggedAt key). The per-entry `upsert` + tombstone check + + * tag links + reverse push are unchanged. Tests below drive `findMany` + * instead of `findUnique`. */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; @@ -8,7 +16,7 @@ import { NextRequest } from "next/server"; vi.mock("@/lib/db", () => ({ prisma: { moodEntry: { - findUnique: vi.fn(), + findMany: vi.fn(), upsert: vi.fn(), }, auditLog: { create: vi.fn() }, @@ -84,6 +92,29 @@ function postReq(body: unknown): NextRequest { }); } +/** + * Shape the bulk pre-read's `findMany` select always returns. Every mocked + * "existing row" needs every field the route registers into its two lookup + * maps, even when a test only cares about one of them. + */ +function existingRow(overrides: { + id: string; + deletedAt?: Date | null; + source?: string; + externalId?: string | null; + date?: string; + moodLoggedAt?: Date; +}) { + return { + deletedAt: null, + source: "MANUAL", + externalId: null, + date: "2026-05-16", + moodLoggedAt: new Date("2026-05-16T08:00:00.000Z"), + ...overrides, + }; +} + beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); @@ -190,7 +221,7 @@ describe("POST /api/mood-entries/bulk — 422 multi-issue (v1.4.43 W6)", () => { describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => { beforeEach(() => { - vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue(null as never); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([]); vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({ id: "entry-1", } as never); @@ -272,11 +303,7 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => { ); }); - it("dedups on (userId, source, externalId) when an entry carries externalId", async () => { - vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue(null as never); - vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({ - id: "entry-ext", - } as never); + it("reads existence once via a batched findMany, keyed on (source, externalId) when the entry carries one", async () => { const res = await POST( postReq({ entries: [ @@ -293,11 +320,16 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => { data: { inserted: number; entries: Array<{ externalId?: string }> }; }; expect(json.data.inserted).toBe(1); - // The probe + upsert both key on the externalId compound, not the - // legacy wall-clock tuple. - const probeWhere = vi.mocked(prisma.moodEntry.findUnique).mock.calls[0]?.[0] - ?.where as Record; - expect(probeWhere).toHaveProperty("userId_source_externalId"); + expect(prisma.moodEntry.findMany).toHaveBeenCalledTimes(1); + const findManyArg = vi.mocked(prisma.moodEntry.findMany).mock + .calls[0]?.[0] as { + where: { userId: string; OR: Array> }; + }; + expect(findManyArg.where.OR).toContainEqual({ + source: "MANUAL", + externalId: "ios-uuid-1", + }); + // The per-entry upsert still keys on the compound unique. const upsertArg = vi.mocked(prisma.moodEntry.upsert).mock.calls[0]?.[0] as { where: Record; create: { externalId: string | null; source: string }; @@ -308,11 +340,7 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => { expect(json.data.entries[0].externalId).toBe("ios-uuid-1"); }); - it("uses the legacy wall-clock key and omits externalId when none is sent", async () => { - vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue(null as never); - vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({ - id: "entry-legacy", - } as never); + it("keys the batched existence read on (date, moodLoggedAt) and omits externalId when none is sent", async () => { const res = await POST( postReq({ entries: [{ mood: "OKAY", moodLoggedAt: "2026-05-16T08:00:00.000Z" }], @@ -322,18 +350,31 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => { const json = (await res.json()) as { data: { entries: Array<{ externalId?: string }> }; }; - const probeWhere = vi.mocked(prisma.moodEntry.findUnique).mock.calls[0]?.[0] - ?.where as Record; - expect(probeWhere).toHaveProperty("userId_date_moodLoggedAt"); + const findManyArg = vi.mocked(prisma.moodEntry.findMany).mock + .calls[0]?.[0] as { + where: { OR: Array> }; + }; + expect(findManyArg.where.OR).toContainEqual({ + date: "2026-05-16", + moodLoggedAt: new Date("2026-05-16T08:00:00.000Z"), + }); + const upsertArg = vi.mocked(prisma.moodEntry.upsert).mock.calls[0]?.[0] as { + where: Record; + }; + expect(upsertArg.where).toHaveProperty("userId_date_moodLoggedAt"); expect(json.data.entries[0].externalId).toBeUndefined(); }); it("reports a re-posted externalId entry as a duplicate (idempotent)", async () => { - // Probe finds the row → the response classifies it duplicate, not - // inserted, and the upsert update branch refreshes it in place. - vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue({ - id: "entry-ext", - } as never); + // The batched pre-read finds the row → the response classifies it + // duplicate, not inserted, and the upsert update branch refreshes it. + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([ + existingRow({ + id: "entry-ext", + source: "MANUAL", + externalId: "ios-uuid-1", + }), + ] as never); vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({ id: "entry-ext", } as never); @@ -416,13 +457,17 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => { describe("POST /api/mood-entries/bulk — tombstone suppression", () => { it("keeps a tombstoned match deleted: true no-op reported as duplicate", async () => { - // A soft-deleted row under the probe key: the sync feed already + // A soft-deleted row under the batched pre-read: the sync feed already // surfaced the deletion to paired clients, so a stale offline re-post // must NOT resurrect it — and must not churn the hidden row either. - vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue({ - id: "entry-tomb", - deletedAt: new Date("2026-05-01T00:00:00.000Z"), - } as never); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([ + existingRow({ + id: "entry-tomb", + deletedAt: new Date("2026-05-01T00:00:00.000Z"), + source: "MANUAL", + externalId: "ios-uuid-tomb", + }), + ] as never); const res = await POST( postReq({ entries: [ @@ -455,10 +500,14 @@ describe("POST /api/mood-entries/bulk — tombstone suppression", () => { }); it("still refreshes a LIVE match in place (duplicate + upsert runs)", async () => { - vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue({ - id: "entry-live", - deletedAt: null, - } as never); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([ + existingRow({ + id: "entry-live", + deletedAt: null, + source: "MANUAL", + externalId: "ios-uuid-live", + }), + ] as never); vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({ id: "entry-live", } as never); diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index 7afe11bd4..95fde2461 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -189,6 +189,57 @@ async function postBulk(request: NextRequest): Promise { let duplicates = 0; const skipped: Array<{ index: number; reason: string }> = []; + // Perf — a single indexed existence read replaces the per-entry + // `findUnique` probe (up to 500 sequential round trips pre-fix). Every + // entry still resolves through exactly the key its own dedup contract + // uses (externalId when carried, else the legacy date+moodLoggedAt key), + // so lookups below are byte-identical to the per-entry probe — only the + // read is now batched. The per-entry `upsert` + tombstone check + tag + // links + reverse push all stay unchanged; batching those further would + // risk the correctness-sensitive per-row side effects for a write path + // that is usually small (a normal sync posts the current + previous + // local day, not a full historical backfill). + interface ExistingMoodRow { + id: string; + deletedAt: Date | null; + } + const orClauses: Array< + | { source: string; externalId: string } + | { date: string; moodLoggedAt: Date } + > = entries.map((entry) => + entry.externalId + ? { source: entry.source, externalId: entry.externalId } + : { + date: moodDateKey(entry.moodLoggedAt, tz), + moodLoggedAt: entry.moodLoggedAt, + }, + ); + const existingRows = + orClauses.length > 0 + ? await prisma.moodEntry.findMany({ + where: { userId: user.id, OR: orClauses }, + select: { + id: true, + deletedAt: true, + source: true, + externalId: true, + date: true, + moodLoggedAt: true, + }, + }) + : []; + const existingByExternalKey = new Map(); + const existingByDateKey = new Map(); + for (const row of existingRows) { + if (row.externalId !== null) { + existingByExternalKey.set(`${row.source}::${row.externalId}`, row); + } + existingByDateKey.set( + `${row.date}::${row.moodLoggedAt.toISOString()}`, + row, + ); + } + for (let i = 0; i < entries.length; i++) { const entry = entries[i]; const date = moodDateKey(entry.moodLoggedAt, tz); @@ -220,15 +271,15 @@ async function postBulk(request: NextRequest): Promise { }, }; - // Probe-then-upsert so the response reliably distinguishes - // "inserted" from "duplicate". Two round-trips per entry is - // acceptable given the 500-entry cap; a more cache-friendly - // shape (batched probe) is a v1.4.31 optimisation if the cap - // grows. - const existing = await prisma.moodEntry.findUnique({ - where: probeWhere, - select: { id: true, deletedAt: true }, - }); + // The response reliably distinguishes "inserted" from "duplicate" + // via the batched pre-read above (keyed exactly like `probeWhere`), + // so the per-entry probe here is now a Map lookup, not a round trip. + const existing = + (entry.externalId + ? existingByExternalKey.get(`${resolvedSource}::${entry.externalId}`) + : existingByDateKey.get( + `${date}::${entry.moodLoggedAt.toISOString()}`, + )) ?? null; // Tombstone suppression: a soft-deleted match stays deleted. The // user-facing DELETE route flips `deletedAt` so the `/api/sync/changes` diff --git a/src/app/api/nutrients/batch/__tests__/route.test.ts b/src/app/api/nutrients/batch/__tests__/route.test.ts index 5afdb0047..1faac5028 100644 --- a/src/app/api/nutrients/batch/__tests__/route.test.ts +++ b/src/app/api/nutrients/batch/__tests__/route.test.ts @@ -3,8 +3,15 @@ * * Pins the ingest posture: module gate first (403 `module.disabled`), * per-entry skip guards (unit mismatch / plausibility / day key), the - * insert-vs-update statuses off the composite-PK probe, and the batch - * envelope errors (`too_large`, multi-issue `invalid`). + * insert-vs-update statuses off a single bulk existence probe, and the + * batch envelope errors (`too_large`, multi-issue `invalid`). + * + * v1.29 perf fix — the probe-then-upsert-per-entry pattern (up to 1000 + * sequential round trips) was replaced by one indexed `findMany` existence + * read + a bulk `createMany` for new pairs + a `$transaction` of per-row + * `update` calls for existing pairs. `$transaction` is mocked to actually + * run the array of promises it's handed so the update path executes for + * real in these tests. */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; @@ -12,9 +19,11 @@ import { NextRequest } from "next/server"; vi.mock("@/lib/db", () => ({ prisma: { nutrientIntakeDay: { - findUnique: vi.fn(), - upsert: vi.fn(), + findMany: vi.fn(), + createMany: vi.fn(), + update: vi.fn(), }, + $transaction: vi.fn(), }, })); @@ -93,8 +102,19 @@ beforeEach(() => { vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); vi.mocked(auditLog).mockResolvedValue(undefined as never); - vi.mocked(prisma.nutrientIntakeDay.findUnique).mockResolvedValue(null); - vi.mocked(prisma.nutrientIntakeDay.upsert).mockResolvedValue({} as never); + // No existing (day, nutrient) pairs by default — every valid entry + // takes the insert path unless a test overrides this. + vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([]); + vi.mocked(prisma.nutrientIntakeDay.createMany).mockImplementation( + (async (args: { data: unknown[] }) => ({ + count: args.data.length, + })) as never, + ); + vi.mocked(prisma.nutrientIntakeDay.update).mockResolvedValue({} as never); + // Real Prisma resolves every promise in the `$transaction([...])` array; + // the mock does the same so the update path actually runs in these tests. + vi.mocked(prisma.$transaction).mockImplementation((async (ops: unknown[]) => + Promise.all(ops)) as never); }); describe("POST /api/nutrients/batch — module gate", () => { @@ -122,7 +142,7 @@ describe("POST /api/nutrients/batch — module gate", () => { expect(body.data).toBeNull(); expect(body.meta?.errorCode).toBe("module.disabled"); expect(body.meta?.module).toBe("nutrients"); - expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled(); + expect(prisma.nutrientIntakeDay.createMany).not.toHaveBeenCalled(); }); }); @@ -190,7 +210,8 @@ describe("POST /api/nutrients/batch — per-entry skip guards", () => { reason: "unit_mismatch", }); expect(body.data.skipped).toEqual([{ index: 0, reason: "unit_mismatch" }]); - expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled(); + expect(prisma.nutrientIntakeDay.findMany).not.toHaveBeenCalled(); + expect(prisma.nutrientIntakeDay.createMany).not.toHaveBeenCalled(); }); it("skips a value above the plausibility cap", async () => { @@ -205,7 +226,7 @@ describe("POST /api/nutrients/batch — per-entry skip guards", () => { const body = (await res.json()) as BatchResponse; expect(body.data.entries[0].status).toBe("skipped"); expect(body.data.entries[0].reason).toBe("value_out_of_range"); - expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled(); + expect(prisma.nutrientIntakeDay.createMany).not.toHaveBeenCalled(); }); it("skips an impossible calendar day and a far-future day as day_invalid", async () => { @@ -224,7 +245,7 @@ describe("POST /api/nutrients/batch — per-entry skip guards", () => { "day_invalid", "day_invalid", ]); - expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled(); + expect(prisma.nutrientIntakeDay.createMany).not.toHaveBeenCalled(); }); it("a skipped entry never fails the batch — siblings still land", async () => { @@ -240,12 +261,13 @@ describe("POST /api/nutrients/batch — per-entry skip guards", () => { expect(body.data.processed).toBe(2); expect(body.data.inserted).toBe(1); expect(body.data.skipped).toHaveLength(1); - expect(prisma.nutrientIntakeDay.upsert).toHaveBeenCalledTimes(1); + // One bulk createMany call carries the single valid entry. + expect(prisma.nutrientIntakeDay.createMany).toHaveBeenCalledTimes(1); }); }); -describe("POST /api/nutrients/batch — upsert semantics", () => { - it("reports inserted for a fresh (day, nutrient) and writes the catalog's canonical unit", async () => { +describe("POST /api/nutrients/batch — write semantics", () => { + it("reports inserted for a fresh (day, nutrient), reads existence once, and writes the catalog's canonical unit", async () => { const day = recentDay(); const res = await POST( postReq({ @@ -265,42 +287,43 @@ describe("POST /api/nutrients/batch — upsert semantics", () => { expect(body.data.updated).toBe(0); expect(body.data.entries[0].status).toBe("inserted"); - expect(prisma.nutrientIntakeDay.upsert).toHaveBeenCalledWith({ + // Single indexed existence read, not a per-entry probe. + expect(prisma.nutrientIntakeDay.findMany).toHaveBeenCalledTimes(1); + expect(prisma.nutrientIntakeDay.findMany).toHaveBeenCalledWith({ where: { - userId_day_nutrient_source: { + userId: "user-1", + source: "APPLE_HEALTH", + OR: [{ day, nutrient: "vitamin_d" }], + }, + select: { day: true, nutrient: true }, + }); + + expect(prisma.nutrientIntakeDay.createMany).toHaveBeenCalledWith({ + data: [ + { userId: "user-1", day, nutrient: "vitamin_d", + amount: 22.5, + unit: "ug", source: "APPLE_HEALTH", + externalSourceVersion: "yazio-9.9", }, - }, - create: { - userId: "user-1", - day, - nutrient: "vitamin_d", - amount: 22.5, - unit: "ug", - source: "APPLE_HEALTH", - externalSourceVersion: "yazio-9.9", - }, - update: { - amount: 22.5, - unit: "ug", - externalSourceVersion: "yazio-9.9", - }, + ], + skipDuplicates: true, }); + expect(prisma.nutrientIntakeDay.update).not.toHaveBeenCalled(); }); it("reports updated (never duplicate) when the composite key already exists", async () => { - vi.mocked(prisma.nutrientIntakeDay.findUnique).mockResolvedValue({ - userId: "user-1", - } as never); + const day = recentDay(); + vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([ + { day, nutrient: "caffeine" }, + ] as never); const res = await POST( postReq({ - entries: [ - { day: recentDay(), nutrient: "caffeine", unit: "mg", amount: 310 }, - ], + entries: [{ day, nutrient: "caffeine", unit: "mg", amount: 310 }], }), ); const body = (await res.json()) as BatchResponse; @@ -310,6 +333,20 @@ describe("POST /api/nutrients/batch — upsert semantics", () => { expect( body.data.entries.some((e) => e.status === ("duplicate" as string)), ).toBe(false); + + expect(prisma.nutrientIntakeDay.createMany).not.toHaveBeenCalled(); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(prisma.nutrientIntakeDay.update).toHaveBeenCalledWith({ + where: { + userId_day_nutrient_source: { + userId: "user-1", + day, + nutrient: "caffeine", + source: "APPLE_HEALTH", + }, + }, + data: { amount: 310, unit: "mg", externalSourceVersion: null }, + }); }); it("writes the audit-ledger breadcrumb only when at least one row landed", async () => { @@ -335,19 +372,24 @@ describe("POST /api/nutrients/batch — upsert semantics", () => { ); }); - it("a thrown upsert marks only that entry skipped upsert_failed without leaking the raw error", async () => { + it("a failed bulk insert marks only the insert group skipped, without touching the update group", async () => { // The exception message must never reach the client — the per-entry - // `reason` is a closed set. A raw-message echo was a v1.28.48 fix. + // `reason` is a closed set. const spy = vi.spyOn(console, "error").mockImplementation(() => {}); - vi.mocked(prisma.nutrientIntakeDay.upsert) - .mockRejectedValueOnce(new Error("boom: sensitive db detail")) - .mockResolvedValueOnce({} as never); + const day = recentDay(); + // `zinc` is new (insert path); `iron` already exists (update path). + vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([ + { day, nutrient: "iron" }, + ] as never); + vi.mocked(prisma.nutrientIntakeDay.createMany).mockRejectedValue( + new Error("boom: sensitive db detail"), + ); const res = await POST( postReq({ entries: [ - { day: recentDay(), nutrient: "zinc", unit: "mg", amount: 10 }, - { day: recentDay(), nutrient: "iron", unit: "mg", amount: 12 }, + { day, nutrient: "zinc", unit: "mg", amount: 10 }, + { day, nutrient: "iron", unit: "mg", amount: 12 }, ], }), ); @@ -358,8 +400,29 @@ describe("POST /api/nutrients/batch — upsert semantics", () => { // The raw exception text is logged server-side only, never echoed. expect(JSON.stringify(body)).not.toContain("sensitive db detail"); expect(spy).toHaveBeenCalled(); - expect(body.data.entries[1].status).toBe("inserted"); - expect(body.data.inserted).toBe(1); + // The independent update group still lands. + expect(body.data.entries[1].status).toBe("updated"); + expect(body.data.updated).toBe(1); spy.mockRestore(); }); + + it("a race that drops one createMany row still counts consistently and is observable", async () => { + vi.mocked(prisma.nutrientIntakeDay.createMany).mockResolvedValue({ + count: 1, + } as never); + + const res = await POST( + postReq({ + entries: [ + { day: recentDay(), nutrient: "zinc", unit: "mg", amount: 10 }, + { day: recentDay(2), nutrient: "iron", unit: "mg", amount: 12 }, + ], + }), + ); + const body = (await res.json()) as BatchResponse; + // Both entries are still labelled inserted (count-only trade-off, + // documented at the call site) — the response never fails. + expect(body.data.inserted).toBe(1); + expect(res.status).toBe(200); + }); }); diff --git a/src/app/api/nutrients/batch/route.ts b/src/app/api/nutrients/batch/route.ts index 73506e330..44ea0c9a3 100644 --- a/src/app/api/nutrients/batch/route.ts +++ b/src/app/api/nutrients/batch/route.ts @@ -10,10 +10,14 @@ * Upsert key = the composite PK (userId, day, nutrient). Re-post * replaces amount / unit / externalSourceVersion — last-writer-wins, * the day-total contract; per-entry status is `updated` then, never - * `duplicate`. Per-entry failures come back `skipped` with a reason - * (`unit_mismatch` | `value_out_of_range` | `day_invalid` | - * `upsert_failed`), never a batch failure — the measurements-batch - * posture. + * `duplicate`. Validation failures (`unit_mismatch` | `value_out_of_range` + * | `day_invalid`) are always isolated per entry. The DB write itself runs + * as two batched groups — a single indexed existence read splits valid + * entries into a bulk `createMany` (new pairs) and a `$transaction` of + * per-row updates (existing pairs), the measurements-batch shape. A write + * failure marks its WHOLE group `skipped upsert_failed` rather than + * isolating the one entry that would have caused it (unavoidable once the + * write is batched) — the request itself never fails. * * Module gate FIRST: the opt-in `nutrients` module (default off) * refuses ingest with the 403 `module.disabled` envelope, so a phone @@ -130,11 +134,21 @@ async function postBatch(request: NextRequest): Promise { const { entries } = parsed.data; const dayCeiling = maxAcceptableDay(new Date()); - const results: EntryResult[] = []; + const results: EntryResult[] = new Array(entries.length); const skipped: Array<{ index: number; reason: string }> = []; let inserted = 0; let updated = 0; + interface ValidEntry { + index: number; + day: string; + nutrient: (typeof entries)[number]["nutrient"]; + amount: number; + unit: string; + externalSourceVersion: string | null; + } + const validEntries: ValidEntry[] = []; + for (let i = 0; i < entries.length; i++) { const entry = entries[i]; const definition = NUTRIENT_CATALOG[entry.nutrient]; @@ -144,86 +158,158 @@ async function postBatch(request: NextRequest): Promise { // unit skips the entry rather than converting. if (entry.unit !== definition.unit) { skipped.push({ index: i, reason: "unit_mismatch" }); - results.push({ index: i, status: "skipped", reason: "unit_mismatch" }); + results[i] = { index: i, status: "skipped", reason: "unit_mismatch" }; continue; } if (entry.amount > definition.plausibleDailyMax) { skipped.push({ index: i, reason: "value_out_of_range" }); - results.push({ + results[i] = { index: i, status: "skipped", reason: "value_out_of_range", - }); + }; continue; } if (!isRealCalendarDay(entry.day) || entry.day > dayCeiling) { skipped.push({ index: i, reason: "day_invalid" }); - results.push({ index: i, status: "skipped", reason: "day_invalid" }); + results[i] = { index: i, status: "skipped", reason: "day_invalid" }; continue; } - try { - // v1.29 — `source` joined the composite PK (migration 0249) so a - // manual water entry (source=MANUAL, written by - // `POST /api/nutrients/water`) coexists with the Apple-synced day - // total instead of one clobbering the other on the next sync. The - // batch route always owns the APPLE_HEALTH row — byte-identical - // upsert behaviour to pre-0249, just against the wider key. - const key = { - userId_day_nutrient_source: { - userId: user.id, - day: entry.day, - nutrient: entry.nutrient, - source: "APPLE_HEALTH", - }, - }; + validEntries.push({ + index: i, + day: entry.day, + nutrient: entry.nutrient, + amount: entry.amount, + unit: definition.unit, + externalSourceVersion: entry.externalSourceVersion ?? null, + }); + } - // Probe-then-upsert so the response reliably distinguishes - // `inserted` from `updated` (the mood-bulk pattern; two round - // trips per entry is fine under the 500-entry cap). - const existing = await prisma.nutrientIntakeDay.findUnique({ - where: key, - select: { userId: true }, - }); + // v1.29 — `source` joined the composite PK (migration 0249) so a manual + // water entry (source=MANUAL, written by `POST /api/nutrients/water`) + // coexists with the Apple-synced day total instead of one clobbering the + // other on the next sync. The batch route always owns the APPLE_HEALTH + // row. + // + // Perf (measurements/batch shape): ONE indexed existence read replaces the + // per-entry probe-then-upsert (up to 1000 sequential round trips pre-fix). + // Genuinely new (day, nutrient) pairs land via a single chunked + // `createMany`; pairs that already exist update via a batched + // `$transaction` of per-row `update` calls (values differ per row, so a + // single bulk statement isn't available without raw SQL — this still + // collapses the per-entry PROBE, and pipelines the writes through one + // transaction instead of N standalone round trips). + if (validEntries.length > 0) { + const existing = await prisma.nutrientIntakeDay.findMany({ + where: { + userId: user.id, + source: "APPLE_HEALTH", + OR: validEntries.map((e) => ({ day: e.day, nutrient: e.nutrient })), + }, + select: { day: true, nutrient: true }, + }); + const existingKeys = new Set( + existing.map((r) => `${r.day}::${r.nutrient}`), + ); - await prisma.nutrientIntakeDay.upsert({ - where: key, - create: { - userId: user.id, - day: entry.day, - nutrient: entry.nutrient, - amount: entry.amount, - unit: definition.unit, - source: "APPLE_HEALTH", - externalSourceVersion: entry.externalSourceVersion ?? null, - }, - update: { - // Last-writer-wins on the day total: iOS re-posts the current - // and previous local day on every sync as the totals grow. - amount: entry.amount, - unit: definition.unit, - externalSourceVersion: entry.externalSourceVersion ?? null, - }, - }); + const toInsert: ValidEntry[] = []; + const toUpdate: ValidEntry[] = []; + for (const e of validEntries) { + (existingKeys.has(`${e.day}::${e.nutrient}`) ? toUpdate : toInsert).push( + e, + ); + } - if (existing) { - updated += 1; - results.push({ index: i, status: "updated" }); - } else { - inserted += 1; - results.push({ index: i, status: "inserted" }); + if (toInsert.length > 0) { + try { + const result = await prisma.nutrientIntakeDay.createMany({ + data: toInsert.map((e) => ({ + userId: user.id, + day: e.day, + nutrient: e.nutrient, + amount: e.amount, + unit: e.unit, + source: "APPLE_HEALTH" as const, + externalSourceVersion: e.externalSourceVersion, + })), + skipDuplicates: true, + }); + inserted += result.count; + for (const e of toInsert) { + results[e.index] = { index: e.index, status: "inserted" }; + } + // Rare race: a concurrent request claimed one of these keys between + // our existence probe and this createMany, so skipDuplicates + // silently dropped that row's write here — measurements/batch + // accepts the same count-only trade-off for its own createMany race. + // The DB itself stays consistent (the racing writer's row is + // stored); only this response's per-row label may be stale for + // that one entry. + if (result.count < toInsert.length) { + annotate({ + action: { name: "nutrient.batch.insert_race" }, + meta: { attempted: toInsert.length, stored: result.count }, + }); + } + } catch (err: unknown) { + console.error("[nutrient-batch] bulk insert failed", err); + for (const e of toInsert) { + skipped.push({ index: e.index, reason: "upsert_failed" }); + results[e.index] = { + index: e.index, + status: "skipped", + reason: "upsert_failed", + }; + } + } + } + + if (toUpdate.length > 0) { + try { + await prisma.$transaction( + toUpdate.map((e) => + prisma.nutrientIntakeDay.update({ + where: { + userId_day_nutrient_source: { + userId: user.id, + day: e.day, + nutrient: e.nutrient, + source: "APPLE_HEALTH", + }, + }, + data: { + // Last-writer-wins on the day total: iOS re-posts the + // current and previous local day on every sync as the + // totals grow. + amount: e.amount, + unit: e.unit, + externalSourceVersion: e.externalSourceVersion, + }, + }), + ), + ); + updated += toUpdate.length; + for (const e of toUpdate) { + results[e.index] = { index: e.index, status: "updated" }; + } + } catch (err: unknown) { + // The client-facing `reason` is a closed set (see the file header) — + // never echo raw exception text into it. Log the real error + // server-side only (SWC keeps `console.error` in prod) and return + // the fixed `upsert_failed` member. + console.error("[nutrient-batch] bulk update failed", err); + for (const e of toUpdate) { + skipped.push({ index: e.index, reason: "upsert_failed" }); + results[e.index] = { + index: e.index, + status: "skipped", + reason: "upsert_failed", + }; + } } - } catch (err: unknown) { - // The client-facing `reason` is a closed set (see the file header) — - // never echo raw exception text into it. Log the real error - // server-side only (SWC keeps `console.error` in prod) and return the - // fixed `upsert_failed` member. - console.error("[nutrient-batch] upsert failed", err); - const reason = "upsert_failed"; - skipped.push({ index: i, reason }); - results.push({ index: i, status: "skipped", reason }); } } diff --git a/src/app/api/nutrients/daily/__tests__/route.test.ts b/src/app/api/nutrients/daily/__tests__/route.test.ts index e8a7ed9a3..af942a685 100644 --- a/src/app/api/nutrients/daily/__tests__/route.test.ts +++ b/src/app/api/nutrients/daily/__tests__/route.test.ts @@ -118,17 +118,23 @@ describe("GET /api/nutrients/daily", () => { }); it("sums amounts across sources within the same day before bucketing", async () => { + // Relative day-keys — a fixed literal slides out of the window as the + // calendar advances (a `?days=3` window only ever holds today..today-2). + const utcDay = (offset: number) => + new Date(Date.now() - offset * 86_400_000).toISOString().slice(0, 10); + const today = utcDay(0); + const yesterday = utcDay(1); vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([ - { day: "2026-07-16", amount: 900 }, - { day: "2026-07-16", amount: 600 }, - { day: "2026-07-15", amount: 400 }, + { day: today, amount: 900 }, + { day: today, amount: 600 }, + { day: yesterday, amount: 400 }, ] as never); const res = await GET(getReq("?nutrient=water&days=3")); const body = (await res.json()) as DailyResponse; const byDay = new Map(body.data.days.map((d) => [d.day, d.amount])); - expect(byDay.get("2026-07-16")).toBe(1500); - expect(byDay.get("2026-07-15")).toBe(400); + expect(byDay.get(today)).toBe(1500); + expect(byDay.get(yesterday)).toBe(400); }); it("resolves the sex-split reference against the caller's profile sex", async () => { diff --git a/src/app/api/oura/connect/__tests__/route.test.ts b/src/app/api/oura/connect/__tests__/route.test.ts new file mode 100644 index 000000000..65cc3a80c --- /dev/null +++ b/src/app/api/oura/connect/__tests__/route.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { rateLimitMock, getCredsMock, mintMock, authUrlMock } = vi.hoisted( + () => ({ + rateLimitMock: vi.fn(), + getCredsMock: vi.fn(), + mintMock: vi.fn(() => "signed-state"), + authUrlMock: vi.fn(() => "https://cloud.ouraring.com/oauth/authorize?x=1"), + }), +); + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/auth/secure-cookie", () => ({ + shouldEmitSecureCookie: () => true, +})); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: rateLimitMock })); +vi.mock("@/lib/oura/client", () => ({ getAuthorizationUrl: authUrlMock })); +vi.mock("@/lib/oura/credentials", () => ({ + getOuraClientCredentials: getCredsMock, +})); +vi.mock("@/lib/oauth/signed-state", () => ({ + OAUTH_STATE_TTL_MS: 600_000, + mintSignedState: mintMock, + oauthStateCookieName: () => "oura_oauth_state", +})); + +import { GET } from "../route"; + +process.env.NEXT_PUBLIC_APP_URL = "https://app.example"; + +const run = GET as unknown as () => Promise<{ + status: number; + headers: { get(k: string): string | null }; + cookies: { get(k: string): { value: string } | undefined }; +}>; + +beforeEach(() => { + vi.clearAllMocks(); + rateLimitMock.mockResolvedValue({ allowed: true }); + getCredsMock.mockResolvedValue({ clientId: "c", clientSecret: "s" }); +}); + +describe("GET /api/oura/connect", () => { + it("redirects to the consent screen and sets the state cookie", async () => { + const res = await run(); + expect(res.headers.get("location")).toContain("cloud.ouraring.com"); + expect(res.cookies.get("oura_oauth_state")?.value).toBe("signed-state"); + }); + + it("redirects to an error when the bucket is exhausted", async () => { + rateLimitMock.mockResolvedValue({ allowed: false }); + const res = await run(); + expect(res.headers.get("location")).toContain("reason=rate_limited"); + expect(getCredsMock).not.toHaveBeenCalled(); + }); + + it("redirects to reason=nocreds when neither BYO nor env credentials resolve", async () => { + getCredsMock.mockResolvedValue(null); + const res = await run(); + const location = res.headers.get("location") ?? ""; + expect(location).toContain("/settings/integrations"); + expect(location).toContain("oura=error"); + expect(location).toContain("reason=nocreds"); + }); +}); diff --git a/src/app/api/oura/connect/route.ts b/src/app/api/oura/connect/route.ts index ce2314afb..ec9fac009 100644 --- a/src/app/api/oura/connect/route.ts +++ b/src/app/api/oura/connect/route.ts @@ -1,5 +1,4 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiError } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -19,7 +18,10 @@ import { NextResponse } from "next/server"; * BYO Oura client id/secret wins, falling back to the shared env app * (`OURA_CLIENT_ID` / `OURA_CLIENT_SECRET`) when none is stored. CSRF via the * stateless signed state (`src/lib/oauth/signed-state.ts`): the same token is - * the `state` URL param AND an httpOnly cookie. Rate-limited per user. + * the `state` URL param AND an httpOnly cookie. Rate-limited per user. The + * no-credentials branch redirects too (v1.29.x) rather than returning a raw + * JSON 400 — every other failure path here already redirects, and a + * browser-navigation entry point should never surface an unstyled JSON page. */ const CONNECT_RATE_LIMIT = 10; const CONNECT_WINDOW_MS = 60_000; @@ -45,7 +47,13 @@ export const GET = apiHandler(async () => { const creds = await getOuraClientCredentials(user.id); if (!creds) { - return apiError("Oura integration is not configured on this server.", 400); + annotate({ action: { name: "oura.connect.no_credentials" } }); + return NextResponse.redirect( + new URL( + "/settings/integrations?oura=error&reason=nocreds", + process.env.NEXT_PUBLIC_APP_URL!, + ), + ); } const state = mintSignedState("oura", user.id); diff --git a/src/app/api/polar/connect/__tests__/route.test.ts b/src/app/api/polar/connect/__tests__/route.test.ts index 87511d54e..baad21c01 100644 --- a/src/app/api/polar/connect/__tests__/route.test.ts +++ b/src/app/api/polar/connect/__tests__/route.test.ts @@ -13,9 +13,6 @@ vi.mock("@/lib/api-handler", () => ({ apiHandler: unknown>(fn: T) => fn, requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), })); -vi.mock("@/lib/api-response", () => ({ - apiError: (error: string, status: number) => ({ data: null, error, status }), -})); vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); vi.mock("@/lib/auth/secure-cookie", () => ({ shouldEmitSecureCookie: () => true, @@ -61,9 +58,12 @@ describe("GET /api/polar/connect", () => { expect(getCredsMock).not.toHaveBeenCalled(); }); - it("returns 400 when neither BYO nor env credentials resolve", async () => { + it("redirects to reason=nocreds when neither BYO nor env credentials resolve", async () => { getCredsMock.mockResolvedValue(null); - const res = (await run()) as unknown as { status: number }; - expect(res.status).toBe(400); + const res = await run(); + const location = res.headers.get("location") ?? ""; + expect(location).toContain("/settings/integrations"); + expect(location).toContain("polar=error"); + expect(location).toContain("reason=nocreds"); }); }); diff --git a/src/app/api/polar/connect/route.ts b/src/app/api/polar/connect/route.ts index 3b9d8b5f2..824d4cc47 100644 --- a/src/app/api/polar/connect/route.ts +++ b/src/app/api/polar/connect/route.ts @@ -1,5 +1,4 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiError } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -22,7 +21,10 @@ import { NextResponse } from "next/server"; * same token is the `state` URL param AND an httpOnly cookie, and the callback * enforces both signature validity and a byte-exact cookie match. * - * Rate-limited per user so a session can't spam the consent redirect. + * Rate-limited per user so a session can't spam the consent redirect. The + * no-credentials branch redirects too (v1.29.x) rather than returning a raw + * JSON 400 — every other failure path here already redirects, and a + * browser-navigation entry point should never surface an unstyled JSON page. */ const CONNECT_RATE_LIMIT = 10; const CONNECT_WINDOW_MS = 60_000; @@ -48,7 +50,13 @@ export const GET = apiHandler(async () => { const creds = await getPolarClientCredentials(user.id); if (!creds) { - return apiError("Polar integration is not configured on this server.", 400); + annotate({ action: { name: "polar.connect.no_credentials" } }); + return NextResponse.redirect( + new URL( + "/settings/integrations?polar=error&reason=nocreds", + process.env.NEXT_PUBLIC_APP_URL!, + ), + ); } const state = mintSignedState("polar", user.id); diff --git a/src/app/api/strava/connect/__tests__/route.test.ts b/src/app/api/strava/connect/__tests__/route.test.ts new file mode 100644 index 000000000..9bcbb6610 --- /dev/null +++ b/src/app/api/strava/connect/__tests__/route.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { rateLimitMock, getCredsMock, mintMock, authUrlMock } = vi.hoisted( + () => ({ + rateLimitMock: vi.fn(), + getCredsMock: vi.fn(), + mintMock: vi.fn(() => "signed-state"), + authUrlMock: vi.fn(() => "https://www.strava.com/oauth/authorize?x=1"), + }), +); + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/auth/secure-cookie", () => ({ + shouldEmitSecureCookie: () => true, +})); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: rateLimitMock })); +vi.mock("@/lib/strava/client", () => ({ getAuthorizationUrl: authUrlMock })); +vi.mock("@/lib/strava/credentials", () => ({ + getStravaClientCredentials: getCredsMock, +})); +vi.mock("@/lib/oauth/signed-state", () => ({ + OAUTH_STATE_TTL_MS: 600_000, + mintSignedState: mintMock, + oauthStateCookieName: () => "strava_oauth_state", +})); + +import { GET } from "../route"; + +process.env.NEXT_PUBLIC_APP_URL = "https://app.example"; + +const run = GET as unknown as () => Promise<{ + status: number; + headers: { get(k: string): string | null }; + cookies: { get(k: string): { value: string } | undefined }; +}>; + +beforeEach(() => { + vi.clearAllMocks(); + rateLimitMock.mockResolvedValue({ allowed: true }); + getCredsMock.mockResolvedValue({ clientId: "c", clientSecret: "s" }); +}); + +describe("GET /api/strava/connect", () => { + it("redirects to the consent screen and sets the state cookie", async () => { + const res = await run(); + expect(res.headers.get("location")).toContain("www.strava.com"); + expect(res.cookies.get("strava_oauth_state")?.value).toBe("signed-state"); + }); + + it("redirects to an error when the bucket is exhausted", async () => { + rateLimitMock.mockResolvedValue({ allowed: false }); + const res = await run(); + expect(res.headers.get("location")).toContain("reason=rate_limited"); + expect(getCredsMock).not.toHaveBeenCalled(); + }); + + it("redirects to reason=nocreds when neither BYO nor env credentials resolve", async () => { + getCredsMock.mockResolvedValue(null); + const res = await run(); + const location = res.headers.get("location") ?? ""; + expect(location).toContain("/settings/integrations"); + expect(location).toContain("strava=error"); + expect(location).toContain("reason=nocreds"); + }); +}); diff --git a/src/app/api/strava/connect/route.ts b/src/app/api/strava/connect/route.ts index 24e9a62bc..c76a61786 100644 --- a/src/app/api/strava/connect/route.ts +++ b/src/app/api/strava/connect/route.ts @@ -1,5 +1,4 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiError } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -19,7 +18,10 @@ import { NextResponse } from "next/server"; * user's BYO Strava client id/secret wins, falling back to the shared env app * (`STRAVA_CLIENT_ID` / `STRAVA_CLIENT_SECRET`) when none is stored. CSRF via * the stateless signed state (`src/lib/oauth/signed-state.ts`): the same token - * is the `state` URL param AND an httpOnly cookie. Rate-limited per user. + * is the `state` URL param AND an httpOnly cookie. Rate-limited per user. The + * no-credentials branch redirects too (v1.29.x) rather than returning a raw + * JSON 400 — every other failure path here already redirects, and a + * browser-navigation entry point should never surface an unstyled JSON page. */ const CONNECT_RATE_LIMIT = 10; const CONNECT_WINDOW_MS = 60_000; @@ -45,9 +47,12 @@ export const GET = apiHandler(async () => { const creds = await getStravaClientCredentials(user.id); if (!creds) { - return apiError( - "Strava integration is not configured on this server.", - 400, + annotate({ action: { name: "strava.connect.no_credentials" } }); + return NextResponse.redirect( + new URL( + "/settings/integrations?strava=error&reason=nocreds", + process.env.NEXT_PUBLIC_APP_URL!, + ), ); } diff --git a/src/app/api/whoop/connect/__tests__/route.test.ts b/src/app/api/whoop/connect/__tests__/route.test.ts index 6e243ee0f..fd93eabfd 100644 --- a/src/app/api/whoop/connect/__tests__/route.test.ts +++ b/src/app/api/whoop/connect/__tests__/route.test.ts @@ -53,6 +53,7 @@ import { prisma } from "@/lib/db"; import { requireAuth } from "@/lib/api-handler"; import { consumeWhoopConnectTicket } from "@/lib/whoop/connect-ticket"; import { checkRateLimit } from "@/lib/rate-limit"; +import { getUserWhoopCredentials } from "@/lib/whoop/credentials"; import type { NextRequest } from "next/server"; const create = prisma.whoopOAuthState.create as ReturnType; @@ -133,6 +134,17 @@ describe("GET /api/whoop/connect", () => { expect(create.mock.calls[0][0].data.returnScheme).toBeNull(); }); + it("redirects to reason=nocreds when no per-user credentials are stored", async () => { + vi.mocked(getUserWhoopCredentials).mockResolvedValueOnce(null); + const res = await GET(makeReq("https://app.example/api/whoop/connect")); + expect(res.status).toBe(307); + const location = res.headers.get("location") ?? ""; + expect(location).toContain("/settings/integrations"); + expect(location).toContain("whoop=error"); + expect(location).toContain("reason=nocreds"); + expect(create).not.toHaveBeenCalled(); + }); + it("ticket + return_scheme combine", async () => { consume.mockResolvedValue({ userId: "ticket-user" }); const res = await GET( diff --git a/src/app/api/whoop/connect/route.ts b/src/app/api/whoop/connect/route.ts index 8f5bf8226..716e13895 100644 --- a/src/app/api/whoop/connect/route.ts +++ b/src/app/api/whoop/connect/route.ts @@ -40,7 +40,10 @@ import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; * Rate-limited per user (10 calls / 60 s) so a logged-in session can't spam * ledger rows for the full 10-min TTL window. Both the rate-limit and * create-failure paths redirect (not JSON) because the cookie entry point is a - * browser navigation — a 429 envelope would surface as a blank page. + * browser navigation — a 429 envelope would surface as a blank page. The + * no-credentials branch redirects too (v1.29.x), matching Withings — only the + * ticket-invalid branch stays a typed 401 JSON response, since that path is + * consumed by a native client, not a browser navigation. */ const CONNECT_RATE_LIMIT = 10; const CONNECT_WINDOW_MS = 60_000; @@ -89,9 +92,9 @@ export const GET = apiHandler(async (req: NextRequest) => { const creds = await getUserWhoopCredentials(userId); if (!creds) { - return apiError( - "Please configure your WHOOP Client ID and Client Secret in Settings first.", - 400, + annotate({ action: { name: "whoop.connect.no_credentials" } }); + return NextResponse.redirect( + new URL("/settings/integrations?whoop=error&reason=nocreds", req.url), ); } diff --git a/src/app/api/withings/connect/__tests__/route.test.ts b/src/app/api/withings/connect/__tests__/route.test.ts index cc3fec2b5..32d1044d7 100644 --- a/src/app/api/withings/connect/__tests__/route.test.ts +++ b/src/app/api/withings/connect/__tests__/route.test.ts @@ -12,6 +12,10 @@ * redirects to * `/settings/integrations?withings=error&reason=connect` * instead of bubbling a 500. + * 4. No credentials: redirects to + * `/settings/integrations?withings=error&reason=nocreds` instead of + * returning a raw JSON 400 (v1.29.x — the flagship onboarding CTA + * dead-ended in an unstyled JSON tab for every brand-new account). */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; @@ -132,4 +136,17 @@ describe("GET /api/withings/connect", () => { expect(location).toContain("withings=error"); expect(location).toContain("reason=connect"); }); + + it("redirects to reason=nocreds when no per-user credentials are stored", async () => { + vi.mocked(getUserWithingsCredentials).mockResolvedValueOnce(null); + + const response = await GET(connectRequest()); + + expect(response.status).toBe(307); + const location = response.headers.get("location") ?? ""; + expect(location).toContain("/settings/integrations"); + expect(location).toContain("withings=error"); + expect(location).toContain("reason=nocreds"); + expect(prisma.withingsOAuthState.create).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/withings/connect/route.ts b/src/app/api/withings/connect/route.ts index c1e0c672f..44773679d 100644 --- a/src/app/api/withings/connect/route.ts +++ b/src/app/api/withings/connect/route.ts @@ -1,5 +1,4 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiError } from "@/lib/api-response"; import { prisma } from "@/lib/db"; import { annotate, getEvent } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -32,6 +31,12 @@ import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; * instead of bubbling a 500. Both rate-limit and create-failure paths * redirect rather than returning JSON because the entry point is a * browser navigation — a 429 envelope would surface as a blank page. + * + * v1.29.x — the no-credentials branch redirects too, matching every + * other failure path in this route. It used to return a raw JSON 400, + * which every browser navigation into this route (onboarding's + * "Connect Withings" tap included) surfaced as an unstyled JSON page — + * the first "connect a device" moment for a brand-new self-hoster. */ const CONNECT_RATE_LIMIT = 10; @@ -58,9 +63,9 @@ export const GET = apiHandler(async (req: NextRequest) => { const creds = await getUserWithingsCredentials(user.id); if (!creds) { - return apiError( - "Please configure your Withings Client ID and Client Secret in Settings first.", - 400, + annotate({ action: { name: "withings.connect.no_credentials" } }); + return NextResponse.redirect( + new URL("/settings/integrations?withings=error&reason=nocreds", req.url), ); } diff --git a/src/app/api/withings/status/route.ts b/src/app/api/withings/status/route.ts index 68c306513..7e4311481 100644 --- a/src/app/api/withings/status/route.ts +++ b/src/app/api/withings/status/route.ts @@ -83,7 +83,22 @@ export const GET = apiHandler(async () => { // the sync path does. const message = error instanceof Error ? error.message : String(error); if (isWithingsRefreshReauthFailure(message)) { - await markReauthRequired(user.id, "withings", message).catch(() => {}); + // Awaited so the persisted re-auth state is reflected in this same + // status response — but a persistence failure must not fail the read. + // Breadcrumb it instead of swallowing silently (the message rides the + // central annotate/redact path, never the raw console). + await markReauthRequired(user.id, "withings", message).catch( + (error: unknown) => { + annotate({ + meta: { + "fire_and_forget.integration.reauth.mark": { + error: error instanceof Error ? error.message : String(error), + provider: "withings", + }, + }, + }); + }, + ); } } } diff --git a/src/app/globals.css b/src/app/globals.css index 067640131..441b14be6 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -160,6 +160,17 @@ --dracula-red: #ff5555; --dracula-yellow: #f1fa8c; --dracula-lavender: #d6acff; + /* + * v1.28.17 — the sleep-stage IN_BED fill. Distinct from `--dracula-comment` + * above (an orphaned token — no `@theme` utility was ever generated for + * it, per the `confidence-badge.ts` note — and its `#9aa3b3` value never + * matched the classic Dracula "Comment" blue-grey this chart actually + * paints). Minted here instead of retuning the unrelated token, so this + * default carries the exact hex the stacked bar hardcoded before + * (`#6272a4`) and the light override below (`:root.light`) is this + * token's own AA-tuned counterpart. + */ + --chart-inbed: #6272a4; /* * Semantic feedback tokens (see @theme `--color-success` / `--color-warning` @@ -378,6 +389,9 @@ /* Lavender has no semantic twin; a violet one step lighter than purple, * AA-tuned — 4.97:1 card / 4.63:1 bg. */ --dracula-lavender: #7c50e0; + /* IN_BED sleep-stage fill (see the `:root` default above) — same + * blue-grey hue family, darkened for AA — 6.66:1 card / 6.03:1 bg. */ + --chart-inbed: #4a5b8a; --sidebar: oklch(0.988 0.003 300); --sidebar-foreground: oklch(0.3 0.012 295); --sidebar-primary: #644ac9; diff --git a/src/app/insights/catalog/page.tsx b/src/app/insights/catalog/page.tsx new file mode 100644 index 000000000..9cda95039 --- /dev/null +++ b/src/app/insights/catalog/page.tsx @@ -0,0 +1,297 @@ +"use client"; + +import Link from "next/link"; +import { useQuery } from "@tanstack/react-query"; +import { + Activity, + ChevronRight, + Droplet, + Ear, + Footprints, + HeartPulse, + Moon, + Pill, + Scale, + Siren, + Sun, + Waves, + type LucideIcon, +} from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; +import { SubPageShell } from "@/components/insights/sub-page-shell"; +import { useAuth } from "@/hooks/use-auth"; +import { useWorkouts } from "@/hooks/use-workouts"; +import { useTranslations } from "@/lib/i18n/context"; +import { apiGet } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; +import { + hasMetricData, + type InsightInputs, +} from "@/lib/insights/metric-availability"; +import { + CATALOG_GROUP_HEADER_KEYS, + CATALOG_GROUP_ORDER, + catalogEntriesByGroup, + type CatalogEntry, +} from "@/lib/insights/metric-catalog"; +import type { ManagerGroup } from "@/lib/insights/sub-page-metric"; + +/** + * `/insights/catalog` — "what can HealthLog track" (2026-07-17 + * discoverability audit, recommendation R1). + * + * A single, on-purpose surface: every metric domain HealthLog supports, + * grouped, each row showing its name, whether THIS account has data for + * it yet, what supplies it (device-concrete, vendor-neutral), and — when + * absent and a device could supply it — a quiet CTA into + * `/settings/integrations`. This is deliberately NOT surfaced in the + * daily nav: metrics with no data stay hidden from the dashboard / + * Insights tab strip by design (`metric-availability.ts` — "a metric + * with zero observations doesn't surface a navigation target the user + * can't act on"), so an account with a sparse device kit would otherwise + * have no reachable evidence a capability exists at all. This page is + * that evidence, reached only when the user navigates here on purpose + * (the quiet "All metrics" pill at the tail of the Insights tab strip, + * see `insights-tab-strip.tsx`). + * + * Presence per row reuses the EXACT gating inputs the tab strip / nav + * pills already compute (`hasMetricData` over the shared analytics + + * comprehensive + workouts + nutrients reads) — this page adds no new + * server route and no new derivation of "does the user have this metric". + * The ECG row is the one exception: ECG carries no `MeasurementType`, so + * its presence comes from the same `hasRecordings` probe the overview's + * `` already reads. + */ + +interface ComprehensivePayload { + moodSummary: { count: number } | null; + medications: Array<{ id: string }>; +} + +const GROUP_ICONS: Record = { + vitals: Activity, + body: Scale, + activity: Footprints, + sleep: Moon, + heart: HeartPulse, + hearing: Ear, + environment: Sun, + metabolic: Droplet, + mood: Waves, + events: Pill, +}; + +function StatusBadge({ present }: { present: boolean }) { + const { t } = useTranslations(); + if (present) { + return ( + + {t("metricCatalog.statusPresent")} + + ); + } + return ( + + {t("metricCatalog.statusAbsent")} + + ); +} + +function CatalogRow({ + entry, + present, +}: { + entry: CatalogEntry; + present: boolean | null; +}) { + const { t } = useTranslations(); + + if (entry.kind === "info") { + return ( +
+ +
+

+ {t(entry.nameKey)} +

+

{t(entry.sourceKey)}

+
+
+ ); + } + + const isPresent = present === true; + + return ( +
+
+
+ + {t(entry.nameKey)} + + +
+

{t(entry.sourceKey)}

+
+
+ {isPresent && entry.href ? ( + + ) : !isPresent && entry.connectable ? ( + + ) : !isPresent && entry.manualHref && entry.manualCtaKey ? ( + + ) : null} +
+
+ ); +} + +export default function MetricCatalogPage() { + const { t } = useTranslations(); + const { isAuthenticated, user } = useAuth(); + + const analyticsQuery = useAnalyticsQuery({ slice: "summaries" }); + + const comprehensiveQuery = useQuery({ + queryKey: queryKeys.insightsComprehensive(), + queryFn: () => apiGet("/api/insights/comprehensive"), + enabled: isAuthenticated, + }); + + const workoutsProbe = useWorkouts({ limit: 1 }); + + const nutrientsModuleEnabled = user?.modules?.nutrients === true; + const nutrientsProbe = useQuery({ + queryKey: queryKeys.nutrientIntake(1), + queryFn: () => + apiGet<{ nutrients: Array<{ nutrient: string }> }>( + "/api/nutrients?days=1", + ), + enabled: isAuthenticated && nutrientsModuleEnabled, + }); + + const ecgQuery = useQuery({ + queryKey: queryKeys.insightsEcgList(), + queryFn: () => apiGet<{ hasRecordings: boolean }>("/api/insights/ecg"), + enabled: isAuthenticated, + }); + + const availability: InsightInputs | undefined = isAuthenticated + ? { + summaries: analyticsQuery.data?.summaries, + hasMood: (comprehensiveQuery.data?.moodSummary?.count ?? 0) > 0, + hasMedication: (comprehensiveQuery.data?.medications?.length ?? 0) > 0, + hasWorkouts: (workoutsProbe.data?.workouts.length ?? 0) > 0, + hasNutrients: (nutrientsProbe.data?.nutrients.length ?? 0) > 0, + } + : undefined; + + const grouped = catalogEntriesByGroup(); + + function isPresent(entry: CatalogEntry): boolean | null { + if (entry.kind === "info") return null; + if (entry.id === "ecg") return ecgQuery.data?.hasRecordings === true; + if (!availability || !entry.metric) return false; + return hasMetricData(entry.metric, availability); + } + + return ( + +
+ {CATALOG_GROUP_ORDER.map((group) => { + const entries = grouped.get(group) ?? []; + if (entries.length === 0) return null; + const Icon = GROUP_ICONS[group]; + // Nutrients rides the metabolic group but needs a module-off + // branch (B1 — the tab-strip pill requires the module ON before + // it ever probes for rows, so a module-off account never saw + // the in-context enable CTA; this row is the fix). + return ( + + + + + +
+ {entries.map((entry) => { + if (entry.id === "nutrients" && !nutrientsModuleEnabled) { + return ( +
+
+
+ + {t(entry.nameKey)} + + + {t("metricCatalog.statusModuleOff")} + +
+

+ {t("nutrients.page.moduleOffDescription")} +

+
+
+ +
+
+ ); + } + return ( + + ); + })} +
+
+
+ ); + })} +
+
+ ); +} diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index 6838d8f7a..6be2d586e 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -169,6 +169,14 @@ export default function MedicationsPage() { // the param from the URL so a manual close + refresh stays closed. const shouldOpenFromUrl = searchParams?.get("new") === "1"; const [dialogOpen, setDialogOpen] = useState(shouldOpenFromUrl); + // v1.29.x — the Today digest's "Log dose" action deep-links straight to + // the overdue medication (`/medications?highlight=`) instead of the + // bare list. Read once on mount; a scroll effect below (once the list + // resolves) brings the target card into view and rings it briefly, then + // clears the state so the ring doesn't linger indefinitely. + const [highlightId, setHighlightId] = useState( + () => searchParams?.get("highlight") ?? null, + ); // v1.14.0 — the medications-page "Add" choice. The top button now offers // two paths: log an intake (incl. a backdated one) against an existing // medication, or create a new medication (the existing wizard). @@ -213,6 +221,21 @@ export default function MedicationsPage() { refetchOnMount: "always", }); + // v1.29.x — once the highlighted card is in the DOM, scroll it into view + // and clear the highlight state after a few seconds so the ring is a + // one-time "you tapped here" cue, not a permanent tint (mirrors the + // vault's upload-duplicate flash-highlight, `HIGHLIGHT_MS` in + // `use-document-upload.ts`). + useEffect(() => { + if (!highlightId || !medications?.length) return; + const card = document.querySelector( + `[data-medication-id="${highlightId}"]`, + ); + card?.scrollIntoView({ behavior: "smooth", block: "center" }); + const timer = setTimeout(() => setHighlightId(null), 3000); + return () => clearTimeout(timer); + }, [highlightId, medications]); + // v1.16.11 — the same reminder-thresholds read the cards make (shared // key + cache), so the header's due-set derivation tiers late / // very_late exactly like the card pills. Null on failure keeps the @@ -468,6 +491,7 @@ export default function MedicationsPage() { ) : (
@@ -485,6 +509,7 @@ export default function MedicationsPage() { medication={med} onEdit={openEdit} onOpenHistory={openHistory} + highlighted={highlightId === med.id} /> ) : ( ), )} @@ -513,6 +539,7 @@ export default function MedicationsPage() { medication={med} onEdit={openEdit} onOpenHistory={openHistory} + highlighted={highlightId === med.id} /> ) : ( ), )} diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 2f99a5eec..a23f4a47a 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -35,6 +35,7 @@ import dynamic from "next/dynamic"; import Link from "next/link"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; import { ChartErrorBoundary } from "@/components/charts/chart-error-state"; import { ChartSkeleton } from "@/components/charts/chart-skeleton"; import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; @@ -790,8 +791,11 @@ export default function DashboardPageClient() { decision 1). Renders the S1 daily digest: the day's read, the honest score/freshness face, and the worth-a-look rail. Shimmer skeleton while the cached digest loads; a genuinely empty account - degrades to nothing (the hero itself returns null). The dense - tile grid + charts below are untouched. */} + degrades to nothing (the hero itself returns null). A FAILED fetch + is a distinct state from that honest empty degrade — it renders + `` with a retry action rather than silently + falling through to nothing. The dense tile grid + charts below + are untouched. */} {insightsEnabled && // `!mounted` pins the SSR pass AND the first hydration render to the // skeleton (v1.16.4 pattern): the digest query rehydrates from the @@ -800,6 +804,8 @@ export default function DashboardPageClient() { // skeleton — a text-content hydration mismatch (React #418). (!mounted || digestQuery.isLoading ? ( + ) : digestQuery.isError ? ( + digestQuery.refetch()} /> ) : digestQuery.data ? ( ) : null)} diff --git a/src/components/admin/__tests__/app-log-preview-summary-position.test.tsx b/src/components/admin/__tests__/app-log-preview-summary-position.test.tsx index be2894fe0..356dc6068 100644 --- a/src/components/admin/__tests__/app-log-preview-summary-position.test.tsx +++ b/src/components/admin/__tests__/app-log-preview-summary-position.test.tsx @@ -98,3 +98,16 @@ describe("AppLogPreviewSection — summary lives outside the scroll wrapper", () expect(wrapper![0]).not.toContain("app-log-preview-summary"); }); }); + +describe("AppLogPreviewSection — row detail is keyboard-reachable (2026-07-17 a11y audit H1)", () => { + // The row itself stays a pointer-only convenience (`onClick` on the + // ``); a real ` + + + {event.duration_ms} ms + + + {event.trace_id.slice(0, 8)}… + + + ); + })}
diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index bc01e77e5..5b73bbbdf 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -229,6 +229,16 @@ export function BackupsSection() { moodEntries: number; cycles?: number; cycleDayLogs?: number; + // v1.28 backup-completeness — optional so an older-schema upload + // (pre-v1.28) still totals correctly without these keys. + labResults?: number; + biomarkers?: number; + illnessEpisodes?: number; + illnessDayLogs?: number; + allergies?: number; + familyHistory?: number; + workouts?: number; + documents?: number; }; }>("/api/admin/backups/upload", { method: "POST", body: fd }); }, @@ -239,7 +249,15 @@ export function BackupsSection() { data.summary.intakeEvents + data.summary.moodEntries + (data.summary.cycles ?? 0) + - (data.summary.cycleDayLogs ?? 0); + (data.summary.cycleDayLogs ?? 0) + + (data.summary.labResults ?? 0) + + (data.summary.biomarkers ?? 0) + + (data.summary.illnessEpisodes ?? 0) + + (data.summary.illnessDayLogs ?? 0) + + (data.summary.allergies ?? 0) + + (data.summary.familyHistory ?? 0) + + (data.summary.workouts ?? 0) + + (data.summary.documents ?? 0); toast.success( t("admin.section.backups.uploadSuccess", { count: String(total) }), ); diff --git a/src/components/charts/__tests__/compliance-heatmap-a11y.test.tsx b/src/components/charts/__tests__/compliance-heatmap-a11y.test.tsx new file mode 100644 index 000000000..5e5de3c15 --- /dev/null +++ b/src/components/charts/__tests__/compliance-heatmap-a11y.test.tsx @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { ComplianceHeatmap } from "../compliance-heatmap"; + +/** + * 2026-07-17 a11y audit (M2) — medication-compliance heatmap day cells + * were pointer-only SVG ``s: no `tabIndex`, no per-day `aria-label`. + * This pins the keyboard-reachability fix on top of the pre-existing + * aggregate `role="img"` summary on the SVG. + */ +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe(" — keyboard-reachable day cells", () => { + it("keeps the aggregate role=img summary on the SVG", () => { + const html = render(); + expect(html).toMatch(/]*role="img"[^>]*aria-label="[^"]+"/); + }); + + it("makes every day cell focusable with its own aria-label", () => { + const html = render(); + const rectMatches = html.match(/]*>/g) ?? []; + expect(rectMatches.length).toBeGreaterThan(0); + for (const rect of rectMatches) { + expect(rect).toContain('tabindex="0"'); + expect(rect).toContain('role="img"'); + expect(rect).toMatch(/aria-label="[^"]+"/); + } + }); +}); diff --git a/src/components/charts/__tests__/mood-heatmap-a11y.test.tsx b/src/components/charts/__tests__/mood-heatmap-a11y.test.tsx new file mode 100644 index 000000000..61db1fc72 --- /dev/null +++ b/src/components/charts/__tests__/mood-heatmap-a11y.test.tsx @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { MoodHeatmap } from "../mood-heatmap"; + +/** + * 2026-07-17 a11y audit (M2) — mood heatmap day cells were pointer-only + * SVG ``s: no `tabIndex`, no per-day `aria-label`. This pins the + * keyboard-reachability fix (each cell focusable + self-describing) on + * top of the pre-existing aggregate `role="img"` summary on the SVG. + */ +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe(" — keyboard-reachable day cells", () => { + it("keeps the aggregate role=img summary on the SVG", () => { + const html = render(); + expect(html).toMatch(/]*role="img"[^>]*aria-label="[^"]+"/); + }); + + it("makes every day cell focusable with its own aria-label", () => { + const html = render(); + const rectMatches = html.match(/]*>/g) ?? []; + expect(rectMatches.length).toBeGreaterThan(0); + for (const rect of rectMatches) { + expect(rect).toContain('tabindex="0"'); + expect(rect).toContain('role="img"'); + expect(rect).toMatch(/aria-label="[^"]+"/); + } + }); +}); diff --git a/src/components/charts/__tests__/touch-action-guard.test.ts b/src/components/charts/__tests__/touch-action-guard.test.ts index 0104bda2b..6e00f2525 100644 --- a/src/components/charts/__tests__/touch-action-guard.test.ts +++ b/src/components/charts/__tests__/touch-action-guard.test.ts @@ -24,7 +24,6 @@ const CHART_FILES = [ "health-chart.tsx", "mood-chart.tsx", "medication-compliance-chart.tsx", - "compliance-line-chart.tsx", "scatter-correlation-chart.tsx", ]; diff --git a/src/components/charts/compliance-heatmap.tsx b/src/components/charts/compliance-heatmap.tsx index 06071fb69..b4a506449 100644 --- a/src/components/charts/compliance-heatmap.tsx +++ b/src/components/charts/compliance-heatmap.tsx @@ -119,6 +119,20 @@ export function ComplianceHeatmap({ }; }, [tooltip?.pinned]); + // 2026-07-17 a11y audit (M2) — Escape dismisses any open tooltip + // (pinned touch tooltip or keyboard-focus tooltip), matching 1.4.13's + // "dismissible" requirement. + useEffect(() => { + if (!tooltip) return; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setTooltip(null); + }; + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [tooltip]); + const WEEKDAY_LABELS = [ t("charts.weekdays.mon"), "", @@ -348,7 +362,14 @@ export function ComplianceHeatmap({ height={cellSize} rx={2} fill={cell.color} - className="cursor-pointer" + className="cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--ring)]" + // 2026-07-17 a11y audit (M2) — the aggregate `role="img"` on + // the whole SVG summarises the window; each cell also gets + // its own per-day label so a screen-reader / keyboard user + // can reach the same value the pointer-only tooltip shows. + tabIndex={0} + role="img" + aria-label={buildText()} onPointerEnter={(e) => { // Touch enter fires synthetically immediately before // `pointerdown`; skip it so the pinned tooltip below @@ -373,6 +394,17 @@ export function ComplianceHeatmap({ pinned: true, }); }} + onFocus={(e) => { + const box = e.currentTarget.getBoundingClientRect(); + setTooltip({ + x: box.left + box.width / 2, + y: box.top, + text: buildText(), + }); + }} + onBlur={() => { + setTooltip((prev) => (prev?.pinned ? prev : null)); + }} /> ); })} diff --git a/src/components/charts/compliance-line-chart.tsx b/src/components/charts/compliance-line-chart.tsx deleted file mode 100644 index 5a1fc8dbe..000000000 --- a/src/components/charts/compliance-line-chart.tsx +++ /dev/null @@ -1,169 +0,0 @@ -"use client"; - -import { useMemo, useState } from "react"; -import { - ResponsiveContainer, - LineChart, - Line, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ReferenceLine, -} from "recharts"; -import { Button } from "@/components/ui/button"; -import { useViewportWidth } from "@/hooks/use-viewport-width"; -import { chooseTickInterval } from "@/lib/charts/x-axis-density"; -import { useTranslations } from "@/lib/i18n/context"; -import { makeFormatters } from "@/lib/format-locale"; - -interface DailyData { - expected: number; - taken: number; - skipped: number; - onTime?: number; - late?: number; - veryLate?: number; -} - -interface ComplianceLineChartProps { - dailyCompliance: Record; - rangePoints?: 30 | 90 | 0; - onRangePointsChange?: (value: 30 | 90 | 0) => void; - showRangeControls?: boolean; - /** - * v1.4.25 W7b — per-user display timezone for the X-axis labels. - * Defaults to "Europe/Berlin" so older callers keep their previous - * rendering bit-for-bit. - */ - userTimezone?: string; -} - -const TIME_RANGES = [ - { label: "30M", points: 30, title: "Die dreissig letzten Messpunkte" }, - { label: "90M", points: 90, title: "Die neunzig letzten Messpunkte" }, - { label: "Alle", points: 0, title: "Alle verfuegbaren Messpunkte" }, -] as const; - -export function ComplianceLineChart({ - dailyCompliance, - rangePoints, - onRangePointsChange, - showRangeControls = true, - userTimezone = "Europe/Berlin", -}: ComplianceLineChartProps) { - const [internalRangePoints, setInternalRangePoints] = useState<30 | 90 | 0>( - 30, - ); - const activeRangePoints = rangePoints ?? internalRangePoints; - const setActiveRangePoints = onRangePointsChange ?? setInternalRangePoints; - const viewportWidth = useViewportWidth(); - const { locale } = useTranslations(); - // v1.4.25 W7b — tz-aware date label for the X-axis. Falls back to - // legacy Europe/Berlin when the prop is omitted. - const tzFmt = useMemo( - () => makeFormatters(locale, userTimezone), - [locale, userTimezone], - ); - - const chartData = useMemo(() => { - const points = Object.entries(dailyCompliance) - .filter(([, data]) => data.expected > 0) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([dateKey, data]) => { - const date = new Date(`${dateKey}T12:00:00.000Z`); - return { - date: tzFmt.date(date), - rate: Math.min(100, Math.round((data.taken / data.expected) * 100)), - timestamp: date.getTime(), - }; - }); - - return activeRangePoints > 0 ? points.slice(-activeRangePoints) : points; - }, [dailyCompliance, activeRangePoints, tzFmt]); - - return ( -
- {showRangeControls ? ( -
- {TIME_RANGES.map((r) => ( - - ))} -
- ) : null} - - {chartData.length === 0 ? ( -
- Keine Daten im gewählten Zeitraum -
- ) : ( -
- - - - - - [`${value}%`, "Compliance"]} - /> - - - - -
- )} -
- ); -} diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index adb101917..ee68e1142 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -952,7 +952,7 @@ export function HealthChart({ const allData: ChartDataPoint[] = Array.from(dailyAggregates.values()) .map((bucket) => { const point: ChartDataPoint = { - date: tzFmt.dateShort(new Date(bucket.timestamp)), + date: tzFmt.dateShortSmart(new Date(bucket.timestamp)), timestamp: bucket.timestamp, }; @@ -1079,7 +1079,7 @@ export function HealthChart({ ).points.map((point) => { const date = new Date(point.timestamp); const out: ChartDataPoint = { - date: tzFmt.dateShort(date), + date: tzFmt.dateShortSmart(date), timestamp: point.timestamp, }; for (const [type, value] of Object.entries(point.values)) { @@ -1209,7 +1209,7 @@ export function HealthChart({ // Index shifted rows by the same day-key the chart already uses. const shiftedByDay = new Map>(); for (const row of shifted) { - const dayKey = tzFmt.dateShort(new Date(row.timestamp)); + const dayKey = tzFmt.dateShortSmart(new Date(row.timestamp)); const slot = shiftedByDay.get(dayKey) ?? {}; for (const [type, value] of Object.entries(row.values)) { if (typeof value === "number" && Number.isFinite(value)) { diff --git a/src/components/charts/medication-compliance-chart.tsx b/src/components/charts/medication-compliance-chart.tsx index de819d182..303cd8cde 100644 --- a/src/components/charts/medication-compliance-chart.tsx +++ b/src/components/charts/medication-compliance-chart.tsx @@ -284,7 +284,7 @@ export function MedicationComplianceChart({ const chartData = useMemo( () => data - ? aggregateMedicationCompliance(data, (d) => tzFmt.dateShort(d)) + ? aggregateMedicationCompliance(data, (d) => tzFmt.dateShortSmart(d)) : [], [data, tzFmt], ); diff --git a/src/components/charts/mood-chart.tsx b/src/components/charts/mood-chart.tsx index d641e9ce6..33715a22e 100644 --- a/src/components/charts/mood-chart.tsx +++ b/src/components/charts/mood-chart.tsx @@ -400,7 +400,7 @@ export function MoodChart({ const allPoints: ChartDataPoint[] = data.entries .map((entry) => ({ - date: tzFmt.dateShort(new Date(dayKeyToTimestamp(entry.date))), + date: tzFmt.dateShortSmart(new Date(dayKeyToTimestamp(entry.date))), timestamp: dayKeyToTimestamp(entry.date), pointIndex: 0, score: entry.score, @@ -436,7 +436,7 @@ export function MoodChart({ })), { bucket: bucketType }, ).points.map((point) => ({ - date: tzFmt.dateShort(new Date(point.timestamp)), + date: tzFmt.dateShortSmart(new Date(point.timestamp)), timestamp: point.timestamp, pointIndex: 0, score: point.values.score, @@ -513,7 +513,7 @@ export function MoodChart({ const shiftedByDay = new Map(); for (const row of shifted) { - const dayKey = tzFmt.dateShort(new Date(row.timestamp)); + const dayKey = tzFmt.dateShortSmart(new Date(row.timestamp)); if (typeof row.score === "number" && Number.isFinite(row.score)) { shiftedByDay.set(dayKey, row.score); } diff --git a/src/components/charts/mood-heatmap.tsx b/src/components/charts/mood-heatmap.tsx index 2c4e1ca45..2a06f99c1 100644 --- a/src/components/charts/mood-heatmap.tsx +++ b/src/components/charts/mood-heatmap.tsx @@ -90,6 +90,20 @@ export function MoodHeatmap({ }; }, [tooltip?.pinned]); + // 2026-07-17 a11y audit (M2) — Escape dismisses any open tooltip + // (pinned touch tooltip or keyboard-focus tooltip), matching 1.4.13's + // "dismissible" requirement. + useEffect(() => { + if (!tooltip) return; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setTooltip(null); + }; + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [tooltip]); + const WEEKDAY_LABELS = [ t("charts.weekdays.mon"), "", @@ -287,7 +301,14 @@ export function MoodHeatmap({ // mood band reads clearly; only the no-entry cells stay the // quiet `--secondary` empty-state tint. fillOpacity={1} - className="cursor-pointer" + className="cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--ring)]" + // 2026-07-17 a11y audit (M2) — the aggregate `role="img"` on + // the whole SVG summarises the window; each cell also gets + // its own per-day label so a screen-reader / keyboard user + // can reach the same value the pointer-only tooltip shows. + tabIndex={0} + role="img" + aria-label={buildText()} onPointerEnter={(e) => { if (e.pointerType === "touch") return; setTooltip({ @@ -309,6 +330,17 @@ export function MoodHeatmap({ pinned: true, }); }} + onFocus={(e) => { + const box = e.currentTarget.getBoundingClientRect(); + setTooltip({ + x: box.left + box.width / 2, + y: box.top, + text: buildText(), + }); + }} + onBlur={() => { + setTooltip((prev) => (prev?.pinned ? prev : null)); + }} /> ); })} diff --git a/src/components/charts/nutrient-daily-bar-chart.tsx b/src/components/charts/nutrient-daily-bar-chart.tsx index 84bd26eaa..074418f28 100644 --- a/src/components/charts/nutrient-daily-bar-chart.tsx +++ b/src/components/charts/nutrient-daily-bar-chart.tsx @@ -14,7 +14,7 @@ import { import { CHART_HEIGHT_PX } from "@/lib/charts/constants"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; -import { formatDateShort } from "@/lib/format"; +import { useFormatters } from "@/lib/i18n/context"; import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip"; @@ -49,6 +49,7 @@ export function NutrientDailyBarChart({ valueLabel, referenceValue, }: NutrientDailyBarChartProps) { + const fmt = useFormatters(); const points = useMemo( () => days.map((d) => ({ day: d.day, amount: Math.round(d.amount) })), [days], @@ -77,7 +78,7 @@ export function NutrientDailyBarChart({ /> formatDateShort(dayToDate(day))} + tickFormatter={(day: string) => fmt.dateShortSmart(dayToDate(day))} tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} stroke="var(--border)" minTickGap={24} @@ -116,7 +117,7 @@ export function NutrientDailyBarChart({ return ( ); diff --git a/src/components/custom-metrics/custom-metric-chart.tsx b/src/components/custom-metrics/custom-metric-chart.tsx index 9e98a35ff..9cfadde5e 100644 --- a/src/components/custom-metrics/custom-metric-chart.tsx +++ b/src/components/custom-metrics/custom-metric-chart.tsx @@ -16,8 +16,7 @@ import { import { Button } from "@/components/ui/button"; import { CHART_HEIGHT_PX } from "@/lib/charts/constants"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; -import { formatDateShort } from "@/lib/format"; -import { useTranslations } from "@/lib/i18n/context"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { RichChartTooltip, type RichTooltipRow } from "../charts/chart-tooltip"; import { formatMetricValue } from "./format-value"; @@ -62,6 +61,7 @@ export function CustomMetricChart({ decimals: number | null; }) { const { t } = useTranslations(); + const fmt = useFormatters(); const [range, setRange] = useState("365"); const [nowMs] = useState(() => Date.now()); @@ -136,7 +136,7 @@ export function CustomMetricChart({ type="number" scale="time" domain={["dataMin", "dataMax"]} - tickFormatter={(ts: number) => formatDateShort(new Date(ts))} + tickFormatter={(ts: number) => fmt.dateShortSmart(new Date(ts))} tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} stroke="var(--border)" minTickGap={32} @@ -192,7 +192,7 @@ export function CustomMetricChart({ return ( ); diff --git a/src/components/cycle/bbt-chart.tsx b/src/components/cycle/bbt-chart.tsx index 4f7f81656..d3378192c 100644 --- a/src/components/cycle/bbt-chart.tsx +++ b/src/components/cycle/bbt-chart.tsx @@ -191,7 +191,9 @@ export function BbtChart({ scale="time" domain={["dataMin", "dataMax"]} ticks={ticks} - tickFormatter={(v) => fmt.dateShort(new Date(v as number))} + tickFormatter={(v) => + fmt.dateShortSmart(new Date(v as number)) + } tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} tickLine={false} axisLine={false} @@ -285,7 +287,9 @@ function BbtTooltip(props: { "bg-popover border-border rounded-md border px-2.5 py-2 text-xs shadow-md", )} > -

{fmt.dateShort(new Date(p.t))}

+

+ {fmt.dateShortSmart(new Date(p.t))} +

{t("cycle.bbt.tooltipTemp", { temp: fmt.number(p.temp) })}

diff --git a/src/components/cycle/predictions-panel.tsx b/src/components/cycle/predictions-panel.tsx index f7175c2c8..66e428f0d 100644 --- a/src/components/cycle/predictions-panel.tsx +++ b/src/components/cycle/predictions-panel.tsx @@ -11,7 +11,8 @@ import { } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { TileHeader } from "@/components/insights/tile-header"; -import { useTranslations } from "@/lib/i18n/context"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import type { Formatters } from "@/lib/format-locale"; import { FERTILE_HUE, FLOW_HUE, OVULATION_HUE } from "./phase-tokens"; import type { CyclePrediction, CycleHistoryResponse } from "./types"; @@ -26,12 +27,17 @@ import type { CyclePrediction, CycleHistoryResponse } from "./types"; * cycle-tracking on-ramp, not repeated beneath every prediction surface. */ -function formatDate(d: string): string { +/** + * Issue #66 — was a bespoke `toLocaleDateString(undefined, …)` (browser + * locale, never the app's) with no year. Predictions run 1-2 cycles out, so + * a window straddling New Year's would otherwise print an unadorned "Jan 3" + * in December. Routes through the app-locale conditional-year formatter + * instead; `dateShortSmart` already renders day+month in the locale's field + * order and only adds the year when it differs from the current one. + */ +function formatDate(d: string, fmt: Formatters): string { // Render at noon UTC so the YYYY-MM-DD never rolls a day across tz. - return new Date(`${d}T12:00:00Z`).toLocaleDateString(undefined, { - month: "short", - day: "numeric", - }); + return fmt.dateShortSmart(`${d}T12:00:00Z`); } function confidenceTone(c: number): { @@ -60,6 +66,7 @@ export function PredictionsPanel({ history, }: PredictionsPanelProps) { const { t } = useTranslations(); + const fmt = useFormatters(); return (
@@ -92,8 +99,9 @@ export function PredictionsPanel({ ) : null} @@ -105,7 +113,7 @@ export function PredictionsPanel({ ? t("cycle.predictions.ovulationConfirmed") : t("cycle.predictions.ovulation") } - value={formatDate(prediction.predictedOvulation)} + value={formatDate(prediction.predictedOvulation, fmt)} /> ) : null} @@ -120,6 +128,7 @@ export function PredictionsPanel({ function NextPeriod({ prediction }: { prediction: CyclePrediction }) { const { t } = useTranslations(); + const fmt = useFormatters(); const tone = confidenceTone(prediction.confidence); return (
@@ -128,8 +137,8 @@ function NextPeriod({ prediction }: { prediction: CyclePrediction }) { hue={FLOW_HUE} label={t("cycle.predictions.nextPeriod")} value={t("cycle.predictions.nextPeriodWindow", { - low: formatDate(prediction.nextPeriodStartLow), - high: formatDate(prediction.nextPeriodStartHigh), + low: formatDate(prediction.nextPeriodStartLow, fmt), + high: formatDate(prediction.nextPeriodStartHigh, fmt), })} /> @@ -186,6 +195,7 @@ function HistoryCard({ history: CycleHistoryResponse | undefined; }) { const { t } = useTranslations(); + const fmt = useFormatters(); const stats = history?.stats; // Most-recent observed cycles (the spread the regularity number summarises — // seeing it builds trust; the data was already on the wire). QA H2. @@ -253,10 +263,10 @@ function HistoryCard({ className="flex items-center justify-between gap-3 py-2 text-sm" > - {formatDate(c.startDate)} + {formatDate(c.startDate, fmt)} {" – "} {c.endDate - ? formatDate(c.endDate) + ? formatDate(c.endDate, fmt) : t("cycle.history.ongoing")} diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx index 18b8f8a65..c9bebb8cb 100644 --- a/src/components/daily/today-hero.tsx +++ b/src/components/daily/today-hero.tsx @@ -138,7 +138,7 @@ export function TodayHero({ digest }: { digest: DailyDigest }) { > {topSignal.headline} {topSignal.delta ? ( - + {" "} · {topSignal.delta} diff --git a/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx b/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx index 750d97bab..cfea873bc 100644 --- a/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx +++ b/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx @@ -32,11 +32,15 @@ let mockResult: { isLoading: boolean; isEmpty: boolean; error: Error | null; + isError: boolean; + refetch: () => void; } = { data: undefined, isLoading: false, isEmpty: false, error: null, + isError: false, + refetch: () => {}, }; vi.mock("@/hooks/use-workouts", async () => { @@ -66,15 +70,26 @@ beforeEach(() => { isLoading: false, isEmpty: false, error: null, + isError: false, + refetch: () => {}, }; }); describe("", () => { - it("renders the loading placeholder while the query is in flight", () => { + it("renders row-shaped skeletons while the query is in flight", () => { mockResult.isLoading = true; const html = render(); expect(html).toContain('data-slot="recent-workouts-loading"'); - expect(html).toContain("Loading workouts"); + expect(html).toContain('data-slot="skeleton"'); + }); + + it("renders a retry card instead of the empty state on a failed fetch", () => { + mockResult.isError = true; + const html = render(); + expect(html).toContain('data-slot="query-error-card"'); + // A failed fetch must never read as the honest "no workouts yet" + // empty state — that would misreport an outage as an empty account. + expect(html).not.toContain('data-slot="recent-workouts-empty"'); }); it("renders the empty-state with the Apple-Health onboarding cue", () => { diff --git a/src/components/dashboard/recent-workouts-tile.tsx b/src/components/dashboard/recent-workouts-tile.tsx index db30b451e..7fcf86efc 100644 --- a/src/components/dashboard/recent-workouts-tile.tsx +++ b/src/components/dashboard/recent-workouts-tile.tsx @@ -1,22 +1,16 @@ "use client"; import Link from "next/link"; -import { - Activity, - Bike, - Dumbbell, - Footprints, - HeartPulse, - Mountain, - PersonStanding, - type LucideIcon, -} from "lucide-react"; +import { Activity } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; import { useWorkouts, type WorkoutListEntry } from "@/hooks/use-workouts"; import { cn } from "@/lib/utils"; import { TileHeader } from "@/components/insights/tile-header"; import { getDateTimeFormat } from "@/lib/intl/formatter-cache"; +import { Skeleton } from "@/components/ui/skeleton"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { iconForSport } from "@/lib/workouts/sport-icons"; /** * v1.4.32 — ``. @@ -36,33 +30,6 @@ import { getDateTimeFormat } from "@/lib/intl/formatter-cache"; * round-trip per session. */ -const SPORT_TYPE_ICON: Record = { - walking: Footprints, - running: PersonStanding, - cycling: Bike, - hiking: Mountain, - swimming: Activity, - rowing: Activity, - elliptical: Activity, - stairClimber: Activity, - yoga: PersonStanding, - mindAndBody: PersonStanding, - strength: Dumbbell, - hiit: Activity, - dance: Activity, - golf: Activity, - tennis: Activity, - basketball: Activity, - soccer: Activity, - crossTraining: Activity, - mixedCardio: HeartPulse, - other: Activity, -}; - -function iconForSport(sportType: string): LucideIcon { - return SPORT_TYPE_ICON[sportType] ?? Activity; -} - function formatDuration(seconds: number): string { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); @@ -99,7 +66,7 @@ function renderRow(workout: WorkoutListEntry, sportName: string) { export function RecentWorkoutsTile() { const { t, locale } = useTranslations(); - const { data, isLoading } = useWorkouts({ limit: 3 }); + const { data, isLoading, isError, refetch } = useWorkouts({ limit: 3 }); const workouts = data?.workouts ?? []; return ( @@ -131,12 +98,24 @@ export function RecentWorkoutsTile() { // v1.4.43 W11-L6 — reserve roughly the loaded tile height // (3 workouts × ~3 rem rows + spacing) so the surrounding // dashboard layout doesn't reflow once the data lands. -

- {t("dashboard.recentWorkouts.loading")} -

+ // v1.29.x — row-shaped skeletons instead of a "loading…" text + // line, matching the sport-icon + label + day + duration shape + // the loaded rows render. +
+ {[0, 1, 2].map((i) => ( +
+ + + + +
+ ))} +
+ ) : isError ? ( + refetch()} + className="border-0 bg-transparent shadow-none" + /> ) : workouts.length === 0 ? (

{t("dashboard.recentWorkouts.empty.title")}

diff --git a/src/components/documents/__tests__/document-card.test.tsx b/src/components/documents/__tests__/document-card.test.tsx index 8734d2ab0..5cb9a5e0b 100644 --- a/src/components/documents/__tests__/document-card.test.tsx +++ b/src/components/documents/__tests__/document-card.test.tsx @@ -175,6 +175,57 @@ describe("", () => { expect(html).toContain("Untitled document"); expect(html).toContain("ring-2"); }); + + // v1.29.x — the fire-and-forget auto-index job is invisible otherwise; + // the card surfaces its own "Processing…" chip for a freshly uploaded, + // not-yet-indexed document. `isDocumentProcessing` (vault-utils) bounds + // this to the recent-upload window; `doc()`'s default `createdAt` is old, + // so the baseline render above never shows the chip. + it("shows the Processing… chip for a freshly uploaded, not-yet-indexed document", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="document-processing-badge"'); + expect(html).toContain("Processing"); + }); + + it("shows no Processing… chip once the document is indexed", () => { + const html = render( + , + ); + expect(html).not.toContain('data-slot="document-processing-badge"'); + }); + + it("shows no Processing… chip for an old, never-indexed document (outside the window)", () => { + const html = render( + , + ); + expect(html).not.toContain('data-slot="document-processing-badge"'); + }); }); describe("", () => { diff --git a/src/components/documents/__tests__/vault-utils.test.ts b/src/components/documents/__tests__/vault-utils.test.ts index 47f72c1ea..3a3723e91 100644 --- a/src/components/documents/__tests__/vault-utils.test.ts +++ b/src/components/documents/__tests__/vault-utils.test.ts @@ -10,8 +10,12 @@ import { expandRangeSelection, formatBytes, formatMonthLabel, + hasProcessingDocument, + isDocumentProcessing, + isRecentlyUploaded, parseUploadResponse, parseVaultSearchParams, + RECENT_UPLOAD_WINDOW_MS, resolveBulkShareDocuments, SHARE_LINK_MAX_DOCUMENTS, vaultFiltersToSearch, @@ -343,3 +347,67 @@ describe("resolveBulkShareDocuments", () => { expect(result.documents).toHaveLength(SHARE_LINK_MAX_DOCUMENTS); }); }); + +// v1.29.x — the vault's "Processing…" / "Ready" chip + the poll-while- +// indexing refetch both key off the bounded recent-upload window rather than +// a bare `!hasContentIndex`, so an old / permanently-unindexed document never +// shows a stuck spinner and the list query never polls forever. +describe("isRecentlyUploaded / isDocumentProcessing / hasProcessingDocument", () => { + const NOW = new Date("2026-03-11T08:10:00.000Z").getTime(); + + it("is recently-uploaded just inside the window, not right at/after it", () => { + const justInside = new Date( + NOW - RECENT_UPLOAD_WINDOW_MS + 1000, + ).toISOString(); + expect(isRecentlyUploaded(justInside, NOW)).toBe(true); + + const atTheEdge = new Date(NOW - RECENT_UPLOAD_WINDOW_MS).toISOString(); + expect(isRecentlyUploaded(atTheEdge, NOW)).toBe(false); + }); + + it("is never recently-uploaded for a future createdAt (clock skew defensiveness)", () => { + const future = new Date(NOW + 1000).toISOString(); + expect(isRecentlyUploaded(future, NOW)).toBe(false); + }); + + it("is processing only while unindexed AND inside the recent-upload window", () => { + const fresh = new Date(NOW - 1000).toISOString(); + expect( + isDocumentProcessing({ createdAt: fresh, hasContentIndex: false }, NOW), + ).toBe(true); + // Indexed already — done, not processing, regardless of age. + expect( + isDocumentProcessing({ createdAt: fresh, hasContentIndex: true }, NOW), + ).toBe(false); + // Old and never indexed (unsupported format, content index disabled, + // …) — outside the window, so it does NOT read as "still processing". + const old = new Date(NOW - RECENT_UPLOAD_WINDOW_MS - 1000).toISOString(); + expect( + isDocumentProcessing({ createdAt: old, hasContentIndex: false }, NOW), + ).toBe(false); + }); + + it("hasProcessingDocument is true when ANY loaded document is still processing", () => { + const fresh = new Date(NOW - 1000).toISOString(); + const old = new Date(NOW - RECENT_UPLOAD_WINDOW_MS - 1000).toISOString(); + const docs = [ + doc({ id: "d1", createdAt: old, hasContentIndex: false }), + doc({ id: "d2", createdAt: fresh, hasContentIndex: false }), + ]; + expect(hasProcessingDocument(docs, NOW)).toBe(true); + }); + + it("hasProcessingDocument is false when every document is indexed or outside the window", () => { + const fresh = new Date(NOW - 1000).toISOString(); + const old = new Date(NOW - RECENT_UPLOAD_WINDOW_MS - 1000).toISOString(); + const docs = [ + doc({ id: "d1", createdAt: old, hasContentIndex: false }), + doc({ id: "d2", createdAt: fresh, hasContentIndex: true }), + ]; + expect(hasProcessingDocument(docs, NOW)).toBe(false); + }); + + it("hasProcessingDocument is false for an empty list", () => { + expect(hasProcessingDocument([], NOW)).toBe(false); + }); +}); diff --git a/src/components/documents/document-card.tsx b/src/components/documents/document-card.tsx index 462d7816e..ba9f94763 100644 --- a/src/components/documents/document-card.tsx +++ b/src/components/documents/document-card.tsx @@ -16,8 +16,8 @@ * whole card is clickable through an invisible overlay button; the checkbox * floats above it. */ -import { Download, X } from "lucide-react"; -import { useRef, useState } from "react"; +import { Check, Download, Loader2, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -28,7 +28,14 @@ import { cn } from "@/lib/utils"; import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; import { DOCUMENT_KIND_ICONS } from "./document-kind-meta"; import type { UploadQueueItem } from "./use-document-upload"; -import { documentDateKey, formatBytes } from "./vault-utils"; +import { + documentDateKey, + formatBytes, + isDocumentProcessing, +} from "./vault-utils"; + +/** How long the "Ready" chip stays up after a card's index lands. */ +const READY_FLASH_MS = 3000; /** Press-and-hold duration that flips a touch press into "select" mode. */ const LONG_PRESS_MS = 500; @@ -86,6 +93,27 @@ export function DocumentCard({ const showFilename = document.filename !== null && document.filename !== title; + // v1.29.x — processing/ready feedback for the fire-and-forget auto-index + // (+ optional AI summary) job that starts right after upload. `processing` + // is derived straight from the DTO (bounded to the recent-upload window, so + // an old/never-indexed document never shows a stuck spinner). `justIndexed` + // is local: it flashes once when a card that WAS processing on this page + // transitions to indexed (the poll in `documents-view.tsx` is what drives + // the transition), then clears on its own — a one-time "it landed" cue, + // not a permanent tint. + const processing = isDocumentProcessing(document); + const wasProcessingRef = useRef(processing); + const [justIndexed, setJustIndexed] = useState(false); + useEffect(() => { + if (wasProcessingRef.current && !processing) { + setJustIndexed(true); + const timer = setTimeout(() => setJustIndexed(false), READY_FLASH_MS); + wasProcessingRef.current = processing; + return () => clearTimeout(timer); + } + wasProcessingRef.current = processing; + }, [processing]); + return (
{document.conditionLinks.length > 0 || - document.servingClass === "attachment" ? ( + document.servingClass === "attachment" || + processing || + justIndexed ? (
{document.conditionLinks.map((link) => ( ) : null} + {processing ? ( + + + {t("documents.card.processing")} + + ) : justIndexed ? ( + + + {t("documents.card.ready")} + + ) : null}
) : null} diff --git a/src/components/documents/documents-view.tsx b/src/components/documents/documents-view.tsx index 717ad242e..d9d61b9ef 100644 --- a/src/components/documents/documents-view.tsx +++ b/src/components/documents/documents-view.tsx @@ -65,6 +65,7 @@ import { countActiveFilters, documentDateKey, expandRangeSelection, + hasProcessingDocument, parseVaultSearchParams, resolveBulkShareDocuments, SHARE_LINK_MAX_DOCUMENTS, @@ -213,6 +214,18 @@ export function DocumentsView() { ), initialPageParam: null as string | null, getNextPageParam: (lastPage) => lastPage.nextCursor, + // v1.29.x — the auto-index (+ optional AI summary) job that lands right + // after upload is fire-and-forget server-side; nothing pushes its + // completion to the client. Poll while any loaded document is still + // inside its recent-upload processing window (see `hasProcessingDocument` + // — bounded, so an old/permanently-unindexed document never keeps this + // polling forever) so the card's "Processing…" chip clears on its own + // once the index lands, without a manual refresh. + refetchInterval: (query) => { + const pages = query.state.data?.pages ?? []; + const docs = pages.flatMap((p) => p.documents); + return hasProcessingDocument(docs) ? 4000 : false; + }, }); const documents = useMemo( diff --git a/src/components/documents/vault-utils.ts b/src/components/documents/vault-utils.ts index bb15c532d..68629ff77 100644 --- a/src/components/documents/vault-utils.ts +++ b/src/components/documents/vault-utils.ts @@ -115,6 +115,49 @@ export function documentMonthKey(doc: InboundDocumentDto): string { return documentDateKey(doc).slice(0, 7); } +/** + * v1.29.x — bounded "still processing" window for a freshly uploaded + * document. `hasContentIndex` stays false until the fire-and-forget + * auto-index job (`enqueueDocumentIndex`, `src/app/api/documents/inbound/ + * route.ts`) lands — which for some documents (an unsupported format, + * content indexing disabled entirely) never happens. Gating the vault's + * "Processing…" chip AND the timeline's poll-while-processing refetch on + * this window means an old, permanently-unindexed document never shows a + * stuck spinner and the list never polls forever. + */ +export const RECENT_UPLOAD_WINDOW_MS = 3 * 60 * 1000; + +/** True while `createdAt` is still inside the recent-upload processing window. */ +export function isRecentlyUploaded( + createdAt: string, + now: number = Date.now(), +): boolean { + const ageMs = now - new Date(createdAt).getTime(); + return ageMs >= 0 && ageMs < RECENT_UPLOAD_WINDOW_MS; +} + +/** + * True while a recently-uploaded document's content index has not landed + * yet — the vault card's "Processing…" chip condition. + */ +export function isDocumentProcessing( + doc: Pick, + now: number = Date.now(), +): boolean { + return !doc.hasContentIndex && isRecentlyUploaded(doc.createdAt, now); +} + +/** + * Whether ANY document in the list is still inside its processing window — + * drives the vault list query's poll-while-indexing `refetchInterval`. + */ +export function hasProcessingDocument( + documents: InboundDocumentDto[], + now: number = Date.now(), +): boolean { + return documents.some((doc) => isDocumentProcessing(doc, now)); +} + /** * One flat item in the virtualized timeline: a month section label or one * grid row of up to `columns` documents. The virtualizer windows over this diff --git a/src/components/illness/illness-day-timeline.tsx b/src/components/illness/illness-day-timeline.tsx index 674686a3c..54841722e 100644 --- a/src/components/illness/illness-day-timeline.tsx +++ b/src/components/illness/illness-day-timeline.tsx @@ -34,7 +34,7 @@ function DayLogRow({ log }: { log: IllnessDayLogDTO }) { return (

- {fmt.dateShort(`${log.date}T12:00:00`)} + {fmt.dateShortSmart(`${log.date}T12:00:00`)}

{log.symptoms.length > 0 ? ( diff --git a/src/components/illness/illness-episode-detail.tsx b/src/components/illness/illness-episode-detail.tsx index 0f61ba6d3..79e4a8a17 100644 --- a/src/components/illness/illness-episode-detail.tsx +++ b/src/components/illness/illness-episode-detail.tsx @@ -82,11 +82,11 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) { {t("illness.onsetOn", { - date: fmt.dateShort(new Date(episode.onsetAt)), + date: fmt.dateShortSmart(new Date(episode.onsetAt)), })} {episode.resolvedAt ? ` · ${t("illness.recoveredOn", { - date: fmt.dateShort(new Date(episode.resolvedAt)), + date: fmt.dateShortSmart(new Date(episode.resolvedAt)), })}` : ""} diff --git a/src/components/illness/illness-settings.tsx b/src/components/illness/illness-settings.tsx index d6952bc5e..f7d859ca2 100644 --- a/src/components/illness/illness-settings.tsx +++ b/src/components/illness/illness-settings.tsx @@ -43,7 +43,7 @@ export function IllnessSettings() { : e.lifecycle === "CHRONIC_ONGOING" ? t("illness.status.ongoing") : t("illness.status.active") - } · ${fmt.dateShort(new Date(e.onsetAt))}`, + } · ${fmt.dateShortSmart(new Date(e.onsetAt))}`, })); return ( diff --git a/src/components/illness/illness-view.tsx b/src/components/illness/illness-view.tsx index 9b7efe866..da308b211 100644 --- a/src/components/illness/illness-view.tsx +++ b/src/components/illness/illness-view.tsx @@ -103,7 +103,7 @@ function EpisodeCard({ : t("illness.status.recovered")} - {fmt.dateShort(new Date(episode.onsetAt))} + {fmt.dateShortSmart(new Date(episode.onsetAt))}
@@ -167,11 +167,11 @@ function EpisodeCard({

{t("illness.onsetOn", { - date: fmt.dateShort(new Date(episode.onsetAt)), + date: fmt.dateShortSmart(new Date(episode.onsetAt)), })} {episode.resolvedAt ? ` · ${t("illness.recoveredOn", { - date: fmt.dateShort(new Date(episode.resolvedAt)), + date: fmt.dateShortSmart(new Date(episode.resolvedAt)), })}` : ""}

@@ -192,7 +192,7 @@ function EpisodeCard({ > {flare.label} - {fmt.dateShort(new Date(flare.onsetAt))} + {fmt.dateShortSmart(new Date(flare.onsetAt))} diff --git a/src/components/insights/__tests__/insights-tab-strip.test.tsx b/src/components/insights/__tests__/insights-tab-strip.test.tsx index 051a4c8e3..d8e103e8a 100644 --- a/src/components/insights/__tests__/insights-tab-strip.test.tsx +++ b/src/components/insights/__tests__/insights-tab-strip.test.tsx @@ -70,10 +70,11 @@ describe(" — availability gating (v1.4.27 F19)", () => { hasMedication: false, }; const html = render(); - // Pills that have data stay. - expect(html).toContain(">Pulse<"); + // Pills that have data stay — pulse and bmi surface through their + // Heart / Body groups (2026-07-17 UX/IA audit M1). + expect(html).toContain('data-group="heart"'); expect(html).toContain(">Weight<"); - expect(html).toContain(">BMI<"); + expect(html).toContain('data-group="body"'); // Pills without data drop. expect(html).not.toContain(">Blood Pressure<"); expect(html).not.toContain(">Mood<"); @@ -143,7 +144,8 @@ describe(" — availability gating (v1.4.27 F19)", () => { }; const html = render(); expect(html).toContain(">Weight<"); - expect(html).toContain(">BMI<"); + // bmi surfaces through the Body group (2026-07-17 UX/IA audit M1). + expect(html).toContain('data-group="body"'); }); }); @@ -221,7 +223,7 @@ describe(" — vitals group collapse (v1.4.34 IW-D)", () => { }; const html = render(); expect(html).not.toContain(">Vitals<"); - expect(html).not.toContain('data-slot="insights-tab-strip-group"'); + expect(html).not.toContain('data-group="vitals"'); }); it("renders the parent pill as a popover trigger (button, not link)", () => { @@ -230,17 +232,80 @@ describe(" — vitals group collapse (v1.4.34 IW-D)", () => { expect(html).toContain('data-group="vitals"'); }); - it("preserves the non-grouped pills inline", () => { + it("preserves the pinned flat pills inline", () => { const html = render(); - // Flat (non-grouped) pills must still be reachable directly. + // Pinned pills must still be reachable directly. expect(html).toContain(">Blood Pressure<"); - expect(html).toContain(">Pulse<"); expect(html).toContain(">Weight<"); - expect(html).toContain(">BMI<"); expect(html).toContain(">Sleep<"); expect(html).toContain(">Mood<"); expect(html).toContain(">Medication<"); - expect(html).toContain(">Workouts<"); + }); +}); + +describe(" — heart group collapse (2026-07-17 UX/IA audit H2)", () => { + // Pulse, resting HR, HRV and cardio recovery used to be scattered across + // a flat "Pulse" pill and the "Vitals" popover; they now share one + // "Heart" group. Blood pressure stays pinned flat (see the pinned-pills + // test above). + const heartAvailability: InsightInputs = { + summaries: { + PULSE: fakeSummary(5), + HEART_RATE_VARIABILITY: fakeSummary(5), + RESTING_HEART_RATE: fakeSummary(5), + CARDIO_RECOVERY: fakeSummary(5), + }, + hasMood: false, + hasMedication: false, + }; + + it("renders a single 'Heart' parent pill instead of flat Pulse / HRV / Resting HR / Cardio recovery pills", () => { + const html = render(); + const parentMatches = html.match(/>HeartPulse<"); + expect(html).not.toContain(">HRV<"); + expect(html).not.toContain(">Resting HR<"); + expect(html).not.toContain(">Cardio Recovery<"); + }); + + it("hides the Heart parent pill when no heart metric has data", () => { + const availability: InsightInputs = { + summaries: { WEIGHT: fakeSummary(2) }, + hasMood: false, + hasMedication: false, + }; + const html = render(); + expect(html).not.toContain(">Heart<"); + expect(html).not.toContain('data-group="heart"'); + }); +}); + +describe(" — bmi / workouts collapse into their groups (2026-07-17 UX/IA audit M1)", () => { + it("bmi renders inside the 'Body' popover, not as a flat pill", () => { + const availability: InsightInputs = { + summaries: { WEIGHT: fakeSummary(5) }, + hasMood: false, + hasMedication: false, + }; + const html = render(); + expect(html).toContain(">Weight<"); + expect(html).not.toContain(">BMI<"); + expect(html).toContain('data-group="body"'); + }); + + it("workouts renders inside the 'Activity' popover, not as a flat pill", () => { + const availability: InsightInputs = { + summaries: {}, + hasMood: false, + hasMedication: false, + hasWorkouts: true, + }; + const html = render(); + expect(html).not.toContain(">Workouts<"); + expect(html).toContain('data-group="activity"'); }); }); @@ -255,8 +320,10 @@ describe(" — saved-layout visibility gate (v1.15.14 W2)", () }; it("hides a pill whose slug is layout-hidden even though it has data", () => { - // `pulse` has data but is NOT in the visible set ⇒ no pill. `weight` - // has data AND is visible ⇒ pill shows. + // `pulse` has data but is NOT in the visible set ⇒ its Heart group + // never emits. `weight` has data AND is visible ⇒ pill shows. `bmi` + // has data (derived from WEIGHT) AND is visible ⇒ its Body group + // emits (2026-07-17 UX/IA audit M1 — bmi collapsed out of the flat row). const visibleTileIds = new Set(["overview", "weight", "bmi"]); const html = render( — saved-layout visibility gate (v1.15.14 W2)", () />, ); expect(html).toContain(">Weight<"); - expect(html).toContain(">BMI<"); - expect(html).not.toContain(">Pulse<"); + expect(html).toContain('data-group="body"'); + expect(html).not.toContain('data-group="heart"'); }); - it("shows a pill when its slug is layout-visible AND has data", () => { + it("shows a group parent pill when its slug is layout-visible AND has data", () => { const visibleTileIds = new Set(["overview", "pulse", "weight", "bmi"]); const html = render( — saved-layout visibility gate (v1.15.14 W2)", () visibleTileIds={visibleTileIds} />, ); - expect(html).toContain(">Pulse<"); + expect(html).toContain('data-group="heart"'); expect(html).toContain(">Weight<"); }); @@ -302,9 +369,9 @@ describe(" — saved-layout visibility gate (v1.15.14 W2)", () it("falls back to the data-only gate when no layout is loaded (visibleTileIds undefined)", () => { // No visibleTileIds prop ⇒ pre-W2 behaviour: every data-having pill shows. const html = render(); - expect(html).toContain(">Pulse<"); + expect(html).toContain('data-group="heart"'); expect(html).toContain(">Weight<"); - expect(html).toContain(">BMI<"); + expect(html).toContain('data-group="body"'); }); it("always shows the Overview pill regardless of layout visibility", () => { @@ -316,7 +383,7 @@ describe(" — saved-layout visibility gate (v1.15.14 W2)", () />, ); expect(html).toContain(">Overview<"); - expect(html).not.toContain(">Pulse<"); + expect(html).not.toContain('data-group="heart"'); expect(html).not.toContain(">Weight<"); }); @@ -405,7 +472,9 @@ describe(" — module enable/disable gate (v1.18.0)", () => { // A pill belonging to a toggleable module (mood / sleep / glucose / // workouts) is hidden when that module is disabled in the account's // resolved module map — on TOP of the data + layout gates. Core metric - // pills (BP, pulse, weight, BMI …) carry no module key and ignore it. + // pills (BP, pulse, weight, BMI …) carry no module key and ignore it — + // pulse and bmi now surface through their Heart / Body group instead of + // a flat pill, but the module gate still never touches them. const moduleAvailability: InsightInputs = { summaries: { PULSE: fakeSummary(5), @@ -418,10 +487,11 @@ describe(" — module enable/disable gate (v1.18.0)", () => { hasWorkouts: true, }; - it("hides the Mood / Sleep / Workouts pills + the glucose (Metabolic) group when their modules are disabled", () => { + it("hides the Mood / Sleep / Activity(workouts) pills/groups + the glucose (Metabolic) group when their modules are disabled", () => { // `blood-glucose` is the sole metabolic metric with data here, so its // module gate collapses the whole "Metabolic" group parent pill — - // mood / sleep / workouts are flat pills that drop directly. + // mood / sleep are flat pills that drop directly; workouts is the + // sole Activity-group member here, so disabling it collapses Activity. const html = render( — module enable/disable gate (v1.18.0)", () => { ); expect(html).not.toContain(">Mood<"); expect(html).not.toContain(">Sleep<"); - expect(html).not.toContain(">Workouts<"); + expect(html).not.toContain('data-group="activity"'); expect(html).not.toContain('data-group="metabolic"'); - // Core metric pills are unaffected by the module map. - expect(html).toContain(">Pulse<"); + // Core metric pills are unaffected by the module map — pulse and bmi + // carry no module key and still surface through their Heart / Body + // groups (2026-07-17 UX/IA audit — both collapsed out of the flat row). + expect(html).toContain('data-group="heart"'); expect(html).toContain(">Weight<"); - expect(html).toContain(">BMI<"); + expect(html).toContain('data-group="body"'); expect(html).toContain(">Overview<"); }); @@ -458,7 +530,8 @@ describe(" — module enable/disable gate (v1.18.0)", () => { ); expect(html).toContain(">Mood<"); expect(html).toContain(">Sleep<"); - expect(html).toContain(">Workouts<"); + // Workouts collapses into the Activity group (2026-07-17 UX/IA audit M1). + expect(html).toContain('data-group="activity"'); expect(html).toContain('data-group="metabolic"'); }); @@ -486,7 +559,7 @@ describe(" — module enable/disable gate (v1.18.0)", () => { ); expect(html).toContain(">Mood<"); expect(html).toContain(">Sleep<"); - expect(html).toContain(">Workouts<"); + expect(html).toContain('data-group="activity"'); expect(html).toContain('data-group="metabolic"'); expect(html).toContain(">Recovery<"); } diff --git a/src/components/insights/__tests__/metric-target-summary.test.tsx b/src/components/insights/__tests__/metric-target-summary.test.tsx index 18e781bb6..55d3b021b 100644 --- a/src/components/insights/__tests__/metric-target-summary.test.tsx +++ b/src/components/insights/__tests__/metric-target-summary.test.tsx @@ -161,7 +161,13 @@ describe("", () => { // redundant "Target: 60.1–80.9 kg" string above it. expect(html).toContain('data-slot="tile-header"'); expect(html).toContain("Target"); - expect(html).not.toContain("Target: 60.1–80.9 kg"); + // 2026-07-17 a11y audit (M1) — the range-bar marker now carries an + // `aria-label` that reuses this same string as a text alternative + // (screen-reader only, not a rendered heading) — strip attribute + // values before asserting no redundant VISIBLE "Target: …" text node + // exists above the bar. + const visibleText = html.replace(/aria-label="[^"]*"/g, ""); + expect(visibleText).not.toContain("Target: 60.1–80.9 kg"); // The min / max endpoints surface as the range-bar axis labels. expect(html).toContain("60.1 kg"); expect(html).toContain("80.9 kg"); diff --git a/src/components/insights/__tests__/sleep-hypnogram.test.tsx b/src/components/insights/__tests__/sleep-hypnogram.test.tsx deleted file mode 100644 index 4bd96d7b6..000000000 --- a/src/components/insights/__tests__/sleep-hypnogram.test.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - -import { I18nProvider } from "@/lib/i18n/context"; -import { SleepHypnogram, type SleepHypnogramSession } from "../sleep-hypnogram"; - -/** - * v1.16.8 — hypnogram polish: the night's measuring source rides the - * header as a muted caption, and the depth lanes derive from the stages - * actually present in the night's spans (in `LANE_ORDER` order) instead - * of always painting all six — an Apple night (AWAKE/REM/CORE/DEEP) - * used to carry two permanently empty rows. - * - * Project convention: SSR-only tests. Recharts' `ResponsiveContainer` - * paints nothing without measured dimensions, so the lane derivation is - * pinned structurally against the source. - */ - -const baseSession: SleepHypnogramSession = { - night: "2026-06-09", - source: "Apple Health", - start: "2026-06-08T22:30:00.000Z", - end: "2026-06-09T06:00:00.000Z", - asleepMinutes: 420, - inBedMinutes: 450, - awakeMinutes: 30, - awakenings: 2, - stages: { DEEP: 90, REM: 100, CORE: 230, AWAKE: 30 }, - segments: [ - { - stage: "AWAKE", - start: "2026-06-08T22:30:00.000Z", - end: "2026-06-08T22:45:00.000Z", - minutes: 15, - }, - { - stage: "CORE", - start: "2026-06-08T22:45:00.000Z", - end: "2026-06-09T01:00:00.000Z", - minutes: 135, - }, - { - stage: "DEEP", - start: "2026-06-09T01:00:00.000Z", - end: "2026-06-09T02:30:00.000Z", - minutes: 90, - }, - ], -}; - -function render(session: SleepHypnogramSession, locale: "en" | "de" = "en") { - return renderToStaticMarkup( - - - , - ); -} - -describe(" — source caption", () => { - it("surfaces the night's measuring source as a muted header caption", () => { - const html = render(baseSession); - expect(html).toContain('data-slot="sleep-hypnogram-source"'); - expect(html).toContain("Source: Apple Health"); - }); - - it("localises the caption label", () => { - const html = render(baseSession, "de"); - expect(html).toContain("Quelle: Apple Health"); - }); - - it("omits the caption when the session carries no source", () => { - const html = render({ ...baseSession, source: null }); - expect(html).not.toContain('data-slot="sleep-hypnogram-source"'); - }); -}); - -describe(" — lane derivation", () => { - it("derives the Y lanes from the stages present in the spans", () => { - const src = readFileSync( - join(process.cwd(), "src/components/insights/sleep-hypnogram.tsx"), - "utf8", - ); - // Lanes come from the present stages, preserving LANE_ORDER… - expect(src).toContain("LANE_ORDER.filter((stage) => present.has(stage))"); - // …and the axis reads the derived lane list, not the full constant. - expect(src).toContain("domain={[-0.5, lanes.length - 0.5]}"); - expect(src).toContain("ticks={lanes.map((_, i) => lanes.length - 1 - i)}"); - }); -}); - -describe(" — timeline bar visibility", () => { - const sharedEnd = "2026-06-09T06:00:00.000Z"; - - it("draws the timeline bar for a multi-stage night whose segments share a right edge", () => { - // Distinct STARTS but a shared END instant — the shape a summary-derived - // writer reconstructs to. The earlier end-instant guard hid the bar here; - // gating on the start instant restores it. (Regression: bar vanished, - // leaving only the breakdown numbers.) - const session: SleepHypnogramSession = { - ...baseSession, - segments: [ - { - stage: "CORE", - start: "2026-06-08T22:30:00.000Z", - end: sharedEnd, - minutes: 200, - }, - { - stage: "DEEP", - start: "2026-06-09T01:00:00.000Z", - end: sharedEnd, - minutes: 100, - }, - { - stage: "REM", - start: "2026-06-09T03:00:00.000Z", - end: sharedEnd, - minutes: 120, - }, - ], - }; - expect(render(session)).toContain('role="img"'); - }); - - it("suppresses the timeline bar for a SYNTHESISED (reconstructed) night", () => { - // WHOOP / Polar report per-stage TOTALS and the server lays the segments - // out in a fixed physiological order — the timeline would invent a phase - // progression that was never measured. So even with distinct, well-timed - // spans, a `reconstructed` night drops the bar and keeps the breakdown. - const session: SleepHypnogramSession = { - ...baseSession, - source: "WHOOP", - reconstructed: true, - }; - const html = render(session); - expect(html).not.toContain('role="img"'); - expect(html).toContain('data-slot="sleep-hypnogram-breakdown"'); - expect(html).toContain('data-slot="sleep-hypnogram-estimate-note"'); - }); - - it("keeps the timeline bar for a measured (non-reconstructed) night", () => { - // An Apple Health night carries real per-stage onsets, so the stepped bar - // is genuine and stays. The estimate note never appears. - const html = render({ ...baseSession, reconstructed: false }); - expect(html).toContain('role="img"'); - expect(html).not.toContain('data-slot="sleep-hypnogram-estimate-note"'); - }); - - it("collapses to the breakdown footer for a degenerate single-instant session", () => { - // Every segment stamped on ONE instant — no real timeline, so only the - // breakdown footer, no bar. - const instant = "2026-06-09T06:00:00.000Z"; - const session: SleepHypnogramSession = { - ...baseSession, - segments: [ - { stage: "CORE", start: instant, end: instant, minutes: 200 }, - { stage: "DEEP", start: instant, end: instant, minutes: 100 }, - ], - }; - const html = render(session); - expect(html).not.toContain('role="img"'); - expect(html).toContain('data-slot="sleep-hypnogram-breakdown"'); - }); -}); - -describe(" — source discrepancy marker", () => { - const discrepancy = { - deltaMinutes: 160, - sources: [ - { source: "WITHINGS", deviceType: null, asleepMinutes: 615 }, - { source: "GOOGLE_HEALTH", deviceType: null, asleepMinutes: 455 }, - ], - }; - - it("renders the discreet marker when the server flags disagreeing sources", () => { - const html = render({ ...baseSession, sourceDiscrepancy: discrepancy }); - expect(html).toContain('data-slot="sleep-source-discrepancy"'); - // Muted marker, no colour wash — the trigger stays on the muted tier. - expect(html).toContain("Sources disagree"); - }); - - it("omits the marker when the night carries no discrepancy", () => { - expect(render(baseSession)).not.toContain( - 'data-slot="sleep-source-discrepancy"', - ); - expect(render({ ...baseSession, sourceDiscrepancy: null })).not.toContain( - 'data-slot="sleep-source-discrepancy"', - ); - }); -}); diff --git a/src/components/insights/coach-panel/__tests__/coach-launch-scope.test.ts b/src/components/insights/coach-panel/__tests__/coach-launch-scope.test.ts index 5df370787..90959e2fb 100644 --- a/src/components/insights/coach-panel/__tests__/coach-launch-scope.test.ts +++ b/src/components/insights/coach-panel/__tests__/coach-launch-scope.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { launchScopeToCoachScope } from "../coach-conversation"; +import { + dropInvalidStagedAttachments, + launchScopeToCoachScope, +} from "../coach-conversation"; import { metricScopeFromExplainer, scopeSourceFromMetricKey, @@ -61,6 +64,30 @@ describe("metricScopeFromExplainer", () => { }); }); +describe("dropInvalidStagedAttachments (v1.29.x — bad ?doc= deep-link)", () => { + it("returns an empty list when nothing errored", () => { + expect( + dropInvalidStagedAttachments(["doc-1", "doc-2"], [false, false]), + ).toEqual([]); + }); + + it("returns the id whose detail fetch settled into an error (404)", () => { + expect(dropInvalidStagedAttachments(["bad-doc"], [true])).toEqual([ + "bad-doc", + ]); + }); + + it("only drops the errored id, keeping a still-resolving/valid sibling staged", () => { + expect( + dropInvalidStagedAttachments(["good-doc", "bad-doc"], [false, true]), + ).toEqual(["bad-doc"]); + }); + + it("returns an empty list for an empty staged set", () => { + expect(dropInvalidStagedAttachments([], [])).toEqual([]); + }); +}); + describe("scopeSourceFromMetricKey", () => { it("maps the model's snapshot-key vocabulary to a scope source", () => { expect(scopeSourceFromMetricKey("bloodPressure")).toBe("bp"); diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index 4eddfbb7b..629046bc0 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -102,6 +102,24 @@ export function launchScopeToCoachScope( }; } +/** + * v1.29.x — a stale/invalid `?doc=` deep-link (a deleted document, a + * mistyped id, someone else's document) 404s the staged detail fetch + * forever, which used to trap every send behind a phantom "untitled" + * attachment that could never resolve (the fenced gate never clears). + * Pure so the drop decision is unit-testable without mounting the + * component: given the currently staged ids and which of their detail + * fetches settled into an error (index-aligned with `pendingAttachmentIds`, + * matching `useQueries`' `isError`), returns the ids that should fall out of + * staging. A bad link degrades to a normal chat instead of a dead end. + */ +export function dropInvalidStagedAttachments( + pendingAttachmentIds: string[], + isErrorByIndex: boolean[], +): string[] { + return pendingAttachmentIds.filter((_, i) => isErrorByIndex[i] === true); +} + /** * v1.21.4 (C2) — the localStorage key that records the seeded "worth a look" * opener as dismissed for a given LOCAL calendar day. Date-stamped so the @@ -358,6 +376,43 @@ export function CoachConversation({ return d?.title ?? d?.filename ?? t("documents.card.untitled"); }; + // v1.29.x — a stale/invalid `?doc=` deep-link (a deleted document, a + // mistyped id, someone else's document) 404s the detail fetch above + // forever. `refetchInterval` reads `false` once a query has no data, so the + // fetch never retries on its own — `isStagedIndexed` stayed permanently + // false and the fenced send-gate (`fenced && anyPendingUnindexed` below) + // trapped every message behind a phantom "untitled" attachment that could + // never resolve. Drop a staged id the moment its detail fetch settles into + // an error instead: the doc-scope clears, the banner disappears, and the + // surface degrades to a normal chat rather than a dead end. + // + // The drop itself runs in the render-phase-update pattern (mirrors + // `autoOpenResolved` below) rather than an effect: `react-hooks/set-state- + // in-effect` rejects a setState call inside an effect whose source is a + // query result. It is self-limiting without a ref guard: once an id drops + // out of `pendingAttachmentIds`, React's render-phase-update loop + // re-renders BEFORE committing, and on that re-render the id is no longer + // in `pendingAttachmentIds` at all, so `dropInvalidStagedAttachments` can + // never surface it again. The toast is the one genuine side effect and + // stays in its own effect (no setState inside it), keyed on the ids just + // dropped so it fires exactly once per distinct drop. + const invalidStagedIds = dropInvalidStagedAttachments( + pendingAttachmentIds, + stagedDetailResults.map((q) => q.isError === true), + ); + const [invalidToastKey, setInvalidToastKey] = useState(""); + if (invalidStagedIds.length > 0) { + setPendingAttachmentIds((prev) => + prev.filter((id) => !invalidStagedIds.includes(id)), + ); + const key = invalidStagedIds.join(","); + if (key !== invalidToastKey) setInvalidToastKey(key); + } + useEffect(() => { + if (!invalidToastKey) return; + toast.error(t("insights.coach.attach.notFoundError")); + }, [invalidToastKey, t]); + // The pills row above the composer. On a fresh chat every pill is a staged id; // on an existing conversation the server attachments are the live pills and any // staged uploads (still indexing) render alongside them. diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx index 57aa57ddf..ec0e39478 100644 --- a/src/components/insights/derived/vitals-dashboard.tsx +++ b/src/components/insights/derived/vitals-dashboard.tsx @@ -16,7 +16,7 @@ import { SectionHeading } from "@/components/insights/section-heading"; import { SparklineDeltaTile } from "./sparkline-delta-tile"; import { LearnMoreLink } from "@/components/ui/learn-more-link"; import { CoverageMeter } from "./coverage-meter"; -import { VitalsInfoTip } from "./vitals-info-tip"; +import { InfoPopover } from "@/components/ui/info-popover"; import { METRIC_PROVENANCE } from "./standards"; import { type DerivedBatchRead } from "./use-derived-metric"; import { @@ -171,11 +171,11 @@ function tileOrder(layout: InsightsLayout | undefined, tileId: string): number { /** * The (i) info-tip trigger for one derived metric, wired from the map. - * `provenance` (inputs / source / window / asOf) isn't rendered here — - * `VitalsInfoTip` carries only the method/caveat + cited standard, mirroring - * what `ProvenanceExplainer` itself renders today — but the param is kept so - * every call site (which already reads `data.provenance` off the derived - * batch) doesn't need touching if a future pass restores the detail. + * `provenance` (inputs / source / window / asOf) isn't rendered here — the + * shared `InfoPopover` carries only the method/caveat + cited standard, + * mirroring what `ProvenanceExplainer` itself renders today — but the param + * is kept so every call site (which already reads `data.provenance` off the + * derived batch) doesn't need touching if a future pass restores the detail. */ function MetricProvenance({ metric, @@ -195,7 +195,13 @@ function MetricProvenance({ {t(meta.methodKey)} ); - return ; + return ( + + ); } interface TileProps { @@ -291,7 +297,7 @@ function BaselineTile({ // v1.29.1 muted this on VITALS_BASELINE tiles because the always-on // "typical range = median ± MAD" caption repeated across every vital // baseline tile and read as clutter. v1.29.2 restores the context — - // now behind the compact (i) trigger (`VitalsInfoTip`) instead of a + // now behind the compact (i) trigger (`InfoPopover`) instead of a // full-width caption, so every baseline tile carries it again // without the repetition or the header-row squeeze that clipped the // cardio-fitness heading. diff --git a/src/components/insights/derived/vitals-info-tip.tsx b/src/components/insights/derived/vitals-info-tip.tsx deleted file mode 100644 index 753ab3fbc..000000000 --- a/src/components/insights/derived/vitals-info-tip.tsx +++ /dev/null @@ -1,90 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { Info } from "lucide-react"; - -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { useTranslations } from "@/lib/i18n/context"; -import { cn } from "@/lib/utils"; -import type { ProvenanceStandard } from "./provenance-explainer"; - -/** - * v1.29.2 QoL — the vitals-dashboard tile's (i) info affordance. - * - * v1.29.1 dropped the always-on "your personal normal range is the - * median…" method caption from every `VITALS_BASELINE` tile because it - * repeated verbatim across up to six tiles and read as clutter at the top - * of the vitals surface — and, on the narrower tiles, squeezed the heading - * itself (the VO₂max/cardio-fitness clip). Rather than reintroduce the - * caption at full length, the SAME method/caveat/standard content moves - * behind a small (i) trigger: a click-opened `Popover` (not a hover - * tooltip — the vitals grid sees most of its use on touch), so the context - * is one tap away instead of permanently occupying header space. - * - * This is a DELIBERATE, narrowly-scoped exception to the v1.22 "no - * icon-only disclosure" direction (`ProvenanceExplainer`'s own docstring): - * that decision targeted the always-visible-caption pattern on OTHER - * surfaces (coincident-deviation, the score-anatomy pages), which stay - * unchanged. The vitals grid is dense enough, and the caption repetitive - * enough, that the same content is better one tap away here. - */ -export interface VitalsInfoTipProps { - /** Plain-language method (+ optional caveat) description — text children only. */ - method: React.ReactNode; - /** Optional cited standard, rendered as a plain external link. */ - standard?: ProvenanceStandard; - className?: string; -} - -export function VitalsInfoTip({ - method, - standard, - className, -}: VitalsInfoTipProps) { - const { t } = useTranslations(); - const [open, setOpen] = useState(false); - - return ( - - - - - -

{method}

- {standard ? ( - - {standard.name} - - ) : null} -
-
- ); -} diff --git a/src/components/insights/glucose/glucose-advanced-disclosure.tsx b/src/components/insights/glucose/glucose-advanced-disclosure.tsx index 4392173f2..fd725cf9f 100644 --- a/src/components/insights/glucose/glucose-advanced-disclosure.tsx +++ b/src/components/insights/glucose/glucose-advanced-disclosure.tsx @@ -3,7 +3,7 @@ import { useId, useState } from "react"; import { ChevronDown } from "lucide-react"; -import { InfoHint } from "@/components/ui/info-hint"; +import { InfoPopover } from "@/components/ui/info-popover"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; @@ -149,7 +149,7 @@ function AdvancedStat({
{label} - +
{value} diff --git a/src/components/insights/glucose/glucose-clinical-panel.tsx b/src/components/insights/glucose/glucose-clinical-panel.tsx index 6a0214972..fe55b5580 100644 --- a/src/components/insights/glucose/glucose-clinical-panel.tsx +++ b/src/components/insights/glucose/glucose-clinical-panel.tsx @@ -6,7 +6,7 @@ import { Activity, Droplet, FlaskConical, TrendingUp } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { TileHeader } from "@/components/insights/tile-header"; -import { InfoHint } from "@/components/ui/info-hint"; +import { InfoPopover } from "@/components/ui/info-popover"; import { LearningGate } from "@/components/ui/learning-gate"; import { LearnMoreLink } from "@/components/ui/learn-more-link"; import { Skeleton } from "@/components/ui/skeleton"; @@ -198,7 +198,9 @@ export function GlucoseClinicalPanel() {
{t("insights.bloodGlucose.clinical.cv.label")} - +
@@ -285,7 +287,7 @@ function Metric({ {icon} {label} - {help ? : null} + {help ? : null} {value} diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx index c4e34fa26..c5ad3679d 100644 --- a/src/components/insights/insights-layout-shell.tsx +++ b/src/components/insights/insights-layout-shell.tsx @@ -106,20 +106,15 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) { // dashboard tile so navigation between surfaces is a free cache hit. const workoutsProbe = useWorkouts({ limit: 1 }); - // v1.29 — nutrients-presence gate. The nutrients pill gates on at - // least one `NutrientIntakeDay` row in a 1-day probe window (any - // nutrient, any source). Enabled only when the opt-in module is on — - // the route 403s "module.disabled" otherwise, so probing while off - // would just burn a request every mount. + // v1.29 — nutrients gate. `nutrients` is opt-in (default off), unlike + // the always-on metric clusters, so the pill's floor is the module + // toggle alone — not a data-presence probe. Gating on BOTH the toggle + // AND a first row was a catch-22: the in-context enable CTA lives on + // `/insights/nutrients` itself, and that page was only reachable once + // data already existed. A user who just flipped the module on (or + // wants to) can now always reach the page; the page itself still + // degrades from "enable" → "no rows yet" → the three-card spine. const nutrientsEnabled = user?.modules?.nutrients === true; - const nutrientsProbe = useQuery({ - queryKey: queryKeys.nutrientIntake(1), - queryFn: () => - apiGet<{ nutrients: Array<{ nutrient: string }> }>( - "/api/nutrients?days=1", - ), - enabled: isAuthenticated && nutrientsEnabled, - }); // v1.4.31 — memoise the `availability` prop so an unchanged // payload doesn't recreate the object on every cache-write of @@ -132,7 +127,7 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) { const hasMood = (comprehensiveQuery.data?.moodSummary?.count ?? 0) > 0; const hasMedication = (comprehensiveQuery.data?.medications?.length ?? 0) > 0; const hasWorkouts = (workoutsProbe.data?.workouts.length ?? 0) > 0; - const hasNutrients = (nutrientsProbe.data?.nutrients.length ?? 0) > 0; + const hasNutrients = nutrientsEnabled; const availability: InsightInputs | undefined = useMemo(() => { if (!isAuthenticated) return undefined; return { summaries, hasMood, hasMedication, hasWorkouts, hasNutrients }; diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index 34339ce0b..d7f4b6f61 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -23,6 +23,7 @@ import { cn } from "@/lib/utils"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; import { INSIGHTS_OVERVIEW_PATH, + PINNED_SUB_PAGE_SLUGS, SUB_PAGE_GROUP, SUB_PAGE_GROUP_ORDER, SUB_PAGE_SLUGS, @@ -366,8 +367,10 @@ const SUB_PAGE_MODULE: Partial> = { "breathing-disturbances": "sleep", "blood-glucose": "glucose", workouts: "workouts", - // v1.29 — the nutrients pill gates on the opt-in `nutrients` module on - // top of the `hasNutrients` data floor. + // v1.29 — the nutrients pill gates on the opt-in `nutrients` module + // alone; there is no data floor on top (see `hasNutrients` in + // `insights-layout-shell.tsx` — reachability, not row count, is the + // gate once the user has opted in). nutrients: "nutrients", }; @@ -393,10 +396,12 @@ function isSlugModuleEnabled( /** * v1.4.34 IW-D — group metadata (label + popover header) keyed by * `SubPageGroup`. The strip renders one parent pill per group. v1.7.0 - * adds the body / activity / cardiovascular / hearing / environment / - * metabolic clusters as the previously-orphan metrics each get a - * sub-page; collapsing them behind a parent pill keeps the strip - * scannable. + * adds the body / activity / hearing / environment / metabolic clusters + * as the previously-orphan metrics each get a sub-page; collapsing them + * behind a parent pill keeps the strip scannable. The 2026-07-17 UX/IA + * audit (H2) replaced the old "Cardiovascular" group — which only ever + * held three rare Withings markers — with a unified "Heart" group that + * also carries pulse, resting HR, HRV and cardio recovery. */ const SUB_PAGE_GROUP_META: Record< SubPageGroup, @@ -414,9 +419,9 @@ const SUB_PAGE_GROUP_META: Record< labelKey: "insights.tabStrip.activityParent.label", headerKey: "insights.tabStrip.activityParent.header", }, - cardiovascular: { - labelKey: "insights.tabStrip.cardiovascularParent.label", - headerKey: "insights.tabStrip.cardiovascularParent.header", + heart: { + labelKey: "insights.tabStrip.heartParent.label", + headerKey: "insights.tabStrip.heartParent.header", }, hearing: { labelKey: "insights.tabStrip.hearingParent.label", @@ -515,7 +520,13 @@ function buildTabs( const emittedGroups = new Set(); for (const slug of orderedSlugs) { - const group = SUB_PAGE_GROUP[slug]; + // 2026-07-17 UX/IA audit (H2/M1) — a pinned slug always renders as + // its own flat pill, even if it carries a `SUB_PAGE_GROUP` entry (for + // manager/catalog purposes). It is also excluded below from its + // group's popover children so it never appears twice. + const group = PINNED_SUB_PAGE_SLUGS.has(slug) + ? undefined + : SUB_PAGE_GROUP[slug]; if (group) { if (emittedGroups.has(group)) continue; emittedGroups.add(group); @@ -527,7 +538,10 @@ function buildTabs( ) : SUB_PAGE_GROUP_ORDER[group]; const children = orderedChildren - .filter((childSlug) => visibleSet.has(childSlug)) + .filter( + (childSlug) => + visibleSet.has(childSlug) && !PINNED_SUB_PAGE_SLUGS.has(childSlug), + ) .map((childSlug) => ({ slug: childSlug, href: `${INSIGHTS_OVERVIEW_PATH}/${childSlug}`, @@ -549,6 +563,19 @@ function buildTabs( labelKey: SUB_PAGE_TABS[slug].labelKey, }); } + + // 2026-07-17 discoverability audit (R1) — a quiet, always-present pill + // trailing every other pill, linking to the "what can HealthLog track" + // catalog. Unlike every metric pill above, it carries no availability + // gate (like Overview / Recovery): the catalog's whole purpose is + // listing the metrics that AREN'T visible elsewhere yet, so gating its + // own entry point on data would defeat it. + entries.push({ + kind: "link", + href: `${INSIGHTS_OVERVIEW_PATH}/catalog`, + labelKey: "insights.navCatalog", + }); + return entries; } diff --git a/src/components/insights/intraday-pulse-chart.tsx b/src/components/insights/intraday-pulse-chart.tsx index 563d3edbd..e9a29f0d1 100644 --- a/src/components/insights/intraday-pulse-chart.tsx +++ b/src/components/insights/intraday-pulse-chart.tsx @@ -22,10 +22,12 @@ import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { userDayKey, shiftDateKey } from "@/lib/tz/format"; import { TileHeader } from "@/components/insights/tile-header"; import { Button } from "@/components/ui/button"; -import type { - IntradayHrBucket, - IntradayResolution, - TensionWindow, +import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { + BUCKET_MINUTES, + type IntradayHrBucket, + type IntradayResolution, + type TensionWindow, } from "@/lib/analytics/intraday-pulse"; /** Slim DTO the intraday route returns (type-only — no server code bundled). */ @@ -57,13 +59,23 @@ const AXIS_TICKS = [0, 360, 720, 1080, 1440]; * and non-diagnostic — "possible tension", never a stress score. Empty / no- * baseline days render an honest one-liner rather than an empty grid. * + * The area never interpolates across a gap of missing buckets — `chartData` + * is a full-day grid with `null` for every absent bucket and the `Area` + * renders with `connectNulls={false}`, so a handful of spot readings shows + * as isolated points rather than a fabricated smooth curve. Whenever that + * produces a visible break, a small "based on N readings across M hours" + * line discloses the coverage so the shape doesn't read as more continuous + * than it is. + * * v1.29.x — a day navigator (`TileHeader`'s `right` slot, per UI-STANDARDS * §11) pages the same route backward through prior days; "next" is disabled * at today, so the view can never step into the future. A day outside the * dense retention window (`DENSE_INTRADAY_RETENTION_DAYS`) reads back at the * coarser hourly grain instead of an empty chart — `resolution` on the DTO * tells the caption which, and the tension read is always absent on an - * hourly day (it needs per-sample resolution to stay honest). + * hourly day (it needs per-sample resolution to stay honest). A failed fetch + * renders `` with a retry action instead of falling through + * to the empty-day copy. */ export function IntradayPulseChart({ userTimezone, @@ -91,7 +103,7 @@ export function IntradayPulseChart({ setSelectedDateKey(next === todayKey ? null : next); }; - const { data, isLoading } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.insightsPulseIntraday(dateKey), queryFn: () => apiGet(`/api/insights/pulse/intraday?date=${dateKey}`), @@ -100,15 +112,51 @@ export function IntradayPulseChart({ refetchOnWindowFocus: false, }); - const chartData = useMemo( - () => - (data?.series ?? []).map((b) => ({ - minute: b.startMinute, - label: minuteLabel(b.startMinute), - bpm: b.mean, - })), - [data?.series], - ); + const bucketMinutes = data?.bucketMinutes ?? BUCKET_MINUTES; + + // A handful of spot readings scattered across the day must never read as + // a smooth, continuous curve — that fabricates a shape the data doesn't + // support. Building one grid point per possible bucket (present buckets + // carry their mean, absent ones are `null`) lets `connectNulls={false}` + // below break the area over any missing bucket instead of interpolating + // across it, so only genuinely adjacent readings connect. + const chartData = useMemo(() => { + const series = data?.series; + if (!series || series.length === 0) return []; + const byMinute = new Map(series.map((b) => [b.startMinute, b.mean])); + const points: Array<{ minute: number; label: string; bpm: number | null }> = + []; + for (let minute = 0; minute < 1440; minute += bucketMinutes) { + points.push({ + minute, + label: minuteLabel(minute), + bpm: byMinute.get(minute) ?? null, + }); + } + return points; + }, [data?.series, bucketMinutes]); + + // The coverage disclosure only fires when the chart actually contains a + // break — a fully contiguous day already reads honestly without it. Whole + // hours (not fractional) keep the copy simple across locales. + const coverage = useMemo(() => { + const series = data?.series; + if (!series || series.length < 2) return null; + const sorted = [...series].sort((a, b) => a.startMinute - b.startMinute); + let hasGap = false; + for (let i = 1; i < sorted.length; i++) { + if (sorted[i].startMinute - sorted[i - 1].startMinute > bucketMinutes) { + hasGap = true; + break; + } + } + if (!hasGap) return null; + const readingCount = sorted.reduce((sum, b) => sum + b.count, 0); + const first = sorted[0].startMinute; + const last = sorted[sorted.length - 1].startMinute + bucketMinutes; + const hours = Math.max(1, Math.round((last - first) / 60)); + return { readingCount, hours }; + }, [data?.series, bucketMinutes]); const caption = data?.tension ? t(`insights.intradayPulse.tension.${data.tension.partOfDay}`) @@ -116,9 +164,18 @@ export function IntradayPulseChart({ ? t("insights.intradayPulse.hourlyNote") : t("insights.intradayPulse.caption"); + const coverageNote = coverage + ? coverage.readingCount === 1 + ? t("insights.intradayPulse.coverageOne", { hours: coverage.hours }) + : t("insights.intradayPulse.coverageMany", { + count: coverage.readingCount, + hours: coverage.hours, + }) + : null; + const dayLabel = isToday ? t("insights.intradayPulse.today") - : fmt.dateShort(new Date(`${dateKey}T12:00:00.000Z`)); + : fmt.dateShortSmart(new Date(`${dateKey}T12:00:00.000Z`)); return (

{caption}

+ {coverageNote ? ( +

+ {coverageNote} +

+ ) : null} {isLoading ? (
+ ) : isError ? ( + refetch()} + className="border-0 bg-transparent shadow-none" + /> ) : chartData.length === 0 ? (
{t("insights.intradayPulse.empty")} @@ -254,15 +324,22 @@ export function IntradayPulseChart({ }} /> ) : null} + {/* `connectNulls={false}` (the default, spelled out here for + intent) is the honesty fix: `chartData` carries one point + per possible bucket, `null` where no reading exists, so a + gap of missing buckets breaks the line/fill instead of + interpolating a shape across hours of silence. `dot` + keeps an isolated reading visible as a point even where + it has no neighbour to connect to. */} diff --git a/src/components/insights/mood/__tests__/mood-tag-bars-muted.test.tsx b/src/components/insights/mood/__tests__/mood-tag-bars-muted.test.tsx index 8fef436b1..2b48239b4 100644 --- a/src/components/insights/mood/__tests__/mood-tag-bars-muted.test.tsx +++ b/src/components/insights/mood/__tests__/mood-tag-bars-muted.test.tsx @@ -34,9 +34,10 @@ describe("mood tag bars — full-saturation palette (v1.19.0)", () => { for (const fill of fills ?? []) { expect(fill).not.toContain("opacity-55"); } - // The level hue itself stays a Dracula token. - expect(html).toContain("var(--dracula-red)"); - expect(html).toContain("var(--dracula-green)"); + // The level hue itself stays a token — semantic since v1.28.17 (was a + // raw `--dracula-*` primitive; see mood-tag-breakdown.tsx). + expect(html).toContain("var(--destructive)"); + expect(html).toContain("var(--success)"); }); it("structured tag breakdown bars render at full opacity", () => { diff --git a/src/components/insights/mood/mood-correlation-cards.tsx b/src/components/insights/mood/mood-correlation-cards.tsx index 062f9fa1d..b7add85ca 100644 --- a/src/components/insights/mood/mood-correlation-cards.tsx +++ b/src/components/insights/mood/mood-correlation-cards.tsx @@ -66,12 +66,16 @@ const Y_LABEL_KEY: Record = { bloodPressure: "insights.mood.correlation.bloodPressureAxis", }; +// v1.28.17 — the shared `--chart-1..5` vocabulary (same values as the +// `--dracula-purple/cyan/pink/green/orange` primitives it replaces, per +// theme), not raw palette tokens — matches the `no-raw-palette-color` +// discipline and the app's other chart-series colouring. const FILL_BY_KIND: Record = { - sleep: "var(--dracula-purple)", - steps: "var(--dracula-cyan)", - pulse: "var(--dracula-pink)", - weight: "var(--dracula-green)", - bloodPressure: "var(--dracula-orange)", + sleep: "var(--chart-1)", + steps: "var(--chart-4)", + pulse: "var(--chart-3)", + weight: "var(--chart-2)", + bloodPressure: "var(--chart-5)", }; const STRENGTH_KEY: Record = { diff --git a/src/components/insights/mood/mood-distribution-chart.tsx b/src/components/insights/mood/mood-distribution-chart.tsx index 6074baf80..c710db552 100644 --- a/src/components/insights/mood/mood-distribution-chart.tsx +++ b/src/components/insights/mood/mood-distribution-chart.tsx @@ -28,12 +28,17 @@ export interface MoodDistributionRow { count: number; } +// v1.28.17 — semantic tokens, not raw `--dracula-*` primitives: every one of +// these already carries an AA-tuned Alucard (light-theme) override, but +// routing through the meaning-bearing alias (destructive/warning/yellow/ +// success) keeps this map legible as a severity ladder rather than an +// arbitrary palette pick, matching the `no-raw-palette-color` discipline. const BAR_COLOR_BY_SCORE: Record = { - 1: "var(--dracula-red)", - 2: "var(--dracula-orange)", + 1: "var(--destructive)", + 2: "var(--warning)", 3: "var(--dracula-yellow)", - 4: "var(--dracula-green)", - 5: "var(--dracula-green)", + 4: "var(--success)", + 5: "var(--success)", }; export function MoodDistributionChart({ @@ -68,8 +73,8 @@ export function MoodDistributionChart({ // not overridden in `:root.light` (Alucard white card) → white-on- // white. `--muted-foreground` is the token every other in-app chart // uses for axis ticks and is legible in both themes; dark mode is - // unchanged. The mood-hue bar Cells stay on `--dracula-*` — only the - // axis text legibility changes here. + // unchanged. The mood-hue bar Cells route through semantic tokens + // (see `BAR_COLOR_BY_SCORE`), AA-tuned per theme too. tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} stroke="var(--muted-foreground)" interval={0} @@ -80,8 +85,8 @@ export function MoodDistributionChart({ // not overridden in `:root.light` (Alucard white card) → white-on- // white. `--muted-foreground` is the token every other in-app chart // uses for axis ticks and is legible in both themes; dark mode is - // unchanged. The mood-hue bar Cells stay on `--dracula-*` — only the - // axis text legibility changes here. + // unchanged. The mood-hue bar Cells route through semantic tokens + // (see `BAR_COLOR_BY_SCORE`), AA-tuned per theme too. tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} stroke="var(--muted-foreground)" width={28} @@ -117,7 +122,7 @@ export function MoodDistributionChart({ {data.map((row) => ( ))} diff --git a/src/components/insights/mood/mood-tag-breakdown.tsx b/src/components/insights/mood/mood-tag-breakdown.tsx index 763548820..2e4c3d5bc 100644 --- a/src/components/insights/mood/mood-tag-breakdown.tsx +++ b/src/components/insights/mood/mood-tag-breakdown.tsx @@ -19,11 +19,13 @@ export interface MoodTagRow { avgScore: number; } +// v1.28.17 — semantic tokens, not raw `--dracula-*` primitives (see +// mood-distribution-chart's `BAR_COLOR_BY_SCORE` for the same swap). function colorForScore(score: number): string { - if (score < 2) return "var(--dracula-red)"; - if (score < 3) return "var(--dracula-orange)"; + if (score < 2) return "var(--destructive)"; + if (score < 3) return "var(--warning)"; if (score < 3.5) return "var(--dracula-yellow)"; - return "var(--dracula-green)"; + return "var(--success)"; } export function MoodTagBreakdown({ tags }: { tags: MoodTagRow[] }) { diff --git a/src/components/insights/mood/mood-time-of-day-chart.tsx b/src/components/insights/mood/mood-time-of-day-chart.tsx index 481f25aa4..e1f96541a 100644 --- a/src/components/insights/mood/mood-time-of-day-chart.tsx +++ b/src/components/insights/mood/mood-time-of-day-chart.tsx @@ -47,11 +47,13 @@ const LABEL_KEYS: Record = { night: "insights.mood.timeOfDay.night", }; +// v1.28.17 — semantic tokens, not raw `--dracula-*` primitives (see +// mood-distribution-chart's `BAR_COLOR_BY_SCORE` for the same swap). function colorForScore(score: number): string { - if (score < 2) return "var(--dracula-red)"; - if (score < 3) return "var(--dracula-orange)"; + if (score < 2) return "var(--destructive)"; + if (score < 3) return "var(--warning)"; if (score < 3.5) return "var(--dracula-yellow)"; - return "var(--dracula-green)"; + return "var(--success)"; } export function MoodTimeOfDayChart({ diff --git a/src/components/insights/mood/mood-weekday-chart.tsx b/src/components/insights/mood/mood-weekday-chart.tsx index 3f72af6b4..a0d69225f 100644 --- a/src/components/insights/mood/mood-weekday-chart.tsx +++ b/src/components/insights/mood/mood-weekday-chart.tsx @@ -39,11 +39,13 @@ const WEEKDAY_LABEL_KEYS = [ "charts.weekdaysFull.sun", ] as const; +// v1.28.17 — semantic tokens, not raw `--dracula-*` primitives (see +// mood-distribution-chart's `BAR_COLOR_BY_SCORE` for the same swap). function colorForScore(score: number): string { - if (score < 2) return "var(--dracula-red)"; - if (score < 3) return "var(--dracula-orange)"; + if (score < 2) return "var(--destructive)"; + if (score < 3) return "var(--warning)"; if (score < 3.5) return "var(--dracula-yellow)"; - return "var(--dracula-green)"; + return "var(--success)"; } export function MoodWeekdayChart({ weekday }: { weekday: MoodWeekdayRow[] }) { @@ -88,7 +90,8 @@ export function MoodWeekdayChart({ weekday }: { weekday: MoodWeekdayRow[] }) { // v1.15.14 — theme-aware axis text (see mood-distribution-chart). // `--dracula-fg` was white-on-white on the light-mode card; switch // to the shared `--muted-foreground` axis-tick token. Bar mood - // hues stay `--dracula-*`; dark mode is unchanged. + // hues route through semantic tokens (see `colorForScore`); + // dark mode is unchanged. tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} stroke="var(--muted-foreground)" interval={0} @@ -99,7 +102,8 @@ export function MoodWeekdayChart({ weekday }: { weekday: MoodWeekdayRow[] }) { // v1.15.14 — theme-aware axis text (see mood-distribution-chart). // `--dracula-fg` was white-on-white on the light-mode card; switch // to the shared `--muted-foreground` axis-tick token. Bar mood - // hues stay `--dracula-*`; dark mode is unchanged. + // hues route through semantic tokens (see `colorForScore`); + // dark mode is unchanged. tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} stroke="var(--muted-foreground)" width={24} diff --git a/src/components/insights/nutrients/water-quick-add-sheet.tsx b/src/components/insights/nutrients/water-quick-add-sheet.tsx index 2b60136cd..78396e516 100644 --- a/src/components/insights/nutrients/water-quick-add-sheet.tsx +++ b/src/components/insights/nutrients/water-quick-add-sheet.tsx @@ -10,7 +10,7 @@ import { Label } from "@/components/ui/label"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { useTranslations } from "@/lib/i18n/context"; import { apiPost } from "@/lib/api/api-fetch"; -import { queryKeys } from "@/lib/query-keys"; +import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys"; /** * v1.29 — manual water quick-add sheet, opened from the hydration card. @@ -45,11 +45,21 @@ export function WaterQuickAddSheet({ const [editMode, setEditMode] = useState(false); const [editAmount, setEditAmount] = useState(() => String(todayTotalMl)); + // v1.29.x — the Today digest joins the invalidation. The write route + // already hard-evicts the server snapshot bucket + // (`invalidateUserDashboardSnapshot`), but the client digest/snapshot + // queries are typically unmounted while this sheet is open (the user is + // on `/insights/nutrients`, not the dashboard), so a default ("active") + // invalidation would mark them stale without refetching them — + // `refetchInactiveDailyReads` forces the inactive refetch, mirroring the + // measurement / mood / medication fix. const invalidate = () => { void queryClient.invalidateQueries({ queryKey: ["nutrients"] }); void queryClient.invalidateQueries({ queryKey: queryKeys.dashboardSnapshot(), }); + void queryClient.invalidateQueries({ queryKey: queryKeys.dailyDigest() }); + void refetchInactiveDailyReads(queryClient); }; const addMutation = useMutation({ diff --git a/src/components/insights/sleep-hypnogram.tsx b/src/components/insights/sleep-hypnogram.tsx deleted file mode 100644 index ca6f6f353..000000000 --- a/src/components/insights/sleep-hypnogram.tsx +++ /dev/null @@ -1,417 +0,0 @@ -"use client"; - -import { useMemo } from "react"; -import { - CartesianGrid, - ComposedChart, - ReferenceArea, - ResponsiveContainer, - XAxis, - YAxis, -} from "recharts"; - -import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { TileHeader } from "@/components/insights/tile-header"; -import { - SleepSourceDiscrepancyMarker, - formatMinutes, - type SleepSourceDiscrepancyDto, -} from "@/components/insights/sleep-source-discrepancy-marker"; -import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context"; -import { hourCycleOptions } from "@/lib/format-locale"; -import { STAGE_COLORS } from "./sleep-stage-stacked-bar"; - -/** - * v1.11.5 — last-night HYPNOGRAM (the maintainer directive: the phase-progression - * view). Renders one night's stage timeline as a stepped depth track — - * Awake at the top, Deep at the base — over clock time, in the Dracula - * stage palette reused from `sleep-stage-stacked-bar.tsx` (no token - * reshuffle, per the charts-visual-identity rule). Below the chart: the - * per-stage breakdown (time + %), total asleep, time in bed, and the - * mid-sleep awakenings count. - * - * One canonical source per night (the endpoint already collapsed it), so - * exactly one timeline renders — Apple ↔ WHOOP are never overlaid. - * - * Recharts stays (project rule). Each stage segment is drawn as its own - * `ReferenceArea` rectangle spanning the segment's [start, end] clock range - * at the stage's depth lane, filled with the segment's `STAGE_COLORS` token - * (the same palette the breakdown legend below uses) — so the chart reads as - * a coloured "cityscape" rather than one flat single-colour line. Depth still - * increases downward (Awake top, Deep base). - * - * v1.11.5 — the chart renders only when per-segment `segments` exist - * (`hasTrack`); the per-stage breakdown + totals footer render whenever - * `session.stages` carries any positive value, so a legacy stage-summary - * night (no segment spans) still shows its breakdown instead of collapsing - * the whole card to "unavailable". - */ - -export interface SleepHypnogramSegment { - stage: string | null; - start: string; - end: string; - minutes: number; -} - -export interface SleepHypnogramSession { - night: string; - source: string | null; - start: string; - end: string; - asleepMinutes: number; - inBedMinutes: number | null; - awakeMinutes: number | null; - awakenings: number; - stages: Record; - segments: SleepHypnogramSegment[]; - /** - * `true` when the winning source has no measured per-stage onset - * timestamps and the server synthesised a contiguous timeline in a fixed - * physiological order (WHOOP / Polar). The "timeline" of such a night is - * not a real hypnogram — it would read as one long Core block followed by - * one Deep block, never the phase-to-phase progression a measured night - * shows — so we never draw the misleading stepped bar for it and fall back - * to the per-stage breakdown alone. A real-series source (Apple Health, - * Withings, Fitbit) is `false` and keeps the timeline. - */ - reconstructed?: boolean; - /** - * Non-null when two writer buckets reported clearly different asleep - * totals for this night (server-computed, observational only — the - * headline total stays the winning writer's). Renders as a discreet - * marker with a per-source breakdown in a tooltip. - */ - sourceDiscrepancy?: SleepSourceDiscrepancyDto | null; -} - -export interface SleepHypnogramProps { - session: SleepHypnogramSession; -} - -/** - * Lane order top → bottom. Awake highest, Deep lowest — depth increases - * downward, matching Apple Health / Oura's "cityscape" convention. Only - * the stages actually present in the night's spans get a lane (the - * order is preserved) — painting all six lanes left an Apple night - * (AWAKE/REM/CORE/DEEP) with two permanently empty rows. - */ -const LANE_ORDER = [ - "AWAKE", - "IN_BED", - "ASLEEP", - "REM", - "CORE", - "DEEP", -] as const; - -/** Breakdown order — deepest restorative first, matching the stacked bar. */ -const BREAKDOWN_ORDER = [ - "DEEP", - "REM", - "CORE", - "ASLEEP", - "AWAKE", - "IN_BED", -] as const; - -// v1.28.x — `formatMinutes`, the source/device label maps, and the -// discrepancy marker itself moved to `sleep-source-discrepancy-marker.tsx` -// so the dashboard sleep tile renders the identical hint. - -export function SleepHypnogram({ session }: SleepHypnogramProps) { - const { t, locale } = useTranslations(); - const timeFormat = useTimeFormatPreference(); - - const stageLabels: Record = { - DEEP: t("insights.sleep.stages.deep"), - REM: t("insights.sleep.stages.rem"), - CORE: t("insights.sleep.stages.core"), - ASLEEP: t("insights.sleep.stages.asleep"), - AWAKE: t("insights.sleep.stages.awake"), - IN_BED: t("insights.sleep.stages.inBed"), - }; - - // Build one coloured span per stage segment: its [start, end] clock range - // at the stage's depth lane, in the stage's own palette token. Each span - // renders as a `ReferenceArea` so the chart is multi-coloured (a single - // Recharts line can only carry one stroke). Lanes derive from the stages - // PRESENT in the spans (in `LANE_ORDER` order) so a four-stage night - // paints four lanes, not six. - const { spans, lanes } = useMemo(() => { - const known = new Set(LANE_ORDER); - const segments = [...session.segments] - .filter((seg) => seg.stage != null && known.has(seg.stage)) - .sort( - (a, b) => new Date(a.start).getTime() - new Date(b.start).getTime(), - ); - const present = new Set(segments.map((seg) => seg.stage as string)); - const lanes = LANE_ORDER.filter((stage) => present.has(stage)); - const laneOf: Record = Object.fromEntries( - // Reverse-index so the first present stage sits at the highest y. - lanes.map((s, i) => [s, lanes.length - 1 - i]), - ); - const spans = segments.map((seg) => { - const stage = seg.stage as string; - return { - stage, - x1: new Date(seg.start).getTime(), - x2: new Date(seg.end).getTime(), - lane: laneOf[stage], - fill: STAGE_COLORS[stage], - }; - }); - return { spans, lanes }; - }, [session.segments]); - - const domain = useMemo<[number, number]>( - () => [new Date(session.start).getTime(), new Date(session.end).getTime()], - [session.start, session.end], - ); - - // v1.12.0 — explicit clock-aligned X-axis ticks. With `data={[]}` Recharts - // has no points to derive ticks from and would otherwise label the domain - // endpoints with raw bedtime/wake stamps (e.g. 23:14 / 06:02). Snap to whole - // clock hours and pick a 1–3 h step from the night's span so the axis reads - // 23:00 / 01:00 / 03:00 / 05:00 and stays mobile-legible (≈4–6 ticks). - const xTicks = useMemo(() => { - const [from, to] = domain; - const spanHours = (to - from) / 3_600_000; - if (!Number.isFinite(spanHours) || spanHours <= 0) return []; - // Aim for ~5 ticks: 1 h up to 5 h span, 2 h up to 10 h, else 3 h. - const stepHours = spanHours <= 5 ? 1 : spanHours <= 10 ? 2 : 3; - const stepMs = stepHours * 3_600_000; - // First whole hour at or after `from`, aligned to the step grid. - const firstHour = new Date(from); - firstHour.setMinutes(0, 0, 0); - if (firstHour.getTime() < from) - firstHour.setTime(firstHour.getTime() + 3_600_000); - // Align the first tick to a multiple of the step (relative to midnight). - while (firstHour.getHours() % stepHours !== 0) { - firstHour.setTime(firstHour.getTime() + 3_600_000); - } - const ticks: number[] = []; - for (let t = firstHour.getTime(); t <= to; t += stepMs) { - ticks.push(t); - } - return ticks; - }, [domain]); - - const timeFmt = useMemo( - () => - new Intl.DateTimeFormat(locale === "de" ? "de-DE" : "en-US", { - hour: "2-digit", - minute: "2-digit", - ...hourCycleOptions(timeFormat), - }), - [locale, timeFormat], - ); - - // Per-stage breakdown with percentage of the night's total tracked time. - const breakdown = useMemo(() => { - const total = Object.values(session.stages).reduce((s, v) => s + v, 0); - return BREAKDOWN_ORDER.filter( - (stage) => (session.stages[stage] ?? 0) > 0, - ).map((stage) => { - const mins = session.stages[stage] ?? 0; - return { - stage, - minutes: mins, - pct: total > 0 ? Math.round((mins / total) * 100) : 0, - }; - }); - }, [session.stages]); - - const ariaLabel = t("insights.sleep.hypnogram.ariaLabel", { - asleep: formatMinutes(session.asleepMinutes, locale), - awakenings: String(session.awakenings), - }); - - // The CHART needs per-segment spans; the BREAKDOWN + totals footer only - // need any positive per-stage minutes. A legacy stage-summary night (no - // segment spans) still shows its breakdown rather than collapsing the - // whole card. "Unavailable" is reserved for a truly empty session. - // Spans must also carry more than one distinct START instant: a real - // night with timed stages steps through the clock, so its segments begin - // at different instants and earn the timeline bar. Only a degenerate - // session whose every segment starts on a single instant collapses to the - // breakdown footer. (Gating on the END instant instead hid the bar for an - // ordinary multi-stage night whose segments happened to share a right - // edge — the regression this restores.) - // A SYNTHESISED night (WHOOP / Polar) has no measured stage onsets — the - // server laid its segments out in a fixed physiological order, so drawing - // the stepped bar would invent a phase progression that was never recorded - // (the "Core block then Deep block, no real breakdown" the bar reads as). - // For those we keep the per-stage breakdown + totals, which ARE real, and - // drop the misleading timeline entirely. Real-series sources keep the bar. - const hasTrack = - session.reconstructed !== true && - spans.length > 0 && - (spans.length === 1 || new Set(spans.map((s) => s.x1)).size > 1); - const hasBreakdown = breakdown.length > 0; - - return ( - // v1.12.0 — card tightened: drop the default Card `gap-4 md:gap-6` to - // `gap-3` so the header sits closer to the timeline, reclaiming the empty - // band the maintainer flagged. The chart keeps its 200 px footprint. - - -
- - - {t("insights.sleep.hypnogram.subtitle", { - asleep: formatMinutes(session.asleepMinutes, locale), - })} - {/* Discreet source-discrepancy marker: two writers reported - clearly different totals for this night. Observational — the - headline stays the winning writer's number; the tooltip lists - each source's own total. Shared with the dashboard sleep - tile via `sleep-source-discrepancy-marker.tsx`. */} - - - {/* The night's measuring source rides the header as a muted - caption — it is already on the wire, and "which device - tracked this" is the first question a multi-tracker user - asks about a surprising night. */} - {session.source ? ( - - {t("insights.sleep.hypnogram.source", { - source: session.source, - })} - - ) : null} - {/* When the source reports stage TOTALS but no measured onsets - (WHOOP / Polar) the stepped timeline is suppressed — drawing one - would invent a phase order that was never recorded. Surface a calm - note so the breakdown below doesn't read as a missing chart. */} - {session.reconstructed === true && hasBreakdown ? ( - - {t("insights.sleep.hypnogram.estimateNote")} - - ) : null} -
-
- - {!hasTrack && !hasBreakdown ? ( -

- {t("insights.sleep.stages.unavailable")} -

- ) : ( - <> - {hasTrack && ( -
- - - - timeFmt.format(new Date(v))} - /> - lanes.length - 1 - i)} - stroke="var(--muted-foreground)" - fontSize={10} - width={56} - tickFormatter={(v: number) => { - const stage = lanes[lanes.length - 1 - v]; - return stage ? (stageLabels[stage] ?? "") : ""; - }} - /> - {spans.map((span, i) => ( - - ))} - - -
- )} - - {hasBreakdown && ( - <> -
- {breakdown.map(({ stage, minutes, pct }) => ( -
- - - - {formatMinutes(minutes, locale)} · {pct}% - -
- ))} -
- -
- - {t("insights.sleep.hypnogram.asleepTotal", { - value: formatMinutes(session.asleepMinutes, locale), - })} - - {session.inBedMinutes != null && ( - - {t("insights.sleep.hypnogram.inBedTotal", { - value: formatMinutes(session.inBedMinutes, locale), - })} - - )} - - {t("insights.sleep.hypnogram.awakenings", { - count: String(session.awakenings), - })} - -
- - )} - - )} -
-
- ); -} diff --git a/src/components/insights/sleep-stage-stacked-bar.tsx b/src/components/insights/sleep-stage-stacked-bar.tsx index b413419e0..587721c73 100644 --- a/src/components/insights/sleep-stage-stacked-bar.tsx +++ b/src/components/insights/sleep-stage-stacked-bar.tsx @@ -94,7 +94,7 @@ export const STAGE_COLORS: Record = { CORE: "var(--info)", // dracula-cyan — bulk of sleep ASLEEP: "var(--success)", // dracula-green — legacy iOS 15- unspecified AWAKE: "var(--dracula-yellow)", // dracula-yellow — wake bouts - IN_BED: "#6272a4", // dracula-comment — pre-asleep + IN_BED: "var(--chart-inbed)", // muted blue-grey — pre-asleep }; function formatMinutes(total: number, locale: string): string { diff --git a/src/components/insights/sleep/sleep-debt-card.tsx b/src/components/insights/sleep/sleep-debt-card.tsx index 8799ad0e2..350478c1b 100644 --- a/src/components/insights/sleep/sleep-debt-card.tsx +++ b/src/components/insights/sleep/sleep-debt-card.tsx @@ -4,7 +4,7 @@ import { Moon } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { InfoHint } from "@/components/ui/info-hint"; +import { InfoPopover } from "@/components/ui/info-popover"; import { TileHeader } from "@/components/insights/tile-header"; import { LearningGate } from "@/components/ui/learning-gate"; import type { SleepDebtDto } from "./use-sleep-rhythm"; @@ -43,7 +43,7 @@ export function SleepDebtCard({ debt }: { debt: SleepDebtDto }) { title={t("insights.sleep.debt.title")} right={ debt.source === "COMPUTED" ? ( - + ) : undefined } /> @@ -75,7 +75,7 @@ export function SleepDebtCard({ debt }: { debt: SleepDebtDto }) { title={t("insights.sleep.debt.title")} right={ debt.source === "COMPUTED" ? ( - + ) : undefined } /> diff --git a/src/components/insights/workout-detail.tsx b/src/components/insights/workout-detail.tsx index 6ca8783df..6bc216c64 100644 --- a/src/components/insights/workout-detail.tsx +++ b/src/components/insights/workout-detail.tsx @@ -2,17 +2,12 @@ import type { ReactNode } from "react"; import { - Activity, - Bike, - Dumbbell, Flame, Footprints, HeartPulse, Map, Mountain, - PersonStanding, Timer, - type LucideIcon, } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -23,6 +18,7 @@ import { hourCycleOptions, type TimeFormatPreference, } from "@/lib/format-locale"; +import { iconForSport } from "@/lib/workouts/sport-icons"; import type { WorkoutDetailPayload } from "@/hooks/use-workouts"; /** @@ -35,33 +31,6 @@ import type { WorkoutDetailPayload } from "@/hooks/use-workouts"; * `WorkoutDetailPayload`. */ -const SPORT_TYPE_ICON: Record = { - walking: Footprints, - running: PersonStanding, - cycling: Bike, - hiking: Mountain, - swimming: Activity, - rowing: Activity, - elliptical: Activity, - stairClimber: Activity, - yoga: PersonStanding, - mindAndBody: PersonStanding, - strength: Dumbbell, - hiit: Activity, - dance: Activity, - golf: Activity, - tennis: Activity, - basketball: Activity, - soccer: Activity, - crossTraining: Activity, - mixedCardio: HeartPulse, - other: Activity, -}; - -function iconForSport(sportType: string): LucideIcon { - return SPORT_TYPE_ICON[sportType] ?? Activity; -} - function formatDuration(seconds: number): string { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); diff --git a/src/components/insights/workout-list.tsx b/src/components/insights/workout-list.tsx index 6cdfcf319..18cbaaec8 100644 --- a/src/components/insights/workout-list.tsx +++ b/src/components/insights/workout-list.tsx @@ -1,16 +1,6 @@ "use client"; import Link from "next/link"; -import { - Activity, - Bike, - Dumbbell, - Footprints, - Heart, - Mountain, - PersonStanding, - type LucideIcon, -} from "lucide-react"; import { cn } from "@/lib/utils"; import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache"; @@ -19,6 +9,7 @@ import { hourCycleOptions, type TimeFormatPreference, } from "@/lib/format-locale"; +import { iconForSport } from "@/lib/workouts/sport-icons"; import type { WorkoutListEntry } from "@/hooks/use-workouts"; /** @@ -33,33 +24,6 @@ import type { WorkoutListEntry } from "@/hooks/use-workouts"; * lands here. */ -const SPORT_TYPE_ICON: Record = { - walking: Footprints, - running: PersonStanding, - cycling: Bike, - hiking: Mountain, - swimming: Activity, - rowing: Activity, - elliptical: Activity, - stairClimber: Activity, - yoga: PersonStanding, - mindAndBody: PersonStanding, - strength: Dumbbell, - hiit: Activity, - dance: Activity, - golf: Activity, - tennis: Activity, - basketball: Activity, - soccer: Activity, - crossTraining: Activity, - mixedCardio: Heart, - other: Activity, -}; - -function iconForSport(sportType: string): LucideIcon { - return SPORT_TYPE_ICON[sportType] ?? Activity; -} - function formatDuration(seconds: number): string { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); diff --git a/src/components/labs/lab-biomarker-chart.tsx b/src/components/labs/lab-biomarker-chart.tsx index 1a7cae6d1..96a48fad9 100644 --- a/src/components/labs/lab-biomarker-chart.tsx +++ b/src/components/labs/lab-biomarker-chart.tsx @@ -16,9 +16,8 @@ import { import { Button } from "@/components/ui/button"; import { CHART_HEIGHT_PX } from "@/lib/charts/constants"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; -import { formatDateShort } from "@/lib/format"; import { formatLabValue } from "@/lib/labs/format-value"; -import { useTranslations } from "@/lib/i18n/context"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { RichChartTooltip, type RichTooltipRow } from "../charts/chart-tooltip"; import type { LabResultDto } from "./types"; @@ -62,6 +61,7 @@ export function LabBiomarkerChart({ upperBound: number | null; }) { const { t } = useTranslations(); + const fmt = useFormatters(); const [range, setRange] = useState("365"); // Capture "now" once at mount so the recency filter stays pure across // re-renders (an inline `Date.now()` in render is impure). @@ -145,7 +145,7 @@ export function LabBiomarkerChart({ type="number" scale="time" domain={["dataMin", "dataMax"]} - tickFormatter={(ts: number) => formatDateShort(new Date(ts))} + tickFormatter={(ts: number) => fmt.dateShortSmart(new Date(ts))} tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} stroke="var(--border)" minTickGap={32} @@ -204,7 +204,7 @@ export function LabBiomarkerChart({ return ( ); diff --git a/src/components/measurements/measurement-form.tsx b/src/components/measurements/measurement-form.tsx index d415d1c8c..972036a3d 100644 --- a/src/components/measurements/measurement-form.tsx +++ b/src/components/measurements/measurement-form.tsx @@ -24,7 +24,11 @@ import { import { Loader2, MoreHorizontal, Plus, RotateCcw } from "lucide-react"; import { toast } from "sonner"; import { useTranslations } from "@/lib/i18n/context"; -import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys"; +import { + invalidateKeys, + measurementDependentKeys, + refetchInactiveDailyReads, +} from "@/lib/query-keys"; import { ApiError, apiPost } from "@/lib/api/api-fetch"; import { MEASUREMENT_NOTES_MAX_LENGTH } from "@/lib/validations/measurement"; @@ -326,6 +330,7 @@ export function MeasurementForm({ setPulse(""); setNotes(""); await invalidateKeys(queryClient, measurementDependentKeys); + await refetchInactiveDailyReads(queryClient); toast.success(t("common.saved")); onSuccess?.(); } catch (err) { diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 03cfed717..67ec1c0f6 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -66,6 +66,7 @@ import { invalidateKeys, measurementDependentKeys, queryKeys, + refetchInactiveDailyReads, } from "@/lib/query-keys"; import { MEASUREMENT_NOTES_MAX_LENGTH, @@ -544,6 +545,7 @@ export function MeasurementList({ try { await apiPost("/api/measurements/restore", { ids }); await invalidateKeys(queryClient, measurementDependentKeys); + await refetchInactiveDailyReads(queryClient); toast.success(t("measurements.restoredToast")); } catch { toast.error(t("measurements.restoreError")); @@ -558,6 +560,7 @@ export function MeasurementList({ }, onSuccess: (_data, id) => { void invalidateKeys(queryClient, measurementDependentKeys); + void refetchInactiveDailyReads(queryClient); toast.success(t("measurements.deletedToast"), { action: { label: t("common.undo"), @@ -587,6 +590,7 @@ export function MeasurementList({ }, onSuccess: async (deleted, ids) => { await invalidateKeys(queryClient, measurementDependentKeys); + await refetchInactiveDailyReads(queryClient); clearSelection(); toast.success( t("measurements.bulkDeleteSuccess", { count: String(deleted) }), @@ -649,6 +653,7 @@ export function MeasurementList({ }, onSuccess: async () => { await invalidateKeys(queryClient, measurementDependentKeys); + await refetchInactiveDailyReads(queryClient); setEditing(null); setEditError(null); }, diff --git a/src/components/medications/__tests__/card-parts.test.tsx b/src/components/medications/__tests__/card-parts.test.tsx index 12e01579f..f5721b897 100644 --- a/src/components/medications/__tests__/card-parts.test.tsx +++ b/src/components/medications/__tests__/card-parts.test.tsx @@ -420,9 +420,15 @@ describe("streak-token parity — generic vs GLP-1 card", () => { * structural identity that makes the two variants impossible to diverge. */ describe("medication card body — shared shell + dose-state presentation", () => { - function renderBody(doseStatus: DoseStatus, active = true) { + function renderBody( + doseStatus: DoseStatus, + active = true, + highlighted = false, + ) { return render( `) rings the target card, mirroring + // ``. Carries the id for the page's + // scroll-into-view lookup regardless of highlight state. + it("carries the medication id for the deep-link target lookup", () => { + const html = renderBody("upcoming"); + expect(html).toContain('data-medication-id="m1"'); + }); + + it("rings the card when it is the deep-link highlight target", () => { + const html = renderBody("upcoming", true, true); + expect(html).toContain("ring-primary"); + expect(html).toContain("ring-2"); + }); + + it("does not ring the card when it is not the highlight target", () => { + const html = renderBody("upcoming", true, false); + expect(html).not.toContain("ring-primary"); + }); + it("renders the decisive next + last lines exactly once each", () => { const html = renderBody("upcoming"); expect((html.match(/Next intake:/g) ?? []).length).toBe(1); @@ -605,6 +631,7 @@ describe("compliance error fallback (v1.16.8)", () => { }) { return render( — structure + shared payloads", () => { expect(html).not.toContain('aria-label="Take – Amoxicillin"'); }); + // v1.29.x — the Today digest's "Log dose" deep-link + // (`/medications?highlight=`) rings the matching row, mirroring the + // card grid's transient highlight. + it("carries the medication id and rings the row matching highlightId", () => { + const client = makeClient(); + seedCompliance(client, "m1", 90, 88); + seedCompliance(client, "m2", 90, 88); + const html = render( + , + client, + ); + + expect(html).toContain('data-medication-id="m1"'); + expect(html).toContain('data-medication-id="m2"'); + expect(html).toContain("ring-primary"); + }); + + it("rings no row when highlightId matches nothing", () => { + const client = makeClient(); + seedCompliance(client, "m1", 90, 88); + const html = render( + , + client, + ); + + expect(html).not.toContain("ring-primary"); + }); + it("renders a GLP-1 medication like any other row", () => { const client = makeClient(); seedCompliance(client, "g1", 100, 100); diff --git a/src/components/medications/card-parts/medication-card-body.tsx b/src/components/medications/card-parts/medication-card-body.tsx index 38e290699..1f0bee3c2 100644 --- a/src/components/medications/card-parts/medication-card-body.tsx +++ b/src/components/medications/card-parts/medication-card-body.tsx @@ -50,6 +50,12 @@ import type { MedicationWindowStatus } from "@/lib/medications/window-status"; * - `upcoming` / `taken_*` / `skipped` → no escalation; the card stays calm. */ export interface MedicationCardBodyProps { + /** + * Medication id. Rendered as `data-medication-id` so a deep-link (e.g. the + * Today digest's "Log dose" action, `/medications?highlight=`) can + * locate + scroll to the right card. + */ + id: string; /** Medication name shown on the header's line 1. */ name: string; /** Dose shown beside the name on line 1. */ @@ -140,9 +146,18 @@ export interface MedicationCardBodyProps { /** GLP-1 variant mounts its post-dose injection-site dialog here. */ children?: ReactNode; + + /** + * v1.29.x — briefly ringed when this card is the Today digest's + * "Log dose" deep-link target (`/medications?highlight=`), mirroring + * the vault's `` treatment. Transient — the + * page clears it a few seconds after scrolling the card into view. + */ + highlighted?: boolean; } export function MedicationCardBody({ + id, name, dose, categoryLabel, @@ -164,6 +179,7 @@ export function MedicationCardBody({ intakeLoading, onRecordIntake, children, + highlighted = false, }: MedicationCardBodyProps) { const { t, locale } = useTranslations(); @@ -227,6 +243,7 @@ export function MedicationCardBody({ return ( `) for the Today digest's deep-link + // target. Never a persistent tint — the neutral-card rule above still + // holds for dose STATUS; this is a one-time "you tapped here" cue. + className={cn( + "h-full gap-3 md:gap-3", + active ? "" : "opacity-60", + highlighted && "ring-primary ring-2", + )} > apiGet( @@ -95,7 +96,7 @@ export function EfficacyTab({ staleTime: 60_000, }); - if (isLoading || !data) { + if (isLoading) { return ( + refetch()} /> + + ); + } + // The tab is hidden upstream when ineligible; this is a defensive fallback. if (!data.eligible) { return ( diff --git a/src/components/medications/dose-history-ledger.tsx b/src/components/medications/dose-history-ledger.tsx index 1be882b5a..c15a22de7 100644 --- a/src/components/medications/dose-history-ledger.tsx +++ b/src/components/medications/dose-history-ledger.tsx @@ -82,7 +82,7 @@ import { useFormatters, useDateFormatPreference, } from "@/lib/i18n/context"; -import { formatDateWithWeekday } from "@/lib/date-format"; +import { formatDateWithWeekdaySmart } from "@/lib/date-format"; import { invalidateKeys, medicationDependentKeys, @@ -448,7 +448,7 @@ export function DoseHistoryLedger({ the DEVICE zone and could shift the heading a day when browser ≠ profile zone; the day-key label renders UTC-pinned instead (zone-independent, weekday included). */} - {formatDateWithWeekday(group.dayKey, dateFormatPref, locale)} + {formatDateWithWeekdaySmart(group.dayKey, dateFormatPref, locale)}
    {group.rows.map((row, i) => ( diff --git a/src/components/medications/dose-strength-curve.tsx b/src/components/medications/dose-strength-curve.tsx index 3c11d42b3..c4f4990db 100644 --- a/src/components/medications/dose-strength-curve.tsx +++ b/src/components/medications/dose-strength-curve.tsx @@ -218,7 +218,7 @@ export function DoseStrengthCurve({ scale="time" domain={["dataMin", "dataMax"]} ticks={ticks} - tickFormatter={(v) => fmt.dateShort(new Date(v as number))} + tickFormatter={(v) => fmt.dateShortSmart(new Date(v as number))} tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} tickLine={false} axisLine={false} @@ -237,7 +237,7 @@ export function DoseStrengthCurve({ strokeOpacity: 0.3, strokeDasharray: "3 3", }} - labelFormatter={(v) => fmt.dateShort(new Date(v as number))} + labelFormatter={(v) => fmt.dateShortSmart(new Date(v as number))} formatter={(value) => [ `${fmt.number(value as number)} ${unit}`, t("medications.doseStrength.tooltipLabel"), diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index bce130661..a10519cbb 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -150,6 +150,11 @@ interface Glp1MedicationCardProps { */ onOpenHistory: (med: Glp1Medication) => void; onLogSideEffect?: (med: Glp1Medication) => void; + /** + * v1.29.x — briefly ringed when this card is the Today digest's "Log + * dose" deep-link target (`/medications?highlight=`). + */ + highlighted?: boolean; } function diffDays(target: Date, from: Date): number { @@ -182,6 +187,7 @@ export function Glp1MedicationCard({ onEdit, onOpenHistory, onLogSideEffect, + highlighted = false, }: Glp1MedicationCardProps) { const queryClient = useQueryClient(); const { t } = useTranslations(); @@ -414,7 +420,7 @@ export function Glp1MedicationCard({ if (next.daysAway === 0) return t("medications.glp1NextInjectionToday"); if (next.daysAway === 1) return t("medications.glp1NextInjectionTomorrow"); const dayName = weekdayLabel(getDayOfWeekInTz(next.date, displayTz)); - const dateShort = fmt.dateShort(next.date); + const dateShort = fmt.dateShortSmart(next.date); return t("medications.glp1NextInjectionDays", { label: `${dayName}, ${dateShort}`, days: next.daysAway, @@ -463,7 +469,7 @@ export function Glp1MedicationCard({ medication.nextDueOverdue && next ? diffDays(next.date, now) === 0 ? formatTime(next.date.toISOString()) - : `${weekdayLabel(getDayOfWeekInTz(next.date, displayTz))}, ${fmt.dateShort(next.date)}` + : `${weekdayLabel(getDayOfWeekInTz(next.date, displayTz))}, ${fmt.dateShortSmart(next.date)}` : null; const nextLine = @@ -493,6 +499,8 @@ export function Glp1MedicationCard({ return ( void; + /** + * v1.29.x — briefly ringed when this card is the Today digest's "Log + * dose" deep-link target (`/medications?highlight=`). + */ + highlighted?: boolean; } export function MedicationCard({ medication, onEdit, onOpenHistory, + highlighted = false, }: MedicationCardProps) { const queryClient = useQueryClient(); const { t, locale } = useTranslations(); @@ -431,7 +437,7 @@ export function MedicationCard({ } else if (diffDays <= 5) { dayLabel = weekdayLabel(nextDate.getDay()); } else { - dayLabel = fmt.dateWithWeekday(nextDate); + dayLabel = fmt.dateWithWeekdaySmart(nextDate); } } @@ -463,6 +469,8 @@ export function MedicationCard({ return ( `). The matching row rings briefly, + * mirroring the card grid's ``. + */ + highlightId?: string | null; } export function MedicationTable({ activeMedications, inactiveMedications, initialSort = null, + highlightId = null, }: MedicationTableProps) { const { t, locale } = useTranslations(); const { user } = useAuth(); @@ -350,6 +357,7 @@ export function MedicationTable({ lateMinutes={lateMinutes} missedMinutes={missedMinutes} lowStockRunwayDays={lowStockRunwayDays} + highlighted={highlightId === med.id} /> ))} {sortedInactive.map((med) => ( @@ -360,6 +368,7 @@ export function MedicationTable({ lateMinutes={lateMinutes} missedMinutes={missedMinutes} lowStockRunwayDays={lowStockRunwayDays} + highlighted={highlightId === med.id} /> ))} @@ -375,6 +384,8 @@ interface MedicationTableRowItemProps { missedMinutes: number; /** Per-user low-stock runway threshold in days; null = alert off. */ lowStockRunwayDays: number | null; + /** v1.29.x — the deep-link highlight target (see `MedicationTableProps`). */ + highlighted?: boolean; } function MedicationTableRowItem({ @@ -383,6 +394,7 @@ function MedicationTableRowItem({ lateMinutes, missedMinutes, lowStockRunwayDays, + highlighted = false, }: MedicationTableRowItemProps) { const { t, locale } = useTranslations(); const fmt = useFormatters(); @@ -539,7 +551,7 @@ function MedicationTableRowItem({ ? t("medications.tomorrow") : diffDays <= 5 ? weekdayLabel(nextDate.getDay()) - : fmt.dateWithWeekday(nextDate); + : fmt.dateWithWeekdaySmart(nextDate); nextCell = ( {dayLabel}, {formatTime(new Date(nextAt).toISOString())} @@ -637,7 +649,15 @@ function MedicationTableRowItem({ ); return ( - + {/* Sticky name column — opaque background so scrolled content passes underneath, not through. */} diff --git a/src/components/medications/medication-view-toggle.tsx b/src/components/medications/medication-view-toggle.tsx index e9e08d109..5f32c0e08 100644 --- a/src/components/medications/medication-view-toggle.tsx +++ b/src/components/medications/medication-view-toggle.tsx @@ -3,16 +3,13 @@ import { LayoutGrid, Table as TableIcon } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; -import { cn } from "@/lib/utils"; +import { ViewToggle } from "@/components/ui/view-toggle"; import type { MedicationListView } from "@/lib/medication-list-layout"; /** * v1.16.10 — icon-only card/table view toggle for the /medications page - * header. A small two-segment control: the active segment carries the - * filled surface, the inactive one stays ghost. `aria-pressed` announces - * the state per segment; the group is named for screen readers. Segments - * keep the 44-px mobile tap floor (`size-11`), shrinking to the standard - * 36-px control height on desktop like the neighbouring Add button. + * header. Thin wrapper over the shared `ViewToggle` primitive; see its + * docstring for the interaction grammar. */ interface MedicationViewToggleProps { view: MedicationListView; @@ -25,49 +22,24 @@ export function MedicationViewToggle({ }: MedicationViewToggleProps) { const { t } = useTranslations(); - const segments = [ - { - value: "cards" as const, - label: t("medications.viewCards"), - icon: LayoutGrid, - }, - { - value: "table" as const, - label: t("medications.viewTable"), - icon: TableIcon, - }, - ]; - return ( -
    - {segments.map(({ value, label, icon: Icon }) => { - const active = view === value; - return ( - - ); - })} -
    + ); } diff --git a/src/components/medications/side-effects-section.tsx b/src/components/medications/side-effects-section.tsx index ae6e0338f..9b07cf638 100644 --- a/src/components/medications/side-effects-section.tsx +++ b/src/components/medications/side-effects-section.tsx @@ -324,7 +324,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) { className="text-muted-foreground w-14 shrink-0 text-right text-xs tabular-nums" data-slot="side-effect-row-date" > - {fmt.dateShort(new Date(row.occurredAt))} + {fmt.dateShortSmart(new Date(row.occurredAt))}

{row.notes && ( diff --git a/src/components/medications/titration-timeline.tsx b/src/components/medications/titration-timeline.tsx index 3fafd3024..1bc91e710 100644 --- a/src/components/medications/titration-timeline.tsx +++ b/src/components/medications/titration-timeline.tsx @@ -221,9 +221,11 @@ export function TitrationTimeline({

{step.isPast - ? fmt.dateShort(new Date(step.effectiveFrom)) + ? fmt.dateShortSmart(new Date(step.effectiveFrom)) : t("medications.titration.plannedFor", { - date: fmt.dateShort(new Date(step.effectiveFrom)), + date: fmt.dateShortSmart( + new Date(step.effectiveFrom), + ), })}

diff --git a/src/components/mental-health/assessment-history-chart.tsx b/src/components/mental-health/assessment-history-chart.tsx index 95c4dc8fe..8dd09f896 100644 --- a/src/components/mental-health/assessment-history-chart.tsx +++ b/src/components/mental-health/assessment-history-chart.tsx @@ -34,8 +34,7 @@ import { import { RichChartTooltip, type RichTooltipRow } from "../charts/chart-tooltip"; import { CHART_HEIGHT_PX } from "@/lib/charts/constants"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; -import { formatDateShort } from "@/lib/format"; -import { useTranslations } from "@/lib/i18n/context"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { INSTRUMENTS } from "@/lib/mental-health/instruments"; import type { AssessmentRow, InstrumentId } from "./types"; @@ -55,6 +54,7 @@ export function AssessmentHistoryChart({ rows: AssessmentRow[]; }) { const { t } = useTranslations(); + const fmt = useFormatters(); const def = INSTRUMENTS[instrument]; const points = useMemo(() => { @@ -103,7 +103,7 @@ export function AssessmentHistoryChart({ type="number" scale="time" domain={["dataMin", "dataMax"]} - tickFormatter={(ts: number) => formatDateShort(new Date(ts))} + tickFormatter={(ts: number) => fmt.dateShortSmart(new Date(ts))} tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} stroke="var(--border)" minTickGap={32} @@ -139,7 +139,7 @@ export function AssessmentHistoryChart({ return ( ); diff --git a/src/components/mental-health/mental-wellbeing.tsx b/src/components/mental-health/mental-wellbeing.tsx index 2b0ad486e..af57f203d 100644 --- a/src/components/mental-health/mental-wellbeing.tsx +++ b/src/components/mental-health/mental-wellbeing.tsx @@ -18,7 +18,7 @@ * `assessment-result`, `assessment-history`, `crisis-card`). * The landing follows the card grammar of Vorsorge / Medications: instrument * cards (last-result line + Start) + a history card. The disclaimer renders - * ONLY here (a muted caption) and behind the InfoHint — never while testing. + * ONLY here (a muted caption) and behind the InfoPopover — never while testing. * * v1.27.9 — the landing is intro + instrument cards, nothing else: the * combined history card left the main page entirely. Each card opens a diff --git a/src/components/module-list/module-view-toggle.tsx b/src/components/module-list/module-view-toggle.tsx index 2fc25c0cb..139a55845 100644 --- a/src/components/module-list/module-view-toggle.tsx +++ b/src/components/module-list/module-view-toggle.tsx @@ -3,15 +3,15 @@ import { LayoutGrid, List as ListIcon } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; -import { cn } from "@/lib/utils"; +import { ViewToggle } from "@/components/ui/view-toggle"; import type { ModuleListView } from "@/lib/module-list-prefs"; /** * v1.18.6 (W8 / MOD-03) — icon-only card/list view toggle shared by the - * Vorsorge / Illness / Labs settings pages. Mirrors the medication module's - * `MedicationViewToggle` grammar (two ghost segments, the active one filled, - * `aria-pressed` per segment) but switches between a card grid and a compact - * list rather than cards-vs-table, so the label set is `moduleList.view*`. + * Vorsorge / Illness / Labs settings pages. Thin wrapper over the shared + * `ViewToggle` primitive (mirrors the medication module's + * `MedicationViewToggle`, which is the same wrapper switching between a + * card grid and a compact list rather than cards-vs-table). */ interface ModuleViewToggleProps { view: ModuleListView; @@ -21,49 +21,24 @@ interface ModuleViewToggleProps { export function ModuleViewToggle({ view, onChange }: ModuleViewToggleProps) { const { t } = useTranslations(); - const segments = [ - { - value: "cards" as const, - label: t("moduleList.viewCards"), - icon: LayoutGrid, - }, - { - value: "list" as const, - label: t("moduleList.viewList"), - icon: ListIcon, - }, - ]; - return ( -
- {segments.map(({ value, label, icon: Icon }) => { - const active = view === value; - return ( - - ); - })} -
+ ); } diff --git a/src/components/mood/mood-form.tsx b/src/components/mood/mood-form.tsx index ce8d0e536..0e5443d49 100644 --- a/src/components/mood/mood-form.tsx +++ b/src/components/mood/mood-form.tsx @@ -37,7 +37,11 @@ import { import { toast } from "sonner"; import { useTranslations } from "@/lib/i18n/context"; import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group"; -import { invalidateKeys, moodDependentKeys } from "@/lib/query-keys"; +import { + invalidateKeys, + moodDependentKeys, + refetchInactiveDailyReads, +} from "@/lib/query-keys"; import { ApiError, apiPost } from "@/lib/api/api-fetch"; import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section"; import { MoodTagPicker, type RatedFactor } from "./mood-tag-picker"; @@ -216,6 +220,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { resetForm(); setMoreTagsOpen(false); await invalidateKeys(queryClient, moodDependentKeys); + await refetchInactiveDailyReads(queryClient); toast.success(t("common.saved")); onSuccess?.(); } catch (err) { diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index 9fe2e15af..b8622fcc0 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -62,7 +62,12 @@ import { toast } from "sonner"; import { formatDateTime } from "@/lib/format"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { MOOD_LABEL_KEYS, MOOD_SCORE_BY_ENUM } from "@/lib/mood/labels"; -import { invalidateKeys, moodDependentKeys, queryKeys } from "@/lib/query-keys"; +import { + invalidateKeys, + moodDependentKeys, + queryKeys, + refetchInactiveDailyReads, +} from "@/lib/query-keys"; import { moodSourceEnum } from "@/lib/validations/moodlog"; import { Checkbox } from "@/components/ui/checkbox"; import { @@ -268,6 +273,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { try { await apiPost("/api/mood-entries/restore", { ids }); await invalidateKeys(queryClient, moodDependentKeys); + await refetchInactiveDailyReads(queryClient); toast.success(t("mood.restoredToast")); } catch { toast.error(t("mood.restoreError")); @@ -282,6 +288,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { }, onSuccess: (_data, id) => { void invalidateKeys(queryClient, moodDependentKeys); + void refetchInactiveDailyReads(queryClient); toast.success(t("mood.deletedToast"), { action: { label: t("common.undo"), @@ -309,6 +316,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { }, onSuccess: async (deleted, ids) => { await invalidateKeys(queryClient, moodDependentKeys); + await refetchInactiveDailyReads(queryClient); clearSelection(); toast.success(t("mood.bulkDeleteSuccess", { count: String(deleted) }), { action: { @@ -369,6 +377,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { }, onSuccess: async () => { await invalidateKeys(queryClient, moodDependentKeys); + await refetchInactiveDailyReads(queryClient); setEditing(null); setEditError(null); }, diff --git a/src/components/onboarding/__tests__/source-card-grid.test.tsx b/src/components/onboarding/__tests__/source-card-grid.test.tsx new file mode 100644 index 000000000..7721480cd --- /dev/null +++ b/src/components/onboarding/__tests__/source-card-grid.test.tsx @@ -0,0 +1,91 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +/** + * v1.29.x — UX audit H1: the wizard's Withings card must not launch an + * OAuth handshake that can only 400 for a brand-new account (Withings + * credentials are per-user BYO with no env fallback). The card reads the + * consolidated integrations-status envelope and swaps its CTA + href + * depending on whether the account already has its own credentials saved. + */ + +const integrationStatusState = vi.hoisted(() => ({ + data: undefined as + | { + threshold: number; + integrations: Array<{ integration: string; configured?: boolean }>; + } + | undefined, +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: integrationStatusState.data }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true }), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + ...rest + }: { + href: string; + children: React.ReactNode; + }) => ( + + {children} + + ), +})); + +import { SourceCardGrid } from "../source-card-grid"; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe(" Withings card", () => { + it("points at Settings instead of launching OAuth when unconfigured", () => { + integrationStatusState.data = { + threshold: 3, + integrations: [{ integration: "withings", configured: false }], + }; + const html = render(); + expect(html).toContain('data-testid="source-card-withings-setup"'); + expect(html).toContain('href="/settings/integrations#withings"'); + expect(html).not.toContain('href="/api/withings/connect"'); + expect(html).toContain("Set up in Settings"); + }); + + it("also redirects to Settings when the status envelope hasn't loaded yet", () => { + integrationStatusState.data = undefined; + const html = render(); + expect(html).toContain('href="/settings/integrations#withings"'); + expect(html).not.toContain('href="/api/withings/connect"'); + }); + + it("opens the real OAuth connect flow once credentials are configured", () => { + integrationStatusState.data = { + threshold: 3, + integrations: [{ integration: "withings", configured: true }], + }; + const html = render(); + expect(html).toContain('href="/api/withings/connect"'); + expect(html).not.toContain('data-testid="source-card-withings-setup"'); + expect(html).toContain("Connect Withings"); + }); +}); diff --git a/src/components/onboarding/source-card-grid.tsx b/src/components/onboarding/source-card-grid.tsx index 6ba4305d9..8c0d64fdb 100644 --- a/src/components/onboarding/source-card-grid.tsx +++ b/src/components/onboarding/source-card-grid.tsx @@ -15,6 +15,11 @@ import { } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { + pickStatus, + useIntegrationStatuses, +} from "@/components/settings/integrations/shared"; +import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import { apiPost } from "@/lib/api/api-fetch"; @@ -26,8 +31,12 @@ import { queryKeys } from "@/lib/query-keys"; * Presents the data sources HealthLog ships today. The headline cards * cover the two paths most people start on: * 1. Manual entry (enabled, recommended — works on any device) - * 2. Withings (enabled — opens OAuth `/api/withings/connect` in a new - * tab; the callback flips the connection server-side) + * 2. Withings — opens OAuth `/api/withings/connect` in a new tab once + * the account has its own Withings developer app configured; the + * callback flips the connection server-side. Before that, the card + * is honest about the prerequisite (v1.29.x, UX audit H1/H2): the + * CTA reads "Set up in Settings" and points at the credentials form + * instead of launching an OAuth handshake that can only 400. * * Below them a calm "more sources" row lists the remaining shipped * integrations (Apple Health, WHOOP, Oura, Polar, Nightscout, Fitbit), @@ -97,10 +106,20 @@ export function SourceCardGrid() { const { t } = useTranslations(); const router = useRouter(); const queryClient = useQueryClient(); + const { isAuthenticated } = useAuth(); const [selected, setSelected] = useState("manual"); const [advancing, setAdvancing] = useState(false); + // v1.29.x — the wizard's "Connect Withings" card must not launch an OAuth + // handshake that can only fail: Withings credentials are per-user BYO with + // no env fallback, and a brand-new account never has them yet. Read the + // same consolidated envelope Settings → Integrations uses so the card + // knows whether the handshake can actually succeed. + const { data: integrationStatus } = useIntegrationStatuses(isAuthenticated); + const withingsConfigured = + pickStatus(integrationStatus, "withings")?.configured ?? false; + async function advance() { if (advancing) return; setAdvancing(true); @@ -144,6 +163,9 @@ export function SourceCardGrid() { card.recommendedKey ? t(card.recommendedKey) : null } withingsCtaLabel={t("onboarding.source.withings.cta")} + withingsConfigured={withingsConfigured} + withingsSetupCtaLabel={t("onboarding.source.withings.setupCta")} + withingsSetupHint={t("onboarding.source.withings.setupHint")} /> ))}
@@ -234,6 +256,14 @@ interface SourceCardItemProps { bodyLabel: string; recommendedLabel: string | null; withingsCtaLabel: string; + /** Whether the account already has its own Withings developer-app + * credentials saved — i.e. whether `/api/withings/connect` can + * actually succeed right now. */ + withingsConfigured: boolean; + /** CTA label shown instead of "Connect Withings" when unconfigured. */ + withingsSetupCtaLabel: string; + /** One-sentence explanation of the BYO credential prerequisite. */ + withingsSetupHint: string; } function SourceCardItem({ @@ -244,6 +274,9 @@ function SourceCardItem({ bodyLabel, recommendedLabel, withingsCtaLabel, + withingsConfigured, + withingsSetupCtaLabel, + withingsSetupHint, }: SourceCardItemProps) { const Icon = card.Icon; @@ -292,20 +325,40 @@ function SourceCardItem({ > {headerRow} {body} - + {withingsConfigured ? ( + + ) : ( + // No per-user Withings credentials yet — an OAuth handshake here + // can only 400 (Withings has no env fallback). Point at the + // credentials form instead of launching a doomed connect flow. +
+

{withingsSetupHint}

+ +
+ )}
); } diff --git a/src/components/settings/export-section.tsx b/src/components/settings/export-section.tsx index 6fc50159d..4140f0dca 100644 --- a/src/components/settings/export-section.tsx +++ b/src/components/settings/export-section.tsx @@ -494,6 +494,17 @@ function FullBackupCard() { } > + {/* v1.28 backup-completeness — the description above lists what's + included; this line honestly discloses what isn't (document + binaries, workout GPS/sample series) rather than implying + "everything" is in the file. Always visible, not gated on the + encrypt toggle. */} +

+ {t("settings.sections.export.cards.fullBackup.scopeNote")} +