mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
219 files changed
+10272
-4515
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
Reference in new issue
Block a user