mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Compare commits
No files matched your search
@@ -101,7 +101,7 @@ jobs:
|
||||
|
||||
- name: Compute image labels
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.IMAGE_REF }}
|
||||
labels: |
|
||||
@@ -251,7 +251,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download digest artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digest-*
|
||||
@@ -275,7 +275,7 @@ jobs:
|
||||
|
||||
- name: Compute image tags & labels
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.IMAGE_REF }}
|
||||
tags: |
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# v1.5.5 — walking step length + walking speed wired (follow-up)
|
||||
|
||||
Server-side change shipping on `main`, follows
|
||||
[`v155-wire-six-deferred-identifiers.md`](./v155-wire-six-deferred-identifiers.md).
|
||||
|
||||
## Summary
|
||||
|
||||
After the six-identifier wire-up landed, the iOS team flagged two
|
||||
more HK quantity types they needed mapped before they could flip
|
||||
them client-side: `walkingStepLength` and `walkingSpeed`. Both sat
|
||||
in `HK_QUANTITY_TYPE_DEFERRED` inside
|
||||
`src/lib/measurements/apple-health-mapping.ts`; the batch route
|
||||
returned HTTP 200 with a per-entry `skipped:"unmappable_identifier"`,
|
||||
and the iOS app advanced its sync anchor on the 200. Every sample
|
||||
carrying one of these identifiers was lost forever, no retry path.
|
||||
|
||||
Both identifiers now wire end-to-end with new `MeasurementType`
|
||||
values + Zod validator entries + DB plausibility ranges + category /
|
||||
PR-direction / list-meta / chart-token coverage:
|
||||
|
||||
| HK identifier | MeasurementType | DB unit |
|
||||
| ---------------------------------------------- | --------------------- | ------- |
|
||||
| `HKQuantityTypeIdentifierWalkingStepLength` | `WALKING_STEP_LENGTH` | `m` |
|
||||
| `HKQuantityTypeIdentifierWalkingSpeed` | `WALKING_SPEED` | `m/s` |
|
||||
|
||||
DB migration:
|
||||
`prisma/migrations/0086_v155_ios_walking_step_length_speed`. Pure
|
||||
additive `ALTER TYPE ... ADD VALUE IF NOT EXISTS`.
|
||||
|
||||
## Unit convention — raw SI, NO server-side scaling
|
||||
|
||||
The percent gait metrics added in the earlier wire-up
|
||||
(`walkingAsymmetryPercentage`, `walkingDoubleSupportPercentage`)
|
||||
ride the ×100 server-side scaling path because Apple ships them as
|
||||
0..1 fractions. The two follow-up identifiers are different:
|
||||
|
||||
- `walkingStepLength` ships as raw metres on the wire.
|
||||
- `walkingSpeed` ships as raw metres-per-second on the wire.
|
||||
|
||||
Both pass through `convertToDbUnit` as identity — no scaling, no
|
||||
clamp. The convention block at the top of `apple-health-mapping.ts`
|
||||
now spells the split out (percent path vs raw SI path) so a future
|
||||
contributor wiring a new gait identifier picks the correct bucket.
|
||||
|
||||
## iOS action
|
||||
|
||||
iOS can flip both identifiers client-side now. Wire format expected
|
||||
by the server:
|
||||
|
||||
```json
|
||||
{
|
||||
"hkIdentifier": "HKQuantityTypeIdentifierWalkingStepLength",
|
||||
"value": 0.72,
|
||||
"unit": "m",
|
||||
"startDate": "2026-05-28T09:00:00.000Z",
|
||||
"endDate": "2026-05-28T09:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"hkIdentifier": "HKQuantityTypeIdentifierWalkingSpeed",
|
||||
"value": 1.34,
|
||||
"unit": "m/s",
|
||||
"startDate": "2026-05-28T09:00:00.000Z",
|
||||
"endDate": "2026-05-28T09:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
The server stores both verbatim — no pre-multiplication needed and
|
||||
no pre-multiplication accepted (the plausibility-range guard caps
|
||||
step length at 2.0 m and speed at 3.0 m/s; a stray ×100 would land
|
||||
in `skipped:"value_out_of_range"`).
|
||||
|
||||
## Plausibility ranges
|
||||
|
||||
Both ranges are tuned for the adult walking band with margin for
|
||||
the extremes (race-walk speeds for the upper edge, shuffling gait
|
||||
for the lower):
|
||||
|
||||
- `WALKING_STEP_LENGTH`: 0.1–2.0 m
|
||||
- `WALKING_SPEED`: 0.1–3.0 m/s
|
||||
|
||||
## Personal-record direction
|
||||
|
||||
- `WALKING_SPEED` → `MAX`. Walking speed is the "sixth vital sign"
|
||||
in older-adult medicine — faster casual gait correlates with
|
||||
cardiovascular fitness, sarcopenia recovery, and overall mobility
|
||||
resilience. Apple's own Mobility section frames higher as the
|
||||
achievement.
|
||||
- `WALKING_STEP_LENGTH` → `null` (no PR direction). Step length is
|
||||
a state metric: taller users have longer strides regardless of
|
||||
fitness, and very long strides can also signal an unsafe
|
||||
overstride. The trend chart still surfaces deltas, but the PR
|
||||
detection worker skips this type.
|
||||
@@ -0,0 +1,77 @@
|
||||
# v1.5.5 — six previously-deferred HK identifiers wired
|
||||
|
||||
Server-side change shipping on `main` for v1.5.5.
|
||||
|
||||
## Summary
|
||||
|
||||
Six HealthKit identifiers used to sit in
|
||||
`HK_QUANTITY_TYPE_DEFERRED` inside
|
||||
`src/lib/measurements/apple-health-mapping.ts`. The mapping returned
|
||||
null; the batch route emitted HTTP 200 with a per-entry
|
||||
`skipped:"unmappable_identifier"`; the iOS app read 200 as success
|
||||
and advanced its sync anchor. Net effect: every sample carrying one
|
||||
of these identifiers was silently dropped forever with no retry
|
||||
path. The audit team flagged the gap on the v0.8.0 iOS pass.
|
||||
|
||||
The six identifiers now wire end-to-end with new `MeasurementType`
|
||||
values + Zod validator entries + DB plausibility ranges + category /
|
||||
PR-direction / list-meta / chart-token coverage:
|
||||
|
||||
| HK identifier | MeasurementType | DB unit |
|
||||
| ---------------------------------------------------------- | ---------------------------- | ------------- |
|
||||
| `HKQuantityTypeIdentifierRespiratoryRate` | `RESPIRATORY_RATE` | `breaths/min` |
|
||||
| `HKQuantityTypeIdentifierBodyMassIndex` | `BODY_MASS_INDEX` | `kg/m²` |
|
||||
| `HKQuantityTypeIdentifierLeanBodyMass` | `LEAN_BODY_MASS` | `kg` |
|
||||
| `HKQuantityTypeIdentifierWalkingHeartRateAverage` | `WALKING_HEART_RATE_AVERAGE` | `bpm` |
|
||||
| `HKQuantityTypeIdentifierWalkingAsymmetryPercentage` | `WALKING_ASYMMETRY` | `%` |
|
||||
| `HKQuantityTypeIdentifierWalkingDoubleSupportPercentage` | `WALKING_DOUBLE_SUPPORT` | `%` |
|
||||
|
||||
DB migration: `prisma/migrations/0083_v155_ios_measurement_types`.
|
||||
Pure additive `ALTER TYPE ... ADD VALUE IF NOT EXISTS`.
|
||||
|
||||
## Unit convention — server-side scaling is canonical
|
||||
|
||||
The audit also flagged a footgun in the gait-percent path. Two
|
||||
conventions for "percent" values were live in the tree:
|
||||
|
||||
- `walkingAsymmetryPercentage` + `walkingDoubleSupportPercentage`
|
||||
— iOS multiplied the HK reading (`0..1` fraction) by ×100 **before
|
||||
upload**, so the wire value already lived in `0..100`.
|
||||
- `walkingSteadiness` (v1.4.30) — iOS sent the raw HK reading
|
||||
(`0..1` fraction), the server applied the ×100 at ingest. Same
|
||||
shape as the existing `oxygenSaturation` + `bodyFatPercentage`
|
||||
paths.
|
||||
|
||||
The split was source-of-bugs: every future contributor adding a new
|
||||
percent metric had to remember which convention applied. We picked
|
||||
the cleaner one as the project convention:
|
||||
|
||||
> **Server-side scaling is canonical.** Every HealthKit value flows
|
||||
> over the wire as the raw HK reading. Whatever ×100 / unit-bend /
|
||||
> clamp the canonical DB shape needs, the server applies it inside
|
||||
> `convertToDbUnit` at ingest.
|
||||
|
||||
`apple-health-mapping.ts` now documents this convention at the top
|
||||
of the `APPLE_HEALTH_TYPE_MAP` block.
|
||||
|
||||
### iOS migration window
|
||||
|
||||
The v1.5.5 server release ships with the new gait identifiers
|
||||
applying the ×100 scaling server-side. iOS releases that still
|
||||
multiply by 100 pre-upload will land values in the `0..10000` band
|
||||
— the plausibility-range guard (`0..100`) will reject them with
|
||||
`skipped:"value_out_of_range"` per entry.
|
||||
|
||||
Action item for the iOS client: drop the pre-multiplication for
|
||||
`HKQuantityTypeIdentifierWalkingAsymmetryPercentage` and
|
||||
`HKQuantityTypeIdentifierWalkingDoubleSupportPercentage` so the raw
|
||||
HK reading (`0..1`) flows over the wire. The server will apply the
|
||||
×100 the same way it already does for `oxygenSaturation`,
|
||||
`bodyFatPercentage`, and `appleWalkingSteadiness`.
|
||||
|
||||
Suggested timing: one iOS release alongside the v1.5.5 server cut,
|
||||
or the next regular iOS sync if the existing TestFlight build can
|
||||
wait. The plausibility-range guard fails closed — no bad data lands
|
||||
in the DB; the only impact during the window is that those two
|
||||
gait identifiers ingest as `skipped:"value_out_of_range"` instead
|
||||
of as recorded samples.
|
||||
+116
@@ -1,5 +1,121 @@
|
||||
# Changelog
|
||||
|
||||
## [1.6.0] — 2026-05-30 — Medication editor overhaul, route of administration, today-tile read-flip
|
||||
|
||||
v1.5.6 settled the medication detail page into a pure history view, but the create/edit plan still funnelled every dose through the same flat shape, the injection-site picker only surfaced for GLP-1, and the today-tile on the dashboard and the Erfassen sheet still expanded daily schedules through a legacy walker that silently skipped bi-weekly, rolling, RRULE, and one-time cadences. This release reworks the medication editor and its detail surface, gives route of administration a first-class column so any injection can carry a site, adds a one-time-injection shape, and flips the today-tile onto the canonical recurrence engine the reminder worker already uses — so what the tile shows matches what the worker mints. It also restores the multi-language request snippets on the API-tokens row, adds a profile-photo upload to Account, and finishes an admin-surface polish pass.
|
||||
|
||||
### Added
|
||||
|
||||
- **Route of administration** — a new `deliveryForm` column (`ORAL` | `INJECTION` | `OTHER`) decoupled from `treatmentClass`. The injection-site picker now surfaces for any `INJECTION` dose rather than only GLP-1, and the editor carries the route through create, update, and the detail snapshot. Migration `0088` adds the enum and backfills `ORAL` onto every existing row via a constant column default (a single non-blocking metadata operation).
|
||||
- **One-time injection** — a one-off dose modelled as `oneShot = true` + `deliveryForm = INJECTION`, with its own editor path that drops the recurring-schedule step.
|
||||
- **Profile-photo upload on Account** — Settings → Account gains an avatar card backed by the v1.5.5 upload endpoint (server-side magic-byte sniff, 2 MiB stream-level cap, 2048² dimension cap, per-user rate limit, owner-scoped read).
|
||||
- **Admin global mood-log toggle** — the admin Services section can suspend mood-log reminders site-wide alongside the existing Web-Push and API toggles.
|
||||
- **Host-metrics memory detail** — the admin host-metrics memory tooltip now reads `used / total GiB` alongside the percentage.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Medication editor + detail surface overhaul** — the editor uses the available desktop width while keeping the mobile sheet, restores the edit fields the modal-wizard rework had dropped, and aligns the detail surface with the new route-of-administration and one-time shapes.
|
||||
- **Measurement note cap raised 25 → 200 characters** — a 25-character note could not hold a meaningful clinical aside; the cap lifts to 200 through a single `MEASUREMENT_NOTES_MAX_LENGTH` constant the client char-counters import. The DB column was already unbounded.
|
||||
- **Dashboard range colors route through semantic tokens** — the dashboard date-range chips read the shared `success` / `warning` / `info` / `destructive` tokens (with their light-mode contrast overrides) instead of hard-coded hues, and the range inputs gain bound-clamping.
|
||||
- **Admin surface tidy** — every sidebar section gets a distinct icon, the shared `helpfulRateColour` helper and a `usePublicVersion` hook are consolidated into `_shared`, and the orphaned `status-overview` route is removed.
|
||||
- **API-tokens row restores multi-language request snippets** — the per-token row again offers copyable cURL / JavaScript / Python examples.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Today-tile diverged from the reminder worker for non-daily cadences** — `/api/medications/intake?scope=today` and `/api/dashboard/summary` projected daily schedules through a legacy walker that read only `daysOfWeek` + `windowStart`, silently skipping `intervalWeeks > 1`, rolling, RRULE, and one-time cadences. Both routes now gate every "does this schedule emit today?" decision through the canonical recurrence engine (the same path the worker uses), anchoring the projected instant to `windowStart` so it stays byte-identical to the worker's row and dedupes against the existing unique index. The rolling-cadence baseline fetch mirrors the worker's per-medication `takenAt` query, so projector and worker resolve the same next-due instant.
|
||||
|
||||
### Tests
|
||||
|
||||
- 5635 → 5628 unit; 1 skipped. The today-tile read-flip carries new coverage on the intake and dashboard-summary projections; the orphaned status-overview route and its test are gone.
|
||||
|
||||
## [1.5.6] — 2026-05-29 — Pure-history detail page, legacy step consolidation, egress finish
|
||||
|
||||
v1.5.5 gave every retired medication feature a home on the detail page, but the page still carried the create/edit affordances that belong on the list. This release turns `/medications/[id]` into a pure history surface, collapses every setting into one advanced sheet, consolidates the pre-v1.5.0 granular step rows that bloated the per-sample read path, and finishes the outbound-fetch migration the `safeFetch` wrapper started.
|
||||
|
||||
### Added
|
||||
|
||||
- **Legacy step consolidation** — a pg-boss job (`STEP_CONSOLIDATION_QUEUE`) folds pre-v1.5.0 granular `ACTIVITY_STEPS` rows into one canonical daily-total row per user per day, anchored to the user's timezone, and soft-deletes the originals. A day that already carries a post-v1.5.0 `stats:` total is left untouched (no double-count) while its legacy rows are still tombstoned. Boot-time discovery enqueues only users still holding live legacy rows, so the pass converges to zero work and is idempotent across reboots. Migration `0087` adds a partial index scoped to live step rows to keep the discovery scan cheap.
|
||||
- **`healthlog/safe-fetch-required` ESLint rule** — bans raw `fetch(` outside the wrapper, exempting only genuine same-origin relative paths. Positioned as the author-time egress floor.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`/medications/[id]` is now a pure history view** — the Today's-dose card stays on the list page; the detail page reads the medication's past intake, cadence summary, and trends only. Every setting (notifications, phase config, grace window, pause/resume, end, purge) moves into a new `<AdvancedSettingsSheet>` reached from the header. The edit trigger becomes a two-option picker (plan vs. advanced). The `/medications` list page is unchanged apart from matching the detail-nav card glyph to its destination.
|
||||
- **Outbound-fetch migration finished** — every remaining server-side fetch routes through `safeFetch`: Withings (token exchange, refresh, measurements, webhook subscribe/unsubscribe, activity + sleep sync), Codex OAuth + SSE client, the bug-report GitHub comment, and the operator-host calls (Umami script/send/test, GlitchTip envelope + store, Loki). Credential-carrying constant hosts inherit the `redirect: "manual"` default so secrets cannot replay onto a redirect hop; operator-supplied hosts additionally pin `requirePublicHost` for the connect-time IP guard.
|
||||
- **`/api/insights/layout`** is registered in the OpenAPI spec so the contract matches the shipped route.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Intake-edit dialog opened empty** — editing a logged intake row now seeds the dialog from the row's real `takenAt` / `skipped` instead of an empty stub.
|
||||
- **Step consolidation could strand a day** — a minted daily total can collide on the second unique index when a manual step row already sits at the day's canonical-noon instant; the collision rolled back the in-transaction soft-delete and the day re-appeared on every boot. The pass now catches the constraint violation, logs and counts the skip, and steps to the next day.
|
||||
- **Avatar upload over-buffered on the abort path** — the upload body is now read once through a bounded reader that drops retained bytes past the 2 MiB cap, replacing the clone + parallel parse that left the native parse buffering an oversized body after the cap tripped.
|
||||
- **Egress lint exemption let absolute URLs through** — the same-origin exemption now rejects protocol-relative (`//host`) and backslash (`/\host`) forms, which both resolve off-origin.
|
||||
|
||||
### Removed
|
||||
|
||||
- **Dead wizard landing-intent path** — the pure-history rewrite removed the last caller that passed a landing intent into the medication wizard, so the prop, its state-key segment, and the intent branch in `landingStepForEdit` are gone; the helper reduces to the schedule-count heuristic.
|
||||
|
||||
### Tests
|
||||
|
||||
- 5615 → 5635 unit; 1 skipped. Detail-page history surface, advanced-settings sheet, intake-edit seed, and legacy step consolidation are covered; the pre-tag reconcile sweep keeps the suite green.
|
||||
|
||||
## [1.5.5] — 2026-05-29 — Medication detail page, iOS coord wave, outbound-fetch hardening
|
||||
|
||||
The v1.5.4 modal wizard landed the create + edit plan flow, but it took 16 features down with the retired flat form — Einnahmen bearbeiten / löschen, Medikament pausieren / beenden / löschen, per-Med API tokens, Phasen-Konfiguration, CSV-Import — and the trends-row on Insights had a 34 px overflow that pushed annotation copy on top of the charts. This release lands the medication detail page that gives every retired feature a coherent home, polishes the wizard against the live walk-through feedback, closes the iOS audit follow-ups in one go, and hardens every outbound fetch the server makes.
|
||||
|
||||
### Added
|
||||
|
||||
- **`/medications/[id]/page.tsx`** — a single Server-Component detail page composed of eight sections (Header band → Today's-dose → Cadence summary → Phasen (GLP-1) → Intake history preview → Notifications → Settings → Verwaltung & Gefahrenzone). One-shot medications walk a five-section variant; paused medications keep the structure but the status pill flips to `Pausiert` and the dose-card surface mutes. Restores every one of the 16 features the v1.5.4 flat-form retirement displaced.
|
||||
- **Per-row + bulk intake history actions** — the preview now ships a per-row kebab (`Bearbeiten` / `Löschen`) and a multi-select toolbar that fires the new `POST /api/medications/{id}/intake/bulk-delete` endpoint. Marc's „Einnahmen löschen" + „Einnahmen bearbeiten" complaints from the v1.5.4 walk-through close cleanly.
|
||||
- **`safeFetch` wrapper** at `src/lib/safe-fetch.ts` — every outbound fetch the server makes for user-supplied hosts now inherits `redirect: "manual"` + `AbortSignal.timeout(15_000)` defaults. Closes #218.
|
||||
- **DNS-rebinding pinned `undici.Agent`** at `src/lib/safe-fetch-dispatcher.ts` — when `requirePublicHost: true`, the dispatcher resolves the hostname literally inside the connect hook, refuses any address `isPublicIp` would reject, and pins the connection to the first valid public address. The five user-host paths (MoodLog sync + push + test, local-AI client, ntfy) route through it. Closes #217.
|
||||
- **Self-hosted avatar storage** at `POST/GET/DELETE /api/user/avatar` — multipart-uploaded JPEG/PNG/WebP, 2 MiB / 2048×2048 max, hand-rolled magic-byte sniff + dimension probe so no native dependency lands. Stored on the User row as BYTEA; rides `pg_dump` alongside the rest of the row. The profile response carries an `avatarUrl` with a cache-busting timestamp. `src/lib/gravatar.ts` retires — Automattic no longer sees the email-hash on every authenticated page load.
|
||||
- **`POST /api/medications/{id}/intake/bulk-delete`** — owner-scoped, capped at 500 event IDs per call, rate-limited per user. Pre-work for the detail page's multi-select.
|
||||
- **`GET / PUT / DELETE /api/insights/layout`** — mirrors `/api/dashboard/widgets` so insights tile order + visibility persist server-side and sync across devices. Default order: `overview`, `blutdruck`, `puls`, `sauerstoff`, `koerpertemperatur`, `gewicht`, `bmi`, `aktive-energie`, `workouts`, `schlaf`, `ruhepuls`, `hrv`, `stimmung`, `medikamente`. Default-visible: overview, blutdruck, puls, gewicht, bmi, workouts, stimmung, medikamente.
|
||||
- **Eight new Apple Health quantity-type mappings** — `RESPIRATORY_RATE`, `BODY_MASS_INDEX`, `LEAN_BODY_MASS`, `WALKING_HEART_RATE_AVERAGE`, `WALKING_ASYMMETRY`, `WALKING_DOUBLE_SUPPORT`, `WALKING_STEP_LENGTH`, `WALKING_SPEED`. The convention block on `apple-health-mapping.ts` documents the project rule: raw HK values flow on the wire, the server scales ×100 server-side for percent metrics. Step length and walking speed flow raw in SI (m and m/s) — no scaling.
|
||||
- **Three new series-kind enum values** — `RESTING_HEART_RATE`, `HEART_RATE_VARIABILITY`, `VO2_MAX`. Detail + trend views on those metrics now respond 200 instead of 422.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Wizard polish** — dialog widens to `sm:max-w-2xl`; the X-close gets a 44 px target; spacing tokens converge on the existing shadcn cadence (`rounded-md` button, `rounded-lg` dialog, no new radii); the step-progress bar is a width-only `<Progress>` + Tailwind `transition-all` with `motion-reduce` snap; step transitions are fade-only. `landingStepForEdit` accepts an `intent` argument so the cadence-summary edit pencil drops the user on Step 5 instead of bouncing them through Step 1.
|
||||
- **`/api/measurements/series` `days` cap** — raised from 365 to 3650, matching the recurrence engine's hard cap. The iOS app's „Alle"-range no longer paints a 422 banner on every metric.
|
||||
- **`/api/dashboard/summary` MetricCard shape** — `title` and `unit` ship as i18n keys (`dashboard.metric.title.*` + `dashboard.metric.unit.*`) instead of hardcoded German strings. Web resolves via the existing `messages/*.json` path; iOS resolves against its `Localizable.xcstrings`. Wire-shape change — clients that decoded the legacy `title` / `unit` strings keep working with the iOS team's tolerant fallback decoder during the transition.
|
||||
- **`assertMedicationOwnership` consistency sweep** — `purge`, `parent PUT`, `parent DELETE`, `intake/import`, `phase-config`, `bulk-delete`, `glp1 GET` all converge on the single ownership helper. The §10 invariant 24 from the design direction now holds across `src/app/api/medications/[id]/**`.
|
||||
- **Trends-row chart slot** — raised from 140 → 180 px so the mini-card shell's header + padding stays inside the slot. The TrendAnnotation under each chart no longer collides with the chart envelope.
|
||||
- **Wizard payload edit path** — `buildCreateBody` omits `notificationsEnabled` on edit so the toggle the user already set in the detail page Notifications section is not overwritten by the wizard's hydrated default.
|
||||
- **`<NotificationsSection>` DOM ids** — section heading and row label carry distinct ids so the Switch's `aria-labelledby` resolves to the row title, not the section heading.
|
||||
- **`phase-config` route surface** — PUT returns the multi-issue 422 envelope on Zod failure (matching every other v1.5.5 route), and the upsert builds the Prisma payload field-by-field instead of spreading `parsed.data`. Mass-assignment surface closes structurally.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Trends-row text overlapping the charts** — Marc's specific Insights complaint. Chart slot was 140 px but the mini-card shell painted ~174 px; the 34 px overflow pushed annotation copy onto the chart envelope. The 180 px slot accommodates the full envelope.
|
||||
- **Grace-row save dead on arrival** — the detail page Settings section PUTs `reminderGraceMinutes` at the top level; the route now normalises the value onto the primary schedule before the Prisma update. The schema declares the top-level field with a description noting the normalisation.
|
||||
- **Purge route did not invalidate server caches** — Tier-3a `Verlauf löschen` dropped the rollup rows but left the analytics + iOS today-tally caches with the pre-purge counts for up to their TTL. The success path now calls `invalidateUserMedications(user.id)` alongside the rollup delete.
|
||||
- **Bi-weekly worker still emitted every Wednesday** — the v1.5.3 cadence engine fix was correct, but the cadence chart + medication card on the dashboard still read the legacy `daysOfWeek` column. The v1.5.x window keeps this in place; the read-flip arrives in v1.5.6. Operators see correct reminder fan-out today; the dashboard chip cosmetic catch-up follows.
|
||||
- **Compose-mode multi-schedule data loss** — the v1.5.4 wizard collapsed a multi-schedule medication to its first schedule on save and silently dropped the rest. Closed in v1.5.4 by the compose-mode commits; the regression test pinning that lives at `wizard-payload.test.ts`.
|
||||
|
||||
### Tests
|
||||
|
||||
- 5594 → 5615 unit (+21 in the reconciliation sweep alone; the detail-page surface + audit fixes add ~110 across the cycle).
|
||||
- 262 integration unchanged + the avatar upload integration test (`tests/integration/user-avatar.test.ts`).
|
||||
- New Playwright spec pre-work — the detail-page surface is component-test pinned; e2e walk lands in a follow-up.
|
||||
|
||||
### Security
|
||||
|
||||
- `safeFetch` + DNS-rebinding pinned dispatcher close issues #217 + #218 architecturally — the input-time `isPublicUrl` guard now pairs with a connect-time IP pin so DNS rebinding cannot flip the resolved host between accept and dispatch.
|
||||
- Avatar route enforces size before parse (Content-Length pre-flight + post-parse `file.size` check), magic-byte sniff over the declared content-type, dimension probe, owner-scoped on every method. No new XSS vector — the served content-type is whitelisted to the three image MIMEs.
|
||||
- `assertMedicationOwnership` sweep closes every detail-page route's ownership narrowing — the route layer is now the single ownership predicate across `src/app/api/medications/[id]/**`.
|
||||
- Pre-tag senior-dev + security architect audit produced two docs at `.planning/medication-detail-page-2026-05-28/F-{1,2}-*.md`. The four senior-dev Criticals + five Highs landed in code; F-1 H-5 (`safeFetch` migration to constant-host call sites like Withings + Codex + the GitHub bug-reporter) + F-2 M-1..M-3 (operator-host `requirePublicHost`, avatar chunked-body pre-flight, raw-fetch lint rule) deferred to v1.5.5.1.
|
||||
|
||||
### iOS coord
|
||||
|
||||
- The iOS team's v0.8.0 audit closed cleanly. Detailed acknowledgements at `.planning/ios-coord/v155-wire-six-deferred-identifiers.md` + `.planning/ios-coord/v155-step-length-speed-followup.md`.
|
||||
- The iOS team flips `walkingAsymmetryPercentage` + `walkingDoubleSupportPercentage` from pre-multiplied to raw in their next release; until then the server fails-closed (`skipped:"value_out_of_range"`) on > 100% values, so no DB pollution.
|
||||
|
||||
### Notes
|
||||
|
||||
- **No new npm dependencies.** Avatar image-header parsing is hand-rolled; no `sharp` / `jimp`. The animation surface is shadcn `<Progress>` + Tailwind `transition-all`; no Framer Motion. Only `undici` was promoted from transitive to explicit so the safeFetch-dispatcher import sits on a documented contract.
|
||||
- **Test totals.** 5615 unit + 1 skipped, 262 integration + 3 skipped. `pnpm typecheck`, `pnpm lint` (one pre-existing withings/resume warning), `pnpm openapi:check`, locale-integrity + call-site coverage all green.
|
||||
|
||||
## [1.5.4] — 2026-05-28 — Medication wizard: modal dialog, compose-mode, ten-bucket taxonomy
|
||||
|
||||
The v1.5.3 creation wizard shipped as a single-page card with seven inline steps. Patient feedback during the first day of live use was unanimous: the page felt dense, the step-by-step intent was lost, the "every N days from my last injection" cadence was unintelligible without a worked example, and the edit form was visibly wider than its container. The bi-weekly worker bug was closed in v1.5.3 but the surface a patient actually touches was not yet where it needed to be. This release replaces the wizard with a real modal-dialog flow, lands compose-mode so a single medication can carry multiple parallel schedules (the insulin short-acting + long-acting case), widens the treatment-class taxonomy to ten buckets, and retires the flat edit form.
|
||||
|
||||
@@ -133,6 +133,7 @@ The v1.5.2 audit produced `.planning/security-audit-v1.5.2.md` and a stack of pe
|
||||
|
||||
- **Encryption: fail closed everywhere.** Missing keys, malformed JSON, unknown key ids — every path throws rather than silently writing plaintext. Operators rotating keys must keep the legacy entry in `ENCRYPTION_KEYS` until the rotation script reports zero remaining legacy rows. The script covers every `*Encrypted` column + `CoachMessage.encryptedContent` (Bytes) + `IntegrationStatus.lastError`.
|
||||
- **SSRF: input-time `isPublicUrl` is the floor.** The helper in `src/lib/validations/notifications.ts:64` catches every IPv4 / IPv6 alt-notation class (octal / hex / decimal IPv4, IPv4-mapped IPv6, IPv6 ULA, CGNAT, link-local, loopback, metadata). Use it for every outbound URL that comes from a user / admin input. DNS rebinding and the redirect-follow gap remain as architectural follow-ups (issues #217 + #218). Until those land, every NEW outbound `fetch()` that touches a user-controlled host must pin `redirect: "manual"` and an `AbortSignal.timeout`.
|
||||
- **Outbound egress goes through `safeFetch`.** `src/lib/safe-fetch.ts` is the one documented egress entry (`redirect: "manual"` + `AbortSignal.timeout(15_000)` defaults; opt into the connect-time DNS-rebinding pin with `requirePublicHost: true` for any operator- or user-supplied host). The in-repo ESLint rule `healthlog/safe-fetch-required` (set to `error`) bans raw `fetch(` under `src/lib/` + `src/app/` outside the wrapper internals and test files; same-origin relative-path (`/api/…`) client fetches are exempt by construction.
|
||||
- **Rate limits live in Postgres.** `src/lib/rate-limit.ts` uses a single atomic SQL upsert returning `{count, reset_at}`. Multi-instance correctness is structural. Bucket-key convention: `<surface>:${identifier}` where identifier is `userId` for authenticated routes and the trusted-proxy IP for anonymous. Anonymous auth surfaces use `checkAuthSurfaceRateLimit(...)` so a trust-violation collapses every anonymous caller into a single tight bucket rather than a free-for-all under the unknown-IP bucket.
|
||||
- **Webhooks: shared-secret + `timingSafeEqual` + per-source rate limit BEFORE secret verification.** Withings, Telegram, moodLog, the Coolify deploy webhook, and the CSP report endpoint all follow this exact pattern. The Withings path-segment secret is scrubbed from `http.path` / `http.route` by `redactSecrets()` via the `PATH_SECRET_PATHS` registry in `src/lib/logging/redact.ts:40`.
|
||||
- **Idempotency keys are user-scoped, length-bounded, and refuse to cache secret-shaped responses.** `src/lib/idempotency.ts`. A client cannot replay another user's idempotency key (composite unique index by `userId_key_method_path`). The cache helper refuses to persist a response body containing `hlk_` / `hlr_` / `sk-` patterns even if a future caller wraps an auth endpoint by mistake.
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<a href="https://docs.healthlog.dev">Documentation</a> ·
|
||||
<a href="https://testflight.apple.com/join/bucuTBpa">iOS TestFlight</a>
|
||||
</p>
|
||||
<img width="2316" height="1207" alt="home" src="https://github.com/user-attachments/assets/aac0931d-5ea2-45b8-9a12-5f2f013d19e5" />
|
||||
|
||||
---
|
||||
|
||||
@@ -95,7 +96,7 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
|
||||
|
||||
**Sub-second dashboard** -- A persistent rollup tier pre-aggregates every measurement at DAY / WEEK / MONTH granularity. The dashboard and analytics paths read those buckets first and fall back to live SQL only when a tenant hasn't been backfilled yet, so paint time stays under 500 ms regardless of how many years of history you've imported.
|
||||
|
||||
**Gamification** -- 30+ persistent achievements across intake streaks, compliance milestones, and healthy metric streaks.
|
||||
**Gamification** -- 59 persistent achievements across intake streaks, compliance milestones, and healthy metric streaks.
|
||||
|
||||
**Internationalization** -- English (default) and German UI with 2500+ translation keys each, guarded by a CI integrity test that fails the build on duplicate keys, drift between locales, or call-sites referencing keys that no locale ships. Numbers, dates, units, and AI prompts all locale-aware via `useFormatters()`. Browser-based detection with per-user override.
|
||||
|
||||
@@ -322,9 +323,11 @@ All mutations require authentication via session cookie. External ingest uses Be
|
||||
| `POST` | `/api/auth/register` | Create account |
|
||||
| `POST` | `/api/auth/login` | Password login |
|
||||
| `POST` | `/api/auth/logout` | Destroy session |
|
||||
| `GET` | `/api/auth/me` | Current user profile + Gravatar URL |
|
||||
| `GET` | `/api/auth/me` | Current user profile + avatar URL |
|
||||
| `POST` | `/api/auth/password` | Change password |
|
||||
| `PATCH` | `/api/auth/profile` | Update profile fields |
|
||||
| `POST` | `/api/user/avatar` | Upload a profile photo (self-hosted)|
|
||||
| `DELETE`| `/api/user/avatar` | Remove the profile photo |
|
||||
| `POST` | `/api/auth/passkey/*` | WebAuthn flows (4 sub-routes) |
|
||||
| `GET` | `/api/auth/passkeys` | List enrolled passkeys |
|
||||
| `POST` | `/api/auth/codex/device-start` | ChatGPT (Codex) device-OAuth start |
|
||||
|
||||
+381
-2
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.5.3
|
||||
version: 1.6.0
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -170,6 +170,14 @@ paths:
|
||||
- TIME_IN_DAYLIGHT
|
||||
- WALKING_STEADINESS
|
||||
- AUDIO_EXPOSURE_EVENT
|
||||
- RESPIRATORY_RATE
|
||||
- BODY_MASS_INDEX
|
||||
- LEAN_BODY_MASS
|
||||
- WALKING_HEART_RATE_AVERAGE
|
||||
- WALKING_ASYMMETRY
|
||||
- WALKING_DOUBLE_SUPPORT
|
||||
- WALKING_STEP_LENGTH
|
||||
- WALKING_SPEED
|
||||
- in: query
|
||||
name: from
|
||||
schema:
|
||||
@@ -295,6 +303,14 @@ paths:
|
||||
- TIME_IN_DAYLIGHT
|
||||
- WALKING_STEADINESS
|
||||
- AUDIO_EXPOSURE_EVENT
|
||||
- RESPIRATORY_RATE
|
||||
- BODY_MASS_INDEX
|
||||
- LEAN_BODY_MASS
|
||||
- WALKING_HEART_RATE_AVERAGE
|
||||
- WALKING_ASYMMETRY
|
||||
- WALKING_DOUBLE_SUPPORT
|
||||
- WALKING_STEP_LENGTH
|
||||
- WALKING_SPEED
|
||||
value:
|
||||
type: number
|
||||
measuredAt:
|
||||
@@ -303,7 +319,7 @@ paths:
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
notes:
|
||||
type: string
|
||||
maxLength: 25
|
||||
maxLength: 200
|
||||
source:
|
||||
default: MANUAL
|
||||
type: string
|
||||
@@ -1406,6 +1422,163 @@ paths:
|
||||
"401": *a1
|
||||
"422": *a2
|
||||
"429": *a3
|
||||
/api/user/avatar:
|
||||
post:
|
||||
tags:
|
||||
- Auth
|
||||
summary: Upload the calling user's avatar
|
||||
description: "Multipart upload (field `file`). Accepts image/jpeg, image/png, image/webp; rejects anything else at the
|
||||
magic-byte sniff (the multipart Content-Type header is informational only). Hard caps: 2 MiB body, 2048×2048
|
||||
dimensions. Replaces the v1.4.22 Gravatar leak by storing the bytes on the User row + serving them from
|
||||
same-origin."
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
file:
|
||||
type: string
|
||||
description: Binary image payload (JPEG / PNG / WebP). Hard-capped at 2 MiB and 2048×2048.
|
||||
required:
|
||||
- file
|
||||
responses:
|
||||
"201":
|
||||
description: Avatar saved. `avatarUrl` is the same value the /me payload returns; the `?v=` suffix busts client caches
|
||||
on re-upload.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UploadAvatarResponse"
|
||||
"401": *a1
|
||||
"413":
|
||||
description: Upload exceeds the 2 MiB byte limit or the 2048×2048 dimension limit.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"415":
|
||||
description: Image format is not one of JPEG / PNG / WebP (magic-byte sniff).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a2
|
||||
"429": *a3
|
||||
delete:
|
||||
tags:
|
||||
- Auth
|
||||
summary: Clear the calling user's avatar
|
||||
description: Resets the avatar columns to null. Idempotent — a delete on an already-empty row returns 204 with no
|
||||
audit-log row.
|
||||
responses:
|
||||
"204":
|
||||
description: Avatar cleared (or already empty).
|
||||
"401": *a1
|
||||
"422": *a2
|
||||
"429": *a3
|
||||
/api/user/avatar/{id}:
|
||||
get:
|
||||
tags:
|
||||
- Auth
|
||||
summary: Serve a user's avatar bytes
|
||||
description: Owner-scoped. The `{id}` path segment must match the calling user's id — cross-user reads return 403.
|
||||
Response body is the raw image bytes with the persisted `Content-Type`; the /me payload appends
|
||||
`?v={updatedAtMs}` so clients can cache aggressively.
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
schema:
|
||||
type: string
|
||||
description: User id (must match the calling user).
|
||||
required: true
|
||||
description: User id (must match the calling user).
|
||||
responses:
|
||||
"200":
|
||||
description: Avatar bytes.
|
||||
content:
|
||||
image/jpeg:
|
||||
schema:
|
||||
type: string
|
||||
description: Raw JPEG bytes.
|
||||
image/png:
|
||||
schema:
|
||||
type: string
|
||||
description: Raw PNG bytes.
|
||||
image/webp:
|
||||
schema:
|
||||
type: string
|
||||
description: Raw WebP bytes.
|
||||
"401": *a1
|
||||
"403":
|
||||
description: Caller is not the avatar's owner.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"404":
|
||||
description: The user has no uploaded avatar.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a2
|
||||
"429": *a3
|
||||
/api/insights/layout:
|
||||
get:
|
||||
tags:
|
||||
- Insights
|
||||
summary: Read the calling user's Insights tile layout
|
||||
description: Returns the per-user Insights tile layout (visibility + order). Falls back to the default layout when the
|
||||
user has not customised it. Mirrors the dashboard-widgets contract.
|
||||
responses:
|
||||
"200":
|
||||
description: The resolved layout (custom or default).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InsightsLayout"
|
||||
"401": *a1
|
||||
"422": *a2
|
||||
"429": *a3
|
||||
put:
|
||||
tags:
|
||||
- Insights
|
||||
summary: Replace the calling user's Insights tile layout
|
||||
description: Persists the full tile layout. The normalised layout is returned. Invalid bodies return the multi-issue 422
|
||||
envelope, matching the dashboard-widgets route.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InsightsLayoutBody"
|
||||
responses:
|
||||
"200":
|
||||
description: Layout saved; the normalised layout is echoed back.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InsightsLayoutSaved"
|
||||
"401": *a1
|
||||
"422": *a2
|
||||
"429": *a3
|
||||
delete:
|
||||
tags:
|
||||
- Insights
|
||||
summary: Reset the calling user's Insights tile layout
|
||||
description: Clears the persisted layout and returns the default layout. Idempotent.
|
||||
responses:
|
||||
"200":
|
||||
description: Layout reset; the default layout is returned.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InsightsLayoutReset"
|
||||
"401": *a1
|
||||
"422": *a2
|
||||
"429": *a3
|
||||
/api/insights/chat/messages/{id}/feedback:
|
||||
post:
|
||||
tags:
|
||||
@@ -1703,6 +1876,12 @@ components:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
deliveryForm:
|
||||
type: string
|
||||
enum:
|
||||
- ORAL
|
||||
- INJECTION
|
||||
- OTHER
|
||||
notificationsEnabled:
|
||||
type: boolean
|
||||
startsOn:
|
||||
@@ -1850,6 +2029,12 @@ components:
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
- type: "null"
|
||||
deliveryForm:
|
||||
type: string
|
||||
enum:
|
||||
- ORAL
|
||||
- INJECTION
|
||||
- OTHER
|
||||
active:
|
||||
type: boolean
|
||||
notificationsEnabled:
|
||||
@@ -1880,6 +2065,14 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/MedicationScheduleInput"
|
||||
reminderGraceMinutes:
|
||||
description: "Detail-page bridge: primary-schedule reminder-window in minutes. The route maps the value onto the primary
|
||||
schedule's `reminderGraceMinutes` field; ignored when a full `schedules` array is also supplied."
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: 0
|
||||
maximum: 1440
|
||||
- type: "null"
|
||||
description: Update-medication body. Every field is optional; omitted fields are left untouched. Supplying `schedules`
|
||||
REPLACES the medication's full schedule list (the route deletes existing rows before re-creating). Flipping
|
||||
`active` to false records the current timestamp on `pausedAt`; flipping back to true clears it. v1.5 invariants
|
||||
@@ -1954,6 +2147,51 @@ components:
|
||||
required:
|
||||
- disableCoach
|
||||
description: Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates.
|
||||
InsightsLayoutBody:
|
||||
type: object
|
||||
properties:
|
||||
version:
|
||||
type: number
|
||||
const: 1
|
||||
tiles:
|
||||
minItems: 1
|
||||
maxItems: 50
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
enum:
|
||||
- overview
|
||||
- blutdruck
|
||||
- puls
|
||||
- sauerstoff
|
||||
- koerpertemperatur
|
||||
- gewicht
|
||||
- bmi
|
||||
- aktive-energie
|
||||
- workouts
|
||||
- schlaf
|
||||
- ruhepuls
|
||||
- hrv
|
||||
- stimmung
|
||||
- medikamente
|
||||
visible:
|
||||
type: boolean
|
||||
order:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 99
|
||||
required:
|
||||
- id
|
||||
- visible
|
||||
- order
|
||||
required:
|
||||
- version
|
||||
- tiles
|
||||
description: "Per-user Insights tile layout: an ordered list of tiles with a visibility flag. `version` is the layout
|
||||
schema version; tile ids are a closed enum derived from the server's tile registry."
|
||||
CoachMessageFeedbackRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -2222,6 +2460,14 @@ components:
|
||||
- TIME_IN_DAYLIGHT
|
||||
- WALKING_STEADINESS
|
||||
- AUDIO_EXPOSURE_EVENT
|
||||
- RESPIRATORY_RATE
|
||||
- BODY_MASS_INDEX
|
||||
- LEAN_BODY_MASS
|
||||
- WALKING_HEART_RATE_AVERAGE
|
||||
- WALKING_ASYMMETRY
|
||||
- WALKING_DOUBLE_SUPPORT
|
||||
- WALKING_STEP_LENGTH
|
||||
- WALKING_SPEED
|
||||
value:
|
||||
type: number
|
||||
unit:
|
||||
@@ -4142,6 +4388,92 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
UploadAvatarResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
avatarUrl:
|
||||
type: string
|
||||
contentType:
|
||||
type: string
|
||||
enum:
|
||||
- image/jpeg
|
||||
- image/png
|
||||
- image/webp
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
required:
|
||||
- avatarUrl
|
||||
- contentType
|
||||
- updatedAt
|
||||
additionalProperties: false
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
InsightsLayout:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/InsightsLayoutBodyOutput"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
InsightsLayoutSaved:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/InsightsLayoutBodyOutput"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
InsightsLayoutReset:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/InsightsLayoutBodyOutput"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
CoachMessageFeedbackResponse:
|
||||
type: object
|
||||
properties:
|
||||
@@ -4450,6 +4782,53 @@ components:
|
||||
- disableCoach
|
||||
additionalProperties: false
|
||||
description: Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates.
|
||||
InsightsLayoutBodyOutput:
|
||||
type: object
|
||||
properties:
|
||||
version:
|
||||
type: number
|
||||
const: 1
|
||||
tiles:
|
||||
minItems: 1
|
||||
maxItems: 50
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
enum:
|
||||
- overview
|
||||
- blutdruck
|
||||
- puls
|
||||
- sauerstoff
|
||||
- koerpertemperatur
|
||||
- gewicht
|
||||
- bmi
|
||||
- aktive-energie
|
||||
- workouts
|
||||
- schlaf
|
||||
- ruhepuls
|
||||
- hrv
|
||||
- stimmung
|
||||
- medikamente
|
||||
visible:
|
||||
type: boolean
|
||||
order:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 99
|
||||
required:
|
||||
- id
|
||||
- visible
|
||||
- order
|
||||
additionalProperties: false
|
||||
required:
|
||||
- version
|
||||
- tiles
|
||||
additionalProperties: false
|
||||
description: "Per-user Insights tile layout: an ordered list of tiles with a visibility flag. `version` is the layout
|
||||
schema version; tile ids are a closed enum derived from the server's tile registry."
|
||||
Medication:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -124,6 +124,23 @@ export async function stubDashboardAnalytics(page: Page): Promise<void> {
|
||||
* redirects to `/medications?new=1` and opens the create dialog).
|
||||
*/
|
||||
export async function openCreateWizard(page: Page): Promise<void> {
|
||||
// Pin the German locale for the wizard flow. The app default locale is
|
||||
// English; these specs assert the "Schritt N von M" counter and other
|
||||
// German label surfaces, which only render when the `healthlog-locale`
|
||||
// cookie resolves to "de". Both the server layout and the client i18n
|
||||
// provider read this exact cookie name, so setting it before the first
|
||||
// navigation makes the dialog render German on first paint. Scoped here
|
||||
// rather than in the shared auth state so English-asserting specs keep
|
||||
// the default locale.
|
||||
await page.context().addCookies([
|
||||
{
|
||||
name: "healthlog-locale",
|
||||
value: "de",
|
||||
url: page.url().startsWith("http")
|
||||
? new URL(page.url()).origin
|
||||
: "http://localhost:3000",
|
||||
},
|
||||
]);
|
||||
await page.goto("/medications/new", { waitUntil: "domcontentloaded" });
|
||||
await expect(
|
||||
page.locator('[data-slot="medication-wizard-dialog"]'),
|
||||
|
||||
@@ -181,7 +181,7 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
// already-onboarded ones, so for our pre-seeded completed e2e user
|
||||
// the cookie is absent and the proxy passes the dashboard through.
|
||||
state.cookies = state.cookies.filter((c) =>
|
||||
["healthlog_session", "healthlog_locale", "hl_onboarding"].includes(c.name),
|
||||
["healthlog_session", "healthlog-locale", "hl_onboarding"].includes(c.name),
|
||||
);
|
||||
await writeFile(STORAGE_STATE_PATH, JSON.stringify(state, null, 2));
|
||||
await ctx.dispose();
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* @fileoverview Aggregates the project-local `healthlog/*` ESLint rules
|
||||
* into a single plugin object for the flat config.
|
||||
*
|
||||
* - `queryKey-factory` — bare-array queryKey / mutationKey bypass guard.
|
||||
* - `safe-fetch-required` — outbound fetch must route through safeFetch.
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
const queryKeyFactory = require("./queryKey-factory.js");
|
||||
const safeFetchRequired = require("./safe-fetch-required.js");
|
||||
|
||||
module.exports = {
|
||||
rules: {
|
||||
"queryKey-factory": queryKeyFactory.rules["queryKey-factory"],
|
||||
"safe-fetch-required": safeFetchRequired,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @fileoverview ESLint rule — outbound `fetch` must route through the
|
||||
* `safeFetch` wrapper.
|
||||
*
|
||||
* v1.5.6 — `src/lib/safe-fetch.ts` is the documented egress entry point:
|
||||
* it pins `redirect: "manual"` + an `AbortSignal.timeout` by default and
|
||||
* gates the connect-time IP pin behind `requirePublicHost`. A raw
|
||||
* `fetch(...)` call under `src/lib/` or `src/app/` bypasses every one of
|
||||
* those defences, so this rule flags it at authoring time and in CI.
|
||||
*
|
||||
* Allowed:
|
||||
* - `safeFetch(...)` — the wrapper itself.
|
||||
* - calls inside `src/lib/safe-fetch.ts` — the wrapper's own `fetch`.
|
||||
* - calls inside `src/lib/safe-fetch-dispatcher.ts` — the pinned dispatcher.
|
||||
* - test files (`*.test.ts(x)`, `__tests__/`, `__mocks__/`) — they
|
||||
* mock or assert against `fetch` directly.
|
||||
*
|
||||
* The check is a syntactic `CallExpression` match against a bare
|
||||
* `fetch(` callee (Identifier `fetch`) and `globalThis.fetch(` /
|
||||
* `window.fetch(` member forms. `safeFetch` is a distinct identifier and
|
||||
* never matches.
|
||||
*
|
||||
* Same-origin client fetches are exempt: a first argument that is a
|
||||
* string literal (or template head) starting with `/` is a relative,
|
||||
* same-origin request (`fetch("/api/…")`) that never leaves the origin
|
||||
* and has no SSRF / redirect-leak / DNS-rebinding surface. The wrapper
|
||||
* is for OUTBOUND egress to external hosts — absolute URLs or
|
||||
* variable-sourced targets — which is exactly what stays flagged.
|
||||
*
|
||||
* @see src/lib/safe-fetch.ts
|
||||
* @see src/lib/safe-fetch-dispatcher.ts
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
// Files exempt from the rule — the wrapper internals and the test
|
||||
// surface. Posix-style suffix matches against the absolute filename via
|
||||
// `String#includes`, mirroring the queryKey-factory rule's convention.
|
||||
const EXEMPT_FILES = [
|
||||
"src/lib/safe-fetch.ts",
|
||||
"src/lib/safe-fetch-dispatcher.ts",
|
||||
];
|
||||
|
||||
// Only enforce inside the application source roots. Scripts, config, and
|
||||
// generated code are out of scope.
|
||||
const ENFORCED_ROOTS = ["src/lib/", "src/app/"];
|
||||
|
||||
function toPosix(filename) {
|
||||
return filename.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function isTestFile(posix) {
|
||||
return (
|
||||
/\.test\.[cm]?[jt]sx?$/.test(posix) ||
|
||||
/\.spec\.[cm]?[jt]sx?$/.test(posix) ||
|
||||
posix.includes("/__tests__/") ||
|
||||
posix.includes("/__mocks__/")
|
||||
);
|
||||
}
|
||||
|
||||
function isEnforced(filename) {
|
||||
const posix = toPosix(filename);
|
||||
if (!ENFORCED_ROOTS.some((root) => posix.includes(root))) return false;
|
||||
if (EXEMPT_FILES.some((f) => posix.includes(f))) return false;
|
||||
if (isTestFile(posix)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @type {import("eslint").Rule.RuleModule} */
|
||||
const safeFetchRequiredRule = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Outbound fetch must route through the safeFetch wrapper (src/lib/safe-fetch.ts).",
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
rawFetch:
|
||||
"Raw fetch() bypasses the safeFetch wrapper's manual-redirect + timeout (and requirePublicHost) defences. Import { safeFetch } from \"@/lib/safe-fetch\" and call it instead.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const filename = context.filename ?? context.getFilename?.();
|
||||
if (!filename || !isEnforced(filename)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
function isFetchCallee(callee) {
|
||||
// Bare `fetch(...)`.
|
||||
if (callee.type === "Identifier" && callee.name === "fetch") return true;
|
||||
// `globalThis.fetch(...)` / `window.fetch(...)` / `self.fetch(...)`.
|
||||
if (
|
||||
callee.type === "MemberExpression" &&
|
||||
!callee.computed &&
|
||||
callee.property.type === "Identifier" &&
|
||||
callee.property.name === "fetch" &&
|
||||
callee.object.type === "Identifier" &&
|
||||
(callee.object.name === "globalThis" ||
|
||||
callee.object.name === "window" ||
|
||||
callee.object.name === "self")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isSameOriginRelative(arg) {
|
||||
if (!arg) return false;
|
||||
// A single leading `/` not followed by `/` or `\`. `//evil.com` is a
|
||||
// protocol-relative absolute URL and `/\evil.com` normalises to the
|
||||
// same under WHATWG parsing — both resolve off-origin, so neither is
|
||||
// a same-origin path and the wrapper must still cover them.
|
||||
const isRelativeHead = (head) => /^\/(?![/\\])/.test(head);
|
||||
// `fetch("/api/…")`
|
||||
if (arg.type === "Literal" && typeof arg.value === "string") {
|
||||
return isRelativeHead(arg.value);
|
||||
}
|
||||
// `fetch(`/api/${id}`)` — inspect the template's first cooked chunk.
|
||||
if (arg.type === "TemplateLiteral" && arg.quasis.length > 0) {
|
||||
return isRelativeHead(arg.quasis[0].value.cooked ?? "");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
CallExpression(node) {
|
||||
if (!isFetchCallee(node.callee)) return;
|
||||
// Exempt same-origin relative-path requests — they never leave
|
||||
// the origin, so the wrapper's outbound defences do not apply.
|
||||
if (isSameOriginRelative(node.arguments[0])) return;
|
||||
context.report({ node, messageId: "rawFetch" });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = safeFetchRequiredRule;
|
||||
+13
-2
@@ -1,7 +1,7 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
import healthlogPlugin from "./eslint-plugins/healthlog/queryKey-factory.js";
|
||||
import healthlogPlugin from "./eslint-plugins/healthlog/index.js";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
@@ -17,6 +17,10 @@ const eslintConfig = defineConfig([
|
||||
// worktree on an older commit can't poison `pnpm lint` locally.
|
||||
// CI never sees this path; the rule is for the dev environment.
|
||||
".claude/**",
|
||||
// The project-local ESLint rule plugin is CommonJS (require/module
|
||||
// .exports) by ESLint convention; it is not application source and
|
||||
// is not linted by the app's TypeScript ruleset.
|
||||
"eslint-plugins/**",
|
||||
]),
|
||||
// v1.4.41 W-PROCESS-DOCS — custom rule that flags any bare-array
|
||||
// `queryKey: [ … ]` / `mutationKey: [ … ]` declaration inside the
|
||||
@@ -27,7 +31,14 @@ const eslintConfig = defineConfig([
|
||||
// whitelist + rationale.
|
||||
{
|
||||
plugins: { healthlog: healthlogPlugin },
|
||||
rules: { "healthlog/queryKey-factory": "error" },
|
||||
rules: {
|
||||
"healthlog/queryKey-factory": "error",
|
||||
// v1.5.6 — every outbound fetch under src/lib + src/app must route
|
||||
// through the safeFetch wrapper (manual-redirect + timeout, and the
|
||||
// requirePublicHost connect-time pin). The wrapper internals and
|
||||
// test files are exempt; see the rule file for the allowlist.
|
||||
"healthlog/safe-fetch-required": "error",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
+215
-1
@@ -8,6 +8,7 @@
|
||||
"create": "Erstellen",
|
||||
"close": "Schließen",
|
||||
"loading": "Laden...",
|
||||
"loadFailed": "Konnte nicht geladen werden",
|
||||
"error": "Fehler",
|
||||
"saved": "Gespeichert",
|
||||
"no": "Nein",
|
||||
@@ -294,6 +295,32 @@
|
||||
"title": "Noch keine Workouts",
|
||||
"cta": "Öffne die Apple-Health-App auf dem iPhone, damit Workouts hier erscheinen."
|
||||
}
|
||||
},
|
||||
"metric": {
|
||||
"title": {
|
||||
"weight": "Gewicht",
|
||||
"bloodPressure": "Blutdruck",
|
||||
"pulse": "Puls",
|
||||
"bodyFat": "Körperfett",
|
||||
"glucose": "Blutzucker",
|
||||
"sleep": "Schlaf",
|
||||
"steps": "Schritte",
|
||||
"totalBodyWater": "Gesamtkörperwasser",
|
||||
"boneMass": "Knochenmasse",
|
||||
"oxygenSaturation": "Sauerstoffsättigung"
|
||||
},
|
||||
"unit": {
|
||||
"weight": "kg",
|
||||
"bloodPressure": "mmHg",
|
||||
"pulse": "bpm",
|
||||
"bodyFat": "%",
|
||||
"glucose": "mg/dL",
|
||||
"sleep": "h",
|
||||
"steps": "Schritte",
|
||||
"totalBodyWater": "kg",
|
||||
"boneMass": "kg",
|
||||
"oxygenSaturation": "%"
|
||||
}
|
||||
}
|
||||
},
|
||||
"measurements": {
|
||||
@@ -337,6 +364,14 @@
|
||||
"typeTimeInDaylight": "Zeit im Tageslicht",
|
||||
"typeWalkingSteadiness": "Gehstabilität",
|
||||
"typeAudioExposureEvent": "Lautes-Hören-Ereignis",
|
||||
"typeRespiratoryRate": "Atemfrequenz",
|
||||
"typeBodyMassIndex": "Body-Mass-Index",
|
||||
"typeLeanBodyMass": "Magermasse",
|
||||
"typeWalkingHeartRateAverage": "Geh-Puls (Mittelwert)",
|
||||
"typeWalkingAsymmetry": "Gang-Asymmetrie",
|
||||
"typeWalkingDoubleSupport": "Gang-Doppelstand",
|
||||
"typeWalkingStepLength": "Schrittlänge",
|
||||
"typeWalkingSpeed": "Gehgeschwindigkeit",
|
||||
"glucoseContext": "Kontext",
|
||||
"glucoseContextFasting": "Nüchtern (≥8 h ohne Nahrung)",
|
||||
"glucoseContextPostprandial": "Postprandial (2 h nach dem Essen)",
|
||||
@@ -439,6 +474,7 @@
|
||||
"scheduleDaily": "Täglich",
|
||||
"inactive": "Pausiert",
|
||||
"intakeHistory": "Einnahme-Verlauf",
|
||||
"openDetailPage": "Medikament-Detailseite öffnen",
|
||||
"deleteConfirm": "Medikament löschen?",
|
||||
"compliance": "Einnahmetreue",
|
||||
"importIntakes": "Einnahmen importieren",
|
||||
@@ -795,7 +831,18 @@
|
||||
"sprays": "Sprühstoß",
|
||||
"pieces": "Stück",
|
||||
"other": "Sonstige"
|
||||
}
|
||||
},
|
||||
"deliveryFormLabel": "Darreichung",
|
||||
"deliveryForm": {
|
||||
"ORAL": "Oral",
|
||||
"INJECTION": "Injektion",
|
||||
"OTHER": "Sonstige"
|
||||
},
|
||||
"dosesPerUnitLabel": "Dosen pro Einheit",
|
||||
"dosesPerUnitPlaceholder": "z. B. 4",
|
||||
"dosesPerUnitHint": "Dosen pro Pen / Ampulle — leer lassen, wenn kein Bestand getrackt wird.",
|
||||
"injectionSiteToggle": "Injektionsstellen-Rotation",
|
||||
"injectionSiteHint": "Optionaler Rotationshelfer. Die Stelle jeder Dosis wird beim Eintragen der Einnahme erfasst."
|
||||
},
|
||||
"step4": {
|
||||
"title": "Über welchen Zeitraum nimmst du es?",
|
||||
@@ -1075,6 +1122,159 @@
|
||||
"gatedCta": "Forschungsmodus in den Einstellungen öffnen",
|
||||
"unknownDrug": "{name} ist nicht im EMA-zugelassenen GLP-1-Katalog enthalten. HealthLog kann dafür keine Populations-Pharmakokinetik-Kurve erzeugen."
|
||||
}
|
||||
},
|
||||
"detail": {
|
||||
"status": {
|
||||
"active": "Aktiv",
|
||||
"paused": "Pausiert",
|
||||
"ended": "Beendet"
|
||||
},
|
||||
"today": {
|
||||
"groupLabel": "Heutige Dosis",
|
||||
"taken": "Genommen",
|
||||
"skipped": "Übersprungen",
|
||||
"toastTaken": "Heutige Dosis erfasst",
|
||||
"toastSkipped": "Heutige Dosis übersprungen",
|
||||
"recordedTaken": "Heute genommen um {time}",
|
||||
"recordedSkipped": "Heute übersprungen um {time}",
|
||||
"recordedOneShot": "Einmalig genommen um {time}",
|
||||
"noneScheduled": "Heute keine Einnahme geplant.",
|
||||
"pausedHint": "Pausiert — heute keine Erinnerung.",
|
||||
"error": "Dosis konnte nicht erfasst werden."
|
||||
},
|
||||
"cadence": {
|
||||
"title": "Rhythmus",
|
||||
"oneShotOn": "Einmalige Dosis am {date}.",
|
||||
"oneShotPending": "Einmalige Dosis ausstehend."
|
||||
},
|
||||
"intake": {
|
||||
"title": "Einnahmeverlauf",
|
||||
"importButton": "Importieren",
|
||||
"viewAllLink": "Vollständigen Verlauf öffnen →",
|
||||
"rowActions": {
|
||||
"openMenu": "Zeilen-Aktionen öffnen",
|
||||
"edit": "Bearbeiten",
|
||||
"delete": "Löschen"
|
||||
},
|
||||
"selection": {
|
||||
"rowToggleLabel": "Zeile auswählen"
|
||||
},
|
||||
"bulkDelete": {
|
||||
"selectionCount": "{count} Einträge ausgewählt",
|
||||
"deleteButton": "Auswahl löschen",
|
||||
"cancelButton": "Abbrechen",
|
||||
"confirmTitle": "Ausgewählte Einträge löschen?",
|
||||
"confirmBody": "{count} Einträge werden dauerhaft entfernt. Das Medikament bleibt.",
|
||||
"confirmAction": "Löschen",
|
||||
"toast": "Auswahl gelöscht",
|
||||
"failed": "Auswahl konnte nicht gelöscht werden."
|
||||
},
|
||||
"edit": {
|
||||
"dialogTitle": "Einnahme bearbeiten",
|
||||
"takenAtLabel": "Eingenommen um",
|
||||
"skippedLabel": "Übersprungen",
|
||||
"noteLabel": "Notiz",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"savedToast": "Einnahme aktualisiert",
|
||||
"failed": "Einnahme konnte nicht aktualisiert werden."
|
||||
},
|
||||
"deleteRow": {
|
||||
"confirmTitle": "Diese Einnahme löschen?",
|
||||
"confirmBody": "Der Eintrag wird dauerhaft entfernt.",
|
||||
"confirmAction": "Löschen",
|
||||
"toast": "Einnahme gelöscht",
|
||||
"failed": "Einnahme konnte nicht gelöscht werden."
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Erinnerungen",
|
||||
"switchLabel": "Erinnern, wenn die Dosis fällig ist",
|
||||
"helperOn": "Erinnerungen sind aktiv.",
|
||||
"helperOff": "Erinnerungen sind aus.",
|
||||
"enabledToast": "Erinnerungen aktiv",
|
||||
"disabledToast": "Erinnerungen aus",
|
||||
"toggleFailed": "Erinnerungs-Einstellung konnte nicht geändert werden.",
|
||||
"clientManagedChip": "Dein iPhone steuert die Erinnerungen für dieses Medikament."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"csvImport": {
|
||||
"title": "CSV-Import",
|
||||
"stub": "Importiere Einnahmen direkt aus dem Verlauf oben."
|
||||
},
|
||||
"phases": {
|
||||
"openButton": "Phasen konfigurieren",
|
||||
"requiresCourseWindow": "Phasen sind nur mit Kurs-Fenster verfügbar."
|
||||
},
|
||||
"grace": {
|
||||
"label": "Erinnerungsfenster — gilt für deinen Hauptzeitplan",
|
||||
"primaryScheduleNote": "Andere Zeitpläne behalten ihr Standard-Fenster.",
|
||||
"unit": "Minuten",
|
||||
"saved": "Erinnerungsfenster gespeichert",
|
||||
"failed": "Erinnerungsfenster konnte nicht gespeichert werden."
|
||||
}
|
||||
},
|
||||
"zone": {
|
||||
"title": "Verwaltung & Gefahrenzone",
|
||||
"pause": {
|
||||
"title": "Pausieren",
|
||||
"helper": "Erinnerungen anhalten, bis du sie wieder aktivierst. Verlauf bleibt erhalten.",
|
||||
"pausedToast": "Erinnerungen pausiert",
|
||||
"resumedToast": "Erinnerungen reaktiviert",
|
||||
"failed": "Status konnte nicht geändert werden."
|
||||
},
|
||||
"end": {
|
||||
"title": "Medikament beenden",
|
||||
"helper": "Erinnerungen stoppen und das Medikament als beendet markieren. Verlauf bleibt sichtbar.",
|
||||
"button": "Beenden",
|
||||
"dialogTitle": "{name} beenden — Erinnerungen stoppen, Verlauf bleibt sichtbar",
|
||||
"dialogBody": "Keine weiteren Erinnerungen. Alte Einträge bleiben in der Übersicht.",
|
||||
"toast": "Medikament beendet",
|
||||
"failed": "Medikament konnte nicht beendet werden."
|
||||
},
|
||||
"purge": {
|
||||
"title": "Verlauf löschen",
|
||||
"helper": "Es sind {count} Einnahmen für dieses Medikament gespeichert.",
|
||||
"button": "Verlauf löschen",
|
||||
"dialogTitle": "Verlauf wirklich löschen?",
|
||||
"dialogBody": "Die {count} Einnahmen werden unwiderruflich gelöscht. Das Medikament selbst bleibt.",
|
||||
"toast": "Verlauf gelöscht",
|
||||
"failed": "Verlauf konnte nicht gelöscht werden."
|
||||
},
|
||||
"delete": {
|
||||
"title": "Medikament löschen",
|
||||
"helper": "Entfernt das Medikament und alle zugehörigen Daten.",
|
||||
"button": "Löschen",
|
||||
"dialogTitle": "{name} löschen?",
|
||||
"dialogBody": "Medikament, Zeitpläne, Verlauf, API-Tokens, Phasen-Konfiguration und Erinnerungen werden unwiderruflich gelöscht.",
|
||||
"toast": "Medikament gelöscht",
|
||||
"failed": "Medikament konnte nicht gelöscht werden."
|
||||
}
|
||||
},
|
||||
"phases": {
|
||||
"modeLabel": "Modus für {phase}",
|
||||
"saveFailed": "Phasen konnten nicht gespeichert werden."
|
||||
},
|
||||
"api": {
|
||||
"caption": "Endpunkt für „{name}\"",
|
||||
"copyUrl": "URL kopieren",
|
||||
"urlCopied": "URL kopiert",
|
||||
"mintToken": "Token erzeugen",
|
||||
"mintAnotherToken": "Weiteres Token erzeugen",
|
||||
"mintFailed": "Token konnte nicht erzeugt werden.",
|
||||
"copyFailed": "Kopieren fehlgeschlagen.",
|
||||
"tokenCopied": "Token kopiert",
|
||||
"copyToken": "Token kopieren",
|
||||
"mintedHint": "Token jetzt kopieren — es wird nur einmal angezeigt."
|
||||
},
|
||||
"edit": {
|
||||
"planOption": "Plan bearbeiten",
|
||||
"advancedOption": "Erweiterte Einstellungen"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Erweiterte Einstellungen"
|
||||
}
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -2008,6 +2208,19 @@
|
||||
"timezoneHint": "Wird für Diagrammachsen, Erinnerungszeiten, Export-Zeitstempel und den Coach-Kontext verwendet. Gespeicherte Daten bleiben unverändert.",
|
||||
"timezoneInvalid": "Keine gültige IANA-Zeitzone.",
|
||||
"profileSaved": "Profil gespeichert",
|
||||
"avatar": {
|
||||
"title": "Profilfoto",
|
||||
"description": "Lade ein Foto hoch, das auf deinem eigenen Server gespeichert wird. Es erscheint überall in der App anstelle eines externen Avatar-Dienstes.",
|
||||
"upload": "Foto hochladen",
|
||||
"replace": "Foto ersetzen",
|
||||
"remove": "Entfernen",
|
||||
"hint": "JPEG, PNG oder WebP. Bis zu 2 MB und 2048×2048 Pixel.",
|
||||
"uploaded": "Profilfoto aktualisiert",
|
||||
"removed": "Profilfoto entfernt",
|
||||
"invalidType": "Nicht unterstützter Bildtyp. Verwende JPEG, PNG oder WebP.",
|
||||
"tooLarge": "Das Bild überschreitet das Limit von 2 MB.",
|
||||
"error": "Profilfoto konnte nicht aktualisiert werden. Bitte versuche es erneut."
|
||||
},
|
||||
"changePassword": "Passwort ändern",
|
||||
"changePasswordDescription": "Ersetze dein aktuelles Passwort durch ein neues.",
|
||||
"passwordReset": "Passwort-Reset",
|
||||
@@ -2421,6 +2634,7 @@
|
||||
"telegramGlobal": "Telegram-Benachrichtigungen erlauben.",
|
||||
"ntfyGlobal": "ntfy-Benachrichtigungen erlauben.",
|
||||
"webPushGlobal": "Browser-Push-Benachrichtigungen erlauben.",
|
||||
"moodLogGlobal": "moodLog-Erinnerungen erlauben.",
|
||||
"integrationWebPush": "Web Push",
|
||||
"integrationBugReport": "Bug-Reports",
|
||||
"offlineGeoLabel": "Geodaten",
|
||||
|
||||
+215
-1
@@ -8,6 +8,7 @@
|
||||
"create": "Create",
|
||||
"close": "Close",
|
||||
"loading": "Loading...",
|
||||
"loadFailed": "Could not load",
|
||||
"error": "Error",
|
||||
"saved": "Saved",
|
||||
"no": "No",
|
||||
@@ -294,6 +295,32 @@
|
||||
"title": "No workouts yet",
|
||||
"cta": "Open Apple Health on your iPhone to sync workouts here."
|
||||
}
|
||||
},
|
||||
"metric": {
|
||||
"title": {
|
||||
"weight": "Weight",
|
||||
"bloodPressure": "Blood Pressure",
|
||||
"pulse": "Pulse",
|
||||
"bodyFat": "Body Fat",
|
||||
"glucose": "Blood Glucose",
|
||||
"sleep": "Sleep",
|
||||
"steps": "Steps",
|
||||
"totalBodyWater": "Total Body Water",
|
||||
"boneMass": "Bone Mass",
|
||||
"oxygenSaturation": "Oxygen Saturation"
|
||||
},
|
||||
"unit": {
|
||||
"weight": "kg",
|
||||
"bloodPressure": "mmHg",
|
||||
"pulse": "bpm",
|
||||
"bodyFat": "%",
|
||||
"glucose": "mg/dL",
|
||||
"sleep": "h",
|
||||
"steps": "steps",
|
||||
"totalBodyWater": "kg",
|
||||
"boneMass": "kg",
|
||||
"oxygenSaturation": "%"
|
||||
}
|
||||
}
|
||||
},
|
||||
"measurements": {
|
||||
@@ -337,6 +364,14 @@
|
||||
"typeTimeInDaylight": "Time in Daylight",
|
||||
"typeWalkingSteadiness": "Walking Steadiness",
|
||||
"typeAudioExposureEvent": "Loud-Listening Event",
|
||||
"typeRespiratoryRate": "Respiratory rate",
|
||||
"typeBodyMassIndex": "Body-mass index",
|
||||
"typeLeanBodyMass": "Lean body mass",
|
||||
"typeWalkingHeartRateAverage": "Walking heart rate (avg)",
|
||||
"typeWalkingAsymmetry": "Walking asymmetry",
|
||||
"typeWalkingDoubleSupport": "Walking double support",
|
||||
"typeWalkingStepLength": "Walking step length",
|
||||
"typeWalkingSpeed": "Walking speed",
|
||||
"glucoseContext": "Context",
|
||||
"glucoseContextFasting": "Fasting (≥8h without food)",
|
||||
"glucoseContextPostprandial": "Post-meal (2h after eating)",
|
||||
@@ -439,6 +474,7 @@
|
||||
"scheduleDaily": "Daily",
|
||||
"inactive": "Paused",
|
||||
"intakeHistory": "Intake history",
|
||||
"openDetailPage": "Open medication detail page",
|
||||
"deleteConfirm": "Delete medication?",
|
||||
"compliance": "Compliance",
|
||||
"importIntakes": "Import intakes",
|
||||
@@ -795,7 +831,18 @@
|
||||
"sprays": "spray",
|
||||
"pieces": "pieces",
|
||||
"other": "other"
|
||||
}
|
||||
},
|
||||
"deliveryFormLabel": "Route",
|
||||
"deliveryForm": {
|
||||
"ORAL": "Oral",
|
||||
"INJECTION": "Injection",
|
||||
"OTHER": "Other"
|
||||
},
|
||||
"dosesPerUnitLabel": "Doses per unit",
|
||||
"dosesPerUnitPlaceholder": "e.g. 4",
|
||||
"dosesPerUnitHint": "Doses per pen / vial — leave empty for no inventory tracking.",
|
||||
"injectionSiteToggle": "Injection-site rotation",
|
||||
"injectionSiteHint": "Optional rotation guide. The site for each dose is recorded when you log the intake."
|
||||
},
|
||||
"step4": {
|
||||
"title": "Over what period are you taking it?",
|
||||
@@ -1075,6 +1122,159 @@
|
||||
"gatedCta": "Open Research Mode in Settings",
|
||||
"unknownDrug": "{name} is not in the EMA-approved GLP-1 catalog, so HealthLog cannot render a population-PK curve for it."
|
||||
}
|
||||
},
|
||||
"detail": {
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"paused": "Paused",
|
||||
"ended": "Ended"
|
||||
},
|
||||
"today": {
|
||||
"groupLabel": "Today's dose",
|
||||
"taken": "Taken",
|
||||
"skipped": "Skipped",
|
||||
"toastTaken": "Today's dose logged",
|
||||
"toastSkipped": "Today's dose skipped",
|
||||
"recordedTaken": "Taken today at {time}",
|
||||
"recordedSkipped": "Skipped today at {time}",
|
||||
"recordedOneShot": "One-shot dose taken at {time}",
|
||||
"noneScheduled": "No dose scheduled for today.",
|
||||
"pausedHint": "Paused — no reminder today.",
|
||||
"error": "Could not log the dose."
|
||||
},
|
||||
"cadence": {
|
||||
"title": "Cadence",
|
||||
"oneShotOn": "One-time dose on {date}.",
|
||||
"oneShotPending": "One-time dose pending."
|
||||
},
|
||||
"intake": {
|
||||
"title": "Intake history",
|
||||
"importButton": "Import",
|
||||
"viewAllLink": "View full history →",
|
||||
"rowActions": {
|
||||
"openMenu": "Open row actions",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"selection": {
|
||||
"rowToggleLabel": "Select row"
|
||||
},
|
||||
"bulkDelete": {
|
||||
"selectionCount": "{count} entries selected",
|
||||
"deleteButton": "Delete selection",
|
||||
"cancelButton": "Cancel",
|
||||
"confirmTitle": "Delete the selected entries?",
|
||||
"confirmBody": "{count} entries will be removed permanently. The medication itself stays.",
|
||||
"confirmAction": "Delete",
|
||||
"toast": "Selection deleted",
|
||||
"failed": "Could not delete the selection."
|
||||
},
|
||||
"edit": {
|
||||
"dialogTitle": "Edit intake",
|
||||
"takenAtLabel": "Taken at",
|
||||
"skippedLabel": "Skipped",
|
||||
"noteLabel": "Note",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"savedToast": "Intake updated",
|
||||
"failed": "Could not update the intake."
|
||||
},
|
||||
"deleteRow": {
|
||||
"confirmTitle": "Delete this intake?",
|
||||
"confirmBody": "The entry is removed permanently.",
|
||||
"confirmAction": "Delete",
|
||||
"toast": "Intake deleted",
|
||||
"failed": "Could not delete the intake."
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"switchLabel": "Send a reminder when this dose is due",
|
||||
"helperOn": "Reminders are on.",
|
||||
"helperOff": "Reminders are off.",
|
||||
"enabledToast": "Reminders on",
|
||||
"disabledToast": "Reminders off",
|
||||
"toggleFailed": "Could not change the reminder setting.",
|
||||
"clientManagedChip": "Your iPhone manages reminders for this medication."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"csvImport": {
|
||||
"title": "CSV import",
|
||||
"stub": "Import intakes directly from the history above."
|
||||
},
|
||||
"phases": {
|
||||
"openButton": "Configure phases",
|
||||
"requiresCourseWindow": "Phases are available once a course window is set."
|
||||
},
|
||||
"grace": {
|
||||
"label": "Reminder window — applies to your primary schedule",
|
||||
"primaryScheduleNote": "Multi-schedule medications keep their other schedules' default window.",
|
||||
"unit": "minutes",
|
||||
"saved": "Reminder window saved",
|
||||
"failed": "Could not save the reminder window."
|
||||
}
|
||||
},
|
||||
"zone": {
|
||||
"title": "Manage & danger zone",
|
||||
"pause": {
|
||||
"title": "Pause",
|
||||
"helper": "Pause reminders until you turn them back on. History stays intact.",
|
||||
"pausedToast": "Reminders paused",
|
||||
"resumedToast": "Reminders resumed",
|
||||
"failed": "Could not change the paused state."
|
||||
},
|
||||
"end": {
|
||||
"title": "End medication",
|
||||
"helper": "Stop reminders and mark the medication as ended. History stays visible.",
|
||||
"button": "End",
|
||||
"dialogTitle": "End {name} — stop reminders, history stays visible",
|
||||
"dialogBody": "No more reminders. Old entries remain in the timeline.",
|
||||
"toast": "Medication ended",
|
||||
"failed": "Could not end the medication."
|
||||
},
|
||||
"purge": {
|
||||
"title": "Delete history",
|
||||
"helper": "{count} intake events are stored for this medication.",
|
||||
"button": "Delete history",
|
||||
"dialogTitle": "Really delete the history?",
|
||||
"dialogBody": "The {count} intake events will be removed permanently. The medication itself stays.",
|
||||
"toast": "History deleted",
|
||||
"failed": "Could not delete the history."
|
||||
},
|
||||
"delete": {
|
||||
"title": "Delete medication",
|
||||
"helper": "Removes the medication and every related record.",
|
||||
"button": "Delete",
|
||||
"dialogTitle": "Delete {name}?",
|
||||
"dialogBody": "The medication, its schedules, intake history, API tokens, phase config and reminders will be removed permanently.",
|
||||
"toast": "Medication deleted",
|
||||
"failed": "Could not delete the medication."
|
||||
}
|
||||
},
|
||||
"phases": {
|
||||
"modeLabel": "{phase} mode",
|
||||
"saveFailed": "Could not save the reminder phases."
|
||||
},
|
||||
"api": {
|
||||
"caption": "Endpoint for \"{name}\"",
|
||||
"copyUrl": "Copy URL",
|
||||
"urlCopied": "URL copied",
|
||||
"mintToken": "Mint token",
|
||||
"mintAnotherToken": "Mint another token",
|
||||
"mintFailed": "Could not mint a token.",
|
||||
"copyFailed": "Could not copy.",
|
||||
"tokenCopied": "Token copied",
|
||||
"copyToken": "Copy token",
|
||||
"mintedHint": "Copy this token now — it is shown only once."
|
||||
},
|
||||
"edit": {
|
||||
"planOption": "Edit plan",
|
||||
"advancedOption": "Advanced settings"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced settings"
|
||||
}
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -2008,6 +2208,19 @@
|
||||
"timezoneHint": "Used for chart axis labels, reminder times, export timestamps, and Coach context. Stored data is unchanged.",
|
||||
"timezoneInvalid": "Not a valid IANA timezone.",
|
||||
"profileSaved": "Profile saved",
|
||||
"avatar": {
|
||||
"title": "Profile photo",
|
||||
"description": "Upload a photo stored on your own server. It appears across the app in place of an external avatar service.",
|
||||
"upload": "Upload photo",
|
||||
"replace": "Replace photo",
|
||||
"remove": "Remove",
|
||||
"hint": "JPEG, PNG, or WebP. Up to 2 MB and 2048×2048 pixels.",
|
||||
"uploaded": "Profile photo updated",
|
||||
"removed": "Profile photo removed",
|
||||
"invalidType": "Unsupported image type. Use JPEG, PNG, or WebP.",
|
||||
"tooLarge": "Image exceeds the 2 MB limit.",
|
||||
"error": "Could not update the profile photo. Please try again."
|
||||
},
|
||||
"changePassword": "Change password",
|
||||
"changePasswordDescription": "Replace your current password with a new one.",
|
||||
"passwordReset": "Password reset",
|
||||
@@ -2421,6 +2634,7 @@
|
||||
"telegramGlobal": "Allow Telegram notifications.",
|
||||
"ntfyGlobal": "Allow ntfy notifications.",
|
||||
"webPushGlobal": "Allow browser push notifications.",
|
||||
"moodLogGlobal": "Allow mood-log reminders.",
|
||||
"integrationWebPush": "Web Push",
|
||||
"integrationBugReport": "Bug Report",
|
||||
"offlineGeoLabel": "Geo data",
|
||||
|
||||
+215
-1
@@ -8,6 +8,7 @@
|
||||
"create": "Crear",
|
||||
"close": "Cerrar",
|
||||
"loading": "Cargando…...",
|
||||
"loadFailed": "Could not load",
|
||||
"error": "Error",
|
||||
"saved": "Guardado",
|
||||
"no": "No",
|
||||
@@ -294,6 +295,32 @@
|
||||
"title": "Aún no hay entrenamientos",
|
||||
"cta": "Abre la app Apple Salud en el iPhone para sincronizar los entrenamientos."
|
||||
}
|
||||
},
|
||||
"metric": {
|
||||
"title": {
|
||||
"weight": "Peso",
|
||||
"bloodPressure": "Presión arterial",
|
||||
"pulse": "Pulso",
|
||||
"bodyFat": "Grasa corporal",
|
||||
"glucose": "Glucemia",
|
||||
"sleep": "Sueño",
|
||||
"steps": "Pasos",
|
||||
"totalBodyWater": "Agua corporal total",
|
||||
"boneMass": "Masa ósea",
|
||||
"oxygenSaturation": "Saturación de oxígeno"
|
||||
},
|
||||
"unit": {
|
||||
"weight": "kg",
|
||||
"bloodPressure": "mmHg",
|
||||
"pulse": "bpm",
|
||||
"bodyFat": "%",
|
||||
"glucose": "mg/dL",
|
||||
"sleep": "h",
|
||||
"steps": "pasos",
|
||||
"totalBodyWater": "kg",
|
||||
"boneMass": "kg",
|
||||
"oxygenSaturation": "%"
|
||||
}
|
||||
}
|
||||
},
|
||||
"measurements": {
|
||||
@@ -337,6 +364,14 @@
|
||||
"typeTimeInDaylight": "Tiempo a la luz del día",
|
||||
"typeWalkingSteadiness": "Estabilidad al caminar",
|
||||
"typeAudioExposureEvent": "Evento de escucha alta",
|
||||
"typeRespiratoryRate": "Frecuencia respiratoria",
|
||||
"typeBodyMassIndex": "Índice de masa corporal",
|
||||
"typeLeanBodyMass": "Masa magra",
|
||||
"typeWalkingHeartRateAverage": "Frecuencia cardíaca al caminar (media)",
|
||||
"typeWalkingAsymmetry": "Asimetría al caminar",
|
||||
"typeWalkingDoubleSupport": "Doble apoyo al caminar",
|
||||
"typeWalkingStepLength": "Longitud de zancada",
|
||||
"typeWalkingSpeed": "Velocidad al caminar",
|
||||
"glucoseContext": "Contexto",
|
||||
"glucoseContextFasting": "En ayunas (≥8 h sin comer)",
|
||||
"glucoseContextPostprandial": "Posprandial (2 h tras comer)",
|
||||
@@ -439,6 +474,7 @@
|
||||
"scheduleDaily": "A diario",
|
||||
"inactive": "En pausa",
|
||||
"intakeHistory": "Historial de tomas",
|
||||
"openDetailPage": "Open medication detail page",
|
||||
"deleteConfirm": "¿Eliminar medicamento?",
|
||||
"compliance": "Cumplimiento",
|
||||
"importIntakes": "Import intakes",
|
||||
@@ -795,7 +831,18 @@
|
||||
"sprays": "spray",
|
||||
"pieces": "pieces",
|
||||
"other": "other"
|
||||
}
|
||||
},
|
||||
"deliveryFormLabel": "Vía",
|
||||
"deliveryForm": {
|
||||
"ORAL": "Oral",
|
||||
"INJECTION": "Inyección",
|
||||
"OTHER": "Otra"
|
||||
},
|
||||
"dosesPerUnitLabel": "Dosis por unidad",
|
||||
"dosesPerUnitPlaceholder": "p. ej. 4",
|
||||
"dosesPerUnitHint": "Dosis por pluma / vial — déjalo vacío para no controlar el inventario.",
|
||||
"injectionSiteToggle": "Rotación del sitio de inyección",
|
||||
"injectionSiteHint": "Guía de rotación opcional. El sitio de cada dosis se registra al anotar la toma."
|
||||
},
|
||||
"step4": {
|
||||
"title": "Over what period are you taking it?",
|
||||
@@ -1075,6 +1122,159 @@
|
||||
"gatedCta": "Abrir el modo Investigación en los ajustes",
|
||||
"unknownDrug": "{name} no está en el catálogo de GLP-1 aprobado por la EMA, por lo que HealthLog no puede trazar una curva de farmacocinética poblacional."
|
||||
}
|
||||
},
|
||||
"detail": {
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"paused": "Paused",
|
||||
"ended": "Ended"
|
||||
},
|
||||
"today": {
|
||||
"groupLabel": "Today's dose",
|
||||
"taken": "Taken",
|
||||
"skipped": "Skipped",
|
||||
"toastTaken": "Today's dose logged",
|
||||
"toastSkipped": "Today's dose skipped",
|
||||
"recordedTaken": "Taken today at {time}",
|
||||
"recordedSkipped": "Skipped today at {time}",
|
||||
"recordedOneShot": "One-shot dose taken at {time}",
|
||||
"noneScheduled": "No dose scheduled for today.",
|
||||
"pausedHint": "Paused — no reminder today.",
|
||||
"error": "Could not log the dose."
|
||||
},
|
||||
"cadence": {
|
||||
"title": "Cadence",
|
||||
"oneShotOn": "One-time dose on {date}.",
|
||||
"oneShotPending": "One-time dose pending."
|
||||
},
|
||||
"intake": {
|
||||
"title": "Intake history",
|
||||
"importButton": "Import",
|
||||
"viewAllLink": "View full history →",
|
||||
"rowActions": {
|
||||
"openMenu": "Open row actions",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"selection": {
|
||||
"rowToggleLabel": "Select row"
|
||||
},
|
||||
"bulkDelete": {
|
||||
"selectionCount": "{count} entries selected",
|
||||
"deleteButton": "Delete selection",
|
||||
"cancelButton": "Cancel",
|
||||
"confirmTitle": "Delete the selected entries?",
|
||||
"confirmBody": "{count} entries will be removed permanently. The medication itself stays.",
|
||||
"confirmAction": "Delete",
|
||||
"toast": "Selection deleted",
|
||||
"failed": "Could not delete the selection."
|
||||
},
|
||||
"edit": {
|
||||
"dialogTitle": "Edit intake",
|
||||
"takenAtLabel": "Taken at",
|
||||
"skippedLabel": "Skipped",
|
||||
"noteLabel": "Note",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"savedToast": "Intake updated",
|
||||
"failed": "Could not update the intake."
|
||||
},
|
||||
"deleteRow": {
|
||||
"confirmTitle": "Delete this intake?",
|
||||
"confirmBody": "The entry is removed permanently.",
|
||||
"confirmAction": "Delete",
|
||||
"toast": "Intake deleted",
|
||||
"failed": "Could not delete the intake."
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"switchLabel": "Send a reminder when this dose is due",
|
||||
"helperOn": "Reminders are on.",
|
||||
"helperOff": "Reminders are off.",
|
||||
"enabledToast": "Reminders on",
|
||||
"disabledToast": "Reminders off",
|
||||
"toggleFailed": "Could not change the reminder setting.",
|
||||
"clientManagedChip": "Your iPhone manages reminders for this medication."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"csvImport": {
|
||||
"title": "CSV import",
|
||||
"stub": "Import intakes directly from the history above."
|
||||
},
|
||||
"phases": {
|
||||
"openButton": "Configure phases",
|
||||
"requiresCourseWindow": "Phases are available once a course window is set."
|
||||
},
|
||||
"grace": {
|
||||
"label": "Reminder window — applies to your primary schedule",
|
||||
"primaryScheduleNote": "Multi-schedule medications keep their other schedules' default window.",
|
||||
"unit": "minutes",
|
||||
"saved": "Reminder window saved",
|
||||
"failed": "Could not save the reminder window."
|
||||
}
|
||||
},
|
||||
"zone": {
|
||||
"title": "Manage & danger zone",
|
||||
"pause": {
|
||||
"title": "Pause",
|
||||
"helper": "Pause reminders until you turn them back on. History stays intact.",
|
||||
"pausedToast": "Reminders paused",
|
||||
"resumedToast": "Reminders resumed",
|
||||
"failed": "Could not change the paused state."
|
||||
},
|
||||
"end": {
|
||||
"title": "End medication",
|
||||
"helper": "Stop reminders and mark the medication as ended. History stays visible.",
|
||||
"button": "End",
|
||||
"dialogTitle": "End {name} — stop reminders, history stays visible",
|
||||
"dialogBody": "No more reminders. Old entries remain in the timeline.",
|
||||
"toast": "Medication ended",
|
||||
"failed": "Could not end the medication."
|
||||
},
|
||||
"purge": {
|
||||
"title": "Delete history",
|
||||
"helper": "{count} intake events are stored for this medication.",
|
||||
"button": "Delete history",
|
||||
"dialogTitle": "Really delete the history?",
|
||||
"dialogBody": "The {count} intake events will be removed permanently. The medication itself stays.",
|
||||
"toast": "History deleted",
|
||||
"failed": "Could not delete the history."
|
||||
},
|
||||
"delete": {
|
||||
"title": "Delete medication",
|
||||
"helper": "Removes the medication and every related record.",
|
||||
"button": "Delete",
|
||||
"dialogTitle": "Delete {name}?",
|
||||
"dialogBody": "The medication, its schedules, intake history, API tokens, phase config and reminders will be removed permanently.",
|
||||
"toast": "Medication deleted",
|
||||
"failed": "Could not delete the medication."
|
||||
}
|
||||
},
|
||||
"phases": {
|
||||
"modeLabel": "{phase} mode",
|
||||
"saveFailed": "Could not save the reminder phases."
|
||||
},
|
||||
"api": {
|
||||
"caption": "Endpoint for \"{name}\"",
|
||||
"copyUrl": "Copy URL",
|
||||
"urlCopied": "URL copied",
|
||||
"mintToken": "Mint token",
|
||||
"mintAnotherToken": "Mint another token",
|
||||
"mintFailed": "Could not mint a token.",
|
||||
"copyFailed": "Could not copy.",
|
||||
"tokenCopied": "Token copied",
|
||||
"copyToken": "Copy token",
|
||||
"mintedHint": "Copy this token now — it is shown only once."
|
||||
},
|
||||
"edit": {
|
||||
"planOption": "Editar plan",
|
||||
"advancedOption": "Ajustes avanzados"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Ajustes avanzados"
|
||||
}
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -2008,6 +2208,19 @@
|
||||
"timezoneHint": "Used for chart axis labels, reminder times, export timestamps, and Coach context. Stored data is unchanged.",
|
||||
"timezoneInvalid": "Not a valid IANA timezone.",
|
||||
"profileSaved": "Profile saved",
|
||||
"avatar": {
|
||||
"title": "Foto de perfil",
|
||||
"description": "Sube una foto que se almacena en tu propio servidor. Aparece en toda la aplicación en lugar de un servicio de avatares externo.",
|
||||
"upload": "Subir foto",
|
||||
"replace": "Reemplazar foto",
|
||||
"remove": "Quitar",
|
||||
"hint": "JPEG, PNG o WebP. Hasta 2 MB y 2048×2048 píxeles.",
|
||||
"uploaded": "Foto de perfil actualizada",
|
||||
"removed": "Foto de perfil eliminada",
|
||||
"invalidType": "Tipo de imagen no admitido. Usa JPEG, PNG o WebP.",
|
||||
"tooLarge": "La imagen supera el límite de 2 MB.",
|
||||
"error": "No se pudo actualizar la foto de perfil. Inténtalo de nuevo."
|
||||
},
|
||||
"changePassword": "Change password",
|
||||
"changePasswordDescription": "Replace your current password with a new one.",
|
||||
"passwordReset": "Password reset",
|
||||
@@ -2421,6 +2634,7 @@
|
||||
"telegramGlobal": "Allow Telegram notifications.",
|
||||
"ntfyGlobal": "Allow ntfy notifications.",
|
||||
"webPushGlobal": "Allow browser push notifications.",
|
||||
"moodLogGlobal": "Allow mood-log reminders.",
|
||||
"integrationWebPush": "Web Push",
|
||||
"integrationBugReport": "Bug Report",
|
||||
"offlineGeoLabel": "Datos geográficos",
|
||||
|
||||
+215
-1
@@ -8,6 +8,7 @@
|
||||
"create": "Créer",
|
||||
"close": "Fermer",
|
||||
"loading": "Chargement…...",
|
||||
"loadFailed": "Could not load",
|
||||
"error": "Erreur",
|
||||
"saved": "Enregistré",
|
||||
"no": "Non",
|
||||
@@ -294,6 +295,32 @@
|
||||
"title": "Aucun entraînement",
|
||||
"cta": "Ouvre l'app Apple Santé sur ton iPhone pour synchroniser les entraînements."
|
||||
}
|
||||
},
|
||||
"metric": {
|
||||
"title": {
|
||||
"weight": "Poids",
|
||||
"bloodPressure": "Tension artérielle",
|
||||
"pulse": "Pouls",
|
||||
"bodyFat": "Masse grasse",
|
||||
"glucose": "Glycémie",
|
||||
"sleep": "Sommeil",
|
||||
"steps": "Pas",
|
||||
"totalBodyWater": "Eau corporelle totale",
|
||||
"boneMass": "Masse osseuse",
|
||||
"oxygenSaturation": "Saturation en oxygène"
|
||||
},
|
||||
"unit": {
|
||||
"weight": "kg",
|
||||
"bloodPressure": "mmHg",
|
||||
"pulse": "bpm",
|
||||
"bodyFat": "%",
|
||||
"glucose": "mg/dL",
|
||||
"sleep": "h",
|
||||
"steps": "pas",
|
||||
"totalBodyWater": "kg",
|
||||
"boneMass": "kg",
|
||||
"oxygenSaturation": "%"
|
||||
}
|
||||
}
|
||||
},
|
||||
"measurements": {
|
||||
@@ -337,6 +364,14 @@
|
||||
"typeTimeInDaylight": "Temps à la lumière du jour",
|
||||
"typeWalkingSteadiness": "Stabilité à la marche",
|
||||
"typeAudioExposureEvent": "Événement d’écoute forte",
|
||||
"typeRespiratoryRate": "Fréquence respiratoire",
|
||||
"typeBodyMassIndex": "Indice de masse corporelle",
|
||||
"typeLeanBodyMass": "Masse maigre",
|
||||
"typeWalkingHeartRateAverage": "Fréquence cardiaque à la marche (moy.)",
|
||||
"typeWalkingAsymmetry": "Asymétrie de la marche",
|
||||
"typeWalkingDoubleSupport": "Double appui à la marche",
|
||||
"typeWalkingStepLength": "Longueur du pas",
|
||||
"typeWalkingSpeed": "Vitesse de marche",
|
||||
"glucoseContext": "Contexte",
|
||||
"glucoseContextFasting": "À jeun (≥8 h sans repas)",
|
||||
"glucoseContextPostprandial": "Postprandial (2 h après le repas)",
|
||||
@@ -439,6 +474,7 @@
|
||||
"scheduleDaily": "Quotidien",
|
||||
"inactive": "En pause",
|
||||
"intakeHistory": "Historique des prises",
|
||||
"openDetailPage": "Open medication detail page",
|
||||
"deleteConfirm": "Supprimer le médicament ?",
|
||||
"compliance": "Observance",
|
||||
"importIntakes": "Import intakes",
|
||||
@@ -795,7 +831,18 @@
|
||||
"sprays": "spray",
|
||||
"pieces": "pieces",
|
||||
"other": "other"
|
||||
}
|
||||
},
|
||||
"deliveryFormLabel": "Voie",
|
||||
"deliveryForm": {
|
||||
"ORAL": "Orale",
|
||||
"INJECTION": "Injection",
|
||||
"OTHER": "Autre"
|
||||
},
|
||||
"dosesPerUnitLabel": "Doses par unité",
|
||||
"dosesPerUnitPlaceholder": "p. ex. 4",
|
||||
"dosesPerUnitHint": "Doses par stylo / flacon — laisser vide pour ne pas suivre le stock.",
|
||||
"injectionSiteToggle": "Rotation des sites d'injection",
|
||||
"injectionSiteHint": "Guide de rotation facultatif. Le site de chaque dose est enregistré lors de la saisie de la prise."
|
||||
},
|
||||
"step4": {
|
||||
"title": "Over what period are you taking it?",
|
||||
@@ -1075,6 +1122,159 @@
|
||||
"gatedCta": "Ouvrir le mode Recherche dans les paramètres",
|
||||
"unknownDrug": "{name} n'est pas dans le catalogue GLP-1 approuvé par l'EMA, donc HealthLog ne peut pas tracer de courbe de pharmacocinétique de population."
|
||||
}
|
||||
},
|
||||
"detail": {
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"paused": "Paused",
|
||||
"ended": "Ended"
|
||||
},
|
||||
"today": {
|
||||
"groupLabel": "Today's dose",
|
||||
"taken": "Taken",
|
||||
"skipped": "Skipped",
|
||||
"toastTaken": "Today's dose logged",
|
||||
"toastSkipped": "Today's dose skipped",
|
||||
"recordedTaken": "Taken today at {time}",
|
||||
"recordedSkipped": "Skipped today at {time}",
|
||||
"recordedOneShot": "One-shot dose taken at {time}",
|
||||
"noneScheduled": "No dose scheduled for today.",
|
||||
"pausedHint": "Paused — no reminder today.",
|
||||
"error": "Could not log the dose."
|
||||
},
|
||||
"cadence": {
|
||||
"title": "Cadence",
|
||||
"oneShotOn": "One-time dose on {date}.",
|
||||
"oneShotPending": "One-time dose pending."
|
||||
},
|
||||
"intake": {
|
||||
"title": "Intake history",
|
||||
"importButton": "Import",
|
||||
"viewAllLink": "View full history →",
|
||||
"rowActions": {
|
||||
"openMenu": "Open row actions",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"selection": {
|
||||
"rowToggleLabel": "Select row"
|
||||
},
|
||||
"bulkDelete": {
|
||||
"selectionCount": "{count} entries selected",
|
||||
"deleteButton": "Delete selection",
|
||||
"cancelButton": "Cancel",
|
||||
"confirmTitle": "Delete the selected entries?",
|
||||
"confirmBody": "{count} entries will be removed permanently. The medication itself stays.",
|
||||
"confirmAction": "Delete",
|
||||
"toast": "Selection deleted",
|
||||
"failed": "Could not delete the selection."
|
||||
},
|
||||
"edit": {
|
||||
"dialogTitle": "Edit intake",
|
||||
"takenAtLabel": "Taken at",
|
||||
"skippedLabel": "Skipped",
|
||||
"noteLabel": "Note",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"savedToast": "Intake updated",
|
||||
"failed": "Could not update the intake."
|
||||
},
|
||||
"deleteRow": {
|
||||
"confirmTitle": "Delete this intake?",
|
||||
"confirmBody": "The entry is removed permanently.",
|
||||
"confirmAction": "Delete",
|
||||
"toast": "Intake deleted",
|
||||
"failed": "Could not delete the intake."
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"switchLabel": "Send a reminder when this dose is due",
|
||||
"helperOn": "Reminders are on.",
|
||||
"helperOff": "Reminders are off.",
|
||||
"enabledToast": "Reminders on",
|
||||
"disabledToast": "Reminders off",
|
||||
"toggleFailed": "Could not change the reminder setting.",
|
||||
"clientManagedChip": "Your iPhone manages reminders for this medication."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"csvImport": {
|
||||
"title": "CSV import",
|
||||
"stub": "Import intakes directly from the history above."
|
||||
},
|
||||
"phases": {
|
||||
"openButton": "Configure phases",
|
||||
"requiresCourseWindow": "Phases are available once a course window is set."
|
||||
},
|
||||
"grace": {
|
||||
"label": "Reminder window — applies to your primary schedule",
|
||||
"primaryScheduleNote": "Multi-schedule medications keep their other schedules' default window.",
|
||||
"unit": "minutes",
|
||||
"saved": "Reminder window saved",
|
||||
"failed": "Could not save the reminder window."
|
||||
}
|
||||
},
|
||||
"zone": {
|
||||
"title": "Manage & danger zone",
|
||||
"pause": {
|
||||
"title": "Pause",
|
||||
"helper": "Pause reminders until you turn them back on. History stays intact.",
|
||||
"pausedToast": "Reminders paused",
|
||||
"resumedToast": "Reminders resumed",
|
||||
"failed": "Could not change the paused state."
|
||||
},
|
||||
"end": {
|
||||
"title": "End medication",
|
||||
"helper": "Stop reminders and mark the medication as ended. History stays visible.",
|
||||
"button": "End",
|
||||
"dialogTitle": "End {name} — stop reminders, history stays visible",
|
||||
"dialogBody": "No more reminders. Old entries remain in the timeline.",
|
||||
"toast": "Medication ended",
|
||||
"failed": "Could not end the medication."
|
||||
},
|
||||
"purge": {
|
||||
"title": "Delete history",
|
||||
"helper": "{count} intake events are stored for this medication.",
|
||||
"button": "Delete history",
|
||||
"dialogTitle": "Really delete the history?",
|
||||
"dialogBody": "The {count} intake events will be removed permanently. The medication itself stays.",
|
||||
"toast": "History deleted",
|
||||
"failed": "Could not delete the history."
|
||||
},
|
||||
"delete": {
|
||||
"title": "Delete medication",
|
||||
"helper": "Removes the medication and every related record.",
|
||||
"button": "Delete",
|
||||
"dialogTitle": "Delete {name}?",
|
||||
"dialogBody": "The medication, its schedules, intake history, API tokens, phase config and reminders will be removed permanently.",
|
||||
"toast": "Medication deleted",
|
||||
"failed": "Could not delete the medication."
|
||||
}
|
||||
},
|
||||
"phases": {
|
||||
"modeLabel": "{phase} mode",
|
||||
"saveFailed": "Could not save the reminder phases."
|
||||
},
|
||||
"api": {
|
||||
"caption": "Endpoint for \"{name}\"",
|
||||
"copyUrl": "Copy URL",
|
||||
"urlCopied": "URL copied",
|
||||
"mintToken": "Mint token",
|
||||
"mintAnotherToken": "Mint another token",
|
||||
"mintFailed": "Could not mint a token.",
|
||||
"copyFailed": "Could not copy.",
|
||||
"tokenCopied": "Token copied",
|
||||
"copyToken": "Copy token",
|
||||
"mintedHint": "Copy this token now — it is shown only once."
|
||||
},
|
||||
"edit": {
|
||||
"planOption": "Modifier le plan",
|
||||
"advancedOption": "Paramètres avancés"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Paramètres avancés"
|
||||
}
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -2008,6 +2208,19 @@
|
||||
"timezoneHint": "Used for chart axis labels, reminder times, export timestamps, and Coach context. Stored data is unchanged.",
|
||||
"timezoneInvalid": "Not a valid IANA timezone.",
|
||||
"profileSaved": "Profile saved",
|
||||
"avatar": {
|
||||
"title": "Photo de profil",
|
||||
"description": "Téléverse une photo stockée sur ton propre serveur. Elle apparaît dans toute l'application à la place d'un service d'avatar externe.",
|
||||
"upload": "Téléverser une photo",
|
||||
"replace": "Remplacer la photo",
|
||||
"remove": "Supprimer",
|
||||
"hint": "JPEG, PNG ou WebP. Jusqu'à 2 Mo et 2048×2048 pixels.",
|
||||
"uploaded": "Photo de profil mise à jour",
|
||||
"removed": "Photo de profil supprimée",
|
||||
"invalidType": "Type d'image non pris en charge. Utilise JPEG, PNG ou WebP.",
|
||||
"tooLarge": "L'image dépasse la limite de 2 Mo.",
|
||||
"error": "Impossible de mettre à jour la photo de profil. Réessaie."
|
||||
},
|
||||
"changePassword": "Change password",
|
||||
"changePasswordDescription": "Replace your current password with a new one.",
|
||||
"passwordReset": "Password reset",
|
||||
@@ -2421,6 +2634,7 @@
|
||||
"telegramGlobal": "Allow Telegram notifications.",
|
||||
"ntfyGlobal": "Allow ntfy notifications.",
|
||||
"webPushGlobal": "Allow browser push notifications.",
|
||||
"moodLogGlobal": "Allow mood-log reminders.",
|
||||
"integrationWebPush": "Web Push",
|
||||
"integrationBugReport": "Bug Report",
|
||||
"offlineGeoLabel": "Données géographiques",
|
||||
|
||||
+215
-1
@@ -8,6 +8,7 @@
|
||||
"create": "Crea",
|
||||
"close": "Chiudi",
|
||||
"loading": "Caricamento…...",
|
||||
"loadFailed": "Could not load",
|
||||
"error": "Errore",
|
||||
"saved": "Salvato",
|
||||
"no": "No",
|
||||
@@ -294,6 +295,32 @@
|
||||
"title": "Nessun allenamento",
|
||||
"cta": "Apri l'app Salute di Apple sull'iPhone per sincronizzare gli allenamenti."
|
||||
}
|
||||
},
|
||||
"metric": {
|
||||
"title": {
|
||||
"weight": "Peso",
|
||||
"bloodPressure": "Pressione arteriosa",
|
||||
"pulse": "Polso",
|
||||
"bodyFat": "Massa grassa",
|
||||
"glucose": "Glicemia",
|
||||
"sleep": "Sonno",
|
||||
"steps": "Passi",
|
||||
"totalBodyWater": "Acqua corporea totale",
|
||||
"boneMass": "Massa ossea",
|
||||
"oxygenSaturation": "Saturazione di ossigeno"
|
||||
},
|
||||
"unit": {
|
||||
"weight": "kg",
|
||||
"bloodPressure": "mmHg",
|
||||
"pulse": "bpm",
|
||||
"bodyFat": "%",
|
||||
"glucose": "mg/dL",
|
||||
"sleep": "h",
|
||||
"steps": "passi",
|
||||
"totalBodyWater": "kg",
|
||||
"boneMass": "kg",
|
||||
"oxygenSaturation": "%"
|
||||
}
|
||||
}
|
||||
},
|
||||
"measurements": {
|
||||
@@ -337,6 +364,14 @@
|
||||
"typeTimeInDaylight": "Tempo alla luce del giorno",
|
||||
"typeWalkingSteadiness": "Stabilità nel cammino",
|
||||
"typeAudioExposureEvent": "Evento di ascolto forte",
|
||||
"typeRespiratoryRate": "Frequenza respiratoria",
|
||||
"typeBodyMassIndex": "Indice di massa corporea",
|
||||
"typeLeanBodyMass": "Massa magra",
|
||||
"typeWalkingHeartRateAverage": "Frequenza cardiaca a passo (media)",
|
||||
"typeWalkingAsymmetry": "Asimmetria del cammino",
|
||||
"typeWalkingDoubleSupport": "Doppio appoggio nel cammino",
|
||||
"typeWalkingStepLength": "Lunghezza del passo",
|
||||
"typeWalkingSpeed": "Velocità del cammino",
|
||||
"glucoseContext": "Contesto",
|
||||
"glucoseContextFasting": "A digiuno (≥8 h senza cibo)",
|
||||
"glucoseContextPostprandial": "Postprandiale (2 h dopo il pasto)",
|
||||
@@ -439,6 +474,7 @@
|
||||
"scheduleDaily": "Quotidiano",
|
||||
"inactive": "In pausa",
|
||||
"intakeHistory": "Cronologia assunzioni",
|
||||
"openDetailPage": "Open medication detail page",
|
||||
"deleteConfirm": "Eliminare il farmaco?",
|
||||
"compliance": "Aderenza",
|
||||
"importIntakes": "Import intakes",
|
||||
@@ -795,7 +831,18 @@
|
||||
"sprays": "spray",
|
||||
"pieces": "pieces",
|
||||
"other": "other"
|
||||
}
|
||||
},
|
||||
"deliveryFormLabel": "Via",
|
||||
"deliveryForm": {
|
||||
"ORAL": "Orale",
|
||||
"INJECTION": "Iniezione",
|
||||
"OTHER": "Altro"
|
||||
},
|
||||
"dosesPerUnitLabel": "Dosi per unità",
|
||||
"dosesPerUnitPlaceholder": "es. 4",
|
||||
"dosesPerUnitHint": "Dosi per penna / fiala — lascia vuoto per non tracciare le scorte.",
|
||||
"injectionSiteToggle": "Rotazione del sito di iniezione",
|
||||
"injectionSiteHint": "Guida alla rotazione facoltativa. Il sito di ogni dose viene registrato al momento della registrazione dell'assunzione."
|
||||
},
|
||||
"step4": {
|
||||
"title": "Over what period are you taking it?",
|
||||
@@ -1075,6 +1122,159 @@
|
||||
"gatedCta": "Apri la modalità Ricerca nelle impostazioni",
|
||||
"unknownDrug": "{name} non è nel catalogo GLP-1 approvato dall'EMA, quindi HealthLog non può tracciare una curva di farmacocinetica di popolazione."
|
||||
}
|
||||
},
|
||||
"detail": {
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"paused": "Paused",
|
||||
"ended": "Ended"
|
||||
},
|
||||
"today": {
|
||||
"groupLabel": "Today's dose",
|
||||
"taken": "Taken",
|
||||
"skipped": "Skipped",
|
||||
"toastTaken": "Today's dose logged",
|
||||
"toastSkipped": "Today's dose skipped",
|
||||
"recordedTaken": "Taken today at {time}",
|
||||
"recordedSkipped": "Skipped today at {time}",
|
||||
"recordedOneShot": "One-shot dose taken at {time}",
|
||||
"noneScheduled": "No dose scheduled for today.",
|
||||
"pausedHint": "Paused — no reminder today.",
|
||||
"error": "Could not log the dose."
|
||||
},
|
||||
"cadence": {
|
||||
"title": "Cadence",
|
||||
"oneShotOn": "One-time dose on {date}.",
|
||||
"oneShotPending": "One-time dose pending."
|
||||
},
|
||||
"intake": {
|
||||
"title": "Intake history",
|
||||
"importButton": "Import",
|
||||
"viewAllLink": "View full history →",
|
||||
"rowActions": {
|
||||
"openMenu": "Open row actions",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"selection": {
|
||||
"rowToggleLabel": "Select row"
|
||||
},
|
||||
"bulkDelete": {
|
||||
"selectionCount": "{count} entries selected",
|
||||
"deleteButton": "Delete selection",
|
||||
"cancelButton": "Cancel",
|
||||
"confirmTitle": "Delete the selected entries?",
|
||||
"confirmBody": "{count} entries will be removed permanently. The medication itself stays.",
|
||||
"confirmAction": "Delete",
|
||||
"toast": "Selection deleted",
|
||||
"failed": "Could not delete the selection."
|
||||
},
|
||||
"edit": {
|
||||
"dialogTitle": "Edit intake",
|
||||
"takenAtLabel": "Taken at",
|
||||
"skippedLabel": "Skipped",
|
||||
"noteLabel": "Note",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"savedToast": "Intake updated",
|
||||
"failed": "Could not update the intake."
|
||||
},
|
||||
"deleteRow": {
|
||||
"confirmTitle": "Delete this intake?",
|
||||
"confirmBody": "The entry is removed permanently.",
|
||||
"confirmAction": "Delete",
|
||||
"toast": "Intake deleted",
|
||||
"failed": "Could not delete the intake."
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"switchLabel": "Send a reminder when this dose is due",
|
||||
"helperOn": "Reminders are on.",
|
||||
"helperOff": "Reminders are off.",
|
||||
"enabledToast": "Reminders on",
|
||||
"disabledToast": "Reminders off",
|
||||
"toggleFailed": "Could not change the reminder setting.",
|
||||
"clientManagedChip": "Your iPhone manages reminders for this medication."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"csvImport": {
|
||||
"title": "CSV import",
|
||||
"stub": "Import intakes directly from the history above."
|
||||
},
|
||||
"phases": {
|
||||
"openButton": "Configure phases",
|
||||
"requiresCourseWindow": "Phases are available once a course window is set."
|
||||
},
|
||||
"grace": {
|
||||
"label": "Reminder window — applies to your primary schedule",
|
||||
"primaryScheduleNote": "Multi-schedule medications keep their other schedules' default window.",
|
||||
"unit": "minutes",
|
||||
"saved": "Reminder window saved",
|
||||
"failed": "Could not save the reminder window."
|
||||
}
|
||||
},
|
||||
"zone": {
|
||||
"title": "Manage & danger zone",
|
||||
"pause": {
|
||||
"title": "Pause",
|
||||
"helper": "Pause reminders until you turn them back on. History stays intact.",
|
||||
"pausedToast": "Reminders paused",
|
||||
"resumedToast": "Reminders resumed",
|
||||
"failed": "Could not change the paused state."
|
||||
},
|
||||
"end": {
|
||||
"title": "End medication",
|
||||
"helper": "Stop reminders and mark the medication as ended. History stays visible.",
|
||||
"button": "End",
|
||||
"dialogTitle": "End {name} — stop reminders, history stays visible",
|
||||
"dialogBody": "No more reminders. Old entries remain in the timeline.",
|
||||
"toast": "Medication ended",
|
||||
"failed": "Could not end the medication."
|
||||
},
|
||||
"purge": {
|
||||
"title": "Delete history",
|
||||
"helper": "{count} intake events are stored for this medication.",
|
||||
"button": "Delete history",
|
||||
"dialogTitle": "Really delete the history?",
|
||||
"dialogBody": "The {count} intake events will be removed permanently. The medication itself stays.",
|
||||
"toast": "History deleted",
|
||||
"failed": "Could not delete the history."
|
||||
},
|
||||
"delete": {
|
||||
"title": "Delete medication",
|
||||
"helper": "Removes the medication and every related record.",
|
||||
"button": "Delete",
|
||||
"dialogTitle": "Delete {name}?",
|
||||
"dialogBody": "The medication, its schedules, intake history, API tokens, phase config and reminders will be removed permanently.",
|
||||
"toast": "Medication deleted",
|
||||
"failed": "Could not delete the medication."
|
||||
}
|
||||
},
|
||||
"phases": {
|
||||
"modeLabel": "{phase} mode",
|
||||
"saveFailed": "Could not save the reminder phases."
|
||||
},
|
||||
"api": {
|
||||
"caption": "Endpoint for \"{name}\"",
|
||||
"copyUrl": "Copy URL",
|
||||
"urlCopied": "URL copied",
|
||||
"mintToken": "Mint token",
|
||||
"mintAnotherToken": "Mint another token",
|
||||
"mintFailed": "Could not mint a token.",
|
||||
"copyFailed": "Could not copy.",
|
||||
"tokenCopied": "Token copied",
|
||||
"copyToken": "Copy token",
|
||||
"mintedHint": "Copy this token now — it is shown only once."
|
||||
},
|
||||
"edit": {
|
||||
"planOption": "Modifica piano",
|
||||
"advancedOption": "Impostazioni avanzate"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Impostazioni avanzate"
|
||||
}
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -2008,6 +2208,19 @@
|
||||
"timezoneHint": "Used for chart axis labels, reminder times, export timestamps, and Coach context. Stored data is unchanged.",
|
||||
"timezoneInvalid": "Not a valid IANA timezone.",
|
||||
"profileSaved": "Profile saved",
|
||||
"avatar": {
|
||||
"title": "Foto del profilo",
|
||||
"description": "Carica una foto archiviata sul tuo server. Appare in tutta l'app al posto di un servizio di avatar esterno.",
|
||||
"upload": "Carica foto",
|
||||
"replace": "Sostituisci foto",
|
||||
"remove": "Rimuovi",
|
||||
"hint": "JPEG, PNG o WebP. Fino a 2 MB e 2048×2048 pixel.",
|
||||
"uploaded": "Foto del profilo aggiornata",
|
||||
"removed": "Foto del profilo rimossa",
|
||||
"invalidType": "Tipo di immagine non supportato. Usa JPEG, PNG o WebP.",
|
||||
"tooLarge": "L'immagine supera il limite di 2 MB.",
|
||||
"error": "Impossibile aggiornare la foto del profilo. Riprova."
|
||||
},
|
||||
"changePassword": "Change password",
|
||||
"changePasswordDescription": "Replace your current password with a new one.",
|
||||
"passwordReset": "Password reset",
|
||||
@@ -2421,6 +2634,7 @@
|
||||
"telegramGlobal": "Allow Telegram notifications.",
|
||||
"ntfyGlobal": "Allow ntfy notifications.",
|
||||
"webPushGlobal": "Allow browser push notifications.",
|
||||
"moodLogGlobal": "Allow mood-log reminders.",
|
||||
"integrationWebPush": "Web Push",
|
||||
"integrationBugReport": "Bug Report",
|
||||
"offlineGeoLabel": "Dati geografici",
|
||||
|
||||
+215
-1
@@ -8,6 +8,7 @@
|
||||
"create": "Utwórz",
|
||||
"close": "Zamknij",
|
||||
"loading": "Ładowanie…...",
|
||||
"loadFailed": "Could not load",
|
||||
"error": "Błąd",
|
||||
"saved": "Zapisano",
|
||||
"no": "Nie",
|
||||
@@ -294,6 +295,32 @@
|
||||
"title": "Brak treningów",
|
||||
"cta": "Otwórz aplikację Apple Zdrowie na iPhonie, aby zsynchronizować treningi."
|
||||
}
|
||||
},
|
||||
"metric": {
|
||||
"title": {
|
||||
"weight": "Waga",
|
||||
"bloodPressure": "Ciśnienie krwi",
|
||||
"pulse": "Tętno",
|
||||
"bodyFat": "Tkanka tłuszczowa",
|
||||
"glucose": "Glikemia",
|
||||
"sleep": "Sen",
|
||||
"steps": "Kroki",
|
||||
"totalBodyWater": "Całkowita woda w organizmie",
|
||||
"boneMass": "Masa kostna",
|
||||
"oxygenSaturation": "Saturacja tlenem"
|
||||
},
|
||||
"unit": {
|
||||
"weight": "kg",
|
||||
"bloodPressure": "mmHg",
|
||||
"pulse": "bpm",
|
||||
"bodyFat": "%",
|
||||
"glucose": "mg/dL",
|
||||
"sleep": "h",
|
||||
"steps": "kroki",
|
||||
"totalBodyWater": "kg",
|
||||
"boneMass": "kg",
|
||||
"oxygenSaturation": "%"
|
||||
}
|
||||
}
|
||||
},
|
||||
"measurements": {
|
||||
@@ -337,6 +364,14 @@
|
||||
"typeTimeInDaylight": "Czas w świetle dziennym",
|
||||
"typeWalkingSteadiness": "Stabilność chodu",
|
||||
"typeAudioExposureEvent": "Zdarzenie głośnego słuchania",
|
||||
"typeRespiratoryRate": "Częstość oddechów",
|
||||
"typeBodyMassIndex": "Wskaźnik masy ciała",
|
||||
"typeLeanBodyMass": "Masa beztłuszczowa",
|
||||
"typeWalkingHeartRateAverage": "Tętno przy chodzeniu (śr.)",
|
||||
"typeWalkingAsymmetry": "Asymetria chodu",
|
||||
"typeWalkingDoubleSupport": "Podwójna podpora chodu",
|
||||
"typeWalkingStepLength": "Długość kroku",
|
||||
"typeWalkingSpeed": "Prędkość chodu",
|
||||
"glucoseContext": "Kontekst",
|
||||
"glucoseContextFasting": "Na czczo (≥8 godz. bez jedzenia)",
|
||||
"glucoseContextPostprandial": "Po posiłku (2 godz. po jedzeniu)",
|
||||
@@ -439,6 +474,7 @@
|
||||
"scheduleDaily": "Codziennie",
|
||||
"inactive": "Wstrzymany",
|
||||
"intakeHistory": "Historia przyjęć",
|
||||
"openDetailPage": "Open medication detail page",
|
||||
"deleteConfirm": "Usunąć lek?",
|
||||
"compliance": "Przestrzeganie zaleceń",
|
||||
"importIntakes": "Import intakes",
|
||||
@@ -795,7 +831,18 @@
|
||||
"sprays": "spray",
|
||||
"pieces": "pieces",
|
||||
"other": "other"
|
||||
}
|
||||
},
|
||||
"deliveryFormLabel": "Droga podania",
|
||||
"deliveryForm": {
|
||||
"ORAL": "Doustnie",
|
||||
"INJECTION": "Iniekcja",
|
||||
"OTHER": "Inna"
|
||||
},
|
||||
"dosesPerUnitLabel": "Dawki na jednostkę",
|
||||
"dosesPerUnitPlaceholder": "np. 4",
|
||||
"dosesPerUnitHint": "Dawki na wstrzykiwacz / fiolkę — zostaw puste, aby nie śledzić zapasów.",
|
||||
"injectionSiteToggle": "Rotacja miejsc wstrzyknięcia",
|
||||
"injectionSiteHint": "Opcjonalny przewodnik rotacji. Miejsce każdej dawki zapisywane jest przy rejestrowaniu przyjęcia."
|
||||
},
|
||||
"step4": {
|
||||
"title": "Over what period are you taking it?",
|
||||
@@ -1075,6 +1122,159 @@
|
||||
"gatedCta": "Otwórz tryb Badawczy w Ustawieniach",
|
||||
"unknownDrug": "{name} nie znajduje się w katalogu GLP-1 zatwierdzonym przez EMA, więc HealthLog nie może wyrysować krzywej farmakokinetyki populacyjnej."
|
||||
}
|
||||
},
|
||||
"detail": {
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"paused": "Paused",
|
||||
"ended": "Ended"
|
||||
},
|
||||
"today": {
|
||||
"groupLabel": "Today's dose",
|
||||
"taken": "Taken",
|
||||
"skipped": "Skipped",
|
||||
"toastTaken": "Today's dose logged",
|
||||
"toastSkipped": "Today's dose skipped",
|
||||
"recordedTaken": "Taken today at {time}",
|
||||
"recordedSkipped": "Skipped today at {time}",
|
||||
"recordedOneShot": "One-shot dose taken at {time}",
|
||||
"noneScheduled": "No dose scheduled for today.",
|
||||
"pausedHint": "Paused — no reminder today.",
|
||||
"error": "Could not log the dose."
|
||||
},
|
||||
"cadence": {
|
||||
"title": "Cadence",
|
||||
"oneShotOn": "One-time dose on {date}.",
|
||||
"oneShotPending": "One-time dose pending."
|
||||
},
|
||||
"intake": {
|
||||
"title": "Intake history",
|
||||
"importButton": "Import",
|
||||
"viewAllLink": "View full history →",
|
||||
"rowActions": {
|
||||
"openMenu": "Open row actions",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"selection": {
|
||||
"rowToggleLabel": "Select row"
|
||||
},
|
||||
"bulkDelete": {
|
||||
"selectionCount": "{count} entries selected",
|
||||
"deleteButton": "Delete selection",
|
||||
"cancelButton": "Cancel",
|
||||
"confirmTitle": "Delete the selected entries?",
|
||||
"confirmBody": "{count} entries will be removed permanently. The medication itself stays.",
|
||||
"confirmAction": "Delete",
|
||||
"toast": "Selection deleted",
|
||||
"failed": "Could not delete the selection."
|
||||
},
|
||||
"edit": {
|
||||
"dialogTitle": "Edit intake",
|
||||
"takenAtLabel": "Taken at",
|
||||
"skippedLabel": "Skipped",
|
||||
"noteLabel": "Note",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"savedToast": "Intake updated",
|
||||
"failed": "Could not update the intake."
|
||||
},
|
||||
"deleteRow": {
|
||||
"confirmTitle": "Delete this intake?",
|
||||
"confirmBody": "The entry is removed permanently.",
|
||||
"confirmAction": "Delete",
|
||||
"toast": "Intake deleted",
|
||||
"failed": "Could not delete the intake."
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"switchLabel": "Send a reminder when this dose is due",
|
||||
"helperOn": "Reminders are on.",
|
||||
"helperOff": "Reminders are off.",
|
||||
"enabledToast": "Reminders on",
|
||||
"disabledToast": "Reminders off",
|
||||
"toggleFailed": "Could not change the reminder setting.",
|
||||
"clientManagedChip": "Your iPhone manages reminders for this medication."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"csvImport": {
|
||||
"title": "CSV import",
|
||||
"stub": "Import intakes directly from the history above."
|
||||
},
|
||||
"phases": {
|
||||
"openButton": "Configure phases",
|
||||
"requiresCourseWindow": "Phases are available once a course window is set."
|
||||
},
|
||||
"grace": {
|
||||
"label": "Reminder window — applies to your primary schedule",
|
||||
"primaryScheduleNote": "Multi-schedule medications keep their other schedules' default window.",
|
||||
"unit": "minutes",
|
||||
"saved": "Reminder window saved",
|
||||
"failed": "Could not save the reminder window."
|
||||
}
|
||||
},
|
||||
"zone": {
|
||||
"title": "Manage & danger zone",
|
||||
"pause": {
|
||||
"title": "Pause",
|
||||
"helper": "Pause reminders until you turn them back on. History stays intact.",
|
||||
"pausedToast": "Reminders paused",
|
||||
"resumedToast": "Reminders resumed",
|
||||
"failed": "Could not change the paused state."
|
||||
},
|
||||
"end": {
|
||||
"title": "End medication",
|
||||
"helper": "Stop reminders and mark the medication as ended. History stays visible.",
|
||||
"button": "End",
|
||||
"dialogTitle": "End {name} — stop reminders, history stays visible",
|
||||
"dialogBody": "No more reminders. Old entries remain in the timeline.",
|
||||
"toast": "Medication ended",
|
||||
"failed": "Could not end the medication."
|
||||
},
|
||||
"purge": {
|
||||
"title": "Delete history",
|
||||
"helper": "{count} intake events are stored for this medication.",
|
||||
"button": "Delete history",
|
||||
"dialogTitle": "Really delete the history?",
|
||||
"dialogBody": "The {count} intake events will be removed permanently. The medication itself stays.",
|
||||
"toast": "History deleted",
|
||||
"failed": "Could not delete the history."
|
||||
},
|
||||
"delete": {
|
||||
"title": "Delete medication",
|
||||
"helper": "Removes the medication and every related record.",
|
||||
"button": "Delete",
|
||||
"dialogTitle": "Delete {name}?",
|
||||
"dialogBody": "The medication, its schedules, intake history, API tokens, phase config and reminders will be removed permanently.",
|
||||
"toast": "Medication deleted",
|
||||
"failed": "Could not delete the medication."
|
||||
}
|
||||
},
|
||||
"phases": {
|
||||
"modeLabel": "{phase} mode",
|
||||
"saveFailed": "Could not save the reminder phases."
|
||||
},
|
||||
"api": {
|
||||
"caption": "Endpoint for \"{name}\"",
|
||||
"copyUrl": "Copy URL",
|
||||
"urlCopied": "URL copied",
|
||||
"mintToken": "Mint token",
|
||||
"mintAnotherToken": "Mint another token",
|
||||
"mintFailed": "Could not mint a token.",
|
||||
"copyFailed": "Could not copy.",
|
||||
"tokenCopied": "Token copied",
|
||||
"copyToken": "Copy token",
|
||||
"mintedHint": "Copy this token now — it is shown only once."
|
||||
},
|
||||
"edit": {
|
||||
"planOption": "Edytuj plan",
|
||||
"advancedOption": "Ustawienia zaawansowane"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Ustawienia zaawansowane"
|
||||
}
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -2008,6 +2208,19 @@
|
||||
"timezoneHint": "Used for chart axis labels, reminder times, export timestamps, and Coach context. Stored data is unchanged.",
|
||||
"timezoneInvalid": "Not a valid IANA timezone.",
|
||||
"profileSaved": "Profile saved",
|
||||
"avatar": {
|
||||
"title": "Zdjęcie profilowe",
|
||||
"description": "Prześlij zdjęcie przechowywane na własnym serwerze. Pojawia się w całej aplikacji zamiast zewnętrznej usługi awatarów.",
|
||||
"upload": "Prześlij zdjęcie",
|
||||
"replace": "Zmień zdjęcie",
|
||||
"remove": "Usuń",
|
||||
"hint": "JPEG, PNG lub WebP. Do 2 MB i 2048×2048 pikseli.",
|
||||
"uploaded": "Zaktualizowano zdjęcie profilowe",
|
||||
"removed": "Usunięto zdjęcie profilowe",
|
||||
"invalidType": "Nieobsługiwany typ obrazu. Użyj JPEG, PNG lub WebP.",
|
||||
"tooLarge": "Obraz przekracza limit 2 MB.",
|
||||
"error": "Nie udało się zaktualizować zdjęcia profilowego. Spróbuj ponownie."
|
||||
},
|
||||
"changePassword": "Change password",
|
||||
"changePasswordDescription": "Replace your current password with a new one.",
|
||||
"passwordReset": "Password reset",
|
||||
@@ -2421,6 +2634,7 @@
|
||||
"telegramGlobal": "Allow Telegram notifications.",
|
||||
"ntfyGlobal": "Allow ntfy notifications.",
|
||||
"webPushGlobal": "Allow browser push notifications.",
|
||||
"moodLogGlobal": "Allow mood-log reminders.",
|
||||
"integrationWebPush": "Web Push",
|
||||
"integrationBugReport": "Bug Report",
|
||||
"offlineGeoLabel": "Dane geograficzne",
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.5.4",
|
||||
"version": "1.6.0",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "AGPL-3.0-only",
|
||||
"homepage": "https://healthlog.dev",
|
||||
@@ -83,6 +83,7 @@
|
||||
"sax": "^1.6.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"undici": "^7.25.0",
|
||||
"web-push": "^3.6.7",
|
||||
"yaml": "^2.8.4",
|
||||
"zod": "^4.4.3",
|
||||
@@ -104,7 +105,7 @@
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"knip": "^6.14.1",
|
||||
"knip": "^6.14.2",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pg": "^8.20.0",
|
||||
"prettier": "^3.8.3",
|
||||
|
||||
Generated
+8
-5
@@ -110,6 +110,9 @@ importers:
|
||||
tailwind-merge:
|
||||
specifier: ^3.6.0
|
||||
version: 3.6.0
|
||||
undici:
|
||||
specifier: ^7.25.0
|
||||
version: 7.25.0
|
||||
web-push:
|
||||
specifier: ^3.6.7
|
||||
version: 3.6.7
|
||||
@@ -169,8 +172,8 @@ importers:
|
||||
specifier: 16.2.6
|
||||
version: 16.2.6(@typescript-eslint/parser@8.59.2(eslint@9.39.3(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0))(typescript@6.0.3)
|
||||
knip:
|
||||
specifier: ^6.14.1
|
||||
version: 6.14.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
|
||||
specifier: ^6.14.2
|
||||
version: 6.14.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
|
||||
pdf-parse:
|
||||
specifier: ^2.4.5
|
||||
version: 2.4.5
|
||||
@@ -4666,8 +4669,8 @@ packages:
|
||||
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
knip@6.14.1:
|
||||
resolution: {integrity: sha512-SN3Ly0ixzj5CQkY/rc4OPHpWrCC0XRIIjgdP76G9Cni5k72ur5jBYOyvJuF5oPTM14v8eHcMUgPbElHa+lnR0g==}
|
||||
knip@6.14.2:
|
||||
resolution: {integrity: sha512-Vg3JhIINjZew1I7qAFI4UHemW1mc4azP/BxJvsq9eGDfxpGO7oVCuD/bsWkog9TO/ZwwJeAeOMFZ1kd9jnY9+Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
@@ -11106,7 +11109,7 @@ snapshots:
|
||||
|
||||
kleur@4.1.5: {}
|
||||
|
||||
knip@6.14.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0):
|
||||
knip@6.14.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0):
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
formatly: 0.3.0
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
-- v1.5.5 — extend `measurement_type` enum for the six iOS-side
|
||||
-- deferred identifiers.
|
||||
--
|
||||
-- Each HK identifier in this set used to land in
|
||||
-- `HK_QUANTITY_TYPE_DEFERRED` in `src/lib/measurements/apple-health-mapping.ts`.
|
||||
-- The batch ingest returned HTTP 200 with a per-entry
|
||||
-- `skipped:"unmappable_identifier"`; the iOS client read that as
|
||||
-- success and advanced its sync anchor, so any sample carrying one
|
||||
-- of these identifiers was lost forever. The mapping now wires them
|
||||
-- end-to-end:
|
||||
--
|
||||
-- RESPIRATORY_RATE ← HKQuantityTypeIdentifierRespiratoryRate
|
||||
-- BODY_MASS_INDEX ← HKQuantityTypeIdentifierBodyMassIndex
|
||||
-- LEAN_BODY_MASS ← HKQuantityTypeIdentifierLeanBodyMass
|
||||
-- WALKING_HEART_RATE_AVERAGE ← HKQuantityTypeIdentifierWalkingHeartRateAverage
|
||||
-- WALKING_ASYMMETRY ← HKQuantityTypeIdentifierWalkingAsymmetryPercentage
|
||||
-- WALKING_DOUBLE_SUPPORT ← HKQuantityTypeIdentifierWalkingDoubleSupportPercentage
|
||||
--
|
||||
-- Pure additive — no existing row carries any of these values. The
|
||||
-- `IF NOT EXISTS` guard keeps the migration idempotent and lets the
|
||||
-- runner replay it on a partially-applied environment without
|
||||
-- failing on the duplicate-enum-value error code.
|
||||
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'RESPIRATORY_RATE';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BODY_MASS_INDEX';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'LEAN_BODY_MASS';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WALKING_HEART_RATE_AVERAGE';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WALKING_ASYMMETRY';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WALKING_DOUBLE_SUPPORT';
|
||||
@@ -0,0 +1,44 @@
|
||||
-- v1.5.5 — per-user insights tile layout.
|
||||
--
|
||||
-- Mirrors `dashboard_widgets_json` for the `/insights` surface. iOS
|
||||
-- v0.8.0 audit flagged that insights tile order + visibility lived in
|
||||
-- the iOS client's local state only, so reordering a tile on one
|
||||
-- device never propagated to the web Settings UI or to a second iOS
|
||||
-- install signed into the same account. This column closes that
|
||||
-- cross-device drift by giving the new `GET / PUT /api/insights/layout`
|
||||
-- endpoint somewhere to persist the user's choice.
|
||||
--
|
||||
-- Shape:
|
||||
-- {
|
||||
-- "version": 1,
|
||||
-- "tiles": [
|
||||
-- { "id": "<tile-id>", "visible": <bool>, "order": <int 0..> },
|
||||
-- …
|
||||
-- ]
|
||||
-- }
|
||||
--
|
||||
-- Default `NULL`, which the application resolver
|
||||
-- (`src/lib/insights-layout.ts:resolveInsightsLayout`) reads as
|
||||
-- "use `DEFAULT_INSIGHTS_LAYOUT`". The GET endpoint never lazy-writes
|
||||
-- on read — the column only carries data once the user has explicitly
|
||||
-- saved a layout, matching the dashboard-widgets contract.
|
||||
--
|
||||
-- Idempotent guard (`IF NOT EXISTS`) matches the 0067 / 0070 / 0071 /
|
||||
-- 0075 / 0078 / 0079 pattern so reruns are safe on environments where
|
||||
-- the migration has already been applied.
|
||||
--
|
||||
-- Reversibility: down migration is
|
||||
-- ALTER TABLE "users" DROP COLUMN IF EXISTS "insights_layout_json";
|
||||
-- A roll-back loses the saved layout for every user who set one (they
|
||||
-- fall back to the default order), but the application reads tolerate
|
||||
-- a missing column / null row and the resolver returns the default
|
||||
-- layout in either case, so a partial deploy where the schema is
|
||||
-- rolled back ahead of the app code keeps working.
|
||||
|
||||
-- No additional index — reads at request time are per-user
|
||||
-- (`WHERE id = $1`), matching the convention set by
|
||||
-- `dashboard_widgets_json`, `thresholds_json`, `source_priority_json`,
|
||||
-- and `doctor_report_prefs_json` on the same table (no GIN index on
|
||||
-- any of them).
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN IF NOT EXISTS "insights_layout_json" JSONB;
|
||||
@@ -0,0 +1,24 @@
|
||||
-- v1.5.5 — self-hosted avatar storage.
|
||||
--
|
||||
-- Replaces the Gravatar third-party leak. The previous /me payload
|
||||
-- carried a `gravatarUrl` built from `SHA-256(email)` and pointed
|
||||
-- every authenticated page-load at www.gravatar.com — Automattic
|
||||
-- could correlate the digest against its known-email table on each
|
||||
-- request. The new path stores the image bytes on the User row and
|
||||
-- serves them from same-origin.
|
||||
--
|
||||
-- Three nullable columns; null on all three = the user has not
|
||||
-- uploaded an avatar and the profile response returns
|
||||
-- `avatarUrl: null`. The upload route guards the size + content-
|
||||
-- type + dimensions before the row write, so the column-level
|
||||
-- constraints intentionally stay loose (the validation is enforced
|
||||
-- at the API layer where it can return a useful 4xx).
|
||||
--
|
||||
-- The bytes ride in pg_dump alongside the rest of the row, so the
|
||||
-- standard backup path already covers them. No new filesystem
|
||||
-- volume to mount, no new Docker compose change.
|
||||
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN "avatar_bytes" BYTEA,
|
||||
ADD COLUMN "avatar_content_type" TEXT,
|
||||
ADD COLUMN "avatar_updated_at" TIMESTAMP(3);
|
||||
@@ -0,0 +1,27 @@
|
||||
-- v1.5.5 — extend `measurement_type` enum for the two remaining iOS
|
||||
-- mobility identifiers (gait step length + walking speed).
|
||||
--
|
||||
-- Background: 0083 wired six previously-deferred Apple Health
|
||||
-- identifiers. The iOS team flagged two more gait metrics they need
|
||||
-- mapped before they can flip them client-side:
|
||||
--
|
||||
-- WALKING_STEP_LENGTH ← HKQuantityTypeIdentifierWalkingStepLength
|
||||
-- WALKING_SPEED ← HKQuantityTypeIdentifierWalkingSpeed
|
||||
--
|
||||
-- Crucial unit convention: both ship raw SI units on the wire
|
||||
-- (metres and metres-per-second respectively). The ×100 server-side
|
||||
-- scaling the percent gait metrics use (`WALKING_ASYMMETRY`,
|
||||
-- `WALKING_DOUBLE_SUPPORT`, `WALKING_STEADINESS`) does NOT apply to
|
||||
-- these two — they pass through `convertToDbUnit` as identity. The
|
||||
-- convention block at the top of
|
||||
-- `src/lib/measurements/apple-health-mapping.ts` spells the split
|
||||
-- out so a future contributor adding a new gait metric picks the
|
||||
-- right path.
|
||||
--
|
||||
-- Pure additive — no existing row carries either of these values.
|
||||
-- The `IF NOT EXISTS` guard keeps the migration idempotent and lets
|
||||
-- the runner replay it on a partially-applied environment without
|
||||
-- failing on the duplicate-enum-value error code.
|
||||
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WALKING_STEP_LENGTH';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WALKING_SPEED';
|
||||
@@ -0,0 +1,38 @@
|
||||
-- v1.5.6 — accelerate the legacy step-consolidation discovery query.
|
||||
--
|
||||
-- The boot-time consolidation pass (`enqueueBootTimeStepConsolidation`
|
||||
-- in src/lib/jobs/step-consolidation.ts) repeatedly scans for users
|
||||
-- still holding live pre-v1.5.0 granular step rows: `ACTIVITY_STEPS`
|
||||
-- rows that are not tombstoned and whose `external_id` is NULL or does
|
||||
-- not carry the `stats:HKQuantityTypeIdentifierStepCount:` daily-total
|
||||
-- prefix. On an account with a multi-year HealthKit history that
|
||||
-- predicate matches hundreds-of-thousands of rows before the
|
||||
-- consolidation tombstones them.
|
||||
--
|
||||
-- A partial index keyed on the legacy-candidate predicate keeps both
|
||||
-- the per-boot discovery scan and the per-user `findMany` cheap, and —
|
||||
-- because the index only covers `deleted_at IS NULL` rows — it shrinks
|
||||
-- to (eventually) empty as the pass converges, so it carries no
|
||||
-- steady-state write cost once every legacy row is consolidated.
|
||||
--
|
||||
-- Additive + order-safe: a pure `CREATE INDEX IF NOT EXISTS`, no column
|
||||
-- add, no backfill, no rewrite of `measurements`.
|
||||
--
|
||||
-- Lock note (conscious trade-off): this is a plain, non-`CONCURRENTLY`
|
||||
-- index build, so it runs inside Prisma's migration transaction and
|
||||
-- holds an ACCESS EXCLUSIVE lock on `measurements` for the duration of
|
||||
-- the build — reads and writes to the table block until it completes.
|
||||
-- The `deleted_at IS NULL` + `type = 'ACTIVITY_STEPS'` partial predicate
|
||||
-- keeps the index tiny (most tenants carry no legacy step rows at all,
|
||||
-- and the covered set shrinks to empty as the pass converges), so on a
|
||||
-- typical self-host the build is sub-second and the lock is not felt.
|
||||
-- The largest multi-year HealthKit tenants are the ones who both need
|
||||
-- this index and would feel a longer lock; for those, run the deploy at
|
||||
-- a low-traffic window. A `CONCURRENTLY` build cannot run inside the
|
||||
-- migration transaction and is not warranted given the partial-index
|
||||
-- size on the overwhelming majority of deployments.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "measurements_legacy_step_consolidation_idx"
|
||||
ON "measurements" ("user_id", "measured_at")
|
||||
WHERE "type" = 'ACTIVITY_STEPS'
|
||||
AND "deleted_at" IS NULL;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- v1.6.0 — route-of-administration on medications.
|
||||
--
|
||||
-- Adds `delivery_form` (ORAL | INJECTION | OTHER), decoupled from the
|
||||
-- existing `treatment_class` discriminator. This lets the injection-site
|
||||
-- picker surface for ANY injection dose (not only GLP-1) and gives the
|
||||
-- one-time-injection shape a first-class flag (`one_shot = true` +
|
||||
-- `delivery_form = INJECTION`).
|
||||
--
|
||||
-- Every existing row backfills to ORAL via the column default, so the
|
||||
-- ADD COLUMN is a single non-blocking metadata operation on Postgres 11+
|
||||
-- (the default is a constant, not a volatile expression).
|
||||
|
||||
CREATE TYPE "medication_delivery_form" AS ENUM ('ORAL', 'INJECTION', 'OTHER');
|
||||
|
||||
ALTER TABLE "medications"
|
||||
ADD COLUMN "delivery_form" "medication_delivery_form" NOT NULL DEFAULT 'ORAL';
|
||||
@@ -71,6 +71,15 @@ model User {
|
||||
// Per-user dashboard layout — widget visibility + ordering.
|
||||
// Shape: { "widgets": [{ "id": "weight", "visible": true, "order": 0 }, ...], "version": 1 }
|
||||
dashboardWidgetsJson Json? @map("dashboard_widgets_json")
|
||||
// Per-user insights tile layout — tile visibility + ordering for the
|
||||
// `/insights` surface. Mirrors `dashboardWidgetsJson` so the iOS
|
||||
// client and the web Settings UI can persist insights tile order +
|
||||
// visibility through the same shape. Null = default layout (see
|
||||
// `src/lib/insights-layout.ts:DEFAULT_INSIGHTS_LAYOUT`). The GET
|
||||
// endpoint never lazy-writes — the column only carries data once
|
||||
// the user has explicitly saved a layout.
|
||||
// Shape: { "tiles": [{ "id": "blutdruck", "visible": true, "order": 0 }, ...], "version": 1 }
|
||||
insightsLayoutJson Json? @map("insights_layout_json")
|
||||
// Blood-glucose display unit preference. Canonical storage is mg/dL.
|
||||
glucoseUnit String? @map("glucose_unit") // "mg/dL" | "mmol/L" | null = default "mg/dL"
|
||||
// AI provider selection for insights. Null = OPENAI fallback.
|
||||
@@ -264,6 +273,33 @@ model User {
|
||||
// `useAuth()` hook.
|
||||
disableCoach Boolean @default(false) @map("disable_coach")
|
||||
|
||||
// v1.5.5 — self-hosted avatar storage.
|
||||
//
|
||||
// Replaces the Gravatar third-party leak (the old `getGravatarUrl`
|
||||
// path hashed the email with SHA-256 and shipped the digest to
|
||||
// www.gravatar.com on every authenticated page load, which let
|
||||
// Automattic correlate the digest against its own table of known
|
||||
// emails). The bytes live on the User row so the standard pg_dump
|
||||
// backup path already covers them — no new filesystem volume to
|
||||
// mount, no new Docker compose change.
|
||||
//
|
||||
// `avatarBytes` carries the raw image (JPEG / PNG / WebP), hard-
|
||||
// capped at 2 MiB by the upload route and at 2048×2048 by the
|
||||
// post-decode dimension check. `avatarContentType` carries the
|
||||
// sniffed MIME (`image/jpeg`, `image/png`, `image/webp`); the
|
||||
// value is never trusted from the multipart header — it is
|
||||
// resolved from the file's magic bytes. `avatarUpdatedAt` is the
|
||||
// cache-bust anchor: the /api/auth/me profile response appends
|
||||
// the unix-ms suffix to the avatar URL so a re-upload paints
|
||||
// fresh client-side without an HTTP cache flush.
|
||||
//
|
||||
// Null on all three columns = the user has not uploaded an
|
||||
// avatar and the profile response returns `avatarUrl: null`. The
|
||||
// client falls back to the username initials.
|
||||
avatarBytes Bytes? @map("avatar_bytes")
|
||||
avatarContentType String? @map("avatar_content_type")
|
||||
avatarUpdatedAt DateTime? @map("avatar_updated_at")
|
||||
|
||||
// Relations
|
||||
passkeys Passkey[]
|
||||
sessions Session[]
|
||||
@@ -443,6 +479,25 @@ enum MeasurementType {
|
||||
// ── v1.4.30 R-F T1.4 + T1.5 Tier-1 additions ──
|
||||
WALKING_STEADINESS // percent (0-100, scaled ×100 from Apple's 0..1 fraction) — HealthKit `appleWalkingSteadiness` (iOS 15+). Daily rollup. Falls + mobility-risk signal Apple's own Health app shows prominently in the Mobility section.
|
||||
AUDIO_EXPOSURE_EVENT // count (event flag: 1 per fired loud-listening event) — HealthKit `environmentalAudioExposureEvent` + `headphoneAudioExposureEvent` category-events. Apple fires these when the rolling 7-day average crosses the WHO 80-dBA threshold. The notes field carries the source identifier (env vs headphone).
|
||||
// ── v1.5.5 iOS-coord additions — six identifiers the deferred list refused to map. ──
|
||||
// Each one was emitted by the iOS app, dropped server-side with a
|
||||
// per-entry `skipped:"unmappable_identifier"`, and ack-200'd to the
|
||||
// client. iOS read 200 as success, advanced its sync anchor, and the
|
||||
// samples were lost forever. Now mapped end-to-end.
|
||||
RESPIRATORY_RATE // count/min — HealthKit `respiratoryRate`. Watch + iPhone sleep + workout sampling.
|
||||
BODY_MASS_INDEX // kg/m² — HealthKit `bodyMassIndex`. iOS computes it from weight + height before upload; we still store it as a first-class metric for trend display.
|
||||
LEAN_BODY_MASS // kg — HealthKit `leanBodyMass`. Body-composition counterpart to FAT_MASS; pairs with WEIGHT − FAT_MASS for sanity checks.
|
||||
WALKING_HEART_RATE_AVERAGE // bpm — HealthKit `walkingHeartRateAverage`. Daily rollup; cardio-fitness signal distinct from RESTING_HEART_RATE and spot PULSE.
|
||||
WALKING_ASYMMETRY // percent (0-100, scaled ×100 from Apple's 0..1 fraction) — HealthKit `walkingAsymmetryPercentage`. Mobility section sibling to WALKING_STEADINESS.
|
||||
WALKING_DOUBLE_SUPPORT // percent (0-100, scaled ×100 from Apple's 0..1 fraction) — HealthKit `walkingDoubleSupportPercentage`. Mobility section sibling.
|
||||
// ── v1.5.5 iOS-coord follow-up — gait raw-SI pair ──
|
||||
// Both identifiers flow over the wire in raw SI units (no ×100
|
||||
// scaling). The percent gait metrics above use the server-side
|
||||
// scaling path because Apple ships them as 0..1 fractions; the two
|
||||
// below are already in metres and metres-per-second respectively
|
||||
// and pass through `convertToDbUnit` as identity.
|
||||
WALKING_STEP_LENGTH // metres — HealthKit `walkingStepLength`. Mobility section gait metric; adult walking typically 0.5–0.8 m.
|
||||
WALKING_SPEED // metres per second — HealthKit `walkingSpeed`. Mobility section gait metric; healthy adult casual gait ≈ 1.2–1.4 m/s.
|
||||
|
||||
@@map("measurement_type")
|
||||
}
|
||||
@@ -555,6 +610,11 @@ model Measurement {
|
||||
@@unique([userId, type, source, externalId])
|
||||
@@index([userId, type, measuredAt])
|
||||
@@index([externalId])
|
||||
// `measurements_legacy_step_consolidation_idx` is a partial index
|
||||
// over `type = 'ACTIVITY_STEPS' AND deleted_at IS NULL` — see
|
||||
// migration 0087. It accelerates the v1.5.6 legacy step-consolidation
|
||||
// discovery scan. Prisma can't express a partial-predicate index, so
|
||||
// it lives in raw migration SQL and is intentionally omitted here.
|
||||
@@map("measurements")
|
||||
}
|
||||
|
||||
@@ -763,6 +823,20 @@ enum MedicationCategory {
|
||||
@@map("medication_category")
|
||||
}
|
||||
|
||||
/// v1.6.0 — route of administration, decoupled from `treatmentClass`.
|
||||
/// Drives the injection-site picker (available for any INJECTION, not
|
||||
/// only GLP-1) and the one-time-injection shape (`oneShot = true` +
|
||||
/// `deliveryForm = INJECTION`). ORAL is the backfilled default for every
|
||||
/// pre-v1.6.0 row. OTHER is the catch-all for topical / inhaled / nasal
|
||||
/// routes that don't need injection-site tracking.
|
||||
enum MedicationDeliveryForm {
|
||||
ORAL
|
||||
INJECTION
|
||||
OTHER
|
||||
|
||||
@@map("medication_delivery_form")
|
||||
}
|
||||
|
||||
model Medication {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
@@ -802,6 +876,11 @@ model Medication {
|
||||
/// the migration backfills `oneShot = false` for every existing
|
||||
/// row.
|
||||
oneShot Boolean @default(false) @map("one_shot")
|
||||
/// v1.6.0 — route of administration. Decoupled from `treatmentClass`
|
||||
/// so the injection-site picker can surface for any INJECTION dose.
|
||||
/// A one-time injection is `oneShot = true` + `deliveryForm =
|
||||
/// INJECTION`. The migration backfills ORAL for every existing row.
|
||||
deliveryForm MedicationDeliveryForm @default(ORAL) @map("delivery_form")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
|
||||
@@ -39,8 +39,8 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
const payload: BackupsList = {
|
||||
rows,
|
||||
// Mirrors the value surfaced by `/api/admin/status-overview` so the
|
||||
// backups page and the status card grid agree.
|
||||
// Matches the retention window the backup-prune job enforces so the
|
||||
// backups page states the same number the worker acts on.
|
||||
retentionDays: 90,
|
||||
};
|
||||
|
||||
|
||||
@@ -135,9 +135,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
meta: {
|
||||
since: parsed.since,
|
||||
count: samples.length,
|
||||
// Memory total in bytes is useful so the chart can render an
|
||||
// absolute label ("4.2 GiB / 8 GiB") in the tooltip without a
|
||||
// second round-trip. Latest row wins; falls back to 0 on empty.
|
||||
// Memory total in bytes feeds the tooltip's absolute label
|
||||
// ("X.X / Y.Y GiB") so the percentage isn't read in a vacuum.
|
||||
// Latest row wins; falls back to 0 on empty.
|
||||
memTotalBytes: rows.length
|
||||
? Number(rows[rows.length - 1].memTotalBytes)
|
||||
: 0,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -88,15 +89,20 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
let lastDetails = "";
|
||||
|
||||
for (const targetUrl of targetUrls) {
|
||||
const upstream = await fetch(targetUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"user-agent": "healthlog-admin-monitoring-test",
|
||||
const upstream = await safeFetch(
|
||||
targetUrl,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"user-agent": "healthlog-admin-monitoring-test",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
});
|
||||
// Operator-configured Umami host — pin the connect-time IP.
|
||||
{ requirePublicHost: true },
|
||||
);
|
||||
|
||||
if (upstream.ok) {
|
||||
return apiSuccess({
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { count: vi.fn() },
|
||||
withingsConnection: { count: vi.fn() },
|
||||
notificationChannel: { count: vi.fn() },
|
||||
pushSubscription: { count: vi.fn() },
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
auditLog: { findFirst: vi.fn(), count: vi.fn() },
|
||||
dataBackup: { findFirst: vi.fn(), findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAdmin: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jobs/worker-status", () => ({
|
||||
getWorkerStatus: vi.fn(() => ({
|
||||
running: true,
|
||||
startedAt: new Date(Date.now() - 3_600_000).toISOString(),
|
||||
lastHeartbeat: null,
|
||||
lastReminderCheck: null,
|
||||
lastWithingsSync: null,
|
||||
lastInsightsRun: null,
|
||||
jobsProcessed: 0,
|
||||
errors: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const mocks = prisma as unknown as {
|
||||
user: { count: ReturnType<typeof vi.fn> };
|
||||
withingsConnection: { count: ReturnType<typeof vi.fn> };
|
||||
notificationChannel: { count: ReturnType<typeof vi.fn> };
|
||||
pushSubscription: { count: ReturnType<typeof vi.fn> };
|
||||
appSettings: { findUnique: ReturnType<typeof vi.fn> };
|
||||
auditLog: {
|
||||
findFirst: ReturnType<typeof vi.fn>;
|
||||
count: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
dataBackup: {
|
||||
findFirst: ReturnType<typeof vi.fn>;
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
|
||||
describe("GET /api/admin/status-overview", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// default returns
|
||||
mocks.user.count.mockResolvedValue(0);
|
||||
mocks.withingsConnection.count.mockResolvedValue(0);
|
||||
mocks.notificationChannel.count.mockResolvedValue(0);
|
||||
mocks.pushSubscription.count.mockResolvedValue(0);
|
||||
mocks.appSettings.findUnique.mockResolvedValue(null);
|
||||
mocks.auditLog.findFirst.mockResolvedValue(null);
|
||||
mocks.auditLog.count.mockResolvedValue(0);
|
||||
mocks.dataBackup.findFirst.mockResolvedValue(null);
|
||||
mocks.dataBackup.findMany.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("returns the 6-section overview shape", async () => {
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
expect(body.data).toHaveProperty("users");
|
||||
expect(body.data).toHaveProperty("integrations");
|
||||
expect(body.data).toHaveProperty("monitoring");
|
||||
expect(body.data).toHaveProperty("backups");
|
||||
expect(body.data).toHaveProperty("maintenance");
|
||||
expect(body.data).toHaveProperty("auditLog");
|
||||
});
|
||||
|
||||
it("batches DB calls in a single Promise.allSettled (no N+1)", async () => {
|
||||
await GET();
|
||||
// 3 user.count + 1 withings + 1 ntfy notifications + 1 webPush + 3
|
||||
// moodLog/telegram done via user.count → expect user.count called 4 times
|
||||
// (total, admins, newThisWeek, moodLog, telegram)
|
||||
expect(mocks.user.count).toHaveBeenCalledTimes(5);
|
||||
expect(mocks.withingsConnection.count).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.notificationChannel.count).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.pushSubscription.count).toHaveBeenCalledTimes(1);
|
||||
// The route does not loop — every aggregate is a single query.
|
||||
expect(mocks.dataBackup.findMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("P20: a single failed probe still returns the rest of the grid", async () => {
|
||||
// Simulate a transient DB failure on the withings count probe only.
|
||||
// Without Promise.allSettled the whole route would 500 and the entire
|
||||
// admin overview grid would go blank — with allSettled the failure
|
||||
// is isolated to its card (severity → "alert", value → 0) and the
|
||||
// other 5 cards still render.
|
||||
mocks.withingsConnection.count.mockRejectedValueOnce(
|
||||
new Error("simulated db timeout"),
|
||||
);
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
users: { severity: string };
|
||||
integrations: { severity: string; withings: number };
|
||||
backups: { severity: string };
|
||||
};
|
||||
};
|
||||
expect(body.data.integrations.severity).toBe("alert");
|
||||
expect(body.data.integrations.withings).toBe(0);
|
||||
// Untouched cards keep their normal severity.
|
||||
expect(body.data.users.severity).toBe("good");
|
||||
});
|
||||
|
||||
it("flags backups as alert when none exist", async () => {
|
||||
mocks.dataBackup.findFirst.mockResolvedValue(null);
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { backups: { severity: string } };
|
||||
};
|
||||
expect(body.data.backups.severity).toBe("alert");
|
||||
});
|
||||
|
||||
it("flags backups as good when fresh (<8d)", async () => {
|
||||
mocks.dataBackup.findFirst.mockResolvedValue({
|
||||
createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000),
|
||||
});
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { backups: { severity: string } };
|
||||
};
|
||||
expect(body.data.backups.severity).toBe("good");
|
||||
});
|
||||
|
||||
it("flags monitoring as alert on a recent error (<24h)", async () => {
|
||||
mocks.auditLog.findFirst.mockImplementation(
|
||||
({ where }: { where: { action: unknown } }) => {
|
||||
const action = where.action as { startsWith?: string } | string;
|
||||
if (
|
||||
typeof action === "object" &&
|
||||
action.startsWith === "system.error"
|
||||
) {
|
||||
return Promise.resolve({ createdAt: new Date(Date.now() - 60_000) });
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
);
|
||||
mocks.appSettings.findUnique.mockResolvedValue({
|
||||
glitchtipEnabled: true,
|
||||
umamiEnabled: true,
|
||||
});
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { monitoring: { severity: string } };
|
||||
};
|
||||
expect(body.data.monitoring.severity).toBe("alert");
|
||||
});
|
||||
|
||||
it("flags maintenance as alert when worker is stopped", async () => {
|
||||
const wsModule = await import("@/lib/jobs/worker-status");
|
||||
vi.mocked(wsModule.getWorkerStatus).mockReturnValue({
|
||||
running: false,
|
||||
startedAt: null,
|
||||
lastHeartbeat: null,
|
||||
lastReminderCheck: null,
|
||||
lastWithingsSync: null,
|
||||
lastInsightsRun: null,
|
||||
jobsProcessed: 0,
|
||||
errors: 0,
|
||||
});
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { maintenance: { severity: string; workerRunning: boolean } };
|
||||
};
|
||||
expect(body.data.maintenance.severity).toBe("alert");
|
||||
expect(body.data.maintenance.workerRunning).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,318 +0,0 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getWorkerStatus } from "@/lib/jobs/worker-status";
|
||||
|
||||
/**
|
||||
* Severity tokens map onto §2.6 status taxonomy of `docs/ui-guidelines.md`.
|
||||
* Returned as plain strings so the client renders dots + labels — not RAW
|
||||
* colours — and never leans on color alone for state.
|
||||
*/
|
||||
export type StatusSeverity = "good" | "info" | "caution" | "alert" | "pending";
|
||||
|
||||
export interface StatusOverview {
|
||||
users: {
|
||||
severity: StatusSeverity;
|
||||
total: number;
|
||||
admins: number;
|
||||
newThisWeek: number;
|
||||
};
|
||||
integrations: {
|
||||
severity: StatusSeverity;
|
||||
withings: number;
|
||||
moodLog: number;
|
||||
telegram: number;
|
||||
ntfy: number;
|
||||
webPush: number;
|
||||
};
|
||||
monitoring: {
|
||||
severity: StatusSeverity;
|
||||
glitchtipEnabled: boolean;
|
||||
umamiEnabled: boolean;
|
||||
wideEventsEnabled: boolean;
|
||||
lastErrorAt: string | null;
|
||||
};
|
||||
backups: {
|
||||
severity: StatusSeverity;
|
||||
lastBackupAt: string | null;
|
||||
backedUpUsers: number;
|
||||
retentionDays: number;
|
||||
};
|
||||
maintenance: {
|
||||
severity: StatusSeverity;
|
||||
workerRunning: boolean;
|
||||
workerUptimeSeconds: number | null;
|
||||
lastIdempotencyCleanup: string | null;
|
||||
lastAuditLogCleanup: string | null;
|
||||
};
|
||||
auditLog: {
|
||||
severity: StatusSeverity;
|
||||
eventsLast30d: number;
|
||||
lastLoginAt: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function severityForBackup(lastBackupAt: Date | null): StatusSeverity {
|
||||
if (!lastBackupAt) return "alert";
|
||||
const ageMs = Date.now() - lastBackupAt.getTime();
|
||||
if (ageMs > 14 * ONE_DAY_MS) return "alert";
|
||||
if (ageMs > 8 * ONE_DAY_MS) return "caution";
|
||||
return "good";
|
||||
}
|
||||
|
||||
function severityForMaintenance(workerRunning: boolean): StatusSeverity {
|
||||
return workerRunning ? "good" : "alert";
|
||||
}
|
||||
|
||||
function severityForMonitoring(
|
||||
glitchtipEnabled: boolean,
|
||||
umamiEnabled: boolean,
|
||||
recentErrorAt: Date | null,
|
||||
): StatusSeverity {
|
||||
if (recentErrorAt && Date.now() - recentErrorAt.getTime() < ONE_DAY_MS) {
|
||||
return "alert";
|
||||
}
|
||||
if (glitchtipEnabled && umamiEnabled) return "good";
|
||||
if (glitchtipEnabled || umamiEnabled) return "caution";
|
||||
return "info";
|
||||
}
|
||||
|
||||
function severityForIntegrations(totalConfigured: number): StatusSeverity {
|
||||
if (totalConfigured === 0) return "info";
|
||||
return "good";
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.status-overview" } });
|
||||
|
||||
const now = new Date();
|
||||
const sevenDaysAgo = new Date(now.getTime() - 7 * ONE_DAY_MS);
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * ONE_DAY_MS);
|
||||
|
||||
// P20: switch from Promise.all to Promise.allSettled so a single
|
||||
// failed probe (e.g. a missing index, a transient timeout, an
|
||||
// unavailable model) doesn't blank the whole admin overview grid.
|
||||
// Each probe is labelled so we can map a rejection back to the
|
||||
// overview field it was meant to populate, then surface the failed
|
||||
// labels in a Wide Event annotation for ops triage.
|
||||
const probes = [
|
||||
["userTotal", () => prisma.user.count()],
|
||||
["userAdmins", () => prisma.user.count({ where: { role: "ADMIN" } })],
|
||||
[
|
||||
"userNewThisWeek",
|
||||
() => prisma.user.count({ where: { createdAt: { gte: sevenDaysAgo } } }),
|
||||
],
|
||||
["withingsCount", () => prisma.withingsConnection.count()],
|
||||
[
|
||||
"moodLogCount",
|
||||
() => prisma.user.count({ where: { moodLogEnabled: true } }),
|
||||
],
|
||||
[
|
||||
"telegramCount",
|
||||
() => prisma.user.count({ where: { telegramEnabled: true } }),
|
||||
],
|
||||
[
|
||||
"ntfyCount",
|
||||
() =>
|
||||
prisma.notificationChannel.count({
|
||||
where: { type: "NTFY", enabled: true },
|
||||
}),
|
||||
],
|
||||
["webPushCount", () => prisma.pushSubscription.count()],
|
||||
[
|
||||
"appSettings",
|
||||
() => prisma.appSettings.findUnique({ where: { id: "singleton" } }),
|
||||
],
|
||||
[
|
||||
"lastErrorEntry",
|
||||
() =>
|
||||
prisma.auditLog.findFirst({
|
||||
where: { action: { startsWith: "system.error" } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"latestBackup",
|
||||
() =>
|
||||
prisma.dataBackup.findFirst({
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"backedUpUsers",
|
||||
() =>
|
||||
prisma.dataBackup
|
||||
.findMany({ select: { userId: true }, distinct: ["userId"] })
|
||||
.then((rows) => rows.length),
|
||||
],
|
||||
[
|
||||
"last30dEvents",
|
||||
() =>
|
||||
prisma.auditLog.count({ where: { createdAt: { gte: thirtyDaysAgo } } }),
|
||||
],
|
||||
[
|
||||
"lastLoginEntry",
|
||||
() =>
|
||||
prisma.auditLog.findFirst({
|
||||
where: { action: "auth.login" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"lastIdempotencyCleanup",
|
||||
() =>
|
||||
prisma.auditLog.findFirst({
|
||||
where: { action: "system.cleanup.idempotency" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"lastAuditLogCleanup",
|
||||
() =>
|
||||
prisma.auditLog.findFirst({
|
||||
where: { action: "system.cleanup.audit-log" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
],
|
||||
] as const;
|
||||
|
||||
const settled = await Promise.allSettled(probes.map(([, run]) => run()));
|
||||
|
||||
const failed: string[] = [];
|
||||
const results: Record<string, unknown> = {};
|
||||
settled.forEach((r, i) => {
|
||||
const label = probes[i][0];
|
||||
if (r.status === "fulfilled") {
|
||||
results[label] = r.value;
|
||||
} else {
|
||||
failed.push(label);
|
||||
results[label] = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Pull each probe out in the same shape the overview expects. `?? 0`
|
||||
// turns a null (failed-count probe) into a renderable zero so the
|
||||
// grid tiles still draw something instead of going blank.
|
||||
const userTotal = (results.userTotal as number | null) ?? 0;
|
||||
const userAdmins = (results.userAdmins as number | null) ?? 0;
|
||||
const userNewThisWeek = (results.userNewThisWeek as number | null) ?? 0;
|
||||
const withingsCount = (results.withingsCount as number | null) ?? 0;
|
||||
const moodLogCount = (results.moodLogCount as number | null) ?? 0;
|
||||
const telegramCount = (results.telegramCount as number | null) ?? 0;
|
||||
const ntfyCount = (results.ntfyCount as number | null) ?? 0;
|
||||
const webPushCount = (results.webPushCount as number | null) ?? 0;
|
||||
const appSettings = results.appSettings as {
|
||||
glitchtipEnabled: boolean;
|
||||
umamiEnabled: boolean;
|
||||
} | null;
|
||||
const lastErrorEntry = results.lastErrorEntry as { createdAt: Date } | null;
|
||||
const latestBackup = results.latestBackup as { createdAt: Date } | null;
|
||||
const backedUpUsers = (results.backedUpUsers as number | null) ?? 0;
|
||||
const last30dEvents = (results.last30dEvents as number | null) ?? 0;
|
||||
const lastLoginEntry = results.lastLoginEntry as { createdAt: Date } | null;
|
||||
const lastIdempotencyCleanup = results.lastIdempotencyCleanup as {
|
||||
createdAt: Date;
|
||||
} | null;
|
||||
const lastAuditLogCleanup = results.lastAuditLogCleanup as {
|
||||
createdAt: Date;
|
||||
} | null;
|
||||
|
||||
if (failed.length > 0) {
|
||||
annotate({ meta: { statusOverviewProbeFailures: failed } });
|
||||
}
|
||||
|
||||
const worker = getWorkerStatus();
|
||||
const workerUptimeSeconds = worker.startedAt
|
||||
? Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
(now.getTime() - new Date(worker.startedAt).getTime()) / 1000,
|
||||
),
|
||||
)
|
||||
: null;
|
||||
|
||||
const integrationsTotal =
|
||||
withingsCount + moodLogCount + telegramCount + ntfyCount + webPushCount;
|
||||
|
||||
// Map probe-name → which overview card depends on it. If any probe
|
||||
// for a card failed, force the card's severity to "alert" so the UI
|
||||
// surfaces the failure instead of showing zeros as if they were real.
|
||||
const failedSet = new Set(failed);
|
||||
const anyFailed = (...labels: string[]) =>
|
||||
labels.some((l) => failedSet.has(l));
|
||||
|
||||
const overview: StatusOverview = {
|
||||
users: {
|
||||
severity: anyFailed("userTotal", "userAdmins", "userNewThisWeek")
|
||||
? "alert"
|
||||
: "good",
|
||||
total: userTotal,
|
||||
admins: userAdmins,
|
||||
newThisWeek: userNewThisWeek,
|
||||
},
|
||||
integrations: {
|
||||
severity: anyFailed(
|
||||
"withingsCount",
|
||||
"moodLogCount",
|
||||
"telegramCount",
|
||||
"ntfyCount",
|
||||
"webPushCount",
|
||||
)
|
||||
? "alert"
|
||||
: severityForIntegrations(integrationsTotal),
|
||||
withings: withingsCount,
|
||||
moodLog: moodLogCount,
|
||||
telegram: telegramCount,
|
||||
ntfy: ntfyCount,
|
||||
webPush: webPushCount,
|
||||
},
|
||||
monitoring: {
|
||||
severity: anyFailed("appSettings", "lastErrorEntry")
|
||||
? "alert"
|
||||
: severityForMonitoring(
|
||||
Boolean(appSettings?.glitchtipEnabled),
|
||||
Boolean(appSettings?.umamiEnabled),
|
||||
lastErrorEntry?.createdAt ?? null,
|
||||
),
|
||||
glitchtipEnabled: Boolean(appSettings?.glitchtipEnabled),
|
||||
umamiEnabled: Boolean(appSettings?.umamiEnabled),
|
||||
wideEventsEnabled: true,
|
||||
lastErrorAt: lastErrorEntry?.createdAt?.toISOString() ?? null,
|
||||
},
|
||||
backups: {
|
||||
severity: anyFailed("latestBackup", "backedUpUsers")
|
||||
? "alert"
|
||||
: severityForBackup(latestBackup?.createdAt ?? null),
|
||||
lastBackupAt: latestBackup?.createdAt?.toISOString() ?? null,
|
||||
backedUpUsers,
|
||||
retentionDays: 90,
|
||||
},
|
||||
maintenance: {
|
||||
severity: anyFailed("lastIdempotencyCleanup", "lastAuditLogCleanup")
|
||||
? "alert"
|
||||
: severityForMaintenance(worker.running),
|
||||
workerRunning: worker.running,
|
||||
workerUptimeSeconds,
|
||||
lastIdempotencyCleanup:
|
||||
lastIdempotencyCleanup?.createdAt?.toISOString() ?? null,
|
||||
lastAuditLogCleanup:
|
||||
lastAuditLogCleanup?.createdAt?.toISOString() ?? null,
|
||||
},
|
||||
auditLog: {
|
||||
severity: anyFailed("last30dEvents", "lastLoginEntry") ? "alert" : "info",
|
||||
eventsLast30d: last30dEvents,
|
||||
lastLoginAt: lastLoginEntry?.createdAt?.toISOString() ?? null,
|
||||
},
|
||||
};
|
||||
|
||||
return apiSuccess(overview);
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { getGravatarUrl } from "@/lib/gravatar";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { setOnboardingPendingCookie } from "@/lib/auth/session";
|
||||
import { buildAvatarUrl } from "@/lib/avatar";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -29,7 +29,14 @@ export const GET = apiHandler(async () => {
|
||||
timezone: user.timezone,
|
||||
onboardingCompletedAt: user.onboardingCompletedAt,
|
||||
onboardingTourCompleted: user.onboardingTourCompleted,
|
||||
gravatarUrl: user.email ? getGravatarUrl(user.email) : null,
|
||||
// v1.5.5 — self-hosted avatar. Replaces the Gravatar leak; the
|
||||
// URL is relative so PWA + native clients render identically
|
||||
// and the `?v={updatedAtMs}` suffix busts the browser cache on
|
||||
// a re-upload. Null when the user has not uploaded an avatar
|
||||
// yet; clients paint the username-initials fallback.
|
||||
avatarUrl: user.avatarUpdatedAt
|
||||
? buildAvatarUrl(user.id, user.avatarUpdatedAt)
|
||||
: null,
|
||||
glucoseUnit: user.glucoseUnit ?? null,
|
||||
lastReportPracticeName: user.lastReportPracticeName ?? null,
|
||||
// v1.4.47 W3 — per-user Coach opt-out. Default `false` if the
|
||||
|
||||
@@ -11,6 +11,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
@@ -120,7 +121,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
// Create the issue
|
||||
const issueRes = await fetch(
|
||||
const issueRes = await safeFetch(
|
||||
`https://api.github.com/repos/${ghRepo}/issues`,
|
||||
{
|
||||
method: "POST",
|
||||
@@ -158,7 +159,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// GitHub doesn't support base64 in comments, so we note the limitation
|
||||
const commentBody = `### Screenshot\n\n}...)\n\n*Note: The full screenshot was truncated due to size limits. Please contact the user directly.*`;
|
||||
|
||||
await fetch(
|
||||
await safeFetch(
|
||||
`https://api.github.com/repos/${ghRepo}/issues/${issue.number}/comments`,
|
||||
{
|
||||
method: "POST",
|
||||
|
||||
@@ -131,6 +131,14 @@ describe("GET /api/dashboard/summary", () => {
|
||||
expect(body.data.metrics).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ id: "weight" })]),
|
||||
);
|
||||
// v1.5.x — wire shape carries i18n keys, not translated strings.
|
||||
// The previous shape emitted "Gewicht" / "kg" / "Schritte" verbatim,
|
||||
// which injected DE into an English iOS UI.
|
||||
const weightCard = body.data.metrics.find((m) => m.id === "weight") as
|
||||
| { titleKey: string; unitKey: string }
|
||||
| undefined;
|
||||
expect(weightCard?.titleKey).toBe("dashboard.metric.title.weight");
|
||||
expect(weightCard?.unitKey).toBe("dashboard.metric.unit.weight");
|
||||
expect(
|
||||
body.data.metrics.find((m) => m.id === "bp"),
|
||||
"bp must not emit when allTimeCount === 0 (REG-11)",
|
||||
@@ -536,6 +544,10 @@ describe("GET /api/dashboard/summary", () => {
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "med-ramipril",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
schedules: [
|
||||
{
|
||||
id: "sched-morgens",
|
||||
@@ -543,6 +555,10 @@ describe("GET /api/dashboard/summary", () => {
|
||||
windowStart: "07:00",
|
||||
windowEnd: "09:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: [],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -624,6 +640,10 @@ describe("GET /api/dashboard/summary", () => {
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "med-ramipril",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
schedules: [
|
||||
{
|
||||
id: "sched-morgens",
|
||||
@@ -631,6 +651,10 @@ describe("GET /api/dashboard/summary", () => {
|
||||
windowStart: "07:00",
|
||||
windowEnd: "09:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: [],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -53,7 +53,7 @@ import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { defaultLocale, type Locale } from "@/lib/i18n/config";
|
||||
import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
|
||||
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes";
|
||||
@@ -76,10 +76,26 @@ type MetricKind =
|
||||
interface MetricCard {
|
||||
id: string;
|
||||
kind: MetricKind;
|
||||
title: string;
|
||||
/**
|
||||
* v1.5.x — i18n key for the metric's display title (e.g.
|
||||
* `dashboard.metric.title.weight`). Clients resolve the key against
|
||||
* their own locale bundle: the PWA via `useTranslations()`, the iOS
|
||||
* client via its bundled `Localizable.xcstrings`. The previous wire
|
||||
* shape carried a German string literal which injected DE into an
|
||||
* English-locale iOS UI.
|
||||
*/
|
||||
titleKey: string;
|
||||
latestValue: number | null;
|
||||
secondaryValue: number | null;
|
||||
unit: string;
|
||||
/**
|
||||
* v1.5.x — i18n key for the metric's display unit (e.g.
|
||||
* `dashboard.metric.unit.weight`). Same resolution contract as
|
||||
* `titleKey`. SI tokens like `kg`, `mmHg`, `bpm`, `mg/dL` are
|
||||
* identical across every shipped locale; per-kind keys keep the
|
||||
* door open for locale-specific overrides (e.g. Imperial units)
|
||||
* without another wire-shape change.
|
||||
*/
|
||||
unitKey: string;
|
||||
trend: "up" | "down" | "flat" | "unknown";
|
||||
sparkline: number[];
|
||||
updatedAt: string | null;
|
||||
@@ -96,37 +112,50 @@ interface MetricCard {
|
||||
* v1.4.33 maintainer-item-1 — ISO timestamp of the metric's single
|
||||
* most recent reading. When `allTimeCount > 0` but the latest
|
||||
* reading is older than 7 days, the iOS tile renders a muted
|
||||
* "Letzter Wert vor Xd" caption so the user understands the value
|
||||
* isn't stale silently. `null` when the metric has no readings at
|
||||
* all.
|
||||
* "last reading N days ago" caption so the user understands the
|
||||
* value isn't stale silently. `null` when the metric has no readings
|
||||
* at all.
|
||||
*/
|
||||
lastSeenAt: string | null;
|
||||
}
|
||||
|
||||
const METRIC_TITLES: Record<MetricKind, string> = {
|
||||
weight: "Gewicht",
|
||||
bloodPressure: "Blutdruck",
|
||||
pulse: "Puls",
|
||||
bodyFat: "Körperfett",
|
||||
glucose: "Blutzucker",
|
||||
sleep: "Schlaf",
|
||||
steps: "Schritte",
|
||||
totalBodyWater: "Gesamtkörperwasser",
|
||||
boneMass: "Knochenmasse",
|
||||
oxygenSaturation: "Sauerstoffsättigung",
|
||||
/**
|
||||
* v1.5.x — wire i18n keys, not translated strings.
|
||||
*
|
||||
* The legacy maps emitted German literals like "Gewicht" / "Schritte"
|
||||
* which injected into an English iOS UI once the iOS app flipped its
|
||||
* source language. Clients now resolve these keys against their own
|
||||
* locale bundle (the PWA via `useTranslations()`, iOS via
|
||||
* `Localizable.xcstrings`). The server payload becomes language-neutral
|
||||
* and the response shrinks slightly.
|
||||
*
|
||||
* One key per `MetricKind` — symmetric maps so a new kind has to add
|
||||
* both a title and a unit key (Type-checked: `Record<MetricKind, string>`).
|
||||
*/
|
||||
const METRIC_TITLE_KEYS: Record<MetricKind, string> = {
|
||||
weight: "dashboard.metric.title.weight",
|
||||
bloodPressure: "dashboard.metric.title.bloodPressure",
|
||||
pulse: "dashboard.metric.title.pulse",
|
||||
bodyFat: "dashboard.metric.title.bodyFat",
|
||||
glucose: "dashboard.metric.title.glucose",
|
||||
sleep: "dashboard.metric.title.sleep",
|
||||
steps: "dashboard.metric.title.steps",
|
||||
totalBodyWater: "dashboard.metric.title.totalBodyWater",
|
||||
boneMass: "dashboard.metric.title.boneMass",
|
||||
oxygenSaturation: "dashboard.metric.title.oxygenSaturation",
|
||||
};
|
||||
|
||||
const METRIC_UNITS: Record<MetricKind, string> = {
|
||||
weight: "kg",
|
||||
bloodPressure: "mmHg",
|
||||
pulse: "bpm",
|
||||
bodyFat: "%",
|
||||
glucose: "mg/dL",
|
||||
sleep: "h",
|
||||
steps: "Schritte",
|
||||
totalBodyWater: "kg",
|
||||
boneMass: "kg",
|
||||
oxygenSaturation: "%",
|
||||
const METRIC_UNIT_KEYS: Record<MetricKind, string> = {
|
||||
weight: "dashboard.metric.unit.weight",
|
||||
bloodPressure: "dashboard.metric.unit.bloodPressure",
|
||||
pulse: "dashboard.metric.unit.pulse",
|
||||
bodyFat: "dashboard.metric.unit.bodyFat",
|
||||
glucose: "dashboard.metric.unit.glucose",
|
||||
sleep: "dashboard.metric.unit.sleep",
|
||||
steps: "dashboard.metric.unit.steps",
|
||||
totalBodyWater: "dashboard.metric.unit.totalBodyWater",
|
||||
boneMass: "dashboard.metric.unit.boneMass",
|
||||
oxygenSaturation: "dashboard.metric.unit.oxygenSaturation",
|
||||
};
|
||||
|
||||
function trendOf(values: number[]): MetricCard["trend"] {
|
||||
@@ -287,8 +316,17 @@ function buildContext(
|
||||
user: { displayName: string | null; username: string; locale: string | null },
|
||||
): SummaryBuilderContext {
|
||||
const greetingName = user.displayName ?? user.username;
|
||||
const locale: Locale =
|
||||
user.locale === "de" || user.locale === "en" ? user.locale : defaultLocale;
|
||||
// v1.5.x — accept every shipped locale (de / en / es / fr / it / pl)
|
||||
// so the greeting + streak label resolve against the user's
|
||||
// configured language. The legacy narrowing dropped fr/es/it/pl back
|
||||
// to the default; that bug is masked for the title/unit fields by
|
||||
// the new key-based wire shape but still mattered for the strings
|
||||
// that stay translated server-side.
|
||||
const locale: Locale = (locales as readonly string[]).includes(
|
||||
user.locale ?? "",
|
||||
)
|
||||
? (user.locale as Locale)
|
||||
: defaultLocale;
|
||||
return { greetingName, locale };
|
||||
}
|
||||
|
||||
@@ -568,10 +606,10 @@ async function buildDashboardSummary(
|
||||
metrics.push({
|
||||
id: "weight",
|
||||
kind: "weight",
|
||||
title: METRIC_TITLES.weight,
|
||||
titleKey: METRIC_TITLE_KEYS.weight,
|
||||
latestValue: latest?.value ?? null,
|
||||
secondaryValue: null,
|
||||
unit: METRIC_UNITS.weight,
|
||||
unitKey: METRIC_UNIT_KEYS.weight,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt,
|
||||
@@ -606,10 +644,10 @@ async function buildDashboardSummary(
|
||||
metrics.push({
|
||||
id: "bp",
|
||||
kind: "bloodPressure",
|
||||
title: METRIC_TITLES.bloodPressure,
|
||||
titleKey: METRIC_TITLE_KEYS.bloodPressure,
|
||||
latestValue: latestSys?.value ?? null,
|
||||
secondaryValue: latestDia?.value ?? null,
|
||||
unit: METRIC_UNITS.bloodPressure,
|
||||
unitKey: METRIC_UNIT_KEYS.bloodPressure,
|
||||
trend: trendOf(sysSpark),
|
||||
sparkline: sysSpark,
|
||||
updatedAt:
|
||||
@@ -633,10 +671,10 @@ async function buildDashboardSummary(
|
||||
metrics.push({
|
||||
id: "pulse",
|
||||
kind: "pulse",
|
||||
title: METRIC_TITLES.pulse,
|
||||
titleKey: METRIC_TITLE_KEYS.pulse,
|
||||
latestValue: latest?.value ?? null,
|
||||
secondaryValue: null,
|
||||
unit: METRIC_UNITS.pulse,
|
||||
unitKey: METRIC_UNIT_KEYS.pulse,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt,
|
||||
@@ -658,10 +696,10 @@ async function buildDashboardSummary(
|
||||
metrics.push({
|
||||
id: "bodyFat",
|
||||
kind: "bodyFat",
|
||||
title: METRIC_TITLES.bodyFat,
|
||||
titleKey: METRIC_TITLE_KEYS.bodyFat,
|
||||
latestValue: latest?.value ?? null,
|
||||
secondaryValue: null,
|
||||
unit: METRIC_UNITS.bodyFat,
|
||||
unitKey: METRIC_UNIT_KEYS.bodyFat,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latest?.at.toISOString() ?? meta.lastSeenAt,
|
||||
@@ -692,10 +730,10 @@ async function buildDashboardSummary(
|
||||
metrics.push({
|
||||
id: kind,
|
||||
kind,
|
||||
title: METRIC_TITLES[kind],
|
||||
titleKey: METRIC_TITLE_KEYS[kind],
|
||||
latestValue: latest?.value ?? null,
|
||||
secondaryValue: null,
|
||||
unit: METRIC_UNITS[kind],
|
||||
unitKey: METRIC_UNIT_KEYS[kind],
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latest?.at.toISOString() ?? meta.lastSeenAt,
|
||||
|
||||
@@ -63,6 +63,12 @@ const NOT_COACH_OWNED_ROUTES: ReadonlyArray<string> = [
|
||||
// ability to dispose of stale recommendations.
|
||||
"src/app/api/insights/feedback/route.ts",
|
||||
"src/app/api/insights/glp1-timeline/route.ts",
|
||||
// v1.5.5 — per-user tile layout for the `/insights` surface. The
|
||||
// endpoint persists tile visibility + ordering only; it carries no
|
||||
// assistant prose and is the mirror of `/api/dashboard/widgets`,
|
||||
// which also sits outside the Coach gate. Disabling Coach must not
|
||||
// wedge the user's ability to reorder the insights tile strip.
|
||||
"src/app/api/insights/layout/route.ts",
|
||||
"src/app/api/insights/targets/route.ts",
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* v1.5.5 — `/api/insights/layout` route.
|
||||
*
|
||||
* Mirrors the dashboard-widgets test deck so the two surfaces share
|
||||
* the same contract guarantees: multi-issue 422 envelopes, redacted
|
||||
* wide-event payload diagnostics, audit-row dedup, GET returns the
|
||||
* default layout for users who have not saved one, PUT round-trips
|
||||
* through the resolver, DELETE returns the canonical default.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
},
|
||||
toJson: (v: unknown) => v,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
getSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({
|
||||
emitIfSampled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/logging/context")>();
|
||||
return {
|
||||
...actual,
|
||||
annotate: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, PUT, DELETE } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
import { __resetAuditDedupMemoForTests } from "@/lib/audit-dedup";
|
||||
import { DEFAULT_INSIGHTS_LAYOUT } from "@/lib/insights-layout";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "tester",
|
||||
role: "USER" as const,
|
||||
displayName: null,
|
||||
},
|
||||
};
|
||||
|
||||
const callGet = GET as unknown as () => Promise<Response>;
|
||||
const callPut = PUT as unknown as (req: NextRequest) => Promise<Response>;
|
||||
const callDelete = DELETE as unknown as () => Promise<Response>;
|
||||
|
||||
function makeReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/insights/layout", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
__resetAllCachesForTests();
|
||||
__resetAuditDedupMemoForTests();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
describe("GET /api/insights/layout", () => {
|
||||
it("returns the default layout when the user has no saved row", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
insightsLayoutJson: null,
|
||||
} as never);
|
||||
|
||||
const res = await callGet();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: typeof DEFAULT_INSIGHTS_LAYOUT;
|
||||
};
|
||||
expect(body.data.version).toBe(1);
|
||||
expect(body.data.tiles.length).toBe(DEFAULT_INSIGHTS_LAYOUT.tiles.length);
|
||||
expect(body.data.tiles[0]?.id).toBe(DEFAULT_INSIGHTS_LAYOUT.tiles[0]?.id);
|
||||
});
|
||||
|
||||
it("does not lazy-write a row on GET", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
insightsLayoutJson: null,
|
||||
} as never);
|
||||
|
||||
await callGet();
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the saved layout when one exists, merged through the resolver", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
insightsLayoutJson: {
|
||||
version: 1,
|
||||
tiles: [
|
||||
{ id: "overview", visible: false, order: 0 },
|
||||
{ id: "blutdruck", visible: true, order: 1 },
|
||||
],
|
||||
},
|
||||
} as never);
|
||||
|
||||
const res = await callGet();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: typeof DEFAULT_INSIGHTS_LAYOUT;
|
||||
};
|
||||
// Saved tiles surface in saved order; the resolver appends every
|
||||
// missing tile invisibly so a new release introducing a tile id
|
||||
// doesn't break a returning user's GET.
|
||||
expect(body.data.tiles[0]).toEqual({
|
||||
id: "overview",
|
||||
visible: false,
|
||||
order: 0,
|
||||
});
|
||||
expect(body.data.tiles[1]).toEqual({
|
||||
id: "blutdruck",
|
||||
visible: true,
|
||||
order: 1,
|
||||
});
|
||||
expect(body.data.tiles.length).toBe(DEFAULT_INSIGHTS_LAYOUT.tiles.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/insights/layout — happy path", () => {
|
||||
it("persists a normalised layout and returns it verbatim", async () => {
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const payload = {
|
||||
version: 1,
|
||||
tiles: [
|
||||
{ id: "overview", visible: true, order: 0 },
|
||||
{ id: "blutdruck", visible: true, order: 1 },
|
||||
{ id: "puls", visible: false, order: 2 },
|
||||
],
|
||||
};
|
||||
const res = await callPut(makeReq(payload));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { tiles: Array<{ id: string; visible: boolean; order: number }> };
|
||||
};
|
||||
expect(body.data.tiles[0]?.id).toBe("overview");
|
||||
expect(body.data.tiles[0]?.order).toBe(0);
|
||||
expect(body.data.tiles[2]?.id).toBe("puls");
|
||||
expect(body.data.tiles[2]?.visible).toBe(false);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
const updateCall = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as {
|
||||
where: { id: string };
|
||||
data: { insightsLayoutJson: unknown };
|
||||
};
|
||||
expect(updateCall.where.id).toBe("user-1");
|
||||
expect(updateCall.data.insightsLayoutJson).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/insights/layout — 422 multi-issue envelope", () => {
|
||||
it("surfaces TWO simultaneous validation errors under details.issues", async () => {
|
||||
const res = await callPut(makeReq({ version: 2, tiles: [] }));
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBe(2);
|
||||
const paths = body.details.issues.map((i) => i.path).sort();
|
||||
expect(paths).toEqual(["tiles", "version"]);
|
||||
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an unknown tile id", async () => {
|
||||
const res = await callPut(
|
||||
makeReq({
|
||||
version: 1,
|
||||
tiles: [{ id: "not-a-real-tile", visible: true, order: 0 }],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<{ path: string }> };
|
||||
};
|
||||
expect(
|
||||
body.details.issues.some((i) => i.path.startsWith("tiles")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("writes one audit-ledger row keyed insights.layout.validation-failed", async () => {
|
||||
const res = await callPut(makeReq({ version: 2, tiles: [] }));
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
|
||||
data: { userId: string; action: string; details: string };
|
||||
};
|
||||
expect(call.data.userId).toBe("user-1");
|
||||
expect(call.data.action).toBe("insights.layout.validation-failed");
|
||||
const details = JSON.parse(call.data.details) as {
|
||||
issues: Array<{ path: string; code: string }>;
|
||||
};
|
||||
expect(details.issues.length).toBe(2);
|
||||
for (const issue of details.issues) {
|
||||
// The persisted row strips `message` so a future Zod code that
|
||||
// echoes the offending value cannot leak into the audit
|
||||
// surface.
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("dedups the audit-ledger write across two sequential 422s for the same user", async () => {
|
||||
const res1 = await callPut(makeReq({ version: 2, tiles: [] }));
|
||||
expect(res1.status).toBe(422);
|
||||
const res2 = await callPut(makeReq({ version: 2, tiles: [] }));
|
||||
expect(res2.status).toBe(422);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The full multi-issue envelope still rides on every 422 — the
|
||||
// dedup only suppresses the breadcrumb write, never the response.
|
||||
const body2 = (await res2.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body2.details.issues.length).toBe(2);
|
||||
});
|
||||
|
||||
it("surfaces received_keys + received_shape_excerpt + zod_issues in the wide-event meta", async () => {
|
||||
const payload = { version: 2, tiles: [], extraGarbage: "from-ios" };
|
||||
const res = await callPut(makeReq(payload));
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
const annotated = vi.mocked(annotate).mock.calls.find(
|
||||
(call) =>
|
||||
(call[0] as { action?: { name?: string } })?.action?.name ===
|
||||
"insights.layout.validation-failed",
|
||||
);
|
||||
expect(annotated, "validation-failed annotate call").toBeTruthy();
|
||||
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
|
||||
|
||||
expect(meta.received_keys).toEqual(
|
||||
expect.arrayContaining(["version", "tiles", "extraGarbage"]),
|
||||
);
|
||||
|
||||
expect(typeof meta.received_shape_excerpt).toBe("string");
|
||||
expect((meta.received_shape_excerpt as string).length).toBeLessThanOrEqual(
|
||||
256,
|
||||
);
|
||||
|
||||
const issues = meta.zod_issues as Array<{ path: string; code: string }>;
|
||||
expect(Array.isArray(issues)).toBe(true);
|
||||
expect(issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("redacts sensitive keys before writing the wide-event received_shape_excerpt", async () => {
|
||||
const payload = {
|
||||
version: 2,
|
||||
tiles: [],
|
||||
apnsToken: "ff".repeat(32),
|
||||
authorization: "Bearer leaked",
|
||||
payload: { apiKey: "sk_live_xxx" },
|
||||
};
|
||||
const res = await callPut(makeReq(payload));
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
const annotated = vi.mocked(annotate).mock.calls.find(
|
||||
(call) =>
|
||||
(call[0] as { action?: { name?: string } })?.action?.name ===
|
||||
"insights.layout.validation-failed",
|
||||
);
|
||||
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
|
||||
const excerpt = meta.received_shape_excerpt as string;
|
||||
|
||||
expect(excerpt).not.toContain("ff".repeat(32));
|
||||
expect(excerpt).not.toContain("Bearer leaked");
|
||||
expect(excerpt).not.toContain("sk_live_xxx");
|
||||
expect(excerpt).toContain("[redacted]");
|
||||
expect(meta.received_keys).toEqual(
|
||||
expect.arrayContaining([
|
||||
"version",
|
||||
"tiles",
|
||||
"apnsToken",
|
||||
"authorization",
|
||||
"payload",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not block the 422 response when the audit-row write rejects", async () => {
|
||||
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
|
||||
new Error("db down"),
|
||||
);
|
||||
|
||||
const res = await callPut(makeReq({ version: 2, tiles: [] }));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/insights/layout", () => {
|
||||
it("clears the saved row and returns the default layout", async () => {
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await callDelete();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: typeof DEFAULT_INSIGHTS_LAYOUT;
|
||||
};
|
||||
expect(body.data.tiles.length).toBe(DEFAULT_INSIGHTS_LAYOUT.tiles.length);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as {
|
||||
data: { insightsLayoutJson: unknown };
|
||||
};
|
||||
// The Prisma JsonNull sentinel writes NULL into the column.
|
||||
expect(call.data.insightsLayoutJson).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* GET / PUT / DELETE insights tile layout.
|
||||
*
|
||||
* GET returns the resolved effective layout (defaults merged in if the
|
||||
* user hasn't customised yet). PUT replaces the layout atomically.
|
||||
* DELETE resets to default.
|
||||
*
|
||||
* Mirrors the shape and semantics of `/api/dashboard/widgets` so the
|
||||
* iOS client + the Settings UI can persist insights tile order +
|
||||
* visibility through the same contract. The two surfaces stay separate
|
||||
* columns on `User` (`dashboard_widgets_json` / `insights_layout_json`)
|
||||
* to keep each resolver's known-id set authoritative — overloading a
|
||||
* single blob with a `surface` discriminator would force every read to
|
||||
* demux first.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiSuccess,
|
||||
buildPayloadDiagnostic,
|
||||
safeJson,
|
||||
returnAllZodIssues,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import {
|
||||
resolveInsightsLayout,
|
||||
serializeInsightsLayout,
|
||||
DEFAULT_INSIGHTS_LAYOUT,
|
||||
INSIGHTS_TILE_IDS,
|
||||
type InsightsLayout,
|
||||
} from "@/lib/insights-layout";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { z } from "zod/v4";
|
||||
import { invalidateUserInsightsLayout } from "@/lib/cache/invalidate";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { redactSensitiveFields } from "@/lib/observability/redact-payload";
|
||||
import { shouldEmitAuditRow } from "@/lib/audit-dedup";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
// Derive the enum from `INSIGHTS_TILE_IDS` so the schema + the default
|
||||
// layout cannot drift — the same root-cause class that produced the
|
||||
// v1.4.16 A5 silent-422 on dashboard widgets when `achievements` was
|
||||
// added to the layout but not to the validation enum.
|
||||
const tileIdEnum = z.enum(INSIGHTS_TILE_IDS);
|
||||
|
||||
const layoutSchema = z.object({
|
||||
version: z.literal(1),
|
||||
tiles: z
|
||||
.array(
|
||||
z.object({
|
||||
id: tileIdEnum,
|
||||
visible: z.boolean(),
|
||||
order: z.number().int().min(0).max(99),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(50),
|
||||
});
|
||||
|
||||
async function buildInsightsLayout(userId: string): Promise<InsightsLayout> {
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { insightsLayoutJson: true },
|
||||
});
|
||||
return resolveInsightsLayout(row?.insightsLayoutJson);
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// 5-minute TTL matches the dashboard-widgets cache; the layout
|
||||
// changes only when the user hits the Settings save button, which
|
||||
// invalidates this cache via `invalidateUserInsightsLayout()`.
|
||||
const layout = await cached(
|
||||
caches.insightsLayout as ServerCache<InsightsLayout>,
|
||||
user.id,
|
||||
() => buildInsightsLayout(user.id),
|
||||
annotate,
|
||||
);
|
||||
return apiSuccess(layout);
|
||||
});
|
||||
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = layoutSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
// Multi-issue 422 envelope matches the dashboard-widgets route so
|
||||
// iOS contract debugging surfaces every wrong field in one
|
||||
// round-trip; the wide-event line carries the redacted payload
|
||||
// shape for operator debugging without leaking the raw body.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
const payloadDiagnostic = buildPayloadDiagnostic(redactSensitiveFields(body));
|
||||
annotate({
|
||||
action: { name: "insights.layout.validation-failed" },
|
||||
meta: {
|
||||
issue_count: issues.length,
|
||||
...payloadDiagnostic,
|
||||
zod_issues: issues,
|
||||
},
|
||||
});
|
||||
if (
|
||||
shouldEmitAuditRow(
|
||||
user.id,
|
||||
"insights.layout.validation-failed",
|
||||
Date.now(),
|
||||
)
|
||||
) {
|
||||
// Best-effort breadcrumb — never block the 422 on a write miss.
|
||||
// `message` strips from the audit row so a future Zod code that
|
||||
// embeds the offending value cannot leak through the audit
|
||||
// surface (the wide-event excerpt above already carries the
|
||||
// shape signal for debugging).
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "insights.layout.validation-failed",
|
||||
details: JSON.stringify({ issues: auditIssues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — validation response is the contract, audit row is best-effort */
|
||||
});
|
||||
}
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const normalized = serializeInsightsLayout(parsed.data);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
insightsLayoutJson: toJson(normalized),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.layout.update" },
|
||||
meta: { visible_count: normalized.tiles.filter((t) => t.visible).length },
|
||||
});
|
||||
|
||||
invalidateUserInsightsLayout(user.id);
|
||||
|
||||
return apiSuccess(normalized);
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { insightsLayoutJson: Prisma.JsonNull },
|
||||
});
|
||||
|
||||
annotate({ action: { name: "insights.layout.reset" } });
|
||||
|
||||
invalidateUserInsightsLayout(user.id);
|
||||
|
||||
return apiSuccess(DEFAULT_INSIGHTS_LAYOUT);
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
import { safeFetch, SafeFetchError } from "@/lib/safe-fetch";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -110,20 +111,23 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
probeUrl.searchParams.set("from", yesterday);
|
||||
probeUrl.searchParams.set("to", today);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
const res = await fetch(probeUrl.toString(), {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
// `manual` neutralises the redirect-follow SSRF where a public host
|
||||
// serves a 302 to 169.254.169.254 (cloud metadata) or RFC1918 ranges.
|
||||
redirect: "manual",
|
||||
});
|
||||
// safeFetch keeps the manual-redirect guard (the apiKey would
|
||||
// otherwise leak on a 302 to 169.254.169.254 / RFC1918) and adds
|
||||
// the AbortSignal.timeout convention. requirePublicHost layers in
|
||||
// the connect-time IP pin so a DNS rebinding cannot flip the host
|
||||
// between accept and dispatch (issue #217).
|
||||
const res = await safeFetch(
|
||||
probeUrl.toString(),
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
cache: "no-store",
|
||||
},
|
||||
{ timeoutMs: TIMEOUT_MS, requirePublicHost: true },
|
||||
);
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
|
||||
// 3xx in `manual` redirect mode → we never followed; treat as success
|
||||
@@ -155,7 +159,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
const isAbort = err.name === "AbortError";
|
||||
const isAbort =
|
||||
(e instanceof SafeFetchError && e.kind === "timeout") ||
|
||||
err.name === "AbortError";
|
||||
const code = isAbort ? "timeout" : "connection_failed";
|
||||
annotate({
|
||||
meta: {
|
||||
@@ -168,7 +174,5 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
502,
|
||||
{ errorCode: code },
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getValidToken } from "@/lib/withings/sync";
|
||||
import { safeFetch, SafeFetchError } from "@/lib/safe-fetch";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -95,21 +96,21 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
lastupdate: Math.floor(Date.now() / 1000 - 60).toString(),
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
const res = await fetch(WITHINGS_MEASURE_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Bearer ${tokenInfo.accessToken}`,
|
||||
const res = await safeFetch(
|
||||
WITHINGS_MEASURE_URL,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Bearer ${tokenInfo.accessToken}`,
|
||||
},
|
||||
body: params.toString(),
|
||||
},
|
||||
body: params.toString(),
|
||||
signal: controller.signal,
|
||||
redirect: "manual",
|
||||
});
|
||||
{ timeoutMs: TIMEOUT_MS },
|
||||
);
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -156,7 +157,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
const isAbort = err.name === "AbortError";
|
||||
// safeFetch wraps timeouts into SafeFetchError{kind:"timeout"};
|
||||
// legacy AbortError name is preserved for compatibility with any
|
||||
// future direct-fetch path that still reaches this catch.
|
||||
const isAbort =
|
||||
(e instanceof SafeFetchError && e.kind === "timeout") ||
|
||||
err.name === "AbortError";
|
||||
const code = isAbort ? "timeout" : "connection_failed";
|
||||
annotate({
|
||||
meta: {
|
||||
@@ -169,8 +175,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
502,
|
||||
{ errorCode: code },
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ describe("PUT /api/measurements/[id] — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
putReq({
|
||||
value: "string",
|
||||
measuredAt: "not-iso",
|
||||
notes: "a".repeat(200),
|
||||
notes: "a".repeat(201),
|
||||
}),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
|
||||
@@ -116,6 +116,38 @@ describe("GET /api/measurements/series", () => {
|
||||
};
|
||||
expect(body.data.points[0].secondary).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts the ten-year window (days=3650 — iOS 'Alle'-range)", async () => {
|
||||
// v1.5.5 — the previous 365-day cap rejected the iOS app's
|
||||
// "Alle"-range request with a 422, painting an error banner on
|
||||
// every metric tile. Ten years matches the recurrence engine's
|
||||
// hard cap and the medication course-window upper bound.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
const res = await GET(req("kind=weight&days=3650"));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects a days value above the ten-year cap", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await GET(req("kind=weight&days=3651"));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it.each(["restingHeartRate", "heartRateVariability", "vo2Max"])(
|
||||
"accepts kind=%s (v1.5.5)",
|
||||
async (kind) => {
|
||||
// v1.5.5 — the iOS app surfaces these as series-capable; the
|
||||
// previous enum rejected them with 422 even though the
|
||||
// underlying MeasurementType already carried the values.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
const res = await GET(req(`kind=${kind}&days=30`));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { kind: string } };
|
||||
expect(body.data.kind).toBe(kind);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("GET /api/measurements/series — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
|
||||
@@ -34,11 +34,25 @@ const kindEnum = z.enum([
|
||||
"totalBodyWater",
|
||||
"boneMass",
|
||||
"oxygenSaturation",
|
||||
// v1.5.5 — the iOS app surfaces these as series-capable, but
|
||||
// the route used to reject the trend/detail view request with a
|
||||
// 422. The underlying MeasurementType enum already carries each
|
||||
// value; this list extends the camelCase wire shape so the route
|
||||
// matches the data it can serve.
|
||||
"restingHeartRate",
|
||||
"heartRateVariability",
|
||||
"vo2Max",
|
||||
]);
|
||||
|
||||
const querySchema = z.object({
|
||||
kind: kindEnum,
|
||||
days: z.coerce.number().int().min(1).max(365).optional().default(30),
|
||||
// v1.5.5 — the 365-day cap rejected the iOS app's "Alle"-range
|
||||
// request (days = 3650) with a 422, painting an error banner on
|
||||
// every metric tile. Ten years matches the recurrence engine's
|
||||
// nextOccurrenceAfter hard cap and the medication course-window
|
||||
// upper bound, so the ceiling stays consistent across the surface
|
||||
// area that the user can wire to a "show me everything" intent.
|
||||
days: z.coerce.number().int().min(1).max(3650).optional().default(30),
|
||||
});
|
||||
|
||||
const KIND_TO_TYPE: Record<z.infer<typeof kindEnum>, MeasurementType> = {
|
||||
@@ -52,6 +66,9 @@ const KIND_TO_TYPE: Record<z.infer<typeof kindEnum>, MeasurementType> = {
|
||||
totalBodyWater: "TOTAL_BODY_WATER",
|
||||
boneMass: "BONE_MASS",
|
||||
oxygenSaturation: "OXYGEN_SATURATION",
|
||||
restingHeartRate: "RESTING_HEART_RATE",
|
||||
heartRateVariability: "HEART_RATE_VARIABILITY",
|
||||
vo2Max: "VO2_MAX",
|
||||
};
|
||||
|
||||
interface SeriesPoint {
|
||||
|
||||
@@ -12,6 +12,8 @@ vi.mock("@/lib/db", () => ({
|
||||
},
|
||||
medicationSchedule: {
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
apiToken: {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
@@ -173,3 +175,81 @@ describe("PUT /api/medications/[id] — v1.5 scheduling primitives", () => {
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/medications/[id] — primary-schedule grace bridge", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "m1",
|
||||
userId: "user-1",
|
||||
active: true,
|
||||
schedules: [
|
||||
{
|
||||
id: "sched-primary",
|
||||
windowStart: "08:00",
|
||||
windowEnd: "08:30",
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
vi.mocked(prisma.medication.update).mockResolvedValue({
|
||||
id: "m1",
|
||||
userId: "user-1",
|
||||
schedules: [
|
||||
{
|
||||
id: "sched-primary",
|
||||
windowStart: "08:00",
|
||||
windowEnd: "08:30",
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
vi.mocked(getMedicationCategories).mockResolvedValue({});
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.medicationSchedule.findFirst).mockResolvedValue({
|
||||
id: "sched-primary",
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationSchedule.update).mockResolvedValue({
|
||||
id: "sched-primary",
|
||||
reminderGraceMinutes: 45,
|
||||
} as never);
|
||||
});
|
||||
|
||||
it("lands a top-level reminderGraceMinutes value on the primary schedule", async () => {
|
||||
const res = await PUT(putReq({ reminderGraceMinutes: 45 }), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.medicationSchedule.findFirst).toHaveBeenCalledWith({
|
||||
where: { medicationId: "m1" },
|
||||
orderBy: { windowStart: "asc" },
|
||||
select: { id: true },
|
||||
});
|
||||
expect(prisma.medicationSchedule.update).toHaveBeenCalledWith({
|
||||
where: { id: "sched-primary" },
|
||||
data: { reminderGraceMinutes: 45 },
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the override when the user passes null", async () => {
|
||||
vi.mocked(prisma.medicationSchedule.update).mockResolvedValue({
|
||||
id: "sched-primary",
|
||||
reminderGraceMinutes: null,
|
||||
} as never);
|
||||
const res = await PUT(putReq({ reminderGraceMinutes: null }), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.medicationSchedule.update).toHaveBeenCalledWith({
|
||||
where: { id: "sched-primary" },
|
||||
data: { reminderGraceMinutes: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("skips the schedule bridge when the body carries a full schedules array", async () => {
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
reminderGraceMinutes: 45,
|
||||
schedules: [{ windowStart: "08:00", windowEnd: "08:30" }],
|
||||
}),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.medicationSchedule.findFirst).not.toHaveBeenCalled();
|
||||
expect(prisma.medicationSchedule.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,10 @@ vi.mock("@/lib/db", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/medications/route-guards", () => ({
|
||||
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
@@ -49,11 +53,12 @@ vi.mock("next/headers", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { GET, POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -77,6 +82,7 @@ beforeEach(() => {
|
||||
remaining: 29,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
vi.mocked(assertMedicationOwnership).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe("POST /api/medications/[id]/glp1", () => {
|
||||
@@ -97,10 +103,9 @@ describe("POST /api/medications/[id]/glp1", () => {
|
||||
|
||||
it("returns 404 when the medication belongs to a different user", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "med-1",
|
||||
userId: "OTHER",
|
||||
} as never);
|
||||
vi.mocked(assertMedicationOwnership).mockResolvedValueOnce(
|
||||
new Response(null, { status: 404 }) as never,
|
||||
);
|
||||
const res = await POST(
|
||||
jsonReq({
|
||||
doseChange: {
|
||||
@@ -387,3 +392,39 @@ describe("POST /api/medications/[id]/glp1 — 422 multi-issue (v1.4.43 W6)", ()
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/medications/[id]/glp1 — ownership helper (F-1 C-4)", () => {
|
||||
function getReq(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/medications/med-1/glp1");
|
||||
}
|
||||
|
||||
it("routes ownership through assertMedicationOwnership", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "med-1",
|
||||
userId: "user-1",
|
||||
doseChanges: [],
|
||||
inventoryEvents: [],
|
||||
intakeEvents: [],
|
||||
schedules: [],
|
||||
dosesPerUnit: null,
|
||||
} as never);
|
||||
const res = await GET(getReq(), {
|
||||
params: Promise.resolve({ id: "med-1" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(assertMedicationOwnership).toHaveBeenCalledWith("med-1", "user-1");
|
||||
});
|
||||
|
||||
it("returns the shared helper's 404 without reading the medication", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(assertMedicationOwnership).mockResolvedValueOnce(
|
||||
new Response(null, { status: 404 }) as never,
|
||||
);
|
||||
const res = await GET(getReq(), {
|
||||
params: Promise.resolve({ id: "med-1" }),
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
expect(prisma.medication.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,15 @@ export const GET = apiHandler(
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
|
||||
// v1.5.5 F-1 C-4 — close the §10 invariant 24 gap. The destructive
|
||||
// sweep in af224964 lifted every sibling `[id]/**` handler onto the
|
||||
// shared ownership helper; the glp1 GET still hand-rolled the
|
||||
// `findUnique + userId compare` pattern. Routing it through the
|
||||
// same predicate keeps the 404 leak shape identical across every
|
||||
// handler in the directory.
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
const medication = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
@@ -56,7 +65,7 @@ export const GET = apiHandler(
|
||||
},
|
||||
});
|
||||
|
||||
if (!medication || medication.userId !== user.id) {
|
||||
if (!medication) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* v1.5.5 — POST /api/medications/[id]/intake/bulk-delete tests.
|
||||
*
|
||||
* Pins the contract the detail-page intake-history preview's
|
||||
* multi-select Löschen depends on:
|
||||
*
|
||||
* - Asserts ownership through the shared `assertMedicationOwnership`
|
||||
* helper. Negative branch returns the helper's 404 response.
|
||||
* - Reads rows scoped by `(eventId IN, userId, medicationId)` so a
|
||||
* leaked id from another medication never deletes anything.
|
||||
* - Recomputes the rollup once per unique dayKey (not once per
|
||||
* event), so a 14-row delete spanning two days closes in two
|
||||
* SQL recompute trips.
|
||||
* - 422 on malformed body via `bulkDeleteIntakeEventsSchema`.
|
||||
* - 200 with `{ deleted: number }` carrying the real `deleteMany`
|
||||
* count so the client toast announces the truth.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
medication: { findUnique: vi.fn() },
|
||||
medicationIntakeEvent: {
|
||||
findMany: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/medications/route-guards", () => ({
|
||||
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 29,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
rateLimitHeaders: vi.fn(() => ({ "X-RateLimit-Remaining": "0" })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
dayKeyForScheduledFor: vi.fn((d: Date) => {
|
||||
const iso = d.toISOString();
|
||||
return iso.slice(0, 10);
|
||||
}),
|
||||
recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMedications: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
dayKeyForScheduledFor,
|
||||
recomputeMedicationComplianceForDay,
|
||||
} from "@/lib/rollups/medication-compliance-rollups";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
};
|
||||
|
||||
const ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) };
|
||||
|
||||
function postWithBody(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/medications/med-1/intake/bulk-delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(assertMedicationOwnership).mockResolvedValue(null);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 29,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
timezone: "Europe/Berlin",
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.deleteMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/medications/[id]/intake/bulk-delete", () => {
|
||||
it("422 on missing body", async () => {
|
||||
const res = await POST(postWithBody({}), ROUTE_PARAMS);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.medicationIntakeEvent.deleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422 on empty eventIds", async () => {
|
||||
const res = await POST(postWithBody({ eventIds: [] }), ROUTE_PARAMS);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns the 404 from the shared ownership helper", async () => {
|
||||
vi.mocked(assertMedicationOwnership).mockResolvedValueOnce(
|
||||
new Response(null, { status: 404 }) as never,
|
||||
);
|
||||
const res = await POST(
|
||||
postWithBody({ eventIds: ["evt-1"] }),
|
||||
ROUTE_PARAMS,
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns deleted:0 + skips recompute when no matching rows are found", async () => {
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce(
|
||||
[] as never,
|
||||
);
|
||||
const res = await POST(
|
||||
postWithBody({ eventIds: ["evt-leaked", "evt-other"] }),
|
||||
ROUTE_PARAMS,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.data).toEqual({ deleted: 0 });
|
||||
expect(prisma.medicationIntakeEvent.deleteMany).not.toHaveBeenCalled();
|
||||
expect(recomputeMedicationComplianceForDay).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("recomputes the rollup once per unique dayKey", async () => {
|
||||
const day1 = new Date(Date.UTC(2026, 4, 27, 8, 0, 0));
|
||||
const day2 = new Date(Date.UTC(2026, 4, 28, 8, 0, 0));
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
|
||||
{ id: "evt-a", scheduledFor: day1 },
|
||||
{ id: "evt-b", scheduledFor: day1 },
|
||||
{ id: "evt-c", scheduledFor: day2 },
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.deleteMany).mockResolvedValueOnce({
|
||||
count: 3,
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
postWithBody({ eventIds: ["evt-a", "evt-b", "evt-c"] }),
|
||||
ROUTE_PARAMS,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.data).toEqual({ deleted: 3 });
|
||||
|
||||
expect(dayKeyForScheduledFor).toHaveBeenCalledTimes(3);
|
||||
// Three rows, two unique dayKeys → two recompute calls.
|
||||
expect(recomputeMedicationComplianceForDay).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("scopes deleteMany by both userId and medicationId", async () => {
|
||||
const day1 = new Date(Date.UTC(2026, 4, 27, 8, 0, 0));
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
|
||||
{ id: "evt-a", scheduledFor: day1 },
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.deleteMany).mockResolvedValueOnce({
|
||||
count: 1,
|
||||
} as never);
|
||||
|
||||
await POST(postWithBody({ eventIds: ["evt-a"] }), ROUTE_PARAMS);
|
||||
|
||||
const deleteArg = vi.mocked(prisma.medicationIntakeEvent.deleteMany).mock
|
||||
.calls[0][0]?.where;
|
||||
expect(deleteArg).toEqual({
|
||||
id: { in: ["evt-a"] },
|
||||
userId: "user-1",
|
||||
medicationId: "med-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/medications/[id]/intake/bulk-delete — F-1 H-6 rate limit", () => {
|
||||
it("returns 429 when the per-user cap is exhausted", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
const res = await POST(
|
||||
postWithBody({ eventIds: ["evt-a"] }),
|
||||
ROUTE_PARAMS,
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.headers.get("X-RateLimit-Remaining")).toBe("0");
|
||||
expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled();
|
||||
expect(prisma.medicationIntakeEvent.deleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("scopes the bucket key to the calling user", async () => {
|
||||
await POST(postWithBody({ eventIds: ["evt-a"] }), ROUTE_PARAMS);
|
||||
expect(checkRateLimit).toHaveBeenCalledWith(
|
||||
"medication-intake-bulk-delete:user-1",
|
||||
30,
|
||||
60_000,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { bulkDeleteIntakeEventsSchema } from "@/lib/validations/medication";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import {
|
||||
dayKeyForScheduledFor,
|
||||
recomputeMedicationComplianceForDay,
|
||||
} from "@/lib/rollups/medication-compliance-rollups";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
// v1.5.5 F-1 H-6 — match the glp1 POST shape so a caller firing
|
||||
// repeated 500-id bulk deletes cannot pin the per-dayKey recompute
|
||||
// path. 30/min/user is generous for a hand-driven multi-select and
|
||||
// tight enough to cut off the spam case.
|
||||
const BULK_DELETE_RATE_LIMIT = 30;
|
||||
const BULK_DELETE_WINDOW_MS = 60_000;
|
||||
|
||||
/**
|
||||
* v1.5.5 D-3 §9.5 + §11 — bulk-delete N intake rows in one transaction
|
||||
* so the detail-page intake-history preview can offer multi-select →
|
||||
* Löschen without the user firing N concurrent DELETEs from the
|
||||
* client. The endpoint:
|
||||
*
|
||||
* - Asserts medication ownership via the shared helper (so the 404
|
||||
* leak shape stays identical across `[id]/**`).
|
||||
* - Loads only the (id, scheduledFor) for events that match the body
|
||||
* ids AND the asserted (user, medication) tuple. Ids that don't
|
||||
* match are silently dropped — the client surface is "delete these
|
||||
* rows from this medication" and an attacker-supplied id from
|
||||
* another medication never deletes anything.
|
||||
* - Runs `deleteMany` scoped by the user-and-medication predicate.
|
||||
* - Recomputes the compliance rollup once per affected dayKey (not
|
||||
* once per event), so the rollup tier converges after a 14-row
|
||||
* bulk delete in at most 14 SQL trips and usually 1-3.
|
||||
* - Audits the bulk action with the deleted count.
|
||||
*
|
||||
* Failure modes:
|
||||
*
|
||||
* - 401 unauthenticated — caught by `requireAuth`.
|
||||
* - 404 unknown / not-owned medication — `assertMedicationOwnership`.
|
||||
* - 422 malformed body — Zod via `returnAllZodIssues`.
|
||||
* - 200 with `{ deleted: number }` on success; `deleted` reports the
|
||||
* real `deleteMany` count so the client can show a precise toast.
|
||||
*/
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { id } = await params;
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
// v1.5.5 F-1 H-6 — per-user cap matches the glp1 POST so a caller
|
||||
// firing repeated 500-id bulk deletes cannot pin the per-dayKey
|
||||
// recompute path.
|
||||
const rl = await checkRateLimit(
|
||||
`medication-intake-bulk-delete:${user.id}`,
|
||||
BULK_DELETE_RATE_LIMIT,
|
||||
BULK_DELETE_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many requests", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = bulkDeleteIntakeEventsSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
const { eventIds } = parsed.data;
|
||||
|
||||
// Tz the rollups are anchored on. The detail-page caller persists
|
||||
// intakes through the same hook, so reading `user.timezone` here
|
||||
// matches the write-path's recompute call.
|
||||
const userRow = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { timezone: true },
|
||||
});
|
||||
const tz = userRow?.timezone ?? null;
|
||||
|
||||
// Read the dayKeys of every row we're about to remove so the
|
||||
// post-delete recompute knows which day-buckets need to converge.
|
||||
// Scope by (user, medication) so a leaked id from another
|
||||
// medication's history never affects this bulk action.
|
||||
const targetRows = await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
id: { in: eventIds },
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
},
|
||||
select: { id: true, scheduledFor: true },
|
||||
});
|
||||
|
||||
if (targetRows.length === 0) {
|
||||
return apiSuccess({ deleted: 0 });
|
||||
}
|
||||
|
||||
const dayKeysToRefresh = new Set<string>();
|
||||
for (const row of targetRows) {
|
||||
dayKeysToRefresh.add(dayKeyForScheduledFor(row.scheduledFor, tz));
|
||||
}
|
||||
|
||||
const { count } = await prisma.medicationIntakeEvent.deleteMany({
|
||||
where: {
|
||||
id: { in: targetRows.map((r) => r.id) },
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
},
|
||||
});
|
||||
|
||||
// Best-effort rollup refresh — one trip per unique dayKey so a
|
||||
// 14-row delete spanning two days closes in two SQL trips. The
|
||||
// helper is idempotent and the catch swallows transient
|
||||
// populator failures (same contract as the single-event delete).
|
||||
for (const dayKey of dayKeysToRefresh) {
|
||||
try {
|
||||
await recomputeMedicationComplianceForDay(user.id, id, dayKey, tz);
|
||||
} catch (err) {
|
||||
annotate({
|
||||
action: {
|
||||
name: "medications.intake.bulk-delete.recompute-failed",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: {
|
||||
day_key: dayKey,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await auditLog("medication.intake.bulk_delete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
medicationId: id,
|
||||
requestedCount: eventIds.length,
|
||||
deletedCount: count,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "medications.intake.bulk-delete",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: { deleted_count: count, day_buckets: dayKeysToRefresh.size },
|
||||
});
|
||||
|
||||
invalidateUserMedications(user.id);
|
||||
|
||||
return apiSuccess({ deleted: count });
|
||||
},
|
||||
);
|
||||
@@ -4,12 +4,12 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import {
|
||||
recomputeMedicationComplianceForDay,
|
||||
dayKeyForScheduledFor,
|
||||
@@ -32,10 +32,10 @@ export const POST = apiHandler(
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { id } = await params;
|
||||
const medication = await prisma.medication.findUnique({ where: { id } });
|
||||
if (!medication || medication.userId !== user.id) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
// v1.5.5 C-E3-3 — route ownership through the shared helper so the
|
||||
// 404 leak shape stays identical across every `[id]/**` handler.
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* v1.5.5 F-1 C-3 — purge route fires the medication cache bundle.
|
||||
*
|
||||
* Pins the contract the detail-page Verlauf-löschen cascade depends
|
||||
* on: dropping the rollup rows is necessary but not sufficient. The
|
||||
* analytics + iOS today-tally + dashboard tiles all read off cached
|
||||
* shapes that survive the rollup delete for their TTL; the bundle
|
||||
* invalidation forces every downstream reader to converge on the
|
||||
* post-purge counts in the same tick the user sees the toast.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: { findUnique: vi.fn() },
|
||||
medicationIntakeEvent: { deleteMany: vi.fn() },
|
||||
medicationComplianceRollup: { deleteMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/medications/route-guards", () => ({
|
||||
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMedications: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { DELETE } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
const ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) };
|
||||
|
||||
function deleteReq(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/medications/med-1/intake/purge", {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(assertMedicationOwnership).mockResolvedValue(null);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
name: "Test",
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.deleteMany).mockResolvedValue({
|
||||
count: 7,
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationComplianceRollup.deleteMany).mockResolvedValue({
|
||||
count: 5,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("DELETE /api/medications/[id]/intake/purge", () => {
|
||||
it("returns 200 with the deleted count", async () => {
|
||||
const res = await DELETE(deleteReq(), ROUTE_PARAMS);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.data).toEqual({ purged: true, count: 7 });
|
||||
});
|
||||
|
||||
it("invalidates the user medication caches on success (F-1 C-3)", async () => {
|
||||
await DELETE(deleteReq(), ROUTE_PARAMS);
|
||||
expect(invalidateUserMedications).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("returns the 404 from the shared ownership helper without invalidating", async () => {
|
||||
vi.mocked(assertMedicationOwnership).mockResolvedValueOnce(
|
||||
new Response(null, { status: 404 }) as never,
|
||||
);
|
||||
const res = await DELETE(deleteReq(), ROUTE_PARAMS);
|
||||
expect(res.status).toBe(404);
|
||||
expect(prisma.medicationIntakeEvent.deleteMany).not.toHaveBeenCalled();
|
||||
expect(invalidateUserMedications).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,9 @@ import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
@@ -12,10 +14,20 @@ export const DELETE = apiHandler(
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { id } = await params;
|
||||
const medication = await prisma.medication.findUnique({ where: { id } });
|
||||
if (!medication || medication.userId !== user.id) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
// v1.5.5 C-E3-3 — route purge through the same ownership predicate
|
||||
// every other `src/app/api/medications/[id]/**` handler uses. The
|
||||
// hand-rolled `findUnique + userId !== id ? 404` block returned the
|
||||
// medication row only to read its name for the audit details. The
|
||||
// audit row still wants the name; re-read it after the guard so the
|
||||
// ownership check stays the single source of truth on the 404 leak
|
||||
// shape.
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
const medication = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
select: { name: true },
|
||||
});
|
||||
|
||||
const { count } = await prisma.medicationIntakeEvent.deleteMany({
|
||||
where: { medicationId: id, userId: user.id },
|
||||
@@ -31,7 +43,11 @@ export const DELETE = apiHandler(
|
||||
await auditLog("medication.intake.purge", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { medicationId: id, name: medication.name, deletedCount: count },
|
||||
details: {
|
||||
medicationId: id,
|
||||
name: medication?.name ?? "",
|
||||
deletedCount: count,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
@@ -43,6 +59,13 @@ export const DELETE = apiHandler(
|
||||
meta: { purged_count: count },
|
||||
});
|
||||
|
||||
// v1.5.5 F-1 C-3 — drop the per-user medication caches so the
|
||||
// analytics + iOS today-tally + dashboard tiles converge on the
|
||||
// post-purge counts rather than reading stale values for the
|
||||
// TTL of each cache. Matches the sibling routes (POST intake,
|
||||
// PUT medication, bulk-delete) that all fire this bundle.
|
||||
invalidateUserMedications(user.id);
|
||||
|
||||
return apiSuccess({ purged: true, count });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* v1.5.5 F-1 H-3 — phase-config PUT now returns the multi-issue 422
|
||||
* envelope rather than the flat `400 Invalid input`.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
reminderPhaseConfig: { upsert: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/medications/route-guards", () => ({
|
||||
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { PUT } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
const ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) };
|
||||
|
||||
function putReq(body: unknown): NextRequest {
|
||||
return new NextRequest(
|
||||
"http://localhost/api/medications/med-1/phase-config",
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(assertMedicationOwnership).mockResolvedValue(null);
|
||||
vi.mocked(prisma.reminderPhaseConfig.upsert).mockResolvedValue({
|
||||
medicationId: "med-1",
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("PUT /api/medications/[id]/phase-config — F-1 H-3 multi-issue 422", () => {
|
||||
it("returns the multi-issue envelope on a malformed body", async () => {
|
||||
// Three issues: greenValue not int, greenMode not enum, redValue overflow.
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
greenValue: "not-a-number",
|
||||
greenMode: "BOGUS",
|
||||
yellowValue: 30,
|
||||
yellowMode: "MINUTES",
|
||||
orangeValue: 0,
|
||||
orangeMode: "MINUTES",
|
||||
redValue: 10_000,
|
||||
redMode: "MINUTES",
|
||||
}),
|
||||
ROUTE_PARAMS,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/medications/[id]/phase-config — F-1 H-4 field-by-field upsert", () => {
|
||||
it("builds the create + update payloads field-by-field rather than spreading parsed.data", async () => {
|
||||
const body = {
|
||||
greenValue: 60,
|
||||
greenMode: "MINUTES",
|
||||
yellowValue: 30,
|
||||
yellowMode: "MINUTES",
|
||||
orangeValue: 0,
|
||||
orangeMode: "MINUTES",
|
||||
redValue: 240,
|
||||
redMode: "MINUTES",
|
||||
};
|
||||
await PUT(putReq(body), ROUTE_PARAMS);
|
||||
|
||||
const callArg = vi.mocked(prisma.reminderPhaseConfig.upsert).mock.calls[0][0];
|
||||
// create payload contains exactly the eight enumerated phase fields
|
||||
// plus the `medicationId` route binding — nothing else.
|
||||
expect(Object.keys(callArg.create).sort()).toEqual([
|
||||
"greenMode",
|
||||
"greenValue",
|
||||
"medicationId",
|
||||
"orangeMode",
|
||||
"orangeValue",
|
||||
"redMode",
|
||||
"redValue",
|
||||
"yellowMode",
|
||||
"yellowValue",
|
||||
]);
|
||||
// update payload mirrors the eight phase fields (no `medicationId`).
|
||||
expect(Object.keys(callArg.update).sort()).toEqual([
|
||||
"greenMode",
|
||||
"greenValue",
|
||||
"orangeMode",
|
||||
"orangeValue",
|
||||
"redMode",
|
||||
"redValue",
|
||||
"yellowMode",
|
||||
"yellowValue",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores an unknown field smuggled in the body (no mass assignment)", async () => {
|
||||
const body = {
|
||||
greenValue: 60,
|
||||
greenMode: "MINUTES",
|
||||
yellowValue: 30,
|
||||
yellowMode: "MINUTES",
|
||||
orangeValue: 0,
|
||||
orangeMode: "MINUTES",
|
||||
redValue: 240,
|
||||
redMode: "MINUTES",
|
||||
// Unknown rogue field — should never reach the Prisma upsert.
|
||||
maliciousField: "owned",
|
||||
};
|
||||
await PUT(putReq(body), ROUTE_PARAMS);
|
||||
const callArg = vi.mocked(prisma.reminderPhaseConfig.upsert).mock.calls[0][0];
|
||||
expect((callArg.create as Record<string, unknown>).maliciousField).toBeUndefined();
|
||||
expect((callArg.update as Record<string, unknown>).maliciousField).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,11 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { phaseConfigSchema } from "@/lib/validations/phase-config";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { NextRequest } from "next/server";
|
||||
@@ -59,16 +63,42 @@ export const PUT = apiHandler(
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = phaseConfigSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid input", 400);
|
||||
// v1.5.5 F-1 H-3 — every v1.5.5 route returns the multi-issue
|
||||
// 422 envelope so iOS sees per-field feedback the same way the
|
||||
// bulk-delete and intake routes already do. The old `400 Invalid
|
||||
// input` flat response was the last hold-out in the surface.
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
// v1.5.5 F-1 H-4 — build the Prisma payload field-by-field rather
|
||||
// than spreading `parsed.data`. A future schema extension would
|
||||
// otherwise land on the upsert silently; the explicit pick keeps
|
||||
// the surface honest at the call site.
|
||||
const {
|
||||
greenValue,
|
||||
greenMode,
|
||||
yellowValue,
|
||||
yellowMode,
|
||||
orangeValue,
|
||||
orangeMode,
|
||||
redValue,
|
||||
redMode,
|
||||
} = parsed.data;
|
||||
const phaseFields = {
|
||||
greenValue,
|
||||
greenMode,
|
||||
yellowValue,
|
||||
yellowMode,
|
||||
orangeValue,
|
||||
orangeMode,
|
||||
redValue,
|
||||
redMode,
|
||||
};
|
||||
|
||||
const config = await prisma.reminderPhaseConfig.upsert({
|
||||
where: { medicationId: id },
|
||||
create: {
|
||||
medicationId: id,
|
||||
...parsed.data,
|
||||
},
|
||||
update: parsed.data,
|
||||
create: { medicationId: id, ...phaseFields },
|
||||
update: phaseFields,
|
||||
});
|
||||
|
||||
annotate({
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
setMedicationCategory,
|
||||
} from "@/lib/medication-category";
|
||||
import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
@@ -60,8 +61,18 @@ export const PUT = apiHandler(
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { id } = await params;
|
||||
const existing = await prisma.medication.findUnique({ where: { id } });
|
||||
if (!existing || existing.userId !== user.id) {
|
||||
// v1.5.5 C-E3-3 — route ownership check through the shared helper
|
||||
// so the 404 leak shape stays identical across every
|
||||
// `[id]/**` handler. The PUT branch still needs `existing.active`
|
||||
// for the pausedAt-derivation step below, so re-read the narrow
|
||||
// field after the guard passes.
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
const existing = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
select: { active: true },
|
||||
});
|
||||
if (!existing) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
@@ -80,14 +91,44 @@ export const PUT = apiHandler(
|
||||
category,
|
||||
treatmentClass,
|
||||
dosesPerUnit,
|
||||
deliveryForm,
|
||||
active,
|
||||
notificationsEnabled,
|
||||
schedules,
|
||||
startsOn,
|
||||
endsOn,
|
||||
oneShot,
|
||||
reminderGraceMinutes: topLevelGraceMinutes,
|
||||
} = parsed.data;
|
||||
|
||||
// ── v1.5.5 — primary-schedule grace bridge ──────────────────────
|
||||
//
|
||||
// The detail-page settings section saves the reminder-window in
|
||||
// one PUT carrying only `{ reminderGraceMinutes }` at the top
|
||||
// level (no full `schedules` array). Map it onto the medication's
|
||||
// primary schedule before the Prisma update so the persisted
|
||||
// shape stays per-schedule and the engine reads it from the same
|
||||
// column as the wizard write-path. A full `schedules` array on
|
||||
// the same request takes precedence — the wizard already declares
|
||||
// the grace per row and we never want to overwrite that intent.
|
||||
let primaryScheduleGracePatch: {
|
||||
scheduleId: string;
|
||||
reminderGraceMinutes: number | null;
|
||||
} | null = null;
|
||||
if (topLevelGraceMinutes !== undefined && !schedules) {
|
||||
const primary = await prisma.medicationSchedule.findFirst({
|
||||
where: { medicationId: id },
|
||||
orderBy: { windowStart: "asc" },
|
||||
select: { id: true },
|
||||
});
|
||||
if (primary) {
|
||||
primaryScheduleGracePatch = {
|
||||
scheduleId: primary.id,
|
||||
reminderGraceMinutes: topLevelGraceMinutes,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── v1.5 route invariants for the new scheduling primitives ─────
|
||||
//
|
||||
// Mirrors the POST route: oneShot ⇒ at-most-one schedule + no
|
||||
@@ -140,6 +181,7 @@ export const PUT = apiHandler(
|
||||
...(dose !== undefined && { dose }),
|
||||
...(treatmentClass !== undefined && { treatmentClass }),
|
||||
...(dosesPerUnit !== undefined && { dosesPerUnit }),
|
||||
...(deliveryForm !== undefined && { deliveryForm }),
|
||||
...(active !== undefined && { active }),
|
||||
...(notificationsEnabled !== undefined && { notificationsEnabled }),
|
||||
// v1.5 scheduling primitives — pass-through when supplied.
|
||||
@@ -228,6 +270,27 @@ export const PUT = apiHandler(
|
||||
}
|
||||
if (!medication) throw lastUpdateErr;
|
||||
|
||||
// Apply the v1.5.5 primary-schedule grace bridge after the
|
||||
// medication update so a failure here doesn't roll the medication
|
||||
// edit back. Re-read the row so the response shape carries the
|
||||
// converged grace value.
|
||||
if (primaryScheduleGracePatch) {
|
||||
await prisma.medicationSchedule.update({
|
||||
where: { id: primaryScheduleGracePatch.scheduleId },
|
||||
data: {
|
||||
reminderGraceMinutes:
|
||||
primaryScheduleGracePatch.reminderGraceMinutes,
|
||||
},
|
||||
});
|
||||
const refreshed = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
include: { schedules: true },
|
||||
});
|
||||
if (refreshed) {
|
||||
medication = refreshed;
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedCategory =
|
||||
category !== undefined
|
||||
? await setMedicationCategory(id, category)
|
||||
@@ -263,8 +326,16 @@ export const DELETE = apiHandler(
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { id } = await params;
|
||||
const existing = await prisma.medication.findUnique({ where: { id } });
|
||||
if (!existing || existing.userId !== user.id) {
|
||||
// v1.5.5 C-E3-3 — ownership via the shared helper. The DELETE
|
||||
// branch needs the medication name for the audit-row detail; pull
|
||||
// it after the guard passes.
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
const existing = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
select: { name: true },
|
||||
});
|
||||
if (!existing) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
|
||||
@@ -162,6 +162,10 @@ describe("GET /api/medications/intake", () => {
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "med-ramipril",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
schedules: [
|
||||
{
|
||||
id: "sched-morgens",
|
||||
@@ -169,6 +173,10 @@ describe("GET /api/medications/intake", () => {
|
||||
windowStart: "07:00",
|
||||
windowEnd: "09:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: [],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -237,6 +245,10 @@ describe("GET /api/medications/intake", () => {
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "med-ramipril",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
schedules: [
|
||||
{
|
||||
id: "sched-morgens",
|
||||
@@ -244,6 +256,10 @@ describe("GET /api/medications/intake", () => {
|
||||
windowStart: "07:00",
|
||||
windowEnd: "09:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: [],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -121,6 +121,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
category,
|
||||
treatmentClass,
|
||||
dosesPerUnit,
|
||||
deliveryForm,
|
||||
notificationsEnabled,
|
||||
schedules,
|
||||
startsOn,
|
||||
@@ -181,6 +182,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// wire schema; Prisma fills the default GENERIC when omitted.
|
||||
...(treatmentClass !== undefined && { treatmentClass }),
|
||||
...(dosesPerUnit !== undefined && { dosesPerUnit }),
|
||||
// v1.6.0 — route of administration; Prisma defaults to ORAL when omitted.
|
||||
...(deliveryForm !== undefined && { deliveryForm }),
|
||||
// v1.5 — wizard's reminders toggle now ships through the create
|
||||
// payload (was orphaned in the initial diff). Prisma defaults to
|
||||
// true when omitted, matching the legacy form's behaviour.
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -25,9 +26,16 @@ export const GET = apiHandler(async () => {
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch(settings.umamiScriptUrl, {
|
||||
next: { revalidate: 3600 },
|
||||
});
|
||||
const response = await safeFetch(
|
||||
settings.umamiScriptUrl,
|
||||
{
|
||||
next: { revalidate: 3600 },
|
||||
},
|
||||
// Operator-configured host — pin the connect-time IP so a low-TTL
|
||||
// DNS record cannot rebind the fetch at an internal range between
|
||||
// the input-time `isPublicUrl` accept and the socket connect.
|
||||
{ requirePublicHost: true },
|
||||
);
|
||||
if (!response.ok) {
|
||||
return new NextResponse(NOOP_SCRIPT, {
|
||||
status: 200,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -73,16 +74,21 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
let lastResponseStatus = 404;
|
||||
|
||||
for (const targetUrl of targetUrls) {
|
||||
const upstream = await fetch(targetUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type":
|
||||
request.headers.get("content-type") || "application/json",
|
||||
"user-agent": request.headers.get("user-agent") || "healthlog-proxy",
|
||||
const upstream = await safeFetch(
|
||||
targetUrl,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type":
|
||||
request.headers.get("content-type") || "application/json",
|
||||
"user-agent": request.headers.get("user-agent") || "healthlog-proxy",
|
||||
},
|
||||
body,
|
||||
cache: "no-store",
|
||||
},
|
||||
body,
|
||||
cache: "no-store",
|
||||
});
|
||||
// Operator-configured Umami host — pin the connect-time IP.
|
||||
{ requirePublicHost: true },
|
||||
);
|
||||
|
||||
lastResponseStatus = upstream.status;
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
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";
|
||||
|
||||
// Narrow the `apiHandler` signature for the test fixtures (see
|
||||
// disable-coach/__tests__/route.test.ts for the precedent).
|
||||
type RouteCtx = { params: Promise<{ id: string }> };
|
||||
const get = GET as (r: Request, ctx: RouteCtx) => Promise<Response>;
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function ctx(id: string) {
|
||||
return { params: Promise.resolve({ id }) };
|
||||
}
|
||||
|
||||
function mkRequest() {
|
||||
return new Request("http://localhost/api/user/avatar/user-1");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/user/avatar/{id}", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await get(mkRequest(), ctx("user-1"));
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses cross-user reads with 403", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const res = await get(mkRequest(), ctx("someone-else"));
|
||||
expect(res.status).toBe(403);
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 404 when the user has no avatar", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
avatarBytes: null,
|
||||
avatarContentType: null,
|
||||
avatarUpdatedAt: null,
|
||||
} as never);
|
||||
|
||||
const res = await get(mkRequest(), ctx("user-1"));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns the bytes with the persisted content-type", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const bytes = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
avatarBytes: bytes,
|
||||
avatarContentType: "image/jpeg",
|
||||
avatarUpdatedAt: new Date(),
|
||||
} as never);
|
||||
|
||||
const res = await get(mkRequest(), ctx("user-1"));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toBe("image/jpeg");
|
||||
expect(res.headers.get("cache-control")).toContain("immutable");
|
||||
|
||||
const echoed = Buffer.from(await res.arrayBuffer());
|
||||
expect(echoed.equals(bytes)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* v1.5.5 — owner-scoped avatar GET.
|
||||
*
|
||||
* GET /api/user/avatar/{userId}
|
||||
*
|
||||
* Serves the avatar bytes the user uploaded through POST
|
||||
* /api/user/avatar. Owner-scoped — `requireAuth()` resolves the
|
||||
* caller's id and the `userId` path param must match. Public-by-
|
||||
* default would expose an authenticated user's avatar to anyone who
|
||||
* guessed (or scraped) the cuid; HealthLog is single-tenant per
|
||||
* user, so cross-user reads are a privacy hole even if the bytes
|
||||
* themselves look innocuous.
|
||||
*
|
||||
* 200 returns the raw image bytes with the persisted `Content-Type`
|
||||
* (`image/jpeg` / `image/png` / `image/webp`) and a `Cache-Control`
|
||||
* that lets the client cache aggressively — the /me payload appends
|
||||
* `?v={updatedAtMs}` so a re-upload flips the URL and the next
|
||||
* paint round-trips.
|
||||
*
|
||||
* 404 fires when the user row has no avatar; the client paints the
|
||||
* username-initials fallback.
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Context = { params: Promise<{ id: string }> };
|
||||
|
||||
export const GET = apiHandler(async (_request: Request, ctx: Context) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await ctx.params;
|
||||
|
||||
// Owner-scope. A cookie session and a Bearer token both arrive
|
||||
// through `requireAuth()`; the id-match below covers both. There
|
||||
// is no admin-elevation path here (admin avatars are not a
|
||||
// server-side ops concern).
|
||||
if (id !== user.id) {
|
||||
annotate({
|
||||
action: { name: "user.avatar.get.forbidden" },
|
||||
});
|
||||
return apiError("Forbidden", 403);
|
||||
}
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
avatarBytes: true,
|
||||
avatarContentType: true,
|
||||
avatarUpdatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!row?.avatarBytes || !row.avatarContentType) {
|
||||
return apiError("Avatar not found", 404);
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "user.avatar.get" },
|
||||
meta: {
|
||||
contentType: row.avatarContentType,
|
||||
size: row.avatarBytes.length,
|
||||
},
|
||||
});
|
||||
|
||||
// The /me payload carries `?v={updatedAtMs}` on the URL so the
|
||||
// browser cache invalidates on every re-upload. The bytes
|
||||
// themselves are stable for the lifetime of that URL.
|
||||
return new NextResponse(row.avatarBytes as unknown as BodyInit, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": row.avatarContentType,
|
||||
"Content-Length": String(row.avatarBytes.length),
|
||||
"Cache-Control": "private, max-age=31536000, immutable",
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3_600_000,
|
||||
}),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST, DELETE } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
// `apiHandler` types as `NextRequest → Promise<Response>`; the test
|
||||
// fixtures hand in a plain `Request` which is structurally compatible
|
||||
// for everything the handler reads, so we narrow the call type the
|
||||
// same way `disable-coach/__tests__/route.test.ts` does.
|
||||
const post = POST as (r: Request) => Promise<Response>;
|
||||
const del = DELETE as (r: Request) => Promise<Response>;
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
/**
|
||||
* Minimal valid PNG: 1x1 white pixel. The Vitest `node` env has a
|
||||
* polyfilled `File` + `Blob` + `FormData` so we can build a multipart
|
||||
* body without an extra library.
|
||||
*/
|
||||
const ONE_BY_ONE_PNG_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABXvMqOgAAAABJRU5ErkJggg==";
|
||||
|
||||
function pngBuffer(): Buffer {
|
||||
return Buffer.from(ONE_BY_ONE_PNG_BASE64, "base64");
|
||||
}
|
||||
|
||||
function mkUpload(body: Buffer, filename = "avatar.png", type = "image/png") {
|
||||
const formData = new FormData();
|
||||
// Copy into a plain Uint8Array — Node's `Buffer<ArrayBufferLike>`
|
||||
// is not structurally assignable to `BlobPart` because of the
|
||||
// ArrayBufferLike vs. ArrayBuffer mismatch on the `.buffer` type.
|
||||
const blobBytes = new Uint8Array(body.byteLength);
|
||||
blobBytes.set(body);
|
||||
const blob = new Blob([blobBytes], { type });
|
||||
formData.append("file", blob, filename);
|
||||
return new Request("http://localhost/api/user/avatar", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default rate-limit pass-through; per-test overrides drop in below.
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3_600_000,
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/user/avatar", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await post(mkUpload(pngBuffer()));
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stores a valid PNG and returns the cache-busting URL", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await post(mkUpload(pngBuffer()));
|
||||
expect(res.status).toBe(201);
|
||||
const env = (await res.json()) as {
|
||||
data: { avatarUrl: string; contentType: string; updatedAt: string };
|
||||
};
|
||||
expect(env.data.contentType).toBe("image/png");
|
||||
expect(env.data.avatarUrl).toMatch(/^\/api\/user\/avatar\/user-1\?v=\d+$/);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "user-1" },
|
||||
data: expect.objectContaining({
|
||||
avatarContentType: "image/png",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.avatar.upload",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({
|
||||
contentType: "image/png",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a body larger than 2 MiB with 413", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// 2 MiB + 1 byte. The PNG signature at the front keeps the
|
||||
// detector happy, but the size check trips first.
|
||||
const tooBig = Buffer.concat([pngBuffer(), Buffer.alloc(2 * 1024 * 1024)]);
|
||||
|
||||
const res = await post(mkUpload(tooBig));
|
||||
expect(res.status).toBe(413);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an oversize chunked upload with no Content-Length at the stream level (413)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
// Build a multipart body as a streamed ReadableStream with NO
|
||||
// Content-Length header — the shape a `Transfer-Encoding: chunked`
|
||||
// upload takes. The bounded stream reader must trip before the body
|
||||
// is fully buffered.
|
||||
const boundary = "----healthlogtest";
|
||||
const head = Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="a.png"\r\nContent-Type: image/png\r\n\r\n`,
|
||||
);
|
||||
const tail = Buffer.from(`\r\n--${boundary}--\r\n`);
|
||||
const oversize = Buffer.alloc(2 * 1024 * 1024 + 1, 0x41);
|
||||
const parts = [head, oversize, tail];
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const p of parts) {
|
||||
controller.enqueue(new Uint8Array(p));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const req = new Request("http://localhost/api/user/avatar", {
|
||||
method: "POST",
|
||||
headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
|
||||
body: stream,
|
||||
// Node's fetch requires `duplex: "half"` for a streamed body.
|
||||
duplex: "half",
|
||||
} as RequestInit & { duplex: "half" });
|
||||
|
||||
const res = await post(req);
|
||||
expect(res.status).toBe(413);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an unsupported MIME with 415 (magic-byte sniff)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// Plain text content — no JPEG/PNG/WebP magic.
|
||||
const notAnImage = Buffer.from("hello, world — definitely not an image");
|
||||
|
||||
const res = await post(
|
||||
mkUpload(notAnImage, "avatar.png", "image/png"),
|
||||
);
|
||||
expect(res.status).toBe(415);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a missing 'file' field with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("other", "ignored");
|
||||
const req = new Request("http://localhost/api/user/avatar", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const res = await post(req);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("trips the per-user rate-limit with 429", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 30_000,
|
||||
});
|
||||
|
||||
const res = await post(mkUpload(pngBuffer()));
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/user/avatar", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await del(
|
||||
new Request("http://localhost/api/user/avatar", { method: "DELETE" }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the row and returns 204", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
avatarContentType: "image/png",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await del(
|
||||
new Request("http://localhost/api/user/avatar", { method: "DELETE" }),
|
||||
);
|
||||
expect(res.status).toBe(204);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: {
|
||||
avatarBytes: null,
|
||||
avatarContentType: null,
|
||||
avatarUpdatedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.avatar.delete",
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips the audit row when the avatar was already empty", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
avatarContentType: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await del(
|
||||
new Request("http://localhost/api/user/avatar", { method: "DELETE" }),
|
||||
);
|
||||
expect(res.status).toBe(204);
|
||||
expect(auditLog).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* v1.5.5 — self-hosted avatar storage.
|
||||
*
|
||||
* POST /api/user/avatar — multipart upload, owner-scoped.
|
||||
* DELETE /api/user/avatar — clears the row, owner-scoped.
|
||||
*
|
||||
* Replaces the Gravatar third-party leak. The previous /me payload
|
||||
* carried a `gravatarUrl` built from `SHA-256(email)` and pointed
|
||||
* every authenticated page-load at www.gravatar.com — Automattic
|
||||
* could correlate the digest against its own known-email table on
|
||||
* each request. Storing the image bytes on the User row and serving
|
||||
* them from same-origin closes the gap without adding a filesystem
|
||||
* volume or a new compose change (pg_dump already carries the
|
||||
* bytes alongside the rest of the row).
|
||||
*
|
||||
* Validation:
|
||||
* - Max body size: 2 MiB (small enough to keep the request pipeline
|
||||
* responsive, big enough for a 512×512 JPEG).
|
||||
* - Accepted MIME: image/jpeg, image/png, image/webp. Anything else
|
||||
* fails 415.
|
||||
* - The Content-Type sent by the multipart header is informational
|
||||
* only — the route sniffs the magic bytes and refuses a mismatch.
|
||||
* - The image is decoded just enough to read its native width +
|
||||
* height; > 2048×2048 fails 413 so a single user cannot park a
|
||||
* huge PNG in the database.
|
||||
*
|
||||
* Storage choice: image bytes on the User row (Postgres bytea). The
|
||||
* file alternative needs a new volume mount + a new compose change
|
||||
* + a separate backup story; the byte alternative slots into the
|
||||
* existing backup path with no operator change. Avatars stay small
|
||||
* (< 2 MiB hard cap × < 100 users on a typical self-host = < 200 MB
|
||||
* total), so the row-size trade-off is well inside budget.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import {
|
||||
AVATAR_MAX_BYTES,
|
||||
buildAvatarUrl,
|
||||
detectAvatarMimeType,
|
||||
readAvatarDimensions,
|
||||
} from "@/lib/avatar";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Per-user upload rate-limit. The Settings → Account avatar control
|
||||
* is a deliberate user action, not an automation surface, so a tight
|
||||
* cap (10 / hour / user) is comfortable for real flows and tripwires
|
||||
* a misbehaving client without surfacing as a 429 on a normal
|
||||
* re-upload.
|
||||
*/
|
||||
const UPLOAD_LIMIT_PER_HOUR = 10;
|
||||
const UPLOAD_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
interface UploadResponse {
|
||||
avatarUrl: string;
|
||||
contentType: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Thrown by `readBoundedBody` when the stream exceeds the byte cap. */
|
||||
class BodyTooLargeError extends Error {
|
||||
constructor() {
|
||||
super("Request body exceeds the configured byte cap");
|
||||
this.name = "BodyTooLargeError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a request body stream into a single buffer while counting bytes,
|
||||
* throwing `BodyTooLargeError` the moment the running total passes
|
||||
* `maxBytes`. Bounds the work at the stream level so a chunked /
|
||||
* unbounded upload cannot park past the cap even when no `Content-Length`
|
||||
* header is present. The captured bytes are retained (capped at
|
||||
* `maxBytes`) so the caller can reconstruct `FormData` from them — this
|
||||
* reads the body exactly once, with no clone and no second parser racing
|
||||
* the same body, so a cap trip frees the allocation immediately instead
|
||||
* of leaving a native parse buffering the rest of an oversized upload.
|
||||
*/
|
||||
async function readBoundedBody(
|
||||
body: ReadableStream<Uint8Array> | null,
|
||||
maxBytes: number,
|
||||
): Promise<Uint8Array<ArrayBuffer>> {
|
||||
if (!body) return new Uint8Array(0);
|
||||
const reader = body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let retained = 0;
|
||||
let overflow = false;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (overflow) continue;
|
||||
if (retained + value.byteLength > maxBytes) {
|
||||
// Past the cap. Drop everything retained so far and stop
|
||||
// retaining further chunks — memory stays bounded at the cap.
|
||||
// Keep draining the stream to its natural close rather than
|
||||
// cancelling it: an abrupt cancel mid-transfer races the
|
||||
// underlying (undici) producer, which then enqueues into a
|
||||
// closed controller and surfaces as an unhandled rejection.
|
||||
chunks.length = 0;
|
||||
retained = 0;
|
||||
overflow = true;
|
||||
continue;
|
||||
}
|
||||
chunks.push(value);
|
||||
retained += value.byteLength;
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
if (overflow) throw new BodyTooLargeError();
|
||||
const out = new Uint8Array(retained);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
out.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`user-avatar-upload:${user.id}`,
|
||||
UPLOAD_LIMIT_PER_HOUR,
|
||||
UPLOAD_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many avatar uploads", 429);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
// Pre-flight on the declared content length so a multi-gigabyte
|
||||
// body never lands in the FormData parser. The hard limit below
|
||||
// is the buffered-size check; the header guard just spares the
|
||||
// allocation when the abuse is obvious.
|
||||
const contentLength = Number(request.headers.get("content-length") ?? 0);
|
||||
if (contentLength > AVATAR_MAX_BYTES) {
|
||||
annotate({
|
||||
action: { name: "user.avatar.upload.rejected" },
|
||||
meta: { reason: "content_length_exceeded", contentLength },
|
||||
});
|
||||
return apiError(
|
||||
`Upload exceeds ${AVATAR_MAX_BYTES} byte limit`,
|
||||
413,
|
||||
);
|
||||
}
|
||||
|
||||
// Stream-level cap. The Content-Length pre-flight above only fires when
|
||||
// the header is present; a `Transfer-Encoding: chunked` upload (or any
|
||||
// client that omits the header) skips it, and `request.formData()`
|
||||
// would then buffer the whole stream before the post-parse `file.size`
|
||||
// check can run. Read the body ONCE through a bounded reader that
|
||||
// aborts on the first byte past AVATAR_MAX_BYTES, then reconstruct the
|
||||
// FormData from the captured bytes. A single read means the cap trip
|
||||
// frees the allocation immediately — no clone, no tee, and no native
|
||||
// parse left buffering the rest of an oversized body. The post-parse
|
||||
// `file.size` check stays in place as defence-in-depth.
|
||||
let formData: FormData;
|
||||
try {
|
||||
const bytes = await readBoundedBody(request.body, AVATAR_MAX_BYTES);
|
||||
formData = await new Response(new Blob([bytes]), {
|
||||
headers: { "content-type": request.headers.get("content-type") ?? "" },
|
||||
}).formData();
|
||||
} catch (err) {
|
||||
// Surface the most useful failure: a cap overflow always wins.
|
||||
if (err instanceof BodyTooLargeError) {
|
||||
annotate({
|
||||
action: { name: "user.avatar.upload.rejected" },
|
||||
meta: { reason: "stream_size_exceeded" },
|
||||
});
|
||||
return apiError(`Upload exceeds ${AVATAR_MAX_BYTES} byte limit`, 413);
|
||||
}
|
||||
return apiError(
|
||||
`Invalid multipart body: ${err instanceof Error ? err.message : "unknown"}`,
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return apiError("Field 'file' must be a file", 422);
|
||||
}
|
||||
|
||||
if (file.size > AVATAR_MAX_BYTES) {
|
||||
annotate({
|
||||
action: { name: "user.avatar.upload.rejected" },
|
||||
meta: { reason: "file_size_exceeded", size: file.size },
|
||||
});
|
||||
return apiError(
|
||||
`Upload exceeds ${AVATAR_MAX_BYTES} byte limit`,
|
||||
413,
|
||||
);
|
||||
}
|
||||
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
buffer = Buffer.from(await file.arrayBuffer());
|
||||
} catch {
|
||||
return apiError("Failed to read uploaded file", 400);
|
||||
}
|
||||
|
||||
// Magic-byte sniff. The multipart Content-Type header is operator-
|
||||
// controlled (any client can send `image/jpeg` over a PNG body), so
|
||||
// the wire-side header is informational only.
|
||||
const sniffed = detectAvatarMimeType(buffer);
|
||||
if (!sniffed) {
|
||||
annotate({
|
||||
action: { name: "user.avatar.upload.rejected" },
|
||||
meta: { reason: "unsupported_mime" },
|
||||
});
|
||||
return apiError(
|
||||
"Unsupported image type. Use JPEG, PNG, or WebP.",
|
||||
415,
|
||||
);
|
||||
}
|
||||
|
||||
const dimensions = readAvatarDimensions(buffer, sniffed);
|
||||
if (!dimensions) {
|
||||
annotate({
|
||||
action: { name: "user.avatar.upload.rejected" },
|
||||
meta: { reason: "dimensions_unreadable" },
|
||||
});
|
||||
return apiError("Image dimensions could not be read", 422);
|
||||
}
|
||||
|
||||
if (dimensions.width > 2048 || dimensions.height > 2048) {
|
||||
annotate({
|
||||
action: { name: "user.avatar.upload.rejected" },
|
||||
meta: {
|
||||
reason: "dimensions_exceeded",
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
},
|
||||
});
|
||||
return apiError(
|
||||
`Image exceeds the 2048×2048 dimension limit`,
|
||||
413,
|
||||
);
|
||||
}
|
||||
|
||||
const updatedAt = new Date();
|
||||
// Prisma's Bytes column types as `Uint8Array<ArrayBuffer>`; Node's
|
||||
// `Buffer` extends `Uint8Array` but is generic over
|
||||
// `ArrayBufferLike`. Allocating a fresh `Uint8Array(N).set(buffer)`
|
||||
// copies the bytes into an `ArrayBuffer`-backed array so the
|
||||
// structural assignment to the Prisma `Bytes?` field type-checks
|
||||
// cleanly.
|
||||
const avatarBytes = new Uint8Array(buffer.byteLength);
|
||||
avatarBytes.set(buffer);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
avatarBytes,
|
||||
avatarContentType: sniffed,
|
||||
avatarUpdatedAt: updatedAt,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("user.avatar.upload", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
contentType: sniffed,
|
||||
size: buffer.length,
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "user.avatar.upload" },
|
||||
meta: { contentType: sniffed, size: buffer.length },
|
||||
});
|
||||
|
||||
const response: UploadResponse = {
|
||||
avatarUrl: buildAvatarUrl(user.id, updatedAt),
|
||||
contentType: sniffed,
|
||||
updatedAt: updatedAt.toISOString(),
|
||||
};
|
||||
return apiSuccess(response, 201);
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(async (request: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// Only audit the actual clear so a "delete on an already-empty
|
||||
// row" is a no-op for the ledger. The DB write fires either way
|
||||
// for code simplicity; the size of the row delta is zero when the
|
||||
// columns were already null.
|
||||
const previous = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { avatarContentType: true },
|
||||
});
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
avatarBytes: null,
|
||||
avatarContentType: null,
|
||||
avatarUpdatedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (previous?.avatarContentType) {
|
||||
await auditLog("user.avatar.delete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { previousContentType: previous.avatarContentType },
|
||||
});
|
||||
}
|
||||
|
||||
annotate({ action: { name: "user.avatar.delete" } });
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import packageJson from "../../../../../package.json";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -80,7 +81,7 @@ export const GET = apiHandler(async () => {
|
||||
let release: GithubReleaseShape | null = null;
|
||||
const callStart = Date.now();
|
||||
try {
|
||||
const res = await fetch(RELEASES_URL, {
|
||||
const res = await safeFetch(RELEASES_URL, {
|
||||
headers: { Accept: "application/vnd.github+json" },
|
||||
// GitHub anon rate limit is 60/hr per IP — tight but not zero.
|
||||
// We don't pass a token because the repo is public and the route
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.5.6 G-1 — medication detail page as a pure history surface.
|
||||
*
|
||||
* Supersedes the v1.5.5 composition that stacked every setting inline.
|
||||
* The page now reads as a calm "Vergangenheit" view: back link → slim
|
||||
* header (name / dose / status + a two-option Bearbeiten dropdown) →
|
||||
* STATIC cadence line → GLP-1 dose-ladder read-only visualisation →
|
||||
* intake-history table. Every setting — Notifications, API tokens,
|
||||
* Phasen, CSV import, grace and the destructive zone — moves into the
|
||||
* `<AdvancedSettingsSheet>`, reached from the dropdown's "Erweiterte
|
||||
* Einstellungen" item. "Plan bearbeiten" opens the wizard at Step 1.
|
||||
*
|
||||
* One-shot variant drops the dose-ladder section and renders the
|
||||
* static `Einmalig am …` line in place of the cadence summary.
|
||||
*
|
||||
* Reads:
|
||||
*
|
||||
* - `medicationDetail(id)` for the medication snapshot.
|
||||
* - `medicationIntakeList(id, …)` for `intakeCount`, which the
|
||||
* sheet's destructive zone needs.
|
||||
*
|
||||
* Mutations all cascade through `medicationDependentKeys` (which
|
||||
* includes the `compliance-chart-inline` prefix per D-3 §10).
|
||||
*/
|
||||
|
||||
import { use, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeft, Loader2 } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { DrugLevelChart } from "@/components/medications/DrugLevelChart";
|
||||
import { TitrationSection } from "@/components/medications/TitrationSection";
|
||||
import { MedicationDetailHeader } from "@/components/medications/medication-detail-header";
|
||||
import { CadenceSummaryRow } from "@/components/medications/cadence-summary-row";
|
||||
import { IntakeHistoryPreview } from "@/components/medications/sections/intake-history-preview";
|
||||
import { SideEffectsSection } from "@/components/medications/SideEffectsSection";
|
||||
import { SchedulingSection } from "@/components/medications/SchedulingSection";
|
||||
import { AdvancedSettingsSheet } from "@/components/medications/advanced-settings-sheet";
|
||||
import { PhaseConfigSheet } from "@/components/medications/sections/phase-config-sheet";
|
||||
import { MedicationWizardDialog } from "@/components/medications/wizard/MedicationWizardDialog";
|
||||
import type { MedicationPayload } from "@/components/medications/wizard/wizard-payload";
|
||||
|
||||
interface ScheduleSnapshot {
|
||||
id: string;
|
||||
windowStart: string;
|
||||
windowEnd: string;
|
||||
label: string | null;
|
||||
dose: string | null;
|
||||
daysOfWeek: string | null;
|
||||
timesOfDay?: string[];
|
||||
rrule?: string | null;
|
||||
rollingIntervalDays?: number | null;
|
||||
reminderGraceMinutes?: number | null;
|
||||
}
|
||||
|
||||
interface MedicationDetailSnapshot {
|
||||
id: string;
|
||||
name: string;
|
||||
dose: string;
|
||||
category: string;
|
||||
treatmentClass?: string;
|
||||
deliveryForm?: string;
|
||||
dosesPerUnit?: number | null;
|
||||
active: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
pausedAt: string | null;
|
||||
startsOn?: string | null;
|
||||
endsOn?: string | null;
|
||||
oneShot?: boolean;
|
||||
schedules: ScheduleSnapshot[];
|
||||
}
|
||||
|
||||
interface IntakeListResponse {
|
||||
events: Array<{
|
||||
id: string;
|
||||
scheduledFor: string;
|
||||
takenAt: string | null;
|
||||
skipped: boolean;
|
||||
}>;
|
||||
meta: { total: number; limit: number; offset: number };
|
||||
}
|
||||
|
||||
const INTAKE_PREVIEW_PARAMS = {
|
||||
sortBy: "takenAt",
|
||||
sortDir: "desc" as const,
|
||||
limit: 14,
|
||||
offset: 0,
|
||||
status: "completed",
|
||||
};
|
||||
|
||||
function snapshotToWizardPayload(
|
||||
med: MedicationDetailSnapshot,
|
||||
): MedicationPayload {
|
||||
return {
|
||||
id: med.id,
|
||||
name: med.name,
|
||||
dose: med.dose,
|
||||
category: med.category,
|
||||
treatmentClass: med.treatmentClass,
|
||||
deliveryForm: med.deliveryForm,
|
||||
dosesPerUnit: med.dosesPerUnit ?? null,
|
||||
notificationsEnabled: med.notificationsEnabled,
|
||||
startsOn: med.startsOn ? new Date(med.startsOn) : null,
|
||||
endsOn: med.endsOn ? new Date(med.endsOn) : null,
|
||||
oneShot: med.oneShot ?? false,
|
||||
schedules: med.schedules.map((s) => ({
|
||||
id: s.id,
|
||||
windowStart: s.windowStart,
|
||||
windowEnd: s.windowEnd,
|
||||
label: s.label ?? null,
|
||||
dose: s.dose ?? null,
|
||||
...parseScheduleRecurrence(s.daysOfWeek),
|
||||
timesOfDay: s.timesOfDay,
|
||||
rrule: s.rrule ?? null,
|
||||
rollingIntervalDays: s.rollingIntervalDays ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export default function MedicationDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslations();
|
||||
const formatters = useFormatters();
|
||||
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [phaseSheetOpen, setPhaseSheetOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isAuthenticated) {
|
||||
router.push("/auth/login");
|
||||
}
|
||||
}, [authLoading, isAuthenticated, router]);
|
||||
|
||||
const {
|
||||
data: medication,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery<MedicationDetailSnapshot>({
|
||||
queryKey: queryKeys.medicationDetail(id),
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/medications/${id}`);
|
||||
if (!res.ok) throw new Error("medication_detail_failed");
|
||||
return (await res.json()).data as MedicationDetailSnapshot;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const { data: intakeList } = useQuery<IntakeListResponse>({
|
||||
queryKey: queryKeys.medicationIntakeList(id, INTAKE_PREVIEW_PARAMS),
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({
|
||||
sortBy: INTAKE_PREVIEW_PARAMS.sortBy,
|
||||
sortDir: INTAKE_PREVIEW_PARAMS.sortDir,
|
||||
limit: String(INTAKE_PREVIEW_PARAMS.limit),
|
||||
offset: String(INTAKE_PREVIEW_PARAMS.offset),
|
||||
status: INTAKE_PREVIEW_PARAMS.status,
|
||||
});
|
||||
const res = await fetch(
|
||||
`/api/medications/${id}/intake?${params.toString()}`,
|
||||
);
|
||||
if (!res.ok) throw new Error("intake_list_failed");
|
||||
return (await res.json()).data as IntakeListResponse;
|
||||
},
|
||||
enabled: isAuthenticated && !!medication,
|
||||
});
|
||||
|
||||
// v1.5.6 F-1 M-2 — memoise the wizard payload so the header dropdown
|
||||
// + wizard mount share one object identity per medication snapshot
|
||||
// instead of recomputing it on every render.
|
||||
const wizardPayload = useMemo<MedicationPayload | null>(
|
||||
() => (medication ? snapshotToWizardPayload(medication) : null),
|
||||
[medication],
|
||||
);
|
||||
|
||||
if (authLoading || isLoading) {
|
||||
return (
|
||||
<div
|
||||
className="flex h-64 items-center justify-center"
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !medication) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground -ml-2 gap-1"
|
||||
asChild
|
||||
>
|
||||
<Link href="/medications">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t("medications.back")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Card
|
||||
className="p-6"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
data-slot="medication-detail-error-card"
|
||||
>
|
||||
<p className="text-destructive text-sm">
|
||||
{t("medications.loadFailed")}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const oneShot = medication.oneShot === true;
|
||||
const intakeCount = intakeList?.meta.total ?? 0;
|
||||
const primaryGrace =
|
||||
medication.schedules[0]?.reminderGraceMinutes ?? null;
|
||||
// `wizardPayload` is non-null here — the early returns above bail
|
||||
// before this point whenever `medication` is undefined.
|
||||
const payload = wizardPayload as MedicationPayload;
|
||||
|
||||
// v1.5.6 G-1 §5 — sibling-swap: close the advanced sheet first, then
|
||||
// open the phase sheet so the two never stack (modal depth ≤ 2).
|
||||
function openPhaseSheet() {
|
||||
setAdvancedOpen(false);
|
||||
setPhaseSheetOpen(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-slot="medication-detail-page">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground -ml-2 gap-1"
|
||||
asChild
|
||||
>
|
||||
<Link href="/medications">
|
||||
<ArrowLeft aria-hidden="true" className="h-4 w-4" />
|
||||
{t("medications.back")}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{/* G-1 §3.2 — Header band. The Bearbeiten dropdown owns the
|
||||
wizard ("Plan bearbeiten") + the advanced sheet ("Erweiterte
|
||||
Einstellungen"); the page owns both open-states. */}
|
||||
<MedicationDetailHeader
|
||||
name={medication.name}
|
||||
dose={medication.dose}
|
||||
active={medication.active}
|
||||
endsOn={medication.endsOn}
|
||||
onEditPlan={() => setWizardOpen(true)}
|
||||
onOpenAdvanced={() => setAdvancedOpen(true)}
|
||||
/>
|
||||
|
||||
{/* G-1 §3.3 — Cadence line. One-shot collapses to the
|
||||
`Einmalig am …` card; recurring renders the summary row whose
|
||||
edit affordance opens the editor directly. */}
|
||||
{oneShot ? (
|
||||
<Card
|
||||
className="p-5 sm:p-6"
|
||||
data-slot="medication-detail-one-shot-cadence"
|
||||
>
|
||||
<p className="text-foreground text-sm">
|
||||
{medication.startsOn
|
||||
? t("medications.detail.cadence.oneShotOn", {
|
||||
date: formatters.dateTime(medication.startsOn),
|
||||
})
|
||||
: t("medications.detail.cadence.oneShotPending")}
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<CadenceSummaryRow
|
||||
medication={payload}
|
||||
onEdit={() => setWizardOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* G-1 §3.4 — Dose ladder / Phasen visualisation (read-only,
|
||||
GLP-1 recurring only). The editor lives in the sheet. */}
|
||||
{!oneShot && medication.treatmentClass === "GLP1" && (
|
||||
<>
|
||||
<DrugLevelChart
|
||||
medication={{
|
||||
id: medication.id,
|
||||
name: medication.name,
|
||||
dose: medication.dose,
|
||||
}}
|
||||
/>
|
||||
{/* v1.6.0 — side-effect logbook + cadence/compliance section
|
||||
restored onto the detail page (they previously lived only
|
||||
on the legacy `/history` route). */}
|
||||
<SideEffectsSection medicationId={id} />
|
||||
<SchedulingSection
|
||||
medicationId={id}
|
||||
reminderEnabled={medication.notificationsEnabled}
|
||||
/>
|
||||
<TitrationSection medicationId={id} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* G-1 §3.5 — Intake history table (primary surface). */}
|
||||
<IntakeHistoryPreview
|
||||
medicationId={id}
|
||||
importOpen={importOpen}
|
||||
onImportOpenChange={setImportOpen}
|
||||
/>
|
||||
|
||||
{/* G-1 §5 — Advanced settings sheet. Hosts Notifications →
|
||||
Settings → destructive zone. The Phasen button sibling-swaps
|
||||
to the phase sheet below. */}
|
||||
<AdvancedSettingsSheet
|
||||
open={advancedOpen}
|
||||
onOpenChange={setAdvancedOpen}
|
||||
medicationId={id}
|
||||
medicationName={medication.name}
|
||||
treatmentClass={medication.treatmentClass}
|
||||
active={medication.active}
|
||||
startsOn={medication.startsOn}
|
||||
endsOn={medication.endsOn}
|
||||
notificationsEnabled={medication.notificationsEnabled}
|
||||
reminderGraceMinutes={primaryGrace}
|
||||
intakeCount={intakeCount}
|
||||
onRequestPhaseSheet={openPhaseSheet}
|
||||
/>
|
||||
|
||||
{/* G-1 §5 — phase sheet mounted as a sibling of the advanced
|
||||
sheet so they never stack (GLP-1 + course window only). */}
|
||||
{!oneShot &&
|
||||
medication.treatmentClass === "GLP1" &&
|
||||
Boolean(medication.startsOn) &&
|
||||
Boolean(medication.endsOn) && (
|
||||
<PhaseConfigSheet
|
||||
medicationId={id}
|
||||
open={phaseSheetOpen}
|
||||
onOpenChange={setPhaseSheetOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Wizard mount — edit-only, lands on Step 1 (no landing
|
||||
intent). Cancel + success both close via the cascade. */}
|
||||
<MedicationWizardDialog
|
||||
open={wizardOpen}
|
||||
onOpenChange={setWizardOpen}
|
||||
mode="edit"
|
||||
initial={payload}
|
||||
onSuccess={() => setWizardOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* v1.5.6 G-1 §3 — the `/medications/[id]` detail page is a pure
|
||||
* history surface.
|
||||
*
|
||||
* Source-level guards (the page is a client component with `use()`
|
||||
* + hooks, so a full render is heavier than the contract warrants):
|
||||
* - no `TodaysDoseCard` import or render (removed from the detail
|
||||
* page; it lives only on the list page);
|
||||
* - no `isToday` / `todayEvent` derivations (dead once the card is
|
||||
* gone);
|
||||
* - no `landingIntent` / `wizardIntent` / `openWizardWithIntent`
|
||||
* plumbing (the header dropdown is the only wizard entry, landing
|
||||
* on Step 1);
|
||||
* - the page hosts the `AdvancedSettingsSheet` and renders an
|
||||
* editable cadence row (the v1.6.0 editor opens directly from the
|
||||
* row, so `hideEdit` is gone).
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), "src/app/medications/[id]/page.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
describe("medication detail page is a pure history surface (G-1 §3)", () => {
|
||||
it("does not import or render TodaysDoseCard", () => {
|
||||
expect(source).not.toContain("TodaysDoseCard");
|
||||
expect(source).not.toContain("todays-dose-card");
|
||||
});
|
||||
|
||||
it("drops the dead isToday / todayEvent derivations", () => {
|
||||
expect(source).not.toContain("isToday");
|
||||
expect(source).not.toContain("todayEvent");
|
||||
});
|
||||
|
||||
it("drops the landingIntent plumbing", () => {
|
||||
expect(source).not.toContain("landingIntent");
|
||||
expect(source).not.toContain("wizardIntent");
|
||||
expect(source).not.toContain("openWizardWithIntent");
|
||||
});
|
||||
|
||||
it("hosts the AdvancedSettingsSheet and an editable cadence row", () => {
|
||||
expect(source).toContain("AdvancedSettingsSheet");
|
||||
// v1.6.0 — the cadence row opens the editor directly; the dead
|
||||
// `hideEdit` prop is gone.
|
||||
expect(source).not.toContain("hideEdit");
|
||||
});
|
||||
});
|
||||
+16
-602
@@ -1,15 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
invalidateKeys,
|
||||
medicationDependentKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import { MedicationWizardDialog } from "@/components/medications/wizard/MedicationWizardDialog";
|
||||
import type { MedicationPayload } from "@/components/medications/wizard/wizard-payload";
|
||||
@@ -17,38 +13,7 @@ import { MedicationCard } from "@/components/medications/medication-card";
|
||||
import { Glp1MedicationCard } from "@/components/medications/glp1-medication-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
MoreHorizontal,
|
||||
Loader2,
|
||||
Plus,
|
||||
Pill,
|
||||
Upload,
|
||||
Copy,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { Loader2, Pill, Plus } from "lucide-react";
|
||||
|
||||
interface Schedule {
|
||||
id: string;
|
||||
@@ -76,6 +41,8 @@ interface Medication {
|
||||
treatmentClass?: string;
|
||||
/** v1.4.25 W4d — doses per pen/vial for inventory math. */
|
||||
dosesPerUnit?: number | null;
|
||||
/** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */
|
||||
deliveryForm?: string;
|
||||
active: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
pausedAt: string | null;
|
||||
@@ -102,11 +69,6 @@ export default function MedicationsPage() {
|
||||
const shouldOpenFromUrl = searchParams?.get("new") === "1";
|
||||
const [dialogOpen, setDialogOpen] = useState(shouldOpenFromUrl);
|
||||
const [editingMed, setEditingMed] = useState<Medication | null>(null);
|
||||
const [importMedId, setImportMedId] = useState<string | null>(null);
|
||||
const [apiMed, setApiMed] = useState<{
|
||||
id: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldOpenFromUrl) {
|
||||
@@ -286,14 +248,15 @@ export default function MedicationsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Endpoint Dialog */}
|
||||
<ApiEndpointDialog medication={apiMed} onClose={() => setApiMed(null)} />
|
||||
|
||||
{/* Import Dialog */}
|
||||
<IntakeImportDialog
|
||||
medicationId={importMedId}
|
||||
onClose={() => setImportMedId(null)}
|
||||
/>
|
||||
{/* v1.5.5 — the per-card kebab triggers used to open inline
|
||||
IntakeImportDialog / ApiEndpointDialog mounts here, but both
|
||||
state slots were only ever set from inside the dialogs
|
||||
themselves (never from a list-card prop) so the inline
|
||||
mounts were dead code. v1.5.5 retires both on the list page:
|
||||
the import dialog lives in the detail-page intake-history
|
||||
header (see `src/components/medications/intake-import-dialog.tsx`),
|
||||
the API endpoint moves to the detail-page Settings → Externe
|
||||
Integration sub-row. */}
|
||||
|
||||
{/* v1.5.4 — modal-wizard mount. The same component drives both
|
||||
create (no initial) and edit (hydrates from the medication's
|
||||
@@ -323,6 +286,8 @@ function medicationToPayload(med: Medication): MedicationPayload {
|
||||
dose: med.dose,
|
||||
category: med.category,
|
||||
treatmentClass: med.treatmentClass,
|
||||
deliveryForm: med.deliveryForm,
|
||||
dosesPerUnit: med.dosesPerUnit ?? null,
|
||||
notificationsEnabled: med.notificationsEnabled,
|
||||
startsOn: med.startsOn ? new Date(med.startsOn) : null,
|
||||
endsOn: med.endsOn ? new Date(med.endsOn) : null,
|
||||
@@ -341,554 +306,3 @@ function medicationToPayload(med: Medication): MedicationPayload {
|
||||
};
|
||||
}
|
||||
|
||||
function IntakeImportDialog({
|
||||
medicationId,
|
||||
onClose,
|
||||
}: {
|
||||
medicationId: string | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [jsonText, setJsonText] = useState("");
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [resultType, setResultType] = useState<"success" | "error" | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedFileName, setSelectedFileName] = useState<string | null>(null);
|
||||
const [fileInputKey, setFileInputKey] = useState(0);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
function resetImportForm() {
|
||||
setJsonText("");
|
||||
setResult(null);
|
||||
setResultType(null);
|
||||
setSelectedFileName(null);
|
||||
setFileInputKey((prev) => prev + 1);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
resetImportForm();
|
||||
onClose();
|
||||
}
|
||||
|
||||
async function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setResult(null);
|
||||
setResultType(null);
|
||||
|
||||
try {
|
||||
const content = await file.text();
|
||||
JSON.parse(content);
|
||||
setJsonText(content);
|
||||
setSelectedFileName(file.name);
|
||||
setResult(t("medications.importFileLoaded", { name: file.name }));
|
||||
setResultType("success");
|
||||
} catch {
|
||||
setResult(t("medications.importInvalidJson"));
|
||||
setResultType("error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!medicationId || !jsonText.trim()) return;
|
||||
setImporting(true);
|
||||
setResult(null);
|
||||
setResultType(null);
|
||||
|
||||
try {
|
||||
let data = JSON.parse(jsonText.trim());
|
||||
// Support both array and object-with-array
|
||||
if (!Array.isArray(data)) {
|
||||
const arrKey = Object.keys(data).find((k) => Array.isArray(data[k]));
|
||||
if (arrKey) data = data[arrKey];
|
||||
else throw new Error(t("medications.importNoArray"));
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`/api/medications/${medicationId}/intake/import`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
},
|
||||
);
|
||||
|
||||
const json = await res.json();
|
||||
if (res.ok) {
|
||||
const d = json.data;
|
||||
setResult(
|
||||
t("medications.importResult", { imported: d.imported }) +
|
||||
(d.skippedDuplicates > 0
|
||||
? `, ${t("medications.importDuplicatesSkipped", { count: d.skippedDuplicates })}`
|
||||
: "") +
|
||||
(d.skippedInvalid > 0
|
||||
? `, ${t("medications.importInvalidSkipped", { count: d.skippedInvalid })}`
|
||||
: ""),
|
||||
);
|
||||
setResultType("success");
|
||||
void invalidateKeys(queryClient, medicationDependentKeys);
|
||||
} else {
|
||||
setResult(json.error || t("medications.importFailed"));
|
||||
setResultType("error");
|
||||
}
|
||||
} catch (err) {
|
||||
setResult(
|
||||
err instanceof SyntaxError
|
||||
? t("medications.importInvalidFormat")
|
||||
: (err as Error).message || t("medications.importFailed"),
|
||||
);
|
||||
setResultType("error");
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={!!medicationId} onOpenChange={() => handleClose()}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("medications.importIntakes")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("medications.importDescription")}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="intake-import-file" className="text-xs font-medium">
|
||||
{t("medications.importUploadFile")}
|
||||
</Label>
|
||||
<input
|
||||
key={fileInputKey}
|
||||
id="intake-import-file"
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={handleFileSelect}
|
||||
className="border-input bg-background text-foreground file:bg-muted file:text-foreground w-full cursor-pointer rounded-md border text-sm file:mr-2 file:border-0 file:px-3 file:py-2"
|
||||
/>
|
||||
{selectedFileName && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.importSelected", { name: selectedFileName })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<pre className="bg-muted text-muted-foreground rounded-lg p-3 text-xs">
|
||||
{`[
|
||||
{"datum": "2026-02-14", "uhrzeit": "10:27:43", "zaehler": 523},
|
||||
{"datum": "2026-02-14", "uhrzeit": "23:33:42", "zaehler": 524}
|
||||
]`}
|
||||
</pre>
|
||||
<Textarea
|
||||
value={jsonText}
|
||||
onChange={(e) => setJsonText(e.target.value)}
|
||||
placeholder={t("medications.importPaste")}
|
||||
rows={8}
|
||||
// JSON paste — disable sentence-case and spell-check so the
|
||||
// primitive defaults don't munge structured input.
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
className="font-mono"
|
||||
/>
|
||||
{result && (
|
||||
<p
|
||||
className={`text-sm ${resultType === "success" ? "text-dracula-green" : "text-destructive"}`}
|
||||
>
|
||||
{result}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
disabled={importing}
|
||||
aria-label={t("common.moreOptions")}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={resetImportForm}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
{t("common.reset")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={importing}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={importing || !jsonText.trim()}
|
||||
aria-busy={importing || undefined}
|
||||
>
|
||||
{/*
|
||||
v1.4.33 IW9 — icon swap (not append) on `importing`
|
||||
to keep the button width stable through the
|
||||
in-flight request. Pre-v1.4.33 both icons painted
|
||||
during loading and the button grew by ~24 px, which
|
||||
showed up as a CLS hit on the medications import
|
||||
dialog.
|
||||
*/}
|
||||
{importing ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t("common.import")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiEndpointDialog({
|
||||
medication,
|
||||
onClose,
|
||||
}: {
|
||||
medication: { id: string; name: string } | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
type ExampleType = "curl" | "wget" | "fetch" | "powershell";
|
||||
|
||||
const apiEndpointKey = ["medication-api-endpoint", medication?.id];
|
||||
|
||||
type ApiEndpointStatus = { enabled: boolean; activeTokenCount: number };
|
||||
|
||||
const {
|
||||
data: status,
|
||||
isFetching: loadingStatus,
|
||||
refetch: refetchStatus,
|
||||
error: statusError,
|
||||
} = useQuery<ApiEndpointStatus>({
|
||||
queryKey: apiEndpointKey,
|
||||
queryFn: async () => {
|
||||
const res = await fetch(
|
||||
`/api/medications/${medication!.id}/api-endpoint`,
|
||||
);
|
||||
const json = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(json.error || t("medications.statusLoadFailed"));
|
||||
}
|
||||
return {
|
||||
enabled: json.data.enabled === true,
|
||||
activeTokenCount: json.data.activeTokenCount ?? 0,
|
||||
};
|
||||
},
|
||||
enabled: !!medication,
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
});
|
||||
|
||||
const enabled = status?.enabled ?? false;
|
||||
const activeTokenCount = status?.activeTokenCount ?? 0;
|
||||
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [toggling, setToggling] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const [exampleType, setExampleType] = useState<ExampleType>("curl");
|
||||
|
||||
const displayMsg =
|
||||
msg ?? (statusError instanceof Error ? statusError.message : null);
|
||||
|
||||
function handleClose() {
|
||||
queryClient.removeQueries({ queryKey: apiEndpointKey });
|
||||
setToken(null);
|
||||
setMsg(null);
|
||||
setCopied(null);
|
||||
setExampleType("curl");
|
||||
onClose();
|
||||
}
|
||||
|
||||
async function toggleEndpoint(nextEnabled: boolean) {
|
||||
if (!medication) return;
|
||||
setToggling(true);
|
||||
setToken(null);
|
||||
setMsg(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/medications/${medication.id}/api-endpoint`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: nextEnabled }),
|
||||
},
|
||||
);
|
||||
const json = await res.json();
|
||||
if (!res.ok) {
|
||||
setMsg(json.error || t("medications.changeFailed"));
|
||||
return;
|
||||
}
|
||||
|
||||
const nextStatus: ApiEndpointStatus = {
|
||||
enabled: json.data.enabled === true,
|
||||
activeTokenCount:
|
||||
typeof json.data.activeTokenCount === "number"
|
||||
? json.data.activeTokenCount
|
||||
: !json.data.enabled
|
||||
? 0
|
||||
: (status?.activeTokenCount ?? 0),
|
||||
};
|
||||
queryClient.setQueryData<ApiEndpointStatus>(apiEndpointKey, nextStatus);
|
||||
|
||||
if (json.data.token) {
|
||||
setToken(json.data.token);
|
||||
}
|
||||
|
||||
setMsg(
|
||||
nextEnabled
|
||||
? t("medications.apiEndpointActivated")
|
||||
: t("medications.apiEndpointDeactivated"),
|
||||
);
|
||||
} catch {
|
||||
setMsg(t("medications.changeFailed"));
|
||||
} finally {
|
||||
setToggling(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(text: string, key: string) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(key);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
}
|
||||
|
||||
const baseUrl =
|
||||
typeof window !== "undefined" ? window.location.origin : "https://...";
|
||||
const endpoint = `${baseUrl}/api/ingest/medication`;
|
||||
const payload = `{"medicationName":"${medication?.name ?? ""}","idempotencyKey":"einnahme-202602191230"}`;
|
||||
const curlCmd = `curl -X POST ${endpoint} \\
|
||||
-H "Authorization: Bearer ${token ?? "DEIN_TOKEN"}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '${payload}'`;
|
||||
const wgetCmd = `wget --method=POST "${endpoint}" \\
|
||||
--header="Authorization: Bearer ${token ?? "DEIN_TOKEN"}" \\
|
||||
--header="Content-Type: application/json" \\
|
||||
--body-data='${payload}' \\
|
||||
-O -`;
|
||||
const fetchCmd = `await fetch("${endpoint}", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": "Bearer ${token ?? "DEIN_TOKEN"}",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
medicationName: "${medication?.name ?? ""}",
|
||||
idempotencyKey: "einnahme-" + Date.now()
|
||||
})
|
||||
});`;
|
||||
const powershellCmd = `Invoke-RestMethod -Method Post -Uri "${endpoint}" \\
|
||||
-Headers @{ Authorization = "Bearer ${token ?? "DEIN_TOKEN"}" } \\
|
||||
-ContentType "application/json" \\
|
||||
-Body '${payload}'`;
|
||||
|
||||
const exampleMap: Record<ExampleType, { label: string; value: string }> = {
|
||||
curl: { label: "cURL", value: curlCmd },
|
||||
wget: { label: "wget", value: wgetCmd },
|
||||
fetch: { label: "JavaScript fetch", value: fetchCmd },
|
||||
powershell: { label: "PowerShell", value: powershellCmd },
|
||||
};
|
||||
const selectedExample = exampleMap[exampleType];
|
||||
|
||||
return (
|
||||
<Dialog open={!!medication} onOpenChange={() => handleClose()}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("medications.apiEndpointTitle", {
|
||||
name: medication?.name ?? "",
|
||||
})}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("medications.apiEndpointDescription")}
|
||||
</p>
|
||||
|
||||
<div className="bg-muted/40 rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t("medications.apiEndpointActive")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{enabled
|
||||
? t("medications.apiTokenCount", {
|
||||
count: activeTokenCount,
|
||||
})
|
||||
: t("common.disabled")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={loadingStatus || toggling}
|
||||
onCheckedChange={(checked) => {
|
||||
void toggleEndpoint(checked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Endpoint URL */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">Endpoint</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="bg-muted flex-1 rounded px-3 py-2 font-mono text-xs break-all">
|
||||
POST {endpoint}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => copyText(endpoint, "url")}
|
||||
aria-label={t("common.copy")}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{copied === "url" && (
|
||||
<p className="text-dracula-green text-xs">{t("common.copied")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Token */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">
|
||||
{t("medications.apiToken")}
|
||||
</Label>
|
||||
{token ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="bg-muted flex-1 rounded px-3 py-2 font-mono text-xs break-all">
|
||||
{token}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => copyText(token, "token")}
|
||||
aria-label={t("common.copy")}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{enabled
|
||||
? t("medications.apiTokenActiveHint")
|
||||
: t("medications.apiTokenActivateHint")}
|
||||
</p>
|
||||
)}
|
||||
{token && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.apiTokenOnceHint")}
|
||||
</p>
|
||||
)}
|
||||
{copied === "token" && (
|
||||
<p className="text-dracula-green text-xs">{t("common.copied")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* request examples */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="text-xs font-medium">
|
||||
{t("medications.requestExample")}
|
||||
</Label>
|
||||
<Select
|
||||
value={exampleType}
|
||||
onValueChange={(value) => setExampleType(value as ExampleType)}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-[170px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="curl">cURL</SelectItem>
|
||||
<SelectItem value="wget">wget</SelectItem>
|
||||
<SelectItem value="fetch">JavaScript fetch</SelectItem>
|
||||
<SelectItem value="powershell">PowerShell</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<pre className="bg-muted rounded-lg p-3 font-mono text-xs break-all whitespace-pre-wrap">
|
||||
{selectedExample.value}
|
||||
</pre>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-1 right-1 h-7 w-7"
|
||||
onClick={() => copyText(selectedExample.value, "example")}
|
||||
aria-label={t("common.copy")}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{copied === "example" && (
|
||||
<p className="text-dracula-green text-xs">{t("common.copied")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{displayMsg && (
|
||||
<p
|
||||
className={`text-sm ${displayMsg === t("medications.apiEndpointActivated") || displayMsg === t("medications.apiEndpointDeactivated") ? "text-dracula-green" : "text-destructive"}`}
|
||||
>
|
||||
{displayMsg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
disabled={loadingStatus || toggling}
|
||||
aria-label={t("common.moreOptions")}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
void refetchStatus();
|
||||
}}
|
||||
disabled={loadingStatus || toggling}
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{t("medications.retryLoad")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleClose}>{t("common.close")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+8
-6
@@ -123,9 +123,9 @@ function getRangeColorClass(
|
||||
const inOrange =
|
||||
!inGreen && value >= range.orangeMin && value <= range.orangeMax;
|
||||
|
||||
if (inGreen) return "text-green-400";
|
||||
if (inOrange) return "text-orange-400";
|
||||
return "text-red-400";
|
||||
if (inGreen) return "text-success";
|
||||
if (inOrange) return "text-warning";
|
||||
return "text-destructive";
|
||||
}
|
||||
|
||||
function getRangeHint(
|
||||
@@ -142,20 +142,22 @@ function getRangeHint(
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
<span className="font-bold text-green-400">
|
||||
<span className="text-success font-bold">
|
||||
{t("charts.colorGreen")}
|
||||
</span>{" "}
|
||||
{format(range.greenMin)}-{format(range.greenMax)} {unit}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-bold text-orange-400">
|
||||
<span className="text-warning font-bold">
|
||||
{t("charts.colorOrange")}
|
||||
</span>{" "}
|
||||
{format(range.orangeMin)}-{format(range.greenMin)} {t("common.or")}{" "}
|
||||
{format(range.greenMax)}-{format(range.orangeMax)} {unit}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-bold text-red-400">{t("charts.colorRed")}</span>{" "}
|
||||
<span className="text-destructive font-bold">
|
||||
{t("charts.colorRed")}
|
||||
</span>{" "}
|
||||
{"< "}
|
||||
{format(range.orangeMin)} {t("common.or")} {"> "}
|
||||
{format(range.orangeMax)} {unit}
|
||||
|
||||
@@ -236,9 +236,9 @@ export default function TargetsPage() {
|
||||
|
||||
{/* Profile incomplete hint */}
|
||||
{profileIncomplete && (
|
||||
<Card className="border-l-4 border-l-orange-500">
|
||||
<Card className="border-warning border-l-4">
|
||||
<CardContent className="flex gap-3 py-3">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-orange-500" />
|
||||
<AlertCircle className="text-warning mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t("targets.profileIncomplete")}
|
||||
|
||||
@@ -532,3 +532,32 @@ export function useSystemStatus() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Helpful-rate tint shared by the AI-quality + Coach-feedback tables.
|
||||
* Green ≥80 %, yellow ≥50 %, orange below. */
|
||||
export function helpfulRateColour(rate: number): string {
|
||||
if (rate >= 0.8) return "text-dracula-green";
|
||||
if (rate >= 0.5) return "text-dracula-yellow";
|
||||
return "text-dracula-orange";
|
||||
}
|
||||
|
||||
export interface PublicVersion {
|
||||
version: string;
|
||||
buildSha: string | null;
|
||||
builtAt: string | null;
|
||||
offlineGeoEnabled?: boolean;
|
||||
}
|
||||
|
||||
/** Shared `/api/version` reader — the system-status section + overview
|
||||
* summary both surface app version / build SHA / offline-geo state. */
|
||||
export function usePublicVersion() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.publicVersion(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/version");
|
||||
if (!res.ok) throw new Error("Failed to load version");
|
||||
return (await res.json()).data as PublicVersion;
|
||||
},
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
@@ -17,18 +17,20 @@ import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
Database,
|
||||
FileText,
|
||||
Info,
|
||||
KeyRound,
|
||||
Inbox,
|
||||
MessageSquare,
|
||||
Plug,
|
||||
Radio,
|
||||
ScrollText,
|
||||
Server,
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
@@ -65,12 +67,12 @@ export const ADMIN_SECTIONS: readonly AdminSection[] = [
|
||||
{
|
||||
slug: "services",
|
||||
titleKey: "admin.section.services.title",
|
||||
icon: Plug,
|
||||
icon: Radio,
|
||||
},
|
||||
{
|
||||
slug: "integrations",
|
||||
titleKey: "admin.section.integrations.title",
|
||||
icon: AlertTriangle,
|
||||
icon: Plug,
|
||||
},
|
||||
{
|
||||
slug: "ai-quality",
|
||||
@@ -80,12 +82,12 @@ export const ADMIN_SECTIONS: readonly AdminSection[] = [
|
||||
{
|
||||
slug: "assistant",
|
||||
titleKey: "admin.section.assistant.title",
|
||||
icon: Sparkles,
|
||||
icon: SlidersHorizontal,
|
||||
},
|
||||
{
|
||||
slug: "coach-feedback",
|
||||
titleKey: "admin.section.coach-feedback.title",
|
||||
icon: Sparkles,
|
||||
icon: MessageSquare,
|
||||
},
|
||||
{
|
||||
slug: "feedback",
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Sparkles, Loader2 } from "lucide-react";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { helpfulRateColour } from "./_shared";
|
||||
|
||||
interface FeedbackBucket {
|
||||
severity: string;
|
||||
@@ -53,12 +54,6 @@ const SEVERITY_TINT: Record<string, string> = {
|
||||
urgent: "text-dracula-red",
|
||||
};
|
||||
|
||||
function helpfulRateColour(rate: number): string {
|
||||
if (rate >= 0.8) return "text-dracula-green";
|
||||
if (rate >= 0.5) return "text-dracula-yellow";
|
||||
return "text-dracula-orange";
|
||||
}
|
||||
|
||||
export function AiQualitySection() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Loader2, Sparkles } from "lucide-react";
|
||||
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { helpfulRateColour } from "./_shared";
|
||||
|
||||
interface CoachFeedbackBucket {
|
||||
promptVersion: string;
|
||||
@@ -45,12 +46,6 @@ interface CoachFeedbackResponse {
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
function helpfulRateColour(rate: number): string {
|
||||
if (rate >= 0.8) return "text-dracula-green";
|
||||
if (rate >= 0.5) return "text-dracula-yellow";
|
||||
return "text-dracula-orange";
|
||||
}
|
||||
|
||||
export function CoachFeedbackSection() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
@@ -148,6 +148,10 @@ export function HostMetricsChart() {
|
||||
[data?.samples],
|
||||
);
|
||||
|
||||
// Total physical memory (latest sample) — lets the tooltip pair the
|
||||
// percentage with an absolute "X.X GiB / Y.Y GiB" label. 0 ⇒ omit.
|
||||
const memTotalGiB = (data?.meta.memTotalBytes ?? 0) / 1_073_741_824;
|
||||
|
||||
// Loading skeleton: same footprint as the chart so the panel doesn't
|
||||
// jump when data lands. Animated only when motion isn't reduced.
|
||||
if (isLoading) {
|
||||
@@ -282,6 +286,13 @@ export function HostMetricsChart() {
|
||||
return [fmt.number(value, 2), name];
|
||||
}
|
||||
if (name === t("admin.hostMetrics.memUsedPercent")) {
|
||||
if (memTotalGiB > 0) {
|
||||
const usedGiB = (value / 100) * memTotalGiB;
|
||||
return [
|
||||
`${fmt.number(value, 1)} % · ${fmt.number(usedGiB, 1)} / ${fmt.number(memTotalGiB, 1)} GiB`,
|
||||
name,
|
||||
];
|
||||
}
|
||||
return [`${fmt.number(value, 1)} %`, name];
|
||||
}
|
||||
if (name === t("admin.hostMetrics.diskBusyPercent")) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Bell, Globe, Key, MessageCircle } from "lucide-react";
|
||||
import { Bell, Globe, Key, MessageCircle, Smile } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SettingsToggle, useAdminSettings, useUpdateSettings } from "./_shared";
|
||||
|
||||
@@ -49,6 +49,16 @@ export function ServicesSection() {
|
||||
}
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label="moodLog"
|
||||
description={t("admin.moodLogGlobal")}
|
||||
icon={Smile}
|
||||
checked={settings?.moodLogGlobal ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSettings.mutate({ moodLogGlobal: checked })
|
||||
}
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label="API"
|
||||
description={t("admin.apiGlobal")}
|
||||
|
||||
@@ -18,31 +18,10 @@ import {
|
||||
Server,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { StatusItem, useSystemStatus } from "./_shared";
|
||||
|
||||
interface VersionResponse {
|
||||
version: string;
|
||||
buildSha: string | null;
|
||||
builtAt: string | null;
|
||||
offlineGeoEnabled?: boolean;
|
||||
}
|
||||
|
||||
function useOfflineGeoState() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.publicVersion(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/version");
|
||||
if (!res.ok) throw new Error("Failed to load version");
|
||||
return (await res.json()).data as VersionResponse;
|
||||
},
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared";
|
||||
|
||||
// v1.4.16 phase B3: Recharts is ~108 KiB Brotli — defer-load the host
|
||||
// metrics chart so the system-status page only ships it when an admin
|
||||
@@ -66,7 +45,7 @@ export function SystemStatusSection() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { data: status, isError, refetch, isFetching } = useSystemStatus();
|
||||
const { data: version } = useOfflineGeoState();
|
||||
const { data: version } = usePublicVersion();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -22,35 +22,14 @@ import {
|
||||
Server,
|
||||
Tag,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { StatusItem, useSystemStatus } from "./_shared";
|
||||
|
||||
interface VersionResponse {
|
||||
version: string;
|
||||
buildSha: string | null;
|
||||
builtAt: string | null;
|
||||
offlineGeoEnabled?: boolean;
|
||||
}
|
||||
|
||||
function useVersion() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.publicVersion(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/version");
|
||||
if (!res.ok) throw new Error("Failed to load version");
|
||||
return (await res.json()).data as VersionResponse;
|
||||
},
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared";
|
||||
|
||||
export function SystemStatusSummary() {
|
||||
const { t } = useTranslations();
|
||||
const { data: status, isError } = useSystemStatus();
|
||||
const { data: version } = useVersion();
|
||||
const { data: version } = usePublicVersion();
|
||||
|
||||
return (
|
||||
<section
|
||||
|
||||
@@ -89,19 +89,25 @@ describe("<TrendsRow>", () => {
|
||||
expect(slots.length).toBe(3);
|
||||
});
|
||||
|
||||
it("pins every chart slot to h-[140px] so Recharts mounts with a known size (CLS fix)", () => {
|
||||
it("pins every chart slot to h-[180px] so Recharts mounts with a known size (CLS fix)", () => {
|
||||
// v1.4.36 W2 — without an explicit height the Recharts
|
||||
// ResponsiveContainer mounts with width=-1 height=-1 and emits a
|
||||
// console warning per chart. The mood card also drifted taller
|
||||
// than BP / weight on hydration. Pinning the wrapper at 140 px
|
||||
// matches the mini-chart's internal default; the row paints with
|
||||
// zero layout shift.
|
||||
// than BP / weight on hydration.
|
||||
//
|
||||
// v1.5.4.x — wrapper raised from 140 to 180 px. The 140 px slot
|
||||
// matched the chart's internal painting area, but the mini-mode
|
||||
// card shell adds ~34 px of header + padding above it. The
|
||||
// resulting overflow pushed the chart envelope down into the
|
||||
// TrendAnnotation row below — visible as text sitting on top of
|
||||
// the chart. 180 px absorbs the full envelope so the three tiles
|
||||
// share one chart-band baseline without overlap.
|
||||
const html = render(<TrendsRow />);
|
||||
const slots =
|
||||
html.match(/data-slot="trends-row-chart-slot"[^>]*class="[^"]*"/g) ?? [];
|
||||
expect(slots.length).toBe(3);
|
||||
for (const slot of slots) {
|
||||
expect(slot).toMatch(/h-\[140px\]/);
|
||||
expect(slot).toMatch(/h-\[180px\]/);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -3,11 +3,12 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
// The user bubble pulls `useAuth().user.gravatarUrl` to mirror the
|
||||
// Coach avatar (v1.4.22 B3). SSR rendering through `renderToStaticMarkup`
|
||||
// has no TanStack-Query provider, so we mock the hook the same way
|
||||
// The user bubble pulls `useAuth().user.avatarUrl` to mirror the
|
||||
// Coach avatar (v1.5.5 — self-hosted avatar; replaces v1.4.22 B3
|
||||
// Gravatar leak). SSR rendering through `renderToStaticMarkup` has
|
||||
// no TanStack-Query provider, so we mock the hook the same way
|
||||
// `recommendation-feedback.test.tsx` does — returning a tester user
|
||||
// with no Gravatar so we exercise the initials fallback path by
|
||||
// with no avatar so we exercise the initials fallback path by
|
||||
// default, and override per-test where needed.
|
||||
const useAuthMock = vi.fn<
|
||||
() => {
|
||||
@@ -15,7 +16,7 @@ const useAuthMock = vi.fn<
|
||||
id: string;
|
||||
username: string;
|
||||
role: string;
|
||||
gravatarUrl: string | null;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
@@ -25,7 +26,7 @@ const useAuthMock = vi.fn<
|
||||
id: "test-user",
|
||||
username: "tester",
|
||||
role: "USER",
|
||||
gravatarUrl: null,
|
||||
avatarUrl: null,
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
@@ -377,15 +378,15 @@ describe("<MessageThread>", () => {
|
||||
expect(html).not.toContain("What I'm looking at");
|
||||
});
|
||||
|
||||
it("renders the user bubble with a Gravatar when one is set (B3 parity)", () => {
|
||||
// v1.4.22 B3 — the user-bubble avatar uses the same dimensions
|
||||
// as the Coach avatar and pulls the Gravatar URL from useAuth.
|
||||
it("renders the user bubble with the self-hosted avatar when one is set (v1.5.5)", () => {
|
||||
// The user-bubble avatar uses the same dimensions as the Coach
|
||||
// avatar and pulls the self-hosted avatar URL from useAuth.
|
||||
useAuthMock.mockReturnValueOnce({
|
||||
user: {
|
||||
id: "u-1",
|
||||
username: "marc",
|
||||
role: "USER",
|
||||
gravatarUrl: "https://www.gravatar.com/avatar/abc123?s=64&d=mp",
|
||||
avatarUrl: "/api/user/avatar/u-1?v=1700000000000",
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
@@ -393,15 +394,15 @@ describe("<MessageThread>", () => {
|
||||
const html = render(<MessageThread conversation={baseConversation} />);
|
||||
expect(html).toContain('data-slot="coach-bubble-user-avatar"');
|
||||
expect(html).toMatch(
|
||||
/<img[^>]+src="https:\/\/www\.gravatar\.com\/avatar\/abc123/,
|
||||
/<img[^>]+src="\/api\/user\/avatar\/u-1\?v=1700000000000/,
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/data-slot="coach-bubble-user-avatar"[^>]*class="[^"]*size-8/,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to initials when no Gravatar URL is present", () => {
|
||||
// Default mock returns gravatarUrl: null → initials path.
|
||||
it("falls back to initials when no avatar URL is present", () => {
|
||||
// Default mock returns avatarUrl: null → initials path.
|
||||
const html = render(<MessageThread conversation={baseConversation} />);
|
||||
expect(html).toContain('data-slot="coach-bubble-user-avatar"');
|
||||
expect(html).toMatch(/data-slot="coach-bubble-user-avatar"[^>]*>TE</);
|
||||
|
||||
@@ -355,11 +355,11 @@ function ChatBubble({
|
||||
// as expanded vs collapsed.
|
||||
const [evidenceOpen, setEvidenceOpen] = useState(false);
|
||||
if (role === "user") {
|
||||
// v1.4.22 B3 — pull the user's Gravatar so the user bubble's
|
||||
// avatar matches the Coach avatar in size and visual weight.
|
||||
// Falls back to the generic person glyph when the user hasn't
|
||||
// configured an email (or the Gravatar lookup returned null).
|
||||
const gravatarUrl = user?.gravatarUrl ?? null;
|
||||
// v1.5.5 — pull the user's self-hosted avatar so the user
|
||||
// bubble matches the Coach avatar in size and visual weight.
|
||||
// Falls back to initials when the user has not uploaded an
|
||||
// avatar. Replaces the Gravatar leak (v1.4.22 B3).
|
||||
const avatarUrl = user?.avatarUrl ?? null;
|
||||
const initials = user?.username
|
||||
? user.username.slice(0, 2).toUpperCase()
|
||||
: null;
|
||||
@@ -377,10 +377,10 @@ function ChatBubble({
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
{gravatarUrl ? (
|
||||
{avatarUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={gravatarUrl}
|
||||
src={avatarUrl}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
data-slot="coach-bubble-user-avatar"
|
||||
|
||||
@@ -138,7 +138,7 @@ export function TrendsRow({
|
||||
wrapper (which carries a heavier shell on a default
|
||||
shadcn Card) lines up with the BP/weight tiles' lighter
|
||||
shell. Both chart types ship the same data-slot now. */}
|
||||
<div data-slot="trends-row-chart-slot" className="h-[140px] shrink-0">
|
||||
<div data-slot="trends-row-chart-slot" className="h-[180px] shrink-0">
|
||||
<HealthChartDynamicMini
|
||||
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
|
||||
title={t("charts.bloodPressure")}
|
||||
@@ -161,7 +161,7 @@ export function TrendsRow({
|
||||
data-metric="weight"
|
||||
className="flex h-full flex-col gap-2 md:min-h-[300px]"
|
||||
>
|
||||
<div data-slot="trends-row-chart-slot" className="h-[140px] shrink-0">
|
||||
<div data-slot="trends-row-chart-slot" className="h-[180px] shrink-0">
|
||||
<HealthChartDynamicMini
|
||||
types={["WEIGHT"]}
|
||||
title={t("charts.weight")}
|
||||
@@ -183,7 +183,7 @@ export function TrendsRow({
|
||||
data-metric="mood"
|
||||
className="flex h-full flex-col gap-2 md:min-h-[300px]"
|
||||
>
|
||||
<div data-slot="trends-row-chart-slot" className="h-[140px] shrink-0">
|
||||
<div data-slot="trends-row-chart-slot" className="h-[180px] shrink-0">
|
||||
<MoodChart
|
||||
title={t("charts.mood")}
|
||||
mini
|
||||
|
||||
@@ -18,7 +18,7 @@ const mockUserRef = {
|
||||
username: "marc",
|
||||
email: "marc@example.com",
|
||||
role: "USER" as "USER" | "ADMIN",
|
||||
gravatarUrl: null,
|
||||
avatarUrl: null,
|
||||
},
|
||||
};
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
@@ -89,7 +89,7 @@ describe("<SidebarNav> admin entry mirrors Settings (no sub-item expansion)", ()
|
||||
// sub-items on `/admin/*` was unwanted UX — the in-shell `<AdminShell>`
|
||||
// already provides per-section nav inside the page. The Admin entry
|
||||
// must behave EXACTLY like the Settings entry: a single link with no
|
||||
// sub-list at any route, regardless of where the gravatar dropdown is.
|
||||
// sub-list at any route, regardless of where the avatar dropdown is.
|
||||
|
||||
it("does not render admin entries at all for a regular user", () => {
|
||||
const html = render({ role: "USER", pathname: "/admin/system-status" });
|
||||
|
||||
@@ -100,7 +100,7 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
|
||||
const logout = useLogout();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { t } = useTranslations();
|
||||
const gravatarUrl = user?.gravatarUrl ?? null;
|
||||
const avatarUrl = user?.avatarUrl ?? null;
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
@@ -122,7 +122,7 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
|
||||
>
|
||||
<div className="flex items-center gap-3 px-2 py-2">
|
||||
<Avatar className="h-9 w-9 shrink-0">
|
||||
{gravatarUrl && <AvatarImage src={gravatarUrl} alt={user.username} />}
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
|
||||
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
|
||||
{getInitials(user.username)}
|
||||
</AvatarFallback>
|
||||
@@ -197,8 +197,8 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
|
||||
className="hover:bg-accent focus-visible:ring-ring/50 shrink-0 rounded-md p-1.5 transition-colors focus:outline-none focus-visible:ring-[3px]"
|
||||
>
|
||||
<Avatar className="h-8 w-8">
|
||||
{gravatarUrl && (
|
||||
<AvatarImage src={gravatarUrl} alt={user.username} />
|
||||
{avatarUrl && (
|
||||
<AvatarImage src={avatarUrl} alt={user.username} />
|
||||
)}
|
||||
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
|
||||
{getInitials(user.username)}
|
||||
@@ -216,7 +216,7 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
|
||||
<div className="border-sidebar-border border-t p-3">
|
||||
<div className="flex items-center gap-3 px-2 py-1">
|
||||
<Avatar className="h-8 w-8 shrink-0">
|
||||
{gravatarUrl && <AvatarImage src={gravatarUrl} alt={user.username} />}
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
|
||||
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
|
||||
{getInitials(user.username)}
|
||||
</AvatarFallback>
|
||||
|
||||
@@ -24,8 +24,9 @@ import { Loader2, MoreHorizontal, Plus, RotateCcw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys";
|
||||
import { MEASUREMENT_NOTES_MAX_LENGTH } from "@/lib/validations/measurement";
|
||||
|
||||
const MAX_COMMENT_LENGTH = 25;
|
||||
const MAX_COMMENT_LENGTH = MEASUREMENT_NOTES_MAX_LENGTH;
|
||||
|
||||
const MEASUREMENT_TYPES = [
|
||||
{
|
||||
|
||||
@@ -71,6 +71,16 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
|
||||
// ── v1.4.30 R-F T1.4 + T1.5 ──
|
||||
WALKING_STEADINESS: "measurements.typeWalkingSteadiness",
|
||||
AUDIO_EXPOSURE_EVENT: "measurements.typeAudioExposureEvent",
|
||||
// ── v1.5.5 iOS-coord additions ──
|
||||
RESPIRATORY_RATE: "measurements.typeRespiratoryRate",
|
||||
BODY_MASS_INDEX: "measurements.typeBodyMassIndex",
|
||||
LEAN_BODY_MASS: "measurements.typeLeanBodyMass",
|
||||
WALKING_HEART_RATE_AVERAGE: "measurements.typeWalkingHeartRateAverage",
|
||||
WALKING_ASYMMETRY: "measurements.typeWalkingAsymmetry",
|
||||
WALKING_DOUBLE_SUPPORT: "measurements.typeWalkingDoubleSupport",
|
||||
// ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ──
|
||||
WALKING_STEP_LENGTH: "measurements.typeWalkingStepLength",
|
||||
WALKING_SPEED: "measurements.typeWalkingSpeed",
|
||||
};
|
||||
|
||||
export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
|
||||
@@ -119,6 +129,22 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
|
||||
// event flag — the event is a louder-cousin of the env quantity.
|
||||
WALKING_STEADINESS: Gauge,
|
||||
AUDIO_EXPOSURE_EVENT: Volume2,
|
||||
// ── v1.5.5 iOS-coord additions ──
|
||||
// Wind already carries the breathing family (SpO2 uses it);
|
||||
// Scale carries body-comp; HeartPulse rounds out the cardio
|
||||
// pair; Footprints + Gauge live in the gait family.
|
||||
RESPIRATORY_RATE: Wind,
|
||||
BODY_MASS_INDEX: Scale,
|
||||
LEAN_BODY_MASS: Scale,
|
||||
WALKING_HEART_RATE_AVERAGE: HeartPulse,
|
||||
WALKING_ASYMMETRY: Footprints,
|
||||
WALKING_DOUBLE_SUPPORT: Footprints,
|
||||
// ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ──
|
||||
// Footprints carries the stride/length signal; Gauge mirrors
|
||||
// the velocity-reading shape (same family as VO2_MAX +
|
||||
// WALKING_STEADINESS).
|
||||
WALKING_STEP_LENGTH: Footprints,
|
||||
WALKING_SPEED: Gauge,
|
||||
};
|
||||
|
||||
export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
|
||||
@@ -161,4 +187,20 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
|
||||
// ── v1.4.30 R-F T1.4 + T1.5 ──
|
||||
WALKING_STEADINESS: "bg-chart-2/20 text-chart-2",
|
||||
AUDIO_EXPOSURE_EVENT: "bg-chart-5/20 text-chart-5",
|
||||
// ── v1.5.5 iOS-coord additions ──
|
||||
// Reuse the existing palette families: chart-5 (cardio/pulse),
|
||||
// chart-1 (mass), chart-2 (activity/gait).
|
||||
RESPIRATORY_RATE: "bg-chart-5/20 text-chart-5",
|
||||
BODY_MASS_INDEX: "bg-chart-1/20 text-chart-1",
|
||||
LEAN_BODY_MASS: "bg-chart-1/20 text-chart-1",
|
||||
WALKING_HEART_RATE_AVERAGE: "bg-chart-3/20 text-chart-3",
|
||||
WALKING_ASYMMETRY: "bg-chart-2/20 text-chart-2",
|
||||
WALKING_DOUBLE_SUPPORT: "bg-chart-2/20 text-chart-2",
|
||||
// ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ──
|
||||
// Stay in chart-2 (Dracula green) — the entire Mobility cluster
|
||||
// (steadiness + asymmetry + double-support + step length + speed)
|
||||
// shares the activity-family colour so the gait cards read as one
|
||||
// visual group on Insights.
|
||||
WALKING_STEP_LENGTH: "bg-chart-2/20 text-chart-2",
|
||||
WALKING_SPEED: "bg-chart-2/20 text-chart-2",
|
||||
};
|
||||
@@ -60,6 +60,7 @@ import { formatDateOrRelative, formatDateTime } from "@/lib/format";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { CUMULATIVE_DAY_SUM_TYPES } from "@/lib/measurements/cumulative-day-sum";
|
||||
import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys";
|
||||
import { MEASUREMENT_NOTES_MAX_LENGTH } from "@/lib/validations/measurement";
|
||||
import { DateTimeInput } from "@/components/ui/date-input";
|
||||
import {
|
||||
MEASUREMENT_TYPE_LABEL_KEYS as TYPE_LABEL_KEYS,
|
||||
@@ -105,11 +106,14 @@ interface MeasurementListProps {
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
const MAX_COMMENT_LENGTH = 25;
|
||||
// Input cap mirrors the server Zod bound. The list preview truncates at
|
||||
// a shorter width so a long note does not stretch a list row.
|
||||
const MAX_COMMENT_LENGTH = MEASUREMENT_NOTES_MAX_LENGTH;
|
||||
const COMMENT_PREVIEW_LENGTH = 40;
|
||||
|
||||
function truncateComment(comment: string): string {
|
||||
if (comment.length <= MAX_COMMENT_LENGTH) return comment;
|
||||
return `${comment.slice(0, MAX_COMMENT_LENGTH - 1)}…`;
|
||||
if (comment.length <= COMMENT_PREVIEW_LENGTH) return comment;
|
||||
return `${comment.slice(0, COMMENT_PREVIEW_LENGTH - 1)}…`;
|
||||
}
|
||||
|
||||
function toDateTimeLocalValue(isoString: string): string {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* v1.5.6 G-1 §5 — AdvancedSettingsSheet composition contract.
|
||||
*
|
||||
* The sheet hosts the three moved sections in routine → rare →
|
||||
* destructive order: Notifications → Settings → destructive zone.
|
||||
* `<ResponsiveSheet>` wraps Radix portals that `renderToStaticMarkup`
|
||||
* does not materialise, so we mock it to a passthrough that renders
|
||||
* children only when `open`, and mock the three sections to ordered
|
||||
* markers. The test pins:
|
||||
* - the three sections render in the documented order;
|
||||
* - `onRequestPhaseSheet` threads through to `<SettingsSection>`;
|
||||
* - the destructive zone's `onAfterAction` closes the sheet;
|
||||
* - the sheet renders nothing when `open` is false.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("@/lib/i18n/context", () => ({
|
||||
useTranslations: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/responsive-sheet", () => ({
|
||||
ResponsiveSheet: ({
|
||||
open,
|
||||
children,
|
||||
title,
|
||||
}: {
|
||||
open: boolean;
|
||||
children: React.ReactNode;
|
||||
title: React.ReactNode;
|
||||
}) =>
|
||||
open ? (
|
||||
<div data-slot="mock-responsive-sheet">
|
||||
<div data-slot="mock-title">{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@/components/medications/sections/notifications-section",
|
||||
() => ({
|
||||
NotificationsSection: () => <div data-slot="mock-notifications" />,
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/components/medications/sections/settings-section", () => ({
|
||||
SettingsSection: ({
|
||||
onRequestPhaseSheet,
|
||||
}: {
|
||||
onRequestPhaseSheet?: () => void;
|
||||
}) => (
|
||||
<div
|
||||
data-slot="mock-settings"
|
||||
data-has-phase-swap={onRequestPhaseSheet ? "yes" : "no"}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@/components/medications/sections/destructive-zone-section",
|
||||
() => ({
|
||||
DestructiveZoneSection: ({
|
||||
onAfterAction,
|
||||
}: {
|
||||
onAfterAction?: () => void;
|
||||
}) => (
|
||||
<button
|
||||
data-slot="mock-destructive"
|
||||
data-has-after-action={onAfterAction ? "yes" : "no"}
|
||||
onClick={onAfterAction}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
import { AdvancedSettingsSheet } from "@/components/medications/advanced-settings-sheet";
|
||||
|
||||
function baseProps(open: boolean, onRequestPhaseSheet?: () => void) {
|
||||
return {
|
||||
open,
|
||||
onOpenChange: () => {},
|
||||
medicationId: "med-1",
|
||||
medicationName: "Test Drug",
|
||||
treatmentClass: "GLP1",
|
||||
active: true,
|
||||
startsOn: "2026-01-01",
|
||||
endsOn: "2026-06-01",
|
||||
notificationsEnabled: true,
|
||||
reminderGraceMinutes: 30,
|
||||
intakeCount: 5,
|
||||
onRequestPhaseSheet,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AdvancedSettingsSheet (G-1 §5)", () => {
|
||||
it("renders the three sections in routine → rare → destructive order", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AdvancedSettingsSheet {...baseProps(true, () => {})} />,
|
||||
);
|
||||
const notif = html.indexOf("mock-notifications");
|
||||
const settings = html.indexOf("mock-settings");
|
||||
const destructive = html.indexOf("mock-destructive");
|
||||
expect(notif).toBeGreaterThan(-1);
|
||||
expect(settings).toBeGreaterThan(notif);
|
||||
expect(destructive).toBeGreaterThan(settings);
|
||||
});
|
||||
|
||||
it("threads onRequestPhaseSheet through to the settings section", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AdvancedSettingsSheet {...baseProps(true, () => {})} />,
|
||||
);
|
||||
expect(html).toContain('data-has-phase-swap="yes"');
|
||||
});
|
||||
|
||||
it("passes an onAfterAction to the destructive zone so it can close the sheet", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AdvancedSettingsSheet {...baseProps(true)} />,
|
||||
);
|
||||
expect(html).toContain('data-has-after-action="yes"');
|
||||
});
|
||||
|
||||
it("renders nothing when closed", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AdvancedSettingsSheet {...baseProps(false)} />,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("titles the sheet with the advanced-settings key", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AdvancedSettingsSheet {...baseProps(true)} />,
|
||||
);
|
||||
expect(html).toContain("medications.detail.advanced.title");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* v1.5.5 D-3 §9 — detail-page query-key + invalidation cascade.
|
||||
*
|
||||
* Pins the contract every detail-page section reads/writes:
|
||||
*
|
||||
* §9.1 header → reads `medicationDetail(id)` (under `["medications"]`
|
||||
* prefix; lives in the bundle).
|
||||
* §9.2 today → POSTs intake → bundle invalidates compliance chart
|
||||
* (`compliance-chart-inline` prefix landed in the
|
||||
* bundle per §10 invariant 20).
|
||||
* §9.4 phases → `medicationPhaseConfig(id)` lives at
|
||||
* `["phase-config", id]`, NOT under `["medications"]`,
|
||||
* so the bundle does not catch it. The save mutation
|
||||
* must invalidate it explicitly (settings-section
|
||||
* does so via `[...medicationDependentKeys,
|
||||
* queryKeys.medicationPhaseConfig(id)]`).
|
||||
* §9.5 intake list → reads `medicationIntakeList(id, …)` under the
|
||||
* `["medications", id, "intake", "list"]` prefix.
|
||||
*
|
||||
* These tests guard the per-section query-key shape so a future
|
||||
* rename (e.g. introducing a `["medications"]` -> `["meds"]` factory)
|
||||
* doesn't silently drift across the eight section consumers.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
medicationDependentKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
|
||||
function asStrings(keys: readonly unknown[]): string[] {
|
||||
return keys.map((k) => JSON.stringify(k));
|
||||
}
|
||||
|
||||
function isUnderPrefix(key: readonly unknown[], prefix: readonly unknown[]) {
|
||||
if (key.length < prefix.length) return false;
|
||||
for (let i = 0; i < prefix.length; i++) {
|
||||
if (key[i] !== prefix[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
describe("medication-detail-page query-key cascade (D-3 §9)", () => {
|
||||
it("§9.1 header reads sit under the `[\"medications\"]` prefix so the bundle catches them", () => {
|
||||
const detail = queryKeys.medicationDetail("med-1");
|
||||
expect(isUnderPrefix(detail, queryKeys.medications())).toBe(true);
|
||||
});
|
||||
|
||||
it("§9.2 today's-dose mutation evicts the inline compliance chart via the bundle (C-E2-1 / §10 #20)", () => {
|
||||
const inlineChart = queryKeys.medicationComplianceChart("med-1");
|
||||
const bundleStrings = asStrings(medicationDependentKeys);
|
||||
// Hierarchical-prefix semantics — the per-id chart slot rides
|
||||
// under the `["compliance-chart-inline"]` prefix we added.
|
||||
expect(bundleStrings).toContain(
|
||||
JSON.stringify(["compliance-chart-inline"]),
|
||||
);
|
||||
expect(inlineChart[0]).toBe("compliance-chart-inline");
|
||||
});
|
||||
|
||||
it("§9.4 phase-config is OUTSIDE the bundle and the settings section explicitly invalidates it", () => {
|
||||
const phaseKey = queryKeys.medicationPhaseConfig("med-1");
|
||||
const bundleStrings = asStrings(medicationDependentKeys);
|
||||
expect(bundleStrings).not.toContain(JSON.stringify(phaseKey));
|
||||
expect(bundleStrings).not.toContain(JSON.stringify(["phase-config"]));
|
||||
// The settings section composes the bundle + the phase key on
|
||||
// save (see `phase-config-sheet.tsx` save handler). This guard
|
||||
// documents that contract.
|
||||
expect(phaseKey).toEqual(["phase-config", "med-1"]);
|
||||
});
|
||||
|
||||
it("§9.5 intake-history list keys ride under `[\"medications\", id, \"intake\", \"list\"]`", () => {
|
||||
const listKey = queryKeys.medicationIntakeList("med-1", {
|
||||
sortBy: "takenAt",
|
||||
sortDir: "desc",
|
||||
limit: 14,
|
||||
offset: 0,
|
||||
status: "completed",
|
||||
});
|
||||
expect(listKey.slice(0, 4)).toEqual([
|
||||
"medications",
|
||||
"med-1",
|
||||
"intake",
|
||||
"list",
|
||||
]);
|
||||
// And catches by the bundle's `["medications"]` prefix.
|
||||
expect(isUnderPrefix(listKey, queryKeys.medications())).toBe(true);
|
||||
});
|
||||
|
||||
it("§9.7 api-endpoint per-medication slot rides under the `[\"medications\"]` prefix", () => {
|
||||
// The `<ApiTokensRow>` uses a per-medication key
|
||||
// `["medications", id, "api-endpoint"]` so the bundle catches it
|
||||
// alongside every other medication-scoped tile.
|
||||
const apiEndpointKey = ["medications", "med-1", "api-endpoint"] as const;
|
||||
expect(isUnderPrefix(apiEndpointKey, queryKeys.medications())).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* v1.5.6 G-1 §9 Q1 — IntakeEditDialog seeds from the row's real
|
||||
* values.
|
||||
*
|
||||
* Before this pass the preview opened the dialog with a stub
|
||||
* `{ id, takenAt: null, skipped: false }`, so "Bearbeiten" always
|
||||
* showed an empty form. The fix threads the whole `IntakeEvent` up
|
||||
* through `onEditIntake(event)`; this test pins that the dialog's
|
||||
* datetime-local input + skipped switch seed from the event passed in.
|
||||
*
|
||||
* `<Dialog>` wraps a Radix portal that `renderToStaticMarkup` does not
|
||||
* materialise, so we mock the dialog primitives to passthroughs.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("@/lib/i18n/context", () => ({
|
||||
useTranslations: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dialog", () => ({
|
||||
Dialog: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-slot="mock-dialog">{children}</div>
|
||||
),
|
||||
DialogContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DialogFooter: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DialogHeader: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DialogTitle: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import { IntakeEditDialog } from "@/components/medications/intake-edit-dialog";
|
||||
|
||||
describe("IntakeEditDialog seed (G-1 §9 Q1)", () => {
|
||||
it("seeds the datetime-local input from the event's real takenAt", () => {
|
||||
// 2026-03-04T09:30 in local time — the dialog formats to
|
||||
// `YYYY-MM-DDTHH:mm` in the user's timezone.
|
||||
const taken = new Date(2026, 2, 4, 9, 30).toISOString();
|
||||
const html = renderToStaticMarkup(
|
||||
<IntakeEditDialog
|
||||
medicationId="med-1"
|
||||
event={{ id: "evt-1", takenAt: taken, skipped: false }}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('value="2026-03-04T09:30"');
|
||||
});
|
||||
|
||||
it("seeds the skipped switch from a skipped event (no stub default)", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<IntakeEditDialog
|
||||
medicationId="med-1"
|
||||
event={{ id: "evt-2", takenAt: null, skipped: true }}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
// Radix Switch reflects checked state via data-state.
|
||||
expect(html).toContain('data-state="checked"');
|
||||
});
|
||||
|
||||
it("renders nothing when no event is selected", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<IntakeEditDialog medicationId="med-1" event={null} onClose={() => {}} />,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* v1.5.5 F-1 C-2 — `<IntakeHistoryListV2>` row-actions + selection
|
||||
* contract.
|
||||
*
|
||||
* Mirrors the SSR + `setQueryData` seed pattern the v1.4.36 W4a tests
|
||||
* use. We assert the rendered markup carries the kebab when the
|
||||
* caller passes `onEditIntake` / `onDeleteIntake`, the leading
|
||||
* checkbox when `selection.mode === "multi"`, and that the absence of
|
||||
* the callbacks leaves the table byte-identical to the v1.4.36 shape.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { IntakeHistoryListV2 } from "@/components/medications/intake-history-list-v2";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "test-user", username: "tester", role: "USER" },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
function makeClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
refetchOnMount: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type IntakeRow = {
|
||||
id: string;
|
||||
medicationId: string;
|
||||
scheduledFor: string;
|
||||
takenAt: string | null;
|
||||
skipped: boolean;
|
||||
source: "WEB" | "API" | "REMINDER" | "IMPORT";
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
function seed(client: QueryClient, medId: string, rows: IntakeRow[]) {
|
||||
client.setQueryData(
|
||||
queryKeys.medicationIntakeList(medId, {
|
||||
sortBy: "takenAt",
|
||||
sortDir: "desc",
|
||||
limit: 25,
|
||||
offset: 0,
|
||||
status: "completed",
|
||||
}),
|
||||
{ events: rows, meta: { total: rows.length, limit: 25, offset: 0 } },
|
||||
);
|
||||
}
|
||||
|
||||
function render(node: React.ReactNode, client: QueryClient) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<QueryClientProvider client={client}>{node}</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const ROW: IntakeRow = {
|
||||
id: "evt-row",
|
||||
medicationId: "med-1",
|
||||
scheduledFor: "2026-05-15T08:00:00.000Z",
|
||||
takenAt: "2026-05-15T08:02:00.000Z",
|
||||
skipped: false,
|
||||
source: "WEB",
|
||||
createdAt: "2026-05-15T08:02:00.000Z",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("<IntakeHistoryListV2> — F-1 C-2 row actions", () => {
|
||||
it("does NOT render the kebab when no callbacks are passed (back-compat)", () => {
|
||||
const client = makeClient();
|
||||
seed(client, "med-1", [ROW]);
|
||||
const html = render(
|
||||
<IntakeHistoryListV2 medicationId="med-1" />,
|
||||
client,
|
||||
);
|
||||
expect(html).not.toContain("intake-history-row-kebab");
|
||||
});
|
||||
|
||||
it("renders the kebab trigger when onEditIntake is passed", () => {
|
||||
const client = makeClient();
|
||||
seed(client, "med-1", [ROW]);
|
||||
const html = render(
|
||||
<IntakeHistoryListV2
|
||||
medicationId="med-1"
|
||||
onEditIntake={() => {}}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
expect(html).toContain("intake-history-row-kebab");
|
||||
});
|
||||
|
||||
it("renders the kebab trigger when onDeleteIntake is passed", () => {
|
||||
const client = makeClient();
|
||||
seed(client, "med-1", [ROW]);
|
||||
const html = render(
|
||||
<IntakeHistoryListV2
|
||||
medicationId="med-1"
|
||||
onDeleteIntake={() => {}}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
expect(html).toContain("intake-history-row-kebab");
|
||||
});
|
||||
|
||||
it("does NOT render the selection column when selection prop is absent", () => {
|
||||
const client = makeClient();
|
||||
seed(client, "med-1", [ROW]);
|
||||
const html = render(
|
||||
<IntakeHistoryListV2 medicationId="med-1" />,
|
||||
client,
|
||||
);
|
||||
expect(html).not.toContain("intake-history-row-select");
|
||||
});
|
||||
|
||||
it("renders a leading checkbox per row when selection.mode === 'multi'", () => {
|
||||
const client = makeClient();
|
||||
seed(client, "med-1", [ROW]);
|
||||
const html = render(
|
||||
<IntakeHistoryListV2
|
||||
medicationId="med-1"
|
||||
selection={{
|
||||
mode: "multi",
|
||||
selected: new Set(),
|
||||
onToggle: () => {},
|
||||
}}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
expect(html).toContain("intake-history-row-select");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* v1.5.5 F-1 C-2 — `<IntakeHistoryPreview>` smoke tests.
|
||||
*
|
||||
* The preview mounts in SSR with an empty selection so the bulk-
|
||||
* delete toolbar stays hidden by default. Selection / dialog flips
|
||||
* are interactive surfaces and are exercised by the e2e suite.
|
||||
*
|
||||
* The kebab + selection wiring is covered by
|
||||
* `intake-history-list-v2-row-actions.test.tsx`; this file pins the
|
||||
* wrapper-level contract (no toolbar by default, wired through the
|
||||
* v1.5.5 endpoints).
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { IntakeHistoryPreview } from "@/components/medications/sections/intake-history-preview";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "test-user", username: "tester", role: "USER" },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
function makeClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
refetchOnMount: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function seed(client: QueryClient) {
|
||||
client.setQueryData(
|
||||
queryKeys.medicationIntakeList("med-1", {
|
||||
sortBy: "takenAt",
|
||||
sortDir: "desc",
|
||||
limit: 14,
|
||||
offset: 0,
|
||||
status: "completed",
|
||||
}),
|
||||
{
|
||||
events: [
|
||||
{
|
||||
id: "evt-1",
|
||||
medicationId: "med-1",
|
||||
scheduledFor: "2026-05-15T08:00:00.000Z",
|
||||
takenAt: "2026-05-15T08:02:00.000Z",
|
||||
skipped: false,
|
||||
source: "WEB",
|
||||
createdAt: "2026-05-15T08:02:00.000Z",
|
||||
},
|
||||
],
|
||||
meta: { total: 1, limit: 14, offset: 0 },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function render(node: React.ReactNode, client: QueryClient) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<QueryClientProvider client={client}>{node}</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<IntakeHistoryPreview> — F-1 C-2", () => {
|
||||
it("hides the bulk-delete toolbar when nothing is selected (initial render)", () => {
|
||||
const client = makeClient();
|
||||
seed(client);
|
||||
const html = render(
|
||||
<IntakeHistoryPreview
|
||||
medicationId="med-1"
|
||||
importOpen={false}
|
||||
onImportOpenChange={() => {}}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
expect(html).not.toContain("intake-history-bulk-delete-toolbar");
|
||||
});
|
||||
|
||||
it("renders the row-action kebab and selection checkbox on every row", () => {
|
||||
const client = makeClient();
|
||||
seed(client);
|
||||
const html = render(
|
||||
<IntakeHistoryPreview
|
||||
medicationId="med-1"
|
||||
importOpen={false}
|
||||
onImportOpenChange={() => {}}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
// Per-row affordances feature 15 + 16 — the row carries both.
|
||||
expect(html).toContain("intake-history-row-kebab");
|
||||
expect(html).toContain("intake-history-row-select");
|
||||
// The import header CTA stays alongside the new affordances.
|
||||
expect(html).toContain("intake-history-import");
|
||||
});
|
||||
});
|
||||
@@ -124,7 +124,7 @@ const mounjaro: Glp1Medication = {
|
||||
};
|
||||
|
||||
describe("medication card symmetry — Ramipril vs Mounjaro", () => {
|
||||
it("both cards render History + Pencil header buttons", () => {
|
||||
it("both cards render the detail-nav + Pencil header buttons", () => {
|
||||
const client = makeClient();
|
||||
seedCompliance(client, ramipril.id);
|
||||
seedCompliance(client, mounjaro.id);
|
||||
@@ -140,7 +140,10 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
|
||||
);
|
||||
|
||||
for (const html of [ramiprilHtml, mounjaroHtml]) {
|
||||
expect(html).toContain("lucide-history");
|
||||
// v1.5.6 F-1 M-1 — the detail-nav icon swapped from the
|
||||
// history glyph to a neutral chevron now that it routes to the
|
||||
// detail page rather than the history sub-route.
|
||||
expect(html).toContain("lucide-chevron-right");
|
||||
expect(html).toContain("lucide-pencil");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* v1.5.5 D-3 §9.1 + §10 invariant 5 — status pill renders the label
|
||||
* always (never colour-only) and the dot rides Dracula tokens.
|
||||
*
|
||||
* Critical because an earlier draft proposed bare Tailwind palette
|
||||
* (`bg-emerald-500` / `bg-amber-500`) which:
|
||||
* - bypasses the theme so it would not flip with dark/light;
|
||||
* - relied on colour alone to convey state (WCAG 1.4.1 violation);
|
||||
* - drifted out of sync with `--success` / `--warning` Dracula
|
||||
* tokens declared in `src/app/globals.css`.
|
||||
*
|
||||
* These tests pin the contract:
|
||||
* - active medication → label "Aktiv" + Dracula `--success` token
|
||||
* - paused medication → label "Pausiert" + `--warning` token
|
||||
* - ended medication → label "Beendet" + muted-foreground
|
||||
* - dot always carries `aria-hidden="true"`
|
||||
* - DOM order: H1 (name) before the edit button (C-E4-3)
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("@/lib/i18n/context", () => ({
|
||||
useTranslations: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { MedicationDetailHeader } from "@/components/medications/medication-detail-header";
|
||||
|
||||
function render(props: {
|
||||
name?: string;
|
||||
dose?: string;
|
||||
active?: boolean;
|
||||
endsOn?: string | null;
|
||||
}) {
|
||||
return renderToStaticMarkup(
|
||||
<MedicationDetailHeader
|
||||
name={props.name ?? "Test Drug"}
|
||||
dose={props.dose ?? "5 mg"}
|
||||
active={props.active ?? true}
|
||||
endsOn={props.endsOn}
|
||||
onEditPlan={() => {}}
|
||||
onOpenAdvanced={() => {}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("MedicationDetailHeader (D-3 §9.1)", () => {
|
||||
it("renders the status label and the --success dot for an active medication", () => {
|
||||
const html = render({ active: true });
|
||||
expect(html).toContain("medications.detail.status.active");
|
||||
expect(html).toContain("[hsl(var(--success))]");
|
||||
expect(html).toContain('aria-hidden="true"');
|
||||
});
|
||||
|
||||
it("renders the paused label + --warning dot for an inactive medication", () => {
|
||||
const html = render({ active: false });
|
||||
expect(html).toContain("medications.detail.status.paused");
|
||||
expect(html).toContain("[hsl(var(--warning))]");
|
||||
});
|
||||
|
||||
it("renders the ended label + muted dot when endsOn is past", () => {
|
||||
const html = render({
|
||||
active: false,
|
||||
endsOn: new Date(Date.now() - 86_400_000).toISOString(),
|
||||
});
|
||||
expect(html).toContain("medications.detail.status.ended");
|
||||
expect(html).toContain("bg-muted-foreground");
|
||||
});
|
||||
|
||||
it("DOM order is heading before edit button (C-E4-3)", () => {
|
||||
const html = render({ active: true });
|
||||
const headingIndex = html.indexOf("<h1");
|
||||
const buttonIndex = html.indexOf(
|
||||
"data-slot=\"medication-detail-edit-button\"",
|
||||
);
|
||||
expect(headingIndex).toBeGreaterThan(-1);
|
||||
expect(buttonIndex).toBeGreaterThan(-1);
|
||||
expect(headingIndex).toBeLessThan(buttonIndex);
|
||||
});
|
||||
|
||||
it("renders the edit button beside the advanced-settings gear (v1.6.0)", () => {
|
||||
const html = render({ active: true });
|
||||
expect(html).toContain("common.edit");
|
||||
expect(html).toContain('data-slot="medication-detail-edit-button"');
|
||||
expect(html).toContain('data-slot="medication-detail-advanced-button"');
|
||||
expect(html).toContain('aria-label="medications.detail.edit.advancedOption"');
|
||||
});
|
||||
|
||||
it("orders the primary edit button before the advanced gear", () => {
|
||||
const html = render({ active: true });
|
||||
const edit = html.indexOf('data-slot="medication-detail-edit-button"');
|
||||
const advanced = html.indexOf(
|
||||
'data-slot="medication-detail-advanced-button"',
|
||||
);
|
||||
expect(edit).toBeGreaterThan(-1);
|
||||
expect(advanced).toBeGreaterThan(edit);
|
||||
});
|
||||
|
||||
it("status pill text always renders (no colour-only state)", () => {
|
||||
// Regardless of theme support the label MUST be in the DOM so a
|
||||
// screen reader announces "Aktiv" even when the dot's hue is
|
||||
// imperceptible to the user.
|
||||
const activeHtml = render({ active: true });
|
||||
const pausedHtml = render({ active: false });
|
||||
expect(activeHtml).toMatch(
|
||||
/data-slot="medication-detail-status-row"[\s\S]*?medications\.detail\.status\.active/,
|
||||
);
|
||||
expect(pausedHtml).toMatch(
|
||||
/data-slot="medication-detail-status-row"[\s\S]*?medications\.detail\.status\.paused/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.5.6 G-1 §5 — Advanced settings sheet.
|
||||
*
|
||||
* Hosts the three settings sections the detail page no longer renders
|
||||
* inline: Notifications, Settings (API tokens / CSV stub / Phasen /
|
||||
* grace) and the destructive zone, stacked top → bottom in
|
||||
* routine → rare → destructive order inside a `<ResponsiveSheet>`.
|
||||
*
|
||||
* The Phasen button is sibling-swapped, not nested: when the user
|
||||
* opens it, `onRequestPhaseSheet` closes this sheet first and lets the
|
||||
* parent open the phase sheet so the modal stack never exceeds two
|
||||
* (G-1 §5 / D-3 §10 invariant 7). Each moved section saves itself, so
|
||||
* the footer slot stays empty. On a non-navigating destructive success
|
||||
* the sheet closes; the Tier 3b delete already navigates to
|
||||
* `/medications`.
|
||||
*/
|
||||
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { NotificationsSection } from "@/components/medications/sections/notifications-section";
|
||||
import { SettingsSection } from "@/components/medications/sections/settings-section";
|
||||
import { DestructiveZoneSection } from "@/components/medications/sections/destructive-zone-section";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export interface AdvancedSettingsSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
medicationId: string;
|
||||
medicationName: string;
|
||||
treatmentClass?: string;
|
||||
active: boolean;
|
||||
startsOn?: string | null;
|
||||
endsOn?: string | null;
|
||||
notificationsEnabled: boolean;
|
||||
reminderGraceMinutes: number | null;
|
||||
intakeCount: number;
|
||||
/**
|
||||
* Sibling-swap the phase sheet: the parent closes this sheet and
|
||||
* opens `<PhaseConfigSheet>` so the two never stack (G-1 §5).
|
||||
*/
|
||||
onRequestPhaseSheet?: () => void;
|
||||
}
|
||||
|
||||
export function AdvancedSettingsSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
medicationId,
|
||||
medicationName,
|
||||
treatmentClass,
|
||||
active,
|
||||
startsOn,
|
||||
endsOn,
|
||||
notificationsEnabled,
|
||||
reminderGraceMinutes,
|
||||
intakeCount,
|
||||
onRequestPhaseSheet,
|
||||
}: AdvancedSettingsSheetProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("medications.detail.advanced.title")}
|
||||
contentWidth="lg"
|
||||
bodyClassName="gap-6"
|
||||
>
|
||||
<div
|
||||
className="space-y-6"
|
||||
data-slot="advanced-settings-sheet-body"
|
||||
>
|
||||
{/* routine */}
|
||||
<NotificationsSection
|
||||
medicationId={medicationId}
|
||||
notificationsEnabled={notificationsEnabled}
|
||||
/>
|
||||
|
||||
{/* rare */}
|
||||
<SettingsSection
|
||||
medicationId={medicationId}
|
||||
medicationName={medicationName}
|
||||
treatmentClass={treatmentClass}
|
||||
startsOn={startsOn}
|
||||
endsOn={endsOn}
|
||||
reminderGraceMinutes={reminderGraceMinutes}
|
||||
onRequestPhaseSheet={onRequestPhaseSheet}
|
||||
/>
|
||||
|
||||
{/* destructive */}
|
||||
<DestructiveZoneSection
|
||||
medicationId={medicationId}
|
||||
medicationName={medicationName}
|
||||
active={active}
|
||||
intakeCount={intakeCount}
|
||||
onAfterAction={() => onOpenChange(false)}
|
||||
/>
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.5.5 D-3 §9.3 — Cadence summary row.
|
||||
*
|
||||
* Renders the medication's cadence as a plain-language line (e.g.
|
||||
* "Alle 12 h nach letzter Einnahme", "Alle 2 Wochen, mittwochs",
|
||||
* "Monatlich am 15.") with an optional course-window sub-line, and an
|
||||
* optional edit pencil whose `onEdit` the caller wires (suppressed via
|
||||
* `hideEdit` on the read-only detail surface).
|
||||
* For one-shot medications the line collapses to
|
||||
* `Einmalig am DD.MM.` and the edit affordance is suppressed (the
|
||||
* header pencil owns the wizard route per D-3 §6 one-shot variant).
|
||||
*
|
||||
* The DOM order is `<h2> → <p> → edit`. The button comes last so a
|
||||
* screen-reader tab walk announces the title and the line before the
|
||||
* affordance (C-E4-3).
|
||||
*/
|
||||
|
||||
import { Pencil } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
|
||||
import {
|
||||
hydrateWizardPayload,
|
||||
summariseCadence,
|
||||
type MedicationPayload,
|
||||
} from "@/components/medications/wizard/wizard-payload";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export interface CadenceSummaryRowProps {
|
||||
medication: MedicationPayload;
|
||||
/** Hides the edit pencil — the one-shot variant relies on the header. */
|
||||
hideEdit?: boolean;
|
||||
onEdit: () => void;
|
||||
}
|
||||
|
||||
export function CadenceSummaryRow({
|
||||
medication,
|
||||
hideEdit = false,
|
||||
onEdit,
|
||||
}: CadenceSummaryRowProps) {
|
||||
const { t } = useTranslations();
|
||||
const payload = hydrateWizardPayload(medication);
|
||||
const line = summariseCadence(payload, t);
|
||||
|
||||
return (
|
||||
<MedicationDetailSection
|
||||
titleId="medication-detail-cadence-heading"
|
||||
title={t("medications.detail.cadence.title")}
|
||||
dataSlot="medication-detail-cadence-section"
|
||||
headerExtras={
|
||||
hideEdit ? null : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onEdit}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="cadence-summary-edit"
|
||||
>
|
||||
<Pencil aria-hidden="true" className="h-4 w-4" />
|
||||
<span>{t("common.edit")}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<p className="text-sm" data-slot="cadence-summary-line">
|
||||
{line}
|
||||
</p>
|
||||
</MedicationDetailSection>
|
||||
);
|
||||
}
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronRight,
|
||||
CircleCheck,
|
||||
Flame,
|
||||
History,
|
||||
Loader2,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
@@ -326,15 +326,18 @@ export function Glp1MedicationCard({
|
||||
|
||||
const headerActions = (
|
||||
<>
|
||||
{/* v1.5.5 — routes to the new medication detail page; the
|
||||
detail-page intake-history preview links onward to
|
||||
`/medications/{id}/history` for the bulk-delete sub-route. */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11"
|
||||
asChild
|
||||
aria-label={t("medications.intakeHistory")}
|
||||
aria-label={t("medications.openDetailPage")}
|
||||
>
|
||||
<Link href={`/medications/${medication.id}/history`}>
|
||||
<History className="h-4 w-4" />
|
||||
<Link href={`/medications/${medication.id}`}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.5.5 F-1 C-2 — per-row Bearbeiten dialog on the detail-page
|
||||
* intake-history preview.
|
||||
*
|
||||
* Restores the row-level edit affordance the v1.5.4 flat-form
|
||||
* retirement displaced (I-1 §15). The dialog opens from the
|
||||
* intake-history kebab; fields are `takenAt` (datetime-local),
|
||||
* `skipped` (boolean), `note` (string?). On save it PATCHes
|
||||
* `/api/medications/{id}/intake/{eventId}` and fires the
|
||||
* medication cache bundle so the inline compliance tile + dashboard
|
||||
* chart converge in the same tick.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
|
||||
|
||||
export interface IntakeEditDialogProps {
|
||||
medicationId: string;
|
||||
/** Event being edited; `null` closes the dialog. */
|
||||
event: {
|
||||
id: string;
|
||||
takenAt: string | null;
|
||||
skipped: boolean;
|
||||
note?: string | null;
|
||||
} | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an ISO timestamp into the `<input type="datetime-local">`
|
||||
* shape `YYYY-MM-DDTHH:mm`. Returns an empty string for null inputs.
|
||||
* The string is rendered in the user's local time so the picker
|
||||
* matches the dose timestamp the user remembers.
|
||||
*/
|
||||
function toDateTimeLocal(iso: string | null): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(
|
||||
d.getDate(),
|
||||
)}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
export function IntakeEditDialog(props: IntakeEditDialogProps) {
|
||||
// Re-mount the body whenever the event id flips so the form state
|
||||
// initialises from the row the user clicked. The wrapper is also
|
||||
// responsible for not rendering the dialog when `event` is null.
|
||||
if (!props.event) return null;
|
||||
return <IntakeEditDialogBody key={props.event.id} {...props} />;
|
||||
}
|
||||
|
||||
function IntakeEditDialogBody({
|
||||
medicationId,
|
||||
event,
|
||||
onClose,
|
||||
}: IntakeEditDialogProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [takenAt, setTakenAt] = useState(() =>
|
||||
toDateTimeLocal(event?.takenAt ?? null),
|
||||
);
|
||||
const [skipped, setSkipped] = useState(() => event?.skipped ?? false);
|
||||
const [note, setNote] = useState(() => event?.note ?? "");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
if (!event) return null;
|
||||
|
||||
async function save() {
|
||||
if (!event || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
skipped,
|
||||
};
|
||||
if (takenAt) {
|
||||
// Re-attach the local-time string to ISO with the user's
|
||||
// current tz offset so the server stores the canonical UTC
|
||||
// value the picker showed.
|
||||
body.takenAt = new Date(takenAt).toISOString();
|
||||
} else {
|
||||
body.takenAt = null;
|
||||
}
|
||||
if (note.trim().length > 0) {
|
||||
body.note = note;
|
||||
}
|
||||
const res = await fetch(
|
||||
`/api/medications/${medicationId}/intake/${event.id}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
toast.error(t("medications.detail.intake.edit.failed"));
|
||||
return;
|
||||
}
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
toast.success(t("medications.detail.intake.edit.savedToast"));
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error(t("medications.detail.intake.edit.failed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={!!event} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent data-slot="intake-edit-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("medications.detail.intake.edit.dialogTitle")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="intake-edit-taken-at">
|
||||
{t("medications.detail.intake.edit.takenAtLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="intake-edit-taken-at"
|
||||
type="datetime-local"
|
||||
value={takenAt}
|
||||
onChange={(e) => setTakenAt(e.target.value)}
|
||||
disabled={skipped}
|
||||
/>
|
||||
</div>
|
||||
<label
|
||||
htmlFor="intake-edit-skipped"
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<span className="text-sm font-medium">
|
||||
{t("medications.detail.intake.edit.skippedLabel")}
|
||||
</span>
|
||||
<Switch
|
||||
id="intake-edit-skipped"
|
||||
checked={skipped}
|
||||
onCheckedChange={(checked) => setSkipped(checked)}
|
||||
/>
|
||||
</label>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="intake-edit-note">
|
||||
{t("medications.detail.intake.edit.noteLabel")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="intake-edit-note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={busy}>
|
||||
{t("medications.detail.intake.edit.cancel")}
|
||||
</Button>
|
||||
<Button onClick={() => void save()} disabled={busy} aria-busy={busy || undefined}>
|
||||
{t("medications.detail.intake.edit.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
History,
|
||||
Check,
|
||||
SkipForward,
|
||||
MoreHorizontal,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
@@ -19,6 +20,13 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import {
|
||||
Table,
|
||||
@@ -65,7 +73,7 @@ import {
|
||||
|
||||
type IntakeSource = "WEB" | "API" | "REMINDER" | "IMPORT";
|
||||
|
||||
interface IntakeEvent {
|
||||
export interface IntakeEvent {
|
||||
id: string;
|
||||
medicationId: string;
|
||||
scheduledFor: string;
|
||||
@@ -80,10 +88,41 @@ interface IntakeListResponse {
|
||||
meta: { total: number; limit: number; offset: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.5.5 F-1 C-2 — optional selection contract. When the wrapper
|
||||
* passes `selection`, each row renders a leading `<Checkbox>` that
|
||||
* mirrors the selected set. Single mode hides the bulk-toolbar; multi
|
||||
* mode wires through to `<BulkDeleteToolbar>`. The contract lives on
|
||||
* the list rather than on the wrapper so a future surface (e.g. the
|
||||
* full /history page) can re-use the same shape.
|
||||
*/
|
||||
export interface IntakeHistoryListV2Selection {
|
||||
mode: "single" | "multi";
|
||||
selected: Set<string>;
|
||||
onToggle: (id: string) => void;
|
||||
}
|
||||
|
||||
interface IntakeHistoryListV2Props {
|
||||
medicationId: string;
|
||||
/** Override the default page size — primarily for tests. */
|
||||
pageSize?: number;
|
||||
/**
|
||||
* v1.5.5 F-1 C-2 — per-row Bearbeiten callback. When provided the
|
||||
* row renders a kebab `<DropdownMenu>` with an Edit action. v1.5.6
|
||||
* G-1 §9 Q1: threads the whole `IntakeEvent` (not just the id) so
|
||||
* the edit dialog seeds from the row's real `takenAt` / `skipped`
|
||||
* instead of an empty stub.
|
||||
*/
|
||||
onEditIntake?: (event: IntakeEvent) => void;
|
||||
/**
|
||||
* v1.5.5 F-1 C-2 — per-row Löschen callback. When provided the row
|
||||
* renders a kebab `<DropdownMenu>` with a Delete action.
|
||||
*/
|
||||
onDeleteIntake?: (eventId: string) => void;
|
||||
/**
|
||||
* v1.5.5 F-1 C-2 — selection contract; see `IntakeHistoryListV2Selection`.
|
||||
*/
|
||||
selection?: IntakeHistoryListV2Selection;
|
||||
}
|
||||
|
||||
type SortKey = "takenAt" | "scheduledFor";
|
||||
@@ -100,11 +139,20 @@ const STATUS_FILTER = "completed";
|
||||
export function IntakeHistoryListV2({
|
||||
medicationId,
|
||||
pageSize = DEFAULT_PAGE_SIZE,
|
||||
onEditIntake,
|
||||
onDeleteIntake,
|
||||
selection,
|
||||
}: IntakeHistoryListV2Props) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const formatters = useFormatters();
|
||||
|
||||
// v1.5.5 F-1 C-2 — the kebab + checkbox columns are gated by the
|
||||
// caller-passed callbacks. The list never assumes a wrapper; it
|
||||
// renders the extra columns only when explicitly opted-in.
|
||||
const showRowActions = Boolean(onEditIntake || onDeleteIntake);
|
||||
const showSelection = selection?.mode === "multi";
|
||||
|
||||
const [sortBy, setSortBy] = useState<SortKey>("takenAt");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||||
const [page, setPage] = useState(0);
|
||||
@@ -207,6 +255,12 @@ export function IntakeHistoryListV2({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{showSelection && (
|
||||
<TableHead
|
||||
className="w-8"
|
||||
data-slot="intake-history-select-col"
|
||||
/>
|
||||
)}
|
||||
<TableHead>
|
||||
<button
|
||||
type="button"
|
||||
@@ -235,6 +289,12 @@ export function IntakeHistoryListV2({
|
||||
<TableHead>
|
||||
{t("medications.intakeHistoryColSource")}
|
||||
</TableHead>
|
||||
{showRowActions && (
|
||||
<TableHead
|
||||
className="w-8"
|
||||
data-slot="intake-history-actions-col"
|
||||
/>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -248,8 +308,23 @@ export function IntakeHistoryListV2({
|
||||
// sneaks past as "Eingenommen".
|
||||
const isTaken = !event.skipped && !!event.takenAt;
|
||||
const isSkipped = event.skipped;
|
||||
const isSelected = selection?.selected.has(event.id) ?? false;
|
||||
return (
|
||||
<TableRow key={event.id}>
|
||||
{showSelection && (
|
||||
<TableCell className="w-8">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() =>
|
||||
selection?.onToggle(event.id)
|
||||
}
|
||||
aria-label={t(
|
||||
"medications.detail.intake.selection.rowToggleLabel",
|
||||
)}
|
||||
data-slot="intake-history-row-select"
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="text-sm">
|
||||
{event.takenAt
|
||||
? formatters.dateTime(event.takenAt)
|
||||
@@ -285,6 +360,50 @@ export function IntakeHistoryListV2({
|
||||
{sourceLabels[event.source] ?? event.source}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
{showRowActions && (
|
||||
<TableCell className="w-8 text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
aria-label={t(
|
||||
"medications.detail.intake.rowActions.openMenu",
|
||||
)}
|
||||
data-slot="intake-history-row-kebab"
|
||||
>
|
||||
<MoreHorizontal
|
||||
aria-hidden="true"
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{onEditIntake && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onEditIntake(event)}
|
||||
data-slot="intake-history-row-edit"
|
||||
>
|
||||
{t("medications.detail.intake.rowActions.edit")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onDeleteIntake && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onDeleteIntake(event.id)}
|
||||
data-slot="intake-history-row-delete"
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
{t(
|
||||
"medications.detail.intake.rowActions.delete",
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.5.5 D-3 §10 invariant 19 — `IntakeImportDialog` lifted out of
|
||||
* `src/app/medications/page.tsx` so the detail-page intake-history
|
||||
* header can mount it as the only CSV-import affordance. The list
|
||||
* page previously owned the only mount + the per-card kebab triggered
|
||||
* `setImportMedId(id)`. v1.5.5 retires the per-card trigger; the
|
||||
* detail page is the only surface that opens the dialog, and the
|
||||
* intake-history preview owns the trigger so the import lives next to
|
||||
* the table it changes.
|
||||
*
|
||||
* The contract is preserved verbatim from the v1.5.4 list-page copy
|
||||
* so the integration test surface stays familiar: open via
|
||||
* `medicationId` going non-null, close via `onClose`. The dialog
|
||||
* resets every transient piece of state on close so the next open
|
||||
* never picks up the previous session's text.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, MoreHorizontal, RotateCcw, Upload } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
|
||||
|
||||
export interface IntakeImportDialogProps {
|
||||
medicationId: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function IntakeImportDialog({
|
||||
medicationId,
|
||||
onClose,
|
||||
}: IntakeImportDialogProps) {
|
||||
const { t } = useTranslations();
|
||||
const [jsonText, setJsonText] = useState("");
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [resultType, setResultType] = useState<"success" | "error" | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedFileName, setSelectedFileName] = useState<string | null>(null);
|
||||
const [fileInputKey, setFileInputKey] = useState(0);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
function resetImportForm() {
|
||||
setJsonText("");
|
||||
setResult(null);
|
||||
setResultType(null);
|
||||
setSelectedFileName(null);
|
||||
setFileInputKey((prev) => prev + 1);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
resetImportForm();
|
||||
onClose();
|
||||
}
|
||||
|
||||
async function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setResult(null);
|
||||
setResultType(null);
|
||||
|
||||
try {
|
||||
const content = await file.text();
|
||||
JSON.parse(content);
|
||||
setJsonText(content);
|
||||
setSelectedFileName(file.name);
|
||||
setResult(t("medications.importFileLoaded", { name: file.name }));
|
||||
setResultType("success");
|
||||
} catch {
|
||||
setResult(t("medications.importInvalidJson"));
|
||||
setResultType("error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!medicationId || !jsonText.trim()) return;
|
||||
setImporting(true);
|
||||
setResult(null);
|
||||
setResultType(null);
|
||||
|
||||
try {
|
||||
let data = JSON.parse(jsonText.trim());
|
||||
// Support both array and object-with-array
|
||||
if (!Array.isArray(data)) {
|
||||
const arrKey = Object.keys(data).find((k) => Array.isArray(data[k]));
|
||||
if (arrKey) data = data[arrKey];
|
||||
else throw new Error(t("medications.importNoArray"));
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`/api/medications/${medicationId}/intake/import`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
},
|
||||
);
|
||||
|
||||
const json = await res.json();
|
||||
if (res.ok) {
|
||||
const d = json.data;
|
||||
setResult(
|
||||
t("medications.importResult", { imported: d.imported }) +
|
||||
(d.skippedDuplicates > 0
|
||||
? `, ${t("medications.importDuplicatesSkipped", { count: d.skippedDuplicates })}`
|
||||
: "") +
|
||||
(d.skippedInvalid > 0
|
||||
? `, ${t("medications.importInvalidSkipped", { count: d.skippedInvalid })}`
|
||||
: ""),
|
||||
);
|
||||
setResultType("success");
|
||||
void invalidateKeys(queryClient, medicationDependentKeys);
|
||||
} else {
|
||||
setResult(json.error || t("medications.importFailed"));
|
||||
setResultType("error");
|
||||
}
|
||||
} catch (err) {
|
||||
setResult(
|
||||
err instanceof SyntaxError
|
||||
? t("medications.importInvalidFormat")
|
||||
: (err as Error).message || t("medications.importFailed"),
|
||||
);
|
||||
setResultType("error");
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={!!medicationId} onOpenChange={() => handleClose()}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("medications.importIntakes")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("medications.importDescription")}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="intake-import-file" className="text-xs font-medium">
|
||||
{t("medications.importUploadFile")}
|
||||
</Label>
|
||||
<input
|
||||
key={fileInputKey}
|
||||
id="intake-import-file"
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={handleFileSelect}
|
||||
className="border-input bg-background text-foreground file:bg-muted file:text-foreground w-full cursor-pointer rounded-md border text-sm file:mr-2 file:border-0 file:px-3 file:py-2"
|
||||
/>
|
||||
{selectedFileName && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.importSelected", { name: selectedFileName })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<pre className="bg-muted text-muted-foreground rounded-lg p-3 text-xs">
|
||||
{`[
|
||||
{"datum": "2026-02-14", "uhrzeit": "10:27:43", "zaehler": 523},
|
||||
{"datum": "2026-02-14", "uhrzeit": "23:33:42", "zaehler": 524}
|
||||
]`}
|
||||
</pre>
|
||||
<Textarea
|
||||
value={jsonText}
|
||||
onChange={(e) => setJsonText(e.target.value)}
|
||||
placeholder={t("medications.importPaste")}
|
||||
rows={8}
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
className="font-mono"
|
||||
/>
|
||||
{result && (
|
||||
<p
|
||||
className={`text-sm ${resultType === "success" ? "text-dracula-green" : "text-destructive"}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{result}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
disabled={importing}
|
||||
aria-label={t("common.moreOptions")}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={resetImportForm}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
{t("common.reset")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={importing}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={importing || !jsonText.trim()}
|
||||
aria-busy={importing || undefined}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
{importing ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t("common.import")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
Flame,
|
||||
Pencil,
|
||||
Loader2,
|
||||
History,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
@@ -262,15 +262,20 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
|
||||
min-w-11` (44px) so these meet the WCAG 2.5.5 minimum
|
||||
tap-target on mobile without the icon glyph itself
|
||||
changing size. */}
|
||||
{/* v1.5.5 — the kebab-style "open" icon routes to the new
|
||||
medication detail page. The detail-page intake-history
|
||||
preview links onward to `/medications/{id}/history` for the
|
||||
bulk-delete sub-route, so the user still reaches the full
|
||||
history in one extra tap. */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11"
|
||||
asChild
|
||||
aria-label={t("medications.intakeHistory")}
|
||||
aria-label={t("medications.openDetailPage")}
|
||||
>
|
||||
<Link href={`/medications/${medication.id}/history`}>
|
||||
<History className="h-4 w-4" />
|
||||
<Link href={`/medications/${medication.id}`}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
Loaded 100 of 171 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user