mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
Merge pull request #333 from MBombeck/release/v1.17.0
v1.17.0 — clinical depth for glucose and sleep, three new sources
This commit is contained in:
177 files changed
+16355
-1423
No files matched your search
@@ -50,6 +50,29 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# WHOOP_WEBHOOK_SECRET="<openssl rand -hex 32>"
|
||||
# WHOOP_REDIRECT_URI=""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Polar AccessLink -- optional integration (full OAuth, shared app from env)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Register an app at https://admin.polaraccesslink.com and set its redirect URI
|
||||
# to `${NEXT_PUBLIC_APP_URL}/api/polar/callback`. Both vars together enable the
|
||||
# Polar connect button; set neither to leave the integration disabled. Polar
|
||||
# access tokens do not expire and carry no refresh token. POLAR_REDIRECT_URI
|
||||
# overrides the derived callback when the public URL differs.
|
||||
# POLAR_CLIENT_ID=""
|
||||
# POLAR_CLIENT_SECRET=""
|
||||
# POLAR_REDIRECT_URI=""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Oura Cloud v2 -- optional integration (full OAuth, shared app from env)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Register an app at https://cloud.ouraring.com/oauth/applications and set its
|
||||
# redirect URI to `${NEXT_PUBLIC_APP_URL}/api/oura/callback`. Both vars together
|
||||
# enable the Oura connect button. Oura uses refresh tokens; the sync refreshes
|
||||
# reactively on a 401. OURA_REDIRECT_URI overrides the derived callback.
|
||||
# OURA_CLIENT_ID=""
|
||||
# OURA_CLIENT_SECRET=""
|
||||
# OURA_REDIRECT_URI=""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# APNs -- iOS push (optional, all-or-none)
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
# vouch for.
|
||||
- name: Fetch Dependabot metadata
|
||||
id: metadata
|
||||
uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a # v2.5.0
|
||||
uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
|
||||
- name: Compute image labels
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: ${{ env.IMAGE_REF }}
|
||||
labels: |
|
||||
@@ -301,7 +301,7 @@ jobs:
|
||||
|
||||
- name: Compute image tags & labels
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: ${{ env.IMAGE_REF }}
|
||||
tags: |
|
||||
|
||||
@@ -2,6 +2,38 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.17.0] — 2026-06-14 — clinical depth for glucose and sleep, and three new sources
|
||||
|
||||
This release builds new depth on the coherent foundation laid over the v1.16 line. Blood glucose gains a clinical panel, sleep gains a debt and chronotype reading, and three new data sources — Nightscout, Polar and Oura — join Withings, WHOOP, Fitbit and Apple Health. Every new metric is computed once on the server and read the same way on the dashboard, the coach, the doctor-report and the companion app, and each holds back a confident reading behind a calm "still learning" state until it has enough data. No breaking changes.
|
||||
|
||||
### Added
|
||||
|
||||
- **Blood-glucose clinical panel.** Beneath the glucose chart, a panel reads time-in-range on the consensus bands, the glucose management indicator and an estimated A1C, and the coefficient of variation with an instability flag — with the J-index and the low/high blood-glucose risk indices behind an advanced view. It is honest about its limits: a panel built from spot readings says so, and it waits until enough readings over enough days have accrued before asserting anything.
|
||||
- **Nightscout.** Connect a self-hosted Nightscout instance and its continuous glucose readings flow in — the density that makes the clinical panel meaningful. Public instances work by default; a per-connection toggle allows a private or home-network instance.
|
||||
- **Sleep debt and chronotype.** The sleep page now shows a rolling sleep debt against your age-based need and an MCTQ chronotype with your mid-sleep, social jetlag and a type band — the deeper detail behind an advanced view, and the type held back until enough free-day nights are recorded.
|
||||
- **Polar and Oura.** Connect either over OAuth to bring in sleep, heart rate, respiratory rate, activity and recovery. Recovery now reads from the strongest connected source — WHOOP, then Oura, then Polar, then the computed proxy.
|
||||
- **Reorder-aware medication supply.** The low-stock warning now accounts for how long a refill takes, so a weekly medication is flagged before its last dose rather than after, with a concrete "runs out on … — reorder by …" date. A reorder lead time is configurable globally and per medication.
|
||||
- **Invite deep-link.** Invites open through a link the companion app can intercept to start a prefilled registration, with a browser fallback.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The coach reasons from the new signals.** Glucose time-in-range, sleep debt and chronotype, and a connected device's native recovery and strain now reach the coach, which prefers the device's gold-standard number over a computed proxy and respects each metric's still-learning state.
|
||||
- **Mood and cycle entry is one sectioned sheet.** A quick row of your recent tags stays open, the rest folds into collapsible sections with a count of what's set inside — fast by default, deep on demand.
|
||||
- **Wearable data you already had now surfaces.** Heart-rate variability from a ring or strap appears on the HRV view, and Oura and Polar steps, energy and pulse are ranked as real sources.
|
||||
- **Onboarding tells the truth.** The setup flow now presents the sources that actually shipped, signposts importing existing history, and sets the expectation that some insights sharpen over the first week or two — instead of promising a configuration that did not happen.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The sleep "last night" timeline bar returns for nights whose stages share an end instant, and the dashboard clears a recorded dose without a reload (carried forward from v1.16.17).
|
||||
|
||||
### Security
|
||||
|
||||
- Outbound Nightscout calls pin a public host by default with an explicit private opt-in, the stored token is kept out of any recorded error, and the new OAuth connections verify a signed, expiring state before exchanging a code.
|
||||
|
||||
### Operator note
|
||||
|
||||
- On the first nightly low-stock check after deploy, a medication already flagged low re-notifies once as the warning threshold moves to its lead-time-aware value. Expected and bounded.
|
||||
|
||||
## [1.16.17] — 2026-06-14 — the sleep timeline returns, and the dashboard clears after a dose
|
||||
|
||||
Two fixes to behaviour that surfaced in daily use. No breaking changes.
|
||||
|
||||
@@ -77,6 +77,18 @@ services:
|
||||
# container (the `environment:` block is a whitelist).
|
||||
WHOOP_WEBHOOK_SECRET: "${WHOOP_WEBHOOK_SECRET:-}"
|
||||
WHOOP_REDIRECT_URI: "${WHOOP_REDIRECT_URI:-}"
|
||||
# v1.17.0 (F4) — Polar AccessLink + Oura Cloud OAuth (shared app from
|
||||
# env). Both pairs together enable each connect button; an unset pair
|
||||
# leaves that integration disabled. The optional *_REDIRECT_URI overrides
|
||||
# the derived `${NEXT_PUBLIC_APP_URL}/api/{polar,oura}/callback`. Listed
|
||||
# here so operator values in .env reach the container (the `environment:`
|
||||
# block is a whitelist).
|
||||
POLAR_CLIENT_ID: "${POLAR_CLIENT_ID:-}"
|
||||
POLAR_CLIENT_SECRET: "${POLAR_CLIENT_SECRET:-}"
|
||||
POLAR_REDIRECT_URI: "${POLAR_REDIRECT_URI:-}"
|
||||
OURA_CLIENT_ID: "${OURA_CLIENT_ID:-}"
|
||||
OURA_CLIENT_SECRET: "${OURA_CLIENT_SECRET:-}"
|
||||
OURA_REDIRECT_URI: "${OURA_REDIRECT_URI:-}"
|
||||
# Deploy-result webhook (optional). DEPLOY_WEBHOOK_SECRET authenticates
|
||||
# POST /api/internal/deploy-webhook; DEPLOY_LOGS_URL is the
|
||||
# deployment-dashboard URL (e.g. the Coolify UI) linked in
|
||||
|
||||
+696
-2
File diff suppressed because it is too large.
Load diff
@@ -143,9 +143,8 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
|
||||
|
||||
// Wait for the pills to mount (queries resolve after first paint).
|
||||
const pills = page.locator('[data-testid="integration-status-pill"]');
|
||||
// Three integration cards now (Withings, WHOOP, Google Health) → three
|
||||
// pills.
|
||||
await expect(pills).toHaveCount(3, { timeout: 10_000 });
|
||||
await expect(pills).toHaveCount(6, { timeout: 10_000 });
|
||||
|
||||
// Withings is connected in this fixture; WHOOP is a BYO-keys card that
|
||||
// stays dormant until an operator adds credentials, so assert the one
|
||||
|
||||
+194
-15
@@ -720,7 +720,12 @@
|
||||
"iconGroupWeather": "Wetter",
|
||||
"iconGroupPlaces": "Orte",
|
||||
"iconGroupMisc": "Sonstiges"
|
||||
}
|
||||
},
|
||||
"sectionMoreTags": "Weitere Tags",
|
||||
"sectionFactors": "Faktoren",
|
||||
"sectionNoteDetails": "Notiz & Details",
|
||||
"quickTagsHelp": "Häufig genutzte Tags – ein Tipp genügt",
|
||||
"quickTagsExpand": "Weitere"
|
||||
},
|
||||
"medications": {
|
||||
"title": "Medikamente",
|
||||
@@ -2666,6 +2671,35 @@
|
||||
"title": "Noch keine Schlafdaten",
|
||||
"description": "Apple-Health-Sync folgt mit v1.5 und füllt diese Ansicht automatisch.",
|
||||
"action": "Geräte verwalten"
|
||||
},
|
||||
"debt": {
|
||||
"title": "Schlafschuld",
|
||||
"value": "{hours} Std. {minutes} Min. zu wenig",
|
||||
"caption": "Kumuliertes Defizit der letzten {nights} Nächte",
|
||||
"clearValue": "Ausgeglichen",
|
||||
"clearCaption": "Kein aufgelaufenes Defizit über die letzten {nights} Nächte",
|
||||
"learning": "Dein Schlaf wird noch erfasst — {counted} Nächte bisher, noch {remaining} für eine verlässliche Bilanz."
|
||||
},
|
||||
"chronotype": {
|
||||
"title": "Chronotyp",
|
||||
"learning": "Dein Rhythmus wird noch erfasst — {counted} von {total} freien Nächten. Ein Chronotyp braucht ein paar weckerfreie Morgen, um aussagekräftig zu sein.",
|
||||
"midSleepCaption": "Schlafmitte an freien Tagen gegen {clock}",
|
||||
"advancedToggle": "Mehr Details",
|
||||
"socialJetlagValue": "{hours} Std. {minutes} Min. sozialer Jetlag",
|
||||
"socialJetlagCaption": "Der Unterschied zwischen deinem Schlaf an Arbeits- und freien Tagen — wie eine kleine wöchentliche Zeitzonenverschiebung.",
|
||||
"msfScValue": "Korrigierte Schlafmitte {clock}",
|
||||
"msfScCaption": "Bereinigt um den Schlaf, den du am Wochenende nachholst (MSFsc).",
|
||||
"dayTypeNote": "Freie Tage werden als Wochenenden angenommen, bis eine Tagestyp-Erfassung ergänzt wird.",
|
||||
"band": {
|
||||
"extreme_early": "Ausgeprägter Morgentyp",
|
||||
"early": "Morgentyp",
|
||||
"intermediate": "Mischtyp",
|
||||
"late": "Abendtyp",
|
||||
"extreme_late": "Ausgeprägter Abendtyp"
|
||||
}
|
||||
},
|
||||
"rhythm": {
|
||||
"loadError": "Dein Schlafrhythmus konnte gerade nicht geladen werden. Er erscheint wieder, sobald die Verbindung steht."
|
||||
}
|
||||
},
|
||||
"correlations": "Korrelationen",
|
||||
@@ -2795,6 +2829,71 @@
|
||||
"emptyState": {
|
||||
"title": "Noch keine Glukosedaten",
|
||||
"description": "Blutzuckerwerte erscheinen hier, sobald du einen erfasst oder ein Gerät synchronisiert."
|
||||
},
|
||||
"clinical": {
|
||||
"title": "Glukose-Überblick",
|
||||
"subtitle": "Ein Blick auf deine letzten {days} Tage, aus {count} Messungen.",
|
||||
"spotCaveat": "Das sind Schätzungen aus einzelnen Stichproben-Messungen, kein Profil eines kontinuierlichen Sensors – lies sie als Richtung, nicht als klinisches Urteil.",
|
||||
"learningTitle": "Lerne deine Glukose noch kennen",
|
||||
"learningBody": "Bisher sind das {count} über {days}. Mit etwas mehr Verlauf wird das Bild klarer – mess weiter oder lass es ein verbundenes Gerät ergänzen.",
|
||||
"learningBodyEmpty": "Sobald ein paar Messungen vorliegen – von dir erfasst oder von einem Gerät synchronisiert – nehmen Zeit-im-Zielbereich, Durchschnitt und Schwankung hier Gestalt an.",
|
||||
"readingsCount": "{count} Messungen",
|
||||
"readingsCountOne": "1 Messung",
|
||||
"daysCount": "{days} Tage",
|
||||
"daysCountOne": "1 Tag",
|
||||
"tir": {
|
||||
"title": "Zeit im Zielbereich",
|
||||
"caption": "Anteil der Messungen je Bereich (70–180 mg/dl ist das Ziel).",
|
||||
"veryLow": "Sehr niedrig",
|
||||
"low": "Niedrig",
|
||||
"inRange": "Im Zielbereich",
|
||||
"high": "Hoch",
|
||||
"veryHigh": "Sehr hoch",
|
||||
"ariaLabel": "Zeit im Zielbereich: {tir}% im Ziel, {below}% darunter, {above}% darüber.",
|
||||
"empty": "Noch keine Messungen für das Diagramm."
|
||||
},
|
||||
"gmi": {
|
||||
"label": "Glukose-Management-Indikator",
|
||||
"help": "Eine HbA1c-ähnliche Schätzung aus deiner durchschnittlichen Glukose."
|
||||
},
|
||||
"eA1c": {
|
||||
"label": "Geschätzter HbA1c",
|
||||
"help": "Eine HbA1c-Schätzung aus deinem mittleren Glukosewert (ADAG)."
|
||||
},
|
||||
"mean": {
|
||||
"label": "Durchschnittliche Glukose"
|
||||
},
|
||||
"cv": {
|
||||
"label": "Schwankung",
|
||||
"steady": "Stabil",
|
||||
"unstable": "Instabil",
|
||||
"help": "Variationskoeffizient. Ab 36 % gilt die Glukose als instabil; niedriger ist stabiler."
|
||||
},
|
||||
"advanced": {
|
||||
"toggle": "Erweiterte Indizes",
|
||||
"intro": "Forschungsnahe Kennzahlen für den genaueren Blick. Jede fasst deine Messungen zu einer risikogewichteten Zahl zusammen.",
|
||||
"jIndex": {
|
||||
"label": "J-Index",
|
||||
"help": "Ein einzelner Wert aus Durchschnitt und Streuung – niedriger steht für engere Kontrolle."
|
||||
},
|
||||
"lbgi": {
|
||||
"label": "Risiko für Tiefwerte (LBGI)",
|
||||
"help": "Kovatchevs Index, gewichtet danach, wie oft und wie tief Werte ins Tief rutschen."
|
||||
},
|
||||
"hbgi": {
|
||||
"label": "Risiko für Hochwerte (HBGI)",
|
||||
"help": "Kovatchevs Index, gewichtet danach, wie oft und wie hoch Werte steigen."
|
||||
},
|
||||
"byContext": {
|
||||
"title": "Nach Kontext",
|
||||
"fasting": "Nüchtern",
|
||||
"postprandial": "Nach dem Essen",
|
||||
"random": "Beliebig",
|
||||
"bedtime": "Vor dem Schlafen",
|
||||
"readingsShort": "{count} Messungen",
|
||||
"readingsShortOne": "1 Messung"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"navBloodGlucose": "Glukose",
|
||||
@@ -3549,7 +3648,7 @@
|
||||
},
|
||||
"goals": {
|
||||
"title": "Was möchtest du erfassen?",
|
||||
"body": "Wähle die Bereiche, die dir wichtig sind. Dashboard und Erinnerungen richten sich danach.",
|
||||
"body": "Wähle die Bereiche, die dir wichtig sind – das hilft dir zu entscheiden, was du verfolgen und als Nächstes einrichten willst.",
|
||||
"helpHint": "Ein bis drei Schwerpunkte funktionieren erfahrungsgemäß am besten – mehr geht aber jederzeit.",
|
||||
"options": {
|
||||
"weightManagement": "Gewicht im Griff",
|
||||
@@ -3562,7 +3661,7 @@
|
||||
},
|
||||
"source": {
|
||||
"title": "Quelle verbinden",
|
||||
"body": "Werte automatisch übernehmen oder weiter manuell eintragen – deine Entscheidung.",
|
||||
"body": "Werte automatisch übernehmen oder weiter manuell eintragen. Hier wird nichts für dich eingerichtet – jede Karte zeigt nur, wo du sie verbindest.",
|
||||
"recommended": "Empfohlen",
|
||||
"manual": {
|
||||
"title": "Manuelle Eingabe",
|
||||
@@ -3573,12 +3672,17 @@
|
||||
"body": "Waagen, Blutdruckmesser und Tracker von Withings synchronisieren automatisch.",
|
||||
"cta": "Withings verbinden"
|
||||
},
|
||||
"appleHealth": {
|
||||
"title": "Apple Health",
|
||||
"body": "iPhone- und Apple-Watch-Daten werden über die HealthLog-iOS-App eingelesen.",
|
||||
"badge": "Mit v1.5 (iOS)",
|
||||
"disabledHint": "Verfügbar, sobald die iOS-App ausgeliefert ist."
|
||||
}
|
||||
"more": {
|
||||
"title": "Weitere Quellen",
|
||||
"body": "Verbinde eine davon in den Einstellungen, wann immer du bereit bist.",
|
||||
"appleHealth": "Apple Health",
|
||||
"whoop": "WHOOP",
|
||||
"oura": "Oura",
|
||||
"polar": "Polar",
|
||||
"nightscout": "Nightscout",
|
||||
"fitbit": "Fitbit"
|
||||
},
|
||||
"import": "Bestehende Daten importieren"
|
||||
},
|
||||
"baseline": {
|
||||
"title": "Kurz zu dir",
|
||||
@@ -3598,7 +3702,11 @@
|
||||
"done": {
|
||||
"title": "Alles bereit",
|
||||
"body": "Willkommen an Bord. Dein Dashboard wartet.",
|
||||
"returnCta": "Zum Dashboard"
|
||||
"returnCta": "Zum Dashboard",
|
||||
"learning": "Manche Einblicke – Glukose, Schlafrhythmus, Blutdrucktrends, Zyklus – schärfen sich in den ersten ein bis zwei Wochen, sobald sich deine Daten ansammeln. Leere Kacheln heißen jetzt nur, dass wir deine Basislinie noch lernen.",
|
||||
"connectCta": "Gerät verbinden",
|
||||
"logCta": "Ersten Wert eintragen",
|
||||
"importCta": "Bestehende Daten importieren"
|
||||
},
|
||||
"welcomeBack": {
|
||||
"title": "Willkommen zurück",
|
||||
@@ -3643,9 +3751,9 @@
|
||||
"medicationTitle": "Medikament hinzufügen",
|
||||
"medicationDescription": "Verfolge die Therapietreue und erhalte Erinnerungen genau dann, wenn du sie brauchst.",
|
||||
"medicationCta": "Medikament hinzufügen",
|
||||
"withingsTitle": "Withings verbinden (optional)",
|
||||
"withingsDescription": "Synchronisiere Gewicht, Blutdruck und Puls automatisch aus deinen Withings-Geräten.",
|
||||
"withingsCta": "Verbinden",
|
||||
"dataSourceTitle": "Datenquelle verbinden (optional)",
|
||||
"dataSourceDescription": "Gewicht, Blutdruck, Schlaf und mehr automatisch aus Withings, WHOOP, Oura, Polar, Nightscout, Fitbit oder Apple Health.",
|
||||
"dataSourceCta": "Verbinden",
|
||||
"notificationsTitle": "Benachrichtigungen einrichten",
|
||||
"notificationsDescription": "Wähle, wie HealthLog dich erinnert — Telegram, ntfy oder Web Push.",
|
||||
"notificationsCta": "Benachrichtigungen öffnen"
|
||||
@@ -4470,6 +4578,68 @@
|
||||
"enable": "Zyklus-Tracking aktivieren",
|
||||
"manage": "Zyklus-Tracking öffnen",
|
||||
"error": "Zyklus-Tracking konnte nicht aktualisiert werden."
|
||||
},
|
||||
"nightscout": "Nightscout",
|
||||
"nightscoutTag": "CGM",
|
||||
"nightscoutDescription": "Verbinde deine eigene Nightscout-Seite, um kontinuierliche Glukosewerte für das Glukose-Panel zu synchronisieren.",
|
||||
"nightscoutViewData": "Glukose ansehen",
|
||||
"nightscoutHelp": "Richte HealthLog auf deine Nightscout-Instanz aus. Werte werden stündlich im Hintergrund abgerufen.",
|
||||
"nightscoutUrl": "Nightscout-URL",
|
||||
"nightscoutUrlSavedPlaceholder": "Gespeichert — neue URL eingeben zum Ersetzen",
|
||||
"nightscoutToken": "API-Token",
|
||||
"nightscoutTokenOptional": "Optional bei einer öffentlichen Seite",
|
||||
"nightscoutTokenSavedPlaceholder": "Gespeichert — neuen Token eingeben zum Ersetzen",
|
||||
"nightscoutTokenHelp": "Füge einen Zugriffstoken aus deinen Nightscout-Admin-Tools ein. Leer lassen, wenn deine Seite öffentlich lesbar ist.",
|
||||
"nightscoutPrivateHost": "Diese Seite läuft in einem privaten Netzwerk",
|
||||
"nightscoutPrivateHostHelp": "Nur aktivieren, wenn deine Nightscout-Instanz unter einer LAN-, VPN- oder Tailscale-Adresse läuft. Aus bei öffentlichen Seiten.",
|
||||
"nightscoutConnect": "Verbinden",
|
||||
"nightscoutUpdate": "Änderungen speichern",
|
||||
"nightscoutConnected": "Nightscout verbunden.",
|
||||
"nightscoutConnectFailed": "Verbindung zu Nightscout fehlgeschlagen.",
|
||||
"nightscoutDisconnect": "Trennen",
|
||||
"nightscoutDisconnectTitle": "Nightscout trennen?",
|
||||
"nightscoutDisconnectDescription": "Künftige Synchronisierungen stoppen. Bereits importierte Glukosewerte bleiben in deinem Verlauf.",
|
||||
"polar": "Polar",
|
||||
"polarDescription": "Verbinde dein Polar-Konto, um Nightly-Recharge-Erholung, Schlafphasen und Tagesaktivität zu synchronisieren.",
|
||||
"polarViewData": "Schlaf & Erholung ansehen",
|
||||
"polarHelp": "Melde dich bei Polar an, um den Zugriff zu erlauben. Daten werden stündlich im Hintergrund abgerufen.",
|
||||
"polarUnavailable": "Polar ist auf diesem Server nicht konfiguriert. Bitte deinen Administrator, die Polar-OAuth-Zugangsdaten zu hinterlegen.",
|
||||
"polarConnect": "Polar verbinden",
|
||||
"polarDisconnect": "Trennen",
|
||||
"polarDisconnectTitle": "Polar trennen?",
|
||||
"polarDisconnectDescription": "Künftige Synchronisierungen stoppen. Bereits importierte Werte bleiben in deinem Verlauf.",
|
||||
"polarOauthConnected": "Polar verbunden — die erste Synchronisierung startet im Hintergrund.",
|
||||
"polarOauthFailed": "Polar-Verbindung fehlgeschlagen",
|
||||
"polarOauthError": {
|
||||
"csrf1": "Die Sicherheitsprüfung ist fehlgeschlagen, weil das Browser-Cookie fehlte. Bitte starte die Verbindung im selben Browser erneut.",
|
||||
"state": "Die Antwort von Polar passte zu keiner offenen Verbindungsanfrage. Bitte starte die Verbindung erneut.",
|
||||
"cross_user": "Die Antwort gehört zu einer Anfrage eines anderen Kontos. Bitte starte die Verbindung aus deinem eigenen Konto erneut.",
|
||||
"nocode": "Polar hat keinen Autorisierungscode zurückgegeben. Versuche es erneut und erlaube den Zugriff im Polar-Dialog.",
|
||||
"nocreds": "Polar ist auf diesem Server nicht konfiguriert. Bitte deinen Administrator, die Polar-OAuth-Zugangsdaten zu hinterlegen.",
|
||||
"token": "Der Token-Austausch mit Polar ist fehlgeschlagen. Bitte starte die Verbindung erneut.",
|
||||
"rate_limited": "Zu viele Verbindungsversuche. Warte einen Moment und versuche es erneut.",
|
||||
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut."
|
||||
},
|
||||
"oura": "Oura",
|
||||
"ouraDescription": "Verbinde dein Oura-Konto, um Readiness, Schlafphasen, HRV und Tagesaktivität zu synchronisieren.",
|
||||
"ouraViewData": "Schlaf & Erholung ansehen",
|
||||
"ouraHelp": "Melde dich bei Oura an, um den Zugriff zu erlauben. Daten werden stündlich im Hintergrund abgerufen.",
|
||||
"ouraUnavailable": "Oura ist auf diesem Server nicht konfiguriert. Bitte deinen Administrator, die Oura-OAuth-Zugangsdaten zu hinterlegen.",
|
||||
"ouraConnect": "Oura verbinden",
|
||||
"ouraDisconnect": "Trennen",
|
||||
"ouraDisconnectTitle": "Oura trennen?",
|
||||
"ouraDisconnectDescription": "Künftige Synchronisierungen stoppen. Bereits importierte Werte bleiben in deinem Verlauf.",
|
||||
"ouraOauthConnected": "Oura verbunden — die erste Synchronisierung startet im Hintergrund.",
|
||||
"ouraOauthFailed": "Oura-Verbindung fehlgeschlagen",
|
||||
"ouraOauthError": {
|
||||
"csrf1": "Die Sicherheitsprüfung ist fehlgeschlagen, weil das Browser-Cookie fehlte. Bitte starte die Verbindung im selben Browser erneut.",
|
||||
"state": "Die Antwort von Oura passte zu keiner offenen Verbindungsanfrage. Bitte starte die Verbindung erneut.",
|
||||
"cross_user": "Die Antwort gehört zu einer Anfrage eines anderen Kontos. Bitte starte die Verbindung aus deinem eigenen Konto erneut.",
|
||||
"nocode": "Oura hat keinen Autorisierungscode zurückgegeben. Versuche es erneut und erlaube den Zugriff im Oura-Dialog.",
|
||||
"nocreds": "Oura ist auf diesem Server nicht konfiguriert. Bitte deinen Administrator, die Oura-OAuth-Zugangsdaten zu hinterlegen.",
|
||||
"token": "Der Token-Austausch mit Oura ist fehlgeschlagen. Bitte starte die Verbindung erneut.",
|
||||
"rate_limited": "Zu viele Verbindungsversuche. Warte einen Moment und versuche es erneut.",
|
||||
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -5404,6 +5574,10 @@
|
||||
"statusOff": "Aus",
|
||||
"daysLabel": "Warnen unter (Tage)",
|
||||
"daysAria": "Vorratsschwelle in Tagen wählen",
|
||||
"leadLabel": "Vorlaufzeit zum Nachbestellen (Tage)",
|
||||
"leadAria": "Vorlaufzeit zum Nachbestellen in Tagen",
|
||||
"leadHelp": "Die Warnung kommt um so viele Tage plus ein Dosisintervall früher, damit der Nachschub vor deiner letzten Dosis eintrifft. Ein einzelnes Medikament kann diesen Wert auf seinem Bestand-Tab überschreiben.",
|
||||
"leadSavedToast": "Vorlaufzeit gespeichert.",
|
||||
"savedToast": "Vorratsschwelle gespeichert.",
|
||||
"enabledToast": "Vorratswarnungen aktiviert.",
|
||||
"disabledToast": "Vorratswarnungen deaktiviert.",
|
||||
@@ -5597,7 +5771,9 @@
|
||||
"lowStockReminders": {
|
||||
"title": "Vorrat niedrig: {medName}",
|
||||
"body": "Dein Vorrat an {medName} reicht noch etwa {days} Tage ({units} Einheiten übrig).",
|
||||
"bodyToday": "Dein Vorrat an {medName} reicht keinen ganzen Tag mehr ({units} Einheiten übrig)."
|
||||
"bodyToday": "Dein Vorrat an {medName} reicht keinen ganzen Tag mehr ({units} Einheiten übrig).",
|
||||
"bodyReorder": "Dein Vorrat an {medName} ({units} Einheiten) ist um den {runsOutOn} aufgebraucht — bestelle bis zum {reorderBy} nach.",
|
||||
"bodyLastDose": "Dein Vorrat an {medName} ({units} Einheiten) reicht noch etwa für die letzte Dosis. Aufgebraucht um den {runsOutOn}."
|
||||
},
|
||||
"moodReminders": {
|
||||
"dailyTitle": "Stimmung erfassen",
|
||||
@@ -5926,7 +6102,10 @@
|
||||
"contraceptive": "Verhütung",
|
||||
"severity": "Schweregrad",
|
||||
"severityLevel": "Schweregrad {level} von 4",
|
||||
"delete": "Löschen"
|
||||
"delete": "Löschen",
|
||||
"temperatureSection": "Temperatur & Eisprung",
|
||||
"intimacySection": "Intimität & Verhütung",
|
||||
"testsSection": "Tests"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Zyklus-Einstellungen",
|
||||
|
||||
+194
-15
@@ -720,7 +720,12 @@
|
||||
"iconGroupWeather": "Weather",
|
||||
"iconGroupPlaces": "Places",
|
||||
"iconGroupMisc": "Other"
|
||||
}
|
||||
},
|
||||
"sectionMoreTags": "More tags",
|
||||
"sectionFactors": "Factors",
|
||||
"sectionNoteDetails": "Note & details",
|
||||
"quickTagsHelp": "Your frequent tags — one tap",
|
||||
"quickTagsExpand": "More"
|
||||
},
|
||||
"medications": {
|
||||
"title": "Medications",
|
||||
@@ -2666,6 +2671,35 @@
|
||||
"title": "No sleep data yet",
|
||||
"description": "Apple Health sync arrives in v1.5 and will fill this in automatically.",
|
||||
"action": "Manage devices"
|
||||
},
|
||||
"debt": {
|
||||
"title": "Sleep debt",
|
||||
"value": "{hours}h {minutes}m short",
|
||||
"caption": "Cumulative shortfall over the last {nights} nights",
|
||||
"clearValue": "All caught up",
|
||||
"clearCaption": "No accumulated shortfall over the last {nights} nights",
|
||||
"learning": "Still learning your sleep — {counted} nights so far, {remaining} more to read a reliable balance."
|
||||
},
|
||||
"chronotype": {
|
||||
"title": "Chronotype",
|
||||
"learning": "Still learning your rhythm — {counted} of {total} free nights. A chronotype needs a few alarm-free mornings before it means anything.",
|
||||
"midSleepCaption": "Mid-sleep on free days around {clock}",
|
||||
"advancedToggle": "Advanced",
|
||||
"socialJetlagValue": "{hours}h {minutes}m social jetlag",
|
||||
"socialJetlagCaption": "The gap between your work-day and free-day sleep timing — like a small weekly time-zone shift.",
|
||||
"msfScValue": "Corrected mid-sleep {clock}",
|
||||
"msfScCaption": "Adjusted for the sleep you catch up on at the weekend (MSFsc).",
|
||||
"dayTypeNote": "Free days are assumed to be weekends until day-type tracking is added.",
|
||||
"band": {
|
||||
"extreme_early": "Strong morning type",
|
||||
"early": "Morning type",
|
||||
"intermediate": "Intermediate type",
|
||||
"late": "Evening type",
|
||||
"extreme_late": "Strong evening type"
|
||||
}
|
||||
},
|
||||
"rhythm": {
|
||||
"loadError": "Your sleep rhythm couldn't load just now. It'll reappear once the connection settles."
|
||||
}
|
||||
},
|
||||
"correlations": "Correlations",
|
||||
@@ -2795,6 +2829,71 @@
|
||||
"emptyState": {
|
||||
"title": "No glucose data yet",
|
||||
"description": "Blood-glucose readings appear here once you log one or a connected device syncs."
|
||||
},
|
||||
"clinical": {
|
||||
"title": "Glucose overview",
|
||||
"subtitle": "A read on your last {days} days, from {count} readings.",
|
||||
"spotCaveat": "These are spot-reading estimates from individual measurements, not a continuous-monitor profile — read them as a direction of travel rather than a clinical verdict.",
|
||||
"learningTitle": "Still learning your glucose",
|
||||
"learningBody": "So far that's {count} over {days}. A little more history and a fuller picture comes into focus — keep logging, or let a connected device fill it in.",
|
||||
"learningBodyEmpty": "Once a handful of readings come in — logged by you or synced from a device — your time-in-range, average, and variability take shape here.",
|
||||
"readingsCount": "{count} readings",
|
||||
"readingsCountOne": "1 reading",
|
||||
"daysCount": "{days} days",
|
||||
"daysCountOne": "1 day",
|
||||
"tir": {
|
||||
"title": "Time in range",
|
||||
"caption": "Share of readings in each band (70–180 mg/dL is the target).",
|
||||
"veryLow": "Very low",
|
||||
"low": "Low",
|
||||
"inRange": "In range",
|
||||
"high": "High",
|
||||
"veryHigh": "Very high",
|
||||
"ariaLabel": "Time in range: {tir}% in target, {below}% below, {above}% above.",
|
||||
"empty": "No readings to chart yet."
|
||||
},
|
||||
"gmi": {
|
||||
"label": "Glucose Management Indicator",
|
||||
"help": "An A1C-like estimate derived from your average glucose."
|
||||
},
|
||||
"eA1c": {
|
||||
"label": "Estimated A1C",
|
||||
"help": "An A1C estimate from your mean glucose (ADAG)."
|
||||
},
|
||||
"mean": {
|
||||
"label": "Average glucose"
|
||||
},
|
||||
"cv": {
|
||||
"label": "Variability",
|
||||
"steady": "Steady",
|
||||
"unstable": "Unstable",
|
||||
"help": "Coefficient of variation. At or above 36% glucose is considered unstable; lower is steadier."
|
||||
},
|
||||
"advanced": {
|
||||
"toggle": "Advanced indices",
|
||||
"intro": "Research-grade composites for a closer look. Each folds your readings into a single risk-weighted number.",
|
||||
"jIndex": {
|
||||
"label": "J-index",
|
||||
"help": "A single score blending your average and its spread — lower reads as tighter control."
|
||||
},
|
||||
"lbgi": {
|
||||
"label": "Low-glucose risk (LBGI)",
|
||||
"help": "Kovatchev's index weighting how often and how deeply readings dip low."
|
||||
},
|
||||
"hbgi": {
|
||||
"label": "High-glucose risk (HBGI)",
|
||||
"help": "Kovatchev's index weighting how often and how high readings climb."
|
||||
},
|
||||
"byContext": {
|
||||
"title": "By context",
|
||||
"fasting": "Fasting",
|
||||
"postprandial": "After meals",
|
||||
"random": "Random",
|
||||
"bedtime": "Bedtime",
|
||||
"readingsShort": "{count} readings",
|
||||
"readingsShortOne": "1 reading"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"navBloodGlucose": "Glucose",
|
||||
@@ -3549,7 +3648,7 @@
|
||||
},
|
||||
"goals": {
|
||||
"title": "What would you like to track?",
|
||||
"body": "Pick the areas you care about. Your dashboard and the default reminders follow your choice.",
|
||||
"body": "Pick the areas you care about — it helps you decide what to track and set up next.",
|
||||
"helpHint": "One to three priorities tend to work best — you can always add more later.",
|
||||
"options": {
|
||||
"weightManagement": "Weight management",
|
||||
@@ -3562,7 +3661,7 @@
|
||||
},
|
||||
"source": {
|
||||
"title": "Connect a source",
|
||||
"body": "Bring in measurements automatically, or stay on manual entry — your call.",
|
||||
"body": "Bring in measurements automatically, or stay on manual entry. Nothing here is configured for you — each card just shows where to connect it.",
|
||||
"recommended": "Recommended",
|
||||
"manual": {
|
||||
"title": "Manual entry",
|
||||
@@ -3573,12 +3672,17 @@
|
||||
"body": "Scales, blood-pressure cuffs and trackers from Withings sync automatically.",
|
||||
"cta": "Connect Withings"
|
||||
},
|
||||
"appleHealth": {
|
||||
"title": "Apple Health",
|
||||
"body": "iPhone and Apple Watch data flow in via the HealthLog iOS app.",
|
||||
"badge": "Coming with v1.5 (iOS)",
|
||||
"disabledHint": "Available once the iOS app ships."
|
||||
}
|
||||
"more": {
|
||||
"title": "More sources",
|
||||
"body": "Connect any of these in Settings whenever you're ready.",
|
||||
"appleHealth": "Apple Health",
|
||||
"whoop": "WHOOP",
|
||||
"oura": "Oura",
|
||||
"polar": "Polar",
|
||||
"nightscout": "Nightscout",
|
||||
"fitbit": "Fitbit"
|
||||
},
|
||||
"import": "Import existing history"
|
||||
},
|
||||
"baseline": {
|
||||
"title": "A bit about you",
|
||||
@@ -3598,7 +3702,11 @@
|
||||
"done": {
|
||||
"title": "You're all set",
|
||||
"body": "Welcome aboard. Your dashboard is ready.",
|
||||
"returnCta": "Open dashboard"
|
||||
"returnCta": "Open dashboard",
|
||||
"learning": "Some insights — glucose, sleep rhythm, blood-pressure trends, cycle — sharpen over the first week or two as your data builds up. Empty tiles now just mean we're still learning your baseline.",
|
||||
"connectCta": "Connect a device",
|
||||
"logCta": "Log your first measurement",
|
||||
"importCta": "Import existing history"
|
||||
},
|
||||
"welcomeBack": {
|
||||
"title": "Welcome back",
|
||||
@@ -3643,9 +3751,9 @@
|
||||
"medicationTitle": "Add a medication",
|
||||
"medicationDescription": "Track adherence and get reminders right when you need them.",
|
||||
"medicationCta": "Add medication",
|
||||
"withingsTitle": "Connect Withings (optional)",
|
||||
"withingsDescription": "Auto-sync weight, BP and pulse from your Withings devices.",
|
||||
"withingsCta": "Connect",
|
||||
"dataSourceTitle": "Connect a data source (optional)",
|
||||
"dataSourceDescription": "Auto-sync weight, BP, sleep and more from Withings, WHOOP, Oura, Polar, Nightscout, Fitbit or Apple Health.",
|
||||
"dataSourceCta": "Connect",
|
||||
"notificationsTitle": "Configure notifications",
|
||||
"notificationsDescription": "Choose how HealthLog should remind you — Telegram, ntfy or Web Push.",
|
||||
"notificationsCta": "Open notifications"
|
||||
@@ -4470,6 +4578,68 @@
|
||||
"enable": "Enable cycle tracking",
|
||||
"manage": "Open cycle tracking",
|
||||
"error": "Couldn't update cycle tracking."
|
||||
},
|
||||
"nightscout": "Nightscout",
|
||||
"nightscoutTag": "CGM",
|
||||
"nightscoutDescription": "Connect your own Nightscout site to sync continuous glucose readings for the glucose panel.",
|
||||
"nightscoutViewData": "View your glucose",
|
||||
"nightscoutHelp": "Point HealthLog at your Nightscout instance. Readings are pulled in the background once an hour.",
|
||||
"nightscoutUrl": "Nightscout URL",
|
||||
"nightscoutUrlSavedPlaceholder": "Saved — enter a new URL to replace it",
|
||||
"nightscoutToken": "API token",
|
||||
"nightscoutTokenOptional": "Optional for a public site",
|
||||
"nightscoutTokenSavedPlaceholder": "Saved — enter a new token to replace it",
|
||||
"nightscoutTokenHelp": "Paste an access token from your Nightscout admin tools. Leave empty if your site is publicly readable.",
|
||||
"nightscoutPrivateHost": "This site runs on a private network",
|
||||
"nightscoutPrivateHostHelp": "Enable only if your Nightscout instance is on a LAN, VPN, or Tailscale address. Off for public sites.",
|
||||
"nightscoutConnect": "Connect",
|
||||
"nightscoutUpdate": "Save changes",
|
||||
"nightscoutConnected": "Nightscout connected.",
|
||||
"nightscoutConnectFailed": "Could not connect to Nightscout.",
|
||||
"nightscoutDisconnect": "Disconnect",
|
||||
"nightscoutDisconnectTitle": "Disconnect Nightscout?",
|
||||
"nightscoutDisconnectDescription": "Future syncs stop. Glucose readings already imported stay in your history.",
|
||||
"polar": "Polar",
|
||||
"polarDescription": "Connect your Polar account to sync Nightly Recharge recovery, sleep stages, and daily activity.",
|
||||
"polarViewData": "View your sleep & recovery",
|
||||
"polarHelp": "Sign in with Polar to grant access. Data is pulled in the background once an hour.",
|
||||
"polarUnavailable": "Polar is not configured on this server. Ask your administrator to add the Polar OAuth credentials.",
|
||||
"polarConnect": "Connect Polar",
|
||||
"polarDisconnect": "Disconnect",
|
||||
"polarDisconnectTitle": "Disconnect Polar?",
|
||||
"polarDisconnectDescription": "Future syncs stop. Readings already imported stay in your history.",
|
||||
"polarOauthConnected": "Polar connected — the first sync starts in the background.",
|
||||
"polarOauthFailed": "Polar connection failed",
|
||||
"polarOauthError": {
|
||||
"csrf1": "The security check failed because the browser cookie was missing. Please start the connection again in the same browser.",
|
||||
"state": "The response from Polar did not match any open connection request. Please start the connection again.",
|
||||
"cross_user": "The response belongs to a request from a different account. Please restart the connection from your own account.",
|
||||
"nocode": "Polar did not return an authorization code. Try again and allow access in the Polar dialog.",
|
||||
"nocreds": "Polar is not configured on this server. Ask your administrator to add the Polar OAuth credentials.",
|
||||
"token": "The token exchange with Polar failed. Please start the connection again.",
|
||||
"rate_limited": "Too many connection attempts. Wait a moment and try again.",
|
||||
"generic": "Unknown error while connecting. Please start the connection again."
|
||||
},
|
||||
"oura": "Oura",
|
||||
"ouraDescription": "Connect your Oura account to sync readiness, sleep stages, HRV, and daily activity.",
|
||||
"ouraViewData": "View your sleep & recovery",
|
||||
"ouraHelp": "Sign in with Oura to grant access. Data is pulled in the background once an hour.",
|
||||
"ouraUnavailable": "Oura is not configured on this server. Ask your administrator to add the Oura OAuth credentials.",
|
||||
"ouraConnect": "Connect Oura",
|
||||
"ouraDisconnect": "Disconnect",
|
||||
"ouraDisconnectTitle": "Disconnect Oura?",
|
||||
"ouraDisconnectDescription": "Future syncs stop. Readings already imported stay in your history.",
|
||||
"ouraOauthConnected": "Oura connected — the first sync starts in the background.",
|
||||
"ouraOauthFailed": "Oura connection failed",
|
||||
"ouraOauthError": {
|
||||
"csrf1": "The security check failed because the browser cookie was missing. Please start the connection again in the same browser.",
|
||||
"state": "The response from Oura did not match any open connection request. Please start the connection again.",
|
||||
"cross_user": "The response belongs to a request from a different account. Please restart the connection from your own account.",
|
||||
"nocode": "Oura did not return an authorization code. Try again and allow access in the Oura dialog.",
|
||||
"nocreds": "Oura is not configured on this server. Ask your administrator to add the Oura OAuth credentials.",
|
||||
"token": "The token exchange with Oura failed. Please start the connection again.",
|
||||
"rate_limited": "Too many connection attempts. Wait a moment and try again.",
|
||||
"generic": "Unknown error while connecting. Please start the connection again."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -5404,6 +5574,10 @@
|
||||
"statusOff": "Off",
|
||||
"daysLabel": "Notify below (days)",
|
||||
"daysAria": "Select the low-stock threshold in days",
|
||||
"leadLabel": "Reorder lead time (days)",
|
||||
"leadAria": "Reorder lead time in days",
|
||||
"leadHelp": "The alert fires this many days plus one dose-interval earlier so a refill arrives before your last dose. A single medication can override this on its supply tab.",
|
||||
"leadSavedToast": "Reorder lead time saved.",
|
||||
"savedToast": "Low-stock threshold saved.",
|
||||
"enabledToast": "Low-stock alerts enabled.",
|
||||
"disabledToast": "Low-stock alerts disabled.",
|
||||
@@ -5597,7 +5771,9 @@
|
||||
"lowStockReminders": {
|
||||
"title": "Low supply: {medName}",
|
||||
"body": "{medName}: {units} units left — enough for about {days} more days.",
|
||||
"bodyToday": "{medName}: {units} units left — less than one day of supply."
|
||||
"bodyToday": "{medName}: {units} units left — less than one day of supply.",
|
||||
"bodyReorder": "{medName}: {units} units left. Supply runs out around {runsOutOn} — reorder by {reorderBy}.",
|
||||
"bodyLastDose": "{medName}: {units} units left — about your final dose. Supply runs out around {runsOutOn}."
|
||||
},
|
||||
"moodReminders": {
|
||||
"dailyTitle": "Log your mood",
|
||||
@@ -5926,7 +6102,10 @@
|
||||
"saved": "Day saved",
|
||||
"saveError": "Could not save. Try again.",
|
||||
"periodStarted": "Period start logged",
|
||||
"clear": "None"
|
||||
"clear": "None",
|
||||
"temperatureSection": "Temperature & ovulation",
|
||||
"intimacySection": "Intimacy & contraception",
|
||||
"testsSection": "Tests"
|
||||
},
|
||||
"testResult": {
|
||||
"POSITIVE": "Positive",
|
||||
|
||||
+194
-15
@@ -720,7 +720,12 @@
|
||||
"iconGroupWeather": "Tiempo",
|
||||
"iconGroupPlaces": "Lugares",
|
||||
"iconGroupMisc": "Otros"
|
||||
}
|
||||
},
|
||||
"sectionMoreTags": "Más etiquetas",
|
||||
"sectionFactors": "Factores",
|
||||
"sectionNoteDetails": "Nota y detalles",
|
||||
"quickTagsHelp": "Tus etiquetas frecuentes: un toque",
|
||||
"quickTagsExpand": "Más"
|
||||
},
|
||||
"medications": {
|
||||
"title": "Medicamentos",
|
||||
@@ -2666,6 +2671,35 @@
|
||||
"title": "Aún no hay datos de sueño",
|
||||
"description": "La sincronización con Apple Health llega con la v1.5 y llenará esta vista automáticamente.",
|
||||
"action": "Gestionar dispositivos"
|
||||
},
|
||||
"debt": {
|
||||
"title": "Deuda de sueño",
|
||||
"value": "{hours} h {minutes} min de déficit",
|
||||
"caption": "Déficit acumulado de las últimas {nights} noches",
|
||||
"clearValue": "Al día",
|
||||
"clearCaption": "Sin déficit acumulado en las últimas {nights} noches",
|
||||
"learning": "Aún estamos midiendo tu sueño — {counted} noches hasta ahora, faltan {remaining} para un balance fiable."
|
||||
},
|
||||
"chronotype": {
|
||||
"title": "Cronotipo",
|
||||
"learning": "Aún estamos aprendiendo tu ritmo — {counted} de {total} noches libres. Un cronotipo necesita varias mañanas sin despertador para tener sentido.",
|
||||
"midSleepCaption": "Punto medio del sueño en días libres en torno a las {clock}",
|
||||
"advancedToggle": "Avanzado",
|
||||
"socialJetlagValue": "{hours} h {minutes} min de jet lag social",
|
||||
"socialJetlagCaption": "La diferencia entre el horario de sueño laboral y el de los días libres — como un pequeño cambio de huso horario semanal.",
|
||||
"msfScValue": "Punto medio corregido {clock}",
|
||||
"msfScCaption": "Ajustado por el sueño que recuperas el fin de semana (MSFsc).",
|
||||
"dayTypeNote": "Se asume que los días libres son los fines de semana hasta que se añada el registro del tipo de día.",
|
||||
"band": {
|
||||
"extreme_early": "Matutino marcado",
|
||||
"early": "Matutino",
|
||||
"intermediate": "Intermedio",
|
||||
"late": "Vespertino",
|
||||
"extreme_late": "Vespertino marcado"
|
||||
}
|
||||
},
|
||||
"rhythm": {
|
||||
"loadError": "Tu ritmo de sueño no pudo cargarse ahora mismo. Reaparecerá en cuanto se estabilice la conexión."
|
||||
}
|
||||
},
|
||||
"correlations": "Correlaciones",
|
||||
@@ -2795,6 +2829,71 @@
|
||||
"emptyState": {
|
||||
"title": "Aún no hay datos de glucosa",
|
||||
"description": "Las lecturas de glucosa aparecen aquí cuando registras una o un dispositivo sincroniza."
|
||||
},
|
||||
"clinical": {
|
||||
"title": "Resumen de glucosa",
|
||||
"subtitle": "Una lectura de tus últimos {days} días, a partir de {count} mediciones.",
|
||||
"spotCaveat": "Son estimaciones a partir de mediciones puntuales, no un perfil de un sensor continuo: léelas como una tendencia, no como un veredicto clínico.",
|
||||
"learningTitle": "Aún conociendo tu glucosa",
|
||||
"learningBody": "De momento son {count} en {days}. Con algo más de historial el panorama se aclara: sigue registrando o deja que lo complete un dispositivo conectado.",
|
||||
"learningBodyEmpty": "En cuanto lleguen unas cuantas mediciones —registradas por ti o sincronizadas desde un dispositivo— tu tiempo en rango, promedio y variabilidad tomarán forma aquí.",
|
||||
"readingsCount": "{count} mediciones",
|
||||
"readingsCountOne": "1 medición",
|
||||
"daysCount": "{days} días",
|
||||
"daysCountOne": "1 día",
|
||||
"tir": {
|
||||
"title": "Tiempo en rango",
|
||||
"caption": "Proporción de mediciones en cada banda (70–180 mg/dl es el objetivo).",
|
||||
"veryLow": "Muy bajo",
|
||||
"low": "Bajo",
|
||||
"inRange": "En rango",
|
||||
"high": "Alto",
|
||||
"veryHigh": "Muy alto",
|
||||
"ariaLabel": "Tiempo en rango: {tir}% en objetivo, {below}% por debajo, {above}% por encima.",
|
||||
"empty": "Aún no hay mediciones para el gráfico."
|
||||
},
|
||||
"gmi": {
|
||||
"label": "Indicador de gestión de la glucosa",
|
||||
"help": "Una estimación tipo HbA1c derivada de tu glucosa promedio."
|
||||
},
|
||||
"eA1c": {
|
||||
"label": "HbA1c estimada",
|
||||
"help": "Una estimación de HbA1c a partir de tu glucosa media (ADAG)."
|
||||
},
|
||||
"mean": {
|
||||
"label": "Glucosa promedio"
|
||||
},
|
||||
"cv": {
|
||||
"label": "Variabilidad",
|
||||
"steady": "Estable",
|
||||
"unstable": "Inestable",
|
||||
"help": "Coeficiente de variación. A partir del 36 % la glucosa se considera inestable; menos es más estable."
|
||||
},
|
||||
"advanced": {
|
||||
"toggle": "Índices avanzados",
|
||||
"intro": "Compuestos de nivel investigador para mirar más de cerca. Cada uno reduce tus mediciones a un único número ponderado por riesgo.",
|
||||
"jIndex": {
|
||||
"label": "Índice J",
|
||||
"help": "Una puntuación única que combina tu promedio y su dispersión: cuanto más bajo, más ajustado el control."
|
||||
},
|
||||
"lbgi": {
|
||||
"label": "Riesgo de hipoglucemia (LBGI)",
|
||||
"help": "Índice de Kovatchev que pondera con qué frecuencia y profundidad bajan las lecturas."
|
||||
},
|
||||
"hbgi": {
|
||||
"label": "Riesgo de hiperglucemia (HBGI)",
|
||||
"help": "Índice de Kovatchev que pondera con qué frecuencia y altura suben las lecturas."
|
||||
},
|
||||
"byContext": {
|
||||
"title": "Por contexto",
|
||||
"fasting": "En ayunas",
|
||||
"postprandial": "Después de comer",
|
||||
"random": "Aleatorio",
|
||||
"bedtime": "Antes de dormir",
|
||||
"readingsShort": "{count} mediciones",
|
||||
"readingsShortOne": "1 medición"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"navBloodGlucose": "Glucosa",
|
||||
@@ -3549,7 +3648,7 @@
|
||||
},
|
||||
"goals": {
|
||||
"title": "¿Qué te gustaría registrar?",
|
||||
"body": "Elige las áreas que te interesan. El panel y los recordatorios se ajustarán a tu elección.",
|
||||
"body": "Elige las áreas que te interesan: te ayuda a decidir qué seguir y qué configurar a continuación.",
|
||||
"helpHint": "Una a tres prioridades suelen funcionar mejor — siempre podrás añadir más más adelante.",
|
||||
"options": {
|
||||
"weightManagement": "Control del peso",
|
||||
@@ -3562,7 +3661,7 @@
|
||||
},
|
||||
"source": {
|
||||
"title": "Conectar una fuente",
|
||||
"body": "Importa las mediciones automáticamente o sigue con la entrada manual, tú decides.",
|
||||
"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.",
|
||||
"recommended": "Recomendado",
|
||||
"manual": {
|
||||
"title": "Entrada manual",
|
||||
@@ -3573,12 +3672,17 @@
|
||||
"body": "Las básculas, los tensiómetros y los trackers de Withings se sincronizan automáticamente.",
|
||||
"cta": "Conectar Withings"
|
||||
},
|
||||
"appleHealth": {
|
||||
"title": "Apple Health",
|
||||
"body": "Los datos del iPhone y el Apple Watch llegan a través de la app de HealthLog para iOS.",
|
||||
"badge": "Con la v1.5 (iOS)",
|
||||
"disabledHint": "Disponible cuando se publique la app de iOS."
|
||||
}
|
||||
"more": {
|
||||
"title": "Más fuentes",
|
||||
"body": "Conecta cualquiera de ellas en Ajustes cuando quieras.",
|
||||
"appleHealth": "Apple Health",
|
||||
"whoop": "WHOOP",
|
||||
"oura": "Oura",
|
||||
"polar": "Polar",
|
||||
"nightscout": "Nightscout",
|
||||
"fitbit": "Fitbit"
|
||||
},
|
||||
"import": "Importar historial existente"
|
||||
},
|
||||
"baseline": {
|
||||
"title": "Cuéntanos sobre ti",
|
||||
@@ -3598,7 +3702,11 @@
|
||||
"done": {
|
||||
"title": "Todo listo",
|
||||
"body": "Bienvenido a bordo. Tu panel ya está preparado.",
|
||||
"returnCta": "Abrir el panel"
|
||||
"returnCta": "Abrir el panel",
|
||||
"learning": "Algunas conclusiones —glucosa, ritmo de sueño, tendencias de presión arterial, ciclo— se afinan durante la primera semana o dos a medida que se acumulan tus datos. Las casillas vacías ahora solo significan que aún estamos aprendiendo tu línea base.",
|
||||
"connectCta": "Conectar un dispositivo",
|
||||
"logCta": "Registra tu primera medición",
|
||||
"importCta": "Importar historial existente"
|
||||
},
|
||||
"welcomeBack": {
|
||||
"title": "Bienvenido de nuevo",
|
||||
@@ -3643,9 +3751,9 @@
|
||||
"medicationTitle": "Añadir medicamento",
|
||||
"medicationDescription": "Sigue el cumplimiento y recibe recordatorios justo cuando los necesites.",
|
||||
"medicationCta": "Añadir medicamento",
|
||||
"withingsTitle": "Conectar Withings (opcional)",
|
||||
"withingsDescription": "Sincroniza peso, tensión arterial y pulso automáticamente desde tus dispositivos Withings.",
|
||||
"withingsCta": "Conectar",
|
||||
"dataSourceTitle": "Conectar una fuente de datos (opcional)",
|
||||
"dataSourceDescription": "Sincroniza peso, presión, sueño y más desde Withings, WHOOP, Oura, Polar, Nightscout, Fitbit o Apple Health.",
|
||||
"dataSourceCta": "Conectar",
|
||||
"notificationsTitle": "Configurar notificaciones",
|
||||
"notificationsDescription": "Elige cómo te recuerda HealthLog — Telegram, ntfy o Web Push.",
|
||||
"notificationsCta": "Abrir notificaciones"
|
||||
@@ -4470,6 +4578,68 @@
|
||||
"enable": "Activar el seguimiento del ciclo",
|
||||
"manage": "Abrir seguimiento del ciclo",
|
||||
"error": "No se pudo actualizar el seguimiento del ciclo."
|
||||
},
|
||||
"nightscout": "Nightscout",
|
||||
"nightscoutTag": "CGM",
|
||||
"nightscoutDescription": "Conecta tu propio sitio Nightscout para sincronizar lecturas continuas de glucosa en el panel de glucosa.",
|
||||
"nightscoutViewData": "Ver tu glucosa",
|
||||
"nightscoutHelp": "Apunta HealthLog a tu instancia de Nightscout. Las lecturas se obtienen en segundo plano una vez por hora.",
|
||||
"nightscoutUrl": "URL de Nightscout",
|
||||
"nightscoutUrlSavedPlaceholder": "Guardado: introduce una URL nueva para reemplazarla",
|
||||
"nightscoutToken": "Token de API",
|
||||
"nightscoutTokenOptional": "Opcional para un sitio público",
|
||||
"nightscoutTokenSavedPlaceholder": "Guardado: introduce un token nuevo para reemplazarlo",
|
||||
"nightscoutTokenHelp": "Pega un token de acceso de las herramientas de administración de Nightscout. Déjalo vacío si tu sitio es de lectura pública.",
|
||||
"nightscoutPrivateHost": "Este sitio funciona en una red privada",
|
||||
"nightscoutPrivateHostHelp": "Actívalo solo si tu instancia de Nightscout está en una dirección LAN, VPN o Tailscale. Desactivado para sitios públicos.",
|
||||
"nightscoutConnect": "Conectar",
|
||||
"nightscoutUpdate": "Guardar cambios",
|
||||
"nightscoutConnected": "Nightscout conectado.",
|
||||
"nightscoutConnectFailed": "No se pudo conectar con Nightscout.",
|
||||
"nightscoutDisconnect": "Desconectar",
|
||||
"nightscoutDisconnectTitle": "¿Desconectar Nightscout?",
|
||||
"nightscoutDisconnectDescription": "Las sincronizaciones futuras se detienen. Las lecturas de glucosa ya importadas permanecen en tu historial.",
|
||||
"polar": "Polar",
|
||||
"polarDescription": "Conecta tu cuenta de Polar para sincronizar la recuperación Nightly Recharge, las fases de sueño y la actividad diaria.",
|
||||
"polarViewData": "Ver tu sueño y recuperación",
|
||||
"polarHelp": "Inicia sesión en Polar para conceder acceso. Los datos se obtienen en segundo plano una vez por hora.",
|
||||
"polarUnavailable": "Polar no está configurado en este servidor. Pide a tu administrador que añada las credenciales OAuth de Polar.",
|
||||
"polarConnect": "Conectar Polar",
|
||||
"polarDisconnect": "Desconectar",
|
||||
"polarDisconnectTitle": "¿Desconectar Polar?",
|
||||
"polarDisconnectDescription": "Las sincronizaciones futuras se detienen. Las lecturas ya importadas permanecen en tu historial.",
|
||||
"polarOauthConnected": "Polar conectado: la primera sincronización empieza en segundo plano.",
|
||||
"polarOauthFailed": "Error al conectar con Polar",
|
||||
"polarOauthError": {
|
||||
"csrf1": "La verificación de seguridad falló porque faltaba la cookie del navegador. Inicia la conexión de nuevo en el mismo navegador.",
|
||||
"state": "La respuesta de Polar no coincidió con ninguna solicitud de conexión abierta. Inicia la conexión de nuevo.",
|
||||
"cross_user": "La respuesta pertenece a una solicitud de otra cuenta. Reinicia la conexión desde tu propia cuenta.",
|
||||
"nocode": "Polar no devolvió un código de autorización. Inténtalo de nuevo y permite el acceso en el diálogo de Polar.",
|
||||
"nocreds": "Polar no está configurado en este servidor. Pide a tu administrador que añada las credenciales OAuth de Polar.",
|
||||
"token": "El intercambio de token con Polar falló. Inicia la conexión de nuevo.",
|
||||
"rate_limited": "Demasiados intentos de conexión. Espera un momento e inténtalo de nuevo.",
|
||||
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo."
|
||||
},
|
||||
"oura": "Oura",
|
||||
"ouraDescription": "Conecta tu cuenta de Oura para sincronizar readiness, fases de sueño, VFC y actividad diaria.",
|
||||
"ouraViewData": "Ver tu sueño y recuperación",
|
||||
"ouraHelp": "Inicia sesión en Oura para conceder acceso. Los datos se obtienen en segundo plano una vez por hora.",
|
||||
"ouraUnavailable": "Oura no está configurado en este servidor. Pide a tu administrador que añada las credenciales OAuth de Oura.",
|
||||
"ouraConnect": "Conectar Oura",
|
||||
"ouraDisconnect": "Desconectar",
|
||||
"ouraDisconnectTitle": "¿Desconectar Oura?",
|
||||
"ouraDisconnectDescription": "Las sincronizaciones futuras se detienen. Las lecturas ya importadas permanecen en tu historial.",
|
||||
"ouraOauthConnected": "Oura conectado: la primera sincronización empieza en segundo plano.",
|
||||
"ouraOauthFailed": "Error al conectar con Oura",
|
||||
"ouraOauthError": {
|
||||
"csrf1": "La verificación de seguridad falló porque faltaba la cookie del navegador. Inicia la conexión de nuevo en el mismo navegador.",
|
||||
"state": "La respuesta de Oura no coincidió con ninguna solicitud de conexión abierta. Inicia la conexión de nuevo.",
|
||||
"cross_user": "La respuesta pertenece a una solicitud de otra cuenta. Reinicia la conexión desde tu propia cuenta.",
|
||||
"nocode": "Oura no devolvió un código de autorización. Inténtalo de nuevo y permite el acceso en el diálogo de Oura.",
|
||||
"nocreds": "Oura no está configurado en este servidor. Pide a tu administrador que añada las credenciales OAuth de Oura.",
|
||||
"token": "El intercambio de token con Oura falló. Inicia la conexión de nuevo.",
|
||||
"rate_limited": "Demasiados intentos de conexión. Espera un momento e inténtalo de nuevo.",
|
||||
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -5404,6 +5574,10 @@
|
||||
"statusOff": "Desactivado",
|
||||
"daysLabel": "Avisar por debajo de (días)",
|
||||
"daysAria": "Seleccionar el umbral de existencias en días",
|
||||
"leadLabel": "Plazo de reposición (días)",
|
||||
"leadAria": "Plazo de reposición en días",
|
||||
"leadHelp": "El aviso se envía estos días más un intervalo de dosis antes, para que la reposición llegue antes de tu última dosis. Un medicamento concreto puede anular este valor en su pestaña de existencias.",
|
||||
"leadSavedToast": "Plazo de reposición guardado.",
|
||||
"savedToast": "Umbral de existencias guardado.",
|
||||
"enabledToast": "Avisos de existencias bajas activados.",
|
||||
"disabledToast": "Avisos de existencias bajas desactivados.",
|
||||
@@ -5597,7 +5771,9 @@
|
||||
"lowStockReminders": {
|
||||
"title": "Quedan pocas existencias: {medName}",
|
||||
"body": "{medName}: quedan {units} unidades — para unos {days} días más.",
|
||||
"bodyToday": "{medName}: quedan {units} unidades — menos de un día de suministro."
|
||||
"bodyToday": "{medName}: quedan {units} unidades — menos de un día de suministro.",
|
||||
"bodyReorder": "{medName}: quedan {units} unidades. Las existencias se agotan hacia el {runsOutOn} — vuelve a pedir antes del {reorderBy}.",
|
||||
"bodyLastDose": "{medName}: quedan {units} unidades — alrededor de tu última dosis. Las existencias se agotan hacia el {runsOutOn}."
|
||||
},
|
||||
"moodReminders": {
|
||||
"dailyTitle": "Registrar el ánimo",
|
||||
@@ -5926,7 +6102,10 @@
|
||||
"contraceptive": "Anticonceptivo",
|
||||
"severity": "Intensidad",
|
||||
"severityLevel": "Intensidad {level} de 4",
|
||||
"delete": "Eliminar"
|
||||
"delete": "Eliminar",
|
||||
"temperatureSection": "Temperatura y ovulación",
|
||||
"intimacySection": "Intimidad y anticoncepción",
|
||||
"testsSection": "Pruebas"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ajustes del ciclo",
|
||||
|
||||
+194
-15
@@ -720,7 +720,12 @@
|
||||
"iconGroupWeather": "Météo",
|
||||
"iconGroupPlaces": "Lieux",
|
||||
"iconGroupMisc": "Autres"
|
||||
}
|
||||
},
|
||||
"sectionMoreTags": "Plus de tags",
|
||||
"sectionFactors": "Facteurs",
|
||||
"sectionNoteDetails": "Note et détails",
|
||||
"quickTagsHelp": "Vos tags fréquents — en un geste",
|
||||
"quickTagsExpand": "Plus"
|
||||
},
|
||||
"medications": {
|
||||
"title": "Médicaments",
|
||||
@@ -2666,6 +2671,35 @@
|
||||
"title": "Pas encore de données de sommeil",
|
||||
"description": "La synchro Apple Health arrive avec la v1.5 et remplira automatiquement cette vue.",
|
||||
"action": "Gérer les appareils"
|
||||
},
|
||||
"debt": {
|
||||
"title": "Dette de sommeil",
|
||||
"value": "{hours} h {minutes} min de déficit",
|
||||
"caption": "Déficit cumulé sur les {nights} dernières nuits",
|
||||
"clearValue": "À jour",
|
||||
"clearCaption": "Aucun déficit cumulé sur les {nights} dernières nuits",
|
||||
"learning": "Votre sommeil est encore en cours d'analyse — {counted} nuits jusqu'ici, encore {remaining} pour un bilan fiable."
|
||||
},
|
||||
"chronotype": {
|
||||
"title": "Chronotype",
|
||||
"learning": "Votre rythme est encore en cours d'analyse — {counted} sur {total} nuits libres. Un chronotype a besoin de quelques matins sans réveil pour avoir du sens.",
|
||||
"midSleepCaption": "Milieu de sommeil les jours libres vers {clock}",
|
||||
"advancedToggle": "Avancé",
|
||||
"socialJetlagValue": "{hours} h {minutes} min de jet lag social",
|
||||
"socialJetlagCaption": "L'écart entre vos horaires de sommeil les jours travaillés et les jours libres — comme un petit décalage horaire hebdomadaire.",
|
||||
"msfScValue": "Milieu de sommeil corrigé {clock}",
|
||||
"msfScCaption": "Ajusté pour le sommeil rattrapé le week-end (MSFsc).",
|
||||
"dayTypeNote": "Les jours libres sont supposés être les week-ends jusqu'à l'ajout du suivi du type de jour.",
|
||||
"band": {
|
||||
"extreme_early": "Type du matin marqué",
|
||||
"early": "Type du matin",
|
||||
"intermediate": "Type intermédiaire",
|
||||
"late": "Type du soir",
|
||||
"extreme_late": "Type du soir marqué"
|
||||
}
|
||||
},
|
||||
"rhythm": {
|
||||
"loadError": "Votre rythme de sommeil n'a pas pu se charger pour l'instant. Il réapparaîtra dès que la connexion sera rétablie."
|
||||
}
|
||||
},
|
||||
"correlations": "Corrélations",
|
||||
@@ -2795,6 +2829,71 @@
|
||||
"emptyState": {
|
||||
"title": "Aucune donnée de glycémie",
|
||||
"description": "Les relevés de glycémie apparaissent ici dès que vous en saisissez un ou qu’un appareil se synchronise."
|
||||
},
|
||||
"clinical": {
|
||||
"title": "Aperçu du glucose",
|
||||
"subtitle": "Un point sur vos {days} derniers jours, à partir de {count} mesures.",
|
||||
"spotCaveat": "Ce sont des estimations issues de mesures ponctuelles, pas le profil d’un capteur continu : lisez-les comme une tendance, non comme un verdict clinique.",
|
||||
"learningTitle": "Votre glucose se découvre encore",
|
||||
"learningBody": "Pour l’instant, cela fait {count} sur {days}. Avec un peu plus d’historique, le tableau se précise : continuez à enregistrer ou laissez un appareil connecté compléter.",
|
||||
"learningBodyEmpty": "Dès que quelques mesures arrivent — saisies par vous ou synchronisées depuis un appareil — votre temps dans la cible, votre moyenne et votre variabilité prennent forme ici.",
|
||||
"readingsCount": "{count} mesures",
|
||||
"readingsCountOne": "1 mesure",
|
||||
"daysCount": "{days} jours",
|
||||
"daysCountOne": "1 jour",
|
||||
"tir": {
|
||||
"title": "Temps dans la cible",
|
||||
"caption": "Part des mesures dans chaque plage (70–180 mg/dl est la cible).",
|
||||
"veryLow": "Très bas",
|
||||
"low": "Bas",
|
||||
"inRange": "Dans la cible",
|
||||
"high": "Élevé",
|
||||
"veryHigh": "Très élevé",
|
||||
"ariaLabel": "Temps dans la cible : {tir}% dans la cible, {below}% en dessous, {above}% au-dessus.",
|
||||
"empty": "Pas encore de mesures à afficher."
|
||||
},
|
||||
"gmi": {
|
||||
"label": "Indicateur de gestion du glucose",
|
||||
"help": "Une estimation de type HbA1c dérivée de votre glucose moyen."
|
||||
},
|
||||
"eA1c": {
|
||||
"label": "HbA1c estimée",
|
||||
"help": "Une estimation d’HbA1c à partir de votre glucose moyen (ADAG)."
|
||||
},
|
||||
"mean": {
|
||||
"label": "Glucose moyen"
|
||||
},
|
||||
"cv": {
|
||||
"label": "Variabilité",
|
||||
"steady": "Stable",
|
||||
"unstable": "Instable",
|
||||
"help": "Coefficient de variation. À partir de 36 %, le glucose est considéré comme instable ; plus bas est plus stable."
|
||||
},
|
||||
"advanced": {
|
||||
"toggle": "Indices avancés",
|
||||
"intro": "Des indicateurs de niveau recherche pour regarder de plus près. Chacun condense vos mesures en un seul chiffre pondéré par le risque.",
|
||||
"jIndex": {
|
||||
"label": "Indice J",
|
||||
"help": "Un score unique mêlant votre moyenne et sa dispersion — plus il est bas, plus le contrôle est serré."
|
||||
},
|
||||
"lbgi": {
|
||||
"label": "Risque d’hypoglycémie (LBGI)",
|
||||
"help": "Indice de Kovatchev pondérant la fréquence et la profondeur des baisses."
|
||||
},
|
||||
"hbgi": {
|
||||
"label": "Risque d’hyperglycémie (HBGI)",
|
||||
"help": "Indice de Kovatchev pondérant la fréquence et la hauteur des hausses."
|
||||
},
|
||||
"byContext": {
|
||||
"title": "Par contexte",
|
||||
"fasting": "À jeun",
|
||||
"postprandial": "Après les repas",
|
||||
"random": "Aléatoire",
|
||||
"bedtime": "Au coucher",
|
||||
"readingsShort": "{count} mesures",
|
||||
"readingsShortOne": "1 mesure"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"navBloodGlucose": "Glucose",
|
||||
@@ -3549,7 +3648,7 @@
|
||||
},
|
||||
"goals": {
|
||||
"title": "Que souhaitez-vous suivre ?",
|
||||
"body": "Sélectionnez les domaines qui comptent pour vous. Le tableau de bord et les rappels s'y adapteront.",
|
||||
"body": "Sélectionnez les domaines qui comptent pour vous : cela vous aide à décider quoi suivre et configurer ensuite.",
|
||||
"helpHint": "Un à trois axes prioritaires fonctionnent généralement le mieux, mais vous pourrez en ajouter plus tard.",
|
||||
"options": {
|
||||
"weightManagement": "Gestion du poids",
|
||||
@@ -3562,7 +3661,7 @@
|
||||
},
|
||||
"source": {
|
||||
"title": "Connecter une source",
|
||||
"body": "Importez les mesures automatiquement ou conservez la saisie manuelle, c'est vous qui décidez.",
|
||||
"body": "Importez les mesures automatiquement ou conservez la saisie manuelle. Rien n'est configuré à votre place : chaque carte indique simplement où la connecter.",
|
||||
"recommended": "Recommandé",
|
||||
"manual": {
|
||||
"title": "Saisie manuelle",
|
||||
@@ -3573,12 +3672,17 @@
|
||||
"body": "Balances, tensiomètres et trackers Withings se synchronisent automatiquement.",
|
||||
"cta": "Connecter Withings"
|
||||
},
|
||||
"appleHealth": {
|
||||
"title": "Apple Health",
|
||||
"body": "Les données de l'iPhone et de l'Apple Watch arrivent via l'application iOS HealthLog.",
|
||||
"badge": "Avec la v1.5 (iOS)",
|
||||
"disabledHint": "Disponible dès la sortie de l'application iOS."
|
||||
}
|
||||
"more": {
|
||||
"title": "Autres sources",
|
||||
"body": "Connectez l'une d'elles dans les Réglages quand vous le souhaitez.",
|
||||
"appleHealth": "Apple Health",
|
||||
"whoop": "WHOOP",
|
||||
"oura": "Oura",
|
||||
"polar": "Polar",
|
||||
"nightscout": "Nightscout",
|
||||
"fitbit": "Fitbit"
|
||||
},
|
||||
"import": "Importer un historique existant"
|
||||
},
|
||||
"baseline": {
|
||||
"title": "Quelques informations",
|
||||
@@ -3598,7 +3702,11 @@
|
||||
"done": {
|
||||
"title": "Tout est prêt",
|
||||
"body": "Bienvenue à bord. Votre tableau de bord vous attend.",
|
||||
"returnCta": "Ouvrir le tableau de bord"
|
||||
"returnCta": "Ouvrir le tableau de bord",
|
||||
"learning": "Certaines analyses — glycémie, rythme de sommeil, tendances de tension, cycle — s'affinent au cours de la première semaine ou deux à mesure que vos données s'accumulent. Des tuiles vides signifient simplement que nous apprenons encore votre référence.",
|
||||
"connectCta": "Connecter un appareil",
|
||||
"logCta": "Enregistrez votre première mesure",
|
||||
"importCta": "Importer un historique existant"
|
||||
},
|
||||
"welcomeBack": {
|
||||
"title": "Bon retour",
|
||||
@@ -3643,9 +3751,9 @@
|
||||
"medicationTitle": "Ajouter un médicament",
|
||||
"medicationDescription": "Suis l’observance et reçois des rappels au moment où tu en as besoin.",
|
||||
"medicationCta": "Ajouter un médicament",
|
||||
"withingsTitle": "Connecter Withings (optionnel)",
|
||||
"withingsDescription": "Synchronise automatiquement poids, tension et pouls depuis tes appareils Withings.",
|
||||
"withingsCta": "Connecter",
|
||||
"dataSourceTitle": "Connecter une source de données (facultatif)",
|
||||
"dataSourceDescription": "Synchronisez poids, tension, sommeil et plus depuis Withings, WHOOP, Oura, Polar, Nightscout, Fitbit ou Apple Health.",
|
||||
"dataSourceCta": "Connecter",
|
||||
"notificationsTitle": "Configurer les notifications",
|
||||
"notificationsDescription": "Choisis comment HealthLog te prévient — Telegram, ntfy ou Web Push.",
|
||||
"notificationsCta": "Ouvrir les notifications"
|
||||
@@ -4470,6 +4578,68 @@
|
||||
"enable": "Activer le suivi du cycle",
|
||||
"manage": "Ouvrir le suivi du cycle",
|
||||
"error": "Impossible de mettre à jour le suivi du cycle."
|
||||
},
|
||||
"nightscout": "Nightscout",
|
||||
"nightscoutTag": "CGM",
|
||||
"nightscoutDescription": "Connectez votre propre site Nightscout pour synchroniser les mesures de glycémie en continu dans le panneau glucose.",
|
||||
"nightscoutViewData": "Voir votre glycémie",
|
||||
"nightscoutHelp": "Pointez HealthLog vers votre instance Nightscout. Les mesures sont récupérées en arrière-plan une fois par heure.",
|
||||
"nightscoutUrl": "URL Nightscout",
|
||||
"nightscoutUrlSavedPlaceholder": "Enregistré — saisissez une nouvelle URL pour la remplacer",
|
||||
"nightscoutToken": "Jeton d'API",
|
||||
"nightscoutTokenOptional": "Facultatif pour un site public",
|
||||
"nightscoutTokenSavedPlaceholder": "Enregistré — saisissez un nouveau jeton pour le remplacer",
|
||||
"nightscoutTokenHelp": "Collez un jeton d'accès depuis les outils d'administration de Nightscout. Laissez vide si votre site est lisible publiquement.",
|
||||
"nightscoutPrivateHost": "Ce site fonctionne sur un réseau privé",
|
||||
"nightscoutPrivateHostHelp": "Activez uniquement si votre instance Nightscout est sur une adresse LAN, VPN ou Tailscale. Désactivé pour les sites publics.",
|
||||
"nightscoutConnect": "Connecter",
|
||||
"nightscoutUpdate": "Enregistrer les modifications",
|
||||
"nightscoutConnected": "Nightscout connecté.",
|
||||
"nightscoutConnectFailed": "Impossible de se connecter à Nightscout.",
|
||||
"nightscoutDisconnect": "Déconnecter",
|
||||
"nightscoutDisconnectTitle": "Déconnecter Nightscout ?",
|
||||
"nightscoutDisconnectDescription": "Les synchronisations futures s'arrêtent. Les mesures de glycémie déjà importées restent dans votre historique.",
|
||||
"polar": "Polar",
|
||||
"polarDescription": "Connectez votre compte Polar pour synchroniser la récupération Nightly Recharge, les phases de sommeil et l'activité quotidienne.",
|
||||
"polarViewData": "Voir votre sommeil et récupération",
|
||||
"polarHelp": "Connectez-vous à Polar pour accorder l'accès. Les données sont récupérées en arrière-plan une fois par heure.",
|
||||
"polarUnavailable": "Polar n'est pas configuré sur ce serveur. Demandez à votre administrateur d'ajouter les identifiants OAuth Polar.",
|
||||
"polarConnect": "Connecter Polar",
|
||||
"polarDisconnect": "Déconnecter",
|
||||
"polarDisconnectTitle": "Déconnecter Polar ?",
|
||||
"polarDisconnectDescription": "Les synchronisations futures s'arrêtent. Les mesures déjà importées restent dans votre historique.",
|
||||
"polarOauthConnected": "Polar connecté — la première synchronisation démarre en arrière-plan.",
|
||||
"polarOauthFailed": "Échec de la connexion à Polar",
|
||||
"polarOauthError": {
|
||||
"csrf1": "La vérification de sécurité a échoué car le cookie du navigateur était absent. Veuillez relancer la connexion dans le même navigateur.",
|
||||
"state": "La réponse de Polar ne correspondait à aucune demande de connexion en cours. Veuillez relancer la connexion.",
|
||||
"cross_user": "La réponse appartient à une demande d'un autre compte. Veuillez relancer la connexion depuis votre propre compte.",
|
||||
"nocode": "Polar n'a pas renvoyé de code d'autorisation. Réessayez et autorisez l'accès dans la fenêtre Polar.",
|
||||
"nocreds": "Polar n'est pas configuré sur ce serveur. Demandez à votre administrateur d'ajouter les identifiants OAuth Polar.",
|
||||
"token": "L'échange de jeton avec Polar a échoué. Veuillez relancer la connexion.",
|
||||
"rate_limited": "Trop de tentatives de connexion. Patientez un instant et réessayez.",
|
||||
"generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion."
|
||||
},
|
||||
"oura": "Oura",
|
||||
"ouraDescription": "Connectez votre compte Oura pour synchroniser le readiness, les phases de sommeil, la VFC et l'activité quotidienne.",
|
||||
"ouraViewData": "Voir votre sommeil et récupération",
|
||||
"ouraHelp": "Connectez-vous à Oura pour accorder l'accès. Les données sont récupérées en arrière-plan une fois par heure.",
|
||||
"ouraUnavailable": "Oura n'est pas configuré sur ce serveur. Demandez à votre administrateur d'ajouter les identifiants OAuth Oura.",
|
||||
"ouraConnect": "Connecter Oura",
|
||||
"ouraDisconnect": "Déconnecter",
|
||||
"ouraDisconnectTitle": "Déconnecter Oura ?",
|
||||
"ouraDisconnectDescription": "Les synchronisations futures s'arrêtent. Les mesures déjà importées restent dans votre historique.",
|
||||
"ouraOauthConnected": "Oura connecté — la première synchronisation démarre en arrière-plan.",
|
||||
"ouraOauthFailed": "Échec de la connexion à Oura",
|
||||
"ouraOauthError": {
|
||||
"csrf1": "La vérification de sécurité a échoué car le cookie du navigateur était absent. Veuillez relancer la connexion dans le même navigateur.",
|
||||
"state": "La réponse d'Oura ne correspondait à aucune demande de connexion en cours. Veuillez relancer la connexion.",
|
||||
"cross_user": "La réponse appartient à une demande d'un autre compte. Veuillez relancer la connexion depuis votre propre compte.",
|
||||
"nocode": "Oura n'a pas renvoyé de code d'autorisation. Réessayez et autorisez l'accès dans la fenêtre Oura.",
|
||||
"nocreds": "Oura n'est pas configuré sur ce serveur. Demandez à votre administrateur d'ajouter les identifiants OAuth Oura.",
|
||||
"token": "L'échange de jeton avec Oura a échoué. Veuillez relancer la connexion.",
|
||||
"rate_limited": "Trop de tentatives de connexion. Patientez un instant et réessayez.",
|
||||
"generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -5404,6 +5574,10 @@
|
||||
"statusOff": "Désactivé",
|
||||
"daysLabel": "Prévenir en dessous de (jours)",
|
||||
"daysAria": "Choisir le seuil de stock en jours",
|
||||
"leadLabel": "Délai de réapprovisionnement (jours)",
|
||||
"leadAria": "Délai de réapprovisionnement en jours",
|
||||
"leadHelp": "L’alerte est envoyée ce nombre de jours plus un intervalle de dose plus tôt, pour qu’un réapprovisionnement arrive avant ta dernière dose. Un médicament donné peut remplacer cette valeur dans son onglet stock.",
|
||||
"leadSavedToast": "Délai de réapprovisionnement enregistré.",
|
||||
"savedToast": "Seuil de stock enregistré.",
|
||||
"enabledToast": "Alertes de stock faible activées.",
|
||||
"disabledToast": "Alertes de stock faible désactivées.",
|
||||
@@ -5597,7 +5771,9 @@
|
||||
"lowStockReminders": {
|
||||
"title": "Stock faible : {medName}",
|
||||
"body": "{medName} : {units} unités restantes — environ {days} jours de réserve.",
|
||||
"bodyToday": "{medName} : {units} unités restantes — moins d’un jour de réserve."
|
||||
"bodyToday": "{medName} : {units} unités restantes — moins d’un jour de réserve.",
|
||||
"bodyReorder": "{medName} : {units} unités restantes. Le stock sera épuisé vers le {runsOutOn} — recommandez avant le {reorderBy}.",
|
||||
"bodyLastDose": "{medName} : {units} unités restantes — environ votre dernière dose. Le stock sera épuisé vers le {runsOutOn}."
|
||||
},
|
||||
"moodReminders": {
|
||||
"dailyTitle": "Enregistrer votre humeur",
|
||||
@@ -5926,7 +6102,10 @@
|
||||
"contraceptive": "Contraception",
|
||||
"severity": "Intensité",
|
||||
"severityLevel": "Intensité {level} sur 4",
|
||||
"delete": "Supprimer"
|
||||
"delete": "Supprimer",
|
||||
"temperatureSection": "Température et ovulation",
|
||||
"intimacySection": "Intimité et contraception",
|
||||
"testsSection": "Tests"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Réglages du cycle",
|
||||
|
||||
+194
-15
@@ -720,7 +720,12 @@
|
||||
"iconGroupWeather": "Meteo",
|
||||
"iconGroupPlaces": "Luoghi",
|
||||
"iconGroupMisc": "Altro"
|
||||
}
|
||||
},
|
||||
"sectionMoreTags": "Altri tag",
|
||||
"sectionFactors": "Fattori",
|
||||
"sectionNoteDetails": "Nota e dettagli",
|
||||
"quickTagsHelp": "I tuoi tag frequenti: un tocco",
|
||||
"quickTagsExpand": "Altro"
|
||||
},
|
||||
"medications": {
|
||||
"title": "Farmaci",
|
||||
@@ -2666,6 +2671,35 @@
|
||||
"title": "Ancora nessun dato sul sonno",
|
||||
"description": "La sync con Apple Health arriva con v1.5 e riempirà questa vista in automatico.",
|
||||
"action": "Gestisci dispositivi"
|
||||
},
|
||||
"debt": {
|
||||
"title": "Debito di sonno",
|
||||
"value": "{hours} h {minutes} min in meno",
|
||||
"caption": "Deficit cumulato nelle ultime {nights} notti",
|
||||
"clearValue": "In pari",
|
||||
"clearCaption": "Nessun deficit accumulato nelle ultime {nights} notti",
|
||||
"learning": "Stiamo ancora misurando il tuo sonno — {counted} notti finora, ne mancano {remaining} per un bilancio affidabile."
|
||||
},
|
||||
"chronotype": {
|
||||
"title": "Cronotipo",
|
||||
"learning": "Stiamo ancora imparando il tuo ritmo — {counted} di {total} notti libere. Un cronotipo richiede alcune mattine senza sveglia per avere significato.",
|
||||
"midSleepCaption": "Metà del sonno nei giorni liberi intorno alle {clock}",
|
||||
"advancedToggle": "Avanzate",
|
||||
"socialJetlagValue": "{hours} h {minutes} min di jet lag sociale",
|
||||
"socialJetlagCaption": "La differenza tra gli orari del sonno nei giorni lavorativi e in quelli liberi — come un piccolo cambio di fuso orario settimanale.",
|
||||
"msfScValue": "Metà del sonno corretta {clock}",
|
||||
"msfScCaption": "Corretta per il sonno recuperato nel fine settimana (MSFsc).",
|
||||
"dayTypeNote": "Si presume che i giorni liberi siano i fine settimana finché non verrà aggiunto il tracciamento del tipo di giorno.",
|
||||
"band": {
|
||||
"extreme_early": "Tipo mattutino marcato",
|
||||
"early": "Tipo mattutino",
|
||||
"intermediate": "Tipo intermedio",
|
||||
"late": "Tipo serale",
|
||||
"extreme_late": "Tipo serale marcato"
|
||||
}
|
||||
},
|
||||
"rhythm": {
|
||||
"loadError": "Non è stato possibile caricare il tuo ritmo del sonno in questo momento. Riapparirà non appena la connessione sarà stabile."
|
||||
}
|
||||
},
|
||||
"correlations": "Correlazioni",
|
||||
@@ -2795,6 +2829,71 @@
|
||||
"emptyState": {
|
||||
"title": "Nessun dato di glicemia",
|
||||
"description": "Le letture di glicemia appaiono qui quando ne registri una o un dispositivo si sincronizza."
|
||||
},
|
||||
"clinical": {
|
||||
"title": "Panoramica della glicemia",
|
||||
"subtitle": "Una lettura dei tuoi ultimi {days} giorni, da {count} misurazioni.",
|
||||
"spotCaveat": "Sono stime da singole misurazioni puntuali, non il profilo di un sensore continuo: leggile come una tendenza, non come un verdetto clinico.",
|
||||
"learningTitle": "Stiamo ancora imparando la tua glicemia",
|
||||
"learningBody": "Per ora sono {count} su {days}. Con un po’ più di storico il quadro si chiarisce: continua a registrare o lascia che lo completi un dispositivo connesso.",
|
||||
"learningBodyEmpty": "Appena arrivano alcune misurazioni — registrate da te o sincronizzate da un dispositivo — il tempo nel range, la media e la variabilità prendono forma qui.",
|
||||
"readingsCount": "{count} misurazioni",
|
||||
"readingsCountOne": "1 misurazione",
|
||||
"daysCount": "{days} giorni",
|
||||
"daysCountOne": "1 giorno",
|
||||
"tir": {
|
||||
"title": "Tempo nel range",
|
||||
"caption": "Quota di misurazioni in ciascuna fascia (70–180 mg/dl è l’obiettivo).",
|
||||
"veryLow": "Molto basso",
|
||||
"low": "Basso",
|
||||
"inRange": "Nel range",
|
||||
"high": "Alto",
|
||||
"veryHigh": "Molto alto",
|
||||
"ariaLabel": "Tempo nel range: {tir}% a target, {below}% sotto, {above}% sopra.",
|
||||
"empty": "Ancora nessuna misurazione da mostrare."
|
||||
},
|
||||
"gmi": {
|
||||
"label": "Indicatore di gestione della glicemia",
|
||||
"help": "Una stima simile all’HbA1c derivata dalla tua glicemia media."
|
||||
},
|
||||
"eA1c": {
|
||||
"label": "HbA1c stimata",
|
||||
"help": "Una stima dell’HbA1c dalla tua glicemia media (ADAG)."
|
||||
},
|
||||
"mean": {
|
||||
"label": "Glicemia media"
|
||||
},
|
||||
"cv": {
|
||||
"label": "Variabilità",
|
||||
"steady": "Stabile",
|
||||
"unstable": "Instabile",
|
||||
"help": "Coefficiente di variazione. Dal 36 % la glicemia è considerata instabile; più basso è più stabile."
|
||||
},
|
||||
"advanced": {
|
||||
"toggle": "Indici avanzati",
|
||||
"intro": "Indicatori di livello scientifico per uno sguardo più ravvicinato. Ognuno riduce le tue misurazioni a un singolo numero ponderato sul rischio.",
|
||||
"jIndex": {
|
||||
"label": "Indice J",
|
||||
"help": "Un punteggio unico che combina media e dispersione: più è basso, più stretto è il controllo."
|
||||
},
|
||||
"lbgi": {
|
||||
"label": "Rischio di ipoglicemia (LBGI)",
|
||||
"help": "Indice di Kovatchev che pesa quanto spesso e quanto in basso scendono le letture."
|
||||
},
|
||||
"hbgi": {
|
||||
"label": "Rischio di iperglicemia (HBGI)",
|
||||
"help": "Indice di Kovatchev che pesa quanto spesso e quanto in alto salgono le letture."
|
||||
},
|
||||
"byContext": {
|
||||
"title": "Per contesto",
|
||||
"fasting": "A digiuno",
|
||||
"postprandial": "Dopo i pasti",
|
||||
"random": "Casuale",
|
||||
"bedtime": "Prima di dormire",
|
||||
"readingsShort": "{count} misurazioni",
|
||||
"readingsShortOne": "1 misurazione"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"navBloodGlucose": "Glucosio",
|
||||
@@ -3549,7 +3648,7 @@
|
||||
},
|
||||
"goals": {
|
||||
"title": "Cosa vuoi monitorare?",
|
||||
"body": "Scegli gli ambiti che ti interessano. Dashboard e promemoria si adatteranno alle tue scelte.",
|
||||
"body": "Scegli gli ambiti che ti interessano: ti aiuta a decidere cosa monitorare e configurare in seguito.",
|
||||
"helpHint": "Da una a tre priorità funzionano di solito meglio — potrai aggiungerne altre in qualsiasi momento.",
|
||||
"options": {
|
||||
"weightManagement": "Gestione del peso",
|
||||
@@ -3562,7 +3661,7 @@
|
||||
},
|
||||
"source": {
|
||||
"title": "Connetti una sorgente",
|
||||
"body": "Importa le misurazioni in automatico oppure resta sull'inserimento manuale, decidi tu.",
|
||||
"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.",
|
||||
"recommended": "Consigliato",
|
||||
"manual": {
|
||||
"title": "Inserimento manuale",
|
||||
@@ -3573,12 +3672,17 @@
|
||||
"body": "Bilance, misuratori di pressione e tracker Withings si sincronizzano automaticamente.",
|
||||
"cta": "Connetti Withings"
|
||||
},
|
||||
"appleHealth": {
|
||||
"title": "Apple Health",
|
||||
"body": "I dati di iPhone e Apple Watch arrivano tramite l'app HealthLog per iOS.",
|
||||
"badge": "Con la v1.5 (iOS)",
|
||||
"disabledHint": "Disponibile al rilascio dell'app iOS."
|
||||
}
|
||||
"more": {
|
||||
"title": "Altre fonti",
|
||||
"body": "Collega una di queste nelle Impostazioni quando vuoi.",
|
||||
"appleHealth": "Apple Health",
|
||||
"whoop": "WHOOP",
|
||||
"oura": "Oura",
|
||||
"polar": "Polar",
|
||||
"nightscout": "Nightscout",
|
||||
"fitbit": "Fitbit"
|
||||
},
|
||||
"import": "Importa la cronologia esistente"
|
||||
},
|
||||
"baseline": {
|
||||
"title": "Qualcosa su di te",
|
||||
@@ -3598,7 +3702,11 @@
|
||||
"done": {
|
||||
"title": "Tutto pronto",
|
||||
"body": "Benvenuto a bordo. La tua dashboard è pronta.",
|
||||
"returnCta": "Apri la dashboard"
|
||||
"returnCta": "Apri la dashboard",
|
||||
"learning": "Alcune analisi — glicemia, ritmo del sonno, tendenze della pressione, ciclo — si affinano nella prima settimana o due man mano che i tuoi dati si accumulano. Le caselle vuote ora indicano solo che stiamo ancora imparando il tuo riferimento.",
|
||||
"connectCta": "Collega un dispositivo",
|
||||
"logCta": "Registra la tua prima misurazione",
|
||||
"importCta": "Importa la cronologia esistente"
|
||||
},
|
||||
"welcomeBack": {
|
||||
"title": "Bentornato",
|
||||
@@ -3643,9 +3751,9 @@
|
||||
"medicationTitle": "Aggiungi un farmaco",
|
||||
"medicationDescription": "Monitora l’aderenza e ricevi promemoria proprio quando ti servono.",
|
||||
"medicationCta": "Aggiungi farmaco",
|
||||
"withingsTitle": "Collega Withings (opzionale)",
|
||||
"withingsDescription": "Sincronizza peso, pressione e pulsazioni automaticamente dai tuoi dispositivi Withings.",
|
||||
"withingsCta": "Collega",
|
||||
"dataSourceTitle": "Collega una fonte di dati (opzionale)",
|
||||
"dataSourceDescription": "Sincronizza peso, pressione, sonno e altro da Withings, WHOOP, Oura, Polar, Nightscout, Fitbit o Apple Health.",
|
||||
"dataSourceCta": "Collega",
|
||||
"notificationsTitle": "Configura le notifiche",
|
||||
"notificationsDescription": "Scegli come HealthLog ti ricorda — Telegram, ntfy o Web Push.",
|
||||
"notificationsCta": "Apri notifiche"
|
||||
@@ -4470,6 +4578,68 @@
|
||||
"enable": "Attiva il monitoraggio del ciclo",
|
||||
"manage": "Apri il monitoraggio del ciclo",
|
||||
"error": "Impossibile aggiornare il monitoraggio del ciclo."
|
||||
},
|
||||
"nightscout": "Nightscout",
|
||||
"nightscoutTag": "CGM",
|
||||
"nightscoutDescription": "Collega il tuo sito Nightscout per sincronizzare le letture continue della glicemia nel pannello glicemia.",
|
||||
"nightscoutViewData": "Vedi la tua glicemia",
|
||||
"nightscoutHelp": "Punta HealthLog alla tua istanza Nightscout. Le letture vengono recuperate in background una volta all'ora.",
|
||||
"nightscoutUrl": "URL Nightscout",
|
||||
"nightscoutUrlSavedPlaceholder": "Salvato — inserisci un nuovo URL per sostituirlo",
|
||||
"nightscoutToken": "Token API",
|
||||
"nightscoutTokenOptional": "Facoltativo per un sito pubblico",
|
||||
"nightscoutTokenSavedPlaceholder": "Salvato — inserisci un nuovo token per sostituirlo",
|
||||
"nightscoutTokenHelp": "Incolla un token di accesso dagli strumenti di amministrazione di Nightscout. Lascia vuoto se il tuo sito è leggibile pubblicamente.",
|
||||
"nightscoutPrivateHost": "Questo sito è su una rete privata",
|
||||
"nightscoutPrivateHostHelp": "Attiva solo se la tua istanza Nightscout è su un indirizzo LAN, VPN o Tailscale. Disattivato per i siti pubblici.",
|
||||
"nightscoutConnect": "Connetti",
|
||||
"nightscoutUpdate": "Salva modifiche",
|
||||
"nightscoutConnected": "Nightscout connesso.",
|
||||
"nightscoutConnectFailed": "Impossibile connettersi a Nightscout.",
|
||||
"nightscoutDisconnect": "Disconnetti",
|
||||
"nightscoutDisconnectTitle": "Disconnettere Nightscout?",
|
||||
"nightscoutDisconnectDescription": "Le sincronizzazioni future si interrompono. Le letture della glicemia già importate restano nella tua cronologia.",
|
||||
"polar": "Polar",
|
||||
"polarDescription": "Collega il tuo account Polar per sincronizzare il recupero Nightly Recharge, le fasi del sonno e l'attività giornaliera.",
|
||||
"polarViewData": "Vedi sonno e recupero",
|
||||
"polarHelp": "Accedi a Polar per concedere l'accesso. I dati vengono recuperati in background una volta all'ora.",
|
||||
"polarUnavailable": "Polar non è configurato su questo server. Chiedi al tuo amministratore di aggiungere le credenziali OAuth di Polar.",
|
||||
"polarConnect": "Collega Polar",
|
||||
"polarDisconnect": "Disconnetti",
|
||||
"polarDisconnectTitle": "Disconnettere Polar?",
|
||||
"polarDisconnectDescription": "Le sincronizzazioni future si interrompono. Le letture già importate restano nella tua cronologia.",
|
||||
"polarOauthConnected": "Polar collegato — la prima sincronizzazione parte in background.",
|
||||
"polarOauthFailed": "Connessione a Polar non riuscita",
|
||||
"polarOauthError": {
|
||||
"csrf1": "Il controllo di sicurezza è fallito perché il cookie del browser era assente. Riavvia la connessione nello stesso browser.",
|
||||
"state": "La risposta di Polar non corrispondeva a nessuna richiesta di connessione aperta. Riavvia la connessione.",
|
||||
"cross_user": "La risposta appartiene a una richiesta di un altro account. Riavvia la connessione dal tuo account.",
|
||||
"nocode": "Polar non ha restituito un codice di autorizzazione. Riprova e concedi l'accesso nella finestra di Polar.",
|
||||
"nocreds": "Polar non è configurato su questo server. Chiedi al tuo amministratore di aggiungere le credenziali OAuth di Polar.",
|
||||
"token": "Lo scambio del token con Polar è fallito. Riavvia la connessione.",
|
||||
"rate_limited": "Troppi tentativi di connessione. Attendi un momento e riprova.",
|
||||
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione."
|
||||
},
|
||||
"oura": "Oura",
|
||||
"ouraDescription": "Collega il tuo account Oura per sincronizzare readiness, fasi del sonno, HRV e attività giornaliera.",
|
||||
"ouraViewData": "Vedi sonno e recupero",
|
||||
"ouraHelp": "Accedi a Oura per concedere l'accesso. I dati vengono recuperati in background una volta all'ora.",
|
||||
"ouraUnavailable": "Oura non è configurato su questo server. Chiedi al tuo amministratore di aggiungere le credenziali OAuth di Oura.",
|
||||
"ouraConnect": "Collega Oura",
|
||||
"ouraDisconnect": "Disconnetti",
|
||||
"ouraDisconnectTitle": "Disconnettere Oura?",
|
||||
"ouraDisconnectDescription": "Le sincronizzazioni future si interrompono. Le letture già importate restano nella tua cronologia.",
|
||||
"ouraOauthConnected": "Oura collegato — la prima sincronizzazione parte in background.",
|
||||
"ouraOauthFailed": "Connessione a Oura non riuscita",
|
||||
"ouraOauthError": {
|
||||
"csrf1": "Il controllo di sicurezza è fallito perché il cookie del browser era assente. Riavvia la connessione nello stesso browser.",
|
||||
"state": "La risposta di Oura non corrispondeva a nessuna richiesta di connessione aperta. Riavvia la connessione.",
|
||||
"cross_user": "La risposta appartiene a una richiesta di un altro account. Riavvia la connessione dal tuo account.",
|
||||
"nocode": "Oura non ha restituito un codice di autorizzazione. Riprova e concedi l'accesso nella finestra di Oura.",
|
||||
"nocreds": "Oura non è configurato su questo server. Chiedi al tuo amministratore di aggiungere le credenziali OAuth di Oura.",
|
||||
"token": "Lo scambio del token con Oura è fallito. Riavvia la connessione.",
|
||||
"rate_limited": "Troppi tentativi di connessione. Attendi un momento e riprova.",
|
||||
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -5404,6 +5574,10 @@
|
||||
"statusOff": "Disattivato",
|
||||
"daysLabel": "Avvisa sotto (giorni)",
|
||||
"daysAria": "Seleziona la soglia delle scorte in giorni",
|
||||
"leadLabel": "Tempo di riordino (giorni)",
|
||||
"leadAria": "Tempo di riordino in giorni",
|
||||
"leadHelp": "L’avviso arriva questo numero di giorni più un intervallo di dose in anticipo, così la fornitura arriva prima dell’ultima dose. Un singolo farmaco può sovrascrivere questo valore nella sua scheda scorte.",
|
||||
"leadSavedToast": "Tempo di riordino salvato.",
|
||||
"savedToast": "Soglia delle scorte salvata.",
|
||||
"enabledToast": "Avvisi di scorte basse attivati.",
|
||||
"disabledToast": "Avvisi di scorte basse disattivati.",
|
||||
@@ -5597,7 +5771,9 @@
|
||||
"lowStockReminders": {
|
||||
"title": "Scorte in esaurimento: {medName}",
|
||||
"body": "{medName}: {units} unità rimaste — bastano per circa {days} giorni.",
|
||||
"bodyToday": "{medName}: {units} unità rimaste — meno di un giorno di scorta."
|
||||
"bodyToday": "{medName}: {units} unità rimaste — meno di un giorno di scorta.",
|
||||
"bodyReorder": "{medName}: {units} unità rimaste. Le scorte finiscono intorno al {runsOutOn} — riordina entro il {reorderBy}.",
|
||||
"bodyLastDose": "{medName}: {units} unità rimaste — circa la tua ultima dose. Le scorte finiscono intorno al {runsOutOn}."
|
||||
},
|
||||
"moodReminders": {
|
||||
"dailyTitle": "Registra il tuo umore",
|
||||
@@ -5926,7 +6102,10 @@
|
||||
"contraceptive": "Contraccettivo",
|
||||
"severity": "Intensità",
|
||||
"severityLevel": "Intensità {level} su 4",
|
||||
"delete": "Elimina"
|
||||
"delete": "Elimina",
|
||||
"temperatureSection": "Temperatura e ovulazione",
|
||||
"intimacySection": "Intimità e contraccezione",
|
||||
"testsSection": "Test"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Impostazioni del ciclo",
|
||||
|
||||
+194
-15
@@ -720,7 +720,12 @@
|
||||
"iconGroupWeather": "Pogoda",
|
||||
"iconGroupPlaces": "Miejsca",
|
||||
"iconGroupMisc": "Inne"
|
||||
}
|
||||
},
|
||||
"sectionMoreTags": "Więcej tagów",
|
||||
"sectionFactors": "Czynniki",
|
||||
"sectionNoteDetails": "Notatka i szczegóły",
|
||||
"quickTagsHelp": "Twoje częste tagi — jedno dotknięcie",
|
||||
"quickTagsExpand": "Więcej"
|
||||
},
|
||||
"medications": {
|
||||
"title": "Leki",
|
||||
@@ -2666,6 +2671,35 @@
|
||||
"title": "Brak danych o śnie",
|
||||
"description": "Synchronizacja Apple Health pojawi się w v1.5 i wypełni ten widok automatycznie.",
|
||||
"action": "Zarządzaj urządzeniami"
|
||||
},
|
||||
"debt": {
|
||||
"title": "Dług snu",
|
||||
"value": "{hours} godz. {minutes} min niedoboru",
|
||||
"caption": "Skumulowany niedobór z ostatnich {nights} nocy",
|
||||
"clearValue": "Na bieżąco",
|
||||
"clearCaption": "Brak skumulowanego niedoboru w ostatnich {nights} nocach",
|
||||
"learning": "Wciąż analizujemy Twój sen — {counted} nocy do tej pory, jeszcze {remaining}, aby uzyskać wiarygodny bilans."
|
||||
},
|
||||
"chronotype": {
|
||||
"title": "Chronotyp",
|
||||
"learning": "Wciąż poznajemy Twój rytm — {counted} z {total} wolnych nocy. Chronotyp wymaga kilku poranków bez budzika, aby coś znaczył.",
|
||||
"midSleepCaption": "Środek snu w dni wolne około {clock}",
|
||||
"advancedToggle": "Zaawansowane",
|
||||
"socialJetlagValue": "{hours} godz. {minutes} min jet lagu społecznego",
|
||||
"socialJetlagCaption": "Różnica między porą snu w dni robocze i wolne — jak niewielka cotygodniowa zmiana strefy czasowej.",
|
||||
"msfScValue": "Skorygowany środek snu {clock}",
|
||||
"msfScCaption": "Skorygowany o sen nadrabiany w weekend (MSFsc).",
|
||||
"dayTypeNote": "Dni wolne przyjmuje się jako weekendy, dopóki nie zostanie dodane śledzenie typu dnia.",
|
||||
"band": {
|
||||
"extreme_early": "Wyraźny typ poranny",
|
||||
"early": "Typ poranny",
|
||||
"intermediate": "Typ pośredni",
|
||||
"late": "Typ wieczorny",
|
||||
"extreme_late": "Wyraźny typ wieczorny"
|
||||
}
|
||||
},
|
||||
"rhythm": {
|
||||
"loadError": "Nie udało się teraz wczytać Twojego rytmu snu. Pojawi się ponownie, gdy połączenie się ustabilizuje."
|
||||
}
|
||||
},
|
||||
"correlations": "Korelacje",
|
||||
@@ -2795,6 +2829,71 @@
|
||||
"emptyState": {
|
||||
"title": "Brak danych o glukozie",
|
||||
"description": "Odczyty glukozy pojawią się tutaj po zapisaniu jednego lub synchronizacji urządzenia."
|
||||
},
|
||||
"clinical": {
|
||||
"title": "Przegląd glukozy",
|
||||
"subtitle": "Spojrzenie na ostatnie {days} dni, na podstawie {count} pomiarów.",
|
||||
"spotCaveat": "To szacunki z pojedynczych pomiarów punktowych, a nie profil z czujnika ciągłego — traktuj je jako kierunek, nie jako diagnozę.",
|
||||
"learningTitle": "Wciąż poznajemy Twoją glukozę",
|
||||
"learningBody": "Na razie to {count} w ciągu {days}. Przy nieco dłuższej historii obraz się wyostrza — rejestruj dalej lub pozwól uzupełnić to podłączonemu urządzeniu.",
|
||||
"learningBodyEmpty": "Gdy pojawi się kilka pomiarów — wpisanych przez Ciebie lub zsynchronizowanych z urządzenia — czas w zakresie, średnia i zmienność nabiorą tu kształtu.",
|
||||
"readingsCount": "{count} pomiarów",
|
||||
"readingsCountOne": "1 pomiar",
|
||||
"daysCount": "{days} dni",
|
||||
"daysCountOne": "1 dzień",
|
||||
"tir": {
|
||||
"title": "Czas w zakresie",
|
||||
"caption": "Udział pomiarów w każdym przedziale (70–180 mg/dl to cel).",
|
||||
"veryLow": "Bardzo niski",
|
||||
"low": "Niski",
|
||||
"inRange": "W zakresie",
|
||||
"high": "Wysoki",
|
||||
"veryHigh": "Bardzo wysoki",
|
||||
"ariaLabel": "Czas w zakresie: {tir}% w celu, {below}% poniżej, {above}% powyżej.",
|
||||
"empty": "Brak pomiarów do wykresu."
|
||||
},
|
||||
"gmi": {
|
||||
"label": "Wskaźnik zarządzania glukozą",
|
||||
"help": "Szacunek zbliżony do HbA1c na podstawie średniej glukozy."
|
||||
},
|
||||
"eA1c": {
|
||||
"label": "Szacowane HbA1c",
|
||||
"help": "Szacunek HbA1c na podstawie średniej glukozy (ADAG)."
|
||||
},
|
||||
"mean": {
|
||||
"label": "Średnia glukoza"
|
||||
},
|
||||
"cv": {
|
||||
"label": "Zmienność",
|
||||
"steady": "Stabilna",
|
||||
"unstable": "Niestabilna",
|
||||
"help": "Współczynnik zmienności. Od 36 % glukozę uznaje się za niestabilną; niżej znaczy stabilniej."
|
||||
},
|
||||
"advanced": {
|
||||
"toggle": "Wskaźniki zaawansowane",
|
||||
"intro": "Złożone miary o poziomie badawczym dla bliższego spojrzenia. Każda sprowadza Twoje pomiary do jednej liczby ważonej ryzykiem.",
|
||||
"jIndex": {
|
||||
"label": "Indeks J",
|
||||
"help": "Pojedynczy wynik łączący średnią i jej rozrzut — niższy oznacza ściślejszą kontrolę."
|
||||
},
|
||||
"lbgi": {
|
||||
"label": "Ryzyko niedocukrzeń (LBGI)",
|
||||
"help": "Indeks Kovatcheva ważący, jak często i jak głęboko odczyty spadają."
|
||||
},
|
||||
"hbgi": {
|
||||
"label": "Ryzyko przecukrzeń (HBGI)",
|
||||
"help": "Indeks Kovatcheva ważący, jak często i jak wysoko odczyty rosną."
|
||||
},
|
||||
"byContext": {
|
||||
"title": "Wg kontekstu",
|
||||
"fasting": "Na czczo",
|
||||
"postprandial": "Po posiłku",
|
||||
"random": "Losowy",
|
||||
"bedtime": "Przed snem",
|
||||
"readingsShort": "{count} pomiarów",
|
||||
"readingsShortOne": "1 pomiar"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"navBloodGlucose": "Glukoza",
|
||||
@@ -3549,7 +3648,7 @@
|
||||
},
|
||||
"goals": {
|
||||
"title": "Co chcesz śledzić?",
|
||||
"body": "Wybierz obszary, które są dla Ciebie ważne. Pulpit i przypomnienia dostosują się do Twojego wyboru.",
|
||||
"body": "Wybierz obszary, które są dla Ciebie ważne — to pomaga zdecydować, co śledzić i co skonfigurować w następnej kolejności.",
|
||||
"helpHint": "Od jednego do trzech priorytetów zwykle sprawdza się najlepiej — możesz dodać kolejne w dowolnej chwili.",
|
||||
"options": {
|
||||
"weightManagement": "Kontrola wagi",
|
||||
@@ -3562,7 +3661,7 @@
|
||||
},
|
||||
"source": {
|
||||
"title": "Połącz źródło",
|
||||
"body": "Pobieraj pomiary automatycznie lub zostań przy wpisywaniu ręcznym — Twój wybór.",
|
||||
"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ć.",
|
||||
"recommended": "Polecane",
|
||||
"manual": {
|
||||
"title": "Wpis ręczny",
|
||||
@@ -3573,12 +3672,17 @@
|
||||
"body": "Wagi, ciśnieniomierze i trackery Withings synchronizują się automatycznie.",
|
||||
"cta": "Połącz Withings"
|
||||
},
|
||||
"appleHealth": {
|
||||
"title": "Apple Health",
|
||||
"body": "Dane z iPhone'a i Apple Watch trafiają tu poprzez aplikację HealthLog na iOS.",
|
||||
"badge": "Wraz z v1.5 (iOS)",
|
||||
"disabledHint": "Dostępne po wydaniu aplikacji iOS."
|
||||
}
|
||||
"more": {
|
||||
"title": "Więcej źródeł",
|
||||
"body": "Połącz dowolne z nich w Ustawieniach, gdy zechcesz.",
|
||||
"appleHealth": "Apple Health",
|
||||
"whoop": "WHOOP",
|
||||
"oura": "Oura",
|
||||
"polar": "Polar",
|
||||
"nightscout": "Nightscout",
|
||||
"fitbit": "Fitbit"
|
||||
},
|
||||
"import": "Importuj istniejącą historię"
|
||||
},
|
||||
"baseline": {
|
||||
"title": "Kilka słów o Tobie",
|
||||
@@ -3598,7 +3702,11 @@
|
||||
"done": {
|
||||
"title": "Wszystko gotowe",
|
||||
"body": "Witaj na pokładzie. Pulpit czeka na Ciebie.",
|
||||
"returnCta": "Otwórz pulpit"
|
||||
"returnCta": "Otwórz pulpit",
|
||||
"learning": "Niektóre wnioski — glukoza, rytm snu, trendy ciśnienia, cykl — wyostrzają się w ciągu pierwszego tygodnia lub dwóch, w miarę gromadzenia danych. Puste kafelki oznaczają teraz tylko, że wciąż poznajemy Twój punkt odniesienia.",
|
||||
"connectCta": "Połącz urządzenie",
|
||||
"logCta": "Zapisz pierwszy pomiar",
|
||||
"importCta": "Importuj istniejącą historię"
|
||||
},
|
||||
"welcomeBack": {
|
||||
"title": "Witaj ponownie",
|
||||
@@ -3643,9 +3751,9 @@
|
||||
"medicationTitle": "Dodaj lek",
|
||||
"medicationDescription": "Śledź przestrzeganie i otrzymuj przypomnienia dokładnie wtedy, kiedy ich potrzebujesz.",
|
||||
"medicationCta": "Dodaj lek",
|
||||
"withingsTitle": "Połącz Withings (opcjonalnie)",
|
||||
"withingsDescription": "Automatycznie synchronizuj wagę, ciśnienie i tętno z urządzeń Withings.",
|
||||
"withingsCta": "Połącz",
|
||||
"dataSourceTitle": "Połącz źródło danych (opcjonalnie)",
|
||||
"dataSourceDescription": "Automatycznie synchronizuj wagę, ciśnienie, sen i więcej z Withings, WHOOP, Oura, Polar, Nightscout, Fitbit lub Apple Health.",
|
||||
"dataSourceCta": "Połącz",
|
||||
"notificationsTitle": "Skonfiguruj powiadomienia",
|
||||
"notificationsDescription": "Wybierz, jak HealthLog ma ci przypominać — Telegram, ntfy lub Web Push.",
|
||||
"notificationsCta": "Otwórz powiadomienia"
|
||||
@@ -4470,6 +4578,68 @@
|
||||
"enable": "Włącz śledzenie cyklu",
|
||||
"manage": "Otwórz śledzenie cyklu",
|
||||
"error": "Nie udało się zaktualizować śledzenia cyklu."
|
||||
},
|
||||
"nightscout": "Nightscout",
|
||||
"nightscoutTag": "CGM",
|
||||
"nightscoutDescription": "Połącz własną witrynę Nightscout, aby synchronizować ciągłe odczyty glukozy w panelu glukozy.",
|
||||
"nightscoutViewData": "Zobacz swoją glukozę",
|
||||
"nightscoutHelp": "Wskaż HealthLog na swoją instancję Nightscout. Odczyty są pobierane w tle raz na godzinę.",
|
||||
"nightscoutUrl": "Adres URL Nightscout",
|
||||
"nightscoutUrlSavedPlaceholder": "Zapisano — wpisz nowy adres URL, aby go zastąpić",
|
||||
"nightscoutToken": "Token API",
|
||||
"nightscoutTokenOptional": "Opcjonalny dla witryny publicznej",
|
||||
"nightscoutTokenSavedPlaceholder": "Zapisano — wpisz nowy token, aby go zastąpić",
|
||||
"nightscoutTokenHelp": "Wklej token dostępu z narzędzi administracyjnych Nightscout. Pozostaw puste, jeśli witryna jest publicznie czytelna.",
|
||||
"nightscoutPrivateHost": "Ta witryna działa w sieci prywatnej",
|
||||
"nightscoutPrivateHostHelp": "Włącz tylko, jeśli instancja Nightscout znajduje się pod adresem LAN, VPN lub Tailscale. Wyłączone dla witryn publicznych.",
|
||||
"nightscoutConnect": "Połącz",
|
||||
"nightscoutUpdate": "Zapisz zmiany",
|
||||
"nightscoutConnected": "Nightscout połączony.",
|
||||
"nightscoutConnectFailed": "Nie można połączyć z Nightscout.",
|
||||
"nightscoutDisconnect": "Rozłącz",
|
||||
"nightscoutDisconnectTitle": "Rozłączyć Nightscout?",
|
||||
"nightscoutDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane. Już zaimportowane odczyty glukozy pozostaną w historii.",
|
||||
"polar": "Polar",
|
||||
"polarDescription": "Połącz konto Polar, aby synchronizować regenerację Nightly Recharge, fazy snu i aktywność dzienną.",
|
||||
"polarViewData": "Zobacz sen i regenerację",
|
||||
"polarHelp": "Zaloguj się w Polar, aby przyznać dostęp. Dane są pobierane w tle raz na godzinę.",
|
||||
"polarUnavailable": "Polar nie jest skonfigurowany na tym serwerze. Poproś administratora o dodanie danych OAuth Polar.",
|
||||
"polarConnect": "Połącz Polar",
|
||||
"polarDisconnect": "Rozłącz",
|
||||
"polarDisconnectTitle": "Rozłączyć Polar?",
|
||||
"polarDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane. Już zaimportowane odczyty pozostaną w historii.",
|
||||
"polarOauthConnected": "Polar połączony — pierwsza synchronizacja startuje w tle.",
|
||||
"polarOauthFailed": "Połączenie z Polar nie powiodło się",
|
||||
"polarOauthError": {
|
||||
"csrf1": "Kontrola bezpieczeństwa nie powiodła się, ponieważ brakowało pliku cookie przeglądarki. Rozpocznij połączenie ponownie w tej samej przeglądarce.",
|
||||
"state": "Odpowiedź z Polar nie pasowała do żadnego otwartego żądania połączenia. Rozpocznij połączenie ponownie.",
|
||||
"cross_user": "Odpowiedź należy do żądania z innego konta. Rozpocznij połączenie ponownie z własnego konta.",
|
||||
"nocode": "Polar nie zwrócił kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie Polar.",
|
||||
"nocreds": "Polar nie jest skonfigurowany na tym serwerze. Poproś administratora o dodanie danych OAuth Polar.",
|
||||
"token": "Wymiana tokena z Polar nie powiodła się. Rozpocznij połączenie ponownie.",
|
||||
"rate_limited": "Zbyt wiele prób połączenia. Poczekaj chwilę i spróbuj ponownie.",
|
||||
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
|
||||
},
|
||||
"oura": "Oura",
|
||||
"ouraDescription": "Połącz konto Oura, aby synchronizować readiness, fazy snu, HRV i aktywność dzienną.",
|
||||
"ouraViewData": "Zobacz sen i regenerację",
|
||||
"ouraHelp": "Zaloguj się w Oura, aby przyznać dostęp. Dane są pobierane w tle raz na godzinę.",
|
||||
"ouraUnavailable": "Oura nie jest skonfigurowana na tym serwerze. Poproś administratora o dodanie danych OAuth Oura.",
|
||||
"ouraConnect": "Połącz Oura",
|
||||
"ouraDisconnect": "Rozłącz",
|
||||
"ouraDisconnectTitle": "Rozłączyć Oura?",
|
||||
"ouraDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane. Już zaimportowane odczyty pozostaną w historii.",
|
||||
"ouraOauthConnected": "Oura połączona — pierwsza synchronizacja startuje w tle.",
|
||||
"ouraOauthFailed": "Połączenie z Oura nie powiodło się",
|
||||
"ouraOauthError": {
|
||||
"csrf1": "Kontrola bezpieczeństwa nie powiodła się, ponieważ brakowało pliku cookie przeglądarki. Rozpocznij połączenie ponownie w tej samej przeglądarce.",
|
||||
"state": "Odpowiedź z Oura nie pasowała do żadnego otwartego żądania połączenia. Rozpocznij połączenie ponownie.",
|
||||
"cross_user": "Odpowiedź należy do żądania z innego konta. Rozpocznij połączenie ponownie z własnego konta.",
|
||||
"nocode": "Oura nie zwróciła kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie Oura.",
|
||||
"nocreds": "Oura nie jest skonfigurowana na tym serwerze. Poproś administratora o dodanie danych OAuth Oura.",
|
||||
"token": "Wymiana tokena z Oura nie powiodła się. Rozpocznij połączenie ponownie.",
|
||||
"rate_limited": "Zbyt wiele prób połączenia. Poczekaj chwilę i spróbuj ponownie.",
|
||||
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -5404,6 +5574,10 @@
|
||||
"statusOff": "Wyłączone",
|
||||
"daysLabel": "Powiadom poniżej (dni)",
|
||||
"daysAria": "Wybierz próg zapasu w dniach",
|
||||
"leadLabel": "Czas na ponowne zamówienie (dni)",
|
||||
"leadAria": "Czas na ponowne zamówienie w dniach",
|
||||
"leadHelp": "Alert pojawia się o tyle dni plus jeden interwał dawki wcześniej, aby nowy zapas dotarł przed Twoją ostatnią dawką. Pojedynczy lek może nadpisać tę wartość na swojej karcie zapasu.",
|
||||
"leadSavedToast": "Czas na ponowne zamówienie zapisany.",
|
||||
"savedToast": "Próg zapasu zapisany.",
|
||||
"enabledToast": "Alerty niskiego zapasu włączone.",
|
||||
"disabledToast": "Alerty niskiego zapasu wyłączone.",
|
||||
@@ -5597,7 +5771,9 @@
|
||||
"lowStockReminders": {
|
||||
"title": "Niski zapas: {medName}",
|
||||
"body": "{medName}: pozostało {units} jednostek — wystarczy na około {days} dni.",
|
||||
"bodyToday": "{medName}: pozostało {units} jednostek — mniej niż jeden dzień zapasu."
|
||||
"bodyToday": "{medName}: pozostało {units} jednostek — mniej niż jeden dzień zapasu.",
|
||||
"bodyReorder": "{medName}: pozostało {units} jednostek. Zapas skończy się około {runsOutOn} — zamów ponownie do {reorderBy}.",
|
||||
"bodyLastDose": "{medName}: pozostało {units} jednostek — to mniej więcej Twoja ostatnia dawka. Zapas skończy się około {runsOutOn}."
|
||||
},
|
||||
"moodReminders": {
|
||||
"dailyTitle": "Zapisz nastrój",
|
||||
@@ -5926,7 +6102,10 @@
|
||||
"contraceptive": "Antykoncepcja",
|
||||
"severity": "Nasilenie",
|
||||
"severityLevel": "Nasilenie {level} z 4",
|
||||
"delete": "Usuń"
|
||||
"delete": "Usuń",
|
||||
"temperatureSection": "Temperatura i owulacja",
|
||||
"intimacySection": "Intymność i antykoncepcja",
|
||||
"testsSection": "Testy"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ustawienia cyklu",
|
||||
|
||||
+7
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.16.17",
|
||||
"version": "1.17.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",
|
||||
@@ -69,7 +69,7 @@
|
||||
"fflate": "^0.8.3",
|
||||
"jspdf": "^4.2.1",
|
||||
"jspdf-autotable": "^5.0.8",
|
||||
"lucide-react": "^1.17.0",
|
||||
"lucide-react": "^1.18.0",
|
||||
"mmdb-lib": "^3.0.2",
|
||||
"next": "16.2.6",
|
||||
"next-themes": "^0.4.6",
|
||||
@@ -78,19 +78,19 @@
|
||||
"prisma": "^7.8.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.5",
|
||||
"react-dom": "19.2.5",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"react-hook-form": "^7.78.0",
|
||||
"recharts": "^3.8.1",
|
||||
"rrule": "^2.8.1",
|
||||
"sax": "^1.6.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"undici": "^8.3.0",
|
||||
"undici": "^8.4.1",
|
||||
"web-push": "^3.6.7",
|
||||
"yaml": "^2.8.4",
|
||||
"zod": "^4.4.3",
|
||||
"zod-openapi": "^5.4.6",
|
||||
"zod-openapi": "^6.0.0",
|
||||
"zxcvbn-typescript": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -113,7 +113,7 @@
|
||||
"lefthook": "^2.1.9",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pg": "^8.21.0",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier": "^3.8.4",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"shadcn": "^4.9.0",
|
||||
"tailwindcss": "^4",
|
||||
|
||||
Generated
+612
-612
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,50 @@
|
||||
-- v1.17.0 — schema foundation for three new measurement sources: Nightscout
|
||||
-- glucose (F1) and Polar + Oura OAuth wearables (F4).
|
||||
--
|
||||
-- Purely-additive: three enum-extensions on `measurement_source` plus eight
|
||||
-- new nullable columns on `users` (encrypted credentials + one boolean flag).
|
||||
-- No backfill, no existing row touched, no new tables. The feature waves add
|
||||
-- their own connection / oauth-state tables later; this step only unblocks
|
||||
-- them from colliding on the enum + the credential surface.
|
||||
--
|
||||
-- 1. `measurement_source` += `NIGHTSCOUT`, `POLAR`, `OURA` for the
|
||||
-- server-side native ingest of each source.
|
||||
-- 2. `users` += Nightscout base URL + token (encrypted) + an
|
||||
-- `allow_private_host` opt-in boolean (default false) that lets a
|
||||
-- self-hoster point HealthLog at a private/LAN Nightscout instance past
|
||||
-- the public-host SSRF floor.
|
||||
-- 3. `users` += Polar OAuth credentials (access / refresh token + user id),
|
||||
-- encrypted, mirroring the WHOOP credential columns.
|
||||
-- 4. `users` += Oura OAuth credentials (access / refresh token), encrypted,
|
||||
-- mirroring the WHOOP credential columns.
|
||||
--
|
||||
-- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only.
|
||||
--
|
||||
-- Reversibility: Postgres cannot remove an enum value, so the three sources
|
||||
-- stay; with no rows carrying them they are inert. The columns drop with
|
||||
-- `DROP COLUMN IF EXISTS`.
|
||||
|
||||
-- ── 1. measurement_source — append the three server-owned sources ──────
|
||||
ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'NIGHTSCOUT';
|
||||
ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'POLAR';
|
||||
ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'OURA';
|
||||
|
||||
-- ── 2. users — Nightscout credentials + private-host opt-in ────────────
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN IF NOT EXISTS "nightscout_url_encrypted" TEXT;
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN IF NOT EXISTS "nightscout_token_encrypted" TEXT;
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN IF NOT EXISTS "nightscout_allow_private_host" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- ── 3. users — Polar OAuth credentials (encrypted at app level) ────────
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN IF NOT EXISTS "polar_access_token_encrypted" TEXT;
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN IF NOT EXISTS "polar_user_id_encrypted" TEXT;
|
||||
|
||||
-- ── 4. users — Oura OAuth credentials (encrypted at app level) ─────────
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN IF NOT EXISTS "oura_access_token_encrypted" TEXT;
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN IF NOT EXISTS "oura_refresh_token_encrypted" TEXT;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- v1.17.0 — reorder lead time for the medication low-stock alert.
|
||||
--
|
||||
-- The low-stock alert previously fired only when the projected runway fell
|
||||
-- below the user's threshold (default 7 days). For a sparse cadence (e.g. a
|
||||
-- weekly injection) 7 days is barely one dose-interval, so the warning landed
|
||||
-- with about one dose left — too late to reorder before the supply ran out.
|
||||
--
|
||||
-- `medications.reorder_lead_days` carries an OPTIONAL per-medication reorder
|
||||
-- lead time (days between placing an order and the new supply arriving). The
|
||||
-- alert engine adds it, plus one dose-interval, on top of the user threshold
|
||||
-- so the warning lands before the LAST dose for any cadence. NULL = inherit
|
||||
-- the per-user `notificationPrefs.medication.reorderLeadDays` default.
|
||||
--
|
||||
-- Additive + back-compatible: the column is nullable with no backfill; every
|
||||
-- existing row keeps NULL and inherits the global default. iOS picks the field
|
||||
-- up without a migration of its own.
|
||||
|
||||
ALTER TABLE "medications"
|
||||
ADD COLUMN IF NOT EXISTS "reorder_lead_days" INTEGER;
|
||||
@@ -109,6 +109,31 @@ model User {
|
||||
fitbitClientIdEncrypted String? @map("fitbit_client_id_encrypted")
|
||||
fitbitClientSecretEncrypted String? @map("fitbit_client_secret_encrypted")
|
||||
|
||||
// v1.17.0 — Nightscout glucose integration (F1, per-user, encrypted at app
|
||||
// level). A self-hoster points HealthLog at their own Nightscout instance:
|
||||
// the base URL + the API token / secret are stored encrypted. By default
|
||||
// the base URL must resolve to a public host (the SSRF floor); a self-hoster
|
||||
// running Nightscout on a private/LAN network can flip
|
||||
// `nightscoutAllowPrivateHost` to opt that instance past the public-host
|
||||
// requirement explicitly.
|
||||
nightscoutUrlEncrypted String? @map("nightscout_url_encrypted")
|
||||
nightscoutTokenEncrypted String? @map("nightscout_token_encrypted")
|
||||
nightscoutAllowPrivateHost Boolean @default(false) @map("nightscout_allow_private_host")
|
||||
|
||||
// v1.17.0 — Polar OAuth credentials (F4, per-user, encrypted at app level).
|
||||
// Mirrors the WHOOP credential columns: the per-app authorized-user cap on
|
||||
// Polar AccessLink makes a single shared app unworkable for a multi-operator
|
||||
// product, so each self-hoster registers their own Polar app and the OAuth
|
||||
// tokens are stored encrypted.
|
||||
polarAccessTokenEncrypted String? @map("polar_access_token_encrypted")
|
||||
polarUserIdEncrypted String? @map("polar_user_id_encrypted")
|
||||
|
||||
// v1.17.0 — Oura OAuth credentials (F4, per-user, encrypted at app level).
|
||||
// Mirrors the WHOOP credential columns; OAuth access/refresh tokens stored
|
||||
// encrypted.
|
||||
ouraAccessTokenEncrypted String? @map("oura_access_token_encrypted")
|
||||
ouraRefreshTokenEncrypted String? @map("oura_refresh_token_encrypted")
|
||||
|
||||
// moodLog integration (per-user, encrypted)
|
||||
moodLogUrlEncrypted String? @map("mood_log_url_encrypted")
|
||||
moodLogApiKeyEncrypted String? @map("mood_log_api_key_encrypted")
|
||||
@@ -790,6 +815,19 @@ enum MeasurementSource {
|
||||
// (no client write path); rejected on batch + single POST like WITHINGS/WHOOP.
|
||||
// iOS DTO mirrors this exact spelling so /api/sync/changes decodes the rows.
|
||||
FITBIT
|
||||
// v1.17.0 — Nightscout glucose integration (F1). Server-owned (no client
|
||||
// write path); glucose rows ingest from the user's Nightscout instance.
|
||||
// Rejected on batch + single POST like WITHINGS/WHOOP/FITBIT. iOS DTO
|
||||
// mirrors this exact spelling so /api/sync/changes decodes the rows.
|
||||
NIGHTSCOUT
|
||||
// v1.17.0 — Polar OAuth integration (F4). Server-owned (no client write
|
||||
// path); rejected on batch + single POST like WITHINGS/WHOOP/FITBIT. iOS
|
||||
// DTO mirrors this exact spelling so /api/sync/changes decodes the rows.
|
||||
POLAR
|
||||
// v1.17.0 — Oura OAuth integration (F4). Server-owned (no client write
|
||||
// path); rejected on batch + single POST like WITHINGS/WHOOP/FITBIT. iOS
|
||||
// DTO mirrors this exact spelling so /api/sync/changes decodes the rows.
|
||||
OURA
|
||||
|
||||
@@map("measurement_source")
|
||||
}
|
||||
@@ -1369,6 +1407,14 @@ model Medication {
|
||||
/// against. A changed threshold re-arms the medication even while
|
||||
/// the runway stays low, so the user hears about the new crossing.
|
||||
lowStockNotifiedThresholdDays Int? @map("low_stock_notified_threshold_days")
|
||||
/// v1.17.0 — optional per-medication reorder lead time in days (how
|
||||
/// long between placing a refill order and the new supply arriving).
|
||||
/// The low-stock alert adds this lead plus one dose-interval on top of
|
||||
/// the user threshold so the warning lands BEFORE the last dose for any
|
||||
/// cadence. NULL = inherit the per-user
|
||||
/// `notificationPrefs.medication.reorderLeadDays` default. Additive; no
|
||||
/// backfill.
|
||||
reorderLeadDays Int? @map("reorder_lead_days")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
|
||||
+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.16.17";
|
||||
/* @sw-version-fallback */ "v1.17.0";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
const MAX_STATIC_ENTRIES = 150;
|
||||
|
||||
@@ -54,6 +54,38 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Polar OAuth",
|
||||
"description": "Required to enable the Polar AccessLink integration. App boots without these but the Polar connect button stays disabled. Register an app at https://admin.polaraccesslink.com and set the redirect URI to ${NEXT_PUBLIC_APP_URL}/api/polar/callback.",
|
||||
"required": false,
|
||||
"allOrNone": true,
|
||||
"variables": [
|
||||
{
|
||||
"name": "POLAR_CLIENT_ID",
|
||||
"purpose": "Polar AccessLink OAuth client id."
|
||||
},
|
||||
{
|
||||
"name": "POLAR_CLIENT_SECRET",
|
||||
"purpose": "Polar AccessLink OAuth client secret."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Oura OAuth",
|
||||
"description": "Required to enable the Oura Cloud v2 integration. App boots without these but the Oura connect button stays disabled. Register an app at https://cloud.ouraring.com/oauth/applications and set the redirect URI to ${NEXT_PUBLIC_APP_URL}/api/oura/callback.",
|
||||
"required": false,
|
||||
"allOrNone": true,
|
||||
"variables": [
|
||||
{
|
||||
"name": "OURA_CLIENT_ID",
|
||||
"purpose": "Oura Cloud OAuth client id."
|
||||
},
|
||||
{
|
||||
"name": "OURA_CLIENT_SECRET",
|
||||
"purpose": "Oura Cloud OAuth client secret."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "APNs (iOS push)",
|
||||
"description": "All four core APNS_* vars together. Missing any disables APNs silently — the v1.4.40 AP-2 gap that motivated this manifest. allOrNone trips the synthetic <all-or-none> required row whenever 1-3 of the 4 are set.",
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* v1.17.0 — `/invite/<hlv_token>` is the invite universal-link landing
|
||||
* (iOS #16). It is a thin shape-validated redirect onto
|
||||
* `/auth/register?invite=…` and carries no session, so an unauthenticated
|
||||
* visitor (scanning the admin QR) must reach it without the auth-gate
|
||||
* bounce to `/auth/login`. This guard locks the allowlist entry in place
|
||||
* so a future refactor of `src/proxy.ts` cannot silently re-gate it.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/process-type", () => ({
|
||||
shouldRunWeb: () => true,
|
||||
}));
|
||||
|
||||
import { proxy } from "../proxy";
|
||||
|
||||
const VALID_TOKEN = `hlv_${"a".repeat(64)}`;
|
||||
|
||||
function makeRequest(
|
||||
pathname: string,
|
||||
cookies: Record<string, string> = {},
|
||||
): NextRequest {
|
||||
const cookieHeader = Object.entries(cookies)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("; ");
|
||||
return new NextRequest(`http://localhost${pathname}`, {
|
||||
headers: cookieHeader ? { cookie: cookieHeader } : {},
|
||||
});
|
||||
}
|
||||
|
||||
describe("proxy.ts /invite public-allowlist (v1.17.0)", () => {
|
||||
it("does not 307 an unauthenticated /invite/<token> to /auth/login", () => {
|
||||
const res = proxy(makeRequest(`/invite/${VALID_TOKEN}`));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("location")).toBeNull();
|
||||
});
|
||||
|
||||
it("admits a pending-onboarding session onto the invite landing", () => {
|
||||
const res = proxy(
|
||||
makeRequest(`/invite/${VALID_TOKEN}`, {
|
||||
healthlog_session: "sess-1",
|
||||
hl_onboarding: "pending",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("location")).toBeNull();
|
||||
});
|
||||
|
||||
it("hardens the token-in-path surface against cache / index / referrer leaks", () => {
|
||||
// Mirror the `/c/<token>` edge defence — the secret rides in the path,
|
||||
// so no CDN may cache it and no crawler may index the URL.
|
||||
const res = proxy(makeRequest(`/invite/${VALID_TOKEN}`));
|
||||
expect(res.headers.get("Cache-Control")).toBe(
|
||||
"no-store, no-cache, must-revalidate",
|
||||
);
|
||||
expect(res.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
|
||||
expect(res.headers.get("Referrer-Policy")).toBe("no-referrer");
|
||||
});
|
||||
|
||||
it("still redirects unauthenticated requests for protected pages", () => {
|
||||
// Negative check — confirms the fixture would catch a real
|
||||
// regression. `/insights` is not public.
|
||||
const res = proxy(makeRequest("/insights"));
|
||||
expect([307, 308]).toContain(res.status);
|
||||
expect(res.headers.get("location")).toMatch(/\/auth\/login/);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,11 @@ import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fas
|
||||
import { buildHealthScoreBpInputs } from "@/lib/analytics/health-score-inputs";
|
||||
import { deriveBpWindow90 } from "@/lib/analytics/window-confidence";
|
||||
import { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path";
|
||||
import {
|
||||
computeGlucoseClinicalMetrics,
|
||||
GLUCOSE_PANEL_WINDOW_DAYS,
|
||||
type GlucoseClinicalMetrics,
|
||||
} from "@/lib/analytics/glucose-metrics";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -342,7 +347,9 @@ async function buildAnalyticsResponse(user: AuthedUser) {
|
||||
// v1.4.29 H2 — bound to the trailing 30 days. The dashboard tile
|
||||
// path only reads the trailing window; pre-bound this query
|
||||
// walked every persisted BG row a multi-year user has written.
|
||||
const glucoseSince = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
const glucoseSince = new Date(
|
||||
Date.now() - GLUCOSE_PANEL_WINDOW_DAYS * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
const glucoseRows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
@@ -365,6 +372,19 @@ async function buildAnalyticsResponse(user: AuthedUser) {
|
||||
}
|
||||
}
|
||||
|
||||
// v1.17.0 — server-authoritative glucose clinical panel. The TIR / GMI /
|
||||
// eA1C / CV% headline and the advanced J-index + LBGI/HBGI tier are computed
|
||||
// ONCE here over the declared 30-day window (matching `glucoseSince`) so the
|
||||
// insights panel, the coach snapshot, and the doctor report all render the
|
||||
// same numbers instead of each re-deriving. The learning gate keeps a thin
|
||||
// spot-data window from being asserted as a clinical AGP. Always present in
|
||||
// the payload (even with zero readings) so the client renders the calm
|
||||
// "still learning" state from a populated object rather than a missing key.
|
||||
const glucoseClinical: GlucoseClinicalMetrics = computeGlucoseClinicalMetrics(
|
||||
glucoseRows.map((r) => ({ measuredAt: r.measuredAt, mgdl: r.value })),
|
||||
{ windowDays: GLUCOSE_PANEL_WINDOW_DAYS, now: new Date() },
|
||||
);
|
||||
|
||||
// v1.4.20 phase B3 — three pre-defined correlation hypotheses.
|
||||
// v1.4.37 W2 — probe-gated helper. The 28-day scan window (down
|
||||
// from 30 to keep the cold critical path tight while still
|
||||
@@ -420,6 +440,7 @@ async function buildAnalyticsResponse(user: AuthedUser) {
|
||||
bpInTargetCount90,
|
||||
bpInTargetSpanDays90,
|
||||
glucoseByContext,
|
||||
glucoseClinical,
|
||||
correlations,
|
||||
healthScore,
|
||||
sleepStages,
|
||||
|
||||
@@ -92,6 +92,7 @@ describe("GET /api/auth/me/notification-prefs", () => {
|
||||
clientManaged: false,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -123,6 +124,7 @@ describe("GET /api/auth/me/notification-prefs", () => {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -156,6 +158,7 @@ describe("GET /api/auth/me/notification-prefs", () => {
|
||||
clientManaged: false,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -200,6 +203,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -220,6 +224,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -244,6 +249,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
clientManaged: false,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -260,6 +266,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -329,6 +336,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -363,6 +371,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -383,6 +392,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
clientManaged: true,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: 22 },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -422,6 +432,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
clientManaged: false,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 7,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
mood: { reminderHour: 9 },
|
||||
cycle: { clientManaged: false },
|
||||
@@ -471,6 +482,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
clientManaged: false,
|
||||
deliveryDefault: "server",
|
||||
lowStockRunwayDays: 14,
|
||||
reorderLeadDays: 10,
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -27,6 +27,11 @@ vi.mock("@/lib/db", () => ({
|
||||
medicationScheduleRevision: {
|
||||
groupBy: vi.fn(),
|
||||
},
|
||||
// v1.17.0 — the sleep-rhythm block resolves the user's age-based sleep
|
||||
// need via the baseline profile (`User` row).
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
// v1.4.38 W-F — the route now uses `$queryRaw` for the per-type
|
||||
// latest reading, the 7-day rollup-bucket sparkline, and the
|
||||
// 365-day distinct-day streak set. Three raw calls per request,
|
||||
@@ -120,6 +125,13 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.medicationScheduleRevision.groupBy).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
// v1.17.0 — baseline profile read for the sleep-rhythm need. Default to a
|
||||
// 40-year-old adult (need = 420 min) so the rhythm block resolves.
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dateOfBirth: new Date("1986-01-01"),
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
} as never);
|
||||
});
|
||||
|
||||
// v1.4.48 L13 — restore real timers no matter how a test exits. A failed
|
||||
@@ -515,6 +527,59 @@ describe("GET /api/dashboard/summary", () => {
|
||||
expect(sleep?.sleepStages?.REM).toBeCloseTo(80 / 60, 2);
|
||||
});
|
||||
|
||||
it("emits the sleep-rhythm DTO (sleep-debt + chronotype) computed from the foundation modules (v1.17.0)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// Ten consecutive 6 h (360 min) nights. Need = 420 (40-year-old default in
|
||||
// beforeEach) → a 60-min deficit per night, 10 nights ≥ the 7-night floor.
|
||||
const rows = [];
|
||||
for (let d = 1; d <= 10; d++) {
|
||||
const day = String(d).padStart(2, "0");
|
||||
rows.push({
|
||||
value: 360,
|
||||
measuredAt: new Date(`2026-06-${day}T06:00:00.000Z`),
|
||||
sleepStage: "ASLEEP",
|
||||
source: "APPLE_HEALTH",
|
||||
deviceType: null,
|
||||
});
|
||||
}
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue(rows as never);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
sleepRhythm: {
|
||||
sleepDebt: {
|
||||
state: string;
|
||||
debtMinutes: number;
|
||||
needMinutes: number;
|
||||
nightsCounted: number;
|
||||
windowNights: number;
|
||||
nightsUntilReady: number;
|
||||
};
|
||||
chronotype: {
|
||||
state: string;
|
||||
freeNightsCounted: number;
|
||||
workNightsCounted: number;
|
||||
freeNightsUntilReady: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
const { sleepDebt, chronotype } = body.data.sleepRhythm;
|
||||
// Debt DTO is the computeSleepDebt result: ready, 10 × 60 = 600 min, need
|
||||
// forwarded as 420 — proves the route reused the module, not a recompute.
|
||||
expect(sleepDebt.state).toBe("ready");
|
||||
expect(sleepDebt.debtMinutes).toBe(600);
|
||||
expect(sleepDebt.needMinutes).toBe(420);
|
||||
expect(sleepDebt.nightsCounted).toBe(10);
|
||||
expect(sleepDebt.nightsUntilReady).toBe(0);
|
||||
// Chronotype DTO carries every wiring field; the calm learning state holds
|
||||
// until enough free-day nights exist (these are all weekday wake days).
|
||||
expect(chronotype.state).toBe("learning");
|
||||
expect(typeof chronotype.freeNightsCounted).toBe("number");
|
||||
expect(typeof chronotype.freeNightsUntilReady).toBe("number");
|
||||
});
|
||||
|
||||
it("paints the steps sparkline from rollup sum_value, not mean (v1.4.39 W-SUM)", async () => {
|
||||
// The ACTIVITY_STEPS tile renders the per-day SUM, not the
|
||||
// per-bucket MEAN. The sparkline query feeds the SQL aggregate
|
||||
|
||||
@@ -64,6 +64,15 @@ import {
|
||||
} from "@/lib/analytics/sleep-night";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import type { SleepStage } from "@/generated/prisma/client";
|
||||
import { loadBaselineProfile } from "@/lib/insights/derived/baseline";
|
||||
import {
|
||||
reconstructNights,
|
||||
sleepNeedMinutes,
|
||||
} from "@/lib/insights/derived/sleep-score";
|
||||
import {
|
||||
computeSleepRhythmFromNights,
|
||||
type SleepRhythmDto,
|
||||
} from "@/lib/insights/derived/sleep-rhythm";
|
||||
|
||||
const SPARK_DAYS = 7;
|
||||
const STREAK_WINDOW_DAYS = 365;
|
||||
@@ -534,8 +543,13 @@ async function buildDashboardSummary(
|
||||
|
||||
// v1.11.4 — the user's sleep source-priority ladder, used to collapse a
|
||||
// dual-source night (e.g. WHOOP + Apple Health) to one canonical source
|
||||
// before the per-night reconstruction sums it.
|
||||
const sleepPriorityJson = await loadUserSourcePriority(userId);
|
||||
// before the per-night reconstruction sums it. v1.17.0 — the baseline
|
||||
// profile rides along (one read) so the sleep-rhythm block resolves the
|
||||
// age-based sleep need the cumulative-debt deficit uses.
|
||||
const [sleepPriorityJson, sleepProfile] = await Promise.all([
|
||||
loadUserSourcePriority(userId),
|
||||
loadBaselineProfile(prisma, userId),
|
||||
]);
|
||||
|
||||
// Per-type metadata lookup — typed Map so a metric with no readings
|
||||
// at all falls through `metaForType` to the `{ allTimeCount: 0,
|
||||
@@ -632,6 +646,23 @@ async function buildDashboardSummary(
|
||||
sleepPriorityJson,
|
||||
);
|
||||
|
||||
// v1.17.0 — server-authoritative sleep-rhythm DTO (sleep-debt + chronotype)
|
||||
// off the SAME canonical reconstruction the Sleep page + Sleep Score read,
|
||||
// so iOS renders identical values. Reuses the rows already loaded above —
|
||||
// `reconstructNights` adapts them into the asleep + wall-clock-midpoint
|
||||
// shape the foundation modules consume, then `computeSleepRhythmFromNights`
|
||||
// forwards them to `computeSleepDebt` / `computeChronotype` (the one place
|
||||
// the math lives). Both signals carry a calm partial / learning state below
|
||||
// their night thresholds — never an assertion off thin data.
|
||||
const sleepRhythm: SleepRhythmDto = computeSleepRhythmFromNights(
|
||||
reconstructNights(
|
||||
sleepStageRows as SleepStageRow[],
|
||||
userTz,
|
||||
sleepPriorityJson,
|
||||
),
|
||||
sleepNeedMinutes(sleepProfile.ageYears),
|
||||
);
|
||||
|
||||
const metrics: MetricCard[] = [];
|
||||
|
||||
// v1.4.33 maintainer-item-1 — every emitted card now carries
|
||||
@@ -862,6 +893,9 @@ async function buildDashboardSummary(
|
||||
},
|
||||
highlightInsight: null,
|
||||
metrics,
|
||||
// v1.17.0 — sleep-debt + chronotype, server-authoritative. Same values
|
||||
// the Sleep page's `/api/sleep/rhythm` read returns.
|
||||
sleepRhythm,
|
||||
lastUpdated: now.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,10 @@ import { NextRequest } from "next/server";
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: { findUnique: vi.fn() },
|
||||
// v1.17.0 — the GET reads the caller's notificationPrefs to resolve
|
||||
// the reorder-lead-aware low-stock trigger. Default null = the
|
||||
// documented defaults (alert on, 7-day floor, 10-day lead).
|
||||
user: { findUnique: vi.fn().mockResolvedValue({ notificationPrefs: null }) },
|
||||
medicationDoseChange: { create: vi.fn() },
|
||||
medicationInventoryEvent: { create: vi.fn() },
|
||||
},
|
||||
@@ -443,7 +447,19 @@ describe("GET /api/medications/[id]/glp1 — v1.16.10 item-backed inventory", ()
|
||||
userId: "user-1",
|
||||
doseChanges: [],
|
||||
intakeEvents: [],
|
||||
schedules: [],
|
||||
// Weekly cadence — the canonical GLP-1 case. 3 doses ≈ 21 days of
|
||||
// runway, comfortably above the reorder-lead-aware trigger
|
||||
// (max(7, 10 + 7) = 17), so the flag is FALSE.
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
daysOfWeek: "6",
|
||||
timesOfDay: ["08:00"],
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
},
|
||||
],
|
||||
reorderLeadDays: null,
|
||||
dosesPerUnit: 4,
|
||||
unitsPerDose: 2,
|
||||
inventoryItems: [
|
||||
@@ -476,14 +492,90 @@ describe("GET /api/medications/[id]/glp1 — v1.16.10 item-backed inventory", ()
|
||||
};
|
||||
};
|
||||
// 2 usable containers; 7 pooled units / 2 units per dose = 3 doses.
|
||||
// v1.17.0 — lowStock now rides the runway model: 3 weekly doses ≈ 21
|
||||
// days > trigger 17 ⇒ false.
|
||||
expect(body.data.inventory).toEqual({
|
||||
pensRemaining: 2,
|
||||
dosesRemaining: 3,
|
||||
weeksOfSupply: 3,
|
||||
lowStock: true,
|
||||
lowStock: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("v1.17.0 — lowStock flag matches the reorder-lead-aware runway decision (fires before the last weekly dose)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "med-1",
|
||||
userId: "user-1",
|
||||
doseChanges: [],
|
||||
intakeEvents: [],
|
||||
// Weekly cadence, ONE dose left ≈ 7 days of runway. With the default
|
||||
// 10-day reorder lead the trigger is max(7, 10 + 7) = 17, so runway
|
||||
// 7 ≤ 17 ⇒ the card flag lights with reorder headroom — exactly the
|
||||
// cron's decision (no longer "at the last dose").
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
daysOfWeek: "6",
|
||||
timesOfDay: ["08:00"],
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
},
|
||||
],
|
||||
reorderLeadDays: null,
|
||||
dosesPerUnit: 1,
|
||||
unitsPerDose: 1,
|
||||
inventoryItems: [{ state: "ACTIVE", unitsTotal: 4, unitsRemaining: 1 }],
|
||||
inventoryEvents: [],
|
||||
} as never);
|
||||
|
||||
const res = await GET(getReq(), {
|
||||
params: Promise.resolve({ id: "med-1" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { inventory: { dosesRemaining: number; lowStock: boolean } };
|
||||
};
|
||||
expect(body.data.inventory.dosesRemaining).toBe(1);
|
||||
expect(body.data.inventory.lowStock).toBe(true);
|
||||
});
|
||||
|
||||
it("v1.17.0 — lowStock is false when the alert is switched off (null threshold)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
notificationPrefs: { medication: { lowStockRunwayDays: null } },
|
||||
} as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "med-1",
|
||||
userId: "user-1",
|
||||
doseChanges: [],
|
||||
intakeEvents: [],
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
daysOfWeek: "6",
|
||||
timesOfDay: ["08:00"],
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
},
|
||||
],
|
||||
reorderLeadDays: null,
|
||||
dosesPerUnit: 1,
|
||||
unitsPerDose: 1,
|
||||
inventoryItems: [{ state: "ACTIVE", unitsTotal: 4, unitsRemaining: 1 }],
|
||||
inventoryEvents: [],
|
||||
} as never);
|
||||
|
||||
const res = await GET(getReq(), {
|
||||
params: Promise.resolve({ id: "med-1" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { inventory: { lowStock: boolean } };
|
||||
};
|
||||
expect(body.data.inventory.lowStock).toBe(false);
|
||||
});
|
||||
|
||||
it("returns a null inventory block when no items and no ledger rows exist", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
|
||||
@@ -28,11 +28,20 @@ import {
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { glp1PostBodySchema } from "@/lib/validations/medication";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import {
|
||||
estimateDailyDoseCount,
|
||||
estimateRunwayDays,
|
||||
lowStockTriggerDays,
|
||||
type RunwaySchedule,
|
||||
} from "@/components/medications/detail/supply-runway";
|
||||
import {
|
||||
resolveLowStockRunwayDays,
|
||||
resolveReorderLeadDays,
|
||||
} from "@/lib/validations/notification-prefs";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
const LOW_STOCK_DOSE_THRESHOLD = 4;
|
||||
const POST_RATE_LIMIT = 30;
|
||||
const POST_WINDOW_MS = 60_000;
|
||||
|
||||
@@ -76,6 +85,47 @@ export const GET = apiHandler(
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
// v1.17.0 — the card `lowStock` flag rides the SAME reorder-lead-aware
|
||||
// runway evaluation as the daily low-stock cron, so the GLP-1 card and
|
||||
// the notification can never disagree. The legacy fixed 4-dose count
|
||||
// threshold (`LOW_STOCK_DOSE_THRESHOLD`) is retired: a weekly cadence's
|
||||
// "4 doses" was ~28 days — far too eager — while a daily med at 4 doses
|
||||
// was ~4 days — far too late. Runway days ≤ the effective trigger
|
||||
// (`max(lowStockRunwayDays, leadDays + cadenceIntervalDays)`) is the one
|
||||
// truth. `lowStockRunwayDays === null` (alert off) ⇒ flag never lights.
|
||||
const prefsRow = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { notificationPrefs: true },
|
||||
});
|
||||
const runwayFloor = resolveLowStockRunwayDays(
|
||||
prefsRow?.notificationPrefs ?? null,
|
||||
);
|
||||
const leadDays = resolveReorderLeadDays(
|
||||
prefsRow?.notificationPrefs ?? null,
|
||||
medication.reorderLeadDays,
|
||||
);
|
||||
const schedules: RunwaySchedule[] = medication.schedules;
|
||||
/** runway ≤ effective trigger AND the alert is enabled. */
|
||||
const isLowStock = (dosesRemaining: number): boolean => {
|
||||
if (runwayFloor === null) return false;
|
||||
// No consuming schedule ⇒ no runway ⇒ never low (matches the cron's
|
||||
// `evaluateMedicationRunway` null branch).
|
||||
if (estimateDailyDoseCount(schedules) <= 0) return false;
|
||||
const triggerDays = lowStockTriggerDays({
|
||||
lowStockRunwayDays: runwayFloor,
|
||||
leadDays,
|
||||
schedules,
|
||||
});
|
||||
// Exhausted supply with a consuming schedule is runway 0 (below
|
||||
// every trigger); otherwise the cadence-aware days of supply. Same
|
||||
// semantics the cron's `evaluateMedicationRunway` applies.
|
||||
const runwayDays =
|
||||
dosesRemaining > 0
|
||||
? (estimateRunwayDays(dosesRemaining, schedules) ?? 0)
|
||||
: 0;
|
||||
return runwayDays <= triggerDays;
|
||||
};
|
||||
|
||||
// Inventory math over the per-item entities. The response shape is
|
||||
// locked for the iOS client: `pensRemaining` = count of usable
|
||||
// containers (ACTIVE / IN_USE with units left), `dosesRemaining` =
|
||||
@@ -103,7 +153,7 @@ export const GET = apiHandler(
|
||||
unitsRemaining / (Number(medication.unitsPerDose) || 1),
|
||||
);
|
||||
const weeksOfSupply = dosesRemaining;
|
||||
const lowStock = dosesRemaining < LOW_STOCK_DOSE_THRESHOLD;
|
||||
const lowStock = isLowStock(dosesRemaining);
|
||||
inventory = {
|
||||
pensRemaining,
|
||||
dosesRemaining,
|
||||
@@ -127,7 +177,7 @@ export const GET = apiHandler(
|
||||
const pensRemaining = Math.max(0, pens);
|
||||
const dosesRemaining = pensRemaining * medication.dosesPerUnit;
|
||||
const weeksOfSupply = dosesRemaining;
|
||||
const lowStock = dosesRemaining < LOW_STOCK_DOSE_THRESHOLD;
|
||||
const lowStock = isLowStock(dosesRemaining);
|
||||
inventory = {
|
||||
pensRemaining,
|
||||
dosesRemaining,
|
||||
|
||||
@@ -232,6 +232,7 @@ export const PUT = apiHandler(
|
||||
treatmentClass,
|
||||
dosesPerUnit,
|
||||
unitsPerDose,
|
||||
reorderLeadDays,
|
||||
deliveryForm,
|
||||
trackInjectionSites,
|
||||
allowedInjectionSites,
|
||||
@@ -598,6 +599,9 @@ export const PUT = apiHandler(
|
||||
// v1.16.10 — units consumed per dose. Field-by-field; the stamp on
|
||||
// already-taken intake events freezes their recorded consumption.
|
||||
...(unitsPerDose !== undefined && { unitsPerDose }),
|
||||
// v1.17.0 — reorder lead override. Field-by-field; `null` is a
|
||||
// valid explicit value (clears the override → user-level default).
|
||||
...(reorderLeadDays !== undefined && { reorderLeadDays }),
|
||||
...(deliveryForm !== undefined && { deliveryForm }),
|
||||
// v1.8.5 — injection-site tracking opt-in + per-medication allowed
|
||||
// sites. Field-by-field; `false` / `[]` are valid explicit values
|
||||
|
||||
@@ -300,6 +300,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
treatmentClass,
|
||||
dosesPerUnit,
|
||||
unitsPerDose,
|
||||
reorderLeadDays,
|
||||
deliveryForm,
|
||||
trackInjectionSites,
|
||||
allowedInjectionSites,
|
||||
@@ -379,6 +380,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
...(dosesPerUnit !== undefined && { dosesPerUnit }),
|
||||
// v1.16.10 — units consumed per dose; Prisma defaults to 1 when omitted.
|
||||
...(unitsPerDose !== undefined && { unitsPerDose }),
|
||||
// v1.17.0 — optional reorder lead override; null/omitted = inherit
|
||||
// the user-level default in the low-stock alert.
|
||||
...(reorderLeadDays !== undefined && { reorderLeadDays }),
|
||||
// v1.6.0 — route of administration; Prisma defaults to ORAL when omitted.
|
||||
...(deliveryForm !== undefined && { deliveryForm }),
|
||||
// v1.8.5 — injection-site tracking opt-in + per-medication allowed
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { SafeFetchError } from "@/lib/safe-fetch";
|
||||
import { NightscoutApiError } from "@/lib/nightscout/client";
|
||||
|
||||
const { fetchSgvEntriesMock, rateLimitMock } = vi.hoisted(() => ({
|
||||
fetchSgvEntriesMock: vi.fn(),
|
||||
rateLimitMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { update: vi.fn() } },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc:${s}` }));
|
||||
vi.mock("@/lib/integrations/status", () => ({ markReconnected: vi.fn() }));
|
||||
vi.mock("@/lib/nightscout/client", async (importOriginal) => {
|
||||
const actual = await importOriginal<
|
||||
typeof import("@/lib/nightscout/client")
|
||||
>();
|
||||
return { ...actual, fetchSgvEntries: fetchSgvEntriesMock };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: rateLimitMock,
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
|
||||
apiError: (error: string, status: number) => ({ data: null, error, status }),
|
||||
safeJson: async (req: NextRequest) => {
|
||||
try {
|
||||
return { data: await req.json(), error: null };
|
||||
} catch {
|
||||
return { data: null, error: { status: 400 } };
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { markReconnected } from "@/lib/integrations/status";
|
||||
|
||||
const userUpdate = prisma.user.update as ReturnType<typeof vi.fn>;
|
||||
|
||||
function req(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/nightscout/connect", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
type RouteResult = { data: unknown; error: string | null; status: number };
|
||||
const post = POST as unknown as (r: NextRequest) => Promise<RouteResult>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
rateLimitMock.mockResolvedValue({ allowed: true, remaining: 9, resetAt: 0 });
|
||||
fetchSgvEntriesMock.mockResolvedValue([{ id: "x", sgv: 100, date: 1 }]);
|
||||
});
|
||||
|
||||
describe("POST /api/nightscout/connect", () => {
|
||||
it("422s on a missing / malformed URL", async () => {
|
||||
const res = await post(req({ url: "not a url" }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(userUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("validates via a test fetch and stores encrypted creds on success", async () => {
|
||||
const res = await post(
|
||||
req({ url: "https://ns.example.com", token: "tok" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(fetchSgvEntriesMock).toHaveBeenCalledTimes(1);
|
||||
const fetchArg = fetchSgvEntriesMock.mock.calls[0]![0];
|
||||
expect(fetchArg.count).toBe(1);
|
||||
expect(fetchArg.allowPrivateHost).toBe(false);
|
||||
|
||||
const data = userUpdate.mock.calls[0]![0].data;
|
||||
expect(data.nightscoutUrlEncrypted).toBe("enc:https://ns.example.com");
|
||||
expect(data.nightscoutTokenEncrypted).toBe("enc:tok");
|
||||
expect(data.nightscoutAllowPrivateHost).toBe(false);
|
||||
expect(markReconnected).toHaveBeenCalledWith("u1", "nightscout");
|
||||
});
|
||||
|
||||
it("stores a null token for a public, token-less instance", async () => {
|
||||
await post(req({ url: "https://ns.example.com" }));
|
||||
const data = userUpdate.mock.calls[0]![0].data;
|
||||
expect(data.nightscoutTokenEncrypted).toBeNull();
|
||||
});
|
||||
|
||||
it("passes the private-host opt-in through to the test fetch", async () => {
|
||||
await post(
|
||||
req({
|
||||
url: "http://192.168.1.5:1337",
|
||||
token: "tok",
|
||||
allowPrivateHost: true,
|
||||
}),
|
||||
);
|
||||
const fetchArg = fetchSgvEntriesMock.mock.calls[0]![0];
|
||||
expect(fetchArg.allowPrivateHost).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a private host when the opt-in is off (clear 422, no store)", async () => {
|
||||
fetchSgvEntriesMock.mockRejectedValue(
|
||||
new SafeFetchError("refused", "private_host"),
|
||||
);
|
||||
const res = await post(req({ url: "http://10.0.0.5", token: "tok" }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(String(res.error)).toMatch(/private network/i);
|
||||
expect(userUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces a wrong-token rejection as a clear 422", async () => {
|
||||
fetchSgvEntriesMock.mockRejectedValue(
|
||||
new NightscoutApiError("responded 401", 401),
|
||||
);
|
||||
const res = await post(req({ url: "https://ns.example.com", token: "x" }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(String(res.error)).toMatch(/token/i);
|
||||
expect(userUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces an unreachable instance as a clear 422", async () => {
|
||||
fetchSgvEntriesMock.mockRejectedValue(
|
||||
new SafeFetchError("timeout", "timeout"),
|
||||
);
|
||||
const res = await post(req({ url: "https://ns.example.com" }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(String(res.error)).toMatch(/reach/i);
|
||||
expect(userUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rate-limits the connect surface", async () => {
|
||||
rateLimitMock.mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: 0,
|
||||
});
|
||||
const res = await post(req({ url: "https://ns.example.com" }));
|
||||
expect(res.status).toBe(429);
|
||||
expect(fetchSgvEntriesMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { markReconnected } from "@/lib/integrations/status";
|
||||
import { SafeFetchError } from "@/lib/safe-fetch";
|
||||
import {
|
||||
fetchSgvEntries,
|
||||
NightscoutApiError,
|
||||
} from "@/lib/nightscout/client";
|
||||
import { nightscoutConnectSchema } from "@/lib/validations/nightscout";
|
||||
|
||||
/**
|
||||
* Connect (or update) the user's Nightscout instance (v1.17.0).
|
||||
*
|
||||
* Validates the URL + token by a live TEST FETCH of one SGV entry before
|
||||
* storing anything — a wrong token (401/403), an unreachable instance, or a
|
||||
* private host the user didn't opt into all surface as a clear error here
|
||||
* rather than silently parking the integration on its first cron tick.
|
||||
*
|
||||
* On success the URL + token are encrypted at rest on `User` and the
|
||||
* `nightscout` integration ledger is reset to connected. Per-user rate-limited
|
||||
* (a test fetch is an outbound call; throttle the connect surface so a tight
|
||||
* retry loop can't be used to probe arbitrary hosts).
|
||||
*/
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "nightscout.connect" } });
|
||||
|
||||
const rl = await checkRateLimit(`nightscout-connect:${user.id}`, 10, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many connection attempts", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const result = z.safeParse(nightscoutConnectSchema, body);
|
||||
if (!result.success) {
|
||||
return apiError("A valid Nightscout URL is required", 422);
|
||||
}
|
||||
|
||||
const { url, token, allowPrivateHost } = result.data;
|
||||
|
||||
// Live validation: pull a single SGV entry. A connection that can't even
|
||||
// fetch one row is not worth storing.
|
||||
try {
|
||||
await fetchSgvEntries({
|
||||
baseUrl: url,
|
||||
token,
|
||||
count: 1,
|
||||
allowPrivateHost,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof SafeFetchError && err.kind === "private_host") {
|
||||
return apiError(
|
||||
"That instance is on a private network. Enable the private-network option to connect to it.",
|
||||
422,
|
||||
);
|
||||
}
|
||||
if (err instanceof NightscoutApiError) {
|
||||
if (err.status === 401 || err.status === 403) {
|
||||
return apiError(
|
||||
"Nightscout rejected the token. Check the API token and try again.",
|
||||
422,
|
||||
);
|
||||
}
|
||||
return apiError(
|
||||
"Could not reach the Nightscout instance. Check the URL and that it is online.",
|
||||
422,
|
||||
);
|
||||
}
|
||||
return apiError(
|
||||
"Could not reach the Nightscout instance. Check the URL and that it is online.",
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
// Build the data object field-by-field — never spread the parsed body.
|
||||
nightscoutUrlEncrypted: encrypt(url),
|
||||
nightscoutTokenEncrypted: token ? encrypt(token) : null,
|
||||
nightscoutAllowPrivateHost: allowPrivateHost,
|
||||
},
|
||||
});
|
||||
|
||||
await markReconnected(user.id, "nightscout");
|
||||
await auditLog("nightscout.connect", { userId: user.id });
|
||||
|
||||
return apiSuccess({ connected: true });
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { markDisconnected } from "@/lib/integrations/status";
|
||||
|
||||
/**
|
||||
* Disconnect the user's Nightscout instance (v1.17.0).
|
||||
*
|
||||
* Clears the encrypted URL + token and the private-host flag on `User`, and
|
||||
* parks the `nightscout` integration ledger at `disconnected`. Already-ingested
|
||||
* glucose rows are left in place — disconnecting stops future syncs, it does
|
||||
* not delete the history the panel reads.
|
||||
*/
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "nightscout.disconnect" } });
|
||||
|
||||
const rl = await checkRateLimit(`nightscout-disconnect:${user.id}`, 20, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many requests", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { nightscoutUrlEncrypted: true },
|
||||
});
|
||||
|
||||
if (!dbUser?.nightscoutUrlEncrypted) {
|
||||
return apiError("No Nightscout connection", 404);
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
nightscoutUrlEncrypted: null,
|
||||
nightscoutTokenEncrypted: null,
|
||||
nightscoutAllowPrivateHost: false,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("nightscout.disconnect", { userId: user.id });
|
||||
await markDisconnected(user.id, "nightscout");
|
||||
|
||||
return apiSuccess({ disconnected: true });
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { getIntegrationStatus } from "@/lib/integrations/status";
|
||||
|
||||
/**
|
||||
* Nightscout connection status for the current user (v1.17.0).
|
||||
*
|
||||
* `configured` is whether an instance URL is stored (the connect marker — a
|
||||
* fully-public instance has no token). The ledger snapshot (state /
|
||||
* lastSuccessAt / lastError) comes off the shared `nightscout` IntegrationKey
|
||||
* so the Settings card surfaces the same connected / error / parked pill the
|
||||
* other integrations use. The stored URL is echoed back (host only is enough
|
||||
* for the card, but the URL is the user's own and not a secret); the token is
|
||||
* NEVER returned.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "nightscout.status" } });
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
nightscoutUrlEncrypted: true,
|
||||
nightscoutTokenEncrypted: true,
|
||||
nightscoutAllowPrivateHost: true,
|
||||
},
|
||||
});
|
||||
|
||||
const configured = !!dbUser?.nightscoutUrlEncrypted;
|
||||
|
||||
if (!configured) {
|
||||
return apiSuccess({ connected: false, configured: false });
|
||||
}
|
||||
|
||||
const status = await getIntegrationStatus(user.id, "nightscout");
|
||||
|
||||
return apiSuccess({
|
||||
connected: true,
|
||||
configured: true,
|
||||
hasToken: !!dbUser?.nightscoutTokenEncrypted,
|
||||
allowPrivateHost: dbUser?.nightscoutAllowPrivateHost ?? false,
|
||||
state: status.state,
|
||||
lastSuccessAt: status.lastSuccessAt,
|
||||
lastAttemptAt: status.lastAttemptAt,
|
||||
lastError: status.lastError,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getUserNightscoutCredentials } from "@/lib/nightscout/credentials";
|
||||
import { fetchSgvEntries, NightscoutApiError } from "@/lib/nightscout/client";
|
||||
import { SafeFetchError } from "@/lib/safe-fetch";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Cheap live probe — re-validate the stored URL + token by pulling a single
|
||||
// SGV entry off the user's instance via the same client the sync uses. No new
|
||||
// credentials path: it reuses `getUserNightscoutCredentials` + `fetchSgvEntries`.
|
||||
const TIMEOUT_MS = 5_000;
|
||||
|
||||
function categoriseStatus(status: number | null): {
|
||||
code: string;
|
||||
message: string;
|
||||
} {
|
||||
if (status === 401 || status === 403) {
|
||||
return {
|
||||
code: "credentials_rejected",
|
||||
message: "Nightscout rejected the token",
|
||||
};
|
||||
}
|
||||
if (status === 429) {
|
||||
return {
|
||||
code: "rate_limited",
|
||||
message: "Nightscout rate-limited the request",
|
||||
};
|
||||
}
|
||||
if (status !== null && status >= 500) {
|
||||
return { code: "upstream_error", message: "Nightscout returned an error" };
|
||||
}
|
||||
return { code: "connection_failed", message: "Nightscout connection failed" };
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "nightscout.test" } });
|
||||
|
||||
const rl = await checkRateLimit(`nightscout-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many test requests", 429, {
|
||||
errorCode: "rate_limited_self",
|
||||
});
|
||||
}
|
||||
|
||||
const creds = await getUserNightscoutCredentials(user.id);
|
||||
if (!creds) {
|
||||
return apiError("Nightscout not connected", 422, {
|
||||
errorCode: "not_configured",
|
||||
});
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
await fetchSgvEntries({
|
||||
baseUrl: creds.baseUrl,
|
||||
token: creds.token,
|
||||
count: 1,
|
||||
allowPrivateHost: creds.allowPrivateHost,
|
||||
timeoutMs: TIMEOUT_MS,
|
||||
});
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
return apiSuccess({ ok: true, latencyMs });
|
||||
} catch (e) {
|
||||
if (e instanceof NightscoutApiError) {
|
||||
const cat = categoriseStatus(e.status);
|
||||
annotate({
|
||||
meta: { nightscout_test_status: e.status, nightscout_test_code: cat.code },
|
||||
});
|
||||
return apiError(cat.message, 502, { errorCode: cat.code });
|
||||
}
|
||||
const isTimeout = e instanceof SafeFetchError && e.kind === "timeout";
|
||||
const isPrivate = e instanceof SafeFetchError && e.kind === "private_host";
|
||||
const code = isTimeout
|
||||
? "timeout"
|
||||
: isPrivate
|
||||
? "url_not_public"
|
||||
: "connection_failed";
|
||||
annotate({ meta: { nightscout_test_code: code } });
|
||||
return apiError(
|
||||
isTimeout ? "Nightscout request timed out" : "Nightscout connection failed",
|
||||
502,
|
||||
{ errorCode: code },
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { exchangeCode, getOuraCredentials } from "@/lib/oura/client";
|
||||
import {
|
||||
oauthStateCookieName,
|
||||
stateMatchesCookie,
|
||||
verifySignedState,
|
||||
} from "@/lib/oauth/signed-state";
|
||||
import { markReconnected } from "@/lib/integrations/status";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* v1.17.0 (F4) — OAuth callback from Oura.
|
||||
*
|
||||
* Identity + CSRF mirror the Polar callback: the `state` param is a stateless
|
||||
* HMAC-signed token, enforced as a byte-exact double-submit against the
|
||||
* httpOnly cookie AND verified for signature / expiry / `oura` provider
|
||||
* binding. The `userId` is read from inside the verified token, cross-checked
|
||||
* against the web session when present.
|
||||
*
|
||||
* On success: exchange the code, persist the encrypted access + refresh token
|
||||
* on `User`, clear any prior reauth state, and redirect back to Settings.
|
||||
*/
|
||||
const ERR = (reason: string) =>
|
||||
NextResponse.redirect(
|
||||
new URL(
|
||||
`/settings/integrations?oura=error&reason=${reason}`,
|
||||
process.env.NEXT_PUBLIC_APP_URL!,
|
||||
),
|
||||
);
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "oura.callback" } });
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const code = searchParams.get("code");
|
||||
const state = searchParams.get("state");
|
||||
const cookieState = request.cookies.get(oauthStateCookieName("oura"))?.value;
|
||||
|
||||
if (!stateMatchesCookie(state, cookieState)) {
|
||||
annotate({ meta: { reason: "csrf1" } });
|
||||
return ERR("csrf1");
|
||||
}
|
||||
|
||||
const verified = verifySignedState("oura", state);
|
||||
if (!verified) {
|
||||
annotate({ meta: { reason: "state" } });
|
||||
return ERR("state");
|
||||
}
|
||||
const userId = verified.userId;
|
||||
|
||||
const evt = getEvent();
|
||||
if (evt) evt.setAuth({ user_id: userId, auth_method: "session" });
|
||||
|
||||
const sessionData = await getSession();
|
||||
if (sessionData && sessionData.user.id !== userId) {
|
||||
annotate({ meta: { reason: "cross_user" } });
|
||||
return ERR("cross_user");
|
||||
}
|
||||
|
||||
if (!code) return ERR("nocode");
|
||||
|
||||
try {
|
||||
const creds = getOuraCredentials();
|
||||
if (!creds) return ERR("nocreds");
|
||||
|
||||
const tokens = await exchangeCode(code, creds);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
ouraAccessTokenEncrypted: encrypt(tokens.access_token),
|
||||
ouraRefreshTokenEncrypted: encrypt(tokens.refresh_token),
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("oura.connect", { userId });
|
||||
await markReconnected(userId, "oura");
|
||||
|
||||
const response = NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings/integrations?oura=connected",
|
||||
process.env.NEXT_PUBLIC_APP_URL!,
|
||||
),
|
||||
);
|
||||
response.cookies.delete(oauthStateCookieName("oura"));
|
||||
return response;
|
||||
} catch (err) {
|
||||
getEvent()?.setError(err);
|
||||
return ERR("token");
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
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";
|
||||
import { getAuthorizationUrl, getOuraCredentials } from "@/lib/oura/client";
|
||||
import {
|
||||
OAUTH_STATE_TTL_MS,
|
||||
mintSignedState,
|
||||
oauthStateCookieName,
|
||||
} from "@/lib/oauth/signed-state";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* v1.17.0 (F4) — redirect the user to the Oura consent screen.
|
||||
*
|
||||
* Full OAuth from env (`OURA_CLIENT_ID` / `OURA_CLIENT_SECRET`). 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.
|
||||
*/
|
||||
const CONNECT_RATE_LIMIT = 10;
|
||||
const CONNECT_WINDOW_MS = 60_000;
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "oura.connect" } });
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`oura:connect:${user.id}`,
|
||||
CONNECT_RATE_LIMIT,
|
||||
CONNECT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
annotate({ action: { name: "oura.connect.rate_limited" } });
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings/integrations?oura=error&reason=rate_limited",
|
||||
process.env.NEXT_PUBLIC_APP_URL!,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const creds = getOuraCredentials();
|
||||
if (!creds) {
|
||||
return apiError("Oura integration is not configured on this server.", 400);
|
||||
}
|
||||
|
||||
const state = mintSignedState("oura", user.id);
|
||||
const url = getAuthorizationUrl(state, creds);
|
||||
|
||||
const response = NextResponse.redirect(url);
|
||||
response.cookies.set(oauthStateCookieName("oura"), state, {
|
||||
httpOnly: true,
|
||||
secure: shouldEmitSecureCookie(),
|
||||
sameSite: "lax",
|
||||
maxAge: Math.floor(OAUTH_STATE_TTL_MS / 1000),
|
||||
path: "/",
|
||||
});
|
||||
return response;
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const { rateLimitMock } = vi.hoisted(() => ({ rateLimitMock: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: vi.fn(), update: vi.fn() } },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: rateLimitMock,
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
|
||||
apiError: (error: string, status: number) => ({ data: null, error, status }),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const userFind = prisma.user.findUnique as ReturnType<typeof vi.fn>;
|
||||
|
||||
type RouteResult = { data: unknown; error: string | null; status: number };
|
||||
const post = POST as unknown as () => Promise<RouteResult>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
rateLimitMock.mockResolvedValue({ allowed: true, remaining: 19, resetAt: 0 });
|
||||
userFind.mockResolvedValue({ ouraAccessTokenEncrypted: "enc:tok" });
|
||||
});
|
||||
|
||||
describe("POST /api/oura/disconnect", () => {
|
||||
it("disconnects a connected user", async () => {
|
||||
const res = await post();
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ disconnected: true });
|
||||
});
|
||||
|
||||
it("rate-limits the disconnect surface (429 on bucket exhaustion)", async () => {
|
||||
rateLimitMock.mockResolvedValue({ allowed: false, remaining: 0, resetAt: 0 });
|
||||
const res = await post();
|
||||
expect(res.status).toBe(429);
|
||||
// The user lookup never runs once the bucket is exhausted.
|
||||
expect(userFind).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { markDisconnected } from "@/lib/integrations/status";
|
||||
|
||||
/**
|
||||
* v1.17.0 (F4) — disconnect Oura for the current user.
|
||||
*
|
||||
* Clears the stored access + refresh token on `User` and parks the integration
|
||||
* status at `disconnected`. Imported `source = OURA` measurements are left
|
||||
* intact; a reconnect resumes sync.
|
||||
*/
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "oura.disconnect" } });
|
||||
|
||||
const rl = await checkRateLimit(`oura-disconnect:${user.id}`, 20, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many requests", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { ouraAccessTokenEncrypted: true },
|
||||
});
|
||||
if (!dbUser?.ouraAccessTokenEncrypted) {
|
||||
return apiError("No Oura connection", 404);
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
ouraAccessTokenEncrypted: null,
|
||||
ouraRefreshTokenEncrypted: null,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("oura.disconnect", { userId: user.id });
|
||||
await markDisconnected(user.id, "oura");
|
||||
|
||||
return apiSuccess({ disconnected: true });
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: vi.fn() } },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiSuccess: (data: unknown) => ({ data, error: null }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/oura/client", () => ({
|
||||
getOuraCredentials: vi.fn(() => ({ clientId: "c", clientSecret: "s" })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/integrations/status", () => ({
|
||||
getIntegrationStatus: vi.fn(async () => ({
|
||||
integration: "oura",
|
||||
state: "error_reauth",
|
||||
lastSuccessAt: null,
|
||||
lastAttemptAt: "2026-06-10T00:00:00.000Z",
|
||||
lastError: "http_401",
|
||||
consecutiveFailuresByKind: null,
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOuraCredentials } from "@/lib/oura/client";
|
||||
|
||||
const userFind = prisma.user.findUnique as ReturnType<typeof vi.fn>;
|
||||
const credsMock = getOuraCredentials as ReturnType<typeof vi.fn>;
|
||||
|
||||
type Body = {
|
||||
connected: boolean;
|
||||
available: boolean;
|
||||
state?: string;
|
||||
lastError?: string | null;
|
||||
};
|
||||
const call = () =>
|
||||
(GET as unknown as () => Promise<{ data: Body }>)().then((r) => r.data);
|
||||
|
||||
describe("GET /api/oura/status", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
credsMock.mockReturnValue({ clientId: "c", clientSecret: "s" });
|
||||
});
|
||||
|
||||
it("reports not-connected but available", async () => {
|
||||
userFind.mockResolvedValue({ ouraAccessTokenEncrypted: null });
|
||||
const res = await call();
|
||||
expect(res.connected).toBe(false);
|
||||
expect(res.available).toBe(true);
|
||||
});
|
||||
|
||||
it("surfaces the ledger reauth state when connected", async () => {
|
||||
userFind.mockResolvedValue({ ouraAccessTokenEncrypted: "enc" });
|
||||
const res = await call();
|
||||
expect(res.connected).toBe(true);
|
||||
expect(res.state).toBe("error_reauth");
|
||||
expect(res.lastError).toBe("http_401");
|
||||
});
|
||||
|
||||
it("never returns the token", async () => {
|
||||
userFind.mockResolvedValue({ ouraAccessTokenEncrypted: "enc-secret" });
|
||||
const res = (await call()) as Record<string, unknown>;
|
||||
expect(JSON.stringify(res)).not.toContain("enc-secret");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { getOuraCredentials } from "@/lib/oura/client";
|
||||
import { getIntegrationStatus } from "@/lib/integrations/status";
|
||||
|
||||
/**
|
||||
* v1.17.0 (F4) — Oura connection status for the current user.
|
||||
*
|
||||
* `available` reports whether the server has the shared OAuth app configured.
|
||||
* `connected` is whether a token is stored. The ledger snapshot comes off the
|
||||
* shared `oura` IntegrationKey. The access / refresh tokens are NEVER returned.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "oura.status" } });
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { ouraAccessTokenEncrypted: true },
|
||||
});
|
||||
|
||||
const available = !!getOuraCredentials();
|
||||
const connected = !!dbUser?.ouraAccessTokenEncrypted;
|
||||
|
||||
if (!connected) {
|
||||
return apiSuccess({ connected: false, configured: false, available });
|
||||
}
|
||||
|
||||
const status = await getIntegrationStatus(user.id, "oura");
|
||||
|
||||
return apiSuccess({
|
||||
connected: true,
|
||||
configured: true,
|
||||
available,
|
||||
state: status.state,
|
||||
lastSuccessAt: status.lastSuccessAt,
|
||||
lastAttemptAt: status.lastAttemptAt,
|
||||
lastError: status.lastError,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getOuraConnection } from "@/lib/oura/credentials";
|
||||
import { safeFetch, SafeFetchError } from "@/lib/safe-fetch";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Lightweight token probe — fetch the personal-info record, the cheapest
|
||||
// authenticated Oura call that confirms the stored grant is still valid.
|
||||
// Reuses `getOuraConnection`; no new credential path.
|
||||
// Base from `src/lib/oura/client.ts` (`OURA_API_BASE`).
|
||||
const OURA_PERSONAL_INFO_URL =
|
||||
"https://api.ouraring.com/v2/usercollection/personal_info";
|
||||
const TIMEOUT_MS = 5_000;
|
||||
|
||||
function categoriseHttpStatus(status: number): { code: string; message: string } {
|
||||
if (status === 401 || status === 403) {
|
||||
return { code: "credentials_rejected", message: "Oura rejected the token" };
|
||||
}
|
||||
if (status === 429) {
|
||||
return { code: "rate_limited", message: "Oura rate-limited the request" };
|
||||
}
|
||||
if (status >= 500) {
|
||||
return { code: "upstream_error", message: "Oura returned a server error" };
|
||||
}
|
||||
return { code: "connection_failed", message: "Oura connection failed" };
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "oura.test" } });
|
||||
|
||||
const rl = await checkRateLimit(`oura-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many test requests", 429, {
|
||||
errorCode: "rate_limited_self",
|
||||
});
|
||||
}
|
||||
|
||||
const connection = await getOuraConnection(user.id);
|
||||
if (!connection) {
|
||||
return apiError("Oura not connected", 422, { errorCode: "not_configured" });
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
const res = await safeFetch(
|
||||
OURA_PERSONAL_INFO_URL,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${connection.accessToken}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
},
|
||||
{ timeoutMs: TIMEOUT_MS },
|
||||
);
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
|
||||
if (!res.ok) {
|
||||
const cat = categoriseHttpStatus(res.status);
|
||||
annotate({
|
||||
meta: { oura_test_status: res.status, oura_test_code: cat.code },
|
||||
});
|
||||
return apiError(cat.message, 502, { errorCode: cat.code });
|
||||
}
|
||||
|
||||
return apiSuccess({ ok: true, latencyMs });
|
||||
} catch (e) {
|
||||
const isTimeout = e instanceof SafeFetchError && e.kind === "timeout";
|
||||
const code = isTimeout ? "timeout" : "connection_failed";
|
||||
annotate({ meta: { oura_test_code: code } });
|
||||
return apiError(
|
||||
isTimeout ? "Oura request timed out" : "Oura connection failed",
|
||||
502,
|
||||
{ errorCode: code },
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import {
|
||||
exchangeCode,
|
||||
getPolarCredentials,
|
||||
registerUser,
|
||||
} from "@/lib/polar/client";
|
||||
import {
|
||||
oauthStateCookieName,
|
||||
stateMatchesCookie,
|
||||
verifySignedState,
|
||||
} from "@/lib/oauth/signed-state";
|
||||
import { markReconnected } from "@/lib/integrations/status";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* v1.17.0 (F4) — OAuth callback from Polar AccessLink.
|
||||
*
|
||||
* Identity + CSRF: the `state` URL param is a stateless HMAC-signed token. The
|
||||
* callback enforces (1) the param equals the httpOnly cookie byte-for-byte
|
||||
* (double-submit), and (2) the signature + expiry + `polar` provider binding
|
||||
* are valid. The `userId` is read from INSIDE the verified token — tamper-
|
||||
* evident — so identity never comes from the ambient session. When a web
|
||||
* session cookie is also present we cross-check it matches, so a logged-in user
|
||||
* can't complete another user's in-flight handshake.
|
||||
*
|
||||
* On success: exchange the code (Basic-auth), register the user with the Polar
|
||||
* app, persist the encrypted access token + member id on `User`, clear any
|
||||
* prior reauth state, and redirect back to Settings.
|
||||
*/
|
||||
const ERR = (reason: string) =>
|
||||
NextResponse.redirect(
|
||||
new URL(
|
||||
`/settings/integrations?polar=error&reason=${reason}`,
|
||||
process.env.NEXT_PUBLIC_APP_URL!,
|
||||
),
|
||||
);
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "polar.callback" } });
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const code = searchParams.get("code");
|
||||
const state = searchParams.get("state");
|
||||
const cookieState = request.cookies.get(oauthStateCookieName("polar"))?.value;
|
||||
|
||||
// CSRF double-submit: the URL state must equal the cookie (constant-time).
|
||||
if (!stateMatchesCookie(state, cookieState)) {
|
||||
annotate({ meta: { reason: "csrf1" } });
|
||||
return ERR("csrf1");
|
||||
}
|
||||
|
||||
// Verify signature + expiry + provider binding; userId rides inside.
|
||||
const verified = verifySignedState("polar", state);
|
||||
if (!verified) {
|
||||
annotate({ meta: { reason: "state" } });
|
||||
return ERR("state");
|
||||
}
|
||||
const userId = verified.userId;
|
||||
|
||||
const evt = getEvent();
|
||||
if (evt) evt.setAuth({ user_id: userId, auth_method: "session" });
|
||||
|
||||
// Optional session cross-check when a web session is present.
|
||||
const sessionData = await getSession();
|
||||
if (sessionData && sessionData.user.id !== userId) {
|
||||
annotate({ meta: { reason: "cross_user" } });
|
||||
return ERR("cross_user");
|
||||
}
|
||||
|
||||
if (!code) return ERR("nocode");
|
||||
|
||||
try {
|
||||
const creds = getPolarCredentials();
|
||||
if (!creds) return ERR("nocreds");
|
||||
|
||||
const tokens = await exchangeCode(code, creds);
|
||||
// Guard against an off-spec token body without `x_user_id`: persisting the
|
||||
// string "undefined" as the member id would break every later data read
|
||||
// (`/v3/users/undefined/...`) with no clear cause.
|
||||
if (typeof tokens.x_user_id !== "number" || !Number.isFinite(tokens.x_user_id)) {
|
||||
return ERR("token");
|
||||
}
|
||||
const polarUserId = String(tokens.x_user_id);
|
||||
|
||||
// Register the user with the Polar app (idempotent; 409 = already linked).
|
||||
await registerUser(tokens.access_token, polarUserId);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
polarAccessTokenEncrypted: encrypt(tokens.access_token),
|
||||
// Polar issues no refresh token, so no refresh column is stored.
|
||||
polarUserIdEncrypted: encrypt(polarUserId),
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("polar.connect", { userId });
|
||||
await markReconnected(userId, "polar");
|
||||
|
||||
const response = NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings/integrations?polar=connected",
|
||||
process.env.NEXT_PUBLIC_APP_URL!,
|
||||
),
|
||||
);
|
||||
response.cookies.delete(oauthStateCookieName("polar"));
|
||||
return response;
|
||||
} catch (err) {
|
||||
getEvent()?.setError(err);
|
||||
return ERR("token");
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
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";
|
||||
import { getAuthorizationUrl, getPolarCredentials } from "@/lib/polar/client";
|
||||
import {
|
||||
OAUTH_STATE_TTL_MS,
|
||||
mintSignedState,
|
||||
oauthStateCookieName,
|
||||
} from "@/lib/oauth/signed-state";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* v1.17.0 (F4) — redirect the user to the Polar AccessLink consent screen.
|
||||
*
|
||||
* Full OAuth from env: the shared client id/secret come from `POLAR_CLIENT_ID`
|
||||
* / `POLAR_CLIENT_SECRET` (not per-user BYO). The CSRF defence is a stateless
|
||||
* signed state (`src/lib/oauth/signed-state.ts`): the 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.
|
||||
*/
|
||||
const CONNECT_RATE_LIMIT = 10;
|
||||
const CONNECT_WINDOW_MS = 60_000;
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "polar.connect" } });
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`polar:connect:${user.id}`,
|
||||
CONNECT_RATE_LIMIT,
|
||||
CONNECT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
annotate({ action: { name: "polar.connect.rate_limited" } });
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings/integrations?polar=error&reason=rate_limited",
|
||||
process.env.NEXT_PUBLIC_APP_URL!,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const creds = getPolarCredentials();
|
||||
if (!creds) {
|
||||
return apiError(
|
||||
"Polar integration is not configured on this server.",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const state = mintSignedState("polar", user.id);
|
||||
const url = getAuthorizationUrl(state, creds);
|
||||
|
||||
const response = NextResponse.redirect(url);
|
||||
response.cookies.set(oauthStateCookieName("polar"), state, {
|
||||
httpOnly: true,
|
||||
secure: shouldEmitSecureCookie(),
|
||||
sameSite: "lax",
|
||||
maxAge: Math.floor(OAUTH_STATE_TTL_MS / 1000),
|
||||
path: "/",
|
||||
});
|
||||
return response;
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const { rateLimitMock } = vi.hoisted(() => ({ rateLimitMock: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: vi.fn(), update: vi.fn() } },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: rateLimitMock,
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
|
||||
apiError: (error: string, status: number) => ({ data: null, error, status }),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const userFind = prisma.user.findUnique as ReturnType<typeof vi.fn>;
|
||||
|
||||
type RouteResult = { data: unknown; error: string | null; status: number };
|
||||
const post = POST as unknown as () => Promise<RouteResult>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
rateLimitMock.mockResolvedValue({ allowed: true, remaining: 19, resetAt: 0 });
|
||||
userFind.mockResolvedValue({ polarAccessTokenEncrypted: "enc:tok" });
|
||||
});
|
||||
|
||||
describe("POST /api/polar/disconnect", () => {
|
||||
it("disconnects a connected user", async () => {
|
||||
const res = await post();
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ disconnected: true });
|
||||
});
|
||||
|
||||
it("rate-limits the disconnect surface (429 on bucket exhaustion)", async () => {
|
||||
rateLimitMock.mockResolvedValue({ allowed: false, remaining: 0, resetAt: 0 });
|
||||
const res = await post();
|
||||
expect(res.status).toBe(429);
|
||||
// The user lookup never runs once the bucket is exhausted.
|
||||
expect(userFind).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { markDisconnected } from "@/lib/integrations/status";
|
||||
|
||||
/**
|
||||
* v1.17.0 (F4) — disconnect Polar for the current user.
|
||||
*
|
||||
* Clears the stored OAuth token + member id on `User` and parks the integration
|
||||
* status at `disconnected`. Imported `source = POLAR` measurements are left
|
||||
* intact (they are the user's historical readings); a reconnect resumes sync.
|
||||
*/
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "polar.disconnect" } });
|
||||
|
||||
const rl = await checkRateLimit(`polar-disconnect:${user.id}`, 20, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many requests", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { polarAccessTokenEncrypted: true },
|
||||
});
|
||||
if (!dbUser?.polarAccessTokenEncrypted) {
|
||||
return apiError("No Polar connection", 404);
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
polarAccessTokenEncrypted: null,
|
||||
polarUserIdEncrypted: null,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("polar.disconnect", { userId: user.id });
|
||||
await markDisconnected(user.id, "polar");
|
||||
|
||||
return apiSuccess({ disconnected: true });
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: vi.fn() } },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiSuccess: (data: unknown) => ({ data, error: null }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/polar/client", () => ({
|
||||
getPolarCredentials: vi.fn(() => ({ clientId: "c", clientSecret: "s" })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/integrations/status", () => ({
|
||||
getIntegrationStatus: vi.fn(async () => ({
|
||||
integration: "polar",
|
||||
state: "connected",
|
||||
lastSuccessAt: "2026-06-10T00:00:00.000Z",
|
||||
lastAttemptAt: "2026-06-10T00:00:00.000Z",
|
||||
lastError: null,
|
||||
consecutiveFailuresByKind: null,
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getPolarCredentials } from "@/lib/polar/client";
|
||||
|
||||
const userFind = prisma.user.findUnique as ReturnType<typeof vi.fn>;
|
||||
const credsMock = getPolarCredentials as ReturnType<typeof vi.fn>;
|
||||
|
||||
type Body = {
|
||||
connected: boolean;
|
||||
configured: boolean;
|
||||
available: boolean;
|
||||
state?: string;
|
||||
};
|
||||
const call = () =>
|
||||
(GET as unknown as () => Promise<{ data: Body }>)().then((r) => r.data);
|
||||
|
||||
describe("GET /api/polar/status", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
credsMock.mockReturnValue({ clientId: "c", clientSecret: "s" });
|
||||
});
|
||||
|
||||
it("reports not-connected but available when env is set and no token stored", async () => {
|
||||
userFind.mockResolvedValue({ polarAccessTokenEncrypted: null });
|
||||
const res = await call();
|
||||
expect(res.connected).toBe(false);
|
||||
expect(res.available).toBe(true);
|
||||
});
|
||||
|
||||
it("reports available=false when the server has no Polar app configured", async () => {
|
||||
credsMock.mockReturnValue(null);
|
||||
userFind.mockResolvedValue(null);
|
||||
const res = await call();
|
||||
expect(res.available).toBe(false);
|
||||
expect(res.connected).toBe(false);
|
||||
});
|
||||
|
||||
it("reports connected + ledger state when a token is stored", async () => {
|
||||
userFind.mockResolvedValue({ polarAccessTokenEncrypted: "enc" });
|
||||
const res = await call();
|
||||
expect(res.connected).toBe(true);
|
||||
expect(res.configured).toBe(true);
|
||||
expect(res.state).toBe("connected");
|
||||
});
|
||||
|
||||
it("never returns the token", async () => {
|
||||
userFind.mockResolvedValue({ polarAccessTokenEncrypted: "enc" });
|
||||
const res = (await call()) as Record<string, unknown>;
|
||||
expect(JSON.stringify(res)).not.toContain("enc");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { getPolarCredentials } from "@/lib/polar/client";
|
||||
import { getIntegrationStatus } from "@/lib/integrations/status";
|
||||
|
||||
/**
|
||||
* v1.17.0 (F4) — Polar connection status for the current user.
|
||||
*
|
||||
* `available` reports whether the server has the shared OAuth app configured
|
||||
* (env client id/secret) so the card can grey out the connect button when the
|
||||
* operator hasn't set it up. `connected` is whether a token is stored. The
|
||||
* ledger snapshot (state / lastSuccessAt / lastError) comes off the shared
|
||||
* `polar` IntegrationKey. The access token + member id are NEVER returned.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "polar.status" } });
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { polarAccessTokenEncrypted: true },
|
||||
});
|
||||
|
||||
const available = !!getPolarCredentials();
|
||||
const connected = !!dbUser?.polarAccessTokenEncrypted;
|
||||
|
||||
if (!connected) {
|
||||
return apiSuccess({ connected: false, configured: false, available });
|
||||
}
|
||||
|
||||
const status = await getIntegrationStatus(user.id, "polar");
|
||||
|
||||
return apiSuccess({
|
||||
connected: true,
|
||||
configured: true,
|
||||
available,
|
||||
state: status.state,
|
||||
lastSuccessAt: status.lastSuccessAt,
|
||||
lastAttemptAt: status.lastAttemptAt,
|
||||
lastError: status.lastError,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getPolarConnection } from "@/lib/polar/credentials";
|
||||
import { safeFetch, SafeFetchError } from "@/lib/safe-fetch";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Lightweight token probe — fetch the registered AccessLink user record, the
|
||||
// cheapest authenticated Polar call that confirms the stored grant is still
|
||||
// valid. Reuses `getPolarConnection`; no new credential path.
|
||||
// Base from `src/lib/polar/client.ts` (`POLAR_API_BASE`).
|
||||
const POLAR_USERS_BASE = "https://www.polaraccesslink.com/v3/users";
|
||||
const TIMEOUT_MS = 5_000;
|
||||
|
||||
function categoriseHttpStatus(status: number): { code: string; message: string } {
|
||||
if (status === 401 || status === 403) {
|
||||
return { code: "credentials_rejected", message: "Polar rejected the token" };
|
||||
}
|
||||
if (status === 429) {
|
||||
return { code: "rate_limited", message: "Polar rate-limited the request" };
|
||||
}
|
||||
if (status >= 500) {
|
||||
return { code: "upstream_error", message: "Polar returned a server error" };
|
||||
}
|
||||
return { code: "connection_failed", message: "Polar connection failed" };
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "polar.test" } });
|
||||
|
||||
const rl = await checkRateLimit(`polar-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many test requests", 429, {
|
||||
errorCode: "rate_limited_self",
|
||||
});
|
||||
}
|
||||
|
||||
const connection = await getPolarConnection(user.id);
|
||||
if (!connection) {
|
||||
return apiError("Polar not connected", 422, { errorCode: "not_configured" });
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
const res = await safeFetch(
|
||||
`${POLAR_USERS_BASE}/${encodeURIComponent(connection.polarUserId)}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${connection.accessToken}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
},
|
||||
{ timeoutMs: TIMEOUT_MS },
|
||||
);
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
|
||||
if (!res.ok) {
|
||||
const cat = categoriseHttpStatus(res.status);
|
||||
annotate({
|
||||
meta: { polar_test_status: res.status, polar_test_code: cat.code },
|
||||
});
|
||||
return apiError(cat.message, 502, { errorCode: cat.code });
|
||||
}
|
||||
|
||||
return apiSuccess({ ok: true, latencyMs });
|
||||
} catch (e) {
|
||||
const isTimeout = e instanceof SafeFetchError && e.kind === "timeout";
|
||||
const code = isTimeout ? "timeout" : "connection_failed";
|
||||
annotate({ meta: { polar_test_code: code } });
|
||||
return apiError(
|
||||
isTimeout ? "Polar request timed out" : "Polar connection failed",
|
||||
502,
|
||||
{ errorCode: code },
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -77,12 +77,15 @@ describe("GET /api/settings/reminder-thresholds", () => {
|
||||
lateMinutes: number;
|
||||
missedMinutes: number;
|
||||
lowStockRunwayDays: number | null;
|
||||
reorderLeadDays: number;
|
||||
};
|
||||
};
|
||||
expect(env.data).toEqual({
|
||||
lateMinutes: 120,
|
||||
missedMinutes: 240,
|
||||
lowStockRunwayDays: 7,
|
||||
// v1.17.0 — the per-user reorder lead default rides along.
|
||||
reorderLeadDays: 10,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getReminderThresholds } from "@/lib/app-settings";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveLowStockRunwayDays } from "@/lib/validations/notification-prefs";
|
||||
import { parseNotificationPrefs } from "@/lib/validations/notification-prefs";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -22,11 +22,15 @@ export const GET = apiHandler(async () => {
|
||||
where: { id: user.id },
|
||||
select: { notificationPrefs: true },
|
||||
});
|
||||
const lowStockRunwayDays = resolveLowStockRunwayDays(
|
||||
row?.notificationPrefs ?? null,
|
||||
);
|
||||
const prefs = parseNotificationPrefs(row?.notificationPrefs ?? null);
|
||||
const lowStockRunwayDays = prefs.medication.lowStockRunwayDays;
|
||||
// v1.17.0 — the user-level reorder lead default rides along so the
|
||||
// medication cards can derive the same reorder-lead-aware trigger the
|
||||
// daily cron uses (per-medication overrides come from the list payload's
|
||||
// own `reorderLeadDays`).
|
||||
const reorderLeadDays = prefs.medication.reorderLeadDays;
|
||||
|
||||
annotate({ action: { name: "settings.reminder-thresholds.get" } });
|
||||
|
||||
return apiSuccess({ ...thresholds, lowStockRunwayDays });
|
||||
return apiSuccess({ ...thresholds, lowStockRunwayDays, reorderLeadDays });
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/tz/resolver", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/tz/resolver")>();
|
||||
return { ...actual, resolveUserTimezone: vi.fn(async () => "UTC") };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/rollups/measurement-read", () => ({
|
||||
loadUserSourcePriority: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
|
||||
return { ...actual, annotate: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
// `apiHandler` always reads `request.url`; the inner handler ignores it, so we
|
||||
// bypass the arity check with a cast (same pattern as the dashboard summary
|
||||
// test).
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
function req(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/sleep/rhythm");
|
||||
}
|
||||
|
||||
/** A bare-ASLEEP night: end = wake instant, asleep = `minutes`. */
|
||||
function night(wakeIso: string, minutes: number) {
|
||||
return {
|
||||
value: minutes,
|
||||
measuredAt: new Date(wakeIso),
|
||||
sleepStage: "ASLEEP",
|
||||
source: "APPLE_HEALTH",
|
||||
deviceType: null,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dateOfBirth: new Date("1986-01-01"),
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
describe("GET /api/sleep/rhythm", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await callGet(req());
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns the sleep-debt + chronotype DTO shape", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(req());
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
// Sleep-debt DTO fields.
|
||||
expect(body.data.sleepDebt).toMatchObject({
|
||||
state: "partial",
|
||||
debtMinutes: 0,
|
||||
needMinutes: expect.any(Number),
|
||||
nightsCounted: 0,
|
||||
windowNights: expect.any(Number),
|
||||
nightsUntilReady: expect.any(Number),
|
||||
});
|
||||
// Chronotype DTO fields.
|
||||
expect(body.data.chronotype).toMatchObject({
|
||||
state: "learning",
|
||||
msfMinutes: null,
|
||||
msfScMinutes: null,
|
||||
band: null,
|
||||
socialJetlagMinutes: null,
|
||||
freeNightsCounted: 0,
|
||||
workNightsCounted: 0,
|
||||
freeNightsUntilReady: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it("reaches the ready debt state with a full window of short nights", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// Ten consecutive nights of 6 h (360 min); need is 420 → 60/night deficit.
|
||||
const rows = [];
|
||||
for (let d = 1; d <= 10; d++) {
|
||||
const day = String(d).padStart(2, "0");
|
||||
rows.push(night(`2026-06-${day}T06:00:00.000Z`, 360));
|
||||
}
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue(rows as never);
|
||||
|
||||
const res = await callGet(req());
|
||||
const body = await res.json();
|
||||
expect(body.data.sleepDebt.state).toBe("ready");
|
||||
expect(body.data.sleepDebt.nightsCounted).toBe(10);
|
||||
expect(body.data.sleepDebt.nightsUntilReady).toBe(0);
|
||||
// 10 nights × 60-min deficit = 600 min cumulative debt.
|
||||
expect(body.data.sleepDebt.debtMinutes).toBe(600);
|
||||
expect(body.data.sleepDebt.needMinutes).toBe(420);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* GET /api/sleep/rhythm
|
||||
*
|
||||
* v1.17.0 — server-authoritative sleep-rhythm read. Returns the two timing
|
||||
* signals the Sleep page + the iOS client render off the SAME canonical night
|
||||
* reconstruction the Sleep Score uses:
|
||||
*
|
||||
* - `sleepDebt` — cumulative deficit over the rolling window, with a calm
|
||||
* `partial` state under the night threshold.
|
||||
* - `chronotype` — MCTQ MSF / MSFsc band + social jetlag, with a `learning`
|
||||
* state until enough free-day nights exist.
|
||||
*
|
||||
* The math lives in `sleep-debt.ts` / `chronotype.ts`; this route is the auth +
|
||||
* read boundary over `buildSleepRhythm`, which owns the DB read, the canonical
|
||||
* reconstruction, the age-based `needMinutes`, and the weekday/weekend day-type
|
||||
* default. No schema, no new type — a view over the existing per-stage
|
||||
* `SLEEP_DURATION` rows.
|
||||
*
|
||||
* Auth: cookie session OR Bearer token (`requireAuth`). Soft-delete-filtered.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { buildSleepRhythm } from "@/lib/insights/derived/sleep-rhythm";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rhythm = await buildSleepRhythm(user.id);
|
||||
|
||||
annotate({
|
||||
action: { name: "sleep.rhythm" },
|
||||
meta: {
|
||||
debt_state: rhythm.sleepDebt.state,
|
||||
debt_minutes: rhythm.sleepDebt.debtMinutes,
|
||||
chronotype_state: rhythm.chronotype.state,
|
||||
chronotype_band: rhythm.chronotype.band,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(rhythm);
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Droplet } from "lucide-react";
|
||||
|
||||
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
|
||||
import { GlucoseClinicalPanel } from "@/components/insights/glucose/glucose-clinical-panel";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { resolveGlucoseUnit } from "@/lib/glucose";
|
||||
@@ -51,6 +52,7 @@ export default function InsightsBlutzuckerPage() {
|
||||
emptyStateIcon={<Droplet className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
targetSummarySlug="blood-glucose"
|
||||
afterChart={<GlucoseClinicalPanel />}
|
||||
coachPrefill="I haven't logged any blood glucose yet — what does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -10,13 +10,18 @@ import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page
|
||||
* Heart-rate variability sub-page. Pairs with the iOS HKQuantityType
|
||||
* `heartRateVariabilitySDNN` ingest path; the user typically logs one
|
||||
* sample per night during sleep, so the chart is sparse but the
|
||||
* weekly trend is the load-bearing signal. Stored as millisecond
|
||||
* `HEART_RATE_VARIABILITY` rows by every existing ingest path.
|
||||
* weekly trend is the load-bearing signal. Apple Health / Fitbit store
|
||||
* SDNN as millisecond `HEART_RATE_VARIABILITY` rows; Oura / Polar / WHOOP
|
||||
* store nightly RMSSD as `HRV_RMSSD`. The page renders SDNN when present
|
||||
* and falls back to the RMSSD series (labelled "RMSSD") otherwise, so a
|
||||
* ring / strap user's stored HRV always surfaces.
|
||||
*/
|
||||
export default function InsightsHrvPage() {
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="HEART_RATE_VARIABILITY"
|
||||
fallbackMeasurementType="HRV_RMSSD"
|
||||
fallbackMeasureLabel="RMSSD"
|
||||
statusMetric="HEART_RATE_VARIABILITY"
|
||||
insightMetric="HEART_RATE_VARIABILITY"
|
||||
chartKey="hrv"
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* v1.17.0 — the sleep empty-state CTA must point at a REAL settings slug.
|
||||
* It previously linked to `/settings/data-sources`, which is not in
|
||||
* `SETTINGS_SECTION_SLUGS`, so the dynamic `[section]` route calls
|
||||
* `notFound()` → a 404 dead-end. This pins the href to the `integrations`
|
||||
* slug and guards against a regression to any non-existent slug.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isSettingsSectionSlug } from "@/components/settings/section-slugs";
|
||||
|
||||
describe("sleep empty-state CTA href", () => {
|
||||
it("links to /settings/integrations (a real settings slug)", () => {
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), "src/app/insights/sleep/page.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const match = source.match(/href="\/settings\/([a-z-]+)"/);
|
||||
expect(match, "expected a /settings/<slug> href in the sleep page").not.toBeNull();
|
||||
const slug = match![1];
|
||||
expect(slug).toBe("integrations");
|
||||
expect(isSettingsSectionSlug(slug)).toBe(true);
|
||||
});
|
||||
|
||||
it("never links to the removed /settings/data-sources slug", () => {
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), "src/app/insights/sleep/page.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
expect(source).not.toContain("/settings/data-sources");
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import { MetricStatusCard } from "@/components/insights/metric-status-card";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
|
||||
import { SleepOverview } from "@/components/insights/sleep-overview";
|
||||
import { SleepRhythmSection } from "@/components/insights/sleep/sleep-rhythm-section";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
|
||||
/**
|
||||
@@ -24,7 +25,7 @@ import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
*
|
||||
* v1.4.27 F17 — when `summaries.SLEEP_DURATION.count === 0` (no
|
||||
* Apple-Health / Withings sleep rows yet), the page short-circuits
|
||||
* to an empty-state CTA pointing at `/settings/data-sources` so the
|
||||
* to an empty-state CTA pointing at `/settings/integrations` so the
|
||||
* user can connect a sleep source.
|
||||
*
|
||||
* v1.4.28 R3d (BK-F-H1 + BK-F-M1) — analytics fetch + empty-state
|
||||
@@ -49,7 +50,7 @@ export default function InsightsSchlafPage() {
|
||||
description={t("insights.emptyState.sleep.description")}
|
||||
cta={
|
||||
<Button size="sm" asChild>
|
||||
<Link href="/settings/data-sources">
|
||||
<Link href="/settings/integrations">
|
||||
{t("insights.emptyState.sleep.cta")}
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -81,6 +82,13 @@ export default function InsightsSchlafPage() {
|
||||
>
|
||||
<SleepOverview />
|
||||
|
||||
{/*
|
||||
v1.17.0 — sleep-debt headline + chronotype card. Server-authoritative
|
||||
(the same DTO iOS renders), gated on the data-bearing branch so a
|
||||
source-less account never fetches.
|
||||
*/}
|
||||
<SleepRhythmSection enabled={!isEmpty} />
|
||||
|
||||
<MetricTargetSummary slug="sleep" />
|
||||
|
||||
{/*
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
/**
|
||||
* v1.17.0 — invite universal-link landing (iOS #16).
|
||||
*
|
||||
* `/invite/<hlv_token>` is a thin, server-side redirect: it validates the
|
||||
* `hlv_` shape, then bounces a browser onto the existing
|
||||
* `/auth/register?invite=<token>` flow. These tests pin the security
|
||||
* contract — the token shape gate, the carry-through to register, and the
|
||||
* non-oracle behaviour where a malformed segment is indistinguishable
|
||||
* from "no invite". The route never touches the database.
|
||||
*/
|
||||
const redirectMock = vi.fn((href: string) => {
|
||||
// next/navigation `redirect()` throws a sentinel inside the
|
||||
// server-component renderer to short-circuit rendering. Mimic it by
|
||||
// throwing an error tagged with the href so each test can assert the
|
||||
// exact redirect target.
|
||||
const err = new Error(`__redirect__:${href}`);
|
||||
(err as Error & { __redirect__: string }).__redirect__ = href;
|
||||
throw err;
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: (href: string) => redirectMock(href),
|
||||
}));
|
||||
|
||||
import InviteLandingPage from "../page";
|
||||
|
||||
beforeEach(() => {
|
||||
redirectMock.mockClear();
|
||||
});
|
||||
|
||||
const VALID_TOKEN = `hlv_${"a".repeat(64)}`;
|
||||
|
||||
async function runRedirect(token: string): Promise<string> {
|
||||
try {
|
||||
await InviteLandingPage({ params: Promise.resolve({ token }) });
|
||||
} catch (e) {
|
||||
const tagged = e as Error & { __redirect__?: string };
|
||||
if (tagged.__redirect__) return tagged.__redirect__;
|
||||
throw e;
|
||||
}
|
||||
throw new Error("expected redirect, none thrown");
|
||||
}
|
||||
|
||||
describe("<InviteLandingPage> universal-link landing", () => {
|
||||
it("carries a well-formed token through to the register flow", async () => {
|
||||
const href = await runRedirect(VALID_TOKEN);
|
||||
expect(href).toBe(`/auth/register?invite=${VALID_TOKEN}`);
|
||||
});
|
||||
|
||||
it("encodes the token segment into the register query string", async () => {
|
||||
const href = await runRedirect(VALID_TOKEN);
|
||||
const url = new URL(href, "https://h.example.com");
|
||||
expect(url.pathname).toBe("/auth/register");
|
||||
expect(url.searchParams.get("invite")).toBe(VALID_TOKEN);
|
||||
});
|
||||
|
||||
it("drops a malformed token to plain register (no enumeration oracle)", async () => {
|
||||
// Wrong prefix, wrong length, and outright garbage all land on the
|
||||
// identical target — the page never reveals whether a token exists.
|
||||
for (const bad of [
|
||||
"not-a-token",
|
||||
`hlk_${"a".repeat(64)}`,
|
||||
`hlv_${"a".repeat(63)}`,
|
||||
`hlv_${"G".repeat(64)}`,
|
||||
]) {
|
||||
redirectMock.mockClear();
|
||||
const href = await runRedirect(bad);
|
||||
expect(href).toBe("/auth/register");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { looksLikeInviteToken } from "@/lib/auth/invite-token";
|
||||
|
||||
/**
|
||||
* v1.17.0 — invite universal-link landing (iOS #16).
|
||||
*
|
||||
* `https://<host>/invite/<hlv_token>` is the URL encoded in the admin
|
||||
* invite QR and the path the AASA `["*"]` matcher hands to the iOS app:
|
||||
* - On an installed iOS app the scene delegate intercepts the
|
||||
* Universal Link before this page ever renders and starts onboarding
|
||||
* registration with the token prefilled. This server component is the
|
||||
* BROWSER fallback only.
|
||||
* - In a browser, the page is a thin, safe redirect onto the existing
|
||||
* `/auth/register?invite=<token>` flow, where the invite banner shows
|
||||
* and the token rides the signup POST exactly as before.
|
||||
*
|
||||
* Security:
|
||||
* - The `hlv_` shape is validated before the token is ever reflected
|
||||
* into the redirect target — a malformed segment lands on plain
|
||||
* `/auth/register` with no `?invite`, so the page can never echo
|
||||
* attacker-controlled text into the URL.
|
||||
* - The route does NOT touch the database and is NOT an enumeration
|
||||
* oracle: a well-formed-but-unknown token and a well-formed-and-valid
|
||||
* token redirect to the identical target. Whether a token is real is
|
||||
* only ever decided at `POST /api/auth/register`, which keeps its
|
||||
* existing uniform error semantics and rate limit.
|
||||
* - The token is never logged here; the redirect is the only effect.
|
||||
*
|
||||
* The route is listed in `PUBLIC_PATHS` (`/invite/`) so an unauthenticated
|
||||
* visitor reaches it without the auth-gate bounce to `/auth/login`.
|
||||
*/
|
||||
export default async function InviteLandingPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ token: string }>;
|
||||
}) {
|
||||
const { token } = await params;
|
||||
|
||||
if (looksLikeInviteToken(token)) {
|
||||
redirect(`/auth/register?invite=${encodeURIComponent(token)}`);
|
||||
}
|
||||
|
||||
// Malformed token: behave exactly like a visitor with no invite. No DB
|
||||
// hit, no leak of whether any token exists.
|
||||
redirect("/auth/register");
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { ClinicianView } from "../clinician-view";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { DEFAULT_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs";
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
|
||||
|
||||
function makeReport(
|
||||
overrides: Partial<DoctorReportData> = {},
|
||||
@@ -36,6 +37,9 @@ function makeReport(
|
||||
},
|
||||
glucoseStats: {},
|
||||
glucoseRanges: {},
|
||||
glucoseClinical: computeGlucoseClinicalMetrics([], {
|
||||
now: new Date("2026-01-31T00:00:00.000Z"),
|
||||
}),
|
||||
glucoseUnit: "mg/dL",
|
||||
bmi: 24.5,
|
||||
compliance: {},
|
||||
|
||||
@@ -3,6 +3,11 @@ import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildDayLogPatch,
|
||||
buildDayLogInput,
|
||||
symptomsCount,
|
||||
temperatureCount,
|
||||
intimacyCount,
|
||||
testsCount,
|
||||
noteCount,
|
||||
type DayLogFormState,
|
||||
} from "../log-day-sheet";
|
||||
|
||||
@@ -136,3 +141,56 @@ describe("buildDayLogInput (new row → omit empties)", () => {
|
||||
expect("temperatureExcluded" in input).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("section summary-badge counts (v1.17.0)", () => {
|
||||
it("counts each logged symptom", () => {
|
||||
expect(symptomsCount(blank())).toBe(0);
|
||||
const s = { ...blank(), symptoms: new Map([["cramps", 2], ["headache", null]]) };
|
||||
expect(symptomsCount(s)).toBe(2);
|
||||
});
|
||||
|
||||
it("counts only the rendered symptothermal sign alongside BBT and OPK", () => {
|
||||
expect(temperatureCount(blank(), false)).toBe(0);
|
||||
// A non-finite / empty BBT string does not count.
|
||||
expect(temperatureCount({ ...blank(), bbt: "abc" }, false)).toBe(0);
|
||||
const s: DayLogFormState = {
|
||||
...blank(),
|
||||
bbt: "36.5",
|
||||
opk: "POSITIVE_LH_SURGE",
|
||||
mucus: "EGG_WHITE",
|
||||
cervixPosition: "HIGH",
|
||||
cervixFirmness: "SOFT",
|
||||
cervixOpening: "OPEN",
|
||||
};
|
||||
// Mucus surface (showCervix=false): BBT + OPK + mucus = 3; cervix signs
|
||||
// are not rendered, so they don't count.
|
||||
expect(temperatureCount(s, false)).toBe(3);
|
||||
// Cervix surface (showCervix=true): BBT + OPK + 3 cervix signs = 5; the
|
||||
// stale mucus value is not rendered, so it doesn't count.
|
||||
expect(temperatureCount(s, true)).toBe(5);
|
||||
});
|
||||
|
||||
it("counts intercourse and contraceptive", () => {
|
||||
expect(intimacyCount(blank())).toBe(0);
|
||||
expect(
|
||||
intimacyCount({ ...blank(), intercourse: true, contraceptive: "ORAL" }),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it("counts each home-test result", () => {
|
||||
expect(testsCount(blank())).toBe(0);
|
||||
expect(
|
||||
testsCount({
|
||||
...blank(),
|
||||
pregnancyTest: "NEGATIVE",
|
||||
progesteroneTest: "POSITIVE",
|
||||
}),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it("counts a note only when it carries non-whitespace content", () => {
|
||||
expect(noteCount(blank())).toBe(0);
|
||||
expect(noteCount({ ...blank(), note: " " })).toBe(0);
|
||||
expect(noteCount({ ...blank(), note: "tired today" })).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,10 @@ import {
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import {
|
||||
SheetSection,
|
||||
SheetSectionCount,
|
||||
} from "@/components/ui/sheet-section";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms-shared";
|
||||
import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog";
|
||||
@@ -249,6 +253,62 @@ export function buildDayLogInput(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.17.0 — section summary-badge counts for the sectioned log-day sheet.
|
||||
*
|
||||
* Each helper reports how many fields a collapsed section holds, so the
|
||||
* badge communicates its contents without expanding it. Pure + exported
|
||||
* for the unit tests.
|
||||
*/
|
||||
export function symptomsCount(s: DayLogFormState): number {
|
||||
return s.symptoms.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Temperature & ovulation: BBT reading, OPK, and the symptothermal secondary
|
||||
* sign. Only the sign the section actually renders is counted — mucus when
|
||||
* `showCervix` is false, the three cervix observations when true — so the
|
||||
* badge never reports a stale value from the surface that isn't shown (e.g.
|
||||
* after the user flips the advanced-settings secondary-symptom choice).
|
||||
*/
|
||||
export function temperatureCount(
|
||||
s: DayLogFormState,
|
||||
showCervix: boolean,
|
||||
): number {
|
||||
let n = 0;
|
||||
if (resolveBbt(s.bbt) != null) n += 1;
|
||||
if (s.opk) n += 1;
|
||||
if (showCervix) {
|
||||
if (s.cervixPosition) n += 1;
|
||||
if (s.cervixFirmness) n += 1;
|
||||
if (s.cervixOpening) n += 1;
|
||||
} else if (s.mucus) {
|
||||
n += 1;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Intimacy & contraception: intercourse logged, and any contraceptive. */
|
||||
export function intimacyCount(s: DayLogFormState): number {
|
||||
let n = 0;
|
||||
if (s.intercourse) n += 1;
|
||||
if (s.contraceptive) n += 1;
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Tests: pregnancy + progesterone home-test results. */
|
||||
export function testsCount(s: DayLogFormState): number {
|
||||
let n = 0;
|
||||
if (s.pregnancyTest) n += 1;
|
||||
if (s.progesteroneTest) n += 1;
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Note: 1 when the free-text note carries content. */
|
||||
export function noteCount(s: DayLogFormState): number {
|
||||
return s.note.trim() ? 1 : 0;
|
||||
}
|
||||
|
||||
export interface LogDaySheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -421,25 +481,28 @@ export function LogDaySheet({
|
||||
});
|
||||
}
|
||||
|
||||
// The live form state, shared by the save-payload builders and the section
|
||||
// summary badges (so a collapsed section reports what it holds).
|
||||
const formState: DayLogFormState = {
|
||||
flow,
|
||||
intermenstrual,
|
||||
bbt,
|
||||
bbtDisturbed,
|
||||
opk,
|
||||
mucus,
|
||||
cervixPosition,
|
||||
cervixFirmness,
|
||||
cervixOpening,
|
||||
intercourse,
|
||||
protectedSex,
|
||||
pregnancyTest,
|
||||
progesteroneTest,
|
||||
contraceptive,
|
||||
note,
|
||||
symptoms,
|
||||
};
|
||||
|
||||
async function handleSave() {
|
||||
const state: DayLogFormState = {
|
||||
flow,
|
||||
intermenstrual,
|
||||
bbt,
|
||||
bbtDisturbed,
|
||||
opk,
|
||||
mucus,
|
||||
cervixPosition,
|
||||
cervixFirmness,
|
||||
cervixOpening,
|
||||
intercourse,
|
||||
protectedSex,
|
||||
pregnancyTest,
|
||||
progesteroneTest,
|
||||
contraceptive,
|
||||
note,
|
||||
symptoms,
|
||||
};
|
||||
// v1.16.4 — catch so a rejected save doesn't escape as an unhandled
|
||||
// rejection; the inline `logDay.isError || patchDay.isError` strip in the
|
||||
// footer carries the visible failure signal and the sheet stays open.
|
||||
@@ -449,10 +512,10 @@ export function LogDaySheet({
|
||||
// chip actually CLEARS (the POST merge can only add/keep — QA W-2).
|
||||
await patchDay.mutateAsync({
|
||||
id: rowId,
|
||||
patch: buildDayLogPatch(state),
|
||||
patch: buildDayLogPatch(formState),
|
||||
});
|
||||
} else {
|
||||
await logDay.mutateAsync(buildDayLogInput(state, date));
|
||||
await logDay.mutateAsync(buildDayLogInput(formState, date));
|
||||
}
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
@@ -573,7 +636,9 @@ export function LogDaySheet({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Flow */}
|
||||
{/* Quick row — Flow is the single most common period sign, so it
|
||||
stays always-open at the top alongside the intermenstrual toggle.
|
||||
Everything else lives in collapsible sections below. */}
|
||||
<Field label={t("cycle.sheet.flow")}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{FLOW_LEVELS.map((f) => (
|
||||
@@ -599,7 +664,10 @@ export function LogDaySheet({
|
||||
</Field>
|
||||
|
||||
{/* Symptoms */}
|
||||
<Field label={t("cycle.sheet.symptoms")}>
|
||||
<SheetSection
|
||||
title={t("cycle.sheet.symptoms")}
|
||||
summary={<SheetSectionCount count={symptomsCount(formState)} />}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{CYCLE_SYMPTOM_CATALOG.map((cat) => (
|
||||
<div key={cat.key} className="space-y-1.5">
|
||||
@@ -656,10 +724,18 @@ export function LogDaySheet({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</SheetSection>
|
||||
|
||||
{/* BBT */}
|
||||
<Field label={t("cycle.sheet.temperature")}>
|
||||
{/* Temperature & ovulation — BBT, OPK, and the symptothermal secondary
|
||||
sign (mucus or cervix) grouped under one disclosure. */}
|
||||
<SheetSection
|
||||
title={t("cycle.sheet.temperatureSection")}
|
||||
summary={
|
||||
<SheetSectionCount count={temperatureCount(formState, showCervix)} />
|
||||
}
|
||||
>
|
||||
{/* BBT */}
|
||||
<Field label={t("cycle.sheet.temperature")}>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
@@ -756,9 +832,15 @@ export function LogDaySheet({
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
</SheetSection>
|
||||
|
||||
{/* Intercourse + protection */}
|
||||
<Field label={t("cycle.sheet.intercourse")}>
|
||||
{/* Intimacy & contraception */}
|
||||
<SheetSection
|
||||
title={t("cycle.sheet.intimacySection")}
|
||||
summary={<SheetSectionCount count={intimacyCount(formState)} />}
|
||||
>
|
||||
{/* Intercourse + protection */}
|
||||
<Field label={t("cycle.sheet.intercourse")}>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="cycle-intercourse" className="text-sm font-normal">
|
||||
@@ -786,57 +868,71 @@ export function LogDaySheet({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Field>
|
||||
</Field>
|
||||
|
||||
{/* Pregnancy test */}
|
||||
<Field label={t("cycle.sheet.pregnancyTest")}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TEST_RESULTS.map((v) => (
|
||||
<Chip
|
||||
key={v}
|
||||
active={pregnancyTest === v}
|
||||
onClick={() => setPregnancyTest((cur) => (cur === v ? null : v))}
|
||||
>
|
||||
{t(`cycle.testResult.${v}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
{/* Contraceptive */}
|
||||
<Field label={t("cycle.sheet.contraceptive")}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CONTRACEPTIVE_KINDS.map((v) => (
|
||||
<Chip
|
||||
key={v}
|
||||
active={contraceptive === v}
|
||||
onClick={() =>
|
||||
setContraceptive((cur) => (cur === v ? null : v))
|
||||
}
|
||||
>
|
||||
{t(`cycle.contraceptive.${v}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
</SheetSection>
|
||||
|
||||
{/* Progesterone test */}
|
||||
<Field label={t("cycle.sheet.progesteroneTest")}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TEST_RESULTS.map((v) => (
|
||||
<Chip
|
||||
key={v}
|
||||
active={progesteroneTest === v}
|
||||
onClick={() =>
|
||||
setProgesteroneTest((cur) => (cur === v ? null : v))
|
||||
}
|
||||
>
|
||||
{t(`cycle.testResult.${v}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
{/* Tests */}
|
||||
<SheetSection
|
||||
title={t("cycle.sheet.testsSection")}
|
||||
summary={<SheetSectionCount count={testsCount(formState)} />}
|
||||
>
|
||||
{/* Pregnancy test */}
|
||||
<Field label={t("cycle.sheet.pregnancyTest")}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TEST_RESULTS.map((v) => (
|
||||
<Chip
|
||||
key={v}
|
||||
active={pregnancyTest === v}
|
||||
onClick={() =>
|
||||
setPregnancyTest((cur) => (cur === v ? null : v))
|
||||
}
|
||||
>
|
||||
{t(`cycle.testResult.${v}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* Contraceptive */}
|
||||
<Field label={t("cycle.sheet.contraceptive")}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CONTRACEPTIVE_KINDS.map((v) => (
|
||||
<Chip
|
||||
key={v}
|
||||
active={contraceptive === v}
|
||||
onClick={() => setContraceptive((cur) => (cur === v ? null : v))}
|
||||
>
|
||||
{t(`cycle.contraceptive.${v}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
{/* Progesterone test */}
|
||||
<Field label={t("cycle.sheet.progesteroneTest")}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TEST_RESULTS.map((v) => (
|
||||
<Chip
|
||||
key={v}
|
||||
active={progesteroneTest === v}
|
||||
onClick={() =>
|
||||
setProgesteroneTest((cur) => (cur === v ? null : v))
|
||||
}
|
||||
>
|
||||
{t(`cycle.testResult.${v}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
</SheetSection>
|
||||
|
||||
{/* Note */}
|
||||
<Field label={t("cycle.sheet.note")}>
|
||||
<SheetSection
|
||||
title={t("cycle.sheet.note")}
|
||||
summary={<SheetSectionCount count={noteCount(formState)} />}
|
||||
>
|
||||
<Textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
@@ -845,7 +941,7 @@ export function LogDaySheet({
|
||||
placeholder={t("cycle.sheet.notePlaceholder")}
|
||||
aria-label={t("cycle.sheet.note")}
|
||||
/>
|
||||
</Field>
|
||||
</SheetSection>
|
||||
|
||||
{logDay.isError || patchDay.isError ? (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
|
||||
@@ -59,7 +59,7 @@ const METRIC_HREF: Record<
|
||||
// the category pages use to drill into a single type). Every other
|
||||
// briefing finding with a real destination is already tappable; steps
|
||||
// was the lone static row.
|
||||
steps: "/insights/values/ACTIVITY_STEPS",
|
||||
steps: "/insights/steps",
|
||||
active_energy: null,
|
||||
flights: null,
|
||||
distance: null,
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import { GlucoseTirBar } from "../glucose-tir-bar";
|
||||
import { GlucoseAdvancedDisclosure } from "../glucose-advanced-disclosure";
|
||||
|
||||
/** Minimal valid `DataSummary` — the panel + disclosure only read `count`. */
|
||||
function summary(count: number): DataSummary {
|
||||
return {
|
||||
count,
|
||||
latest: 95,
|
||||
min: 60,
|
||||
max: 140,
|
||||
mean: 100,
|
||||
median: 98,
|
||||
avg7: null,
|
||||
avg30: null,
|
||||
slope7: null,
|
||||
slope30: null,
|
||||
slope90: null,
|
||||
anomalyCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.17.0 — glucose clinical panel render tests.
|
||||
*
|
||||
* Covers the two states the maintainer called out: the calm "still learning"
|
||||
* branch (no asserted TIR / GMI off thin spot data) and the asserted branch
|
||||
* with the advanced progressive disclosure. The panel leans on `useAuth` +
|
||||
* `useAnalyticsQuery`; both are mocked so the branch under test is
|
||||
* deterministic. The TIR bar + advanced disclosure are pure props, tested
|
||||
* directly.
|
||||
*/
|
||||
|
||||
const authMock = vi.fn();
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => authMock(),
|
||||
}));
|
||||
|
||||
const analyticsMock = vi.fn();
|
||||
vi.mock("@/lib/queries/use-analytics-query", () => ({
|
||||
useAnalyticsQuery: () => analyticsMock(),
|
||||
}));
|
||||
|
||||
// Import after the mocks are registered.
|
||||
const { GlucoseClinicalPanel } = await import("../glucose-clinical-panel");
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const NOW = new Date("2026-06-14T12:00:00Z");
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
beforeEach(() => {
|
||||
authMock.mockReturnValue({
|
||||
user: { glucoseUnit: "mg/dL" },
|
||||
isAuthenticated: true,
|
||||
});
|
||||
analyticsMock.mockReset();
|
||||
});
|
||||
|
||||
describe("<GlucoseClinicalPanel> learning state", () => {
|
||||
it("renders the calm still-learning copy and asserts NO TIR/GMI", () => {
|
||||
// 5 readings over 4 days — below the 14-reading default floor.
|
||||
const readings = [100, 110, 120, 130, 140].map((mgdl, i) => ({
|
||||
measuredAt: new Date(NOW.getTime() - (4 - i) * DAY),
|
||||
mgdl,
|
||||
}));
|
||||
const glucoseClinical = computeGlucoseClinicalMetrics(readings, { now: NOW });
|
||||
expect(glucoseClinical.stillLearning).toBe(true);
|
||||
|
||||
analyticsMock.mockReturnValue({
|
||||
isLoading: false,
|
||||
data: { glucoseClinical, glucoseByContext: {} },
|
||||
});
|
||||
|
||||
const html = render(<GlucoseClinicalPanel />);
|
||||
expect(html).toContain('data-state="learning"');
|
||||
expect(html).toContain("Still learning your glucose");
|
||||
// the asserted blocks must NOT be present in the learning state
|
||||
expect(html).not.toContain("data-slot=\"glucose-tir-bar\"");
|
||||
expect(html).not.toContain("Glucose Management Indicator");
|
||||
// the spot-reading caveat is always carried
|
||||
expect(html).toContain("spot-reading estimates");
|
||||
});
|
||||
|
||||
it("renders the empty learning copy when there are zero readings", () => {
|
||||
const glucoseClinical = computeGlucoseClinicalMetrics([], { now: NOW });
|
||||
analyticsMock.mockReturnValue({
|
||||
isLoading: false,
|
||||
data: { glucoseClinical, glucoseByContext: {} },
|
||||
});
|
||||
const html = render(<GlucoseClinicalPanel />);
|
||||
expect(html).toContain('data-state="learning"');
|
||||
expect(html).toContain("Once a handful of readings come in");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<GlucoseClinicalPanel> asserted state", () => {
|
||||
function assertedClinical() {
|
||||
// 20 readings, one per day → clears the gate; a spread that lands in all
|
||||
// three TIR bands so the bar has real segments and CV% is non-trivial.
|
||||
const values = [
|
||||
60, 90, 120, 150, 180, 200, 100, 110, 95, 130, 170, 220, 80, 140, 160,
|
||||
105, 115, 125, 135, 145,
|
||||
];
|
||||
const readings = values.map((mgdl, i) => ({
|
||||
measuredAt: new Date(NOW.getTime() - (values.length - 1 - i) * DAY),
|
||||
mgdl,
|
||||
}));
|
||||
return computeGlucoseClinicalMetrics(readings, { now: NOW, windowDays: 30 });
|
||||
}
|
||||
|
||||
it("renders TIR bar, GMI, eA1C, a CV badge, and the advanced disclosure", () => {
|
||||
const glucoseClinical = assertedClinical();
|
||||
expect(glucoseClinical.stillLearning).toBe(false);
|
||||
|
||||
analyticsMock.mockReturnValue({
|
||||
isLoading: false,
|
||||
data: {
|
||||
glucoseClinical,
|
||||
glucoseByContext: {
|
||||
FASTING: summary(8),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const html = render(<GlucoseClinicalPanel />);
|
||||
expect(html).toContain('data-state="asserted"');
|
||||
expect(html).toContain('data-slot="glucose-tir-bar"');
|
||||
expect(html).toContain("Glucose Management Indicator");
|
||||
expect(html).toContain("Estimated A1C");
|
||||
expect(html).toContain('data-slot="glucose-cv-badge"');
|
||||
// advanced disclosure trigger is present (region collapsed by default in SSR)
|
||||
expect(html).toContain('data-slot="glucose-advanced-toggle"');
|
||||
expect(html).toContain("aria-expanded=\"false\"");
|
||||
expect(html).toContain("J-index");
|
||||
});
|
||||
|
||||
it("flags the CV badge unstable at CV% >= 36", () => {
|
||||
// wide spread → CV well above 36
|
||||
const values = Array.from({ length: 20 }, (_, i) =>
|
||||
i % 2 === 0 ? 60 : 260,
|
||||
);
|
||||
const readings = values.map((mgdl, i) => ({
|
||||
measuredAt: new Date(NOW.getTime() - (values.length - 1 - i) * DAY),
|
||||
mgdl,
|
||||
}));
|
||||
const glucoseClinical = computeGlucoseClinicalMetrics(readings, {
|
||||
now: NOW,
|
||||
windowDays: 30,
|
||||
});
|
||||
expect(glucoseClinical.variability?.unstable).toBe(true);
|
||||
|
||||
analyticsMock.mockReturnValue({
|
||||
isLoading: false,
|
||||
data: { glucoseClinical, glucoseByContext: {} },
|
||||
});
|
||||
const html = render(<GlucoseClinicalPanel />);
|
||||
expect(html).toContain('data-unstable="true"');
|
||||
expect(html).toContain("Unstable");
|
||||
});
|
||||
|
||||
it("renders nothing when the thick slice carries no glucoseClinical", () => {
|
||||
analyticsMock.mockReturnValue({ isLoading: false, data: { summaries: {} } });
|
||||
const html = render(<GlucoseClinicalPanel />);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<GlucoseTirBar>", () => {
|
||||
it("renders one segment per non-empty Battelino band summing to 100%", () => {
|
||||
const dist = computeGlucoseClinicalMetrics(
|
||||
[40, 60, 120, 200, 300].map((mgdl, i) => ({
|
||||
measuredAt: new Date(NOW.getTime() - i * DAY),
|
||||
mgdl,
|
||||
})),
|
||||
{ now: NOW, minReadings: 1, minSpanDays: 1 },
|
||||
).distribution!;
|
||||
const html = render(<GlucoseTirBar distribution={dist} />);
|
||||
// very-low (40), low (60), in-range (120), high (200), very-high (300)
|
||||
expect(html).toContain("glucose-tir-segment-veryLow");
|
||||
expect(html).toContain("glucose-tir-segment-low");
|
||||
expect(html).toContain("glucose-tir-segment-inRange");
|
||||
expect(html).toContain("glucose-tir-segment-high");
|
||||
expect(html).toContain("glucose-tir-segment-veryHigh");
|
||||
// aria summary present
|
||||
expect(html).toContain('role="img"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<GlucoseAdvancedDisclosure>", () => {
|
||||
it("is collapsed by default and exposes a toggle + the three indices", () => {
|
||||
const html = render(
|
||||
<GlucoseAdvancedDisclosure
|
||||
advanced={{ jIndex: 23.7, lbgi: 1.2, hbgi: 6.8 }}
|
||||
byContext={{
|
||||
FASTING: summary(1),
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('aria-expanded="false"');
|
||||
expect(html).toContain("hidden"); // region hidden in initial SSR state
|
||||
expect(html).toContain("J-index");
|
||||
expect(html).toContain("LBGI");
|
||||
expect(html).toContain("HBGI");
|
||||
// singular reading label for a single-reading context
|
||||
expect(html).toContain("1 reading");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
|
||||
/**
|
||||
* v1.17.0 — "advanced" progressive disclosure for the glucose panel.
|
||||
*
|
||||
* The default panel view leads with TIR / GMI / eA1C / CV%. The research-grade
|
||||
* composites the maintainer chose for v1.17.0 — J-index (Wojcicki) and the
|
||||
* Kovatchev low/high blood-glucose risk indices — live behind this disclosure
|
||||
* so a casual user is not confronted with them, while a clinician or an engaged
|
||||
* user can open them. Per-context reading counts ride along so the advanced
|
||||
* view also answers "where do these readings come from". Values are computed
|
||||
* server-side; this only renders them.
|
||||
*
|
||||
* No Collapsible primitive exists in the UI kit, so this is a minimal,
|
||||
* accessible button + region disclosure (the same pattern other insights cards
|
||||
* use): `aria-expanded` on the trigger, `aria-controls`/`hidden` on the region.
|
||||
*/
|
||||
|
||||
const CONTEXT_LABEL_KEYS: Record<string, string> = {
|
||||
FASTING: "insights.bloodGlucose.clinical.advanced.byContext.fasting",
|
||||
POSTPRANDIAL: "insights.bloodGlucose.clinical.advanced.byContext.postprandial",
|
||||
RANDOM: "insights.bloodGlucose.clinical.advanced.byContext.random",
|
||||
BEDTIME: "insights.bloodGlucose.clinical.advanced.byContext.bedtime",
|
||||
};
|
||||
|
||||
export interface GlucoseAdvancedDisclosureProps {
|
||||
advanced: NonNullable<GlucoseClinicalMetrics["advanced"]>;
|
||||
/** Per-context summaries keyed by `GlucoseContext`; optional. */
|
||||
byContext?: Record<string, DataSummary> | undefined;
|
||||
/** Decimals for the index values. Defaults to 1. */
|
||||
fractionDigits?: number;
|
||||
}
|
||||
|
||||
export function GlucoseAdvancedDisclosure({
|
||||
advanced,
|
||||
byContext,
|
||||
fractionDigits = 1,
|
||||
}: GlucoseAdvancedDisclosureProps) {
|
||||
const { t } = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
const regionId = useId();
|
||||
|
||||
const fmt = (v: number | null) =>
|
||||
v === null ? "—" : v.toFixed(fractionDigits);
|
||||
|
||||
const contexts = Object.entries(byContext ?? {}).filter(
|
||||
([, s]) => s && s.count > 0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-slot="glucose-advanced" className="border-border border-t pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
aria-controls={regionId}
|
||||
data-slot="glucose-advanced-toggle"
|
||||
className="text-muted-foreground hover:text-foreground flex min-h-11 w-full items-center justify-between gap-2 text-sm font-medium transition-colors"
|
||||
>
|
||||
<span>{t("insights.bloodGlucose.clinical.advanced.toggle")}</span>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform",
|
||||
open && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div
|
||||
id={regionId}
|
||||
hidden={!open}
|
||||
data-slot="glucose-advanced-region"
|
||||
className="space-y-4 pt-3"
|
||||
>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.bloodGlucose.clinical.advanced.intro")}
|
||||
</p>
|
||||
|
||||
<dl className="grid grid-cols-3 gap-3">
|
||||
<AdvancedStat
|
||||
label={t("insights.bloodGlucose.clinical.advanced.jIndex.label")}
|
||||
help={t("insights.bloodGlucose.clinical.advanced.jIndex.help")}
|
||||
value={fmt(advanced.jIndex)}
|
||||
/>
|
||||
<AdvancedStat
|
||||
label={t("insights.bloodGlucose.clinical.advanced.lbgi.label")}
|
||||
help={t("insights.bloodGlucose.clinical.advanced.lbgi.help")}
|
||||
value={fmt(advanced.lbgi)}
|
||||
/>
|
||||
<AdvancedStat
|
||||
label={t("insights.bloodGlucose.clinical.advanced.hbgi.label")}
|
||||
help={t("insights.bloodGlucose.clinical.advanced.hbgi.help")}
|
||||
value={fmt(advanced.hbgi)}
|
||||
/>
|
||||
</dl>
|
||||
|
||||
{contexts.length > 0 ? (
|
||||
<div className="space-y-2" data-slot="glucose-advanced-context">
|
||||
<p className="text-foreground text-xs font-medium">
|
||||
{t("insights.bloodGlucose.clinical.advanced.byContext.title")}
|
||||
</p>
|
||||
<ul className="text-muted-foreground space-y-1 text-xs">
|
||||
{contexts.map(([ctx, summary]) => (
|
||||
<li
|
||||
key={ctx}
|
||||
className="flex items-center justify-between gap-2"
|
||||
>
|
||||
<span>
|
||||
{CONTEXT_LABEL_KEYS[ctx] ? t(CONTEXT_LABEL_KEYS[ctx]) : ctx}
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{summary.count === 1
|
||||
? t(
|
||||
"insights.bloodGlucose.clinical.advanced.byContext.readingsShortOne",
|
||||
)
|
||||
: t(
|
||||
"insights.bloodGlucose.clinical.advanced.byContext.readingsShort",
|
||||
{ count: summary.count },
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdvancedStat({
|
||||
label,
|
||||
help,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
help: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<dt className="text-muted-foreground text-xs" title={help}>
|
||||
{label}
|
||||
</dt>
|
||||
<dd className="text-foreground text-base font-semibold tabular-nums">
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
"use client";
|
||||
|
||||
import { Activity, Droplet, TrendingUp } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import { GlucoseTirBar } from "./glucose-tir-bar";
|
||||
import { GlucoseAdvancedDisclosure } from "./glucose-advanced-disclosure";
|
||||
|
||||
/**
|
||||
* v1.17.0 — glucose clinical panel.
|
||||
*
|
||||
* The default view leads with the three numbers that matter to a person
|
||||
* managing glucose: time-in-range (Battelino bands, as a stacked bar), a GMI +
|
||||
* estimated-A1C pair, and a variability badge that flags "unstable" at CV% ≥ 36
|
||||
* (Monnier). The advanced indices (J-index, LBGI/HBGI) sit behind a calm
|
||||
* disclosure. Every number is server-computed by the one literature-locked
|
||||
* engine — the panel renders verbatim and never re-derives.
|
||||
*
|
||||
* Honesty contract: HealthLog stores SPOT readings, not a CGM trace, so the
|
||||
* copy always carries the "spot-reading estimate" caveat, and a thin window
|
||||
* shows a warm "still learning" state instead of asserting a clinical profile.
|
||||
* Tone follows the cycle-phase texts: warm, professional, grounded — never
|
||||
* alarmist, never breezy.
|
||||
*
|
||||
* Reads the thick `/api/analytics` payload directly (the slim slice does not
|
||||
* carry `glucoseClinical`); the cache cell is shared with the dashboard so the
|
||||
* mount is usually a free hit.
|
||||
*/
|
||||
export function GlucoseClinicalPanel() {
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const query = useAnalyticsQuery({});
|
||||
|
||||
const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit ?? null);
|
||||
const isMmol = glucoseUnit === "mmol/L";
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<Card data-slot="glucose-clinical-panel-skeleton">
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-5 w-40" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Skeleton className="h-6 w-full" />
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Skeleton className="h-12" />
|
||||
<Skeleton className="h-12" />
|
||||
<Skeleton className="h-12" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const clinical = query.data?.glucoseClinical;
|
||||
// No glucose data at all, or the thick slice did not carry the block — the
|
||||
// page's own empty state already covers the truly-no-data case, so render
|
||||
// nothing rather than a redundant card.
|
||||
if (!clinical) return null;
|
||||
|
||||
const byContext = query.data?.glucoseByContext as
|
||||
| Record<string, DataSummary>
|
||||
| undefined;
|
||||
|
||||
const formatGlucoseValue = (mgdl: number): string => {
|
||||
const v = convertGlucose(mgdl, glucoseUnit);
|
||||
return isMmol ? v.toFixed(1) : String(Math.round(v));
|
||||
};
|
||||
|
||||
// Learning state — calm, never asserting TIR / GMI off thin data.
|
||||
if (clinical.stillLearning) {
|
||||
const hasAny = clinical.readingCount > 0;
|
||||
const readingsLabel =
|
||||
clinical.readingCount === 1
|
||||
? t("insights.bloodGlucose.clinical.readingsCountOne")
|
||||
: t("insights.bloodGlucose.clinical.readingsCount", {
|
||||
count: clinical.readingCount,
|
||||
});
|
||||
const spanDays = Math.max(1, Math.round(clinical.actualSpanDays));
|
||||
const daysLabel =
|
||||
spanDays === 1
|
||||
? t("insights.bloodGlucose.clinical.daysCountOne")
|
||||
: t("insights.bloodGlucose.clinical.daysCount", { days: spanDays });
|
||||
|
||||
return (
|
||||
<Card data-slot="glucose-clinical-panel" data-state="learning">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base font-semibold">
|
||||
<Droplet className="h-4 w-4" aria-hidden="true" />
|
||||
{t("insights.bloodGlucose.clinical.learningTitle")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-muted-foreground text-sm" data-slot="glucose-learning-body">
|
||||
{hasAny
|
||||
? t("insights.bloodGlucose.clinical.learningBody", {
|
||||
count: readingsLabel,
|
||||
days: daysLabel,
|
||||
})
|
||||
: t("insights.bloodGlucose.clinical.learningBodyEmpty")}
|
||||
</p>
|
||||
<p className="text-muted-foreground/80 text-xs">
|
||||
{t("insights.bloodGlucose.clinical.spotCaveat")}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Asserted view.
|
||||
const dist = clinical.distribution;
|
||||
const cv = clinical.variability;
|
||||
|
||||
return (
|
||||
<Card data-slot="glucose-clinical-panel" data-state="asserted">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<CardTitle className="flex items-center gap-2 text-base font-semibold">
|
||||
<Droplet className="h-4 w-4" aria-hidden="true" />
|
||||
{t("insights.bloodGlucose.clinical.title")}
|
||||
</CardTitle>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("insights.bloodGlucose.clinical.subtitle", {
|
||||
days: Math.max(1, Math.round(clinical.actualSpanDays)),
|
||||
count: clinical.readingCount,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{/* Time in range */}
|
||||
{dist ? (
|
||||
<section className="space-y-2" data-slot="glucose-tir-section">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-foreground text-sm font-medium">
|
||||
{t("insights.bloodGlucose.clinical.tir.title")}
|
||||
</h3>
|
||||
</div>
|
||||
<GlucoseTirBar distribution={dist} />
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.bloodGlucose.clinical.tir.caption")}
|
||||
</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* Headline metrics */}
|
||||
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4" data-slot="glucose-headline">
|
||||
{clinical.meanMgdl !== null ? (
|
||||
<Metric
|
||||
icon={<Droplet className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||
label={t("insights.bloodGlucose.clinical.mean.label")}
|
||||
value={formatGlucoseValue(clinical.meanMgdl)}
|
||||
unit={glucoseUnit}
|
||||
/>
|
||||
) : null}
|
||||
{clinical.gmi !== null ? (
|
||||
<Metric
|
||||
icon={<Activity className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||
label={t("insights.bloodGlucose.clinical.gmi.label")}
|
||||
help={t("insights.bloodGlucose.clinical.gmi.help")}
|
||||
value={clinical.gmi.toFixed(1)}
|
||||
unit="%"
|
||||
/>
|
||||
) : null}
|
||||
{clinical.estimatedA1c !== null ? (
|
||||
<Metric
|
||||
icon={<TrendingUp className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||
label={t("insights.bloodGlucose.clinical.eA1c.label")}
|
||||
help={t("insights.bloodGlucose.clinical.eA1c.help")}
|
||||
value={clinical.estimatedA1c.toFixed(1)}
|
||||
unit="%"
|
||||
/>
|
||||
) : null}
|
||||
{cv ? (
|
||||
<div className="space-y-0.5" data-slot="glucose-cv">
|
||||
<span
|
||||
className="text-muted-foreground flex items-center gap-1 text-xs"
|
||||
title={t("insights.bloodGlucose.clinical.cv.help")}
|
||||
>
|
||||
{t("insights.bloodGlucose.clinical.cv.label")}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-foreground text-lg font-semibold tabular-nums">
|
||||
{Math.round(cv.cv)}%
|
||||
</span>
|
||||
<Badge
|
||||
variant={cv.unstable ? "destructive" : "secondary"}
|
||||
data-slot="glucose-cv-badge"
|
||||
data-unstable={cv.unstable}
|
||||
>
|
||||
{cv.unstable
|
||||
? t("insights.bloodGlucose.clinical.cv.unstable")
|
||||
: t("insights.bloodGlucose.clinical.cv.steady")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* Spot-reading honesty caveat — only for sparse spot data. A dense
|
||||
continuous stream (a CGM such as Nightscout) clears the density bar
|
||||
and reports `isSpotEstimate: false`, so the caveat would undersell
|
||||
the data. */}
|
||||
{clinical.isSpotEstimate ? (
|
||||
<p className="text-muted-foreground/80 text-xs" data-slot="glucose-spot-caveat">
|
||||
{t("insights.bloodGlucose.clinical.spotCaveat")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Advanced indices behind a disclosure. */}
|
||||
{clinical.advanced ? (
|
||||
<GlucoseAdvancedDisclosure
|
||||
advanced={clinical.advanced}
|
||||
byContext={byContext}
|
||||
/>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({
|
||||
icon,
|
||||
label,
|
||||
help,
|
||||
value,
|
||||
unit,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
help?: string;
|
||||
value: string;
|
||||
unit: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<span
|
||||
className="text-muted-foreground flex items-center gap-1 text-xs"
|
||||
title={help}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-foreground flex items-baseline gap-1">
|
||||
<span className="text-lg font-semibold tabular-nums">{value}</span>
|
||||
<span className="text-muted-foreground text-xs">{unit}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { TimeInRangeDistribution } from "@/lib/analytics/glucose-metrics";
|
||||
|
||||
/**
|
||||
* v1.17.0 — Battelino 2019 time-in-range stacked bar.
|
||||
*
|
||||
* A single horizontal 100%-width bar split into the five consensus bands
|
||||
* (very low / low / in range / high / very high), bottom-to-top severity
|
||||
* read left-to-right with the in-range band centred and dominant. Fractions
|
||||
* arrive already computed by the server engine — this component only renders
|
||||
* them, it never re-derives a band. The level-2 sub-bands are NESTED inside
|
||||
* level-1 in the source distribution (TBR2 ⊆ TBR1, TAR2 ⊆ TAR1), so the
|
||||
* segment widths here use the EXCLUSIVE slices to fill exactly 100%.
|
||||
*/
|
||||
|
||||
const BAND_COLORS = {
|
||||
veryLow: "#ff5555", // dracula-red — urgent low
|
||||
low: "#ffb86c", // dracula-orange — low
|
||||
inRange: "#50fa7b", // dracula-green — target
|
||||
high: "#f1fa8c", // dracula-yellow — high
|
||||
veryHigh: "#ff79c6", // dracula-pink — urgent high
|
||||
} as const;
|
||||
|
||||
type BandKey = keyof typeof BAND_COLORS;
|
||||
|
||||
export interface GlucoseTirBarProps {
|
||||
distribution: TimeInRangeDistribution;
|
||||
}
|
||||
|
||||
export function GlucoseTirBar({ distribution }: GlucoseTirBarProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// Exclusive slices from the nested consensus fractions: level-2 is a subset
|
||||
// of level-1, so the level-1-only slice = level1 − level2.
|
||||
const veryLow = distribution.tbrLevel2;
|
||||
const low = Math.max(0, distribution.tbrLevel1 - distribution.tbrLevel2);
|
||||
const inRange = distribution.tir;
|
||||
const high = Math.max(0, distribution.tarLevel1 - distribution.tarLevel2);
|
||||
const veryHigh = distribution.tarLevel2;
|
||||
|
||||
const segments: Array<{ key: BandKey; label: string; fraction: number }> = [
|
||||
{ key: "veryLow", label: t("insights.bloodGlucose.clinical.tir.veryLow"), fraction: veryLow },
|
||||
{ key: "low", label: t("insights.bloodGlucose.clinical.tir.low"), fraction: low },
|
||||
{ key: "inRange", label: t("insights.bloodGlucose.clinical.tir.inRange"), fraction: inRange },
|
||||
{ key: "high", label: t("insights.bloodGlucose.clinical.tir.high"), fraction: high },
|
||||
{ key: "veryHigh", label: t("insights.bloodGlucose.clinical.tir.veryHigh"), fraction: veryHigh },
|
||||
];
|
||||
|
||||
const pct = (f: number) => Math.round(f * 100);
|
||||
const ariaLabel = t("insights.bloodGlucose.clinical.tir.ariaLabel", {
|
||||
tir: pct(inRange),
|
||||
below: pct(distribution.tbrLevel1),
|
||||
above: pct(distribution.tarLevel1),
|
||||
});
|
||||
|
||||
return (
|
||||
<div data-slot="glucose-tir-bar" className="space-y-2">
|
||||
<div
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
className="bg-muted flex h-6 w-full overflow-hidden rounded-md"
|
||||
>
|
||||
{segments.map((s) =>
|
||||
s.fraction > 0 ? (
|
||||
<div
|
||||
key={s.key}
|
||||
data-slot={`glucose-tir-segment-${s.key}`}
|
||||
className="h-full"
|
||||
style={{
|
||||
width: `${s.fraction * 100}%`,
|
||||
background: BAND_COLORS[s.key],
|
||||
}}
|
||||
title={`${s.label} · ${pct(s.fraction)}%`}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
<ul
|
||||
className="text-muted-foreground grid grid-cols-2 gap-x-4 gap-y-1 text-xs sm:grid-cols-3"
|
||||
data-slot="glucose-tir-legend"
|
||||
>
|
||||
{segments.map((s) => (
|
||||
<li key={s.key} className="flex items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block h-2 w-2 rounded-sm"
|
||||
style={{ background: BAND_COLORS[s.key] }}
|
||||
/>
|
||||
{s.label}
|
||||
</span>
|
||||
<span className="text-foreground tabular-nums font-medium">
|
||||
{pct(s.fraction)}%
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -54,6 +54,24 @@ import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
export interface HealthKitMetricPageProps {
|
||||
/** The MeasurementType that backs the chart. */
|
||||
measurementType: string;
|
||||
/**
|
||||
* v1.17.0 — optional secondary MeasurementType the page falls back to
|
||||
* when the primary type has no rows but the fallback does. Used by HRV:
|
||||
* the primary `HEART_RATE_VARIABILITY` (SDNN, Apple / Fitbit) is empty
|
||||
* for a ring / strap user whose nightly HRV is stored as `HRV_RMSSD`
|
||||
* (Oura / Polar / WHOOP). When the swap is active the chart, stat strip,
|
||||
* "all values" list, and diversity nudge key off the fallback type and
|
||||
* `fallbackMeasureLabel` names the measure so the two are never silently
|
||||
* merged. Omit it and every other page renders byte-identically.
|
||||
*/
|
||||
fallbackMeasurementType?: string;
|
||||
/**
|
||||
* v1.17.0 — short label naming the fallback measure (e.g. "RMSSD") shown
|
||||
* beneath the chart title when the fallback series is active, so the user
|
||||
* sees which measure their reading is. Required in practice whenever
|
||||
* `fallbackMeasurementType` is set.
|
||||
*/
|
||||
fallbackMeasureLabel?: string;
|
||||
/** The InsightMetric key used by `useInsightsAnalytics()`. */
|
||||
insightMetric: InsightMetric;
|
||||
/** The chart-overlay slot id. */
|
||||
@@ -154,10 +172,21 @@ export interface HealthKitMetricPageProps {
|
||||
* 90-day p50 is not read as an all-time central value.
|
||||
*/
|
||||
statMedianLabel?: string;
|
||||
/**
|
||||
* v1.17.0 — optional extra content rendered beneath the chart + target
|
||||
* summary, before the metric-status card. Blood glucose mounts its clinical
|
||||
* panel (TIR / GMI / eA1C / CV% + advanced indices) here. Only rendered on
|
||||
* the data-bearing branch (the empty / loading / error branches skip it, so
|
||||
* a no-data metric never shows a void panel). Additive: every other page
|
||||
* omits it and renders byte-identically.
|
||||
*/
|
||||
afterChart?: ReactNode;
|
||||
}
|
||||
|
||||
export function HealthKitMetricPage({
|
||||
measurementType,
|
||||
fallbackMeasurementType,
|
||||
fallbackMeasureLabel,
|
||||
insightMetric,
|
||||
chartKey,
|
||||
i18nPrefix,
|
||||
@@ -176,6 +205,7 @@ export function HealthKitMetricPage({
|
||||
forecast = false,
|
||||
statFractionDigits,
|
||||
statMedianLabel,
|
||||
afterChart,
|
||||
}: HealthKitMetricPageProps) {
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
@@ -194,7 +224,22 @@ export function HealthKitMetricPage({
|
||||
// stat strip reads it back for this page's single series.
|
||||
const { statsByType, onVisibleStats } = useChartDomainStats();
|
||||
|
||||
const rawSummary = analytics?.summaries?.[measurementType] ?? null;
|
||||
// v1.17.0 — fallback-type swap. When the primary type has no rows but the
|
||||
// declared fallback does (HRV: SDNN empty, RMSSD present), key the chart,
|
||||
// stat strip, and "all values" list off the fallback so a ring / strap
|
||||
// user's stored HRV renders instead of an empty state. The swap only
|
||||
// triggers once analytics has loaded and the primary is genuinely empty.
|
||||
const primaryCount = analytics?.summaries?.[measurementType]?.count ?? 0;
|
||||
const fallbackCount = fallbackMeasurementType
|
||||
? (analytics?.summaries?.[fallbackMeasurementType]?.count ?? 0)
|
||||
: 0;
|
||||
const usingFallback =
|
||||
primaryCount === 0 && fallbackCount > 0 && !!fallbackMeasurementType;
|
||||
const effectiveType = usingFallback
|
||||
? (fallbackMeasurementType as string)
|
||||
: measurementType;
|
||||
|
||||
const rawSummary = analytics?.summaries?.[effectiveType] ?? null;
|
||||
// The stat strip renders display-unit values. The summary holds stored
|
||||
// values, so when the page renders a scaled unit (e.g. WALKING_SPEED
|
||||
// stores m/s but displays km/h via `valueScale`), fold the same scale
|
||||
@@ -306,24 +351,28 @@ export function HealthKitMetricPage({
|
||||
fractionDigits={statFractionDigits}
|
||||
seriesLabel={title}
|
||||
icon={statIcon}
|
||||
windowStats={statsByType?.[measurementType] ?? null}
|
||||
windowStats={statsByType?.[effectiveType] ?? null}
|
||||
medianLabel={statMedianLabel}
|
||||
/>
|
||||
}
|
||||
diversityNudge={
|
||||
<MeasurementDiversityNudge
|
||||
measurementType={measurementType}
|
||||
measurementType={effectiveType}
|
||||
metricLabel={title}
|
||||
timeZone={user?.timezone ?? undefined}
|
||||
/>
|
||||
}
|
||||
coachLaunch
|
||||
showAllValuesType={measurementType}
|
||||
showAllValuesType={effectiveType}
|
||||
>
|
||||
<HealthChartDynamic
|
||||
chartKey={chartKey}
|
||||
types={[measurementType]}
|
||||
title={t(`${i18nPrefix}.chartTitle`)}
|
||||
types={[effectiveType]}
|
||||
title={
|
||||
usingFallback && fallbackMeasureLabel
|
||||
? `${t(`${i18nPrefix}.chartTitle`)} · ${fallbackMeasureLabel}`
|
||||
: t(`${i18nPrefix}.chartTitle`)
|
||||
}
|
||||
titleIcon={statIcon}
|
||||
colors={[color]}
|
||||
unit={unit}
|
||||
@@ -337,6 +386,9 @@ export function HealthKitMetricPage({
|
||||
{targetSummarySlug ? (
|
||||
<MetricTargetSummary slug={targetSummarySlug} />
|
||||
) : null}
|
||||
{/* v1.17.0 — page-specific extra block (blood glucose: the clinical
|
||||
panel). Mounted on the data-bearing branch only. */}
|
||||
{afterChart}
|
||||
{forecast && isTrajectoryType(measurementType) ? (
|
||||
<TrajectoryForecastCard
|
||||
type={measurementType}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { SleepDebtCard } from "../sleep-debt-card";
|
||||
import { ChronotypeCard } from "../chronotype-card";
|
||||
import type { SleepDebtDto, ChronotypeDto } from "../use-sleep-rhythm";
|
||||
|
||||
/**
|
||||
* v1.17.0 — sleep-debt + chronotype card rendering, with a focus on the calm
|
||||
* NOT-YET-READY states (partial / learning): both must show a "still learning"
|
||||
* nudge and NEVER assert a debt total or a chronotype band off thin data.
|
||||
*/
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const PARTIAL_DEBT: SleepDebtDto = {
|
||||
state: "partial",
|
||||
debtMinutes: 0,
|
||||
needMinutes: 420,
|
||||
nightsCounted: 3,
|
||||
windowNights: 14,
|
||||
nightsUntilReady: 4,
|
||||
};
|
||||
|
||||
const READY_DEBT: SleepDebtDto = {
|
||||
state: "ready",
|
||||
debtMinutes: 600,
|
||||
needMinutes: 420,
|
||||
nightsCounted: 10,
|
||||
windowNights: 14,
|
||||
nightsUntilReady: 0,
|
||||
};
|
||||
|
||||
const LEARNING_CHRONO: ChronotypeDto = {
|
||||
state: "learning",
|
||||
msfMinutes: null,
|
||||
msfScMinutes: null,
|
||||
band: null,
|
||||
socialJetlagMinutes: null,
|
||||
freeNightsCounted: 1,
|
||||
workNightsCounted: 5,
|
||||
freeNightsUntilReady: 2,
|
||||
};
|
||||
|
||||
const READY_CHRONO: ChronotypeDto = {
|
||||
state: "ready",
|
||||
msfMinutes: 4 * 60 + 30, // 04:30
|
||||
msfScMinutes: 4 * 60, // 04:00
|
||||
band: "intermediate",
|
||||
socialJetlagMinutes: 75,
|
||||
freeNightsCounted: 6,
|
||||
workNightsCounted: 14,
|
||||
freeNightsUntilReady: 0,
|
||||
};
|
||||
|
||||
describe("<SleepDebtCard>", () => {
|
||||
it("shows the learning nudge in the partial state, not a debt total", () => {
|
||||
const html = render(<SleepDebtCard debt={PARTIAL_DEBT} />);
|
||||
expect(html).toContain("Still learning your sleep");
|
||||
expect(html).toContain("3 nights");
|
||||
// No asserted "short" figure while partial.
|
||||
expect(html).not.toContain("short");
|
||||
});
|
||||
|
||||
it("asserts the cumulative debt in the ready state", () => {
|
||||
const html = render(<SleepDebtCard debt={READY_DEBT} />);
|
||||
// 600 min = 10h 0m short.
|
||||
expect(html).toContain("10h 0m short");
|
||||
expect(html).toContain("14 nights");
|
||||
});
|
||||
|
||||
it("inherits the standard Card rhythm (no compact override)", () => {
|
||||
const partial = render(<SleepDebtCard debt={PARTIAL_DEBT} />);
|
||||
const ready = render(<SleepDebtCard debt={READY_DEBT} />);
|
||||
for (const html of [partial, ready]) {
|
||||
expect(html).not.toContain("gap-2 py-4 md:gap-2 md:py-4");
|
||||
expect(html).toContain("md:py-6");
|
||||
// Semantic info token, not the raw Dracula cyan.
|
||||
expect(html).toContain("text-info");
|
||||
expect(html).not.toContain("text-dracula-cyan");
|
||||
}
|
||||
});
|
||||
|
||||
it("shows the caught-up state at zero debt", () => {
|
||||
const html = render(
|
||||
<SleepDebtCard debt={{ ...READY_DEBT, debtMinutes: 0 }} />,
|
||||
);
|
||||
expect(html).toContain("All caught up");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<ChronotypeCard>", () => {
|
||||
it("shows the learning state and asserts NO band off thin data", () => {
|
||||
const html = render(<ChronotypeCard chronotype={LEARNING_CHRONO} />);
|
||||
expect(html).toContain("Still learning your rhythm");
|
||||
expect(html).toContain("1 of 3 free nights");
|
||||
// No band label leaks in the learning state — none of the five MCTQ
|
||||
// class labels appears.
|
||||
expect(html).not.toContain("Intermediate type");
|
||||
expect(html).not.toContain("Morning type");
|
||||
expect(html).not.toContain("Evening type");
|
||||
});
|
||||
|
||||
it("shows the band + mid-sleep clock in the ready state", () => {
|
||||
const html = render(<ChronotypeCard chronotype={READY_CHRONO} />);
|
||||
expect(html).toContain("Intermediate type");
|
||||
expect(html).toContain("04:30");
|
||||
});
|
||||
|
||||
it("inherits the standard Card rhythm (no compact override)", () => {
|
||||
const learning = render(<ChronotypeCard chronotype={LEARNING_CHRONO} />);
|
||||
const ready = render(<ChronotypeCard chronotype={READY_CHRONO} />);
|
||||
for (const html of [learning, ready]) {
|
||||
expect(html).not.toContain("gap-2 py-4 md:gap-2 md:py-4");
|
||||
expect(html).toContain("md:py-6");
|
||||
expect(html).toContain("text-info");
|
||||
expect(html).not.toContain("text-dracula-cyan");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps social jetlag + MSFsc behind the advanced disclosure (collapsed by default)", () => {
|
||||
const html = render(<ChronotypeCard chronotype={READY_CHRONO} />);
|
||||
// The toggle is present, the advanced detail is not rendered until opened.
|
||||
expect(html).toContain("Advanced");
|
||||
expect(html).not.toContain("social jetlag");
|
||||
expect(html).not.toContain("Corrected mid-sleep");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { SleepRhythmDto } from "../use-sleep-rhythm";
|
||||
|
||||
/**
|
||||
* v1.17.0 — sleep-rhythm SECTION states.
|
||||
*
|
||||
* The cards' own partial/learning copy is covered in `sleep-rhythm-cards`;
|
||||
* this file pins the section wrapper's three states, mirroring the glucose
|
||||
* clinical panel's treatment:
|
||||
* - a query error renders a quiet inline notice, NOT an endless skeleton
|
||||
* - loading (and settled-but-undefined) shows the skeletons
|
||||
* - a settled DTO renders both cards
|
||||
* `useAuth` + `useSleepRhythm` are mocked so each branch is deterministic.
|
||||
*/
|
||||
|
||||
const authMock = vi.fn();
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => authMock(),
|
||||
}));
|
||||
|
||||
const rhythmMock = vi.fn();
|
||||
vi.mock("../use-sleep-rhythm", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../use-sleep-rhythm")>();
|
||||
return { ...actual, useSleepRhythm: () => rhythmMock() };
|
||||
});
|
||||
|
||||
// Import after the mocks are registered.
|
||||
const { SleepRhythmSection } = await import("../sleep-rhythm-section");
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const READY_DTO: SleepRhythmDto = {
|
||||
sleepDebt: {
|
||||
state: "ready",
|
||||
debtMinutes: 600,
|
||||
needMinutes: 420,
|
||||
nightsCounted: 10,
|
||||
windowNights: 14,
|
||||
nightsUntilReady: 0,
|
||||
},
|
||||
chronotype: {
|
||||
state: "ready",
|
||||
msfMinutes: 4 * 60 + 30,
|
||||
msfScMinutes: 4 * 60,
|
||||
band: "intermediate",
|
||||
socialJetlagMinutes: 75,
|
||||
freeNightsCounted: 6,
|
||||
workNightsCounted: 14,
|
||||
freeNightsUntilReady: 0,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
authMock.mockReturnValue({ isAuthenticated: true });
|
||||
rhythmMock.mockReset();
|
||||
});
|
||||
|
||||
describe("<SleepRhythmSection>", () => {
|
||||
it("renders the quiet inline notice on a query error, not an infinite skeleton", () => {
|
||||
rhythmMock.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
});
|
||||
const html = render(<SleepRhythmSection enabled />);
|
||||
expect(html).toContain('data-slot="sleep-rhythm-error"');
|
||||
expect(html).toContain("Your sleep rhythm");
|
||||
// No skeletons while errored.
|
||||
expect(html).not.toContain('data-slot="sleep-rhythm-loading"');
|
||||
});
|
||||
|
||||
it("shows the skeletons while loading", () => {
|
||||
rhythmMock.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
});
|
||||
const html = render(<SleepRhythmSection enabled />);
|
||||
expect(html).toContain('data-slot="sleep-rhythm-loading"');
|
||||
expect(html).not.toContain('data-slot="sleep-rhythm-error"');
|
||||
});
|
||||
|
||||
it("renders both cards on a settled DTO with the standard Card rhythm", () => {
|
||||
rhythmMock.mockReturnValue({
|
||||
data: READY_DTO,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
const html = render(<SleepRhythmSection enabled />);
|
||||
expect(html).toContain("Intermediate type");
|
||||
expect(html).toContain("10h 0m short");
|
||||
// The cards inherit the house Card rhythm (gap-4 py-4 md:gap-6 md:py-6) —
|
||||
// the old compact override must NOT reappear.
|
||||
expect(html).not.toContain("gap-2 py-4 md:gap-2 md:py-4");
|
||||
expect(html).toContain("md:py-6");
|
||||
});
|
||||
|
||||
it("renders nothing when disabled", () => {
|
||||
rhythmMock.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
const html = render(<SleepRhythmSection enabled={false} />);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Clock, ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { ChronotypeDto } from "./use-sleep-rhythm";
|
||||
|
||||
/** Minutes-of-day → "HH:MM" wall-clock label (24 h, zero-padded). */
|
||||
function clockLabel(minutesOfDay: number): string {
|
||||
const wrapped = ((Math.round(minutesOfDay) % 1440) + 1440) % 1440;
|
||||
const h = Math.floor(wrapped / 60);
|
||||
const m = wrapped - h * 60;
|
||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** Whole hours + minutes from a minute total, for the social-jetlag readout. */
|
||||
function hoursMinutes(totalMinutes: number): { hours: number; minutes: number } {
|
||||
const t = Math.round(totalMinutes);
|
||||
return { hours: Math.floor(t / 60), minutes: t - Math.floor(t / 60) * 60 };
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.17.0 — chronotype card (MCTQ MSF / MSFsc band + social jetlag).
|
||||
*
|
||||
* Progressive disclosure: the band + mid-sleep clock time show by default; the
|
||||
* social-jetlag detail + the sleep-debt-corrected MSFsc sit behind an
|
||||
* "advanced" toggle. While `learning` it shows "still learning your rhythm —
|
||||
* N of M nights" and asserts NO type. Warm, grounded copy; mobile-first.
|
||||
*/
|
||||
export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) {
|
||||
const { t } = useTranslations();
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
if (chronotype.state === "learning" || chronotype.band == null) {
|
||||
const need =
|
||||
chronotype.freeNightsCounted + chronotype.freeNightsUntilReady;
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="text-info h-4 w-4" />
|
||||
<CardTitle className="text-base font-semibold">
|
||||
{t("insights.sleep.chronotype.title")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.sleep.chronotype.learning", {
|
||||
counted: chronotype.freeNightsCounted,
|
||||
total: need,
|
||||
})}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const bandLabel = t(`insights.sleep.chronotype.band.${chronotype.band}`);
|
||||
const msfClock =
|
||||
chronotype.msfMinutes != null ? clockLabel(chronotype.msfMinutes) : null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="text-info h-4 w-4" />
|
||||
<CardTitle className="text-base font-semibold">
|
||||
{t("insights.sleep.chronotype.title")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Badge variant="secondary" className="text-sm">
|
||||
{bandLabel}
|
||||
</Badge>
|
||||
{msfClock && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.sleep.chronotype.midSleepCaption", {
|
||||
clock: msfClock,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
aria-expanded={showAdvanced}
|
||||
className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs font-medium transition-colors"
|
||||
>
|
||||
{showAdvanced ? (
|
||||
<ChevronUp className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("insights.sleep.chronotype.advancedToggle")}
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
{chronotype.socialJetlagMinutes != null &&
|
||||
(() => {
|
||||
const { hours, minutes } = hoursMinutes(
|
||||
chronotype.socialJetlagMinutes,
|
||||
);
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-medium">
|
||||
{t("insights.sleep.chronotype.socialJetlagValue", {
|
||||
hours,
|
||||
minutes,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.sleep.chronotype.socialJetlagCaption")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{chronotype.msfScMinutes != null && (
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-medium tabular-nums">
|
||||
{t("insights.sleep.chronotype.msfScValue", {
|
||||
clock: clockLabel(chronotype.msfScMinutes),
|
||||
})}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.sleep.chronotype.msfScCaption")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.sleep.chronotype.dayTypeNote")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { Moon } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { SleepDebtDto } from "./use-sleep-rhythm";
|
||||
|
||||
/** Whole hours + minutes from a minute total, for the headline figure. */
|
||||
function hoursMinutes(totalMinutes: number): { hours: number; minutes: number } {
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = Math.round(totalMinutes - hours * 60);
|
||||
return { hours, minutes };
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.17.0 — sleep-debt headline.
|
||||
*
|
||||
* The cumulative shortfall between sleep need and sleep obtained over the
|
||||
* rolling window. Under the night threshold it shows the calm `partial`
|
||||
* state ("still learning — N more nights") and never asserts a total off thin
|
||||
* data. Mobile-first: a single readable figure + one line of grounded context.
|
||||
*/
|
||||
export function SleepDebtCard({ debt }: { debt: SleepDebtDto }) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
if (debt.state === "partial") {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Moon className="text-info h-4 w-4" />
|
||||
<CardTitle className="text-base font-semibold">
|
||||
{t("insights.sleep.debt.title")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.sleep.debt.learning", {
|
||||
counted: debt.nightsCounted,
|
||||
remaining: debt.nightsUntilReady,
|
||||
})}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const { hours, minutes } = hoursMinutes(debt.debtMinutes);
|
||||
const isClear = debt.debtMinutes === 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Moon className="text-info h-4 w-4" />
|
||||
<CardTitle className="text-base font-semibold">
|
||||
{t("insights.sleep.debt.title")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-2xl font-semibold tabular-nums">
|
||||
{isClear
|
||||
? t("insights.sleep.debt.clearValue")
|
||||
: t("insights.sleep.debt.value", { hours, minutes })}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{isClear
|
||||
? t("insights.sleep.debt.clearCaption", {
|
||||
nights: debt.windowNights,
|
||||
})
|
||||
: t("insights.sleep.debt.caption", {
|
||||
nights: debt.windowNights,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useSleepRhythm } from "./use-sleep-rhythm";
|
||||
import { SleepDebtCard } from "./sleep-debt-card";
|
||||
import { ChronotypeCard } from "./chronotype-card";
|
||||
|
||||
/**
|
||||
* v1.17.0 — sleep-rhythm section on the Sleep page.
|
||||
*
|
||||
* Reads the server-authoritative sleep-debt + chronotype DTO and renders the
|
||||
* sleep-debt headline + the chronotype card. Gated on `enabled` so a
|
||||
* source-less account never fetches. The values are identical to what the iOS
|
||||
* client renders (one server computation).
|
||||
*
|
||||
* States, mirroring the glucose clinical panel's treatment:
|
||||
* - loading → skeletons (distinct from a settled-but-empty read)
|
||||
* - error → a quiet inline notice, never an endless skeleton
|
||||
* - settled → the two cards, which carry their OWN calm "still learning" /
|
||||
* source-cue copy for the no-nights-yet case (the route always
|
||||
* returns a full DTO, so emptiness surfaces inside the cards).
|
||||
*/
|
||||
export function SleepRhythmSection({ enabled }: { enabled: boolean }) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading, isError } = useSleepRhythm(
|
||||
isAuthenticated && enabled,
|
||||
);
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sleep-rhythm-error"
|
||||
role="status"
|
||||
className="bg-card border-border text-muted-foreground rounded-xl border p-4 text-sm"
|
||||
>
|
||||
{t("insights.sleep.rhythm.loadError")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<div className="space-y-4" data-slot="sleep-rhythm-loading">
|
||||
<Skeleton className="h-28 w-full rounded-xl" />
|
||||
<Skeleton className="h-28 w-full rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SleepDebtCard debt={data.sleepDebt} />
|
||||
<ChronotypeCard chronotype={data.chronotype} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
// v1.17.0 — the wire shape IS the server DTO. Import the type directly (erased
|
||||
// at build, so no server code reaches the client bundle) rather than keeping a
|
||||
// second hand-mirrored copy that silently drifts when the server shape changes.
|
||||
import type {
|
||||
SleepDebtDto,
|
||||
ChronotypeDto,
|
||||
SleepRhythmDto,
|
||||
} from "@/lib/insights/derived/sleep-rhythm";
|
||||
|
||||
export type { SleepDebtDto, ChronotypeDto, SleepRhythmDto };
|
||||
|
||||
/**
|
||||
* Read the server-authoritative sleep-rhythm DTO. Gated on `enabled` so a
|
||||
* source-less account never fetches; shares the measurement-write
|
||||
* invalidation prefix (`["sleep-rhythm"]`).
|
||||
*/
|
||||
export function useSleepRhythm(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.sleepRhythm(),
|
||||
queryFn: () => apiGet<SleepRhythmDto>("/api/sleep/rhythm"),
|
||||
enabled,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
@@ -6,8 +6,12 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
cadenceIntervalDays,
|
||||
classifyLowStockState,
|
||||
estimateDailyDoseCount,
|
||||
estimateRunwayDays,
|
||||
lowStockTriggerDays,
|
||||
supplyRunwayDates,
|
||||
type RunwaySchedule,
|
||||
} from "@/components/medications/detail/supply-runway";
|
||||
|
||||
@@ -130,3 +134,96 @@ describe("estimateRunwayDays — v1.16.10 multi-unit doses", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cadenceIntervalDays — v1.17.0", () => {
|
||||
it("is ≈1 for a daily med", () => {
|
||||
expect(cadenceIntervalDays([schedule({ timesOfDay: ["08:00"] })])).toBe(1);
|
||||
});
|
||||
|
||||
it("is ≈7 for a weekly injection", () => {
|
||||
expect(
|
||||
cadenceIntervalDays([
|
||||
schedule({ timesOfDay: ["09:00"], rollingIntervalDays: 7 }),
|
||||
]),
|
||||
).toBe(7);
|
||||
});
|
||||
|
||||
it("is null for a schedule-less (as-needed) medication", () => {
|
||||
expect(cadenceIntervalDays([])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("lowStockTriggerDays — v1.17.0", () => {
|
||||
const daily = [schedule({ timesOfDay: ["08:00"] })];
|
||||
const weekly = [schedule({ timesOfDay: ["09:00"], rollingIntervalDays: 7 })];
|
||||
|
||||
it("keeps a daily med with no lead at the bare floor (never shrinks)", () => {
|
||||
expect(
|
||||
lowStockTriggerDays({ lowStockRunwayDays: 7, leadDays: 0, schedules: daily }),
|
||||
).toBe(7);
|
||||
});
|
||||
|
||||
it("widens a weekly med to cover the reorder lead PLUS one dose-interval", () => {
|
||||
// max(7, 10 lead + 7 interval) = 17 → fires ~10 days before the last dose.
|
||||
expect(
|
||||
lowStockTriggerDays({ lowStockRunwayDays: 7, leadDays: 10, schedules: weekly }),
|
||||
).toBe(17);
|
||||
});
|
||||
|
||||
it("never drops below the user floor even when lead + cadence are small", () => {
|
||||
expect(
|
||||
lowStockTriggerDays({ lowStockRunwayDays: 14, leadDays: 2, schedules: daily }),
|
||||
).toBe(14);
|
||||
});
|
||||
|
||||
it("falls back to the bare floor for a schedule-less medication", () => {
|
||||
expect(
|
||||
lowStockTriggerDays({ lowStockRunwayDays: 7, leadDays: 10, schedules: [] }),
|
||||
).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyLowStockState — v1.17.0", () => {
|
||||
const weekly = [schedule({ timesOfDay: ["09:00"], rollingIntervalDays: 7 })];
|
||||
|
||||
it("returns null when comfortably above the trigger", () => {
|
||||
expect(
|
||||
classifyLowStockState({ runwayDays: 20, triggerDays: 17, schedules: weekly }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("flags running_low within the trigger but above one cadence interval", () => {
|
||||
expect(
|
||||
classifyLowStockState({ runwayDays: 14, triggerDays: 17, schedules: weekly }),
|
||||
).toBe("running_low");
|
||||
});
|
||||
|
||||
it("flags last_dose at one cadence interval", () => {
|
||||
expect(
|
||||
classifyLowStockState({ runwayDays: 7, triggerDays: 17, schedules: weekly }),
|
||||
).toBe("last_dose");
|
||||
});
|
||||
});
|
||||
|
||||
describe("supplyRunwayDates — v1.17.0", () => {
|
||||
const today = new Date(Date.UTC(2026, 5, 1)); // 1 Jun 2026
|
||||
|
||||
it("computes runsOutOn = today + runway and reorderBy = runsOutOn − lead", () => {
|
||||
const { runsOutOn, reorderBy } = supplyRunwayDates({
|
||||
today,
|
||||
runwayDays: 14,
|
||||
leadDays: 10,
|
||||
});
|
||||
expect(runsOutOn.toISOString().slice(0, 10)).toBe("2026-06-15");
|
||||
expect(reorderBy.toISOString().slice(0, 10)).toBe("2026-06-05");
|
||||
});
|
||||
|
||||
it("clamps reorderBy to today when the lead pushes it into the past", () => {
|
||||
const { reorderBy } = supplyRunwayDates({
|
||||
today,
|
||||
runwayDays: 3,
|
||||
leadDays: 10,
|
||||
});
|
||||
expect(reorderBy.toISOString().slice(0, 10)).toBe("2026-06-01");
|
||||
});
|
||||
});
|
||||
@@ -67,3 +67,90 @@ export function estimateRunwayDays(
|
||||
if (perDay <= 0) return null;
|
||||
return Math.floor(dosesRemaining / perDay);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.17.0 — one dose-interval in whole days for a cadence: the inverse
|
||||
* of the daily-dose rate, rounded up. ≈1 for a daily med, ≈7 for a
|
||||
* weekly injection, ≈30 for a monthly RRULE. Returns `null` for a
|
||||
* schedule with no derivable consumption (as-needed / empty).
|
||||
*/
|
||||
export function cadenceIntervalDays(schedules: RunwaySchedule[]): number | null {
|
||||
const perDay = estimateDailyDoseCount(schedules);
|
||||
if (perDay <= 0) return null;
|
||||
return Math.ceil(1 / perDay);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.17.0 — reorder-lead-aware low-stock trigger threshold in days.
|
||||
*
|
||||
* The bare `lowStockRunwayDays` floor fired the alert only when the
|
||||
* runway dropped below it; for a sparse cadence (a weekly injection's
|
||||
* 7-day floor ≈ one dose-interval) that warning landed with ~1 dose
|
||||
* left — too late to reorder. The effective trigger widens the floor to
|
||||
* cover the reorder lead PLUS one dose-interval:
|
||||
*
|
||||
* trigger = max(lowStockRunwayDays, leadDays + cadenceIntervalDays)
|
||||
*
|
||||
* so the alert lands before the LAST dose for any cadence. Because the
|
||||
* result is `max(...)` over the user's own floor, it NEVER shrinks
|
||||
* anyone's current threshold — only widens it when the lead + cadence
|
||||
* demand more headroom. A schedule with no derivable cadence falls back
|
||||
* to the bare floor (it has no runway anyway, so it never fires).
|
||||
*/
|
||||
export function lowStockTriggerDays(input: {
|
||||
lowStockRunwayDays: number;
|
||||
leadDays: number;
|
||||
schedules: RunwaySchedule[];
|
||||
}): number {
|
||||
const interval = cadenceIntervalDays(input.schedules);
|
||||
if (interval === null) return input.lowStockRunwayDays;
|
||||
return Math.max(input.lowStockRunwayDays, input.leadDays + interval);
|
||||
}
|
||||
|
||||
/** v1.17.0 — informational vs actionable low-stock presentation state. */
|
||||
export type LowStockState = "running_low" | "last_dose";
|
||||
|
||||
/**
|
||||
* v1.17.0 — classify a runway against its cadence + trigger for the
|
||||
* card / push copy. Returns `null` when the supply is comfortably above
|
||||
* the trigger (no notice). `"last_dose"` when the runway is down to one
|
||||
* dose-interval (informational — about to take the final dose);
|
||||
* `"running_low"` otherwise within the trigger (actionable — reorder
|
||||
* now). `last_dose` wins when both apply so the final-dose case reads
|
||||
* as the calmer, more specific line.
|
||||
*/
|
||||
export function classifyLowStockState(input: {
|
||||
runwayDays: number;
|
||||
triggerDays: number;
|
||||
schedules: RunwaySchedule[];
|
||||
}): LowStockState | null {
|
||||
if (input.runwayDays > input.triggerDays) return null;
|
||||
const interval = cadenceIntervalDays(input.schedules);
|
||||
if (interval !== null && input.runwayDays <= interval) return "last_dose";
|
||||
return "running_low";
|
||||
}
|
||||
|
||||
/** A calendar date as a UTC-midnight `Date` — the copy renders day-only. */
|
||||
function addDaysUtc(base: Date, days: number): Date {
|
||||
const d = new Date(base);
|
||||
d.setUTCDate(d.getUTCDate() + days);
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.17.0 — the two concrete dates the low-stock copy surfaces:
|
||||
* runsOutOn = today + runwayDays (supply depletes)
|
||||
* reorderBy = runsOutOn − leadDays (order by this date to avoid a gap)
|
||||
* Both are clamped to never precede `today` (a deep low-stock state can
|
||||
* push `reorderBy` into the past — surface today instead of a past date).
|
||||
*/
|
||||
export function supplyRunwayDates(input: {
|
||||
today: Date;
|
||||
runwayDays: number;
|
||||
leadDays: number;
|
||||
}): { runsOutOn: Date; reorderBy: Date } {
|
||||
const runsOutOn = addDaysUtc(input.today, Math.max(0, input.runwayDays));
|
||||
const reorderRaw = addDaysUtc(runsOutOn, -input.leadDays);
|
||||
const reorderBy = reorderRaw < input.today ? new Date(input.today) : reorderRaw;
|
||||
return { runsOutOn, reorderBy };
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts
|
||||
import {
|
||||
estimateDailyDoseCount,
|
||||
estimateRunwayDays,
|
||||
lowStockTriggerDays,
|
||||
} from "@/components/medications/detail/supply-runway";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMedicationComplianceSummary } from "@/lib/queries/use-medication-compliance-summary";
|
||||
@@ -117,6 +118,8 @@ export interface Glp1Medication {
|
||||
nextDueOverdue?: boolean;
|
||||
/** v1.16.10 — dose-derived stock from the list payload; null = inventory tracking off. */
|
||||
stockDosesRemaining?: number | null;
|
||||
/** v1.17.0 — per-medication reorder lead override (days); null = inherit the user default. */
|
||||
reorderLeadDays?: number | null;
|
||||
schedules: ScheduleLite[];
|
||||
}
|
||||
|
||||
@@ -225,6 +228,7 @@ export function Glp1MedicationCard({
|
||||
lateMinutes: number;
|
||||
missedMinutes: number;
|
||||
lowStockRunwayDays: number | null;
|
||||
reorderLeadDays?: number;
|
||||
}>("/api/settings/reminder-thresholds");
|
||||
} catch {
|
||||
return null;
|
||||
@@ -339,13 +343,18 @@ export function Glp1MedicationCard({
|
||||
timeZone: userTz,
|
||||
});
|
||||
|
||||
// v1.16.11 — low-stock card context, identical to the generic card:
|
||||
// runway days from the list payload's dose-derived stock, surfaced
|
||||
// ONLY below the user's runway threshold (`lowStockRunwayDays`,
|
||||
// default 7, null = alert off → no card line). Stock 0 with a
|
||||
// consuming schedule is runway 0 (mirrors `evaluateMedicationRunway`).
|
||||
const lowStockThreshold =
|
||||
// v1.16.11 / v1.17.0 — low-stock card context, identical to the generic
|
||||
// card: runway days from the list payload's dose-derived stock, surfaced
|
||||
// ONLY at runway ≤ the reorder-lead-aware trigger
|
||||
// (max(lowStockRunwayDays, lead + cadenceIntervalDays)), so the weekly
|
||||
// GLP-1 cadence is warned before its last dose. Stock 0 with a consuming
|
||||
// schedule is runway 0 (mirrors `evaluateMedicationRunway`).
|
||||
const lowStockFloor =
|
||||
thresholds == null ? 7 : thresholds.lowStockRunwayDays;
|
||||
const leadDays =
|
||||
medication.reorderLeadDays != null
|
||||
? medication.reorderLeadDays
|
||||
: (thresholds?.reorderLeadDays ?? 10);
|
||||
const stockRunwayDays =
|
||||
medication.stockDosesRemaining == null
|
||||
? null
|
||||
@@ -357,10 +366,18 @@ export function Glp1MedicationCard({
|
||||
: estimateDailyDoseCount(medication.schedules) > 0
|
||||
? 0
|
||||
: null;
|
||||
const lowStockTrigger =
|
||||
lowStockFloor === null
|
||||
? null
|
||||
: lowStockTriggerDays({
|
||||
lowStockRunwayDays: lowStockFloor,
|
||||
leadDays,
|
||||
schedules: medication.schedules,
|
||||
});
|
||||
const lowStockRunwayDays =
|
||||
stockRunwayDays !== null &&
|
||||
lowStockThreshold !== null &&
|
||||
stockRunwayDays < lowStockThreshold
|
||||
lowStockTrigger !== null &&
|
||||
stockRunwayDays <= lowStockTrigger
|
||||
? stockRunwayDays
|
||||
: null;
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts
|
||||
import {
|
||||
estimateDailyDoseCount,
|
||||
estimateRunwayDays,
|
||||
lowStockTriggerDays,
|
||||
} from "@/components/medications/detail/supply-runway";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import {
|
||||
@@ -101,6 +102,12 @@ interface Medication {
|
||||
asNeeded?: boolean;
|
||||
/** v1.16.10 — dose-derived stock from the list payload; null = inventory tracking off. */
|
||||
stockDosesRemaining?: number | null;
|
||||
/**
|
||||
* v1.17.0 — optional per-medication reorder lead override (days); null /
|
||||
* absent = inherit the user-level reorderLeadDays default. Widens the
|
||||
* low-stock trigger so the notice lands before the last dose.
|
||||
*/
|
||||
reorderLeadDays?: number | null;
|
||||
schedules: Schedule[];
|
||||
}
|
||||
|
||||
@@ -171,6 +178,7 @@ export function MedicationCard({
|
||||
lateMinutes: number;
|
||||
missedMinutes: number;
|
||||
lowStockRunwayDays: number | null;
|
||||
reorderLeadDays?: number;
|
||||
}>("/api/settings/reminder-thresholds");
|
||||
} catch {
|
||||
return null;
|
||||
@@ -282,8 +290,18 @@ export function MedicationCard({
|
||||
// alert off → no card line either). Stock 0 with a consuming schedule
|
||||
// is runway 0 (mirrors `evaluateMedicationRunway`); as-needed has no
|
||||
// runway, ever.
|
||||
const lowStockThreshold =
|
||||
// v1.17.0 — the trigger is reorder-lead-aware: max(floor, lead +
|
||||
// cadenceIntervalDays). The bare floor (`lowStockRunwayDays`) and the
|
||||
// user-level lead default come from the thresholds endpoint; a per-med
|
||||
// `reorderLeadDays` overrides the default. The notice lights at runway
|
||||
// ≤ trigger (matching the daily cron), so a weekly med is warned before
|
||||
// its last dose, not at it.
|
||||
const lowStockFloor =
|
||||
thresholds == null ? 7 : thresholds.lowStockRunwayDays;
|
||||
const leadDays =
|
||||
medication.reorderLeadDays != null
|
||||
? medication.reorderLeadDays
|
||||
: (thresholds?.reorderLeadDays ?? 10);
|
||||
const stockRunwayDays =
|
||||
medication.asNeeded || medication.stockDosesRemaining == null
|
||||
? null
|
||||
@@ -295,10 +313,18 @@ export function MedicationCard({
|
||||
: estimateDailyDoseCount(medication.schedules) > 0
|
||||
? 0
|
||||
: null;
|
||||
const lowStockTrigger =
|
||||
lowStockFloor === null
|
||||
? null
|
||||
: lowStockTriggerDays({
|
||||
lowStockRunwayDays: lowStockFloor,
|
||||
leadDays,
|
||||
schedules: medication.schedules,
|
||||
});
|
||||
const lowStockRunwayDays =
|
||||
stockRunwayDays !== null &&
|
||||
lowStockThreshold !== null &&
|
||||
stockRunwayDays < lowStockThreshold
|
||||
lowStockTrigger !== null &&
|
||||
stockRunwayDays <= lowStockTrigger
|
||||
? stockRunwayDays
|
||||
: null;
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { promoteRecentTags, selectQuickTagKeys } from "../recent-tags";
|
||||
|
||||
describe("promoteRecentTags", () => {
|
||||
it("prepends a freshly-used key, most-recent first", () => {
|
||||
expect(promoteRecentTags(["a", "b"], ["c"])).toEqual(["c", "a", "b"]);
|
||||
});
|
||||
|
||||
it("moves an already-present key to the front without duplicating", () => {
|
||||
expect(promoteRecentTags(["a", "b", "c"], ["b"])).toEqual([
|
||||
"b",
|
||||
"a",
|
||||
"c",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the latest of a multi-key use at the front", () => {
|
||||
// The last key in `usedKeys` is the most recent → leads the list.
|
||||
expect(promoteRecentTags([], ["x", "y", "z"])).toEqual(["z", "y", "x"]);
|
||||
});
|
||||
|
||||
it("de-duplicates within the used keys", () => {
|
||||
expect(promoteRecentTags(["a"], ["a", "a"])).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("drops empty keys", () => {
|
||||
expect(promoteRecentTags(["a"], ["", "b"])).toEqual(["b", "a"]);
|
||||
});
|
||||
|
||||
it("caps the history length", () => {
|
||||
const existing = Array.from({ length: 30 }, (_, i) => `k${i}`);
|
||||
const result = promoteRecentTags(existing, ["new"]);
|
||||
expect(result.length).toBe(24);
|
||||
expect(result[0]).toBe("new");
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectQuickTagKeys", () => {
|
||||
const catalog = ["a", "b", "c", "d", "e"];
|
||||
|
||||
it("returns the MRU keys that still exist in the catalog", () => {
|
||||
expect(selectQuickTagKeys(["c", "a"], catalog, 3)).toEqual([
|
||||
"c",
|
||||
"a",
|
||||
"b",
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops MRU keys no longer in the catalog", () => {
|
||||
expect(selectQuickTagKeys(["gone", "b"], catalog, 2)).toEqual([
|
||||
"b",
|
||||
"a",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to the first catalog keys with no history", () => {
|
||||
expect(selectQuickTagKeys([], catalog, 3)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("respects the limit", () => {
|
||||
expect(selectQuickTagKeys(["e", "d", "c"], catalog, 2)).toEqual([
|
||||
"e",
|
||||
"d",
|
||||
]);
|
||||
});
|
||||
|
||||
it("never duplicates when topping up from the catalog", () => {
|
||||
const result = selectQuickTagKeys(["b"], catalog, 5);
|
||||
expect(new Set(result).size).toBe(result.length);
|
||||
expect(result[0]).toBe("b");
|
||||
});
|
||||
});
|
||||
+198
-135
@@ -24,13 +24,27 @@ import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { DateTimeInput } from "@/components/ui/date-input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Loader2, MoreHorizontal, Plus, RotateCcw } from "lucide-react";
|
||||
import {
|
||||
ListChecks,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
SlidersHorizontal,
|
||||
StickyNote,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
|
||||
import { invalidateKeys, moodDependentKeys } from "@/lib/query-keys";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
SheetSection,
|
||||
SheetSectionCount,
|
||||
} from "@/components/ui/sheet-section";
|
||||
import { MoodTagPicker, type RatedFactor } from "./mood-tag-picker";
|
||||
import { MoodQuickTags } from "./mood-quick-tags";
|
||||
import { useRecentTags } from "./recent-tags";
|
||||
import { moodFaceIcon } from "./mood-tag-icons";
|
||||
|
||||
// v1.12.0 — best-on-the-left face order for the "How are you?" hero,
|
||||
@@ -116,6 +130,11 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
// fields the user actively fills count toward "dirty"; the timestamp
|
||||
// always carries an auto-populated default, so it is excluded.
|
||||
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
|
||||
// v1.17.0 — MRU tag history powers the always-open Quick row; tapping the
|
||||
// Quick row's "+" expands the full "More tags" section (controlled so the
|
||||
// chip can open it).
|
||||
const { recent, recordUse } = useRecentTags();
|
||||
const [moreTagsOpen, setMoreTagsOpen] = useState(false);
|
||||
const isDirty =
|
||||
mood !== "" ||
|
||||
tagsInput.trim() !== "" ||
|
||||
@@ -193,7 +212,11 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
moodLoggedAt: timestamp,
|
||||
});
|
||||
|
||||
// v1.17.0 — promote the saved structured tags to the Quick row's MRU
|
||||
// history so the next entry surfaces them as one-tap chips.
|
||||
if (tagKeys.length > 0) recordUse(tagKeys);
|
||||
resetForm();
|
||||
setMoreTagsOpen(false);
|
||||
await invalidateKeys(queryClient, moodDependentKeys);
|
||||
toast.success(t("common.saved"));
|
||||
onSuccess?.();
|
||||
@@ -259,6 +282,16 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
// primary input and a fast log is a single tap + Save.
|
||||
const moodPicked = mood !== "";
|
||||
|
||||
// v1.17.0 — the "Note & details" badge counts the free-text tags entered
|
||||
// plus the note (when present), so a collapsed section communicates what
|
||||
// it holds. The auto-populated timestamp is excluded (it always carries a
|
||||
// default, so it would never read as "empty").
|
||||
const freeTextTagCount = tagsInput
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean).length;
|
||||
const detailsCount = freeTextTagCount + (note.trim() ? 1 : 0);
|
||||
|
||||
return (
|
||||
<form id={formId} onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* v1.12.0 — "How are you?" 5-face hero. The five faces are a
|
||||
@@ -318,152 +351,182 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
</div>
|
||||
|
||||
{moodPicked && (
|
||||
<div
|
||||
className="space-y-4 border-t pt-4"
|
||||
data-slot="mood-annotate-panel"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mood-logged-at">{t("mood.timestamp")}</Label>
|
||||
<DateTimeInput
|
||||
id="mood-logged-at"
|
||||
value={moodLoggedAt}
|
||||
onChange={(e) => setMoodLoggedAt(e.target.value)}
|
||||
// v1.17 W1b — match the server bound: no future instant.
|
||||
max={getDefaultMoodLoggedAtValue()}
|
||||
required
|
||||
aria-required="true"
|
||||
aria-invalid={!!error || undefined}
|
||||
aria-describedby={errorDescriptor}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3" data-slot="mood-annotate-panel">
|
||||
{/* v1.17.0 — always-open Quick row: MRU one-tap tags + a "+" that
|
||||
expands the full "More tags" section. The common case stays a
|
||||
face + a couple of taps + Save. */}
|
||||
<MoodQuickTags
|
||||
selected={tagKeys}
|
||||
onToggle={toggleTagKey}
|
||||
recent={recent}
|
||||
onExpand={() => setMoreTagsOpen(true)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label htmlFor="tags">
|
||||
{t("mood.tags")}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
({t("common.optional")})
|
||||
</span>
|
||||
</Label>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("mood.tagsHelp")}
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
id="tags"
|
||||
value={tagsInput}
|
||||
onChange={(e) => setTagsInput(e.target.value)}
|
||||
placeholder={t("mood.tagsPlaceholder")}
|
||||
enterKeyHint="done"
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
{/* v1.17.0 — "More tags": the full structured-tag taxonomy
|
||||
(binary tiles + custom). Controlled so the Quick row's "+" can
|
||||
open it; the badge reports how many tags are selected. */}
|
||||
<SheetSection
|
||||
title={t("mood.sectionMoreTags")}
|
||||
icon={<ListChecks />}
|
||||
open={moreTagsOpen}
|
||||
onOpenChange={setMoreTagsOpen}
|
||||
summary={<SheetSectionCount count={tagKeys.length} />}
|
||||
>
|
||||
<MoodTagPicker
|
||||
selected={tagKeys}
|
||||
onToggle={toggleTagKey}
|
||||
mode="binary"
|
||||
/>
|
||||
{/* v1.4.25 W4d — GLP-1 side-effect quick-tags. Tapping a chip
|
||||
appends the localised label to the free-text tag list.
|
||||
Always visible for now (cheap UX; the Coach side-effect
|
||||
aggregator filters on the canonical English tag set so the
|
||||
German labels still register correctly). */}
|
||||
<div className="space-y-1.5 pt-1">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.sideEffectTagsHelp")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{GLP1_SIDE_EFFECT_KEYS.map((key) => {
|
||||
const label = t(key);
|
||||
const tags = tagsInput
|
||||
.split(",")
|
||||
.map((p) => p.trim().toLowerCase());
|
||||
const isActive = tags.includes(label.toLowerCase());
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isActive) {
|
||||
const next = tagsInput
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter(
|
||||
(p) => p.toLowerCase() !== label.toLowerCase(),
|
||||
);
|
||||
setTagsInput(next.join(", "));
|
||||
} else {
|
||||
const next = tagsInput.trim()
|
||||
? `${tagsInput.replace(/[,\s]+$/, "")}, ${label}`
|
||||
: label;
|
||||
setTagsInput(next);
|
||||
}
|
||||
}}
|
||||
className={`inline-flex min-h-11 items-center rounded-full border px-3 py-2 text-xs transition-colors ${
|
||||
isActive
|
||||
? "border-primary bg-primary/15 text-primary"
|
||||
: "border-border/70 bg-muted text-foreground/75 hover:bg-accent hover:text-foreground"
|
||||
}`}
|
||||
aria-pressed={isActive}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetSection>
|
||||
|
||||
{/* v1.8.5 / v1.12.0 — structured-tag taxonomy picker. Additive next
|
||||
to the free-text input above; an entry can carry both axes. The
|
||||
picker also surfaces the FACTOR-kind tags as 1..scaleMax rating
|
||||
controls, lifted here as `ratedFactors`. */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t("mood.tagPicker")}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
({t("common.optional")})
|
||||
</span>
|
||||
</Label>
|
||||
{/* v1.17.0 — "Factors": the FACTOR-kind segmented ratings, kept
|
||||
exactly as the existing segmented control. */}
|
||||
<SheetSection
|
||||
title={t("mood.sectionFactors")}
|
||||
icon={<SlidersHorizontal />}
|
||||
summary={<SheetSectionCount count={ratedFactors.length} />}
|
||||
>
|
||||
<MoodTagPicker
|
||||
selected={tagKeys}
|
||||
onToggle={toggleTagKey}
|
||||
ratedFactors={ratedFactors}
|
||||
onRateFactor={rateFactor}
|
||||
mode="rated"
|
||||
/>
|
||||
</div>
|
||||
</SheetSection>
|
||||
|
||||
{/* v1.8.5 (C1) — free-text note. */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label htmlFor="mood-note">
|
||||
{t("mood.note")}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
({t("common.optional")})
|
||||
{/* v1.17.0 — "Note & details": free-text tags, GLP-1 quick-tags,
|
||||
the note field, and the timestamp. The badge counts whichever
|
||||
of these carry input. */}
|
||||
<SheetSection
|
||||
title={t("mood.sectionNoteDetails")}
|
||||
icon={<StickyNote />}
|
||||
summary={<SheetSectionCount count={detailsCount} />}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label htmlFor="tags">
|
||||
{t("mood.tags")}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
({t("common.optional")})
|
||||
</span>
|
||||
</Label>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("mood.tagsHelp")}
|
||||
</span>
|
||||
</Label>
|
||||
{/* v1.11.5 — character counter so the `maxLength` cap no longer
|
||||
truncates silently. Turns destructive (warns) as the input
|
||||
approaches the limit. */}
|
||||
<span
|
||||
data-testid="mood-note-counter"
|
||||
className={`text-xs tabular-nums ${
|
||||
note.length >= NOTE_MAX_LENGTH
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
{t("mood.noteCharCount", {
|
||||
count: String(note.length),
|
||||
max: String(NOTE_MAX_LENGTH),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
id="tags"
|
||||
value={tagsInput}
|
||||
onChange={(e) => setTagsInput(e.target.value)}
|
||||
placeholder={t("mood.tagsPlaceholder")}
|
||||
enterKeyHint="done"
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{/* v1.4.25 W4d — GLP-1 side-effect quick-tags. Tapping a chip
|
||||
appends the localised label to the free-text tag list.
|
||||
Always visible for now (cheap UX; the Coach side-effect
|
||||
aggregator filters on the canonical English tag set so the
|
||||
German labels still register correctly). */}
|
||||
<div className="space-y-1.5 pt-1">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.sideEffectTagsHelp")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{GLP1_SIDE_EFFECT_KEYS.map((key) => {
|
||||
const label = t(key);
|
||||
const tags = tagsInput
|
||||
.split(",")
|
||||
.map((p) => p.trim().toLowerCase());
|
||||
const isActive = tags.includes(label.toLowerCase());
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isActive) {
|
||||
const next = tagsInput
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter(
|
||||
(p) => p.toLowerCase() !== label.toLowerCase(),
|
||||
);
|
||||
setTagsInput(next.join(", "));
|
||||
} else {
|
||||
const next = tagsInput.trim()
|
||||
? `${tagsInput.replace(/[,\s]+$/, "")}, ${label}`
|
||||
: label;
|
||||
setTagsInput(next);
|
||||
}
|
||||
}}
|
||||
className={`inline-flex min-h-11 items-center rounded-full border px-3 py-2 text-xs transition-colors ${
|
||||
isActive
|
||||
? "border-primary bg-primary/15 text-primary"
|
||||
: "border-border/70 bg-muted text-foreground/75 hover:bg-accent hover:text-foreground"
|
||||
}`}
|
||||
aria-pressed={isActive}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Textarea
|
||||
id="mood-note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder={t("mood.notePlaceholder")}
|
||||
maxLength={NOTE_MAX_LENGTH}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* v1.8.5 (C1) — free-text note. */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label htmlFor="mood-note">
|
||||
{t("mood.note")}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
({t("common.optional")})
|
||||
</span>
|
||||
</Label>
|
||||
{/* v1.11.5 — character counter so the `maxLength` cap no
|
||||
longer truncates silently. Turns destructive (warns) as the
|
||||
input approaches the limit. */}
|
||||
<span
|
||||
data-testid="mood-note-counter"
|
||||
className={`text-xs tabular-nums ${
|
||||
note.length >= NOTE_MAX_LENGTH
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
{t("mood.noteCharCount", {
|
||||
count: String(note.length),
|
||||
max: String(NOTE_MAX_LENGTH),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
id="mood-note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder={t("mood.notePlaceholder")}
|
||||
maxLength={NOTE_MAX_LENGTH}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mood-logged-at">{t("mood.timestamp")}</Label>
|
||||
<DateTimeInput
|
||||
id="mood-logged-at"
|
||||
value={moodLoggedAt}
|
||||
onChange={(e) => setMoodLoggedAt(e.target.value)}
|
||||
// v1.17 W1b — match the server bound: no future instant.
|
||||
max={getDefaultMoodLoggedAtValue()}
|
||||
required
|
||||
aria-required="true"
|
||||
aria-invalid={!!error || undefined}
|
||||
aria-describedby={errorDescriptor}
|
||||
/>
|
||||
</div>
|
||||
</SheetSection>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { moodTagIcon } from "./mood-tag-icons";
|
||||
import { selectQuickTagKeys } from "./recent-tags";
|
||||
|
||||
/**
|
||||
* v1.17.0 — the always-open Quick row at the top of the mood add-sheet.
|
||||
*
|
||||
* Surfaces the caller's most-recently-used binary tags (falling back to the
|
||||
* first catalogue tags on a fresh account) as one-tap chips, so the common
|
||||
* case is a face + a couple of familiar taps + Save. A trailing "+" expands
|
||||
* the full "More tags" section for everything else.
|
||||
*
|
||||
* Reads the same `/api/mood/tags` catalogue as `MoodTagPicker` (shared query
|
||||
* key → one network round-trip). Renders nothing while the catalogue is
|
||||
* empty so a cleared taxonomy degrades gracefully.
|
||||
*/
|
||||
|
||||
interface CatalogTag {
|
||||
key: string;
|
||||
labelKey: string;
|
||||
label?: string | null;
|
||||
icon: string | null;
|
||||
kind: "BINARY" | "RATED";
|
||||
}
|
||||
|
||||
interface CatalogCategory {
|
||||
tags: CatalogTag[];
|
||||
}
|
||||
|
||||
interface CatalogResponse {
|
||||
categories: CatalogCategory[];
|
||||
}
|
||||
|
||||
const QUICK_LIMIT = 8;
|
||||
|
||||
export function MoodQuickTags({
|
||||
selected,
|
||||
onToggle,
|
||||
recent,
|
||||
onExpand,
|
||||
}: {
|
||||
/** Selected binary tag keys (shared with `MoodTagPicker`). */
|
||||
selected: string[];
|
||||
onToggle: (tagKey: string) => void;
|
||||
/** MRU tag keys, most-recent first (from `useRecentTags`). */
|
||||
recent: readonly string[];
|
||||
/** Opens the full "More tags" section. */
|
||||
onExpand: () => void;
|
||||
}) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: queryKeys.moodTagCatalog(),
|
||||
queryFn: async () => apiGet<CatalogResponse>("/api/mood/tags"),
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className="h-11 w-full rounded-lg" />;
|
||||
}
|
||||
if (!data || data.categories.length === 0) return null;
|
||||
|
||||
// Flatten the catalogue to binary tags only; the Quick row never carries
|
||||
// rated factors (those live under the "Factors" section).
|
||||
const binaryTags = data.categories
|
||||
.flatMap((c) => c.tags)
|
||||
.filter((tag) => tag.kind !== "RATED");
|
||||
if (binaryTags.length === 0) return null;
|
||||
|
||||
const tagByKey = new Map(binaryTags.map((tag) => [tag.key, tag]));
|
||||
const catalogKeys = binaryTags.map((tag) => tag.key);
|
||||
const selectedSet = new Set(selected);
|
||||
|
||||
// Show the MRU/fallback quick keys, but always include any currently
|
||||
// selected key so a tap from the full picker stays visible up here too.
|
||||
const quickKeys = selectQuickTagKeys(recent, catalogKeys, QUICK_LIMIT);
|
||||
const orderedKeys = [
|
||||
...quickKeys,
|
||||
...selected.filter((k) => tagByKey.has(k) && !quickKeys.includes(k)),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5" data-slot="mood-quick-tags">
|
||||
<p className="text-muted-foreground text-xs">{t("mood.quickTagsHelp")}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{orderedKeys.map((key) => {
|
||||
const tag = tagByKey.get(key);
|
||||
if (!tag) return null;
|
||||
const TagIcon = moodTagIcon(tag.icon);
|
||||
const label = tag.label ?? t(tag.labelKey);
|
||||
const isActive = selectedSet.has(key);
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => onToggle(key)}
|
||||
aria-pressed={isActive}
|
||||
data-slot="mood-quick-tag"
|
||||
className={`inline-flex min-h-11 items-center gap-1.5 rounded-full border px-3 py-2 text-xs transition-colors ${
|
||||
isActive
|
||||
? "border-primary bg-primary/15 text-primary"
|
||||
: "border-border/70 bg-muted text-foreground/75 hover:bg-accent hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<TagIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExpand}
|
||||
aria-label={t("mood.quickTagsExpand")}
|
||||
title={t("mood.quickTagsExpand")}
|
||||
data-slot="mood-quick-tags-expand"
|
||||
className="border-border/70 text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-1 rounded-full border border-dashed px-3 py-2 text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("mood.quickTagsExpand")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -77,6 +77,7 @@ export function MoodTagPicker({
|
||||
onToggle,
|
||||
ratedFactors = [],
|
||||
onRateFactor,
|
||||
mode = "all",
|
||||
}: {
|
||||
/** Selected binary tag keys (sent as `tagKeys`). */
|
||||
selected: string[];
|
||||
@@ -89,6 +90,13 @@ export function MoodTagPicker({
|
||||
* when omitted the picker only surfaces binary tags.
|
||||
*/
|
||||
onRateFactor?: (key: string, rating: number | null) => void;
|
||||
/**
|
||||
* v1.17.0 — which tag kinds to surface. The sectioned add-sheet renders
|
||||
* the binary tiles under "More tags" (`mode="binary"`) and the segmented
|
||||
* factor ratings under "Factors" (`mode="rated"`) as two separate
|
||||
* sections; the default `"all"` keeps the legacy interleaved layout.
|
||||
*/
|
||||
mode?: "all" | "binary" | "rated";
|
||||
}) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
@@ -116,6 +124,8 @@ export function MoodTagPicker({
|
||||
|
||||
const selectedSet = new Set(selected);
|
||||
const ratingByKey = new Map(ratedFactors.map((f) => [f.key, f.rating]));
|
||||
const showBinary = mode !== "rated";
|
||||
const showRated = mode !== "binary";
|
||||
|
||||
const groupOptions = data.categories.map((category) => ({
|
||||
key: category.key,
|
||||
@@ -131,10 +141,15 @@ export function MoodTagPicker({
|
||||
<div className="space-y-4" data-slot="mood-tag-picker">
|
||||
{data.categories.map((category) => {
|
||||
const CategoryIcon = moodTagIcon(category.icon);
|
||||
const binaryTags = category.tags.filter((tag) => tag.kind !== "RATED");
|
||||
const ratedTags = category.tags.filter((tag) => tag.kind === "RATED");
|
||||
const binaryTags = showBinary
|
||||
? category.tags.filter((tag) => tag.kind !== "RATED")
|
||||
: [];
|
||||
const ratedTags = showRated
|
||||
? category.tags.filter((tag) => tag.kind === "RATED")
|
||||
: [];
|
||||
// Self-gate an empty category (e.g. a RATED-only category when no
|
||||
// `onRateFactor` is wired) so the picker never shows a bare heading.
|
||||
// `onRateFactor` is wired, or a mode that filters all tags out) so
|
||||
// the picker never shows a bare heading.
|
||||
if (binaryTags.length === 0 && ratedTags.length === 0) return null;
|
||||
if (ratedTags.length > 0 && !onRateFactor && binaryTags.length === 0) {
|
||||
return null;
|
||||
@@ -147,7 +162,7 @@ export function MoodTagPicker({
|
||||
<span>{category.label ?? t(category.labelKey)}</span>
|
||||
</div>
|
||||
|
||||
{
|
||||
{showBinary && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{binaryTags.map((tag) => {
|
||||
const TagIcon = moodTagIcon(tag.icon);
|
||||
@@ -178,7 +193,7 @@ export function MoodTagPicker({
|
||||
onClick={() => setCreateGroupKey(category.key)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
|
||||
{ratedTags.length > 0 && onRateFactor && (
|
||||
<div className="space-y-2" data-slot="mood-factor-ratings">
|
||||
@@ -262,8 +277,9 @@ export function MoodTagPicker({
|
||||
|
||||
{/* Bootstrap node for the seeded Custom group: before the first
|
||||
custom tag exists the plain read carries no `custom` category,
|
||||
so the inline-create entry point renders synthetically. */}
|
||||
{!hasCustomGroup && (
|
||||
so the inline-create entry point renders synthetically. Only the
|
||||
binary-tag surfaces it (custom tags are binary). */}
|
||||
{showBinary && !hasCustomGroup && (
|
||||
<div className="space-y-2" data-slot="mood-tag-custom-bootstrap">
|
||||
<div className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
|
||||
<Tag className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
|
||||
/**
|
||||
* v1.17.0 — most-recently-used mood-tag tracking for the Quick row.
|
||||
*
|
||||
* The Quick row surfaces the caller's handful of most-recently-used binary
|
||||
* tags as one-tap chips so the common case (a face + a couple of familiar
|
||||
* tags + Save) is genuinely fast. The MRU order is a small, client-only
|
||||
* convenience persisted in `localStorage`; it never touches the API and a
|
||||
* cleared store simply degrades to "no quick chips yet".
|
||||
*
|
||||
* The selection logic is split out as pure functions so it is unit-testable
|
||||
* without a DOM.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "healthlog:mood:recent-tags";
|
||||
const MAX_RECENT = 24;
|
||||
|
||||
/**
|
||||
* Promote `usedKeys` to the front of the existing MRU list (most-recent
|
||||
* first), de-duplicated, capped at `MAX_RECENT`. A key already present is
|
||||
* moved to the front rather than duplicated. Order within `usedKeys` is
|
||||
* preserved (first item ends up most-recent).
|
||||
*/
|
||||
export function promoteRecentTags(
|
||||
existing: readonly string[],
|
||||
usedKeys: readonly string[],
|
||||
): string[] {
|
||||
const seen = new Set<string>();
|
||||
const next: string[] = [];
|
||||
// Most-recent first: the latest-used keys lead, then prior history.
|
||||
for (const key of [...usedKeys].reverse().concat(existing)) {
|
||||
if (!key || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
next.push(key);
|
||||
if (next.length >= MAX_RECENT) break;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the ordered Quick-row keys for a render: the MRU keys that still
|
||||
* exist in the live catalog (intersected so a deleted tag drops out), capped
|
||||
* at `limit`. When the user has no history yet, fall back to the first
|
||||
* `limit` catalog keys so the Quick row is never empty on a fresh account.
|
||||
*/
|
||||
export function selectQuickTagKeys(
|
||||
recent: readonly string[],
|
||||
catalogKeys: readonly string[],
|
||||
limit: number,
|
||||
): string[] {
|
||||
const catalogSet = new Set(catalogKeys);
|
||||
const fromHistory = recent.filter((k) => catalogSet.has(k));
|
||||
if (fromHistory.length >= limit) return fromHistory.slice(0, limit);
|
||||
// Top up with catalog keys not already chosen so the row stays full.
|
||||
const chosen = new Set(fromHistory);
|
||||
const topped = [...fromHistory];
|
||||
for (const key of catalogKeys) {
|
||||
if (topped.length >= limit) break;
|
||||
if (!chosen.has(key)) {
|
||||
chosen.add(key);
|
||||
topped.push(key);
|
||||
}
|
||||
}
|
||||
return topped.slice(0, limit);
|
||||
}
|
||||
|
||||
const EMPTY: string[] = [];
|
||||
// Cache the parsed snapshot so `useSyncExternalStore` gets a stable
|
||||
// reference between reads (a fresh array every call would loop forever).
|
||||
let snapshot: string[] = EMPTY;
|
||||
let snapshotRaw: string | null | undefined;
|
||||
|
||||
function readStore(): string[] {
|
||||
if (typeof window === "undefined") return EMPTY;
|
||||
let raw: string | null = null;
|
||||
try {
|
||||
raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
} catch {
|
||||
return EMPTY;
|
||||
}
|
||||
if (raw === snapshotRaw) return snapshot;
|
||||
snapshotRaw = raw;
|
||||
if (!raw) {
|
||||
snapshot = EMPTY;
|
||||
return snapshot;
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
snapshot = Array.isArray(parsed)
|
||||
? parsed.filter((x): x is string => typeof x === "string")
|
||||
: EMPTY;
|
||||
} catch {
|
||||
snapshot = EMPTY;
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
function subscribe(cb: () => void): () => void {
|
||||
listeners.add(cb);
|
||||
return () => listeners.delete(cb);
|
||||
}
|
||||
function emit() {
|
||||
for (const cb of listeners) cb();
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook over the MRU store. Reads the persisted keys via
|
||||
* `useSyncExternalStore` (SSR-safe: the server snapshot is empty and
|
||||
* hydration stays stable), and exposes a `recordUse` to promote
|
||||
* freshly-saved tag keys to the front.
|
||||
*/
|
||||
export function useRecentTags(): {
|
||||
recent: string[];
|
||||
recordUse: (usedKeys: readonly string[]) => void;
|
||||
} {
|
||||
const recent = useSyncExternalStore(subscribe, readStore, () => EMPTY);
|
||||
|
||||
const recordUse = useCallback((usedKeys: readonly string[]) => {
|
||||
if (usedKeys.length === 0 || typeof window === "undefined") return;
|
||||
const next = promoteRecentTags(readStore(), usedKeys);
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* storage full / disabled — nothing to promote */
|
||||
return;
|
||||
}
|
||||
// Invalidate the cached snapshot and notify subscribers to re-read.
|
||||
snapshotRaw = undefined;
|
||||
emit();
|
||||
}, []);
|
||||
|
||||
return { recent, recordUse };
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { CheckCircle2, FileUp, PlusCircle, Plug } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -46,11 +46,42 @@ export function DoneScreen() {
|
||||
<p className="text-muted-foreground mx-auto max-w-md text-base leading-relaxed">
|
||||
{t("onboarding.done.body")}
|
||||
</p>
|
||||
<p className="text-muted-foreground mx-auto max-w-md text-sm leading-relaxed">
|
||||
{t("onboarding.done.learning")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<Button asChild size="lg">
|
||||
<Link href="/">{t("onboarding.done.returnCta")}</Link>
|
||||
</Button>
|
||||
<div className="flex w-full max-w-xs flex-col gap-2">
|
||||
<Button asChild size="lg">
|
||||
<Link
|
||||
href="/settings/integrations"
|
||||
className="inline-flex items-center gap-2"
|
||||
>
|
||||
<Plug className="size-4" />
|
||||
{t("onboarding.done.connectCta")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="lg" variant="outline">
|
||||
<Link
|
||||
href="/measurements"
|
||||
className="inline-flex items-center gap-2"
|
||||
>
|
||||
<PlusCircle className="size-4" />
|
||||
{t("onboarding.done.logCta")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost">
|
||||
<Link href="/">{t("onboarding.done.returnCta")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/settings/export"
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
<FileUp className="size-3.5" />
|
||||
{t("onboarding.done.importCta")}
|
||||
</Link>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,9 @@ import { useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Apple,
|
||||
ClipboardCheck,
|
||||
FileUp,
|
||||
Plug,
|
||||
Watch,
|
||||
Wifi,
|
||||
type LucideIcon,
|
||||
@@ -19,29 +20,31 @@ import { cn } from "@/lib/utils";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
|
||||
/**
|
||||
* v1.4.25 W14b-Content — onboarding step 2 (source).
|
||||
* Onboarding step 2 (source).
|
||||
*
|
||||
* Four-card grid presenting the data sources HealthLog supports today
|
||||
* plus the announce-only Apple Health card so users land on v1.5 with
|
||||
* the right expectation.
|
||||
*
|
||||
* Cards:
|
||||
* 1. Manual entry (enabled, recommended)
|
||||
* Presents the data sources HealthLog ships today. The headline cards
|
||||
* cover the two paths most people start on:
|
||||
* 1. Manual entry (enabled, recommended — works on any device)
|
||||
* 2. Withings (enabled — opens OAuth `/api/withings/connect` in a new
|
||||
* tab; the popup hands control back via the existing callback)
|
||||
* 3. Apple Health (DISABLED — "Coming with v1.5 iOS app")
|
||||
* 4. Garmin — omitted entirely. Not on the v1.5 roadmap so showing
|
||||
* a disabled card adds noise without conveying a real promise.
|
||||
* tab; the callback flips the connection server-side)
|
||||
*
|
||||
* Below them a calm "more sources" row lists the remaining shipped
|
||||
* integrations (Apple Health, WHOOP, Oura, Polar, Nightscout, Fitbit),
|
||||
* each linking to Settings → Integrations where it is actually
|
||||
* connected. We don't try to run six OAuth flows inside the wizard;
|
||||
* the row sets the expectation and hands off. A discreet import link
|
||||
* points at the Apple Health export.zip path — the cold-start escape
|
||||
* hatch for users with existing history.
|
||||
*
|
||||
* Garmin is omitted: not shipped, so a card would over-claim.
|
||||
*
|
||||
* Selection is non-binding — Manual is the implicit default and the
|
||||
* Next CTA always advances. The Withings card opens the OAuth flow in
|
||||
* a new tab; the user returns to this step to click Next when they're
|
||||
* back (callback handler flips the connection server-side, no client
|
||||
* polling needed for the wizard contract).
|
||||
* Next CTA always advances. Nothing here is auto-configured; the step
|
||||
* only signposts where each source is connected.
|
||||
*/
|
||||
|
||||
interface SourceCard {
|
||||
slug: "manual" | "withings" | "apple-health";
|
||||
slug: "manual" | "withings";
|
||||
Icon: LucideIcon;
|
||||
titleKey: string;
|
||||
bodyKey: string;
|
||||
@@ -49,14 +52,11 @@ interface SourceCard {
|
||||
* One of:
|
||||
* - "enabled-select": tap selects the card (Manual)
|
||||
* - "enabled-oauth": tap opens OAuth in a new tab (Withings)
|
||||
* - "disabled": semi-transparent, badge, no-op (Apple Health)
|
||||
*/
|
||||
state: "enabled-select" | "enabled-oauth" | "disabled";
|
||||
state: "enabled-select" | "enabled-oauth";
|
||||
/** External href for `enabled-oauth`. */
|
||||
href?: string;
|
||||
/** Optional badge under the title (e.g. "Coming with v1.5"). */
|
||||
badgeKey?: string;
|
||||
/** Optional recommended badge ("Most popular"). */
|
||||
/** Optional recommended badge. */
|
||||
recommendedKey?: string;
|
||||
}
|
||||
|
||||
@@ -77,14 +77,19 @@ const SOURCE_CARDS: ReadonlyArray<SourceCard> = [
|
||||
state: "enabled-oauth",
|
||||
href: "/api/withings/connect",
|
||||
},
|
||||
{
|
||||
slug: "apple-health",
|
||||
Icon: Apple,
|
||||
titleKey: "onboarding.source.appleHealth.title",
|
||||
bodyKey: "onboarding.source.appleHealth.body",
|
||||
state: "disabled",
|
||||
badgeKey: "onboarding.source.appleHealth.badge",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The remaining shipped integrations. Listed compactly so the step
|
||||
* stays calm — the full connect flow for each lives in Settings.
|
||||
*/
|
||||
const MORE_SOURCES: ReadonlyArray<{ slug: string; labelKey: string }> = [
|
||||
{ slug: "apple-health", labelKey: "onboarding.source.more.appleHealth" },
|
||||
{ slug: "whoop", labelKey: "onboarding.source.more.whoop" },
|
||||
{ slug: "oura", labelKey: "onboarding.source.more.oura" },
|
||||
{ slug: "polar", labelKey: "onboarding.source.more.polar" },
|
||||
{ slug: "nightscout", labelKey: "onboarding.source.more.nightscout" },
|
||||
{ slug: "fitbit", labelKey: "onboarding.source.more.fitbit" },
|
||||
];
|
||||
|
||||
export function SourceCardGrid() {
|
||||
@@ -140,13 +145,58 @@ export function SourceCardGrid() {
|
||||
recommendedLabel={
|
||||
card.recommendedKey ? t(card.recommendedKey) : null
|
||||
}
|
||||
badgeLabel={card.badgeKey ? t(card.badgeKey) : null}
|
||||
withingsCtaLabel={t("onboarding.source.withings.cta")}
|
||||
disabledHintLabel={t("onboarding.source.appleHealth.disabledHint")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-lg border border-dashed border-border p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="bg-muted text-muted-foreground flex size-9 shrink-0 items-center justify-center rounded-md"
|
||||
>
|
||||
<Plug className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<h2 className="text-base font-semibold tracking-tight">
|
||||
{t("onboarding.source.more.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{t("onboarding.source.more.body")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="flex flex-wrap gap-2" data-testid="source-more-list">
|
||||
{MORE_SOURCES.map((item) => (
|
||||
<li key={item.slug}>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-9"
|
||||
>
|
||||
<Link
|
||||
href="/settings/integrations"
|
||||
data-testid={`source-more-${item.slug}`}
|
||||
>
|
||||
{t(item.labelKey)}
|
||||
</Link>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/settings/export"
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-sm underline-offset-4 hover:underline"
|
||||
data-testid="source-import-link"
|
||||
>
|
||||
<FileUp className="size-3.5" />
|
||||
{t("onboarding.source.import")}
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 pt-2">
|
||||
<Button asChild variant="ghost" className="min-h-11 min-w-11">
|
||||
<Link href="/onboarding/1">{t("onboarding.shell.back")}</Link>
|
||||
@@ -182,9 +232,7 @@ interface SourceCardItemProps {
|
||||
titleLabel: string;
|
||||
bodyLabel: string;
|
||||
recommendedLabel: string | null;
|
||||
badgeLabel: string | null;
|
||||
withingsCtaLabel: string;
|
||||
disabledHintLabel: string;
|
||||
}
|
||||
|
||||
function SourceCardItem({
|
||||
@@ -194,9 +242,7 @@ function SourceCardItem({
|
||||
titleLabel,
|
||||
bodyLabel,
|
||||
recommendedLabel,
|
||||
badgeLabel,
|
||||
withingsCtaLabel,
|
||||
disabledHintLabel,
|
||||
}: SourceCardItemProps) {
|
||||
const Icon = card.Icon;
|
||||
|
||||
@@ -211,7 +257,7 @@ function SourceCardItem({
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"flex size-10 shrink-0 items-center justify-center rounded-md",
|
||||
selected && card.state !== "disabled"
|
||||
selected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
@@ -220,14 +266,7 @@ function SourceCardItem({
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-base font-semibold tracking-tight">{titleLabel}</h2>
|
||||
{badgeLabel ? (
|
||||
<span
|
||||
className="bg-muted text-foreground mt-1 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide"
|
||||
data-testid={`source-badge-${card.slug}`}
|
||||
>
|
||||
{badgeLabel}
|
||||
</span>
|
||||
) : recommendedLabel ? (
|
||||
{recommendedLabel ? (
|
||||
<span className="bg-primary/10 text-primary mt-1 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide">
|
||||
{recommendedLabel}
|
||||
</span>
|
||||
@@ -240,22 +279,6 @@ function SourceCardItem({
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">{bodyLabel}</p>
|
||||
);
|
||||
|
||||
if (card.state === "disabled") {
|
||||
return (
|
||||
<div
|
||||
aria-disabled="true"
|
||||
className={cn(baseClasses, "cursor-not-allowed opacity-60")}
|
||||
data-testid={`source-card-${card.slug}`}
|
||||
>
|
||||
{headerRow}
|
||||
{body}
|
||||
<p className="text-muted-foreground text-xs italic">
|
||||
{disabledHintLabel}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (card.state === "enabled-oauth") {
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -37,7 +37,7 @@ const ITEM_ICONS: Record<ChecklistItemId, LucideIcon> = {
|
||||
profile: User2,
|
||||
measurement: Activity,
|
||||
medication: Pill,
|
||||
withings: Wifi,
|
||||
dataSource: Wifi,
|
||||
notifications: Bell,
|
||||
};
|
||||
|
||||
@@ -60,10 +60,10 @@ const ITEM_LABEL_KEYS: Record<
|
||||
description: "gettingStarted.items.medicationDescription",
|
||||
cta: "gettingStarted.items.medicationCta",
|
||||
},
|
||||
withings: {
|
||||
title: "gettingStarted.items.withingsTitle",
|
||||
description: "gettingStarted.items.withingsDescription",
|
||||
cta: "gettingStarted.items.withingsCta",
|
||||
dataSource: {
|
||||
title: "gettingStarted.items.dataSourceTitle",
|
||||
description: "gettingStarted.items.dataSourceDescription",
|
||||
cta: "gettingStarted.items.dataSourceCta",
|
||||
},
|
||||
notifications: {
|
||||
title: "gettingStarted.items.notificationsTitle",
|
||||
@@ -72,8 +72,8 @@ const ITEM_LABEL_KEYS: Record<
|
||||
},
|
||||
};
|
||||
|
||||
interface WithingsStatus {
|
||||
connected?: boolean;
|
||||
interface IntegrationsStatus {
|
||||
integrations?: Array<{ connected?: boolean; enabled?: boolean }>;
|
||||
}
|
||||
|
||||
interface NotificationChannel {
|
||||
@@ -227,10 +227,13 @@ export function GettingStartedChecklist() {
|
||||
// for established users. See docs/audit/v15-performance.md.
|
||||
const onboardingPending = !!user && user.onboardingCompletedAt == null;
|
||||
|
||||
const { data: withingsData } = useQuery<WithingsStatus>({
|
||||
queryKey: ["withings", "status"],
|
||||
// v1.17.0 — any connected data source satisfies the step, so we read
|
||||
// the consolidated integrations envelope (Withings / WHOOP / Fitbit /
|
||||
// moodLog) rather than the Withings-only status route.
|
||||
const { data: integrationsData } = useQuery<IntegrationsStatus>({
|
||||
queryKey: queryKeys.integrationsStatus(),
|
||||
queryFn: async () => {
|
||||
return apiGet("/api/withings/status");
|
||||
return apiGet("/api/integrations/status");
|
||||
},
|
||||
enabled: onboardingPending,
|
||||
});
|
||||
@@ -244,7 +247,10 @@ export function GettingStartedChecklist() {
|
||||
});
|
||||
|
||||
const medicationCount = medsData?.length ?? 0;
|
||||
const withingsConnected = withingsData?.connected === true;
|
||||
const dataSourceConnected = (integrationsData?.integrations ?? []).some(
|
||||
(integration) =>
|
||||
integration?.connected === true || integration?.enabled === true,
|
||||
);
|
||||
const notificationsConfigured = (notificationsData?.channels ?? []).some(
|
||||
(channel) => channel?.enabled === true,
|
||||
);
|
||||
@@ -259,7 +265,7 @@ export function GettingStartedChecklist() {
|
||||
},
|
||||
measurementCount,
|
||||
medicationCount,
|
||||
withingsConnected,
|
||||
dataSourceConnected,
|
||||
notificationsConfigured,
|
||||
dismissedIds,
|
||||
}),
|
||||
@@ -269,7 +275,7 @@ export function GettingStartedChecklist() {
|
||||
user?.gender,
|
||||
measurementCount,
|
||||
medicationCount,
|
||||
withingsConnected,
|
||||
dataSourceConnected,
|
||||
notificationsConfigured,
|
||||
dismissedIds,
|
||||
],
|
||||
|
||||
@@ -131,9 +131,10 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
});
|
||||
|
||||
const html = render();
|
||||
// Exactly one pill per card → 3 pills total (Withings, WHOOP,
|
||||
// Fitbit / Google Health). The moodLog integration was removed.
|
||||
expect(count(html, 'data-testid="integration-status-pill"')).toBe(3);
|
||||
// Exactly one pill per card → 6 pills total (Withings, WHOOP,
|
||||
// Fitbit / Google Health, Polar, Oura, Nightscout). The moodLog integration
|
||||
// was removed; Polar + Oura (F4) were added in v1.17.0.
|
||||
expect(count(html, 'data-testid="integration-status-pill"')).toBe(6);
|
||||
// The redundant banner from v1.4.15 is gone.
|
||||
expect(html).not.toContain('data-testid="integration-status-banner"');
|
||||
// Card-body "letzter Sync" repetition is gone — no
|
||||
|
||||
@@ -7,6 +7,9 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { FitbitCard } from "@/components/settings/integrations/fitbit-card";
|
||||
import { NightscoutCard } from "@/components/settings/integrations/nightscout-card";
|
||||
import { OuraCard } from "@/components/settings/integrations/oura-card";
|
||||
import { PolarCard } from "@/components/settings/integrations/polar-card";
|
||||
import {
|
||||
pickStatus,
|
||||
useIntegrationStatuses,
|
||||
@@ -89,6 +92,58 @@ export function IntegrationsSection() {
|
||||
}
|
||||
}, [withingsOauthOutcome, router, queryClient, t]);
|
||||
|
||||
// v1.17.0 (F4) — generic OAuth-callback toast for the env-based providers.
|
||||
// The Polar / Oura callbacks redirect back with `?<provider>=connected` or
|
||||
// `?<provider>=error&reason=<tag>`; surface the outcome as a toast and scrub
|
||||
// the one-shot params so a reload doesn't replay it.
|
||||
const [oauthOutcome] = useState<
|
||||
{ provider: "polar" | "oura"; kind: "connected" } | { provider: "polar" | "oura"; kind: "error"; reason: string } | null
|
||||
>(() => {
|
||||
if (typeof window === "undefined") return null;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
for (const provider of ["polar", "oura"] as const) {
|
||||
const v = params.get(provider);
|
||||
if (v === "connected") return { provider, kind: "connected" };
|
||||
if (v === "error") {
|
||||
return { provider, kind: "error", reason: params.get("reason") ?? "unknown" };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!oauthOutcome) return;
|
||||
const { provider } = oauthOutcome;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete(provider);
|
||||
url.searchParams.delete("reason");
|
||||
router.replace(`${url.pathname}${url.search}`, { scroll: false });
|
||||
const key = provider === "polar" ? queryKeys.polar() : queryKeys.oura();
|
||||
if (oauthOutcome.kind === "connected") {
|
||||
toast.success(t(`settings.${provider}OauthConnected`));
|
||||
queryClient.invalidateQueries({ queryKey: key });
|
||||
} else {
|
||||
// Known reason tags resolve to a specific message; anything else falls
|
||||
// back to the generic copy (matching the Withings handler).
|
||||
const knownReasons = new Set([
|
||||
"csrf1",
|
||||
"state",
|
||||
"cross_user",
|
||||
"nocode",
|
||||
"nocreds",
|
||||
"token",
|
||||
"rate_limited",
|
||||
]);
|
||||
const reasonKey = knownReasons.has(oauthOutcome.reason)
|
||||
? `settings.${provider}OauthError.${oauthOutcome.reason}`
|
||||
: `settings.${provider}OauthError.generic`;
|
||||
toast.error(t(`settings.${provider}OauthFailed`), {
|
||||
description: t(reasonKey),
|
||||
duration: 10_000,
|
||||
});
|
||||
}
|
||||
}, [oauthOutcome, router, queryClient, t]);
|
||||
|
||||
const withingsViewModel = pickStatus(integrationStatus, "withings");
|
||||
const whoopViewModel = pickStatus(integrationStatus, "whoop");
|
||||
const fitbitViewModel = pickStatus(integrationStatus, "fitbit");
|
||||
@@ -124,6 +179,9 @@ export function IntegrationsSection() {
|
||||
<WithingsCard viewModel={withingsViewModel} />
|
||||
<WhoopCard viewModel={whoopViewModel} />
|
||||
<FitbitCard viewModel={fitbitViewModel} />
|
||||
<PolarCard enabled={isAuthenticated} />
|
||||
<OuraCard enabled={isAuthenticated} />
|
||||
<NightscoutCard enabled={isAuthenticated} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* v1.17.0 — interaction-parity + correctness checks for the Nightscout card.
|
||||
*
|
||||
* 1. A `parked` status renders the warning banner + reconnect button
|
||||
* (matching the WHOOP card's parked treatment, byte-for-byte classes).
|
||||
* 2. A `connected` status renders the shared TestConnectionButton AND the
|
||||
* connect→data link to /insights/blood-glucose.
|
||||
* 3. The disconnect mutation invalidates `nightscoutStatus()` — the key the
|
||||
* status query actually reads — so the pill refreshes after disconnect.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
// Capture the disconnect mutation's onSuccess so the test can assert which
|
||||
// query keys it invalidates. Status is driven off a per-test payload.
|
||||
let statusPayload: unknown = null;
|
||||
const invalidateSpy = vi.fn();
|
||||
type OnSuccess = () => void;
|
||||
const capturedDisconnectOnSuccess: { fn: OnSuccess | null } = { fn: null };
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: statusPayload, isLoading: false }),
|
||||
useMutation: ({ onSuccess }: { onSuccess?: () => void }) => {
|
||||
// The Nightscout card declares exactly one mutation (disconnect).
|
||||
capturedDisconnectOnSuccess.fn = onSuccess ?? null;
|
||||
return { mutate: vi.fn(), isPending: false };
|
||||
},
|
||||
useQueryClient: () => ({ invalidateQueries: invalidateSpy }),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { NightscoutCard } from "../nightscout-card";
|
||||
|
||||
function render() {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<NightscoutCard />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("NightscoutCard — parked + test + data-link + invalidation", () => {
|
||||
it("renders the parked banner + reconnect button when state is parked", () => {
|
||||
statusPayload = {
|
||||
connected: true,
|
||||
configured: true,
|
||||
state: "parked",
|
||||
lastSuccessAt: null,
|
||||
lastError: "Nightscout unreachable",
|
||||
};
|
||||
const html = render();
|
||||
expect(html).toContain('data-state="parked"');
|
||||
expect(html).toContain('data-testid="nightscout-parked-banner"');
|
||||
expect(html).toContain('data-testid="nightscout-resume-button"');
|
||||
expect(html).toContain("border-warning/30 bg-warning/10");
|
||||
});
|
||||
|
||||
it("renders the test-connection button + data link when connected", () => {
|
||||
statusPayload = {
|
||||
connected: true,
|
||||
configured: true,
|
||||
state: "connected",
|
||||
lastSuccessAt: "2026-06-01T00:00:00.000Z",
|
||||
lastError: null,
|
||||
};
|
||||
const html = render();
|
||||
expect(html).toContain("Test connection");
|
||||
expect(html).toContain('data-testid="nightscout-data-link"');
|
||||
expect(html).toContain('href="/insights/blood-glucose"');
|
||||
});
|
||||
|
||||
it("disconnect invalidates the status key the query reads (nightscoutStatus)", () => {
|
||||
statusPayload = { connected: true, configured: true, state: "connected" };
|
||||
invalidateSpy.mockClear();
|
||||
capturedDisconnectOnSuccess.fn = null;
|
||||
render();
|
||||
const onSuccess = capturedDisconnectOnSuccess.fn as (() => void) | null;
|
||||
expect(typeof onSuccess).toBe("function");
|
||||
onSuccess?.();
|
||||
const invalidatedKeys = invalidateSpy.mock.calls.map((c) => c[0]?.queryKey);
|
||||
expect(invalidatedKeys).toContainEqual(queryKeys.nightscoutStatus());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* v1.17.0 — interaction-parity check for the shared OAuth integration card
|
||||
* (Polar / Oura). The new cards must match the existing WHOOP card's
|
||||
* parked-state + test-connection + connect→data treatment:
|
||||
*
|
||||
* 1. A `parked` status renders the warning banner + reconnect button.
|
||||
* 2. A `connected` status renders the shared TestConnectionButton (its
|
||||
* "Test connection" affordance) AND the connect→data link.
|
||||
* 3. The data link resolves to the provider's insight surface.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { Watch } from "lucide-react";
|
||||
|
||||
// Drive the card's own `useQuery` status read off a per-test payload.
|
||||
let statusPayload: unknown = null;
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: statusPayload, isLoading: false }),
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { OAuthProviderCard } from "../oauth-provider-card";
|
||||
|
||||
function render() {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<OAuthProviderCard
|
||||
provider="polar"
|
||||
statusQueryKey={["polar"]}
|
||||
i18nPrefix="settings.polar"
|
||||
icon={Watch}
|
||||
dataHref="/insights/sleep"
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("OAuthProviderCard — parked + test + data-link parity", () => {
|
||||
it("renders the parked banner + reconnect button when state is parked", () => {
|
||||
statusPayload = {
|
||||
connected: true,
|
||||
configured: true,
|
||||
available: true,
|
||||
state: "parked",
|
||||
lastSuccessAt: null,
|
||||
lastError: "Polar grant expired",
|
||||
};
|
||||
const html = render();
|
||||
expect(html).toContain('data-state="parked"');
|
||||
expect(html).toContain('data-testid="polar-parked-banner"');
|
||||
expect(html).toContain('data-testid="polar-resume-button"');
|
||||
expect(html).toContain("reconnect manually");
|
||||
// The parked banner uses the same warning treatment as the WHOOP card.
|
||||
expect(html).toContain("border-warning/30 bg-warning/10");
|
||||
});
|
||||
|
||||
it("renders the test-connection button + data link when connected", () => {
|
||||
statusPayload = {
|
||||
connected: true,
|
||||
configured: true,
|
||||
available: true,
|
||||
state: "connected",
|
||||
lastSuccessAt: "2026-06-01T00:00:00.000Z",
|
||||
lastError: null,
|
||||
};
|
||||
const html = render();
|
||||
// The shared TestConnectionButton surfaces its "Test connection" label.
|
||||
expect(html).toContain("Test connection");
|
||||
// connect→data link points at the provider's insight surface.
|
||||
expect(html).toContain('data-testid="polar-data-link"');
|
||||
expect(html).toContain('href="/insights/sleep"');
|
||||
});
|
||||
|
||||
it("does not render the data link or test button when disconnected", () => {
|
||||
statusPayload = {
|
||||
connected: false,
|
||||
configured: false,
|
||||
available: true,
|
||||
};
|
||||
const html = render();
|
||||
expect(html).not.toContain('data-testid="polar-data-link"');
|
||||
expect(html).not.toContain("Test connection");
|
||||
// The connect CTA stands in instead.
|
||||
expect(html).toContain('data-testid="polar-connect"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,372 @@
|
||||
"use client";
|
||||
|
||||
// v1.17.0 — Nightscout CGM integration card. Unlike WHOOP / Fitbit (OAuth,
|
||||
// BYO-key), Nightscout is a URL + token the self-hoster pastes once: the user
|
||||
// runs their own instance and HealthLog pulls continuous glucose off it. The
|
||||
// card has its own status read (`/api/nightscout/status`) rather than the
|
||||
// consolidated envelope. Warm copy, mobile-first, the private-network opt-in
|
||||
// toggle maps to `nightscoutAllowPrivateHost`.
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowRight, Droplet, Link2, Loader2, Save, Unlink } from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import {
|
||||
IntegrationStatusPill,
|
||||
type IntegrationPillState,
|
||||
} from "@/components/settings/integration-status-pill";
|
||||
import { TestConnectionButton } from "@/components/settings/test-connection-button";
|
||||
import { apiFetchRaw, apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, measurementDependentKeys, queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { IntegrationErrorMessage } from "./shared";
|
||||
|
||||
interface NightscoutStatus {
|
||||
connected: boolean;
|
||||
configured: boolean;
|
||||
hasToken?: boolean;
|
||||
allowPrivateHost?: boolean;
|
||||
state?: "connected" | "error_transient" | "error_reauth" | "disconnected" | "parked";
|
||||
lastSuccessAt?: string | null;
|
||||
lastAttemptAt?: string | null;
|
||||
lastError?: string | null;
|
||||
}
|
||||
|
||||
/** Map the shared ledger state onto the pill's display state. */
|
||||
function pillStateFor(status: NightscoutStatus | undefined): IntegrationPillState {
|
||||
if (!status?.connected) return "disconnected";
|
||||
switch (status.state) {
|
||||
case "parked":
|
||||
return "parked";
|
||||
case "error_reauth":
|
||||
return "error";
|
||||
case "error_transient":
|
||||
return "warning";
|
||||
default:
|
||||
return "connected";
|
||||
}
|
||||
}
|
||||
|
||||
export function NightscoutCard({ enabled = true }: { enabled?: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [url, setUrl] = useState("");
|
||||
const [token, setToken] = useState("");
|
||||
const [allowPrivateHost, setAllowPrivateHost] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
||||
|
||||
const { data: status } = useQuery({
|
||||
queryKey: queryKeys.nightscoutStatus(),
|
||||
queryFn: async () => apiGet<NightscoutStatus>("/api/nightscout/status"),
|
||||
enabled,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const formRef = useRef<HTMLFormElement | null>(null);
|
||||
|
||||
const disconnect = useMutation({
|
||||
mutationFn: async () => {
|
||||
await apiPost("/api/nightscout/disconnect");
|
||||
},
|
||||
onSuccess: () => {
|
||||
setUrl("");
|
||||
setToken("");
|
||||
setAllowPrivateHost(false);
|
||||
// The status read keys on `nightscoutStatus()`; invalidate it so the
|
||||
// pill flips back after a disconnect (matching the Withings / WHOOP
|
||||
// pattern), alongside the cross-integration envelope.
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.nightscoutStatus(),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.integrationsStatus(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
async function handleConnect(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/nightscout/connect", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
url: url.trim(),
|
||||
token: token.trim(),
|
||||
allowPrivateHost,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
setMsg(t("settings.nightscoutConnected"));
|
||||
setMsgType("success");
|
||||
setToken("");
|
||||
void invalidateKeys(queryClient, measurementDependentKeys);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.nightscoutStatus(),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.integrationsStatus(),
|
||||
});
|
||||
} else {
|
||||
let detail = t("settings.nightscoutConnectFailed");
|
||||
try {
|
||||
const json = await res.json();
|
||||
if (json.error) detail = json.error;
|
||||
} catch {
|
||||
// keep the generic message
|
||||
}
|
||||
setMsg(detail);
|
||||
setMsgType("error");
|
||||
}
|
||||
} catch {
|
||||
setMsg(t("common.networkError"));
|
||||
setMsgType("error");
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
const pillState = pillStateFor(status);
|
||||
const errorMessage =
|
||||
(pillState === "error" || pillState === "parked") && status?.lastError
|
||||
? status.lastError
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="nightscout-card"
|
||||
className="bg-card border-border rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={Droplet}
|
||||
title={t("settings.nightscout")}
|
||||
titleAccessory={
|
||||
<span className="bg-muted text-foreground rounded-full px-2 py-0.5 text-[0.6875rem] font-medium">
|
||||
{t("settings.nightscoutTag")}
|
||||
</span>
|
||||
}
|
||||
description={<p>{t("settings.nightscoutDescription")}</p>}
|
||||
status={
|
||||
<IntegrationStatusPill
|
||||
state={pillState}
|
||||
lastSyncAt={status?.lastSuccessAt ?? null}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<hr className="border-border/60 mt-4" />
|
||||
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
|
||||
|
||||
{/* Parked-integration resume CTA. Surfaces only when the row state
|
||||
is `parked` (>24h of persistent failures). Nightscout has no OAuth
|
||||
redirect — the user re-validates by re-submitting the connect form,
|
||||
so "reconnect" scrolls the form into view + focuses the URL field.
|
||||
Markup matches the WHOOP card byte for byte. */}
|
||||
{pillState === "parked" && (
|
||||
<div
|
||||
data-testid="nightscout-parked-banner"
|
||||
className="border-warning/30 bg-warning/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="text-warning min-w-0 text-xs break-words">
|
||||
{t("settings.integrationPill.parkedReconnect")}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
formRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
formRef.current
|
||||
?.querySelector<HTMLInputElement>("#nightscout-url")
|
||||
?.focus();
|
||||
}}
|
||||
data-testid="nightscout-resume-button"
|
||||
className="min-h-11"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
{t("settings.integrationPill.resumeCta")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.nightscoutHelp")}
|
||||
</p>
|
||||
|
||||
<form ref={formRef} onSubmit={handleConnect} className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="nightscout-url">{t("settings.nightscoutUrl")}</Label>
|
||||
<Input
|
||||
id="nightscout-url"
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder={
|
||||
status?.configured
|
||||
? t("settings.nightscoutUrlSavedPlaceholder")
|
||||
: "https://your-site.up.railway.app"
|
||||
}
|
||||
maxLength={2048}
|
||||
autoComplete="off"
|
||||
inputMode="url"
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
enterKeyHint="next"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="nightscout-token">
|
||||
{t("settings.nightscoutToken")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="nightscout-token"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder={
|
||||
status?.hasToken
|
||||
? t("settings.nightscoutTokenSavedPlaceholder")
|
||||
: t("settings.nightscoutTokenOptional")
|
||||
}
|
||||
maxLength={512}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
enterKeyHint="done"
|
||||
/>
|
||||
<p className="text-muted-foreground/80 text-xs">
|
||||
{t("settings.nightscoutTokenHelp")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-border/60 flex items-start justify-between gap-3 rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="nightscout-private" className="text-sm">
|
||||
{t("settings.nightscoutPrivateHost")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground/80 text-xs">
|
||||
{t("settings.nightscoutPrivateHostHelp")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="nightscout-private"
|
||||
checked={allowPrivateHost}
|
||||
onCheckedChange={setAllowPrivateHost}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
disabled={saving || !url.trim()}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : status?.connected ? (
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{status?.connected
|
||||
? t("settings.nightscoutUpdate")
|
||||
: t("settings.nightscoutConnect")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`text-sm ${msgType === "success" ? "text-success" : "text-destructive"}`}
|
||||
>
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{status?.connected && (
|
||||
<>
|
||||
<div className="flex flex-wrap items-start gap-2 [&>*]:min-w-[10rem] sm:[&>*]:min-w-0">
|
||||
<TestConnectionButton
|
||||
endpoint="/api/nightscout/test"
|
||||
disabled={!status?.connected}
|
||||
/>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive min-h-11"
|
||||
>
|
||||
<Unlink className="h-3.5 w-3.5" />
|
||||
{t("settings.nightscoutDisconnect")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.nightscoutDisconnectTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.nightscoutDisconnectDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => disconnect.mutate()}
|
||||
>
|
||||
{t("settings.nightscoutDisconnect")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
{/* connect→data loop: a discreet link to where the glucose
|
||||
readings now surface — doubles as the "your data is richer"
|
||||
cue. */}
|
||||
<Link
|
||||
href="/insights/blood-glucose"
|
||||
data-testid="nightscout-data-link"
|
||||
className="text-primary inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
|
||||
>
|
||||
{t("settings.nightscoutViewData")}
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
"use client";
|
||||
|
||||
// v1.17.0 (F4) — shared OAuth integration card for env-based providers (Polar,
|
||||
// Oura). Unlike WHOOP / Fitbit (per-user BYO client id/secret), these use a
|
||||
// single shared OAuth app the operator configures via env, so the card has NO
|
||||
// credential inputs — just a connect button that redirects to the provider, a
|
||||
// status pill off the self-contained status read, and a disconnect action.
|
||||
// Mirrors the Nightscout card's self-contained status pattern.
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowRight, Link2, Unlink, type LucideIcon } from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import {
|
||||
IntegrationStatusPill,
|
||||
type IntegrationPillState,
|
||||
} from "@/components/settings/integration-status-pill";
|
||||
import { TestConnectionButton } from "@/components/settings/test-connection-button";
|
||||
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys";
|
||||
|
||||
export interface OAuthProviderStatus {
|
||||
connected: boolean;
|
||||
configured: boolean;
|
||||
available: boolean;
|
||||
state?:
|
||||
| "connected"
|
||||
| "error_transient"
|
||||
| "error_reauth"
|
||||
| "disconnected"
|
||||
| "parked";
|
||||
lastSuccessAt?: string | null;
|
||||
lastAttemptAt?: string | null;
|
||||
lastError?: string | null;
|
||||
}
|
||||
|
||||
function pillStateFor(status: OAuthProviderStatus | undefined): IntegrationPillState {
|
||||
if (!status?.connected) return "disconnected";
|
||||
switch (status.state) {
|
||||
case "parked":
|
||||
return "parked";
|
||||
case "error_reauth":
|
||||
return "error";
|
||||
case "error_transient":
|
||||
return "warning";
|
||||
default:
|
||||
return "connected";
|
||||
}
|
||||
}
|
||||
|
||||
export interface OAuthProviderCardProps {
|
||||
/** Lower-case provider key, used for routes + query keys + testids. */
|
||||
provider: "polar" | "oura";
|
||||
/** The query-key array shared by the status read + the invalidations. */
|
||||
statusQueryKey: readonly unknown[];
|
||||
/** i18n key prefix (e.g. `settings.polar`). */
|
||||
i18nPrefix: string;
|
||||
/** Distinct card glyph, kept in the same size/treatment as the other cards. */
|
||||
icon: LucideIcon;
|
||||
/** Where this provider's synced data surfaces (e.g. `/insights/sleep`). */
|
||||
dataHref: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function OAuthProviderCard({
|
||||
provider,
|
||||
statusQueryKey,
|
||||
i18nPrefix,
|
||||
icon,
|
||||
dataHref,
|
||||
enabled = true,
|
||||
}: OAuthProviderCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
|
||||
const { data: status } = useQuery({
|
||||
queryKey: statusQueryKey,
|
||||
queryFn: async () => apiGet<OAuthProviderStatus>(`/api/${provider}/status`),
|
||||
enabled,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const disconnect = useMutation({
|
||||
mutationFn: async () => {
|
||||
await apiPost(`/api/${provider}/disconnect`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: statusQueryKey });
|
||||
},
|
||||
});
|
||||
|
||||
const pillState = pillStateFor(status);
|
||||
const errorMessage =
|
||||
(pillState === "error" || pillState === "parked") && status?.lastError
|
||||
? status.lastError
|
||||
: null;
|
||||
const serverUnavailable = status && !status.available;
|
||||
|
||||
function handleConnect() {
|
||||
setMsg(null);
|
||||
void invalidateKeys(queryClient, measurementDependentKeys);
|
||||
window.location.href = `/api/${provider}/connect`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={`${provider}-card`}
|
||||
className="bg-card border-border rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={icon}
|
||||
title={t(`${i18nPrefix}`)}
|
||||
description={<p>{t(`${i18nPrefix}Description`)}</p>}
|
||||
status={
|
||||
<IntegrationStatusPill
|
||||
state={pillState}
|
||||
lastSyncAt={status?.lastSuccessAt ?? null}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<hr className="border-border/60 mt-4" />
|
||||
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
{errorMessage && (
|
||||
<p
|
||||
role="alert"
|
||||
data-testid={`${provider}-error`}
|
||||
className="text-destructive text-sm break-words"
|
||||
>
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Parked-integration resume CTA. Surfaces only when the row state
|
||||
is `parked` (>24h of persistent failures). For an env-based OAuth
|
||||
provider there are no stored BYO credentials to re-validate — the
|
||||
grant itself has to be re-issued, so "reconnect" re-initiates the
|
||||
existing connect flow (`/api/<provider>/connect`); a successful
|
||||
callback clears the park. Markup matches the WHOOP card byte for
|
||||
byte. */}
|
||||
{pillState === "parked" && (
|
||||
<div
|
||||
data-testid={`${provider}-parked-banner`}
|
||||
className="border-warning/30 bg-warning/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="text-warning min-w-0 text-xs break-words">
|
||||
{t("settings.integrationPill.parkedReconnect")}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleConnect}
|
||||
disabled={serverUnavailable}
|
||||
data-testid={`${provider}-resume-button`}
|
||||
className="min-h-11"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
{t("settings.integrationPill.resumeCta")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(`${i18nPrefix}Help`)}
|
||||
</p>
|
||||
|
||||
{serverUnavailable && (
|
||||
<p
|
||||
className="text-muted-foreground/80 text-xs"
|
||||
data-testid={`${provider}-unavailable`}
|
||||
>
|
||||
{t(`${i18nPrefix}Unavailable`)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status?.connected ? (
|
||||
<>
|
||||
<div className="flex flex-wrap items-start gap-2 [&>*]:min-w-[10rem] sm:[&>*]:min-w-0">
|
||||
<TestConnectionButton
|
||||
endpoint={`/api/${provider}/test`}
|
||||
disabled={!status?.connected}
|
||||
/>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive min-h-11"
|
||||
data-testid={`${provider}-disconnect`}
|
||||
>
|
||||
<Unlink className="h-3.5 w-3.5" />
|
||||
{t(`${i18nPrefix}Disconnect`)}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t(`${i18nPrefix}DisconnectTitle`)}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t(`${i18nPrefix}DisconnectDescription`)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => disconnect.mutate()}
|
||||
>
|
||||
{t(`${i18nPrefix}Disconnect`)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
{/* connect→data loop: a discreet link to where this provider's
|
||||
readings now surface — doubles as the "your data is richer"
|
||||
cue. */}
|
||||
<Link
|
||||
href={dataHref}
|
||||
data-testid={`${provider}-data-link`}
|
||||
className="text-primary inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
|
||||
>
|
||||
{t(`${i18nPrefix}ViewData`)}
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
disabled={serverUnavailable}
|
||||
onClick={handleConnect}
|
||||
data-testid={`${provider}-connect`}
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
{t(`${i18nPrefix}Connect`)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{msg && (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
// v1.17.0 (F4) — Oura Cloud integration card. Thin wrapper over the shared
|
||||
// env-based OAuth card.
|
||||
|
||||
import { CircleDot } from "lucide-react";
|
||||
|
||||
import { OAuthProviderCard } from "@/components/settings/integrations/oauth-provider-card";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
export function OuraCard({ enabled = true }: { enabled?: boolean }) {
|
||||
return (
|
||||
<OAuthProviderCard
|
||||
provider="oura"
|
||||
statusQueryKey={queryKeys.oura()}
|
||||
i18nPrefix="settings.oura"
|
||||
icon={CircleDot}
|
||||
dataHref="/insights/sleep"
|
||||
enabled={enabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
// v1.17.0 (F4) — Polar AccessLink integration card. Thin wrapper over the
|
||||
// shared env-based OAuth card.
|
||||
|
||||
import { Watch } from "lucide-react";
|
||||
|
||||
import { OAuthProviderCard } from "@/components/settings/integrations/oauth-provider-card";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
export function PolarCard({ enabled = true }: { enabled?: boolean }) {
|
||||
return (
|
||||
<OAuthProviderCard
|
||||
provider="polar"
|
||||
statusQueryKey={queryKeys.polar()}
|
||||
i18nPrefix="settings.polar"
|
||||
icon={Watch}
|
||||
dataHref="/insights/sleep"
|
||||
enabled={enabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -24,12 +24,17 @@ import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
*/
|
||||
|
||||
interface NotificationPrefsShape {
|
||||
medication: { lowStockRunwayDays: number | null };
|
||||
medication: { lowStockRunwayDays: number | null; reorderLeadDays?: number };
|
||||
}
|
||||
|
||||
const DEFAULT_DAYS = 7;
|
||||
const MIN_DAYS = 1;
|
||||
const MAX_DAYS = 60;
|
||||
// v1.17.0 — reorder lead time. The alert widens its trigger by this lead
|
||||
// plus one dose-interval so a refill arrives before the last dose.
|
||||
const DEFAULT_LEAD = 10;
|
||||
const MIN_LEAD = 0;
|
||||
const MAX_LEAD = 60;
|
||||
|
||||
export function LowStockCard({
|
||||
isAuthenticated,
|
||||
@@ -42,6 +47,10 @@ export function LowStockCard({
|
||||
undefined,
|
||||
);
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const [leadDraft, setLeadDraft] = useState<string | null>(null);
|
||||
const [leadOptimistic, setLeadOptimistic] = useState<number | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
||||
@@ -87,8 +96,16 @@ export function LowStockCard({
|
||||
const enabled = current !== null;
|
||||
const days = current ?? DEFAULT_DAYS;
|
||||
|
||||
async function persist(next: number | null, successMsg: string) {
|
||||
setOptimistic(next);
|
||||
const persistedLead =
|
||||
prefs?.medication?.reorderLeadDays === undefined
|
||||
? DEFAULT_LEAD
|
||||
: prefs.medication.reorderLeadDays;
|
||||
const leadDays = leadOptimistic === undefined ? persistedLead : leadOptimistic;
|
||||
|
||||
async function patchPrefs(
|
||||
body: Record<string, unknown>,
|
||||
successMsg: string,
|
||||
): Promise<boolean> {
|
||||
setSaving(true);
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
@@ -100,7 +117,7 @@ export function LowStockCard({
|
||||
const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ medication: { lowStockRunwayDays: next } }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
@@ -114,14 +131,33 @@ export function LowStockCard({
|
||||
queryKey: queryKeys.settingsReminderThresholds(),
|
||||
}),
|
||||
]);
|
||||
setOptimistic(undefined);
|
||||
} else {
|
||||
setOptimistic(undefined);
|
||||
setMsg(t("notifications.lowStock.saveError"));
|
||||
setMsgType("error");
|
||||
}
|
||||
scheduleClear();
|
||||
setSaving(false);
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
async function persist(next: number | null, successMsg: string) {
|
||||
setOptimistic(next);
|
||||
const ok = await patchPrefs(
|
||||
{ medication: { lowStockRunwayDays: next } },
|
||||
successMsg,
|
||||
);
|
||||
setOptimistic(undefined);
|
||||
void ok;
|
||||
}
|
||||
|
||||
async function persistLead(next: number, successMsg: string) {
|
||||
setLeadOptimistic(next);
|
||||
const ok = await patchPrefs(
|
||||
{ medication: { reorderLeadDays: next } },
|
||||
successMsg,
|
||||
);
|
||||
setLeadOptimistic(undefined);
|
||||
void ok;
|
||||
}
|
||||
|
||||
async function handleToggle(next: boolean) {
|
||||
@@ -143,6 +179,16 @@ export function LowStockCard({
|
||||
void persist(clamped, t("notifications.lowStock.savedToast"));
|
||||
}
|
||||
|
||||
function commitLeadDraft() {
|
||||
if (leadDraft === null) return;
|
||||
const parsed = Number(leadDraft);
|
||||
setLeadDraft(null);
|
||||
if (!Number.isInteger(parsed)) return;
|
||||
const clamped = Math.min(MAX_LEAD, Math.max(MIN_LEAD, parsed));
|
||||
if (clamped === leadDays) return;
|
||||
void persistLead(clamped, t("notifications.lowStock.leadSavedToast"));
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
// `id` is the anchor target of the supply tab's cross-link
|
||||
@@ -198,6 +244,41 @@ export function LowStockCard({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* v1.17.0 — reorder lead time. Progressive disclosure: shown only
|
||||
while the alert is enabled, with a sensible default (10) most
|
||||
users never touch. A single medication can override this on its
|
||||
own supply tab. */}
|
||||
{enabled && (
|
||||
<div className="mt-4 pl-7">
|
||||
<div className="flex min-h-11 items-center gap-3">
|
||||
<label htmlFor="low-stock-lead" className="text-sm font-medium">
|
||||
{t("notifications.lowStock.leadLabel")}
|
||||
</label>
|
||||
<Input
|
||||
id="low-stock-lead"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={MIN_LEAD}
|
||||
max={MAX_LEAD}
|
||||
className="w-24"
|
||||
value={leadDraft ?? String(leadDays)}
|
||||
disabled={!isAuthenticated || saving}
|
||||
onChange={(e) => setLeadDraft(e.target.value)}
|
||||
onBlur={commitLeadDraft}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commitLeadDraft();
|
||||
}}
|
||||
aria-label={t("notifications.lowStock.leadAria")}
|
||||
autoComplete="off"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1.5 max-w-prose text-xs">
|
||||
{t("notifications.lowStock.leadHelp")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{msg && (
|
||||
<p
|
||||
role="status"
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { SheetSection, SheetSectionCount } from "../sheet-section";
|
||||
|
||||
describe("<SheetSection>", () => {
|
||||
it("renders the title and summary slot on the trigger row", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SheetSection title="More tags" summary={<span>summary-badge</span>}>
|
||||
<p>section body</p>
|
||||
</SheetSection>,
|
||||
);
|
||||
expect(html).toContain("More tags");
|
||||
expect(html).toContain("summary-badge");
|
||||
});
|
||||
|
||||
it("renders children inside an open section", () => {
|
||||
// Radix Collapsible omits the content subtree while collapsed, so the
|
||||
// body is asserted on an expanded section.
|
||||
const html = renderToStaticMarkup(
|
||||
<SheetSection title="More tags" defaultOpen>
|
||||
<p>section body</p>
|
||||
</SheetSection>,
|
||||
);
|
||||
expect(html).toContain("section body");
|
||||
});
|
||||
|
||||
it("starts collapsed by default and open when defaultOpen", () => {
|
||||
const closed = renderToStaticMarkup(
|
||||
<SheetSection title="Factors">
|
||||
<p>x</p>
|
||||
</SheetSection>,
|
||||
);
|
||||
const open = renderToStaticMarkup(
|
||||
<SheetSection title="Factors" defaultOpen>
|
||||
<p>x</p>
|
||||
</SheetSection>,
|
||||
);
|
||||
expect(closed).toContain('data-state="closed"');
|
||||
expect(open).toContain('data-state="open"');
|
||||
});
|
||||
|
||||
it("hides the leading icon from assistive tech", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SheetSection title="Note" icon={<svg data-testid="icon" />}>
|
||||
<p>x</p>
|
||||
</SheetSection>,
|
||||
);
|
||||
expect(html).toMatch(/aria-hidden="true"[^>]*>[^<]*<svg/);
|
||||
});
|
||||
|
||||
it("respects reduced motion on the content and chevron", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SheetSection title="x">
|
||||
<p>y</p>
|
||||
</SheetSection>,
|
||||
);
|
||||
expect(html).toContain("motion-reduce:animate-none");
|
||||
expect(html).toContain("motion-reduce:transition-none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<SheetSectionCount>", () => {
|
||||
it("renders the count when positive", () => {
|
||||
const html = renderToStaticMarkup(<SheetSectionCount count={3} />);
|
||||
expect(html).toContain("3");
|
||||
expect(html).toContain('data-slot="sheet-section-count"');
|
||||
});
|
||||
|
||||
it("renders nothing for zero or negative counts", () => {
|
||||
expect(renderToStaticMarkup(<SheetSectionCount count={0} />)).toBe("");
|
||||
expect(renderToStaticMarkup(<SheetSectionCount count={-1} />)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { Collapsible as CollapsiblePrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* v1.17.0 — `SheetSection` is the collapsible disclosure primitive that
|
||||
* powers the sectioned single-sheet add flows (mood, cycle). Each section
|
||||
* carries a title, an optional leading icon, and a `summary` slot that
|
||||
* communicates what is selected inside it while collapsed (e.g. a count
|
||||
* badge) — so a closed section never hides its contents silently.
|
||||
*
|
||||
* Built on Radix Collapsible. The chevron rotates and the content height
|
||||
* animates open/closed via the `tw-animate-css` `collapsible-down/up`
|
||||
* keyframes; both motions are suppressed under `prefers-reduced-motion`.
|
||||
* Matches the shadcn new-york + zinc token language used across the app.
|
||||
*/
|
||||
|
||||
interface SheetSectionProps {
|
||||
/** Section heading shown on the always-visible trigger row. */
|
||||
title: string;
|
||||
/**
|
||||
* A node rendered on the trigger row's trailing edge — typically a count
|
||||
* badge that reports what is selected inside while the section is
|
||||
* collapsed. Stays visible whether the section is open or closed.
|
||||
*/
|
||||
summary?: React.ReactNode;
|
||||
/** Optional leading icon (a Lucide component or any node). */
|
||||
icon?: React.ReactNode;
|
||||
/** Whether the section starts expanded (uncontrolled). Defaults to closed. */
|
||||
defaultOpen?: boolean;
|
||||
/** Controlled open state. When set, pair with `onOpenChange`. */
|
||||
open?: boolean;
|
||||
/** Controlled open-state change handler. */
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/** Optional className merged onto the section wrapper. */
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SheetSection({
|
||||
title,
|
||||
summary,
|
||||
icon,
|
||||
defaultOpen = false,
|
||||
open,
|
||||
onOpenChange,
|
||||
className,
|
||||
children,
|
||||
}: SheetSectionProps) {
|
||||
return (
|
||||
<CollapsiblePrimitive.Root
|
||||
// Controlled when `open` is provided, otherwise uncontrolled.
|
||||
{...(open === undefined ? { defaultOpen } : { open, onOpenChange })}
|
||||
data-slot="sheet-section"
|
||||
className={cn("border-border/60 border-t pt-3", className)}
|
||||
>
|
||||
<CollapsiblePrimitive.Trigger
|
||||
data-slot="sheet-section-trigger"
|
||||
className="group focus-visible:ring-ring/50 -mx-1 flex w-[calc(100%+0.5rem)] items-center gap-2 rounded-md px-1 py-1.5 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
{icon ? (
|
||||
<span
|
||||
className="text-muted-foreground flex h-4 w-4 shrink-0 items-center justify-center [&>svg]:h-4 [&>svg]:w-4"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-foreground flex-1 text-sm font-medium">
|
||||
{title}
|
||||
</span>
|
||||
{summary ? (
|
||||
<span
|
||||
data-slot="sheet-section-summary"
|
||||
className="flex items-center"
|
||||
>
|
||||
{summary}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className="text-muted-foreground h-4 w-4 shrink-0 transition-transform duration-200 group-data-[state=open]:rotate-180 motion-reduce:transition-none"
|
||||
/>
|
||||
</CollapsiblePrimitive.Trigger>
|
||||
<CollapsiblePrimitive.Content
|
||||
data-slot="sheet-section-content"
|
||||
className="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down motion-reduce:animate-none"
|
||||
>
|
||||
<div className="space-y-3 pt-3 pb-1">{children}</div>
|
||||
</CollapsiblePrimitive.Content>
|
||||
</CollapsiblePrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A compact "(N)" count badge for a `SheetSection` summary slot. Renders
|
||||
* nothing when `count <= 0`, so a section with no selections shows a bare
|
||||
* chevron rather than a "(0)".
|
||||
*/
|
||||
export function SheetSectionCount({ count }: { count: number }) {
|
||||
if (count <= 0) return null;
|
||||
return (
|
||||
<span
|
||||
data-slot="sheet-section-count"
|
||||
className="bg-primary/15 text-primary inline-flex min-w-5 items-center justify-center rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums"
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "../doctor-report-pdf-core";
|
||||
import { measurementTypeEnum } from "../validations/measurement";
|
||||
import type { DoctorReportData } from "../doctor-report-data";
|
||||
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
|
||||
import { getServerTranslator } from "../i18n/server-translator";
|
||||
|
||||
async function extractText(bytes: Uint8Array): Promise<string> {
|
||||
@@ -67,6 +68,7 @@ function makeData(overrides?: Partial<DoctorReportData>): DoctorReportData {
|
||||
FASTING: { avg: 92, min: 85, max: 100, count: 4, latest: 90 },
|
||||
},
|
||||
glucoseRanges: { FASTING: { min: 70, max: 99 } },
|
||||
glucoseClinical: computeGlucoseClinicalMetrics([], { now: FIXED_NOW }),
|
||||
glucoseUnit: "mg/dL",
|
||||
bmi: 24.1,
|
||||
compliance: {
|
||||
|
||||
@@ -333,3 +333,77 @@ describe("buildCoachSnapshot — Apple Health additive metrics (v1.4.23)", () =>
|
||||
expect(snapshot.bodyFat).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.17.0 — WHOOP-native day strain (0–21) reaches the Coach snapshot as its
|
||||
* own `dayStrain` block, distinct from the computed `derived.STRAIN_SCORE`.
|
||||
* The read is a SEPARATE `where.type === "DAY_STRAIN"` query, so the fixtures
|
||||
* here discriminate the mock by type rather than returning one flat array.
|
||||
*/
|
||||
describe("buildCoachSnapshot — WHOOP-native day strain (v1.17.0)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
__resetCoachSnapshotCacheForTests();
|
||||
prismaMock.moodEntry.findMany.mockResolvedValue([]);
|
||||
prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]);
|
||||
prismaMock.medication.findMany.mockResolvedValue([]);
|
||||
prismaMock.workout.findMany.mockResolvedValue([]);
|
||||
prismaMock.user.findUnique.mockResolvedValue({ coachPrefsJson: null });
|
||||
featuresMock.mockResolvedValue({
|
||||
bloodPressure: undefined,
|
||||
weight: undefined,
|
||||
pulse: undefined,
|
||||
mood: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
/** Route each findMany to rows matching its `where.type` filter. */
|
||||
function mockByType(byType: Record<string, ReturnType<typeof daysAgo>[]>) {
|
||||
prismaMock.measurement.findMany.mockImplementation((args: unknown) => {
|
||||
const where = (args as { where?: { type?: unknown } }).where;
|
||||
const t = where?.type;
|
||||
if (typeof t === "string") return Promise.resolve(byType[t] ?? []);
|
||||
if (t && typeof t === "object" && "in" in t) {
|
||||
const wanted = (t as { in: string[] }).in;
|
||||
return Promise.resolve(
|
||||
wanted.flatMap((typeName) => byType[typeName] ?? []),
|
||||
);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
}
|
||||
|
||||
it("emits a native dayStrain block on DAY_STRAIN rows for a WHOOP user", async () => {
|
||||
mockByType({
|
||||
HEART_RATE_VARIABILITY: [daysAgo(2, 64, "HEART_RATE_VARIABILITY")],
|
||||
// Ascending (oldest first), mirroring the `orderBy: measuredAt asc` read.
|
||||
DAY_STRAIN: [daysAgo(3, 11.8, "DAY_STRAIN"), daysAgo(1, 14.2, "DAY_STRAIN")],
|
||||
});
|
||||
|
||||
const out = await buildCoachSnapshot("user-1", {
|
||||
sources: ["hrv"],
|
||||
window: "last30days",
|
||||
});
|
||||
const snapshot = JSON.parse(out.snapshotJson) as Record<string, unknown>;
|
||||
const dayStrain = snapshot.dayStrain as Record<string, unknown> | undefined;
|
||||
|
||||
expect(dayStrain).toBeDefined();
|
||||
expect(dayStrain?.scale).toBe("0-21");
|
||||
expect(dayStrain?.latest).toBe(14.2);
|
||||
expect(dayStrain?.days).toBe(2);
|
||||
});
|
||||
|
||||
it("omits dayStrain when the account has no DAY_STRAIN rows", async () => {
|
||||
mockByType({
|
||||
HEART_RATE_VARIABILITY: [daysAgo(2, 64, "HEART_RATE_VARIABILITY")],
|
||||
});
|
||||
|
||||
const out = await buildCoachSnapshot("user-1", {
|
||||
sources: ["hrv"],
|
||||
window: "last30days",
|
||||
});
|
||||
const snapshot = JSON.parse(out.snapshotJson) as Record<string, unknown>;
|
||||
|
||||
expect(snapshot.dayStrain).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,11 @@ vi.mock("../memory-snapshot", () => ({
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { extractFeatures } from "@/lib/insights/features";
|
||||
import {
|
||||
reconstructNights,
|
||||
sleepNeedMinutes,
|
||||
} from "@/lib/insights/derived/sleep-score";
|
||||
import { computeSleepRhythmFromNights } from "@/lib/insights/derived/sleep-rhythm";
|
||||
|
||||
const prismaMock = prisma as unknown as {
|
||||
measurement: { findMany: ReturnType<typeof vi.fn> };
|
||||
@@ -404,6 +409,119 @@ describe("buildCoachSnapshot", () => {
|
||||
// Two distinct window keys → two cache slots → two Prisma reads.
|
||||
expect(prismaMock.measurement.findMany).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// ── v1.17.0 sleep-rhythm block (sleep-debt + chronotype) ──────────
|
||||
//
|
||||
// The coach reads the SAME computed values the Sleep page + dashboard
|
||||
// show: it hands the canonical per-night reconstruction to the SAME
|
||||
// assembler (`computeSleepRhythmFromNights`) the dashboard route uses,
|
||||
// never recomputing sleep-debt or chronotype inline. These tests pin
|
||||
// the reuse contract + the learning-gate honesty.
|
||||
|
||||
/**
|
||||
* One night's bare-ASLEEP block, wake instant in UTC. `reconstructNights`
|
||||
* reads asleep = `asleepMinutes` and a midpoint at wake − asleep/2 — the
|
||||
* same fixture shape the sleep-rhythm unit suite uses.
|
||||
*/
|
||||
function nightRow(wakeIso: string, asleepMinutes: number) {
|
||||
return {
|
||||
type: "SLEEP_DURATION",
|
||||
value: asleepMinutes,
|
||||
measuredAt: new Date(wakeIso),
|
||||
sleepStage: "ASLEEP" as const,
|
||||
source: null,
|
||||
deviceType: null,
|
||||
};
|
||||
}
|
||||
|
||||
it("emits a ready sleepRhythm block equal to the assembler's DTO", async () => {
|
||||
// 14 consecutive nights across two weekends → debt ready (≥7 nights)
|
||||
// and chronotype ready (≥3 free/weekend nights). 2026-06-01 is a Monday.
|
||||
const rows = Array.from({ length: 14 }, (_, i) => {
|
||||
const day = 1 + i; // 2026-06-01 .. 2026-06-14
|
||||
const dd = String(day).padStart(2, "0");
|
||||
return nightRow(`2026-06-${dd}T06:00:00Z`, 360 + (i % 3) * 20);
|
||||
});
|
||||
prismaMock.measurement.findMany.mockResolvedValue(rows);
|
||||
featuresMock.mockResolvedValue({
|
||||
context: { heightCm: null, ageYears: 40, gender: null },
|
||||
});
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
coachPrefsJson: null,
|
||||
timezone: "UTC",
|
||||
});
|
||||
|
||||
const out = await buildCoachSnapshot("user-1", { sources: ["sleep"] });
|
||||
const parsed = JSON.parse(out.snapshotJson);
|
||||
|
||||
// Independently run the SAME engine over the SAME rows + need — the
|
||||
// snapshot must equal it field-for-field (reuse, never recompute).
|
||||
const expected = computeSleepRhythmFromNights(
|
||||
reconstructNights(rows, "UTC", null),
|
||||
sleepNeedMinutes(40),
|
||||
);
|
||||
expect(expected.sleepDebt.state).toBe("ready");
|
||||
expect(expected.chronotype.state).toBe("ready");
|
||||
expect(expected.chronotype.band).not.toBeNull();
|
||||
|
||||
expect(parsed.sleepRhythm.sleepDebt).toEqual({
|
||||
state: expected.sleepDebt.state,
|
||||
debtMinutes: expected.sleepDebt.debtMinutes,
|
||||
needMinutes: expected.sleepDebt.needMinutes,
|
||||
});
|
||||
expect(parsed.sleepRhythm.chronotype).toEqual({
|
||||
state: expected.chronotype.state,
|
||||
band: expected.chronotype.band,
|
||||
socialJetlagMinutes: expected.chronotype.socialJetlagMinutes,
|
||||
});
|
||||
});
|
||||
|
||||
it("never asserts a band for a learning chronotype", async () => {
|
||||
// 8 weekday nights (2026-06-15 Mon .. 2026-06-22 Mon, weekends 06-20/21
|
||||
// are the only free days) — enough for debt to be ready but the
|
||||
// chronotype stays learning until it has ≥3 free-day nights. With only
|
||||
// two weekend nights here, the band must NOT be asserted.
|
||||
const weekdayRows = [15, 16, 17, 18, 19, 22].map((d) =>
|
||||
nightRow(`2026-06-${String(d).padStart(2, "0")}T06:00:00Z`, 380),
|
||||
);
|
||||
prismaMock.measurement.findMany.mockResolvedValue(weekdayRows);
|
||||
featuresMock.mockResolvedValue({
|
||||
context: { heightCm: null, ageYears: 40, gender: null },
|
||||
});
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
coachPrefsJson: null,
|
||||
timezone: "UTC",
|
||||
});
|
||||
|
||||
const out = await buildCoachSnapshot("user-1", { sources: ["sleep"] });
|
||||
const parsed = JSON.parse(out.snapshotJson);
|
||||
|
||||
const expected = computeSleepRhythmFromNights(
|
||||
reconstructNights(weekdayRows, "UTC", null),
|
||||
sleepNeedMinutes(40),
|
||||
);
|
||||
expect(expected.chronotype.state).toBe("learning");
|
||||
|
||||
expect(parsed.sleepRhythm.chronotype.state).toBe("learning");
|
||||
// A learning chronotype carries no band the data supports.
|
||||
expect(parsed.sleepRhythm.chronotype.band).toBeNull();
|
||||
expect(parsed.sleepRhythm.chronotype.socialJetlagMinutes).toBeNull();
|
||||
});
|
||||
|
||||
it("omits the sleepRhythm block when no sleep rows exist", async () => {
|
||||
prismaMock.measurement.findMany.mockResolvedValue([]);
|
||||
featuresMock.mockResolvedValue({
|
||||
context: { heightCm: null, ageYears: 40, gender: null },
|
||||
});
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
coachPrefsJson: null,
|
||||
timezone: "UTC",
|
||||
});
|
||||
|
||||
const out = await buildCoachSnapshot("user-1", { sources: ["sleep"] });
|
||||
const parsed = JSON.parse(out.snapshotJson);
|
||||
expect(parsed.sleepRhythm).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -98,6 +98,26 @@ describe("getCoachSystemPrompt — EN", () => {
|
||||
// appear.
|
||||
expect(prompt).not.toMatch(/@.+\.(com|net|org|de|test)/);
|
||||
});
|
||||
|
||||
it("documents the glucose.clinical panel sub-key and its gates", () => {
|
||||
// v1.17.0 — the model must know the `clinical` sub-key exists, how to
|
||||
// read TIR/GMI/eA1C/CV, and must NOT assert a clinical figure when the
|
||||
// learning-gate or the spot-estimate caveat is set.
|
||||
expect(prompt).toMatch(/"clinical"/);
|
||||
expect(prompt).toMatch(/tirPercent/);
|
||||
expect(prompt).toMatch(/\bgmi\b/);
|
||||
expect(prompt).toMatch(/estimatedA1c/);
|
||||
expect(prompt).toMatch(/cvPercent/);
|
||||
expect(prompt).toMatch(/stillLearning/);
|
||||
expect(prompt).toMatch(/isSpotEstimate/);
|
||||
});
|
||||
|
||||
it("documents the WHOOP-native dayStrain block vs the computed proxy", () => {
|
||||
// v1.17.0 — native 0–21 strain preferred over the computed 0–100 proxy.
|
||||
expect(prompt).toMatch(/dayStrain/);
|
||||
expect(prompt).toMatch(/0–21/);
|
||||
expect(prompt).toMatch(/0–100/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCoachSystemPrompt — DE", () => {
|
||||
@@ -153,6 +173,14 @@ describe("getCoachSystemPrompt — DE", () => {
|
||||
expect(prompt).toMatch(/Bewegung sehe ich in deinem Tracking gerade nicht/);
|
||||
expect(prompt).toMatch(/außerhalb dessen, womit ich helfen kann/);
|
||||
});
|
||||
|
||||
it("lists glucose + duration units in the evidence-block enumeration", () => {
|
||||
// v1.17.0 — the DE unit list must sanction mg/dL, mmol/L and min so a
|
||||
// German coach can cite glucose + duration values with a valid token.
|
||||
expect(prompt).toMatch(/mg\/dL/);
|
||||
expect(prompt).toMatch(/mmol\/L/);
|
||||
expect(prompt).toMatch(/min[\s\S]*\(Schlafdefizit/);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
Loaded 100 of 177 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user