Compare commits

...
33 Commits
Author SHA1 Message Date
Marc-André BombeckandClaude Opus 4.7 ac1d03f8ea test(meds): green the wizard e2e locale and one-shot timezone fixtures
The medication-wizard e2e specs assert the German step counter, but the
seeded e2e user falls back to the English default locale; pin the
`healthlog-locale=de` cookie for the wizard flow and correct the
storage-state cookie allowlist to the hyphenated cookie name the i18n
layer actually reads.

The one-shot cadence integration fixture derived `startsOn`/`endsOn` from
the UTC calendar date but checked emission against the Europe/Berlin
today-window, so the assertion flipped when the suite ran in the UTC
evening. Derive the fixture dates from the same Berlin calendar day the
window uses.

Also refresh the OpenAPI spec version to 1.6.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 00:30:27 +02:00
Marc-André BombeckandClaude Opus 4.7 8ee7aaac44 chore(release): v1.6.0
Medication editor overhaul, route of administration, today-tile read-flip.

- Route-of-administration column (ORAL | INJECTION | OTHER) decoupled from
  treatmentClass; injection-site picker surfaces for any injection.
  Migration 0088 backfills ORAL via a constant column default.
- One-time injection (oneShot + deliveryForm = INJECTION) editor path.
- Profile-photo upload card on Settings → Account.
- Today-tile read-flip: /api/medications/intake?scope=today and
  /api/dashboard/summary gate through the canonical recurrence engine,
  matching the reminder worker for bi-weekly, rolling, RRULE, and one-time
  cadences.
- Measurement note cap 25 → 200; dashboard range colors on semantic tokens;
  admin sidebar icons + shared-helper consolidation; API-tokens multi-language
  snippets restored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 00:10:12 +02:00
Marc-André Bombeck d01d1d1dc9 Update README.md 2026-05-29 22:11:05 +02:00
Marc-André Bombeck 189d162dff Update README.md 2026-05-29 22:08:53 +02:00
Marc-André Bombeck edfe43d391 Update README.md 2026-05-29 22:08:35 +02:00
Marc-André BombeckandClaude Opus 4.7 8528c3b4fe chore(release): v1.5.6
Pure-history medication detail page, legacy step consolidation, and the
finish of the outbound-fetch migration.

- /medications/[id] becomes a read-only history surface; every setting
  moves into a new advanced-settings sheet, and the edit trigger becomes
  a two-option picker. The list page is unchanged.
- A pg-boss job folds pre-v1.5.0 granular step rows into one canonical
  daily total per user per day and soft-deletes the originals; migration
  0087 adds a partial index for the converging discovery scan.
- Every remaining server-side fetch routes through safeFetch (Withings,
  Codex OAuth + SSE, bug-report, Umami, GlitchTip, Loki); a new
  healthlog/safe-fetch-required ESLint rule guards the boundary.
- Fixes: intake-edit dialog seeded from real values, consolidation skips
  a unique-constraint day instead of stranding it, avatar upload reads
  the body once under a bounded cap, egress lint rejects absolute URLs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 22:07:10 +02:00
Marc-André BombeckandClaude Opus 4.7 d54addd61a chore(release): v1.5.5
Medication detail page, iOS coord wave, outbound-fetch hardening.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 00:55:40 +02:00
Marc-André BombeckandClaude Opus 4.7 353c8d7fa0 feat(meds): row-level edit and bulk-delete on the intake history preview
Per-row Bearbeiten and Löschen actions sit behind a kebab on each
intake row; multi-select gates a bulk-delete toolbar that wires
through to the v1.5.5 bulk-delete endpoint. Closes the two
displaced features from the v1.5.4 flat-form retirement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 00:53:23 +02:00
Marc-André BombeckandClaude Opus 4.7 167cc3afa2 feat(meds): rate-limit the bulk-delete intake endpoint
Match the glp1 POST cap (30 calls per minute per user) so a caller
firing repeated 500-id deletes cannot pin the recompute worker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 00:53:23 +02:00
Marc-André BombeckandClaude Opus 4.7 ff334ff10e fix(meds): phase-config upsert builds the Prisma payload field-by-field
Drop the parsed.data spread so a future schema extension cannot
silently land on the Prisma write.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 00:53:23 +02:00
Marc-André BombeckandClaude Opus 4.7 e6907d4ea9 fix(meds): phase-config returns the 422 multi-issue envelope
The phase-config PUT was the last v1.5.5 route still returning a
flat 400 on Zod failure. Returning the multi-issue envelope means
iOS gets per-field feedback the same way the bulk-delete and
intake routes do.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 00:53:23 +02:00
Marc-André BombeckandClaude Opus 4.7 56523bafb1 chore(meds): centralise the medication api-token query key
A bare ["medications", id, "api-endpoint"] tuple escaped the
factory rule via useMemo; the new medicationApiEndpoint key in
the factory restores the single source of truth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 00:53:23 +02:00
Marc-André BombeckandClaude Opus 4.7 9532f14f3b fix(meds): notification section title id no longer duplicates the section heading
Split the two title ids so the Switch's aria-labelledby resolves
to the row label rather than the section heading. axe duplicate-id
gone; the announced name now matches the user's mental model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 00:53:23 +02:00
Marc-André BombeckandClaude Opus 4.7 359b103e90 fix(meds): glp1 GET routes through assertMedicationOwnership
The destructive sweep in commit af224964 missed the glp1 GET
branch; the handler still hand-rolled findUnique + userId compare.
Lifting it to the same ownership helper closes the §10 invariant
24 gap and aligns the GET with the POST.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 00:53:23 +02:00
Marc-André BombeckandClaude Opus 4.7 043c335ba3 fix(meds): purge route invalidates the user-medication caches
The Tier-3a Verlauf-löschen cascade dropped the rollup rows but
left the analytics / iOS today-tally caches with the pre-purge
counts for up to their TTL. Add invalidateUserMedications to the
success path so every downstream reader sees the fresh state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 00:53:23 +02:00
Marc-André BombeckandClaude Opus 4.7 8adf1b55e8 fix(meds): grace-row save lands on the primary schedule
The detail page settings section PUTs reminderGraceMinutes at the
top level, but the schema only accepted it per-schedule, returning
422 on every save. The route now maps a top-level grace value onto
the primary schedule's reminderGraceMinutes before the Prisma
update; the schema declares the top-level field with a description
noting the normalisation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 00:53:23 +02:00
Marc-André BombeckandClaude Opus 4.7 8c740fd146 feat(meds): list-card history icon routes to the new detail page + cascade pin tests
The list-page medication cards previously pointed their History icon
straight at `/medications/{id}/history`. v1.5.5 promotes the detail
page as the canonical umbrella surface for everything the v1.5.4 form
retirement displaced, so the icon now opens `/medications/{id}` and
the detail page's intake-history preview links onward to the bulk-
delete sub-route in one extra tap.

Two focused tests:

  - `medication-detail-header.test.tsx` pins the status-pill contract
    (label always rendered, dot rides Dracula `--success` / `--warning`
    tokens, dot is `aria-hidden`, DOM order is heading-then-edit).
  - `detail-page-cascade.test.tsx` pins the per-section query-key +
    invalidation cascade against the central factory. The §9.4
    phase-config key lives outside the bundle so the settings section
    must invalidate it explicitly; the test documents that contract
    so a future refactor cannot drop it silently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:54:07 +02:00
Marc-André BombeckandClaude Opus 4.7 4ae8801c56 feat(meds): self-hosted medication detail page restoring every v1.5.4-displaced action
A new `/medications/[id]/page.tsx` surfaces every action the v1.5.4
form retirement buried — log today's dose, edit the plan, manage
notifications, manage API tokens, import CSV, configure GLP-1 phases
in a focused Sheet, walk a three-tier destructive cascade. The page
reads as a clinical document from header to destructive footer; one
phone-scroll reaches every restored feature.

Section order (recurring variant — 8 sections):

  1. Header band — drug name + dose + status pill + edit pencil
  2. Today's dose card — two buttons (`[Genommen]` + `[Übersprungen]`)
  3. Cadence summary — plain-language line + Bearbeiten → wizard
     Step 5 via the new `landingIntent: "cadence"` route
  4. Dose ladder / Phasen — GLP-1 only, reuses the v1.4 components
  5. Intake history preview — last 14 grouped + footer link to
     `/history`; CSV-import trigger lives in the header
  6. Notifications — `<Switch>` in `<label>` row + read-only chip
     strip; replaced by an "iPhone steuert die Erinnerungen" chip
     when `clientManaged === true`
  7. Settings — Externe Integration / CSV stub / Phasen
     (button → Sheet) / Grace minutes
  8. Verwaltung & Gefahrenzone — Card A (Pausieren + Beenden) and
     Card B (Verlauf + Medikament löschen) under one heading

One-shot variant collapses to 5 sections per D-3 §6. Paused variant
keeps the same structure with the header pill flipped and the
today's-dose buttons muted.

Every mutation cascades through `medicationDependentKeys` so the
inline compliance chart, dashboard chart and achievement reads
converge in one tick. Tier 1 Pausieren PUTs `{ active }` only;
the server derives `pausedAt`. Destructive CTAs ship `font-semibold`
so the WCAG large-text contrast band catches the destructive-button
white-on-#ff5555. Status pill text is always rendered; the dot is
`aria-hidden` and reads from Dracula `--success` / `--warning` tokens
so the colour stays in lockstep with the theme.

i18n covers DE + EN natively; ES/FR/IT/PL inherit the EN strings as
the locale-integrity guard's fallback contract specifies. New keys
live under `medications.detail.*`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:51:17 +02:00
Marc-André BombeckandClaude Opus 4.7 7f95a2a44f fix(measurements): wire walking step length and walking speed
The iOS team had gated both client-side waiting on the server map.
Both flow raw SI on the wire (metres and metres per second
respectively) — the server-side ×100 scaling the percent gait
metrics use does NOT apply here. The convention block at the top of
apple-health-mapping.ts now spells the split out so a future
gait-metric addition picks the right path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:38:21 +02:00
Marc-André BombeckandClaude Opus 4.7 8d5d039ed1 feat(meds): bulk-delete intake endpoint for the detail-page preview multi-select
The v1.5.5 detail-page intake-history preview offers a multi-select
that posts the selected eventIds in one round-trip instead of firing
N concurrent DELETEs from the client. The endpoint:

- Asserts ownership through the shared `assertMedicationOwnership`
  helper so the 404 leak shape stays identical across `[id]/**`.
- Reads rows scoped by `(eventId IN, userId, medicationId)` so a
  leaked id from another medication's history never deletes anything.
- Runs `deleteMany` scoped by the same predicate.
- Recomputes the compliance rollup once per unique dayKey (not once
  per event), so a 14-row delete spanning two days closes in two
  recompute trips and usually 1-3.
- Returns `{ deleted: number }` carrying the real `deleteMany` count
  so the client toast announces the truth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:36:56 +02:00
Marc-André BombeckandClaude Opus 4.7 ef21967d12 refactor(meds): lift IntakeImportDialog out of the list page and bundle the inline-compliance prefix
The list page was carrying two large dialogs (`IntakeImportDialog`
and `ApiEndpointDialog`) whose state slots had no list-card trigger
wired to them. The slots were only ever set from inside the dialogs
themselves, so they read as dead code on the list page even though
the dialog bodies themselves were healthy.

`IntakeImportDialog` lifts to `src/components/medications/intake-import-dialog.tsx`
as the canonical mount the detail-page intake-history header will
import. `ApiEndpointDialog` no longer mounts on the list page; its
final v1.5.5 home is the detail-page Settings sub-row (lands in a
later commit). The list page drops the two state slots, the inline
dialog bodies, and the now-unused imports.

`medicationDependentKeys` also gains the `compliance-chart-inline`
prefix. The per-medication inline compliance chart used to mount
under `queryKeys.medicationComplianceChart(id)` (which expands to
`["compliance-chart-inline", id]`) but the bundle did not match the
prefix, so an intake mutation left the chart up to 60 s stale. The
prefix now lands in the bundle so every detail-page mutation evicts
the tile in one tick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:34:08 +02:00
Marc-André BombeckandClaude Opus 4.7 c97c4c3be3 feat(meds): wizard polish — 560 px shell, intent-aware landing, omit notifications on edit
Six changes the v1.5.5 detail page leans on:

- Desktop shell widens from `sm:max-w-md` (448 px) to `sm:max-w-[560px]`
  so the strip + body stay readable on the wider section icons the v1.5
  shell ships. Mobile bottom-sheet keeps the 40 dvh floor so short
  screens never collapse the wizard to a stub.
- Outer spacing scales to `p-4 pr-12 sm:p-6 sm:pr-14` so the close-X
  has room to breathe and the strip + body share one column. The
  fade-only `animate-in fade-in-0 duration-200 motion-reduce:animate-none`
  on the step body keeps motion to one vocabulary — the slide proposed
  earlier was stripped.
- `<Progress>` gets the explicit `transition-all motion-reduce:transition-none`
  so width-only progress reads as the one source of motion on the
  strip.
- `landingStepForEdit` takes an optional `intent` arg (`"cadence"` /
  `"name"` / `"summary"`); the detail page's cadence-summary row uses
  `"cadence"` so the user lands on Step 5 instead of Step 1. One-shot
  payloads fall back to Step 8 because the cadence picker is not on
  their path.
- The step `<h2>` carries `tabIndex={-1}` and receives focus on
  intent-routed edit-opens so the screen reader announces the step
  the user actually landed on, not the first input on it.
- `buildCreateBody` takes `forMode: "create" | "edit"` and omits
  `notificationsEnabled` on edit. The detail-page notifications
  switch is the single source of truth post-create; the wizard now
  refuses to round-trip its default back to the API on edit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:30:35 +02:00
Marc-André BombeckandClaude Opus 4.7 af2249644b fix(meds): route every detail-page sub-route through assertMedicationOwnership
The intake purge endpoint, the parent medication PUT, the parent DELETE
and the CSV-import POST each hand-rolled the same `findUnique +
userId !== id ? 404` block instead of using the shared
`assertMedicationOwnership` helper that every other
`src/app/api/medications/[id]/**` handler already uses. The hand-rolled
checks read slightly different fields and skipped the helper's narrow
`select`, which leaked extra column data into routes that never needed
it. Routing each of the four through the helper keeps the 404 leak
shape on a single line, prepares the purge endpoint for promotion to a
user-discoverable surface on the new detail page, and removes one of
the open audit findings against the medications API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:27:02 +02:00
Marc-André BombeckandClaude Opus 4.7 ca1c52ffcb feat(avatar): self-hosted avatar storage replacing the Gravatar 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. This release moves the
avatar in-house: the user uploads through POST /api/user/avatar
(multipart, JPEG/PNG/WebP, 2 MiB / 2048×2048 max), the bytes ride
on the User row alongside a content-type and updatedAt timestamp,
and GET /api/user/avatar/{id} serves them from same-origin with
the cache-busting timestamp on the profile response.

The image-header parsing is hand-rolled in src/lib/avatar.ts so
the standalone image does not gain a native dep just to read width
and height. Validation rejects oversize / wrong content-type at
the multipart parse boundary; DELETE clears the row. The bytes
ride pg_dump alongside the rest of the row so the existing weekly
encrypted backup path already covers them.

The sidebar, the coach panel, the use-auth hook, and the disable-
coach card stop reading gravatarUrl; src/lib/gravatar.ts retires.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:39:20 +02:00
Marc-André BombeckandClaude Opus 4.7 e93feaf8d1 feat(dashboard): emit i18n keys instead of hardcoded German strings
The /api/dashboard/summary endpoint returned per-metric `title` and
`unit` as translated German strings ("Gewicht", "Schritte", …), which
injected DE into clients with an English UI. The wire now carries i18n
keys (`titleKey` / `unitKey`) under a new `dashboard.metric.*`
namespace; the PWA's `useTranslations()` and the iOS client's bundled
`Localizable.xcstrings` each resolve against their own locale bundle.

Locale parity preserved across de, en, es, fr, it, pl (DE / EN native,
the rest matched against the existing measurement-type labels). The
locale narrowing in `buildContext` is widened to accept every shipped
locale rather than dropping fr / es / it / pl back to the default — the
greeting + streak label now resolve in the user's configured language.

The OpenAPI yaml drift was a pre-existing 1.5.3 → 1.5.4 version-string
gap; regenerating picked it up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:32:46 +02:00
Marc-André BombeckandClaude Opus 4.7 a75b96b2a9 feat(insights): server-persist insights tile order + visibility
Mirrors /api/dashboard/widgets for the insights surface: GET returns
the user's saved layout (defaulting to project order when unset),
PUT writes the layout, the response is the canonical shape the
clients can render directly. Closes the cross-device drift where
insights ordering lived in iOS local state only.

Storage is a new `insights_layout_json` JSONB column on `users`,
parallel to `dashboard_widgets_json` so each surface's resolver owns
its tile-id allowlist. Migration 0083 adds the column idempotently;
default NULL resolves to `DEFAULT_INSIGHTS_LAYOUT`. PUT routes
through the shared multi-issue 422 envelope + payload-redaction +
audit-row dedup pattern the dashboard-widgets route established.
A 5-minute per-user cache mirrors the dashboard-widgets cache and
busts on every save / reset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:32:40 +02:00
Marc-André BombeckandClaude Opus 4.7 19cfd69ff1 feat(measurements): accept restingHeartRate, hrv, vo2max in the series enum
The iOS app surfaces these as series-capable, but the kind enum on
/api/measurements/series did not accept them — opening the detail
or trend view returned 422. The underlying MeasurementType enum
already carries the values; this commit extends the route-side
enum so the wire shape matches the data shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:32:18 +02:00
Marc-André BombeckandClaude Opus 4.7 26f13c5064 feat(measurements): raise the series days cap to ten years
The 365-day cap on /api/measurements/series 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 hard cap and the medication course-window upper bound.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:32:18 +02:00
Marc-André BombeckandClaude Opus 4.7 befac0ec65 fix(measurements): wire the six deferred Apple Health identifiers
Adds RESPIRATORY_RATE, BODY_MASS_INDEX, and the four remaining
deferred quantity types to MeasurementType plus the Apple Health
mapping. The batch route already returns 200 with a per-entry skip
for unmappable identifiers; iOS reads that as success and advances
its sync anchor, so any sample with one of these identifiers used
to be dropped forever. The mapping now accepts them and the rows
land.

The walking-asymmetry and walking-double-support percent values
adopt the project's server-side scaling convention (raw HK reading
on the wire, server applies the ×100 at ingest), matching the
walking-steadiness path. iOS releases that still send the
pre-multiplied percent need a one-release shim and are tracked in
the iOS coord notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:32:18 +02:00
Marc-André BombeckandClaude Opus 4.7 b43b57f657 feat(security): pin resolved IP on outbound fetches to defeat DNS rebinding
Closes issue 217. The isPublicUrl input-time guard catches every
RFC1918 / CGNAT / loopback / link-local alt-notation, but a low-TTL
DNS record can flip between accept and dispatch. The new
pinnedPublicDispatcher resolves the hostname literally inside
undici.Agent's connect hook, refuses any address isPublicIp would
reject, and pins the connection to the first valid public address.

The five outbound paths that accept a user-supplied host route
through the dispatcher via safeFetch's requirePublicHost flag —
moodlog sync, moodlog reverse-push, the moodlog credentials probe,
the local AI client (already gated in commit 1), and the ntfy
sender. The flag now buys both the input-time check and the
connect-time pin in one call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:32:11 +02:00
Marc-André BombeckandClaude Opus 4.7 425503e095 feat(security): safeFetch wrapper with manual redirect and timeout defaults
Closes issue 218. Flips the convention from "remember to opt in to
redirect: manual" to "remember to opt out". Every outbound fetch that
doesn't pin a hard-coded host now routes through safeFetch and inherits
AbortSignal.timeout(15_000) so a tar-pit upstream can no longer pin a
worker indefinitely. The local AI client also gates on
requirePublicHost: true since its host is user-supplied; operators
running self-hosted Ollama / LM Studio on an RFC1918 address keep their
escape hatch via ALLOW_LOCAL_AI_PRIVATE_HOSTS=true (also enforced at
write-time in /api/user/ai-provider).

14 outbound call sites migrated. The four that already pinned manual
redirect (the historic templates) keep their semantics; the wrapper
preserves an explicit init.redirect. Adds undici as an explicit
dependency so the connect-time pin landing alongside this can rely on
the documented API rather than a transitive hop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:32:11 +02:00
Marc-André BombeckandClaude Opus 4.7 bf6dfb8f6d chore(deps): bump knip 6.14.1 → 6.14.2 and docker/metadata-action v5 → v6
Both are Dependabot bumps that have been waiting on the queue; landing
them directly so the lock files stay coherent with the rest of v1.5.5.

knip: minor; docker/metadata-action: clean major (workflow change is
exactly the @v5 → @v6 string in two places).

Closes #209
Closes #183

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:00:19 +02:00
Marc-André BombeckandClaude Opus 4.7 6e204f1a25 fix(insights): raise the trends-row chart-slot wrapper so the annotation no longer overlaps
The 140 px slot matched the mini-chart's internal painting area but
not the mini-card shell's header + padding above it. The roughly
34 px the shell adds overflowed the slot and pushed the chart
envelope down into the TrendAnnotation row below — visible as text
sitting on top of the chart. Raise the wrapper to 180 px so the
full envelope (header + chart band) lives inside the slot and the
three tiles share one annotation baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 21:42:20 +02:00
171 changed files with 12864 additions and 1659 deletions

No files matched your search

+3 -3
View File
@@ -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
View File
@@ -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.
+1
View File
@@ -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.
+5 -2
View File
@@ -30,6 +30,7 @@
<a href="https://docs.healthlog.dev">Documentation</a> &middot;
<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
View File
@@ -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:
+17
View File
@@ -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"]'),
+1 -1
View File
@@ -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();
+19
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+8 -5
View File
@@ -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';
+79
View File
@@ -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")
+2 -2
View File
@@ -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,
};
+3 -3
View File
@@ -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);
});
});
-318
View File
@@ -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);
});
+9 -2
View File
@@ -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
+3 -2
View File
@@ -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![Screenshot](data:image/${ext};base64,${base64Data.slice(0, 100)}...)\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,
},
],
},
+78 -40
View File
@@ -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();
});
});
+168
View File
@@ -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);
});
+18 -14
View File
@@ -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);
}
});
+18 -14
View File
@@ -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)", () => {
+18 -1
View File
@@ -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();
});
});
+10 -1
View File
@@ -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({
+75 -4
View File
@@ -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,
},
],
},
+3
View File
@@ -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.
+11 -3
View File
@@ -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,
+15 -9
View File
@@ -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);
});
});
+80
View File
@@ -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();
});
});
+325
View File
@@ -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 -1
View File
@@ -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
+373
View File
@@ -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
View File
@@ -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
View File
@@ -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}
+2 -2
View File
@@ -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")}
+29
View File
@@ -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,
});
}
+7 -5
View File
@@ -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",
+1 -6
View File
@@ -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")) {
+11 -1
View File
@@ -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")}
+2 -23
View File
@@ -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">
+2 -23
View File
@@ -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&#x27;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"
+3 -3
View File
@@ -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" });
+5 -5
View File
@@ -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