diff --git a/.env.production.example b/.env.production.example index 28072371e..d53e7acd1 100644 --- a/.env.production.example +++ b/.env.production.example @@ -50,6 +50,29 @@ API_TOKEN_HMAC_KEY="" # WHOOP_WEBHOOK_SECRET="" # 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) diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index 3b5721a18..fcae76b97 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -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 }} diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 7089c65cf..a4ed9dfb1 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -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: | diff --git a/CHANGELOG.md b/CHANGELOG.md index a2979e664..71e96a1bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docker-compose.yml b/docker-compose.yml index 67bc2ddbb..57e84f8b5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9a42410e5..bf628854e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.16.17 + version: 1.17.0 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -1634,6 +1634,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA - in: query name: groupBy schema: @@ -1903,6 +1906,27 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/sleep/rhythm: + get: + tags: + - Measurements + summary: Sleep rhythm — sleep-debt + chronotype (v1.17.0) + description: "Returns the two server-authoritative sleep-timing signals the Sleep page + iOS render off the same + canonical night reconstruction the Sleep Score uses: cumulative `sleepDebt` over the rolling window (calm + `partial` state under the night threshold) and MCTQ `chronotype` (MSF/MSFsc band + social jetlag, `learning` + state until enough free-day nights exist). Free vs work nights default to weekend = free in the user's timezone + (no work calendar). A read-only view over existing per-stage SLEEP_DURATION rows — no schema, no new measurement + type." + responses: + "200": + description: Sleep-debt + chronotype DTO. + content: + application/json: + schema: + $ref: "#/components/schemas/SleepRhythmResponse" + "401": *a1 + "422": *a3 + "429": *a4 /api/workouts: get: tags: @@ -2080,6 +2104,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA externalId: type: string maxLength: 128 @@ -3083,6 +3110,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA activeEnergy: maxItems: 8 type: array @@ -3096,6 +3126,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA walkingRunningDistance: maxItems: 8 type: array @@ -3109,6 +3142,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA flightsClimbed: maxItems: 8 type: array @@ -3122,6 +3158,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA sleep: maxItems: 8 type: array @@ -3135,6 +3174,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA weight: maxItems: 8 type: array @@ -3148,6 +3190,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA bloodPressure: maxItems: 8 type: array @@ -3161,6 +3206,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA pulse: maxItems: 8 type: array @@ -3174,6 +3222,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA bodyFat: maxItems: 8 type: array @@ -3187,6 +3238,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA bodyTemperature: maxItems: 8 type: array @@ -3200,6 +3254,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA spo2: maxItems: 8 type: array @@ -3213,6 +3270,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA hrv: maxItems: 8 type: array @@ -3226,6 +3286,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA restingHeartRate: maxItems: 8 type: array @@ -3239,6 +3302,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA vo2Max: maxItems: 8 type: array @@ -3252,6 +3318,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA skinTemperature: maxItems: 8 type: array @@ -3265,6 +3334,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA respiratoryRate: maxItems: 8 type: array @@ -3278,6 +3350,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA recovery: maxItems: 8 type: array @@ -3291,6 +3366,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA metricPriority: type: object properties: @@ -3307,6 +3385,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA activeEnergy: maxItems: 8 type: array @@ -3320,6 +3401,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA walkingRunningDistance: maxItems: 8 type: array @@ -3333,6 +3417,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA flightsClimbed: maxItems: 8 type: array @@ -3346,6 +3433,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA sleep: maxItems: 8 type: array @@ -3359,6 +3449,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA weight: maxItems: 8 type: array @@ -3372,6 +3465,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA bloodPressure: maxItems: 8 type: array @@ -3385,6 +3481,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA pulse: maxItems: 8 type: array @@ -3398,6 +3497,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA bodyFat: maxItems: 8 type: array @@ -3411,6 +3513,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA bodyTemperature: maxItems: 8 type: array @@ -3424,6 +3529,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA spo2: maxItems: 8 type: array @@ -3437,6 +3545,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA hrv: maxItems: 8 type: array @@ -3450,6 +3561,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA restingHeartRate: maxItems: 8 type: array @@ -3463,6 +3577,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA vo2Max: maxItems: 8 type: array @@ -3476,6 +3593,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA skinTemperature: maxItems: 8 type: array @@ -3489,6 +3609,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA respiratoryRate: maxItems: 8 type: array @@ -3502,6 +3625,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA recovery: maxItems: 8 type: array @@ -3515,6 +3641,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA deviceTypePriority: type: object properties: @@ -5352,6 +5481,12 @@ components: supported fraction for a split pill (¼ / ⅓ / ½ / ⅔ / ¾). Default 1. The intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it. type: number + reorderLeadDays: + description: Per-medication reorder lead time in days (0–60). The low-stock alert fires the supply early enough to + reorder before the last dose. Omitted = inherit the user-level reorderLeadDays default (10). + type: integer + minimum: 0 + maximum: 60 deliveryForm: type: string enum: @@ -5639,6 +5774,14 @@ components: / ¾). The intake consumption hook decrements this many units per taken dose; already-stamped intake events keep their recorded consumption. type: number + reorderLeadDays: + description: Per-medication reorder lead time in days (0–60). null clears the override and reverts to the user-level + reorderLeadDays default. + anyOf: + - type: integer + minimum: 0 + maximum: 60 + - type: "null" deliveryForm: type: string enum: @@ -6038,6 +6181,10 @@ components: minimum: 1 maximum: 60 - type: "null" + reorderLeadDays: + type: integer + minimum: 0 + maximum: 60 mood: type: object properties: @@ -8157,6 +8304,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA notes: anyOf: - type: string @@ -8583,6 +8733,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA notes: anyOf: - type: string @@ -8797,6 +8950,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA - type: "null" start: type: string @@ -8914,6 +9070,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA - type: "null" start: type: string @@ -9018,6 +9177,134 @@ components: additionalProperties: false description: "One reconstructed sleep night: the main session's hypnogram segments + breakdown, plus same-wake-day naps surfaced separately." + SleepRhythmResponse: + type: object + properties: + data: + $ref: "#/components/schemas/SleepRhythmResource" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + SleepRhythmResource: + type: object + properties: + sleepDebt: + type: object + properties: + state: + type: string + enum: + - partial + - ready + description: "`partial` until enough tracked nights exist (calm 'still learning'); `ready` once the rolling window can + assert a cumulative debt." + debtMinutes: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Cumulative sleep debt over the window, in minutes, after caps. + needMinutes: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Age-based sleep need (minutes) used for the per-night deficit. + nightsCounted: + type: integer + minimum: 0 + maximum: 9007199254740991 + windowNights: + type: integer + exclusiveMinimum: 0 + maximum: 9007199254740991 + description: Rolling window length in nights. + nightsUntilReady: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Nights still needed before the debt is asserted (0 when ready). + required: + - state + - debtMinutes + - needMinutes + - nightsCounted + - windowNights + - nightsUntilReady + additionalProperties: false + chronotype: + type: object + properties: + state: + type: string + enum: + - learning + - ready + description: "`learning` until enough free-day nights exist (no band asserted); `ready` once the free-day sample is + large enough." + msfMinutes: + anyOf: + - type: number + - type: "null" + description: Mid-sleep on free days (minutes-of-day), null while learning. + msfScMinutes: + anyOf: + - type: number + - type: "null" + description: Sleep-debt-corrected mid-sleep on free days (MSFsc, minutes-of-day), null while learning. + band: + anyOf: + - type: string + enum: + - extreme_early + - early + - intermediate + - late + - extreme_late + - type: "null" + description: MCTQ chronotype band off MSFsc, null while learning. + socialJetlagMinutes: + anyOf: + - type: number + - type: "null" + description: Social jetlag = circular |MSF_work − MSF_free| in minutes, null when one side is missing. + freeNightsCounted: + type: integer + minimum: 0 + maximum: 9007199254740991 + workNightsCounted: + type: integer + minimum: 0 + maximum: 9007199254740991 + freeNightsUntilReady: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Free-day nights still needed before a band is asserted. + required: + - state + - msfMinutes + - msfScMinutes + - band + - socialJetlagMinutes + - freeNightsCounted + - workNightsCounted + - freeNightsUntilReady + additionalProperties: false + required: + - sleepDebt + - chronotype + additionalProperties: false + description: "Server-authoritative sleep-rhythm read: cumulative sleep debt over the rolling window + MCTQ chronotype + (MSF/MSFsc band, social jetlag). Free vs work nights default to weekend = free in the user's timezone (no work + calendar). A view over existing per-stage SLEEP_DURATION rows — no schema, no new type." ListWorkoutsResponse: type: object properties: @@ -9120,6 +9407,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA externalId: anyOf: - type: string @@ -9225,6 +9515,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA externalId: anyOf: - type: string @@ -9432,6 +9725,15 @@ components: description: v1.16.10 — inventory units one dose consumes (e.g. 2 tablets of 2 mg for a 4 mg dose). v1.16.12 — may be a split-pill fraction (¼ / ⅓ / ½ / ⅔ / ¾); thirds carry as ≈0.3333 / 0.6667. Default 1. The intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it. + reorderLeadDays: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: v1.17.0 — optional per-medication reorder lead time in days (0–60). The low-stock alert widens its trigger + by this lead plus one dose-interval so a refill arrives before the last dose. null = inherit the user-level + notificationPrefs.medication.reorderLeadDays default (10). active: type: boolean notificationsEnabled: @@ -9565,6 +9867,7 @@ components: - treatmentClass - dosesPerUnit - unitsPerDose + - reorderLeadDays - active - notificationsEnabled - liveActivityEnabled @@ -9650,6 +9953,15 @@ components: description: v1.16.10 — inventory units one dose consumes (e.g. 2 tablets of 2 mg for a 4 mg dose). v1.16.12 — may be a split-pill fraction (¼ / ⅓ / ½ / ⅔ / ¾); thirds carry as ≈0.3333 / 0.6667. Default 1. The intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it. + reorderLeadDays: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: v1.17.0 — optional per-medication reorder lead time in days (0–60). The low-stock alert widens its trigger + by this lead plus one dose-interval so a refill arrives before the last dose. null = inherit the user-level + notificationPrefs.medication.reorderLeadDays default (10). active: type: boolean notificationsEnabled: @@ -9750,6 +10062,7 @@ components: - treatmentClass - dosesPerUnit - unitsPerDose + - reorderLeadDays - active - notificationsEnabled - liveActivityEnabled @@ -11176,6 +11489,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA activeEnergy: maxItems: 8 type: array @@ -11189,6 +11505,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA walkingRunningDistance: maxItems: 8 type: array @@ -11202,6 +11521,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA flightsClimbed: maxItems: 8 type: array @@ -11215,6 +11537,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA sleep: maxItems: 8 type: array @@ -11228,6 +11553,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA weight: maxItems: 8 type: array @@ -11241,6 +11569,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA bloodPressure: maxItems: 8 type: array @@ -11254,6 +11585,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA pulse: maxItems: 8 type: array @@ -11267,6 +11601,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA bodyFat: maxItems: 8 type: array @@ -11280,6 +11617,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA bodyTemperature: maxItems: 8 type: array @@ -11293,6 +11633,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA spo2: maxItems: 8 type: array @@ -11306,6 +11649,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA hrv: maxItems: 8 type: array @@ -11319,6 +11665,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA restingHeartRate: maxItems: 8 type: array @@ -11332,6 +11681,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA vo2Max: maxItems: 8 type: array @@ -11345,6 +11697,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA skinTemperature: maxItems: 8 type: array @@ -11358,6 +11713,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA respiratoryRate: maxItems: 8 type: array @@ -11371,6 +11729,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA recovery: maxItems: 8 type: array @@ -11384,6 +11745,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA metricPriority: type: object properties: @@ -11400,6 +11764,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA activeEnergy: maxItems: 8 type: array @@ -11413,6 +11780,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA walkingRunningDistance: maxItems: 8 type: array @@ -11426,6 +11796,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA flightsClimbed: maxItems: 8 type: array @@ -11439,6 +11812,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA sleep: maxItems: 8 type: array @@ -11452,6 +11828,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA weight: maxItems: 8 type: array @@ -11465,6 +11844,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA bloodPressure: maxItems: 8 type: array @@ -11478,6 +11860,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA pulse: maxItems: 8 type: array @@ -11491,6 +11876,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA bodyFat: maxItems: 8 type: array @@ -11504,6 +11892,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA bodyTemperature: maxItems: 8 type: array @@ -11517,6 +11908,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA spo2: maxItems: 8 type: array @@ -11530,6 +11924,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA hrv: maxItems: 8 type: array @@ -11543,6 +11940,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA restingHeartRate: maxItems: 8 type: array @@ -11556,6 +11956,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA vo2Max: maxItems: 8 type: array @@ -11569,6 +11972,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA skinTemperature: maxItems: 8 type: array @@ -11582,6 +11988,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA respiratoryRate: maxItems: 8 type: array @@ -11595,6 +12004,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA recovery: maxItems: 8 type: array @@ -11608,6 +12020,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA additionalProperties: false deviceTypePriority: type: object @@ -11670,6 +12085,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA activeEnergy: maxItems: 8 type: array @@ -11683,6 +12101,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA walkingRunningDistance: maxItems: 8 type: array @@ -11696,6 +12117,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA flightsClimbed: maxItems: 8 type: array @@ -11709,6 +12133,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA sleep: maxItems: 8 type: array @@ -11722,6 +12149,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA weight: maxItems: 8 type: array @@ -11735,6 +12165,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA bloodPressure: maxItems: 8 type: array @@ -11748,6 +12181,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA pulse: maxItems: 8 type: array @@ -11761,6 +12197,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA bodyFat: maxItems: 8 type: array @@ -11774,6 +12213,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA bodyTemperature: maxItems: 8 type: array @@ -11787,6 +12229,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA spo2: maxItems: 8 type: array @@ -11800,6 +12245,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA hrv: maxItems: 8 type: array @@ -11813,6 +12261,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA restingHeartRate: maxItems: 8 type: array @@ -11826,6 +12277,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA vo2Max: maxItems: 8 type: array @@ -11839,6 +12293,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA skinTemperature: maxItems: 8 type: array @@ -11852,6 +12309,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA respiratoryRate: maxItems: 8 type: array @@ -11865,6 +12325,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA recovery: maxItems: 8 type: array @@ -11878,6 +12341,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA metricPriority: type: object properties: @@ -11894,6 +12360,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA activeEnergy: maxItems: 8 type: array @@ -11907,6 +12376,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA walkingRunningDistance: maxItems: 8 type: array @@ -11920,6 +12392,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA flightsClimbed: maxItems: 8 type: array @@ -11933,6 +12408,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA sleep: maxItems: 8 type: array @@ -11946,6 +12424,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA weight: maxItems: 8 type: array @@ -11959,6 +12440,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA bloodPressure: maxItems: 8 type: array @@ -11972,6 +12456,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA pulse: maxItems: 8 type: array @@ -11985,6 +12472,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA bodyFat: maxItems: 8 type: array @@ -11998,6 +12488,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA bodyTemperature: maxItems: 8 type: array @@ -12011,6 +12504,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA spo2: maxItems: 8 type: array @@ -12024,6 +12520,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA hrv: maxItems: 8 type: array @@ -12037,6 +12536,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA restingHeartRate: maxItems: 8 type: array @@ -12050,6 +12552,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA vo2Max: maxItems: 8 type: array @@ -12063,6 +12568,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA skinTemperature: maxItems: 8 type: array @@ -12076,6 +12584,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA respiratoryRate: maxItems: 8 type: array @@ -12089,6 +12600,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA recovery: maxItems: 8 type: array @@ -12102,6 +12616,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA additionalProperties: false deviceTypePriority: type: object @@ -12412,10 +12929,18 @@ components: maximum: 60 - type: "null" description: Low-stock alert threshold as remaining runway days (1–60). null = alert off. Default 7. + reorderLeadDays: + type: integer + minimum: 0 + maximum: 60 + description: Reorder lead time in days (0–60) the low-stock alert assumes. The alert fires this lead plus one + dose-interval before the supply runs out so a refill arrives before the last dose. Default 10; a + per-medication reorderLeadDays overrides it. required: - clientManaged - deliveryDefault - lowStockRunwayDays + - reorderLeadDays additionalProperties: false mood: type: object @@ -12972,7 +13497,8 @@ components: description: Raw invite token (`hlv_<64hex>`). Shown exactly once — never persisted in plaintext. url: type: string - description: Composed registration deep link (`/auth/register?invite=…`). + description: Composed invite universal-link (`/invite/`). On iOS it opens the app onto onboarding + registration; in a browser it redirects to `/auth/register?invite=…`. required: - id - createdAt @@ -13353,11 +13879,25 @@ components: anyOf: - type: number - type: "null" + bpInTargetCount90: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + bpInTargetSpanDays90: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" glucoseByContext: type: object propertyNames: type: string additionalProperties: {} + glucoseClinical: + $ref: "#/components/schemas/GlucoseClinicalMetrics" required: - bpInTargetPct - bpInTargetPct7d @@ -13365,7 +13905,10 @@ components: - bpInTargetPctAllTime - bpInTargetPctPriorMonth - bpInTargetPctPriorYear + - bpInTargetCount90 + - bpInTargetSpanDays90 - glucoseByContext + - glucoseClinical additionalProperties: false - type: "null" medsToday: @@ -13487,6 +14030,139 @@ components: `briefingStale: true`. `layoutCatalogue` (full 27-id widget catalogue) and `metricStates` (latest reading per metric, keyed by iOS `MetricKind` raw value) are additive cold-launch seeds for the native client; both derive in-process from data already fetched, adding no DB round-trip." + GlucoseClinicalMetrics: + type: object + properties: + stillLearning: + type: boolean + stillLearningReason: + anyOf: + - type: string + - type: "null" + windowDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + actualSpanDays: + type: number + readingCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + meanMgdl: + anyOf: + - type: number + - type: "null" + distribution: + anyOf: + - type: object + properties: + tir: + type: number + tbrLevel1: + type: number + tbrLevel2: + type: number + tarLevel1: + type: number + tarLevel2: + type: number + minutesEquivalent: + type: object + properties: + tir: + type: number + tbrLevel1: + type: number + tbrLevel2: + type: number + tarLevel1: + type: number + tarLevel2: + type: number + required: + - tir + - tbrLevel1 + - tbrLevel2 + - tarLevel1 + - tarLevel2 + additionalProperties: false + required: + - tir + - tbrLevel1 + - tbrLevel2 + - tarLevel1 + - tarLevel2 + - minutesEquivalent + additionalProperties: false + - type: "null" + gmi: + anyOf: + - type: number + - type: "null" + estimatedA1c: + anyOf: + - type: number + - type: "null" + variability: + anyOf: + - type: object + properties: + sd: + type: number + cv: + type: number + unstable: + type: boolean + required: + - sd + - cv + - unstable + additionalProperties: false + - type: "null" + advanced: + anyOf: + - type: object + properties: + jIndex: + anyOf: + - type: number + - type: "null" + lbgi: + type: number + hbgi: + type: number + required: + - jIndex + - lbgi + - hbgi + additionalProperties: false + - type: "null" + isSpotEstimate: + type: boolean + required: + - stillLearning + - stillLearningReason + - windowDays + - actualSpanDays + - readingCount + - meanMgdl + - distribution + - gmi + - estimatedA1c + - variability + - advanced + - isSpotEstimate + additionalProperties: false + description: Server-authoritative glucose clinical panel over the trailing 30-day window. Figures from sparse spot data + are a SPOT-READING ESTIMATE (a % of readings), not a CGM time-in-range AGP; `isSpotEstimate` is derived from + reading density (true below ~hourly, false for a continuous CGM stream such as Nightscout) and `stillLearning` + gates assertion when the window has too few readings or too short a span. `distribution` carries the Battelino + 2019 TIR/TBR/TAR fractions (level-2 nested in level-1) plus minutes-of-a-day equivalents; `gmi` (Bergenstal + 2018) + `estimatedA1c` (Nathan 2008 ADAG) derive from the mean; `variability` is SD + CV% with the Monnier 2017 + ≥36% instability flag; `advanced` is the disclosure tier — J-index (Wojcicki 1995) + LBGI/HBGI (Kovatchev + hypo/hyper risk). All blocks are null when there are no readings; `advanced.jIndex` is null for a single-reading + window. InsightsComprehensiveResponseEnvelope: type: object properties: @@ -14774,10 +15450,18 @@ components: - type: "null" description: Per-user low-stock alert threshold as remaining runway days (1–60). null = the alert is off. Default 7. Written via PATCH /api/auth/me/notification-prefs. + reorderLeadDays: + type: integer + minimum: 0 + maximum: 60 + description: v1.17.0 — per-user reorder lead default in days (0–60, default 10). The low-stock alert fires this lead + plus one dose-interval before the supply runs out so a refill arrives before the last dose; a per-medication + reorderLeadDays overrides it. Written via PATCH /api/auth/me/notification-prefs. required: - lateMinutes - missedMinutes - lowStockRunwayDays + - reorderLeadDays additionalProperties: false description: "Medication reminder thresholds: the operator-level late/missed minute marks plus the calling user's low-stock runway threshold (v1.16.11)." @@ -15180,6 +15864,15 @@ components: description: v1.16.10 — inventory units one dose consumes (e.g. 2 tablets of 2 mg for a 4 mg dose). v1.16.12 — may be a split-pill fraction (¼ / ⅓ / ½ / ⅔ / ¾); thirds carry as ≈0.3333 / 0.6667. Default 1. The intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it. + reorderLeadDays: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: v1.17.0 — optional per-medication reorder lead time in days (0–60). The low-stock alert widens its trigger + by this lead plus one dose-interval so a refill arrives before the last dose. null = inherit the user-level + notificationPrefs.medication.reorderLeadDays default (10). active: type: boolean notificationsEnabled: @@ -15278,6 +15971,7 @@ components: - treatmentClass - dosesPerUnit - unitsPerDose + - reorderLeadDays - active - notificationsEnabled - liveActivityEnabled diff --git a/e2e/settings-integrations-mobile.spec.ts b/e2e/settings-integrations-mobile.spec.ts index 5a7c79457..37f199529 100644 --- a/e2e/settings-integrations-mobile.spec.ts +++ b/e2e/settings-integrations-mobile.spec.ts @@ -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 diff --git a/messages/de.json b/messages/de.json index cfea1b804..01784ec8b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 0e147aab4..a78cc1a24 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index ee2ea77c3..07ef722cb 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 54b3b084b..acec48c77 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 04730edac..e825a1770 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index e3915160a..b551d7b0b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/package.json b/package.json index 8e8c30f74..a2e94be17 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f65656923..f00356f57 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,16 +13,16 @@ importers: version: 3.1048.0 '@dnd-kit/core': specifier: ^6.3.1 - version: 6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@dnd-kit/sortable': specifier: ^10.0.0 - version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) '@dnd-kit/utilities': specifier: ^3.2.2 - version: 3.2.2(react@19.2.5) + version: 3.2.2(react@19.2.7) '@hookform/resolvers': specifier: ^5.4.0 - version: 5.4.0(react-hook-form@7.78.0(react@19.2.5)) + version: 5.4.0(react-hook-form@7.78.0(react@19.2.7)) '@node-rs/argon2': specifier: ^2.0.2 version: 2.0.2 @@ -34,7 +34,7 @@ importers: version: 7.8.0 '@prisma/client': specifier: ^7.8.0 - version: 7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3))(typescript@6.0.3) + version: 7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3))(typescript@6.0.3) '@simplewebauthn/browser': specifier: ^13.3.0 version: 13.3.0 @@ -43,7 +43,7 @@ importers: version: 13.3.1 '@tanstack/react-query': specifier: ^5.101.0 - version: 5.101.0(react@19.2.5) + version: 5.101.0(react@19.2.7) '@types/sax': specifier: ^1.2.7 version: 1.2.7 @@ -66,17 +66,17 @@ importers: specifier: ^5.0.8 version: 5.0.8(jspdf@4.2.1) lucide-react: - specifier: ^1.17.0 - version: 1.17.0(react@19.2.5) + specifier: ^1.18.0 + version: 1.18.0(react@19.2.7) mmdb-lib: specifier: ^3.0.2 version: 3.0.2 next: specifier: 16.2.6 - version: 16.2.6(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 16.2.6(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) next-themes: specifier: ^0.4.6 - version: 0.4.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) p-limit: specifier: ^7.3.0 version: 7.3.0 @@ -85,25 +85,25 @@ importers: version: 12.18.2 prisma: specifier: ^7.8.0 - version: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3) + version: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3) qrcode: specifier: ^1.5.4 version: 1.5.4 radix-ui: specifier: ^1.4.3 - version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: - specifier: 19.2.5 - version: 19.2.5 + specifier: 19.2.7 + version: 19.2.7 react-dom: - specifier: 19.2.5 - version: 19.2.5(react@19.2.5) + specifier: 19.2.7 + version: 19.2.7(react@19.2.7) react-hook-form: specifier: ^7.78.0 - version: 7.78.0(react@19.2.5) + version: 7.78.0(react@19.2.7) recharts: specifier: ^3.8.1 - version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@16.13.1)(react@19.2.5)(redux@5.0.1) + version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react-is@16.13.1)(react@19.2.7)(redux@5.0.1) rrule: specifier: ^2.8.1 version: 2.8.1 @@ -112,13 +112,13 @@ importers: version: 1.6.0 sonner: specifier: ^2.0.7 - version: 2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) tailwind-merge: specifier: ^3.6.0 version: 3.6.0 undici: - specifier: ^8.3.0 - version: 8.3.0 + specifier: ^8.4.1 + version: 8.4.1 web-push: specifier: ^3.6.7 version: 3.6.7 @@ -129,8 +129,8 @@ importers: specifier: ^4.4.3 version: 4.4.3 zod-openapi: - specifier: ^5.4.6 - version: 5.4.6(zod@4.4.3) + specifier: ^6.0.0 + version: 6.0.0(zod@4.4.3) zxcvbn-typescript: specifier: ^5.0.1 version: 5.0.1 @@ -193,11 +193,11 @@ importers: specifier: ^8.21.0 version: 8.21.0 prettier: - specifier: ^3.8.3 - version: 3.8.3 + specifier: ^3.8.4 + version: 3.8.4 prettier-plugin-tailwindcss: specifier: ^0.8.0 - version: 0.8.0(prettier@3.8.3) + version: 0.8.0(prettier@3.8.4) shadcn: specifier: ^4.9.0 version: 4.9.0(@types/node@25.9.2)(typescript@6.0.3) @@ -4992,8 +4992,8 @@ packages: resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} - lucide-react@1.17.0: - resolution: {integrity: sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==} + lucide-react@1.18.0: + resolution: {integrity: sha512-LZDb7H/0YfM+RJncD0hDQRCAu+vSGODqpe35TuVI8EuXaRjkczbsx7p8dY4J87F/MUSj6bpYqeI8nw8qXaAdmA==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -5567,8 +5567,8 @@ packages: prettier-plugin-svelte: optional: true - prettier@3.8.3: - resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + prettier@3.8.4: + resolution: {integrity: sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==} engines: {node: '>=14'} hasBin: true @@ -5674,10 +5674,10 @@ packages: rc9@3.0.1: resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} - react-dom@19.2.5: - resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} peerDependencies: - react: ^19.2.5 + react: ^19.2.7 react-hook-form@7.78.0: resolution: {integrity: sha512-EEZqc+N23moyzTlz61Pj+JvcXo76ICkpfOZo8JZw+sM4+wLQGh6nI2Ms+PdMOYNluFu0ghlM7B8mCzhRYtJCnA==} @@ -5734,8 +5734,8 @@ packages: '@types/react': optional: true - react@19.2.5: - resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} engines: {node: '>=0.10.0'} readable-stream@2.3.8: @@ -6336,8 +6336,8 @@ packages: resolution: {integrity: sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==} engines: {node: '>=20.18.1'} - undici@8.3.0: - resolution: {integrity: sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==} + undici@8.4.1: + resolution: {integrity: sha512-RNHlB4fxZK0IrkhBsxhlbx7s8kFWwr7rzzOqj5nvZugw3ig3RsB7KW3zVlV0eu8POl+rx5d1hmL7rRg0z1owow==} engines: {node: '>=22.19.0'} unicorn-magic@0.3.0: @@ -6656,11 +6656,11 @@ packages: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} - zod-openapi@5.4.6: - resolution: {integrity: sha512-P2jsOOBAq/6hCwUsMCjUATZ8szkMsV5VAwZENfyxp2Hc/XPJQpVwAgevWZc65xZauCwWB9LAn7zYeiCJFAEL+A==} - engines: {node: '>=20'} + zod-openapi@6.0.0: + resolution: {integrity: sha512-mS4eRJ4DGCPrg6elRbJqc/3nLe4EPVi8KiHRKZ7dcTR5m5orPy8EfoWmceAyGZAq71MAWuyrTTOag7W5N61ZPQ==} + engines: {node: '>=22.14.0'} peerDependencies: - zod: ^3.25.74 || ^4.0.0 + zod: ^4.0.0 zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} @@ -7256,29 +7256,29 @@ snapshots: '@discoveryjs/json-ext@0.5.7': {} - '@dnd-kit/accessibility@3.1.1(react@19.2.5)': + '@dnd-kit/accessibility@3.1.1(react@19.2.7)': dependencies: - react: 19.2.5 + react: 19.2.7 tslib: 2.8.1 - '@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@dnd-kit/accessibility': 3.1.1(react@19.2.5) - '@dnd-kit/utilities': 3.2.2(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@dnd-kit/accessibility': 3.1.1(react@19.2.7) + '@dnd-kit/utilities': 3.2.2(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) tslib: 2.8.1 - '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)': + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': dependencies: - '@dnd-kit/core': 6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@dnd-kit/utilities': 3.2.2(react@19.2.5) - react: 19.2.5 + '@dnd-kit/core': 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@dnd-kit/utilities': 3.2.2(react@19.2.7) + react: 19.2.7 tslib: 2.8.1 - '@dnd-kit/utilities@3.2.2(react@19.2.5)': + '@dnd-kit/utilities@3.2.2(react@19.2.7)': dependencies: - react: 19.2.5 + react: 19.2.7 tslib: 2.8.1 '@dotenvx/dotenvx@1.69.2': @@ -7474,11 +7474,11 @@ snapshots: '@floating-ui/core': 1.7.4 '@floating-ui/utils': 0.2.10 - '@floating-ui/react-dom@2.1.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@floating-ui/react-dom@2.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@floating-ui/dom': 1.7.5 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) '@floating-ui/utils@0.2.10': {} @@ -7511,10 +7511,10 @@ snapshots: dependencies: hono: 4.12.23 - '@hookform/resolvers@5.4.0(react-hook-form@7.78.0(react@19.2.5))': + '@hookform/resolvers@5.4.0(react-hook-form@7.78.0(react@19.2.7))': dependencies: '@standard-schema/utils': 0.3.0 - react-hook-form: 7.78.0(react@19.2.5) + react-hook-form: 7.78.0(react@19.2.7) '@humanfs/core@0.19.1': {} @@ -8164,11 +8164,11 @@ snapshots: '@prisma/client-runtime-utils@7.8.0': {} - '@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3))(typescript@6.0.3)': + '@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3))(typescript@6.0.3)': dependencies: '@prisma/client-runtime-utils': 7.8.0 optionalDependencies: - prisma: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3) + prisma: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3) typescript: 6.0.3 '@prisma/config@7.8.0(magicast@0.5.2)': @@ -8242,13 +8242,13 @@ snapshots: env-paths: 3.0.0 proper-lockfile: 4.1.2 - '@prisma/studio-core@0.27.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@prisma/studio-core@0.27.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@types/react': 19.2.14 chart.js: 4.5.1 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) transitivePeerDependencies: - '@types/react-dom' @@ -8278,750 +8278,750 @@ snapshots: '@radix-ui/primitive@1.1.3': {} - '@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-avatar@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-avatar@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.7)': dependencies: - react: 19.2.5 + react: 19.2.7 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.7)': dependencies: - react: 19.2.5 + react: 19.2.7 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.7) aria-hidden: 1.2.6 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.7)': dependencies: - react: 19.2.5 + react: 19.2.7 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.7)': dependencies: - react: 19.2.5 + react: 19.2.7 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-form@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-form@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.7)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.7) aria-hidden: 1.2.6 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-one-time-password-field@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-one-time-password-field@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/number': 1.1.1 '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-password-toggle-field@0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-password-toggle-field@0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.7) aria-hidden: 1.2.6 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@floating-ui/react-dom': 2.1.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@floating-ui/react-dom': 2.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.7) '@radix-ui/rect': 1.1.1 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/number': 1.1.1 '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/number': 1.1.1 '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) aria-hidden: 1.2.6 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-separator@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-separator@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/number': 1.1.1 '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.7)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-toast@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-toast@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-toolbar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-toolbar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.7)': dependencies: - react: 19.2.5 + react: 19.2.7 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.7)': dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.7)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.7)': dependencies: - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.14)(react@19.2.7)': dependencies: - react: 19.2.5 - use-sync-external-store: 1.6.0(react@19.2.5) + react: 19.2.7 + use-sync-external-store: 1.6.0(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.7)': dependencies: - react: 19.2.5 + react: 19.2.7 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.7)': dependencies: - react: 19.2.5 + react: 19.2.7 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.7)': dependencies: '@radix-ui/rect': 1.1.1 - react: 19.2.5 + react: 19.2.7 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.7)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.7) + react: 19.2.7 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) '@radix-ui/rect@1.1.1': {} - '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1))(react@19.2.5)': + '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.7)(redux@5.0.1))(react@19.2.7)': dependencies: '@standard-schema/spec': 1.1.0 '@standard-schema/utils': 0.3.0 @@ -9030,8 +9030,8 @@ snapshots: redux-thunk: 3.1.0(redux@5.0.1) reselect: 5.1.1 optionalDependencies: - react: 19.2.5 - react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1) + react: 19.2.7 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.7)(redux@5.0.1) '@rolldown/pluginutils@1.0.0-rc.3': {} @@ -9256,10 +9256,10 @@ snapshots: '@tanstack/query-core@5.101.0': {} - '@tanstack/react-query@5.101.0(react@19.2.5)': + '@tanstack/react-query@5.101.0(react@19.2.7)': dependencies: '@tanstack/query-core': 5.101.0 - react: 19.2.5 + react: 19.2.7 '@testcontainers/postgresql@12.0.1': dependencies: @@ -11639,9 +11639,9 @@ snapshots: lru.min@1.1.4: {} - lucide-react@1.17.0(react@19.2.5): + lucide-react@1.18.0(react@19.2.7): dependencies: - react: 19.2.5 + react: 19.2.7 luxon@3.7.2: {} @@ -11774,21 +11774,21 @@ snapshots: negotiator@1.0.0: {} - next-themes@0.4.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + next-themes@0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) - next@16.2.6(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + next@16.2.6(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@next/env': 16.2.6 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.29 caniuse-lite: 1.0.30001792 postcss: 8.4.31 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.5) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.7) optionalDependencies: '@next/swc-darwin-arm64': 16.2.6 '@next/swc-darwin-x64': 16.2.6 @@ -12177,22 +12177,22 @@ snapshots: prelude-ls@1.2.1: {} - prettier-plugin-tailwindcss@0.8.0(prettier@3.8.3): + prettier-plugin-tailwindcss@0.8.0(prettier@3.8.4): dependencies: - prettier: 3.8.3 + prettier: 3.8.4 - prettier@3.8.3: {} + prettier@3.8.4: {} pretty-ms@9.3.0: dependencies: parse-ms: 4.0.0 - prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3): + prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3): dependencies: '@prisma/config': 7.8.0(magicast@0.5.2) '@prisma/dev': 0.24.3(typescript@6.0.3) '@prisma/engines': 7.8.0 - '@prisma/studio-core': 0.27.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@prisma/studio-core': 0.27.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) mysql2: 3.15.3 postgres: 3.4.7 optionalDependencies: @@ -12279,65 +12279,65 @@ snapshots: queue-microtask@1.2.3: {} - radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-accessible-icon': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-alert-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-aspect-ratio': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-avatar': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-checkbox': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context-menu': 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-form': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-hover-card': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-menubar': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-one-time-password-field': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-password-toggle-field': 0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-progress': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-radio-group': 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-slider': 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-switch': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-toast': 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-toolbar': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-accessible-icon': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-alert-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-aspect-ratio': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-avatar': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-checkbox': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context-menu': 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-form': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-hover-card': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-menubar': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-one-time-password-field': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-password-toggle-field': 0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-progress': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-radio-group': 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slider': 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-switch': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toast': 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toolbar': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) @@ -12361,56 +12361,56 @@ snapshots: defu: 6.1.7 destr: 2.0.5 - react-dom@19.2.5(react@19.2.5): + react-dom@19.2.7(react@19.2.7): dependencies: - react: 19.2.5 + react: 19.2.7 scheduler: 0.27.0 - react-hook-form@7.78.0(react@19.2.5): + react-hook-form@7.78.0(react@19.2.7): dependencies: - react: 19.2.5 + react: 19.2.7 react-is@16.13.1: {} - react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1): + react-redux@9.2.0(@types/react@19.2.14)(react@19.2.7)(redux@5.0.1): dependencies: '@types/use-sync-external-store': 0.0.6 - react: 19.2.5 - use-sync-external-store: 1.6.0(react@19.2.5) + react: 19.2.7 + use-sync-external-store: 1.6.0(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 redux: 5.0.1 react-refresh@0.18.0: {} - react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.5): + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.7): dependencies: - react: 19.2.5 - react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.5) + react: 19.2.7 + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.7) tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.14 - react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.5): + react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.7): dependencies: - react: 19.2.5 - react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.5) - react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.5) + react: 19.2.7 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.7) + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.7) tslib: 2.8.1 - use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.5) - use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.5) + use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.7) + use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 - react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.5): + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.7): dependencies: get-nonce: 1.0.1 - react: 19.2.5 + react: 19.2.7 tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.14 - react@19.2.5: {} + react@19.2.7: {} readable-stream@2.3.8: dependencies: @@ -12450,21 +12450,21 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 - recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@16.13.1)(react@19.2.5)(redux@5.0.1): + recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react-is@16.13.1)(react@19.2.7)(redux@5.0.1): dependencies: - '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1))(react@19.2.5) + '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.7)(redux@5.0.1))(react@19.2.7) clsx: 2.1.1 decimal.js-light: 2.5.1 es-toolkit: 1.45.1 eventemitter3: 5.0.4 immer: 10.2.0 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) react-is: 16.13.1 - react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1) + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.7)(redux@5.0.1) reselect: 5.1.1 tiny-invariant: 1.3.3 - use-sync-external-store: 1.6.0(react@19.2.5) + use-sync-external-store: 1.6.0(react@19.2.7) victory-vendor: 37.3.6 transitivePeerDependencies: - '@types/react' @@ -12809,10 +12809,10 @@ snapshots: smol-toml@1.6.1: {} - sonner@2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + sonner@2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) source-map-js@1.2.1: {} @@ -12970,10 +12970,10 @@ snapshots: strnum@2.3.0: {} - styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.5): + styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.7): dependencies: client-only: 0.0.1 - react: 19.2.5 + react: 19.2.7 optionalDependencies: '@babel/core': 7.29.7 @@ -13222,7 +13222,7 @@ snapshots: undici@7.26.0: {} - undici@8.3.0: {} + undici@8.4.1: {} unicorn-magic@0.3.0: {} @@ -13266,24 +13266,24 @@ snapshots: dependencies: punycode: 2.3.1 - use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.5): + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.7): dependencies: - react: 19.2.5 + react: 19.2.7 tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.14 - use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.5): + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.7): dependencies: detect-node-es: 1.1.0 - react: 19.2.5 + react: 19.2.7 tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.14 - use-sync-external-store@1.6.0(react@19.2.5): + use-sync-external-store@1.6.0(react@19.2.7): dependencies: - react: 19.2.5 + react: 19.2.7 util-deprecate@1.0.2: {} @@ -13550,7 +13550,7 @@ snapshots: compress-commons: 6.0.2 readable-stream: 4.7.0 - zod-openapi@5.4.6(zod@4.4.3): + zod-openapi@6.0.0(zod@4.4.3): dependencies: zod: 4.4.3 diff --git a/prisma/migrations/0160_v1170_new_measurement_sources/migration.sql b/prisma/migrations/0160_v1170_new_measurement_sources/migration.sql new file mode 100644 index 000000000..ce2d6c27c --- /dev/null +++ b/prisma/migrations/0160_v1170_new_measurement_sources/migration.sql @@ -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; diff --git a/prisma/migrations/0161_v1170_reorder_lead_days/migration.sql b/prisma/migrations/0161_v1170_reorder_lead_days/migration.sql new file mode 100644 index 000000000..d751dd86b --- /dev/null +++ b/prisma/migrations/0161_v1170_reorder_lead_days/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3a07aacde..ba56aa388 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") diff --git a/public/sw.js b/public/sw.js index dc90a884f..545453b9c 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.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; diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json index ab266a882..6b3d72f90 100644 --- a/scripts/env-manifest.json +++ b/scripts/env-manifest.json @@ -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 required row whenever 1-3 of the 4 are set.", diff --git a/src/__tests__/proxy-invite-public.test.ts b/src/__tests__/proxy-invite-public.test.ts new file mode 100644 index 000000000..feb7aee54 --- /dev/null +++ b/src/__tests__/proxy-invite-public.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * v1.17.0 — `/invite/` 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 = {}, +): 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/ 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/` 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/); + }); +}); diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index bd82bec73..628a28b4a 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -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, diff --git a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts index afae11aea..ae16929e3 100644 --- a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts @@ -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, }, }), }, diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts index 36f1e9506..3b90d33f4 100644 --- a/src/app/api/dashboard/summary/__tests__/route.test.ts +++ b/src/app/api/dashboard/summary/__tests__/route.test.ts @@ -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 diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 9761a8ba3..d9241ce6a 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -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(), }; } diff --git a/src/app/api/medications/[id]/glp1/__tests__/route.test.ts b/src/app/api/medications/[id]/glp1/__tests__/route.test.ts index 6f74d2026..6c2393d1d 100644 --- a/src/app/api/medications/[id]/glp1/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/glp1/__tests__/route.test.ts @@ -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({ diff --git a/src/app/api/medications/[id]/glp1/route.ts b/src/app/api/medications/[id]/glp1/route.ts index 0fe699b56..b6523afd9 100644 --- a/src/app/api/medications/[id]/glp1/route.ts +++ b/src/app/api/medications/[id]/glp1/route.ts @@ -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, diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 054eaacb0..1edbdcd69 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -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 diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 258cda7d0..a71a017a0 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -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 diff --git a/src/app/api/nightscout/connect/__tests__/route.test.ts b/src/app/api/nightscout/connect/__tests__/route.test.ts new file mode 100644 index 000000000..fa81b4160 --- /dev/null +++ b/src/app/api/nightscout/connect/__tests__/route.test.ts @@ -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: 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; + +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; + +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(); + }); +}); diff --git a/src/app/api/nightscout/connect/route.ts b/src/app/api/nightscout/connect/route.ts new file mode 100644 index 000000000..5c49350ee --- /dev/null +++ b/src/app/api/nightscout/connect/route.ts @@ -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 }); +}); diff --git a/src/app/api/nightscout/disconnect/route.ts b/src/app/api/nightscout/disconnect/route.ts new file mode 100644 index 000000000..3a9adff87 --- /dev/null +++ b/src/app/api/nightscout/disconnect/route.ts @@ -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 }); +}); diff --git a/src/app/api/nightscout/status/route.ts b/src/app/api/nightscout/status/route.ts new file mode 100644 index 000000000..38096cd59 --- /dev/null +++ b/src/app/api/nightscout/status/route.ts @@ -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, + }); +}); diff --git a/src/app/api/nightscout/test/route.ts b/src/app/api/nightscout/test/route.ts new file mode 100644 index 000000000..854e5ea32 --- /dev/null +++ b/src/app/api/nightscout/test/route.ts @@ -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 }, + ); + } +}); diff --git a/src/app/api/oura/callback/route.ts b/src/app/api/oura/callback/route.ts new file mode 100644 index 000000000..e30899161 --- /dev/null +++ b/src/app/api/oura/callback/route.ts @@ -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"); + } +}); diff --git a/src/app/api/oura/connect/route.ts b/src/app/api/oura/connect/route.ts new file mode 100644 index 000000000..fd67aa350 --- /dev/null +++ b/src/app/api/oura/connect/route.ts @@ -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; +}); diff --git a/src/app/api/oura/disconnect/__tests__/route.test.ts b/src/app/api/oura/disconnect/__tests__/route.test.ts new file mode 100644 index 000000000..fb7802709 --- /dev/null +++ b/src/app/api/oura/disconnect/__tests__/route.test.ts @@ -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: 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; + +type RouteResult = { data: unknown; error: string | null; status: number }; +const post = POST as unknown as () => Promise; + +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(); + }); +}); diff --git a/src/app/api/oura/disconnect/route.ts b/src/app/api/oura/disconnect/route.ts new file mode 100644 index 000000000..a5daf5f97 --- /dev/null +++ b/src/app/api/oura/disconnect/route.ts @@ -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 }); +}); diff --git a/src/app/api/oura/status/__tests__/route.test.ts b/src/app/api/oura/status/__tests__/route.test.ts new file mode 100644 index 000000000..b62b1d884 --- /dev/null +++ b/src/app/api/oura/status/__tests__/route.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: 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; +const credsMock = getOuraCredentials as ReturnType; + +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; + expect(JSON.stringify(res)).not.toContain("enc-secret"); + }); +}); diff --git a/src/app/api/oura/status/route.ts b/src/app/api/oura/status/route.ts new file mode 100644 index 000000000..d5dcb6484 --- /dev/null +++ b/src/app/api/oura/status/route.ts @@ -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, + }); +}); diff --git a/src/app/api/oura/test/route.ts b/src/app/api/oura/test/route.ts new file mode 100644 index 000000000..7e659115f --- /dev/null +++ b/src/app/api/oura/test/route.ts @@ -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 }, + ); + } +}); diff --git a/src/app/api/polar/callback/route.ts b/src/app/api/polar/callback/route.ts new file mode 100644 index 000000000..34b084e45 --- /dev/null +++ b/src/app/api/polar/callback/route.ts @@ -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"); + } +}); diff --git a/src/app/api/polar/connect/route.ts b/src/app/api/polar/connect/route.ts new file mode 100644 index 000000000..5da066968 --- /dev/null +++ b/src/app/api/polar/connect/route.ts @@ -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; +}); diff --git a/src/app/api/polar/disconnect/__tests__/route.test.ts b/src/app/api/polar/disconnect/__tests__/route.test.ts new file mode 100644 index 000000000..db7fb375e --- /dev/null +++ b/src/app/api/polar/disconnect/__tests__/route.test.ts @@ -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: 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; + +type RouteResult = { data: unknown; error: string | null; status: number }; +const post = POST as unknown as () => Promise; + +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(); + }); +}); diff --git a/src/app/api/polar/disconnect/route.ts b/src/app/api/polar/disconnect/route.ts new file mode 100644 index 000000000..07727ffa1 --- /dev/null +++ b/src/app/api/polar/disconnect/route.ts @@ -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 }); +}); diff --git a/src/app/api/polar/status/__tests__/route.test.ts b/src/app/api/polar/status/__tests__/route.test.ts new file mode 100644 index 000000000..e7e94d27a --- /dev/null +++ b/src/app/api/polar/status/__tests__/route.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: 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; +const credsMock = getPolarCredentials as ReturnType; + +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; + expect(JSON.stringify(res)).not.toContain("enc"); + }); +}); diff --git a/src/app/api/polar/status/route.ts b/src/app/api/polar/status/route.ts new file mode 100644 index 000000000..cfa8ad71f --- /dev/null +++ b/src/app/api/polar/status/route.ts @@ -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, + }); +}); diff --git a/src/app/api/polar/test/route.ts b/src/app/api/polar/test/route.ts new file mode 100644 index 000000000..ebd809626 --- /dev/null +++ b/src/app/api/polar/test/route.ts @@ -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 }, + ); + } +}); diff --git a/src/app/api/settings/reminder-thresholds/__tests__/route.test.ts b/src/app/api/settings/reminder-thresholds/__tests__/route.test.ts index a9fb93cab..b2f000a05 100644 --- a/src/app/api/settings/reminder-thresholds/__tests__/route.test.ts +++ b/src/app/api/settings/reminder-thresholds/__tests__/route.test.ts @@ -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, }); }); diff --git a/src/app/api/settings/reminder-thresholds/route.ts b/src/app/api/settings/reminder-thresholds/route.ts index 8432fc3db..b966ea189 100644 --- a/src/app/api/settings/reminder-thresholds/route.ts +++ b/src/app/api/settings/reminder-thresholds/route.ts @@ -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 }); }); diff --git a/src/app/api/sleep/rhythm/__tests__/route.test.ts b/src/app/api/sleep/rhythm/__tests__/route.test.ts new file mode 100644 index 000000000..85d94cdc5 --- /dev/null +++ b/src/app/api/sleep/rhythm/__tests__/route.test.ts @@ -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(); + 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(); + 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; +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); + }); +}); diff --git a/src/app/api/sleep/rhythm/route.ts b/src/app/api/sleep/rhythm/route.ts new file mode 100644 index 000000000..8eb6c1502 --- /dev/null +++ b/src/app/api/sleep/rhythm/route.ts @@ -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); +}); diff --git a/src/app/insights/blood-glucose/page.tsx b/src/app/insights/blood-glucose/page.tsx index 397999a13..e753ad589 100644 --- a/src/app/insights/blood-glucose/page.tsx +++ b/src/app/insights/blood-glucose/page.tsx @@ -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={} emptyStateCtaType={null} targetSummarySlug="blood-glucose" + afterChart={} coachPrefill="I haven't logged any blood glucose yet — what does this metric tell me about my health, and how do I improve it?" /> ); diff --git a/src/app/insights/hrv/page.tsx b/src/app/insights/hrv/page.tsx index 724600438..74f1c5c2d 100644 --- a/src/app/insights/hrv/page.tsx +++ b/src/app/insights/hrv/page.tsx @@ -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 ( { + 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/ 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"); + }); +}); diff --git a/src/app/insights/sleep/page.tsx b/src/app/insights/sleep/page.tsx index 9266068e7..1cac47cd7 100644 --- a/src/app/insights/sleep/page.tsx +++ b/src/app/insights/sleep/page.tsx @@ -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={ @@ -81,6 +82,13 @@ export default function InsightsSchlafPage() { > + {/* + 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. + */} + + {/* diff --git a/src/app/invite/[token]/__tests__/page.test.tsx b/src/app/invite/[token]/__tests__/page.test.tsx new file mode 100644 index 000000000..2615afd64 --- /dev/null +++ b/src/app/invite/[token]/__tests__/page.test.tsx @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/** + * v1.17.0 — invite universal-link landing (iOS #16). + * + * `/invite/` is a thin, server-side redirect: it validates the + * `hlv_` shape, then bounces a browser onto the existing + * `/auth/register?invite=` 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 { + 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(" 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"); + } + }); +}); diff --git a/src/app/invite/[token]/page.tsx b/src/app/invite/[token]/page.tsx new file mode 100644 index 000000000..236f6be7b --- /dev/null +++ b/src/app/invite/[token]/page.tsx @@ -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:///invite/` 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=` 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"); +} diff --git a/src/components/clinician/__tests__/clinician-view.test.tsx b/src/components/clinician/__tests__/clinician-view.test.tsx index 34bbe9b2a..74dd4606e 100644 --- a/src/components/clinician/__tests__/clinician-view.test.tsx +++ b/src/components/clinician/__tests__/clinician-view.test.tsx @@ -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 = {}, @@ -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: {}, diff --git a/src/components/cycle/__tests__/log-day-sheet.test.ts b/src/components/cycle/__tests__/log-day-sheet.test.ts index 8c9671e01..f2e5a54cf 100644 --- a/src/components/cycle/__tests__/log-day-sheet.test.ts +++ b/src/components/cycle/__tests__/log-day-sheet.test.ts @@ -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); + }); +}); diff --git a/src/components/cycle/log-day-sheet.tsx b/src/components/cycle/log-day-sheet.tsx index 770f32c81..20b9558e4 100644 --- a/src/components/cycle/log-day-sheet.tsx +++ b/src/components/cycle/log-day-sheet.tsx @@ -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} - {/* 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. */}
{FLOW_LEVELS.map((f) => ( @@ -599,7 +664,10 @@ export function LogDaySheet({ {/* Symptoms */} - + } + >
{CYCLE_SYMPTOM_CATALOG.map((cat) => (
@@ -656,10 +724,18 @@ export function LogDaySheet({
-
+ - {/* BBT */} - + {/* Temperature & ovulation — BBT, OPK, and the symptothermal secondary + sign (mucus or cervix) grouped under one disclosure. */} + + } + > + {/* BBT */} + )} + - {/* Intercourse + protection */} - + {/* Intimacy & contraception */} + } + > + {/* Intercourse + protection */} +
) : null}
-
+
- {/* Pregnancy test */} - -
- {TEST_RESULTS.map((v) => ( - setPregnancyTest((cur) => (cur === v ? null : v))} - > - {t(`cycle.testResult.${v}`)} - - ))} -
-
+ {/* Contraceptive */} + +
+ {CONTRACEPTIVE_KINDS.map((v) => ( + + setContraceptive((cur) => (cur === v ? null : v)) + } + > + {t(`cycle.contraceptive.${v}`)} + + ))} +
+
+ - {/* Progesterone test */} - -
- {TEST_RESULTS.map((v) => ( - - setProgesteroneTest((cur) => (cur === v ? null : v)) - } - > - {t(`cycle.testResult.${v}`)} - - ))} -
-
+ {/* Tests */} + } + > + {/* Pregnancy test */} + +
+ {TEST_RESULTS.map((v) => ( + + setPregnancyTest((cur) => (cur === v ? null : v)) + } + > + {t(`cycle.testResult.${v}`)} + + ))} +
+
- {/* Contraceptive */} - -
- {CONTRACEPTIVE_KINDS.map((v) => ( - setContraceptive((cur) => (cur === v ? null : v))} - > - {t(`cycle.contraceptive.${v}`)} - - ))} -
-
+ {/* Progesterone test */} + +
+ {TEST_RESULTS.map((v) => ( + + setProgesteroneTest((cur) => (cur === v ? null : v)) + } + > + {t(`cycle.testResult.${v}`)} + + ))} +
+
+
{/* Note */} - + } + >