Merge pull request #524 from MBombeck/release/v1.30.0

release/v1.30.0
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-18 02:41:27 +02:00
commit eeeb5c551c
219 files changed
+10272 -4515

No files matched your search

+41
View File
@@ -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
+10 -4
View File
@@ -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
+1
View File
@@ -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 }
+48
View File
@@ -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;
-1
View File
@@ -47,7 +47,6 @@
".next/**",
"**/__mocks__/**",
"e2e/setup/test-helpers.ts",
"src/components/charts/compliance-line-chart.tsx",
"src/lib/logging/index.ts"
],
"ignoreDependencies": [
+67 -21
View File
@@ -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}.",
+67 -21
View File
@@ -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.",
+67 -21
View File
@@ -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}.",
+67 -21
View File
@@ -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}.",
+67 -21
View File
@@ -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}.",
+67 -21
View File
@@ -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}.",
+1 -1
View File
@@ -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",
@@ -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'
);
+1 -1
View File
@@ -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/*`
@@ -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;
@@ -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,
},
},
});
+8 -4
View File
@@ -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" },
@@ -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");
});
});
@@ -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<string, unknown> };
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"));
@@ -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 () => {
+12 -3
View File
@@ -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,
},
});
+21 -1
View File
@@ -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:<userId>` 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,
},
});
@@ -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=<tag>` 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: <T extends (...args: unknown[]) => 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();
});
});
+5 -5
View File
@@ -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),
);
}
@@ -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<Response>;
@@ -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
+59 -34
View File
@@ -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) {
+32 -27
View File
@@ -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<string, { sum: number; count: number }>();
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<string, MeasurementSeriesRow[]>();
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) {
+4 -1
View File
@@ -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 });
+4 -1
View File
@@ -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);
}
@@ -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([
{
+4 -1
View File
@@ -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<Response> {
// 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", {
+78 -30
View File
@@ -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<string, Bucket>();
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
@@ -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" },
@@ -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<string>();
// 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;
}
@@ -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<string, unknown>;
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<Record<string, unknown>> };
};
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<string, unknown>;
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<string, unknown>;
expect(probeWhere).toHaveProperty("userId_date_moodLoggedAt");
const findManyArg = vi.mocked(prisma.moodEntry.findMany).mock
.calls[0]?.[0] as {
where: { OR: Array<Record<string, unknown>> };
};
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<string, unknown>;
};
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);
+60 -9
View File
@@ -189,6 +189,57 @@ async function postBulk(request: NextRequest): Promise<Response> {
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<string, ExistingMoodRow>();
const existingByDateKey = new Map<string, ExistingMoodRow>();
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<Response> {
},
};
// 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`
@@ -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);
});
});
+151 -65
View File
@@ -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<Response> {
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<Response> {
// 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 });
}
}
@@ -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 () => {
@@ -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: <T extends (...args: unknown[]) => 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");
});
});
+11 -3
View File
@@ -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);
@@ -13,9 +13,6 @@ vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => 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");
});
});
+11 -3
View File
@@ -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);
@@ -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: <T extends (...args: unknown[]) => 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");
});
});
+10 -5
View File
@@ -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!,
),
);
}
@@ -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<typeof vi.fn>;
@@ -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(
+7 -4
View File
@@ -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),
);
}
@@ -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();
});
});
+9 -4
View File
@@ -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),
);
}
+16 -1
View File
@@ -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",
},
},
});
},
);
}
}
}
+14
View File
@@ -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;
+297
View File
@@ -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
* `<EcgSection>` already reads.
*/
interface ComprehensivePayload {
moodSummary: { count: number } | null;
medications: Array<{ id: string }>;
}
const GROUP_ICONS: Record<ManagerGroup, LucideIcon> = {
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 (
<Badge
variant="outline"
className="border-success/40 bg-success/15 text-success shrink-0"
>
{t("metricCatalog.statusPresent")}
</Badge>
);
}
return (
<span className="text-muted-foreground shrink-0 text-xs">
{t("metricCatalog.statusAbsent")}
</span>
);
}
function CatalogRow({
entry,
present,
}: {
entry: CatalogEntry;
present: boolean | null;
}) {
const { t } = useTranslations();
if (entry.kind === "info") {
return (
<div data-slot="catalog-row-info" className="flex items-start gap-2 py-2">
<Siren className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-foreground text-sm font-medium">
{t(entry.nameKey)}
</p>
<p className="text-muted-foreground text-xs">{t(entry.sourceKey)}</p>
</div>
</div>
);
}
const isPresent = present === true;
return (
<div
data-slot="catalog-row"
data-metric-id={entry.id}
className="flex items-start justify-between gap-3 py-2"
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-foreground text-sm font-medium">
{t(entry.nameKey)}
</span>
<StatusBadge present={isPresent} />
</div>
<p className="text-muted-foreground text-xs">{t(entry.sourceKey)}</p>
</div>
<div className="shrink-0">
{isPresent && entry.href ? (
<Button size="sm" variant="ghost" asChild>
<Link href={entry.href}>
{t("metricCatalog.viewCta")}
<ChevronRight className="size-3.5" />
</Link>
</Button>
) : !isPresent && entry.connectable ? (
<Button size="sm" variant="outline" asChild>
<Link href="/settings/integrations">
{t("metricCatalog.connectCta")}
</Link>
</Button>
) : !isPresent && entry.manualHref && entry.manualCtaKey ? (
<Button size="sm" variant="outline" asChild>
<Link href={entry.manualHref}>{t(entry.manualCtaKey)}</Link>
</Button>
) : null}
</div>
</div>
);
}
export default function MetricCatalogPage() {
const { t } = useTranslations();
const { isAuthenticated, user } = useAuth();
const analyticsQuery = useAnalyticsQuery({ slice: "summaries" });
const comprehensiveQuery = useQuery({
queryKey: queryKeys.insightsComprehensive(),
queryFn: () => apiGet<ComprehensivePayload>("/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 (
<SubPageShell
title={t("metricCatalog.title")}
description={t("metricCatalog.description")}
>
<div className="space-y-6">
{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 (
<Card
key={group}
data-slot="catalog-group"
className="gap-2 py-3 md:py-4"
>
<CardHeader>
<TileHeader
icon={Icon}
title={t(CATALOG_GROUP_HEADER_KEYS[group])}
/>
</CardHeader>
<CardContent>
<div className="divide-border divide-y">
{entries.map((entry) => {
if (entry.id === "nutrients" && !nutrientsModuleEnabled) {
return (
<div
key={entry.id}
data-slot="catalog-row"
data-metric-id="nutrients"
className="flex items-start justify-between gap-3 py-2"
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-foreground text-sm font-medium">
{t(entry.nameKey)}
</span>
<span className="text-muted-foreground shrink-0 text-xs">
{t("metricCatalog.statusModuleOff")}
</span>
</div>
<p className="text-muted-foreground text-xs">
{t("nutrients.page.moduleOffDescription")}
</p>
</div>
<div className="shrink-0">
<Button size="sm" variant="outline" asChild>
<Link href="/settings/modules">
{t("nutrients.page.moduleOffCta")}
</Link>
</Button>
</div>
</div>
);
}
return (
<CatalogRow
key={entry.id}
entry={entry}
present={isPresent(entry)}
/>
);
})}
</div>
</CardContent>
</Card>
);
})}
</div>
</SubPageShell>
);
}
+28
View File
@@ -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=<id>`) 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<string | null>(
() => 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() {
<MedicationTable
activeMedications={activeMeds}
inactiveMedications={inactiveMeds}
highlightId={highlightId}
/>
) : (
<div className="space-y-6">
@@ -485,6 +509,7 @@ export default function MedicationsPage() {
medication={med}
onEdit={openEdit}
onOpenHistory={openHistory}
highlighted={highlightId === med.id}
/>
) : (
<MedicationCard
@@ -492,6 +517,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}
/>
) : (
<MedicationCard
@@ -520,6 +547,7 @@ export default function MedicationsPage() {
medication={med}
onEdit={openEdit}
onOpenHistory={openHistory}
highlighted={highlightId === med.id}
/>
),
)}
+8 -2
View File
@@ -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
`<QueryErrorCard>` 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 ? (
<TodayHeroSkeleton />
) : digestQuery.isError ? (
<QueryErrorCard onRetry={() => digestQuery.refetch()} />
) : digestQuery.data ? (
<TodayHero digest={digestQuery.data} />
) : null)}
@@ -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
// `<tr>`); a real `<button>` in the Action cell gives keyboard / switch
// users a focusable, activatable path to the same detail dialog.
it("renders a real button carrying the action label and a descriptive aria-label", () => {
const html = render();
expect(html).toMatch(
/<button[^>]*type="button"[^>]*>auth\.login\.success</,
);
expect(html).toContain('aria-label="View details for auth.login.success');
});
});
@@ -11,10 +11,11 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { formatDate, formatDateTime } from "@/lib/format";
import type { Formatters } from "@/lib/format-locale";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { ListRow } from "@/components/ui/list-row";
import { useTranslations } from "@/lib/i18n/context";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { type ApiTokenInfo } from "./_shared";
import { apiGet } from "@/lib/api/api-fetch";
@@ -114,35 +115,30 @@ function TokenStatusBadge({
const TOKEN_NAME_ISO_RE =
/^(.+?)\s+(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2}))(.*)$/;
// Locale-fixed dd.MM.yyyy HH:mm in the Europe/Berlin display zone — the
// rest of the app shows timestamps in Berlin time (`format-locale.ts`),
// so a token issued at 19:46 UTC must read as 21:46 in summer / 20:46
// in winter, not the raw UTC clock.
const TOKEN_NAME_DATE_FORMATTER = new Intl.DateTimeFormat("de-DE", {
timeZone: "Europe/Berlin",
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
function formatTokenName(name: string): string {
/**
* Issue #66 (date-format sweep) — this used to hard-code `de-DE` +
* `Europe/Berlin`, so a non-German admin saw a German-formatted
* timestamp baked into the token name regardless of their own locale
* or profile timezone. Routes through the same `fmt.dateTime` every
* other surface renders through, so the admin's locale, hour-cycle
* preference, and profile timezone all apply here too.
*/
function formatTokenName(name: string, fmt: Formatters): string {
const match = TOKEN_NAME_ISO_RE.exec(name);
if (!match) return name;
const [, prefix, iso, rest] = match;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return name;
// Intl emits "05.05.2026, 21:46" — strip the comma so the suffix
// reads as a single date+time chunk.
const stamp = TOKEN_NAME_DATE_FORMATTER.format(d).replace(",", "");
// `fmt.dateTime` emits "05.05.2026, 21:46" — strip the comma so the
// suffix reads as a single date+time chunk.
const stamp = fmt.dateTime(d).replace(",", "");
const formatted = `${prefix} · ${stamp}`;
return rest && rest.trim() ? `${formatted} ${rest.trim()}` : formatted;
}
export function ApiTokenOverviewSection() {
const { t } = useTranslations();
const fmt = useFormatters();
// v1.4.19 phase A7 — `/admin/api-tokens` is a dedicated single-
// section route. The previous pattern carried a "Collapse / Expand"
// toggle as an escape hatch from the v1.4 shared admin page where
@@ -232,7 +228,9 @@ export function ApiTokenOverviewSection() {
<TruncatedCell value={token.user.username} />
</td>
<td className="max-w-0 px-3 py-2">
<TruncatedCell value={formatTokenName(token.name)} />
<TruncatedCell
value={formatTokenName(token.name, fmt)}
/>
</td>
<td className="max-w-0 px-3 py-2">
<div className="flex flex-wrap gap-1">
@@ -303,7 +301,7 @@ export function ApiTokenOverviewSection() {
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<TruncatedCell
value={formatTokenName(token.name)}
value={formatTokenName(token.name, fmt)}
className="min-w-0 flex-1 font-medium"
/>
<TokenStatusBadge token={token} size="xs" />
@@ -230,29 +230,46 @@ export function AppLogPreviewSection() {
</tr>
</thead>
<tbody className="divide-border divide-y">
{events.map((event, i) => (
<tr
key={`${event.request_id}-${i}`}
className={`${i % 2 === 0 ? "bg-muted/30" : ""} hover:bg-muted cursor-pointer`}
onClick={() => setSelected(event)}
>
<td className="px-2 py-2">{levelIcon(event.level)}</td>
<td className="text-muted-foreground px-2 py-2 text-xs whitespace-nowrap">
{formatDateTime(event.timestamp)}
</td>
<td className="px-2 py-2 text-xs">
{event.action?.name ??
(`${event.http?.method ?? ""} ${event.http?.path ?? ""}`.trim() ||
event.kind)}
</td>
<td className="text-muted-foreground px-2 py-2 text-right font-mono text-xs">
{event.duration_ms} ms
</td>
<td className="text-muted-foreground px-2 py-2 font-mono text-xs">
{event.trace_id.slice(0, 8)}…
</td>
</tr>
))}
{events.map((event, i) => {
const actionLabel =
event.action?.name ??
(`${event.http?.method ?? ""} ${event.http?.path ?? ""}`.trim() ||
event.kind);
return (
<tr
key={`${event.request_id}-${i}`}
className={`${i % 2 === 0 ? "bg-muted/30" : ""} hover:bg-muted cursor-pointer`}
// Pointer convenience only — the row itself is not
// keyboard-reachable; the real interactive control is
// the button below (2026-07-17 a11y audit H1).
onClick={() => setSelected(event)}
>
<td className="px-2 py-2">{levelIcon(event.level)}</td>
<td className="text-muted-foreground px-2 py-2 text-xs whitespace-nowrap">
{formatDateTime(event.timestamp)}
</td>
<td className="px-2 py-2 text-xs">
<button
type="button"
onClick={() => setSelected(event)}
className="focus-visible:ring-ring w-full rounded text-left underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:outline-none"
aria-label={t("admin.section.app-logs.viewDetails", {
action: actionLabel,
timestamp: formatDateTime(event.timestamp),
})}
>
{actionLabel}
</button>
</td>
<td className="text-muted-foreground px-2 py-2 text-right font-mono text-xs">
{event.duration_ms} ms
</td>
<td className="text-muted-foreground px-2 py-2 font-mono text-xs">
{event.trace_id.slice(0, 8)}…
</td>
</tr>
);
})}
</tbody>
</table>
</div>
+19 -1
View File
@@ -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) }),
);
@@ -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 `<rect>`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(
<I18nProvider initialLocale="en">
<ComplianceHeatmap
dailyCompliance={{
"2026-01-05": { expected: 2, taken: 2, skipped: 0 },
}}
days={14}
/>
</I18nProvider>,
);
}
describe("<ComplianceHeatmap> — keyboard-reachable day cells", () => {
it("keeps the aggregate role=img summary on the SVG", () => {
const html = render();
expect(html).toMatch(/<svg[^>]*role="img"[^>]*aria-label="[^"]+"/);
});
it("makes every day cell focusable with its own aria-label", () => {
const html = render();
const rectMatches = html.match(/<rect\b[^>]*>/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="[^"]+"/);
}
});
});
@@ -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 `<rect>`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(
<I18nProvider initialLocale="en">
<MoodHeatmap
cells={{
"2026-01-05": { date: "2026-01-05", score: 4.2, samples: 2 },
}}
days={14}
/>
</I18nProvider>,
);
}
describe("<MoodHeatmap> — keyboard-reachable day cells", () => {
it("keeps the aggregate role=img summary on the SVG", () => {
const html = render();
expect(html).toMatch(/<svg[^>]*role="img"[^>]*aria-label="[^"]+"/);
});
it("makes every day cell focusable with its own aria-label", () => {
const html = render();
const rectMatches = html.match(/<rect\b[^>]*>/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="[^"]+"/);
}
});
});
@@ -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",
];
+33 -1
View File
@@ -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));
}}
/>
);
})}
@@ -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<string, DailyData>;
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 (
<div>
{showRangeControls ? (
<div className="mb-3 flex justify-end gap-1">
{TIME_RANGES.map((r) => (
<Button
key={r.label}
variant={activeRangePoints === r.points ? "default" : "ghost"}
aria-pressed={activeRangePoints === r.points}
size="sm"
className="min-h-11 px-3 text-xs"
onClick={() => setActiveRangePoints(r.points)}
title={r.title}
>
{r.label}
</Button>
))}
</div>
) : null}
{chartData.length === 0 ? (
<div className="text-muted-foreground flex h-48 items-center justify-center rounded-lg border border-dashed text-sm">
Keine Daten im gewählten Zeitraum
</div>
) : (
<div className="touch-pan-y">
<ResponsiveContainer width="100%" height={180}>
<LineChart data={chartData}>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.5}
/>
<XAxis
dataKey="date"
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
interval={chooseTickInterval(chartData.length, viewportWidth)}
/>
<YAxis
domain={[0, 100]}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
unit="%"
/>
<Tooltip
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
fontSize: "0.875rem",
}}
formatter={(value) => [`${value}%`, "Compliance"]}
/>
<ReferenceLine
y={80}
stroke="var(--dracula-green)"
strokeDasharray="5 5"
strokeOpacity={0.7}
label={{
value: "Ziel 80%",
position: "right",
fill: "var(--muted-foreground)",
fontSize: 10,
}}
/>
<Line
type="monotone"
dataKey="rate"
name="Compliance"
stroke="var(--dracula-purple)"
strokeWidth={2}
dot={{ r: 2, fill: "var(--dracula-purple)" }}
activeDot={{ r: 4 }}
connectNulls
/>
</LineChart>
</ResponsiveContainer>
</div>
)}
</div>
);
}
+3 -3
View File
@@ -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<ChartDataPoint>((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<string, Record<string, number>>();
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)) {
@@ -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],
);
+3 -3
View File
@@ -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<string, number>();
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);
}
+33 -1
View File
@@ -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));
}}
/>
);
})}
@@ -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({
/>
<XAxis
dataKey="day"
tickFormatter={(day: string) => 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 (
<RichChartTooltip
active
label={formatDateShort(dayToDate(point.day))}
label={fmt.dateShortSmart(dayToDate(point.day))}
rows={rows}
/>
);
@@ -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<RangeKey>("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 (
<RichChartTooltip
active
label={formatDateShort(new Date(point.timestamp))}
label={fmt.dateShortSmart(new Date(point.timestamp))}
rows={rows}
/>
);
+6 -2
View File
@@ -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",
)}
>
<p className="text-muted-foreground">{fmt.dateShort(new Date(p.t))}</p>
<p className="text-muted-foreground">
{fmt.dateShortSmart(new Date(p.t))}
</p>
<p className="text-foreground font-medium tabular-nums">
{t("cycle.bbt.tooltipTemp", { temp: fmt.number(p.temp) })}
</p>
+22 -12
View File
@@ -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 (
<div className="space-y-4">
@@ -92,8 +99,9 @@ export function PredictionsPanel({
<Row
hue={FERTILE_HUE}
label={t("cycle.predictions.fertileWindow")}
value={`${formatDate(prediction.fertileWindowStart)} – ${formatDate(
value={`${formatDate(prediction.fertileWindowStart, fmt)} – ${formatDate(
prediction.fertileWindowEnd,
fmt,
)}`}
/>
) : 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 (
<div className="space-y-1.5">
@@ -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),
})}
/>
<Badge variant={tone.variant} className="shrink-0">
@@ -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"
>
<span className="text-foreground tabular-nums">
{formatDate(c.startDate)}
{formatDate(c.startDate, fmt)}
{" – "}
{c.endDate
? formatDate(c.endDate)
? formatDate(c.endDate, fmt)
: t("cycle.history.ongoing")}
</span>
<span className="text-muted-foreground flex items-center gap-2 tabular-nums">
+1 -1
View File
@@ -138,7 +138,7 @@ export function TodayHero({ digest }: { digest: DailyDigest }) {
>
{topSignal.headline}
{topSignal.delta ? (
<span className="text-muted-foreground/80">
<span className="text-muted-foreground">
{" "}
· {topSignal.delta}
</span>
@@ -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("<RecentWorkoutsTile>", () => {
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", () => {
@@ -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 — `<RecentWorkoutsTile>`.
@@ -36,33 +30,6 @@ import { getDateTimeFormat } from "@/lib/intl/formatter-cache";
* round-trip per session.
*/
const SPORT_TYPE_ICON: Record<string, LucideIcon> = {
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.
<p
data-slot="recent-workouts-loading"
className="text-muted-foreground min-h-[10rem] text-xs"
>
{t("dashboard.recentWorkouts.loading")}
</p>
// v1.29.x — row-shaped skeletons instead of a "loading…" text
// line, matching the sport-icon + label + day + duration shape
// the loaded rows render.
<div data-slot="recent-workouts-loading" className="space-y-2">
{[0, 1, 2].map((i) => (
<div key={i} className="flex items-center gap-3 px-1 py-1">
<Skeleton className="size-7 shrink-0 rounded-full" />
<Skeleton className="h-4 min-w-0 flex-1" />
<Skeleton className="h-4 w-12 shrink-0" />
<Skeleton className="h-4 w-8 shrink-0" />
</div>
))}
</div>
) : isError ? (
<QueryErrorCard
onRetry={() => refetch()}
className="border-0 bg-transparent shadow-none"
/>
) : workouts.length === 0 ? (
<div data-slot="recent-workouts-empty" className="space-y-1">
<p className="text-sm">{t("dashboard.recentWorkouts.empty.title")}</p>
@@ -175,6 +175,57 @@ describe("<DocumentCard>", () => {
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(
<DocumentCard
document={doc({
createdAt: new Date().toISOString(),
hasContentIndex: false,
})}
selected={false}
onToggleSelected={noop}
onOpen={noop}
highlighted={false}
/>,
);
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(
<DocumentCard
document={doc({
createdAt: new Date().toISOString(),
hasContentIndex: true,
})}
selected={false}
onToggleSelected={noop}
onOpen={noop}
highlighted={false}
/>,
);
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(
<DocumentCard
document={doc({ hasContentIndex: false })}
selected={false}
onToggleSelected={noop}
onOpen={noop}
highlighted={false}
/>,
);
expect(html).not.toContain('data-slot="document-processing-badge"');
});
});
describe("<UploadStateCard>", () => {
@@ -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);
});
});
+56 -4
View File
@@ -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 (
<Card
data-slot="document-card"
@@ -163,7 +191,9 @@ export function DocumentCard({
/>
</div>
{document.conditionLinks.length > 0 ||
document.servingClass === "attachment" ? (
document.servingClass === "attachment" ||
processing ||
justIndexed ? (
<div className="mt-auto flex flex-wrap items-center gap-1.5 pt-1">
{document.conditionLinks.map((link) => (
<span
@@ -182,6 +212,28 @@ export function DocumentCard({
{t("documents.card.attachmentBadge")}
</Badge>
) : null}
{processing ? (
<Badge
variant="outline"
data-slot="document-processing-badge"
className="text-muted-foreground gap-1 text-xs font-normal"
>
<Loader2
className="size-3 animate-spin motion-reduce:animate-none"
aria-hidden
/>
{t("documents.card.processing")}
</Badge>
) : justIndexed ? (
<Badge
variant="outline"
data-slot="document-ready-badge"
className="text-success gap-1 text-xs font-normal"
>
<Check className="size-3" aria-hidden />
{t("documents.card.ready")}
</Badge>
) : null}
</div>
) : null}
</CardContent>
@@ -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(
+43
View File
@@ -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<InboundDocumentDto, "createdAt" | "hasContentIndex">,
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
@@ -34,7 +34,7 @@ function DayLogRow({ log }: { log: IllnessDayLogDTO }) {
return (
<div className="border-border/60 space-y-2 border-l-2 pl-3">
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{fmt.dateShort(`${log.date}T12:00:00`)}
{fmt.dateShortSmart(`${log.date}T12:00:00`)}
</p>
{log.symptoms.length > 0 ? (
@@ -82,11 +82,11 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
</Badge>
<span className="text-muted-foreground text-xs">
{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)),
})}`
: ""}
</span>
+1 -1
View File
@@ -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 (
+4 -4
View File
@@ -103,7 +103,7 @@ function EpisodeCard({
: t("illness.status.recovered")}
</Badge>
<span className="text-muted-foreground">
{fmt.dateShort(new Date(episode.onsetAt))}
{fmt.dateShortSmart(new Date(episode.onsetAt))}
</span>
</div>
</div>
@@ -167,11 +167,11 @@ function EpisodeCard({
<p className="text-muted-foreground text-xs">
{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)),
})}`
: ""}
</p>
@@ -192,7 +192,7 @@ function EpisodeCard({
>
<span className="truncate">{flare.label}</span>
<span className="text-muted-foreground shrink-0">
{fmt.dateShort(new Date(flare.onsetAt))}
{fmt.dateShortSmart(new Date(flare.onsetAt))}
</span>
</Link>
</li>
@@ -70,10 +70,11 @@ describe("<InsightsTabStrip> — availability gating (v1.4.27 F19)", () => {
hasMedication: false,
};
const html = render(<InsightsTabStrip availability={availability} />);
// 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("<InsightsTabStrip> — availability gating (v1.4.27 F19)", () => {
};
const html = render(<InsightsTabStrip availability={availability} />);
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("<InsightsTabStrip> — vitals group collapse (v1.4.34 IW-D)", () => {
};
const html = render(<InsightsTabStrip availability={availability} />);
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("<InsightsTabStrip> — 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(<InsightsTabStrip availability={fullAvailability} />);
// 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("<InsightsTabStrip> — 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(<InsightsTabStrip availability={heartAvailability} />);
const parentMatches = html.match(/>Heart</g);
expect(parentMatches).not.toBeNull();
expect(parentMatches!.length).toBe(1);
expect(html).toContain('data-group="heart"');
expect(html).not.toContain(">Pulse<");
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(<InsightsTabStrip availability={availability} />);
expect(html).not.toContain(">Heart<");
expect(html).not.toContain('data-group="heart"');
});
});
describe("<InsightsTabStrip> — 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(<InsightsTabStrip availability={availability} />);
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(<InsightsTabStrip availability={availability} />);
expect(html).not.toContain(">Workouts<");
expect(html).toContain('data-group="activity"');
});
});
@@ -255,8 +320,10 @@ describe("<InsightsTabStrip> — 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(
<InsightsTabStrip
@@ -265,11 +332,11 @@ describe("<InsightsTabStrip> — 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(
<InsightsTabStrip
@@ -277,7 +344,7 @@ describe("<InsightsTabStrip> — 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("<InsightsTabStrip> — 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(<InsightsTabStrip availability={dataAvailability} />);
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("<InsightsTabStrip> — 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("<InsightsTabStrip> — 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("<InsightsTabStrip> — 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(
<InsightsTabStrip
availability={moduleAvailability}
@@ -435,12 +505,14 @@ describe("<InsightsTabStrip> — 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("<InsightsTabStrip> — 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("<InsightsTabStrip> — 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<");
}
@@ -161,7 +161,13 @@ describe("<MetricTargetSummary>", () => {
// 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");
@@ -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(
<I18nProvider initialLocale={locale}>
<SleepHypnogram session={session} />
</I18nProvider>,
);
}
describe("<SleepHypnogram> — 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("<SleepHypnogram> — 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("<SleepHypnogram> — 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("<SleepHypnogram> — 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"',
);
});
});
@@ -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");
@@ -102,6 +102,24 @@ export function launchScopeToCoachScope(
};
}
/**
* v1.29.x — a stale/invalid `?doc=<id>` 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=<id>` 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.
@@ -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 <VitalsInfoTip method={method} standard={meta.standard} />;
return (
<InfoPopover
content={method}
link={meta.standard}
label={t("insights.derived.vitals.infoLabel")}
/>
);
}
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.
@@ -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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
data-slot="vitals-info-trigger"
aria-label={t("insights.derived.vitals.infoLabel")}
aria-expanded={open}
className={cn(
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
"inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full",
"-mx-2 -my-3",
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
className,
)}
>
<Info className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</PopoverTrigger>
<PopoverContent
data-slot="vitals-info-body"
align="end"
sideOffset={6}
className="max-w-xs space-y-1.5"
>
<p className="text-muted-foreground text-xs leading-snug">{method}</p>
{standard ? (
<a
href={standard.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary block text-xs leading-snug hover:underline"
>
{standard.name}
</a>
) : null}
</PopoverContent>
</Popover>
);
}
@@ -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({
<div className="space-y-0.5">
<dt className="text-muted-foreground flex items-center gap-1 text-xs">
{label}
<InfoHint label={help} />
<InfoPopover content={help} />
</dt>
<dd className="text-foreground text-base font-semibold tabular-nums">
{value}
@@ -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() {
<div className="space-y-0.5" data-slot="glucose-cv">
<span className="text-muted-foreground flex items-center gap-1 text-xs">
{t("insights.bloodGlucose.clinical.cv.label")}
<InfoHint label={t("insights.bloodGlucose.clinical.cv.help")} />
<InfoPopover
content={t("insights.bloodGlucose.clinical.cv.help")}
/>
</span>
<div className="flex items-center gap-1.5">
<span className="text-foreground text-lg font-semibold tabular-nums">
@@ -285,7 +287,7 @@ function Metric({
<span className="text-muted-foreground flex items-center gap-1 text-xs">
{icon}
{label}
{help ? <InfoHint label={help} /> : null}
{help ? <InfoPopover content={help} /> : null}
</span>
<span className="text-foreground flex items-baseline gap-1">
<span className="text-lg font-semibold tabular-nums">{value}</span>
@@ -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 };
+38 -11
View File
@@ -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<Record<SubPageSlug, ModuleKey>> = {
"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<SubPageGroup>();
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;
}
@@ -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 `<QueryErrorCard>` 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<IntradayPulseDto>(`/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 (
<div
@@ -167,9 +224,22 @@ export function IntradayPulseChart({
}
/>
<p className="text-muted-foreground text-xs leading-snug">{caption}</p>
{coverageNote ? (
<p
data-slot="intraday-pulse-coverage"
className="text-muted-foreground text-xs leading-snug"
>
{coverageNote}
</p>
) : null}
{isLoading ? (
<div className="bg-muted/40 h-44 animate-pulse rounded-lg motion-reduce:animate-none" />
) : isError ? (
<QueryErrorCard
onRetry={() => refetch()}
className="border-0 bg-transparent shadow-none"
/>
) : chartData.length === 0 ? (
<div className="text-muted-foreground flex h-44 items-center justify-center rounded-lg border border-dashed text-sm">
{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. */}
<Area
type="monotone"
dataKey="bpm"
stroke="var(--chart-1)"
strokeWidth={2}
fill="url(#intradayPulseFill)"
dot={false}
dot={{ r: 2, fill: "var(--chart-1)", strokeWidth: 0 }}
activeDot={{ r: 4 }}
connectNulls
connectNulls={false}
/>
</AreaChart>
</ResponsiveContainer>
@@ -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", () => {
@@ -66,12 +66,16 @@ const Y_LABEL_KEY: Record<MetricKind, string> = {
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<MetricKind, string> = {
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<CorrelationResult["strength"], string> = {
@@ -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<number, string> = {
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) => (
<Cell
key={row.score}
fill={BAR_COLOR_BY_SCORE[row.score] ?? "var(--dracula-purple)"}
fill={BAR_COLOR_BY_SCORE[row.score] ?? "var(--primary)"}
/>
))}
</Bar>
@@ -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[] }) {
@@ -47,11 +47,13 @@ const LABEL_KEYS: Record<MoodTimeOfDayBucket, string> = {
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({
Loaded 100 of 219 files, more files were not shown because too many files have changed in this diff. Show more