chore(release): v1.4.24

Security + accessibility hardening release. Closes the highest-priority
findings from a focused security audit of the v1.4.23 release:

- Bearer auth fail-closed when a route declares no scope (api-handler)
- Public /api/send Umami proxy now rate-limits 120 req/min/IP
- Proxy nonce generation switched to btoa(...) for Edge portability
- Session-expiry delete race uses deleteMany + catch (no more 500s)
- POST /api/import returns the standard apiError envelope on 422
- Runtime Docker image pins Prisma 7.8 + worker deps to minors
- Notifications page renders a real load-error alert
- Accessibility: switch aria-labels, htmlFor pairing, mobile admin
  Overview link, deep-link anchors on notification settings
- Chart overlays: per-chart comparisonBaseline pref, BMI/bodyFat/
  sleep/steps overlay keys, TrendCard layout tolerates long labels

Verification: pnpm typecheck && pnpm lint && pnpm test (2244/2244)
&& pnpm format:check all green.
This commit is contained in:
Marc-André Bombeck committed 2026-05-12 22:33:35 +02:00
1 parent 0dc0e1645c
commit ca03c6099f
59 files changed
+841 -206

No files matched your search

+4 -3
View File
@@ -216,9 +216,10 @@ MEDs, all LOWs, S-05 simplify, pre-existing `coach-prefs.test.ts` mock
issue, sandbox `git commit` no-op). issue, sandbox `git commit` no-op).
Test deltas: W5 ended at 2223 unit. Session A added the `revoke.test.ts` Test deltas: W5 ended at 2223 unit. Session A added the `revoke.test.ts`
+ `apns-dispatch` device tests landing at 2227. Session E ended at 2235.
Session F added the forged-X-Device-Id regression — final at 2236 unit - `apns-dispatch` device tests landing at 2227. Session E ended at 2235.
(+45 across the W5+W6 reconcile arc). Session F added the forged-X-Device-Id regression — final at 2236 unit
(+45 across the W5+W6 reconcile arc).
## Wave 7 — Release v1.4.23 (next) ## Wave 7 — Release v1.4.23 (next)
+15 -15
View File
@@ -6,13 +6,13 @@ six-review brief into shippable develop-branch state.
## Per-session SHA chain ## Per-session SHA chain
| Session | Scope | Commit SHAs | | Session | Scope | Commit SHAs |
| --- | --- | --- | | ------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
| A | CRITICAL + HIGH 1-3 | `5486507`, `13977bc`, `d5202e5`, `a2dfc5e` | | A | CRITICAL + HIGH 1-3 | `5486507`, `13977bc`, `d5202e5`, `a2dfc5e` |
| B | HIGH 4 + simplify (S-02 → S-04) + HIGH 6 | `3ee6dab`, `dfffe6b`, `d25e50e` | | B | HIGH 4 + simplify (S-02 → S-04) + HIGH 6 | `3ee6dab`, `dfffe6b`, `d25e50e` |
| D | HIGH 5 (analytics chunked aggregate landed in W5; D session re-confirmed against the W6 senior-dev brief) | `deadc73` | | D | HIGH 5 (analytics chunked aggregate landed in W5; D session re-confirmed against the W6 senior-dev brief) | `deadc73` |
| E | HIGH 7 (Coach feedback referencing `coach_messages`, plaintext column drop) | `650f150` | | E | HIGH 7 (Coach feedback referencing `coach_messages`, plaintext column drop) | `650f150` |
| F | MED cluster (Sec-MED-2 Coolify scrub, Sr-MED-5 isCurrent device-id, SLEEP_DURATION migration verification note) + planning record | `1a46bfe` (MED) + this commit (planning) | | F | MED cluster (Sec-MED-2 Coolify scrub, Sr-MED-5 isCurrent device-id, SLEEP_DURATION migration verification note) + planning record | `1a46bfe` (MED) + this commit (planning) |
## Counts ## Counts
@@ -28,14 +28,14 @@ six-review brief into shippable develop-branch state.
## Test count delta ## Test count delta
| Snapshot | Unit count | | Snapshot | Unit count |
| --- | --- | | ------------------------------------------------------------- | ---------- |
| W5 close (commit `72829b1`) | 2223 | | W5 close (commit `72829b1`) | 2223 |
| Session A close (CRIT + HIGH 1-3 added device + revoke tests) | 2227 | | Session A close (CRIT + HIGH 1-3 added device + revoke tests) | 2227 |
| Session B close (HIGH 4 + simplify + HIGH 6) | ~2229 | | Session B close (HIGH 4 + simplify + HIGH 6) | ~2229 |
| Session D close (HIGH 5 reconfirm) | ~2229 | | Session D close (HIGH 5 reconfirm) | ~2229 |
| Session E close (HIGH 7 + coach-feedback updates) | 2235 | | Session E close (HIGH 7 + coach-feedback updates) | 2235 |
| Session F close (forged X-Device-Id regression) | **2236** | | Session F close (forged X-Device-Id regression) | **2236** |
Net delta over the W6 reconcile arc: **+13 unit tests**. Net delta over the W6 reconcile arc: **+13 unit tests**.
+1 -1
View File
@@ -12,7 +12,7 @@ without re-reading the full review pack.
the settings cog) duplicates intent. Defer to v1.4.24 once W5 H7 the settings cog) duplicates intent. Defer to v1.4.24 once W5 H7
surfaces enough thumbs data to know whether per-user prompt prefs surfaces enough thumbs data to know whether per-user prompt prefs
drift from per-message ratings. Source: `phase-W6-v1423-design-review.md` drift from per-message ratings. Source: `phase-W6-v1423-design-review.md`
+ `phase-W6-v1423-product-lead-review.md`. - `phase-W6-v1423-product-lead-review.md`.
## Statistics ## Statistics
+139 -14
View File
@@ -1,5 +1,130 @@
# Changelog # Changelog
## [1.4.24] — 2026-05-12
Security + accessibility hardening release. Closes the highest-priority
findings from a focused security audit of the v1.4.23 release: a Bearer
token that omits a scope no longer authenticates routes which never
declared one, the public Umami proxy is rate-limited, the proxy nonce
is Edge-runtime portable, a session-expiry delete race can no longer
500 a parallel request, the import-validation envelope now matches the
standard error contract, and the runtime Docker image installs Prisma
7.8 alongside pinned worker dependencies. Accessibility passes on the
notifications surface, admin shell, chart overlays and notification
settings deep-link anchors round out the user-facing work.
### Security
- **Bearer auth is fail-closed when a route declares no scope.**
`requireAuth()` previously let any non-revoked, non-expired API token
through whenever a handler omitted the `requiredPermission` argument.
A narrowly-scoped token (e.g. `["medication:ingest"]`) could therefore
reach every handler that called `requireAuth()` bare — including
account-sensitive routes such as `/api/auth/profile`,
`/api/auth/password`, `/api/withings/credentials`,
`/api/settings/data`, `/api/export/full-backup` and `/api/tokens`.
The Bearer path now throws `HttpError(403)` with audit reason
`scope_required` unless the token carries the `["*"]` wildcard
(the iOS app login token), preserving the existing session-cookie
path and the explicit-scope path (`/api/ingest/medications`). The
admin surface is unaffected — `requireAdmin()` was already
cookie-only.
- **Public Umami analytics proxy now rate-limits by client IP.**
`POST /api/send` forwards browser analytics events to the configured
Umami origin. The SSRF allow-list and 64 KB body cap were already in
place, but the endpoint itself was unbounded. Added a 120-events-per-
minute-per-IP gate using the existing `checkRateLimit` infrastructure;
abuse returns the standard `429` envelope without invoking the
upstream fetch.
- **Edge-runtime portable nonce generation in the proxy.** The proxy
previously assembled the CSP nonce via `Buffer.from(...)`, which is
not guaranteed in the Edge runtime. Switched to
`btoa(String.fromCharCode(...new Uint8Array(16)))`. Same 128 bits of
entropy, Node-and-Edge compatible without a runtime declaration.
- **Session-expiry delete race no longer 500s a parallel request.**
`getSession()` reaped expired session rows with `prisma.session
.delete()`; two requests arriving at the same expired session would
race and the loser would surface a 500. Replaced with `deleteMany` +
a catch-all guard, matching the discipline already used in
`destroySession()`.
### Fixed
- **`POST /api/import` returns the standard `{ data: null, error }`
envelope on validation failure.** The handler previously returned
the validation message inside `apiSuccess(...)` with status 422,
which clients could not distinguish from a successful import.
- **Runtime Docker image pins Prisma 7.8 + worker dependencies.** The
multi-stage image was installing `prisma@7.4.0`, `@prisma/engines
@7.4.0`, unpinned `pg-boss@12`, `@prisma/adapter-pg@7` and `pg@8`
into the worker prefix while the app code shipped Prisma 7.8 from
the lockfile. Pinned to `prisma@7.8`, `@prisma/engines@7.8`,
`pg-boss@12.18`, `@prisma/adapter-pg@7.8` and `pg@8.20` so
migration engine and generated client cannot drift across a deploy.
- **Notifications page surfaces a load error instead of a silent
"no channels" empty state.** `useQuery` failures now render an
alert with `role="alert"` and `notifications.loadError` copy
(EN/DE), so a transient 500 on `/api/notifications/preferences`
stops looking like a misconfiguration.
### Accessibility
- **Notification-preference switches carry accessible names.** Each
switch in the per-event table now exposes an `aria-label` combining
event-type label and channel label; the mobile per-event layout pairs
each `<Switch>` with its `<Label>` via `htmlFor`/`id` so screen
readers announce the channel a toggle controls.
- **Mobile admin navigation surfaces an Overview link.** The mobile
admin section pill-rail prepended an Overview chip pointing at
`/admin`, matching the desktop sidebar root and unblocking users
who land on a sub-section without a path back.
- **Notification settings deep-link anchors land on the right card.**
Onboarding and admin-driven deep links into `#telegram`, `#ntfy`
and `#web-push` now resolve to the corresponding cards in
`NotificationsSection` instead of falling back to top-of-page.
- **Chart-overlay comparison-baseline toggles expose `aria-pressed`.**
The new per-chart comparison-baseline selector renders three
pill buttons (none / last month / last year) with the correct
pressed-state semantics for assistive tech.
### Changed
- **Per-chart `comparisonBaseline` overlay preference.** The
comparison overlay is now configurable per chart from the existing
overlay-controls popover instead of being a single global toggle.
`ChartOverlayPrefs` adds a `comparisonBaseline: "none" | "lastMonth"
| "lastYear"` field (defaults to `"none"`); a per-chart selection
overrides the dashboard-level default for that chart only.
`ChartOverlayKey` is extended with `bmi`, `bodyFat`, `sleep` and
`steps`, mirroring the chart surfaces already on the dashboard.
- **Trend-card layout tolerates long labels and long values.**
`TrendCard` switches to `min-w-0` + `flex-wrap` containers and
`[overflow-wrap:anywhere]` text utilities so locale labels
("Letzter Wert" / "Resting Heart Rate") and large tabular numbers
no longer push the tile off-axis on narrow viewports.
- **Lint-clean Coach drawer + message thread.** The `useCallback`
dependency on the drawer's reset closure now includes the stable
`setInputValue` setter, and the message thread memoises the
derived `messages` array. Both removed the only two warning-level
hook lints carried over from v1.4.23.
### Tests
- New unit test in `require-auth-bearer.test.ts` proves that a Bearer
token with permissions `["medication:ingest"]` is rejected (403
with audit reason `scope_required`) when the route did not declare
a scope; the existing success case was updated to use the wildcard
scope so the new fail-closed branch does not collide with it.
- New unit suite under `src/lib/auth/__tests__/session.test.ts`
covers the expired-session delete-race path: a rejected `deleteMany`
must still resolve `getSession()` to `null` and clear both the
session and onboarding cookies.
- New unit suite under `src/app/api/send/__tests__/route.test.ts`
covers the rate-limited Umami proxy: a 429 short-circuits the
upstream fetch and a 200 still proxies under quota.
- Extended import-route test asserts the new `apiError` envelope:
`data === null` and a populated `error` for invalid payloads.
## [1.4.23] — 2026-05-11 ## [1.4.23] — 2026-05-11
### Added ### Added
@@ -41,15 +166,15 @@
- **OpenAPI 3.1 generator + drift CI gate.** `pnpm openapi:generate` - **OpenAPI 3.1 generator + drift CI gate.** `pnpm openapi:generate`
reads Zod v4 `.meta()` annotations on the existing validation reads Zod v4 `.meta()` annotations on the existing validation
schemas and emits a byte-stable `docs/api/openapi.yaml` (`zod-openapi` schemas and emits a byte-stable `docs/api/openapi.yaml` (`zod-openapi`
+ `yaml@^2` with `sortMapEntries: true`). The eight iOS-critical - `yaml@^2` with `sortMapEntries: true`). The eight iOS-critical
routes are registered now: `auth/login`, passkey verify, routes are registered now: `auth/login`, passkey verify,
`auth/refresh`, `measurements` GET + POST + batch, `devices` POST, `auth/refresh`, `measurements` GET + POST + batch, `devices` POST,
and the comprehensive insights bundle. A new `pnpm openapi:check` and the comprehensive insights bundle. A new `pnpm openapi:check`
CI step diffs generated vs committed; warn-only for v1.4.23 so a CI step diffs generated vs committed; warn-only for v1.4.23 so a
registry oversight on a non-iOS route doesn't red-bar a PR, flips registry oversight on a non-iOS route doesn't red-bar a PR, flips
to hard-fail in v1.4.24. The legacy hand-maintained spec is to hard-fail in v1.4.24. The legacy hand-maintained spec is
preserved at `docs/api/openapi-v1422-legacy.yaml` so iOS DTO preserved at `docs/api/openapi-v1422-legacy.yaml` so iOS DTO
reference doesn't disappear during the incremental migration. reference doesn't disappear during the incremental migration.
- **Device-management endpoints for native + web settings.** - **Device-management endpoints for native + web settings.**
`GET /api/auth/me/devices` lists active devices with label, last- `GET /api/auth/me/devices` lists active devices with label, last-
seen timestamp, channels (`web_push` / `apns`), and an `isCurrent` seen timestamp, channels (`web_push` / `apns`), and an `isCurrent`
@@ -77,7 +202,7 @@
endpoint `POST /api/insights/chat/messages/:id/feedback`. A new endpoint `POST /api/insights/chat/messages/:id/feedback`. A new
admin section `/admin/coach-feedback` renders helpful-rate buckets admin section `/admin/coach-feedback` renders helpful-rate buckets
by (promptVersion, tone, verbosity) and gets a sidebar entry by (promptVersion, tone, verbosity) and gets a sidebar entry
+ EN/DE i18n bundle. - EN/DE i18n bundle.
### Changed ### Changed
@@ -117,7 +242,7 @@
steps, active energy, flights, distance, VO2 max, body temp) as steps, active energy, flights, distance, VO2 max, body temp) as
silent when the snapshot doesn't carry them. No apologetic openers silent when the snapshot doesn't carry them. No apologetic openers
about missing data. `aiInsightResponseSchema.dailyBriefing about missing data. `aiInsightResponseSchema.dailyBriefing
.keyFindings[].sourceMetric` and `trendAnnotations` enums extend .keyFindings[].sourceMetric` and `trendAnnotations` enums extend
to admit the nine additive HealthKit categories. to admit the nine additive HealthKit categories.
- **`medication_schedules.days_of_week` column deployed.** Migration - **`medication_schedules.days_of_week` column deployed.** Migration
`0039_medication_schedule_days_of_week` adds the nullable column `0039_medication_schedule_days_of_week` adds the nullable column
@@ -135,7 +260,7 @@
- **Partial unique index enforces `apns_token` global uniqueness.** - **Partial unique index enforces `apns_token` global uniqueness.**
App-layer guard catches the cross-user-hijack case; the DB-layer App-layer guard catches the cross-user-hijack case; the DB-layer
partial unique index (`CREATE UNIQUE INDEX … WHERE apns_token IS partial unique index (`CREATE UNIQUE INDEX … WHERE apns_token IS
NOT NULL`) is the defence-in-depth backstop. NOT NULL`) is the defence-in-depth backstop.
- **Apple Health source badge renders on mobile measurement card.** - **Apple Health source badge renders on mobile measurement card.**
Desktop list rendered the chip; the mobile card variant fell back Desktop list rendered the chip; the mobile card variant fell back
to source-less prose. Both surfaces now share the same renderer. to source-less prose. Both surfaces now share the same renderer.
@@ -162,8 +287,8 @@
APNs-token layer (409 + dedicated audit reason). Send-side payload APNs-token layer (409 + dedicated audit reason). Send-side payload
redacts the resolved user + device IDs before logging. redacts the resolved user + device IDs before logging.
- **Coolify webhook URL scrubbed from the deploy runbook.** Webhook - **Coolify webhook URL scrubbed from the deploy runbook.** Webhook
+ token live in GH secrets; the runbook references the secret - token live in GH secrets; the runbook references the secret
names rather than the raw URL. names rather than the raw URL.
- **Coach prose encryption-at-rest restored.** The plaintext - **Coach prose encryption-at-rest restored.** The plaintext
`content` column on `recommendation_feedback` was dropped after `content` column on `recommendation_feedback` was dropped after
the feedback rows migrated to a `coach_messages` FK. Cypher text the feedback rows migrated to a `coach_messages` FK. Cypher text
+2 -2
View File
@@ -55,13 +55,13 @@ COPY --from=builder /app/src/generated ./src/generated
RUN mkdir -p /opt/pg-boss && \ RUN mkdir -p /opt/pg-boss && \
cd /opt/pg-boss && \ cd /opt/pg-boss && \
npm init -y && \ npm init -y && \
npm install --omit=dev pg-boss@12 @prisma/adapter-pg@7 pg@8 npm install --omit=dev pg-boss@12.18 @prisma/adapter-pg@7.8 pg@8.20
# Install Prisma CLI + engines for runtime migrations (isolated from Next standalone tree) # Install Prisma CLI + engines for runtime migrations (isolated from Next standalone tree)
RUN mkdir -p /opt/prisma-cli && \ RUN mkdir -p /opt/prisma-cli && \
cd /opt/prisma-cli && \ cd /opt/prisma-cli && \
npm init -y && \ npm init -y && \
npm install --omit=dev prisma@7.4.0 @prisma/engines@7.4.0 npm install --omit=dev prisma@7.8 @prisma/engines@7.8
# Entrypoint script (runs migrations, then starts app) # Entrypoint script (runs migrations, then starts app)
COPY docker-entrypoint.sh ./ COPY docker-entrypoint.sh ./
+20 -10
View File
@@ -210,7 +210,8 @@ components:
- error - error
type: object type: object
DeviceRegisterRequest: DeviceRegisterRequest:
description: Native device registration. Re-registering an APNs token belonging to another user returns 409 description:
Native device registration. Re-registering an APNs token belonging to another user returns 409
(cross-user-hijack guard). (cross-user-hijack guard).
properties: properties:
apnsEnvironment: apnsEnvironment:
@@ -353,7 +354,8 @@ components:
type: object type: object
InsightsComprehensiveResponse: InsightsComprehensiveResponse:
additionalProperties: false additionalProperties: false
description: AI-generated insights bundle. Strict-schema validated server-side; Coach-routed when the insight surface description:
AI-generated insights bundle. Strict-schema validated server-side; Coach-routed when the insight surface
needs day-level grounding. needs day-level grounding.
properties: properties:
citations: citations:
@@ -707,7 +709,8 @@ openapi: 3.1.0
paths: paths:
/api/auth/login: /api/auth/login:
post: post:
description: "Browser callers receive a session cookie. Native callers (X-Client-Type: native or HealthLog-iOS UA description:
"Browser callers receive a session cookie. Native callers (X-Client-Type: native or HealthLog-iOS UA
prefix) additionally receive a paired access + refresh token." prefix) additionally receive a paired access + refresh token."
requestBody: requestBody:
content: content:
@@ -755,7 +758,8 @@ paths:
- Auth - Auth
/api/auth/me/coach-prefs: /api/auth/me/coach-prefs:
get: get:
description: Returns the persisted preferences with defaults filled in. Null persisted state resolves to the legacy description:
Returns the persisted preferences with defaults filled in. Null persisted state resolves to the legacy
v1.4.22 defaults. v1.4.22 defaults.
responses: responses:
"200": "200":
@@ -771,7 +775,8 @@ paths:
tags: tags:
- Auth - Auth
put: put:
description: Persists the supplied prefs. Body is validated against `CoachPrefs`; missing keys fall back to the description:
Persists the supplied prefs. Body is validated against `CoachPrefs`; missing keys fall back to the
documented defaults. documented defaults.
requestBody: requestBody:
content: content:
@@ -849,7 +854,8 @@ paths:
- Auth - Auth
/api/auth/refresh: /api/auth/refresh:
post: post:
description: Reuse of a consumed refresh token revokes every refresh token still active for the originating device description:
Reuse of a consumed refresh token revokes every refresh token still active for the originating device
(per-device blast radius from v1.4.23). Legacy tokens issued before v1.4.23 with a null deviceId fall back to (per-device blast radius from v1.4.23). Legacy tokens issued before v1.4.23 with a null deviceId fall back to
revoke-all-for-user. revoke-all-for-user.
requestBody: requestBody:
@@ -873,7 +879,8 @@ paths:
- Auth - Auth
/api/devices: /api/devices:
post: post:
description: Idempotent upsert by `token`. APNs token + environment are paired — supplying one without the other returns description:
Idempotent upsert by `token`. APNs token + environment are paired — supplying one without the other returns
422. Cross-user re-registration of either identifier returns 409. 422. Cross-user re-registration of either identifier returns 409.
requestBody: requestBody:
content: content:
@@ -902,7 +909,8 @@ paths:
- Devices - Devices
/api/insights/chat/messages/{id}/feedback: /api/insights/chat/messages/{id}/feedback:
post: post:
description: Persists a helpful/unhelpful rating for a single Coach reply. Reuses the v1.4.16 RecommendationFeedback description:
Persists a helpful/unhelpful rating for a single Coach reply. Reuses the v1.4.16 RecommendationFeedback
table via the polymorphic `targetType` column. The aggregator buckets ratings by (promptVersion, tone, table via the polymorphic `targetType` column. The aggregator buckets ratings by (promptVersion, tone,
verbosity). verbosity).
requestBody: requestBody:
@@ -938,7 +946,8 @@ paths:
- Insights - Insights
/api/insights/comprehensive: /api/insights/comprehensive:
get: get:
description: Full Insights surface — daily briefing, recommendations with rationale, optional weekly report + storyboard description:
Full Insights surface — daily briefing, recommendations with rationale, optional weekly report + storyboard
annotations. Strict-schema validated server-side. annotations. Strict-schema validated server-side.
responses: responses:
"200": "200":
@@ -1110,7 +1119,8 @@ paths:
- Measurements - Measurements
/api/measurements/batch: /api/measurements/batch:
post: post:
description: Up to 500 HealthKit entries per call. Idempotent via the `Idempotency-Key` header (replay window 24h). description:
Up to 500 HealthKit entries per call. Idempotent via the `Idempotency-Key` header (replay window 24h).
Per-entry status lets the iOS client advance its sync cursor accurately. Per-entry status lets the iOS client advance its sync cursor accurately.
requestBody: requestBody:
content: content:
+8 -8
View File
@@ -6,14 +6,14 @@ v1.4.22 is the polish release that closes loops the Insights redesign, the AI Co
## Live state ## Live state
| Field | Value | | Field | Value |
|---|---| | ------------------ | ------------------------------------------------------------------------------ |
| URL | `https://healthlog.bombeck.io` | | URL | `https://healthlog.bombeck.io` |
| `/api/version` | `1.4.22` | | `/api/version` | `1.4.22` |
| Image digest | `sha256:865154614303fdc362ee3941776f73ec0f60e1f16112ec272a75cbbe28e2cffb` | | Image digest | `sha256:865154614303fdc362ee3941776f73ec0f60e1f16112ec272a75cbbe28e2cffb` |
| Version transition | 2026-05-10T22:43:50+02:00 (host-side retag fallback) | | Version transition | 2026-05-10T22:43:50+02:00 (host-side retag fallback) |
| GH release | https://github.com/MBombeck/HealthLog/releases/tag/v1.4.22 | | GH release | https://github.com/MBombeck/HealthLog/releases/tag/v1.4.22 |
| Branch model | second release through the `develop` → `main` model (introduced in v1.4.20 F1) | | Branch model | second release through the `develop` → `main` model (introduced in v1.4.20 F1) |
## Smoke (no session) ## Smoke (no session)
+2
View File
@@ -665,6 +665,7 @@
"trendIndicator": "7-Tage-Trend", "trendIndicator": "7-Tage-Trend",
"trendArrow": "Trend-Pfeil", "trendArrow": "Trend-Pfeil",
"targetRange": "Zielbereich", "targetRange": "Zielbereich",
"comparisonBaseline": "Vergleich",
"tooltip": { "tooltip": {
"openSettings": "Chart-Overlay-Einstellungen" "openSettings": "Chart-Overlay-Einstellungen"
} }
@@ -2492,6 +2493,7 @@
"title": "Benachrichtigungen", "title": "Benachrichtigungen",
"eventColumn": "Ereignis", "eventColumn": "Ereignis",
"subtitle": "Wähle aus, welche Benachrichtigungen du über welchen Kanal erhalten möchtest.", "subtitle": "Wähle aus, welche Benachrichtigungen du über welchen Kanal erhalten möchtest.",
"loadError": "Benachrichtigungseinstellungen konnten nicht geladen werden.",
"noChannels": "Konfiguriere zuerst einen Benachrichtigungskanal in den Einstellungen.", "noChannels": "Konfiguriere zuerst einen Benachrichtigungskanal in den Einstellungen.",
"goToSettings": "Zu den Einstellungen", "goToSettings": "Zu den Einstellungen",
"channelDisabled": "Deaktiviert", "channelDisabled": "Deaktiviert",
+2
View File
@@ -665,6 +665,7 @@
"trendIndicator": "7-day trend", "trendIndicator": "7-day trend",
"trendArrow": "Trend arrow", "trendArrow": "Trend arrow",
"targetRange": "Target range", "targetRange": "Target range",
"comparisonBaseline": "Comparison",
"tooltip": { "tooltip": {
"openSettings": "Chart overlay settings" "openSettings": "Chart overlay settings"
} }
@@ -2492,6 +2493,7 @@
"title": "Notifications", "title": "Notifications",
"eventColumn": "Event", "eventColumn": "Event",
"subtitle": "Choose which notifications you want to receive through which channel.", "subtitle": "Choose which notifications you want to receive through which channel.",
"loadError": "Notification preferences could not be loaded.",
"noChannels": "Configure a notification channel in Settings first.", "noChannels": "Configure a notification channel in Settings first.",
"goToSettings": "Go to Settings", "goToSettings": "Go to Settings",
"channelDisabled": "Disabled", "channelDisabled": "Disabled",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "healthlog", "name": "healthlog",
"version": "1.4.23", "version": "1.4.24",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"homepage": "https://healthlog.dev", "homepage": "https://healthlog.dev",
-4
View File
@@ -22,11 +22,9 @@ function main(): void {
}); });
const committed = readFileSync(SPEC_PATH, "utf8"); const committed = readFileSync(SPEC_PATH, "utf8");
if (generated === committed) { if (generated === committed) {
// eslint-disable-next-line no-console
console.log("OpenAPI spec in sync with source schemas."); console.log("OpenAPI spec in sync with source schemas.");
return; return;
} }
// eslint-disable-next-line no-console
console.error( console.error(
"OpenAPI spec drift detected. Run `pnpm openapi:generate` and commit the result.", "OpenAPI spec drift detected. Run `pnpm openapi:generate` and commit the result.",
); );
@@ -37,7 +35,6 @@ function main(): void {
const max = Math.max(genLines.length, onDiskLines.length); const max = Math.max(genLines.length, onDiskLines.length);
for (let i = 0; i < max; i++) { for (let i = 0; i < max; i++) {
if (genLines[i] !== onDiskLines[i]) { if (genLines[i] !== onDiskLines[i]) {
// eslint-disable-next-line no-console
console.error( console.error(
`Line ${i + 1}:\n generated: ${JSON.stringify(genLines[i] ?? "")}\n committed: ${JSON.stringify(onDiskLines[i] ?? "")}`, `Line ${i + 1}:\n generated: ${JSON.stringify(genLines[i] ?? "")}\n committed: ${JSON.stringify(onDiskLines[i] ?? "")}`,
); );
@@ -46,7 +43,6 @@ function main(): void {
let shown = 1; let shown = 1;
for (let j = i + 1; j < max && shown < 5; j++) { for (let j = i + 1; j < max && shown < 5; j++) {
if (genLines[j] !== onDiskLines[j]) { if (genLines[j] !== onDiskLines[j]) {
// eslint-disable-next-line no-console
console.error( console.error(
`Line ${j + 1}:\n generated: ${JSON.stringify(genLines[j] ?? "")}\n committed: ${JSON.stringify(onDiskLines[j] ?? "")}`, `Line ${j + 1}:\n generated: ${JSON.stringify(genLines[j] ?? "")}\n committed: ${JSON.stringify(onDiskLines[j] ?? "")}`,
); );
-1
View File
@@ -28,7 +28,6 @@ function main(): void {
lineWidth: 120, lineWidth: 120,
}); });
writeFileSync(SPEC_PATH, yaml, "utf8"); writeFileSync(SPEC_PATH, yaml, "utf8");
// eslint-disable-next-line no-console
console.log(`Wrote ${SPEC_PATH} (${yaml.length} bytes)`); console.log(`Wrote ${SPEC_PATH} (${yaml.length} bytes)`);
} }
@@ -128,4 +128,27 @@ describe("proxy.ts onboarding redirect (v1.4.22 C4)", () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.headers.get("location")).toBeNull(); expect(res.headers.get("location")).toBeNull();
}); });
it("generates CSP nonces without relying on Node Buffer", () => {
const originalBuffer = (
globalThis as typeof globalThis & { Buffer?: Buffer }
).Buffer;
Object.defineProperty(globalThis, "Buffer", {
value: undefined,
configurable: true,
});
try {
const res = proxy(makeRequest("/", { healthlog_session: "sess-1" }));
expect(res.status).toBe(200);
expect(res.headers.get("Content-Security-Policy")).toMatch(
/nonce-'?[A-Za-z0-9+/]{22}==/,
);
} finally {
Object.defineProperty(globalThis, "Buffer", {
value: originalBuffer,
configurable: true,
});
}
});
}); });
@@ -6,6 +6,10 @@ import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
function redact(text: string): string {
return text.replace(/https?:\/\/\S+/gi, "[url]");
}
function resolveUmamiSendUrls(scriptUrl: string | null): string[] { function resolveUmamiSendUrls(scriptUrl: string | null): string[] {
if (!scriptUrl) return []; if (!scriptUrl) return [];
try { try {
@@ -112,6 +116,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
break; break;
} }
getEvent()?.addWarning("Umami test event rejected: " + lastStatus); getEvent()?.addWarning(
`Umami test event rejected: ${lastStatus}${lastDetails ? ` ${redact(lastDetails)}` : ""}`,
);
return apiError(`Umami test event rejected (HTTP ${lastStatus})`, 502); return apiError(`Umami test event rejected (HTTP ${lastStatus})`, 502);
}); });
+1 -2
View File
@@ -308,10 +308,9 @@ async function computeCorrelationHypotheses(userId: string): Promise<{
// HealthKit pulse samples) cannot allocate an unbounded buffer. The // HealthKit pulse samples) cannot allocate an unbounded buffer. The
// helper still returns the full filtered series the Pearson runners // helper still returns the full filtered series the Pearson runners
// need; we just bound the per-page Prisma round-trip. // need; we just bound the per-page Prisma round-trip.
const [sysRows, diaRows, pulseRows, weightRows, moodRows, intakeRows] = const [sysRows, pulseRows, weightRows, moodRows, intakeRows] =
await Promise.all([ await Promise.all([
fetchMeasurementSeriesChunked(userId, "BLOOD_PRESSURE_SYS", { since }), fetchMeasurementSeriesChunked(userId, "BLOOD_PRESSURE_SYS", { since }),
fetchMeasurementSeriesChunked(userId, "BLOOD_PRESSURE_DIA", { since }),
fetchMeasurementSeriesChunked(userId, "PULSE", { since }), fetchMeasurementSeriesChunked(userId, "PULSE", { since }),
fetchMeasurementSeriesChunked(userId, "WEIGHT", { since }), fetchMeasurementSeriesChunked(userId, "WEIGHT", { since }),
prisma.moodEntry.findMany({ prisma.moodEntry.findMany({
+2 -1
View File
@@ -22,7 +22,8 @@ import { apiSuccess } from "@/lib/api-response";
* persisted server-side via an encrypted httpOnly cookie scoped to a * persisted server-side via an encrypted httpOnly cookie scoped to a
* 15-minute lifetime — the same window Hydra gives the code. * 15-minute lifetime — the same window Hydra gives the code.
*/ */
export const POST = apiHandler(async (_request: NextRequest) => { export const POST = apiHandler(async (request: NextRequest) => {
void request;
const { user } = await requireAuth(); const { user } = await requireAuth();
const rl = await checkRateLimit(`codex-device-start:${user.id}`, 5, 60_000); const rl = await checkRateLimit(`codex-device-start:${user.id}`, 5, 60_000);
+1 -1
View File
@@ -60,7 +60,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (appSettings?.githubIssueTokenEncrypted) { if (appSettings?.githubIssueTokenEncrypted) {
try { try {
configuredToken = decrypt(appSettings.githubIssueTokenEncrypted); configuredToken = decrypt(appSettings.githubIssueTokenEncrypted);
} catch (err) { } catch {
getEvent()?.addWarning("Failed to decrypt GitHub issue token"); getEvent()?.addWarning("Failed to decrypt GitHub issue token");
} }
} }
@@ -35,6 +35,9 @@ const prefsSchema = z.object({
showTrendIndicator: z.boolean(), showTrendIndicator: z.boolean(),
showTrendArrow: z.boolean(), showTrendArrow: z.boolean(),
showTargetRange: z.boolean(), showTargetRange: z.boolean(),
comparisonBaseline: z
.enum(["none", "lastMonth", "lastYear"])
.default("none"),
}), }),
}); });
@@ -85,4 +85,25 @@ describe("POST /api/import — rate-limit guard", () => {
expect(response.status).toBeLessThan(400); expect(response.status).toBeLessThan(400);
}); });
it("returns the standard apiError envelope for invalid payloads", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 4,
resetAt: new Date(Date.now() + 1000),
} as never);
const response = await POST(
new NextRequest("http://localhost/api/import", {
method: "POST",
body: JSON.stringify({ measurements: "not-an-array" }),
headers: { "content-type": "application/json" },
}),
);
expect(response.status).toBe(422);
const body = (await response.json()) as ApiErrorEnvelope;
expect(body.data).toBeNull();
expect(body.error).toBeTruthy();
});
}); });
+4 -1
View File
@@ -70,7 +70,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (jsonError) return jsonError; if (jsonError) return jsonError;
const parsed = importSchema.safeParse(body); const parsed = importSchema.safeParse(body);
if (!parsed.success) { if (!parsed.success) {
return apiSuccess({ error: parsed.error.issues[0].message }, 422); return apiError(
parsed.error.issues[0]?.message ?? "Invalid import payload",
422,
);
} }
const userId = user.id; const userId = user.id;
@@ -238,18 +238,16 @@ describe("POST /api/insights/generate — rate limit (v1.4.16 A7.1)", () => {
makeWorkingProvider(); makeWorkingProvider();
// Simulate 10 successful checkRateLimit responses, then one denial. // Simulate 10 successful checkRateLimit responses, then one denial.
let callCount = 0; let callCount = 0;
vi.mocked(checkRateLimit).mockImplementation( vi.mocked(checkRateLimit).mockImplementation(async (_key, limit) => {
async (_key, limit, _windowMs) => { callCount += 1;
callCount += 1; // The route must pass `10` as the limit when the env var is unset.
// The route must pass `10` as the limit when the env var is unset. expect(limit).toBe(10);
expect(limit).toBe(10); return {
return { allowed: callCount <= 10,
allowed: callCount <= 10, remaining: Math.max(0, 10 - callCount),
remaining: Math.max(0, 10 - callCount), resetAt: Date.now() + 3600_000,
resetAt: Date.now() + 3600_000, };
}; });
},
);
for (let i = 0; i < 10; i += 1) { for (let i = 0; i < 10; i += 1) {
const res = await POST(jsonRequest({ force: true }) as never); const res = await POST(jsonRequest({ force: true }) as never);
@@ -48,7 +48,8 @@ function redact(text: string): string {
.replace(/https?:\/\/\S+/gi, "[url]"); .replace(/https?:\/\/\S+/gi, "[url]");
} }
export const POST = apiHandler(async (_request: NextRequest) => { export const POST = apiHandler(async (request: NextRequest) => {
void request;
const { user } = await requireAuth(); const { user } = await requireAuth();
annotate({ action: { name: "integrations.moodlog.test" } }); annotate({ action: { name: "integrations.moodlog.test" } });
@@ -65,7 +65,8 @@ function categoriseApiStatus(apiStatus: number): CategorisedError {
} }
} }
export const POST = apiHandler(async (_request: NextRequest) => { export const POST = apiHandler(async (request: NextRequest) => {
void request;
const { user } = await requireAuth(); const { user } = await requireAuth();
annotate({ action: { name: "integrations.withings.test" } }); annotate({ action: { name: "integrations.withings.test" } });
+1 -1
View File
@@ -71,7 +71,7 @@ export const GET = apiHandler(async () => {
let categoryMap: Record<string, string> = {}; let categoryMap: Record<string, string> = {};
try { try {
categoryMap = await getMedicationCategories(medications.map((m) => m.id)); categoryMap = await getMedicationCategories(medications.map((m) => m.id));
} catch (error) { } catch {
getEvent()?.addWarning("Medication categories could not be loaded"); getEvent()?.addWarning("Medication categories could not be loaded");
} }
@@ -11,7 +11,8 @@ export const dynamic = "force-dynamic";
const TIMEOUT_MS = 5_000; const TIMEOUT_MS = 5_000;
export const POST = apiHandler(async (_request: NextRequest) => { export const POST = apiHandler(async (request: NextRequest) => {
void request;
const { user } = await requireAdmin(); const { user } = await requireAdmin();
annotate({ action: { name: "monitoring.glitchtip.test" } }); annotate({ action: { name: "monitoring.glitchtip.test" } });
+2 -1
View File
@@ -15,7 +15,8 @@ function redact(text: string): string {
return text.replace(/https?:\/\/\S+/gi, "[url]"); return text.replace(/https?:\/\/\S+/gi, "[url]");
} }
export const POST = apiHandler(async (_request: NextRequest) => { export const POST = apiHandler(async (request: NextRequest) => {
void request;
const { user } = await requireAdmin(); const { user } = await requireAdmin();
annotate({ action: { name: "monitoring.umami.test" } }); annotate({ action: { name: "monitoring.umami.test" } });
@@ -62,7 +62,7 @@ export const GET = apiHandler(async () => {
channels.push(created); channels.push(created);
} }
} catch (err) { } catch {
getEvent()?.addWarning("Legacy Telegram migration failed"); getEvent()?.addWarning("Legacy Telegram migration failed");
} }
} }
@@ -25,7 +25,8 @@ function redactPushError(text: string): string {
return text.replace(/https?:\/\/\S+/gi, "[endpoint]"); return text.replace(/https?:\/\/\S+/gi, "[endpoint]");
} }
export const POST = apiHandler(async (_request: NextRequest) => { export const POST = apiHandler(async (request: NextRequest) => {
void request;
const { user } = await requireAuth(); const { user } = await requireAuth();
annotate({ action: { name: "notifications.web-push.test" } }); annotate({ action: { name: "notifications.web-push.test" } });
+88
View File
@@ -0,0 +1,88 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
vi.mock("@/lib/monitoring-settings", () => ({
getPublicMonitoringSettings: vi.fn(),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
import { POST } from "../route";
import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
import { checkRateLimit } from "@/lib/rate-limit";
beforeEach(() => {
vi.resetAllMocks();
vi.stubGlobal("fetch", vi.fn());
});
describe("POST /api/send", () => {
it("rate-limits the public Umami proxy by client IP", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
} as never);
const response = await POST(
new NextRequest("http://localhost/api/send", {
method: "POST",
body: JSON.stringify({ type: "event" }),
headers: {
"content-type": "application/json",
"x-forwarded-for": "203.0.113.10",
},
}),
);
expect(response.status).toBe(429);
expect(checkRateLimit).toHaveBeenCalledWith(
"umami-proxy:203.0.113.10",
120,
60 * 1000,
);
expect(getPublicMonitoringSettings).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
});
it("continues to proxy when the request is within quota", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 119,
resetAt: Date.now() + 60_000,
} as never);
vi.mocked(getPublicMonitoringSettings).mockResolvedValue({
umamiEnabled: true,
umamiScriptUrl: "https://analytics.example.com/script.js",
umamiWebsiteId: "site-1",
} as never);
vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 202 }));
const response = await POST(
new NextRequest("http://localhost/api/send", {
method: "POST",
body: JSON.stringify({ type: "event" }),
headers: {
"content-type": "application/json",
"x-real-ip": "203.0.113.11",
},
}),
);
expect(response.status).toBe(202);
expect(fetch).toHaveBeenCalledWith(
"https://analytics.example.com/api/send",
expect.objectContaining({ method: "POST" }),
);
});
});
+6
View File
@@ -1,7 +1,9 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler"; import { apiHandler } from "@/lib/api-handler";
import { apiError, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context"; import { annotate } from "@/lib/logging/context";
import { getPublicMonitoringSettings } from "@/lib/monitoring-settings"; import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
import { checkRateLimit } from "@/lib/rate-limit";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -68,6 +70,10 @@ function resolveUmamiSendUrls(scriptUrl: string | null): string[] {
export const POST = apiHandler(async (request: NextRequest) => { export const POST = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "umami.proxy" } }); annotate({ action: { name: "umami.proxy" } });
const ip = getClientIp(request) ?? "unknown";
const rl = await checkRateLimit(`umami-proxy:${ip}`, 120, 60 * 1000);
if (!rl.allowed) return apiError("Rate limit exceeded", 429);
const settings = await getPublicMonitoringSettings(); const settings = await getPublicMonitoringSettings();
if (!settings.umamiEnabled) { if (!settings.umamiEnabled) {
return NextResponse.json({}, { status: 204 }); return NextResponse.json({}, { status: 204 });
+27 -1
View File
@@ -45,7 +45,7 @@ export default function NotificationsPage() {
const { t } = useTranslations(); const { t } = useTranslations();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data, isLoading } = useQuery({ const { data, isLoading, isError } = useQuery({
queryKey: ["notifications", "preferences"], queryKey: ["notifications", "preferences"],
queryFn: async () => { queryFn: async () => {
const res = await fetch("/api/notifications/preferences"); const res = await fetch("/api/notifications/preferences");
@@ -144,6 +144,27 @@ export default function NotificationsPage() {
const eventTypes = data?.eventTypes ?? []; const eventTypes = data?.eventTypes ?? [];
const activeChannels = channels.filter((ch) => ch.globallyEnabled); const activeChannels = channels.filter((ch) => ch.globallyEnabled);
if (isError) {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">
{t("notifications.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("notifications.subtitle")}
</p>
</div>
<div
role="alert"
className="border-destructive/40 bg-destructive/10 text-destructive rounded-lg border p-4 text-sm"
>
{t("notifications.loadError")}
</div>
</div>
);
}
// No channels configured // No channels configured
if (activeChannels.length === 0) { if (activeChannels.length === 0) {
return ( return (
@@ -236,9 +257,11 @@ export default function NotificationsPage() {
{activeChannels.map((ch) => { {activeChannels.map((ch) => {
const enabled = isEnabled(ch.id, eventType); const enabled = isEnabled(ch.id, eventType);
const disabled = !ch.enabled; const disabled = !ch.enabled;
const switchLabel = `${t(nameKey)} - ${ch.label}`;
return ( return (
<td key={ch.id} className="px-4 py-3 text-center"> <td key={ch.id} className="px-4 py-3 text-center">
<Switch <Switch
aria-label={switchLabel}
checked={enabled && !disabled} checked={enabled && !disabled}
disabled={disabled || toggleMutation.isPending} disabled={disabled || toggleMutation.isPending}
onCheckedChange={(checked: boolean) => onCheckedChange={(checked: boolean) =>
@@ -279,12 +302,14 @@ export default function NotificationsPage() {
{activeChannels.map((ch) => { {activeChannels.map((ch) => {
const enabled = isEnabled(ch.id, eventType); const enabled = isEnabled(ch.id, eventType);
const disabled = !ch.enabled; const disabled = !ch.enabled;
const switchId = `notification-${eventType}-${ch.id}`;
return ( return (
<div <div
key={ch.id} key={ch.id}
className="flex items-center justify-between" className="flex items-center justify-between"
> >
<Label <Label
htmlFor={switchId}
className={`text-sm ${disabled ? "text-muted-foreground/70" : ""}`} className={`text-sm ${disabled ? "text-muted-foreground/70" : ""}`}
> >
{ch.label} {ch.label}
@@ -295,6 +320,7 @@ export default function NotificationsPage() {
)} )}
</Label> </Label>
<Switch <Switch
id={switchId}
checked={enabled && !disabled} checked={enabled && !disabled}
disabled={disabled || toggleMutation.isPending} disabled={disabled || toggleMutation.isPending}
onCheckedChange={(checked: boolean) => onCheckedChange={(checked: boolean) =>
+6 -2
View File
@@ -967,6 +967,7 @@ export default function DashboardPage() {
node: ( node: (
<HealthChart <HealthChart
key="bmi-chart" key="bmi-chart"
chartKey="bmi"
types={["WEIGHT"]} types={["WEIGHT"]}
title={t("targets.bmi")} title={t("targets.bmi")}
colors={["#f1fa8c"]} colors={["#f1fa8c"]}
@@ -1032,6 +1033,7 @@ export default function DashboardPage() {
node: ( node: (
<HealthChart <HealthChart
key="bodyFat-chart" key="bodyFat-chart"
chartKey="bodyFat"
types={["BODY_FAT"]} types={["BODY_FAT"]}
title={t("dashboard.bodyFat")} title={t("dashboard.bodyFat")}
colors={["#ffb86c"]} colors={["#ffb86c"]}
@@ -1064,6 +1066,7 @@ export default function DashboardPage() {
node: ( node: (
<HealthChart <HealthChart
key="sleep-chart" key="sleep-chart"
chartKey="sleep"
types={["SLEEP_DURATION"]} types={["SLEEP_DURATION"]}
title={t("dashboard.sleep") ?? "Sleep"} title={t("dashboard.sleep") ?? "Sleep"}
colors={["#8be9fd"]} colors={["#8be9fd"]}
@@ -1081,6 +1084,7 @@ export default function DashboardPage() {
node: ( node: (
<HealthChart <HealthChart
key="steps-chart" key="steps-chart"
chartKey="steps"
types={["ACTIVITY_STEPS"]} types={["ACTIVITY_STEPS"]}
title={t("dashboard.steps") ?? "Steps"} title={t("dashboard.steps") ?? "Steps"}
colors={["#50fa7b"]} colors={["#50fa7b"]}
@@ -1186,13 +1190,13 @@ export default function DashboardPage() {
// scrolling — the maintainer tested both and prefers the v1.3-era // scrolling — the maintainer tested both and prefers the v1.3-era
// wrap behaviour over the one-row scroll for the symmetry // wrap behaviour over the one-row scroll for the symmetry
// it preserves. // it preserves.
className="grid auto-rows-fr [grid-template-columns:repeat(auto-fit,minmax(9rem,1fr))] gap-3 pb-2" className="grid auto-rows-fr [grid-template-columns:repeat(auto-fit,minmax(min(100%,9rem),1fr))] gap-3 pb-2"
data-slot="dashboard-tile-strip" data-slot="dashboard-tile-strip"
data-tour-id="dashboard-tile-strip" data-tour-id="dashboard-tile-strip"
data-tile-count={trendCards.length} data-tile-count={trendCards.length}
> >
{trendCards.map((entry) => ( {trendCards.map((entry) => (
<div key={entry.id} className="flex"> <div key={entry.id} className="flex min-w-0">
{entry.node} {entry.node}
</div> </div>
))} ))}
+15
View File
@@ -165,6 +165,21 @@ export function AdminShell({ active, children }: AdminShellProps) {
className="no-scrollbar -mx-4 mb-4 overflow-x-auto px-4 md:hidden" className="no-scrollbar -mx-4 mb-4 overflow-x-auto px-4 md:hidden"
> >
<ul className="flex min-w-max gap-2"> <ul className="flex min-w-max gap-2">
<li>
<Link
href="/admin"
aria-current={activeSlug === null ? "page" : undefined}
className={cn(
"flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors",
activeSlug === null
? "border-primary/40 bg-primary/10 text-primary"
: "border-border text-foreground hover:bg-accent",
)}
>
<ScrollText className="h-4 w-4" aria-hidden="true" />
{t("admin.shell.overview")}
</Link>
</li>
{ADMIN_SECTIONS.map((section) => { {ADMIN_SECTIONS.map((section) => {
const isActive = section.slug === activeSlug; const isActive = section.slug === activeSlug;
const Icon = section.icon; const Icon = section.icon;
+1 -8
View File
@@ -2,14 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { import { Activity, Bell, CheckCircle2, Loader2, XCircle } from "lucide-react";
Activity,
Bell,
CheckCircle2,
Clock,
Loader2,
XCircle,
} from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -52,6 +52,7 @@ describe("<ChartOverlayControls>", () => {
showTrendIndicator: false, showTrendIndicator: false,
showTrendArrow: false, showTrendArrow: false,
showTargetRange: false, showTargetRange: false,
comparisonBaseline: "none",
}); });
}); });
@@ -78,6 +79,7 @@ describe("<ChartOverlayControls>", () => {
showTrendIndicator: false, showTrendIndicator: false,
showTrendArrow: false, showTrendArrow: false,
showTargetRange: true, showTargetRange: true,
comparisonBaseline: "none",
}); });
}); });
@@ -105,6 +105,18 @@ describe("<HealthChart mini>", () => {
expect(html).toContain("chart-overlay-controls-trigger"); expect(html).toContain("chart-overlay-controls-trigger");
}); });
it("renders the overlay-controls trigger for BMI charts", () => {
const html = render(
<HealthChart
types={["WEIGHT"]}
title="BMI"
chartKey="bmi"
valueMode="bmi"
/>,
);
expect(html).toContain("chart-overlay-controls-trigger");
});
it("accepts windowOverride and applies the matching range-points", () => { it("accepts windowOverride and applies the matching range-points", () => {
const html = render( const html = render(
<HealthChart <HealthChart
@@ -47,6 +47,7 @@ const mockPrefs = vi.hoisted(() => ({
showTrendIndicator: false, showTrendIndicator: false,
showTrendArrow: false, showTrendArrow: false,
showTargetRange: false, showTargetRange: false,
comparisonBaseline: "none",
}, },
})); }));
@@ -75,6 +76,7 @@ describe("MedicationComplianceChart overlay toggles", () => {
showTrendIndicator: false, showTrendIndicator: false,
showTrendArrow: false, showTrendArrow: false,
showTargetRange: false, showTargetRange: false,
comparisonBaseline: "none",
}; };
const html = await renderChart(); const html = await renderChart();
expect(html).not.toContain("medication-trend-chip"); expect(html).not.toContain("medication-trend-chip");
@@ -85,6 +87,7 @@ describe("MedicationComplianceChart overlay toggles", () => {
showTrendIndicator: true, showTrendIndicator: true,
showTrendArrow: false, showTrendArrow: false,
showTargetRange: false, showTargetRange: false,
comparisonBaseline: "none",
}; };
const html = await renderChart(); const html = await renderChart();
expect(html).toContain("medication-trend-chip"); expect(html).toContain("medication-trend-chip");
@@ -122,3 +122,29 @@ describe("<TrendCard> directionSentiment", () => {
expect(html).not.toContain("text-dracula-orange"); expect(html).not.toContain("text-dracula-orange");
}); });
}); });
describe("<TrendCard> responsive layout", () => {
it("keeps long BP target tile content wrappable inside the card", () => {
const html = render(
<TrendCard
label="BP in target"
latest={50}
unit="%"
avg7={100}
avg30={50}
avgAllTime={11}
slope30={RISING}
trend7Delta={50}
icon={Activity}
directionSentiment="up-good"
compareBaseline="lastMonth"
compareDelta={36}
/>,
);
expect(html).toContain("min-w-0");
expect(html).toContain("[overflow-wrap:anywhere]");
expect(html).toContain('data-slot="tile-compare-delta"');
expect(html).toContain('data-slot="trend-card-all-time"');
});
});
@@ -14,7 +14,11 @@ import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context"; import { useTranslations } from "@/lib/i18n/context";
import { type ChartOverlayPrefs } from "@/lib/dashboard-layout"; import {
COMPARISON_BASELINES,
type ChartOverlayPrefs,
type ComparisonBaseline,
} from "@/lib/dashboard-layout";
/** /**
* v1.4.18 — per-chart overlay-controls popover. * v1.4.18 — per-chart overlay-controls popover.
@@ -78,10 +82,17 @@ export function ChartOverlayControls({
const trendArrowId = useId(); const trendArrowId = useId();
const targetRangeId = useId(); const targetRangeId = useId();
const setKey = (key: keyof ChartOverlayPrefs, value: boolean): void => { const setToggle = (
key: "showTrendIndicator" | "showTrendArrow" | "showTargetRange",
value: boolean,
): void => {
onChange({ ...prefs, [key]: value }); onChange({ ...prefs, [key]: value });
}; };
const setComparisonBaseline = (value: ComparisonBaseline): void => {
onChange({ ...prefs, comparisonBaseline: value });
};
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -118,7 +129,7 @@ export function ChartOverlayControls({
id={trendIndicatorId} id={trendIndicatorId}
checked={prefs.showTrendIndicator} checked={prefs.showTrendIndicator}
onCheckedChange={(value) => onCheckedChange={(value) =>
setKey("showTrendIndicator", Boolean(value)) setToggle("showTrendIndicator", Boolean(value))
} }
data-slot="chart-overlay-toggle-trend-indicator" data-slot="chart-overlay-toggle-trend-indicator"
/> />
@@ -134,7 +145,7 @@ export function ChartOverlayControls({
id={trendArrowId} id={trendArrowId}
checked={prefs.showTrendArrow} checked={prefs.showTrendArrow}
onCheckedChange={(value) => onCheckedChange={(value) =>
setKey("showTrendArrow", Boolean(value)) setToggle("showTrendArrow", Boolean(value))
} }
data-slot="chart-overlay-toggle-trend-arrow" data-slot="chart-overlay-toggle-trend-arrow"
/> />
@@ -150,12 +161,39 @@ export function ChartOverlayControls({
id={targetRangeId} id={targetRangeId}
checked={prefs.showTargetRange} checked={prefs.showTargetRange}
onCheckedChange={(value) => onCheckedChange={(value) =>
setKey("showTargetRange", Boolean(value)) setToggle("showTargetRange", Boolean(value))
} }
data-slot="chart-overlay-toggle-target-range" data-slot="chart-overlay-toggle-target-range"
/> />
</div> </div>
</div> </div>
<DropdownMenuSeparator />
<div className="flex flex-col gap-1.5 pt-1">
<Label className="text-muted-foreground text-xs font-medium">
{t("chart.overlay.controls.comparisonBaseline")}
</Label>
<div
className="grid grid-cols-3 gap-1"
data-slot="chart-overlay-comparison-baseline"
>
{COMPARISON_BASELINES.map((value) => (
<Button
key={value}
type="button"
variant={
prefs.comparisonBaseline === value ? "default" : "outline"
}
size="sm"
className="h-9 px-1 text-[11px]"
onClick={() => setComparisonBaseline(value)}
aria-pressed={prefs.comparisonBaseline === value}
data-slot={`chart-overlay-comparison-${value}`}
>
{t(`comparison.baseline.${value}`)}
</Button>
))}
</div>
</div>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); );
+27 -17
View File
@@ -388,6 +388,9 @@ export function HealthChart({
const showMA = overlayPrefs.prefs.showTrendIndicator; const showMA = overlayPrefs.prefs.showTrendIndicator;
const showTrend = overlayPrefs.prefs.showTrendArrow; const showTrend = overlayPrefs.prefs.showTrendArrow;
const showBands = overlayPrefs.prefs.showTargetRange; const showBands = overlayPrefs.prefs.showTargetRange;
const effectiveCompareBaseline = chartKey
? overlayPrefs.prefs.comparisonBaseline
: compareBaseline;
// v1.4.19 A2 — viewport-aware tick density. Recharts' default is one // v1.4.19 A2 — viewport-aware tick density. Recharts' default is one
// tick per data point; the helper caps that at 4-10 visible labels // tick per data point; the helper caps that at 4-10 visible labels
@@ -605,7 +608,7 @@ export function HealthChart({
// "Comparison unavailable — no data from last month yet" via the // "Comparison unavailable — no data from last month yet" via the
// hasComparisonData flag below). // hasComparisonData flag below).
const chartDataWithCompare = useMemo(() => { const chartDataWithCompare = useMemo(() => {
if (!chartData || compareBaseline === "none") return chartData; if (!chartData || effectiveCompareBaseline === "none") return chartData;
if (!data?.length) return chartData; if (!data?.length) return chartData;
const shifted = shiftDailySeriesForward( const shifted = shiftDailySeriesForward(
@@ -615,7 +618,7 @@ export function HealthChart({
types.map((type) => [type, row[type] as number | undefined]), types.map((type) => [type, row[type] as number | undefined]),
), ),
})), })),
compareBaseline, effectiveCompareBaseline,
); );
// Index shifted rows by the same day-key the chart already uses. // Index shifted rows by the same day-key the chart already uses.
@@ -643,7 +646,7 @@ export function HealthChart({
} }
return merged; return merged;
}); });
}, [chartData, compareBaseline, data, types]); }, [chartData, effectiveCompareBaseline, data, types]);
/** /**
* v1.4.16 phase B8 — true when at least one visible day has a prior- * v1.4.16 phase B8 — true when at least one visible day has a prior-
@@ -654,7 +657,8 @@ export function HealthChart({
* when they can clearly see SOME dimmed history. * when they can clearly see SOME dimmed history.
*/ */
const hasComparisonData = useMemo(() => { const hasComparisonData = useMemo(() => {
if (compareBaseline === "none" || !chartDataWithCompare) return false; if (effectiveCompareBaseline === "none" || !chartDataWithCompare)
return false;
return chartDataWithCompare.some((point) => return chartDataWithCompare.some((point) =>
types.some( types.some(
(type) => (type) =>
@@ -662,7 +666,7 @@ export function HealthChart({
Number.isFinite(point[`${type}_compare`] as number), Number.isFinite(point[`${type}_compare`] as number),
), ),
); );
}, [chartDataWithCompare, compareBaseline, types]); }, [chartDataWithCompare, effectiveCompareBaseline, types]);
const activeBucket: ChartBucketType = useMemo(() => { const activeBucket: ChartBucketType = useMemo(() => {
if (!data?.length) return "day"; if (!data?.length) return "day";
@@ -684,7 +688,7 @@ export function HealthChart({
// v1.4.16 phase B8 — extend the y-domain to fit the prior-period // v1.4.16 phase B8 — extend the y-domain to fit the prior-period
// overlay. Without this the dimmed line can clip outside the // overlay. Without this the dimmed line can clip outside the
// visible range when last-month / last-year had a different scale. // visible range when last-month / last-year had a different scale.
if (compareBaseline !== "none") { if (effectiveCompareBaseline !== "none") {
keys.push(...types.map((type) => `${type}_compare`)); keys.push(...types.map((type) => `${type}_compare`));
} }
@@ -707,7 +711,13 @@ export function HealthChart({
const paddingBottom = Math.max(span * 0.08, 0.5); const paddingBottom = Math.max(span * 0.08, 0.5);
const paddingTop = Math.max(span * 0.16, 1); const paddingTop = Math.max(span * 0.16, 1);
return [min - paddingBottom, max + paddingTop]; return [min - paddingBottom, max + paddingTop];
}, [chartDataWithCompare, compareBaseline, showMA, showTrend, types]); }, [
chartDataWithCompare,
effectiveCompareBaseline,
showMA,
showTrend,
types,
]);
const visibleBands = useMemo(() => { const visibleBands = useMemo(() => {
if (!showBands || !valueBands?.length || !yDomain) return []; if (!showBands || !valueBands?.length || !yDomain) return [];
@@ -959,25 +969,25 @@ export function HealthChart({
neutral muted styling so it doesn't read as an error. neutral muted styling so it doesn't read as an error.
v1.4.19 A2 — hidden on mobile to free up the title row. */} v1.4.19 A2 — hidden on mobile to free up the title row. */}
{compareBaseline !== "none" && hasComparisonData && ( {effectiveCompareBaseline !== "none" && hasComparisonData && (
<span <span
className="text-dracula-purple bg-dracula-purple/10 hidden rounded-md border border-current/30 px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase sm:inline-flex" className="text-dracula-purple bg-dracula-purple/10 hidden rounded-md border border-current/30 px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase sm:inline-flex"
data-slot="chart-compare-caption" data-slot="chart-compare-caption"
> >
{t( {t(
compareBaseline === "lastMonth" effectiveCompareBaseline === "lastMonth"
? "comparison.captionLastMonth" ? "comparison.captionLastMonth"
: "comparison.captionLastYear", : "comparison.captionLastYear",
)} )}
</span> </span>
)} )}
{compareBaseline !== "none" && !hasComparisonData && ( {effectiveCompareBaseline !== "none" && !hasComparisonData && (
<span <span
className="text-muted-foreground bg-muted/40 hidden rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide sm:inline-flex" className="text-muted-foreground bg-muted/40 hidden rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide sm:inline-flex"
data-slot="chart-compare-unavailable" data-slot="chart-compare-unavailable"
> >
{t( {t(
compareBaseline === "lastMonth" effectiveCompareBaseline === "lastMonth"
? "comparison.unavailable.lastMonth" ? "comparison.unavailable.lastMonth"
: "comparison.unavailable.lastYear", : "comparison.unavailable.lastYear",
)} )}
@@ -1291,13 +1301,13 @@ export function HealthChart({
// have a prior value for this day. Otherwise fall // have a prior value for this day. Otherwise fall
// back to the existing baseline-delta path. // back to the existing baseline-delta path.
const compareValue = const compareValue =
compareBaseline !== "none" && hoverPoint effectiveCompareBaseline !== "none" && hoverPoint
? (hoverPoint[`${dataKey}_compare`] as ? (hoverPoint[`${dataKey}_compare`] as
| number | number
| undefined) | undefined)
: undefined; : undefined;
if ( if (
compareBaseline !== "none" && effectiveCompareBaseline !== "none" &&
typeof compareValue === "number" && typeof compareValue === "number" &&
Number.isFinite(compareValue) Number.isFinite(compareValue)
) { ) {
@@ -1311,7 +1321,7 @@ export function HealthChart({
1, 1,
)}${unit ? ` ${unit}` : ""}`; )}${unit ? ` ${unit}` : ""}`;
delta = t( delta = t(
compareBaseline === "lastMonth" effectiveCompareBaseline === "lastMonth"
? "comparison.deltaVs.lastMonth" ? "comparison.deltaVs.lastMonth"
: "comparison.deltaVs.lastYear", : "comparison.deltaVs.lastYear",
).replace("{delta}", formatted); ).replace("{delta}", formatted);
@@ -1345,7 +1355,7 @@ export function HealthChart({
// numbers (current AND last-month / last-year) // numbers (current AND last-month / last-year)
// alongside the delta. // alongside the delta.
if ( if (
compareBaseline !== "none" && effectiveCompareBaseline !== "none" &&
typeof compareValue === "number" && typeof compareValue === "number" &&
Number.isFinite(compareValue) Number.isFinite(compareValue)
) { ) {
@@ -1428,7 +1438,7 @@ export function HealthChart({
stroke opacity (45%) and a thinner stroke (1.25) stroke opacity (45%) and a thinner stroke (1.25)
so the user reads the current line first and the so the user reads the current line first and the
overlay as orientation. */} overlay as orientation. */}
{compareBaseline !== "none" && {effectiveCompareBaseline !== "none" &&
hasComparisonData && hasComparisonData &&
types.map((type, i) => ( types.map((type, i) => (
<Line <Line
@@ -1436,7 +1446,7 @@ export function HealthChart({
type="monotone" type="monotone"
dataKey={`${type}_compare`} dataKey={`${type}_compare`}
name={`${getTypeLabel(type, valueMode, t)} (${t( name={`${getTypeLabel(type, valueMode, t)} (${t(
compareBaseline === "lastMonth" effectiveCompareBaseline === "lastMonth"
? "comparison.captionLastMonth" ? "comparison.captionLastMonth"
: "comparison.captionLastYear", : "comparison.captionLastYear",
)})`} )})`}
@@ -169,7 +169,6 @@ interface MedicationComplianceChartProps {
export function MedicationComplianceChart({ export function MedicationComplianceChart({
title, title,
compareBaseline = "none",
}: MedicationComplianceChartProps) { }: MedicationComplianceChartProps) {
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
const { t } = useTranslations(); const { t } = useTranslations();
@@ -182,6 +181,7 @@ export function MedicationComplianceChart({
const overlayPrefs = useChartOverlayPrefs("medications"); const overlayPrefs = useChartOverlayPrefs("medications");
const showTrendChip = overlayPrefs.prefs.showTrendIndicator; const showTrendChip = overlayPrefs.prefs.showTrendIndicator;
const showTargetRange = overlayPrefs.prefs.showTargetRange; const showTargetRange = overlayPrefs.prefs.showTargetRange;
const effectiveCompareBaseline = overlayPrefs.prefs.comparisonBaseline;
// v1.4.19 A2 — universal tick-density helper. Pre-fix the medication // v1.4.19 A2 — universal tick-density helper. Pre-fix the medication
// chart drew ONE tick per day, so a 30-day Pixel-5 window painted 30 // chart drew ONE tick per day, so a 30-day Pixel-5 window painted 30
@@ -320,11 +320,11 @@ export function MedicationComplianceChart({
v1.4.17 when the underlying intake-event window comparison v1.4.17 when the underlying intake-event window comparison
ships). Without this caption the user toggles Vormonat and ships). Without this caption the user toggles Vormonat and
this card just stays static, which reads as a bug. */} this card just stays static, which reads as a bug. */}
{compareBaseline !== "none" && ( {effectiveCompareBaseline !== "none" && (
<p <p
className="text-muted-foreground -mt-2 mb-3 text-xs" className="text-muted-foreground -mt-2 mb-3 text-xs"
data-slot="medication-comparison-na" data-slot="medication-comparison-na"
data-compare-baseline={compareBaseline} data-compare-baseline={effectiveCompareBaseline}
> >
{t("comparison.notAvailableForCompliance")} {t("comparison.notAvailableForCompliance")}
</p> </p>
+41 -34
View File
@@ -289,6 +289,8 @@ export function MoodChart({
const showMA = !mini && overlayPrefs.prefs.showTrendIndicator; const showMA = !mini && overlayPrefs.prefs.showTrendIndicator;
const showTrend = !mini && overlayPrefs.prefs.showTrendArrow; const showTrend = !mini && overlayPrefs.prefs.showTrendArrow;
const showBands = !mini && overlayPrefs.prefs.showTargetRange; const showBands = !mini && overlayPrefs.prefs.showTargetRange;
const effectiveCompareBaseline =
!mini && chartKey ? overlayPrefs.prefs.comparisonBaseline : compareBaseline;
// v1.4.19 A2 — viewport-aware tick density helper. // v1.4.19 A2 — viewport-aware tick density helper.
const viewportWidth = useViewportWidth(); const viewportWidth = useViewportWidth();
@@ -411,7 +413,7 @@ export function MoodChart({
* below renders the dimmed dashed overlay. * below renders the dimmed dashed overlay.
*/ */
const chartDataWithCompare = useMemo<ChartDataPoint[] | undefined>(() => { const chartDataWithCompare = useMemo<ChartDataPoint[] | undefined>(() => {
if (!chartData || compareBaseline === "none") return chartData; if (!chartData || effectiveCompareBaseline === "none") return chartData;
if (!data?.entries?.length) return chartData; if (!data?.entries?.length) return chartData;
const shifted = shiftDailySeriesForward( const shifted = shiftDailySeriesForward(
@@ -419,7 +421,7 @@ export function MoodChart({
timestamp: dayKeyToTimestamp(entry.date), timestamp: dayKeyToTimestamp(entry.date),
score: entry.score, score: entry.score,
})), })),
compareBaseline, effectiveCompareBaseline,
); );
const shiftedByDay = new Map<string, number>(); const shiftedByDay = new Map<string, number>();
@@ -437,16 +439,17 @@ export function MoodChart({
} }
return point; return point;
}); });
}, [chartData, compareBaseline, data]); }, [chartData, effectiveCompareBaseline, data]);
const hasComparisonData = useMemo(() => { const hasComparisonData = useMemo(() => {
if (compareBaseline === "none" || !chartDataWithCompare) return false; if (effectiveCompareBaseline === "none" || !chartDataWithCompare)
return false;
return chartDataWithCompare.some((point) => return chartDataWithCompare.some((point) =>
typeof point.scoreCompare === "number" typeof point.scoreCompare === "number"
? Number.isFinite(point.scoreCompare) ? Number.isFinite(point.scoreCompare)
: false, : false,
); );
}, [chartDataWithCompare, compareBaseline]); }, [chartDataWithCompare, effectiveCompareBaseline]);
// Mirror the activeBucket calculation from health-chart so the chip // Mirror the activeBucket calculation from health-chart so the chip
// in the header reflects the *same* aggregation chartData used. // in the header reflects the *same* aggregation chartData used.
@@ -533,30 +536,34 @@ export function MoodChart({
)} )}
{/* v1.4.16 phase B8 — comparison caption (mood). {/* v1.4.16 phase B8 — comparison caption (mood).
v1.4.19 A2 — hidden on mobile to free up the title row. */} v1.4.19 A2 — hidden on mobile to free up the title row. */}
{!mini && compareBaseline !== "none" && hasComparisonData && ( {!mini &&
<span effectiveCompareBaseline !== "none" &&
className="text-dracula-purple bg-dracula-purple/10 hidden rounded-md border border-current/30 px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase sm:inline-flex" hasComparisonData && (
data-slot="chart-compare-caption" <span
> className="text-dracula-purple bg-dracula-purple/10 hidden rounded-md border border-current/30 px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase sm:inline-flex"
{t( data-slot="chart-compare-caption"
compareBaseline === "lastMonth" >
? "comparison.captionLastMonth" {t(
: "comparison.captionLastYear", effectiveCompareBaseline === "lastMonth"
)} ? "comparison.captionLastMonth"
</span> : "comparison.captionLastYear",
)} )}
{!mini && compareBaseline !== "none" && !hasComparisonData && ( </span>
<span )}
className="text-muted-foreground bg-muted/40 hidden rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide sm:inline-flex" {!mini &&
data-slot="chart-compare-unavailable" effectiveCompareBaseline !== "none" &&
> !hasComparisonData && (
{t( <span
compareBaseline === "lastMonth" className="text-muted-foreground bg-muted/40 hidden rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide sm:inline-flex"
? "comparison.unavailable.lastMonth" data-slot="chart-compare-unavailable"
: "comparison.unavailable.lastYear", >
)} {t(
</span> effectiveCompareBaseline === "lastMonth"
)} ? "comparison.unavailable.lastMonth"
: "comparison.unavailable.lastYear",
)}
</span>
)}
</div> </div>
{!mini && ( {!mini && (
<div <div
@@ -733,11 +740,11 @@ export function MoodChart({
continue; continue;
let delta: string | undefined; let delta: string | undefined;
const compareValue = const compareValue =
compareBaseline !== "none" && hoverPoint effectiveCompareBaseline !== "none" && hoverPoint
? hoverPoint.scoreCompare ? hoverPoint.scoreCompare
: undefined; : undefined;
if ( if (
compareBaseline !== "none" && effectiveCompareBaseline !== "none" &&
typeof compareValue === "number" && typeof compareValue === "number" &&
Number.isFinite(compareValue) Number.isFinite(compareValue)
) { ) {
@@ -750,7 +757,7 @@ export function MoodChart({
1, 1,
)}`; )}`;
delta = t( delta = t(
compareBaseline === "lastMonth" effectiveCompareBaseline === "lastMonth"
? "comparison.deltaVs.lastMonth" ? "comparison.deltaVs.lastMonth"
: "comparison.deltaVs.lastYear", : "comparison.deltaVs.lastYear",
).replace("{delta}", formatted); ).replace("{delta}", formatted);
@@ -777,7 +784,7 @@ export function MoodChart({
delta, delta,
}); });
if ( if (
compareBaseline !== "none" && effectiveCompareBaseline !== "none" &&
typeof compareValue === "number" && typeof compareValue === "number" &&
Number.isFinite(compareValue) Number.isFinite(compareValue)
) { ) {
@@ -838,7 +845,7 @@ export function MoodChart({
uses; mood is a single metric so we only render uses; mood is a single metric so we only render
one overlay line. Suppressed when there's no prior one overlay line. Suppressed when there's no prior
data via hasComparisonData. */} data via hasComparisonData. */}
{compareBaseline !== "none" && hasComparisonData && ( {effectiveCompareBaseline !== "none" && hasComparisonData && (
<Line <Line
type="monotone" type="monotone"
dataKey="scoreCompare" dataKey="scoreCompare"
+50 -22
View File
@@ -210,21 +210,21 @@ export function TrendCard({
}; };
return ( return (
<div className="bg-card border-border flex h-full w-full flex-col rounded-xl border p-4 md:p-6"> <div className="bg-card border-border flex h-full w-full min-w-0 flex-col rounded-xl border p-4 md:p-6">
<div className="flex items-center justify-between"> <div className="flex min-w-0 items-start justify-between gap-2">
<span className="text-muted-foreground text-xs font-medium tracking-wide uppercase"> <span className="text-muted-foreground min-w-0 text-xs leading-snug font-medium tracking-wide break-words uppercase">
{label} {label}
</span> </span>
<Icon className="text-muted-foreground h-4 w-4" /> <Icon className="text-muted-foreground h-4 w-4 shrink-0" />
</div> </div>
<div className="mt-2 flex items-baseline gap-2"> <div className="mt-2 flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1">
<span className="text-3xl font-bold tracking-tight tabular-nums"> <span className="min-w-0 text-3xl leading-none font-bold tracking-tight [overflow-wrap:anywhere] tabular-nums">
{latest !== null ? renderPair(latest, secondary?.latest) : "—"} {latest !== null ? renderPair(latest, secondary?.latest) : "—"}
</span> </span>
<span className="text-muted-foreground text-sm tabular-nums"> <span className="text-muted-foreground text-sm [overflow-wrap:anywhere] tabular-nums">
{unit} {unit}
</span> </span>
{slope30 && <TrendIcon className={`h-4 w-4 ${trendColor}`} />} {slope30 && <TrendIcon className={`h-4 w-4 shrink-0 ${trendColor}`} />}
</div> </div>
{/* v1.4.16 phase B8 — comparison delta callout. Sits on its own {/* v1.4.16 phase B8 — comparison delta callout. Sits on its own
line below the latest value so the tile stays scannable on line below the latest value so the tile stays scannable on
@@ -246,7 +246,7 @@ export function TrendCard({
> >
<span <span
className={cn( className={cn(
"text-xs font-medium tabular-nums", "inline-block max-w-full text-xs leading-snug font-medium [overflow-wrap:anywhere] tabular-nums",
comparisonDeltaColor, comparisonDeltaColor,
)} )}
data-slot="tile-compare-delta" data-slot="tile-compare-delta"
@@ -266,14 +266,17 @@ export function TrendCard({
</div> </div>
)} )}
<TooltipProvider> <TooltipProvider>
<div className="text-muted-foreground mt-auto flex gap-3 pt-1 text-xs"> <div className="text-muted-foreground mt-auto flex min-w-0 flex-wrap gap-x-3 gap-y-1 pt-1 text-xs leading-snug">
<span> <span className="max-w-full min-w-0 [overflow-wrap:anywhere]">
{t(avg7LabelKey)}:{" "} {t(avg7LabelKey)}:{" "}
{avg7Hint ? ( {avg7Hint ? (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span <span
className={cn("font-medium tabular-nums", avg7ColorClass)} className={cn(
"font-medium [overflow-wrap:anywhere] tabular-nums",
avg7ColorClass,
)}
> >
{avg7 !== null ? renderPair(avg7, secondary?.avg7) : "—"} {avg7 !== null ? renderPair(avg7, secondary?.avg7) : "—"}
</span> </span>
@@ -283,13 +286,21 @@ export function TrendCard({
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
) : ( ) : (
<span className={cn("font-medium tabular-nums", avg7ColorClass)}> <span
className={cn(
"font-medium [overflow-wrap:anywhere] tabular-nums",
avg7ColorClass,
)}
>
{avg7 !== null ? renderPair(avg7, secondary?.avg7) : "—"} {avg7 !== null ? renderPair(avg7, secondary?.avg7) : "—"}
</span> </span>
)} )}
{trend7Delta != null && ( {trend7Delta != null && (
<span <span
className={cn("ml-1 font-medium tabular-nums", deltaColor)} className={cn(
"ml-1 font-medium [overflow-wrap:anywhere] tabular-nums",
deltaColor,
)}
data-slot="trend7-delta" data-slot="trend7-delta"
aria-label={`7-day trend ${formatDelta(trend7Delta)}`} aria-label={`7-day trend ${formatDelta(trend7Delta)}`}
> >
@@ -297,13 +308,16 @@ export function TrendCard({
</span> </span>
)} )}
</span> </span>
<span> <span className="max-w-full min-w-0 [overflow-wrap:anywhere]">
{t("charts.avg30dShort")}:{" "} {t("charts.avg30dShort")}:{" "}
{avg30Hint ? ( {avg30Hint ? (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span <span
className={cn("font-medium tabular-nums", avg30ColorClass)} className={cn(
"font-medium [overflow-wrap:anywhere] tabular-nums",
avg30ColorClass,
)}
> >
{avg30 !== null ? renderPair(avg30, secondary?.avg30) : "—"} {avg30 !== null ? renderPair(avg30, secondary?.avg30) : "—"}
</span> </span>
@@ -313,7 +327,12 @@ export function TrendCard({
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
) : ( ) : (
<span className={cn("font-medium tabular-nums", avg30ColorClass)}> <span
className={cn(
"font-medium [overflow-wrap:anywhere] tabular-nums",
avg30ColorClass,
)}
>
{avg30 !== null ? renderPair(avg30, secondary?.avg30) : "—"} {avg30 !== null ? renderPair(avg30, secondary?.avg30) : "—"}
</span> </span>
)} )}
@@ -328,10 +347,16 @@ export function TrendCard({
combined secondary row below so the tile keeps the same combined secondary row below so the tile keeps the same
vertical density as every other trend card. */} vertical density as every other trend card. */}
{avgAllTime !== undefined && ( {avgAllTime !== undefined && (
<span data-slot="trend-card-all-time" className="hidden sm:inline"> <span
data-slot="trend-card-all-time"
className="hidden max-w-full min-w-0 [overflow-wrap:anywhere] sm:inline"
>
{t(avgAllTimeLabelKey)}:{" "} {t(avgAllTimeLabelKey)}:{" "}
<span <span
className={cn("font-medium tabular-nums", avgAllTimeColorClass)} className={cn(
"font-medium [overflow-wrap:anywhere] tabular-nums",
avgAllTimeColorClass,
)}
> >
{avgAllTime !== null ? formatValue(avgAllTime) : "—"} {avgAllTime !== null ? formatValue(avgAllTime) : "—"}
</span> </span>
@@ -346,12 +371,15 @@ export function TrendCard({
{avgAllTime !== undefined && ( {avgAllTime !== undefined && (
<div <div
data-slot="trend-card-secondary-mobile" data-slot="trend-card-secondary-mobile"
className="text-muted-foreground mt-1 text-xs sm:hidden" className="text-muted-foreground mt-1 min-w-0 text-xs leading-snug [overflow-wrap:anywhere] sm:hidden"
> >
<span> <span>
{t(avgAllTimeLabelKey)}:{" "} {t(avgAllTimeLabelKey)}:{" "}
<span <span
className={cn("font-medium tabular-nums", avgAllTimeColorClass)} className={cn(
"font-medium [overflow-wrap:anywhere] tabular-nums",
avgAllTimeColorClass,
)}
> >
{avgAllTime !== null ? formatValue(avgAllTime) : "—"} {avgAllTime !== null ? formatValue(avgAllTime) : "—"}
</span> </span>
@@ -361,7 +389,7 @@ export function TrendCard({
<span aria-hidden="true"> · </span> <span aria-hidden="true"> · </span>
<span <span
className={cn( className={cn(
"font-medium tabular-nums", "font-medium [overflow-wrap:anywhere] tabular-nums",
comparisonDeltaColor, comparisonDeltaColor,
)} )}
data-slot="tile-compare-delta-mobile" data-slot="tile-compare-delta-mobile"
@@ -172,7 +172,7 @@ export function CoachDrawer({
} }
onOpenChange(next); onOpenChange(next);
}, },
[onOpenChange], [onOpenChange, setInputValue],
); );
const { data: conversation } = useCoachConversation(currentConversationId); const { data: conversation } = useCoachConversation(currentConversationId);
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { import {
Bot, Bot,
@@ -69,7 +69,10 @@ export function MessageThread({
const { data: coachPrefs } = useCoachPrefs(); const { data: coachPrefs } = useCoachPrefs();
const evidenceDefaultOpen = coachPrefs?.showEvidenceByDefault ?? false; const evidenceDefaultOpen = coachPrefs?.showEvidenceByDefault ?? false;
const messages: CoachMessageDTO[] = conversation?.messages ?? []; const messages: CoachMessageDTO[] = useMemo(
() => conversation?.messages ?? [],
[conversation?.messages],
);
// v1.4.20.1 — once the SSE stream emits `done`, the route's // v1.4.20.1 — once the SSE stream emits `done`, the route's
// invalidate-then-refetch pulls the persisted assistant message into // invalidate-then-refetch pulls the persisted assistant message into
// `conversation.messages`. The streaming bubble was still rendering // `conversation.messages`. The streaming bubble was still rendering
@@ -459,7 +459,7 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8" className="h-10 w-10"
onClick={() => startEdit(m)} onClick={() => startEdit(m)}
aria-label={t("common.edit")} aria-label={t("common.edit")}
> >
@@ -536,6 +536,7 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</Button> </Button>
<DeleteButton <DeleteButton
className="h-10 w-10"
onConfirm={() => deleteMutation.mutate(m.id)} onConfirm={() => deleteMutation.mutate(m.id)}
/> />
</div> </div>
@@ -774,7 +775,13 @@ function SortableHead({
); );
} }
function DeleteButton({ onConfirm }: { onConfirm: () => void }) { function DeleteButton({
onConfirm,
className = "h-8 w-8",
}: {
onConfirm: () => void;
className?: string;
}) {
const { t } = useTranslations(); const { t } = useTranslations();
return ( return (
<AlertDialog> <AlertDialog>
@@ -782,7 +789,7 @@ function DeleteButton({ onConfirm }: { onConfirm: () => void }) {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="text-destructive h-8 w-8" className={`text-destructive ${className}`}
aria-label={t("common.delete")} aria-label={t("common.delete")}
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
@@ -664,8 +664,9 @@ function MoodLogCard({
<form onSubmit={handleSave} className="space-y-3"> <form onSubmit={handleSave} className="space-y-3">
<div> <div>
<Label>{t("settings.moodLogUrl")}</Label> <Label htmlFor="moodlog-url">{t("settings.moodLogUrl")}</Label>
<Input <Input
id="moodlog-url"
type="url" type="url"
placeholder={t("settings.moodLogUrlPlaceholder")} placeholder={t("settings.moodLogUrlPlaceholder")}
value={url} value={url}
@@ -673,8 +674,11 @@ function MoodLogCard({
/> />
</div> </div>
<div> <div>
<Label>{t("settings.moodLogApiKey")}</Label> <Label htmlFor="moodlog-api-key">
{t("settings.moodLogApiKey")}
</Label>
<PasswordInput <PasswordInput
id="moodlog-api-key"
placeholder={ placeholder={
status?.configured status?.configured
? t("settings.withingsCredentialsSavedPlaceholder") ? t("settings.withingsCredentialsSavedPlaceholder")
@@ -705,9 +709,12 @@ function MoodLogCard({
<div className="space-y-3 border-t pt-3"> <div className="space-y-3 border-t pt-3">
{status.webhookSecret && ( {status.webhookSecret && (
<div> <div>
<Label>{t("settings.moodLogWebhookSecret")}</Label> <Label htmlFor="moodlog-webhook-secret">
{t("settings.moodLogWebhookSecret")}
</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
id="moodlog-webhook-secret"
value={status.webhookSecret} value={status.webhookSecret}
readOnly readOnly
className="font-mono text-xs" className="font-mono text-xs"
@@ -54,9 +54,21 @@ export function NotificationsSection() {
<NotificationStatusCard /> <NotificationStatusCard />
{showTelegram && <TelegramCard isAuthenticated={isAuthenticated} />} {showTelegram && (
{showNtfy && <NtfyCard isAuthenticated={isAuthenticated} />} <div id="telegram">
{showWebPush && <WebPushCard />} <TelegramCard isAuthenticated={isAuthenticated} />
</div>
)}
{showNtfy && (
<div id="ntfy">
<NtfyCard isAuthenticated={isAuthenticated} />
</div>
)}
{showWebPush && (
<div id="web-push">
<WebPushCard />
</div>
)}
</section> </section>
); );
} }
@@ -244,11 +244,25 @@ describe("resolveDashboardLayout() — chartOverlayPrefs (v1.4.18)", () => {
showTrendIndicator: true, showTrendIndicator: true,
showTrendArrow: false, showTrendArrow: false,
showTargetRange: true, showTargetRange: true,
comparisonBaseline: "lastMonth",
}, },
weight: { weight: {
showTrendIndicator: false, showTrendIndicator: false,
showTrendArrow: true, showTrendArrow: true,
showTargetRange: false, showTargetRange: false,
comparisonBaseline: "none",
},
bmi: {
showTrendIndicator: true,
showTrendArrow: true,
showTargetRange: true,
comparisonBaseline: "lastYear",
},
bodyFat: {
showTrendIndicator: false,
showTrendArrow: false,
showTargetRange: true,
comparisonBaseline: "none",
}, },
}, },
}; };
@@ -259,11 +273,25 @@ describe("resolveDashboardLayout() — chartOverlayPrefs (v1.4.18)", () => {
showTrendIndicator: true, showTrendIndicator: true,
showTrendArrow: false, showTrendArrow: false,
showTargetRange: true, showTargetRange: true,
comparisonBaseline: "lastMonth",
}, },
weight: { weight: {
showTrendIndicator: false, showTrendIndicator: false,
showTrendArrow: true, showTrendArrow: true,
showTargetRange: false, showTargetRange: false,
comparisonBaseline: "none",
},
bmi: {
showTrendIndicator: true,
showTrendArrow: true,
showTargetRange: true,
comparisonBaseline: "lastYear",
},
bodyFat: {
showTrendIndicator: false,
showTrendArrow: false,
showTargetRange: true,
comparisonBaseline: "none",
}, },
}); });
}); });
@@ -277,6 +305,7 @@ describe("resolveDashboardLayout() — chartOverlayPrefs (v1.4.18)", () => {
showTrendIndicator: "yes", showTrendIndicator: "yes",
showTrendArrow: 1, showTrendArrow: 1,
showTargetRange: null, showTargetRange: null,
comparisonBaseline: "nextWeek",
}, },
}, },
}; };
@@ -285,6 +314,7 @@ describe("resolveDashboardLayout() — chartOverlayPrefs (v1.4.18)", () => {
showTrendIndicator: false, showTrendIndicator: false,
showTrendArrow: false, showTrendArrow: false,
showTargetRange: false, showTargetRange: false,
comparisonBaseline: "none",
}); });
}); });
@@ -297,11 +327,13 @@ describe("resolveDashboardLayout() — chartOverlayPrefs (v1.4.18)", () => {
showTrendIndicator: true, showTrendIndicator: true,
showTrendArrow: false, showTrendArrow: false,
showTargetRange: false, showTargetRange: false,
comparisonBaseline: "none",
}, },
not_a_real_chart: { not_a_real_chart: {
showTrendIndicator: true, showTrendIndicator: true,
showTrendArrow: true, showTrendArrow: true,
showTargetRange: true, showTargetRange: true,
comparisonBaseline: "lastMonth",
}, },
}, },
}; };
+25 -1
View File
@@ -81,7 +81,7 @@ describe("requireAuth — Bearer token path", () => {
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "token-1", id: "token-1",
userId: "user-1", userId: "user-1",
permissions: ["medication:ingest"], permissions: ["*"],
revoked: false, revoked: false,
expiresAt, expiresAt,
} as never); } as never);
@@ -198,6 +198,30 @@ describe("requireAuth — Bearer token path", () => {
expect(prisma.user.findUnique).not.toHaveBeenCalled(); expect(prisma.user.findUnique).not.toHaveBeenCalled();
}); });
it("rejects a scoped Bearer token when the route did not declare a scope", async () => {
vi.mocked(getSession).mockResolvedValue(null);
setBearerHeader(`Bearer ${RAW_TOKEN}`);
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "token-5",
userId: "user-1",
permissions: ["medication:ingest"],
revoked: false,
expiresAt: null,
} as never);
await expect(requireAuth()).rejects.toMatchObject({
statusCode: 403,
});
expect(auditLog).toHaveBeenCalledWith(
"auth.bearer.failure",
expect.objectContaining({
details: expect.objectContaining({ reason: "scope_required" }),
}),
);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
it("returns 401 when neither cookie nor Bearer is present", async () => { it("returns 401 when neither cookie nor Bearer is present", async () => {
vi.mocked(getSession).mockResolvedValue(null); vi.mocked(getSession).mockResolvedValue(null);
setBearerHeader(null); setBearerHeader(null);
+2 -4
View File
@@ -1,4 +1,4 @@
import type { AIProvider, CompletionParams, CompletionResult } from "./types"; import type { AIProvider, CompletionResult } from "./types";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto"; import { decrypt } from "@/lib/crypto";
import { CodexClient } from "./codex-client"; import { CodexClient } from "./codex-client";
@@ -19,9 +19,7 @@ const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; // 5 minutes
class NoProvider implements AIProvider { class NoProvider implements AIProvider {
readonly type = "none" as const; readonly type = "none" as const;
async generateCompletion( async generateCompletion(): Promise<CompletionResult> {
_params: CompletionParams,
): Promise<CompletionResult> {
throw new Error( throw new Error(
"No AI provider configured. Connect ChatGPT or set an API key in settings.", "No AI provider configured. Connect ChatGPT or set an API key in settings.",
); );
+14 -1
View File
@@ -222,9 +222,22 @@ async function authenticateBearer(
// adopt requireAuth("scope:name"), the wildcard handling here keeps // adopt requireAuth("scope:name"), the wildcard handling here keeps
// the iOS app working while narrower-scoped tokens (e.g. ["medication: // the iOS app working while narrower-scoped tokens (e.g. ["medication:
// ingest"]) get correctly 403'd. // ingest"]) get correctly 403'd.
const hasWildcardPermission = apiToken.permissions.includes("*");
if (!requiredPermission && !hasWildcardPermission) {
auditLog("auth.bearer.failure", {
userId: apiToken.userId,
details: {
reason: "scope_required",
tokenId: apiToken.id,
},
}).catch(() => {});
throw new HttpError(403, "Insufficient permissions");
}
if ( if (
requiredPermission && requiredPermission &&
!apiToken.permissions.includes("*") && !hasWildcardPermission &&
!apiToken.permissions.includes(requiredPermission) !apiToken.permissions.includes(requiredPermission)
) { ) {
auditLog("auth.bearer.failure", { auditLog("auth.bearer.failure", {
+65
View File
@@ -0,0 +1,65 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const cookieState = vi.hoisted(() => ({
sessionId: undefined as string | undefined,
delete: vi.fn(),
set: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: {
session: {
findUnique: vi.fn(),
deleteMany: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", () => ({
getEvent: () => undefined,
}));
vi.mock("next/headers", () => ({
cookies: vi.fn(async () => ({
get: (name: string) =>
name === "healthlog_session" && cookieState.sessionId
? { value: cookieState.sessionId }
: undefined,
set: cookieState.set,
delete: cookieState.delete,
})),
}));
import { prisma } from "@/lib/db";
import { getSession } from "../session";
beforeEach(() => {
vi.resetAllMocks();
cookieState.sessionId = "sess-expired";
});
describe("getSession", () => {
it("swallows expired-session delete races and clears cookies", async () => {
vi.mocked(prisma.session.findUnique).mockResolvedValue({
id: "sess-expired",
expiresAt: new Date(Date.now() - 1000),
user: { id: "user-1" },
} as never);
vi.mocked(prisma.session.deleteMany).mockRejectedValue(
new Error("already deleted") as never,
);
await expect(getSession()).resolves.toBeNull();
expect(prisma.session.deleteMany).toHaveBeenCalledWith({
where: { id: "sess-expired" },
});
expect(cookieState.delete).toHaveBeenCalledWith("healthlog_session");
expect(cookieState.delete).toHaveBeenCalledWith("hl_onboarding");
});
});
+3 -1
View File
@@ -111,7 +111,9 @@ export async function getSession(): Promise<{
if (!session || session.expiresAt < new Date()) { if (!session || session.expiresAt < new Date()) {
if (session) { if (session) {
await prisma.session.delete({ where: { id: sessionId } }); await prisma.session
.deleteMany({ where: { id: sessionId } })
.catch(() => {});
} }
cookieStore.delete(SESSION_COOKIE); cookieStore.delete(SESSION_COOKIE);
cookieStore.delete(ONBOARDING_COOKIE); cookieStore.delete(ONBOARDING_COOKIE);
+9
View File
@@ -112,9 +112,13 @@ export function isComparisonBaseline(
export const CHART_OVERLAY_KEYS = [ export const CHART_OVERLAY_KEYS = [
"bp", "bp",
"weight", "weight",
"bmi",
"pulse", "pulse",
"bodyFat",
"mood", "mood",
"medications", "medications",
"sleep",
"steps",
] as const; ] as const;
export type ChartOverlayKey = (typeof CHART_OVERLAY_KEYS)[number]; export type ChartOverlayKey = (typeof CHART_OVERLAY_KEYS)[number];
@@ -122,12 +126,14 @@ export interface ChartOverlayPrefs {
showTrendIndicator: boolean; showTrendIndicator: boolean;
showTrendArrow: boolean; showTrendArrow: boolean;
showTargetRange: boolean; showTargetRange: boolean;
comparisonBaseline: ComparisonBaseline;
} }
export const DEFAULT_CHART_OVERLAY_PREFS: ChartOverlayPrefs = { export const DEFAULT_CHART_OVERLAY_PREFS: ChartOverlayPrefs = {
showTrendIndicator: false, showTrendIndicator: false,
showTrendArrow: false, showTrendArrow: false,
showTargetRange: false, showTargetRange: false,
comparisonBaseline: "none",
}; };
export type ChartOverlayPrefsMap = Partial< export type ChartOverlayPrefsMap = Partial<
@@ -145,6 +151,9 @@ function coerceChartOverlayPrefs(value: unknown): ChartOverlayPrefs {
showTrendIndicator: candidate.showTrendIndicator === true, showTrendIndicator: candidate.showTrendIndicator === true,
showTrendArrow: candidate.showTrendArrow === true, showTrendArrow: candidate.showTrendArrow === true,
showTargetRange: candidate.showTargetRange === true, showTargetRange: candidate.showTargetRange === true,
comparisonBaseline: isComparisonBaseline(candidate.comparisonBaseline)
? candidate.comparisonBaseline
: "none",
}; };
} }
+1 -1
View File
@@ -151,7 +151,7 @@ export function loadApnsConfig(): ApnsConfig | null {
signingKey = inlineKey.replace(/\\n/g, "\n"); signingKey = inlineKey.replace(/\\n/g, "\n");
} else { } else {
try { try {
signingKey = readFileSync(keyFile, "utf8"); signingKey = readFileSync(/* turbopackIgnore: true */ keyFile, "utf8");
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : "read_failed"; const message = err instanceof Error ? err.message : "read_failed";
getEvent()?.addWarning(`APNs key file read failed: ${message}`); getEvent()?.addWarning(`APNs key file read failed: ${message}`);
+10 -3
View File
@@ -90,6 +90,15 @@ const LEGACY_ADMIN_ANCHORS: Record<string, string> = {
"/admin/section-danger-zone": "/admin/danger-zone", "/admin/section-danger-zone": "/admin/danger-zone",
}; };
function generateNonce(): string {
const bytes = crypto.getRandomValues(new Uint8Array(16));
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
export function proxy(request: NextRequest) { export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
@@ -178,9 +187,7 @@ export function proxy(request: NextRequest) {
// 128 bits of random, base64-encoded. randomUUID().toString() only carries // 128 bits of random, base64-encoded. randomUUID().toString() only carries
// ~122 bits and has a predictable structure that base64-encodes to a // ~122 bits and has a predictable structure that base64-encodes to a
// partially guessable pattern. // partially guessable pattern.
const nonce = Buffer.from( const nonce = generateNonce();
crypto.getRandomValues(new Uint8Array(16)),
).toString("base64");
const requestHeaders = new Headers(request.headers); const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-nonce", nonce); requestHeaders.set("x-nonce", nonce);
requestHeaders.set("x-request-id", requestId); requestHeaders.set("x-request-id", requestId);
@@ -100,7 +100,10 @@ describe("GET /api/analytics — sleep-stage aggregation", () => {
// route should surface a single Measurement-summary datapoint // route should surface a single Measurement-summary datapoint
// (~480 minutes total) plus a per-stage breakdown. // (~480 minutes total) plus a per-stage breakdown.
const baseDate = new Date("2026-05-09T01:00:00.000Z"); const baseDate = new Date("2026-05-09T01:00:00.000Z");
const stages: Array<{ stage: "IN_BED" | "CORE" | "DEEP" | "REM"; min: number }> = [ const stages: Array<{
stage: "IN_BED" | "CORE" | "DEEP" | "REM";
min: number;
}> = [
{ stage: "IN_BED", min: 480 }, { stage: "IN_BED", min: 480 },
{ stage: "CORE", min: 220 }, { stage: "CORE", min: 220 },
{ stage: "DEEP", min: 90 }, { stage: "DEEP", min: 90 },
@@ -128,9 +131,9 @@ describe("GET /api/analytics — sleep-stage aggregation", () => {
// the inner handler ignores its arguments. Cast through `unknown` // the inner handler ignores its arguments. Cast through `unknown`
// because `apiHandler`'s narrowed type signature is the inner // because `apiHandler`'s narrowed type signature is the inner
// handler's `()`, not the wrapper's `(NextRequest)`. // handler's `()`, not the wrapper's `(NextRequest)`.
const response = await (GET as unknown as (req: Request) => Promise<Response>)( const response = await (
new Request("http://localhost/api/analytics"), GET as unknown as (req: Request) => Promise<Response>
); )(new Request("http://localhost/api/analytics"));
expect(response.status).toBe(200); expect(response.status).toBe(200);
const envelope = (await response.json()) as AnalyticsEnvelope; const envelope = (await response.json()) as AnalyticsEnvelope;
@@ -173,9 +176,9 @@ describe("GET /api/analytics — sleep-stage aggregation", () => {
// the inner handler ignores its arguments. Cast through `unknown` // the inner handler ignores its arguments. Cast through `unknown`
// because `apiHandler`'s narrowed type signature is the inner // because `apiHandler`'s narrowed type signature is the inner
// handler's `()`, not the wrapper's `(NextRequest)`. // handler's `()`, not the wrapper's `(NextRequest)`.
const response = await (GET as unknown as (req: Request) => Promise<Response>)( const response = await (
new Request("http://localhost/api/analytics"), GET as unknown as (req: Request) => Promise<Response>
); )(new Request("http://localhost/api/analytics"));
expect(response.status).toBe(200); expect(response.status).toBe(200);
const envelope = (await response.json()) as AnalyticsEnvelope; const envelope = (await response.json()) as AnalyticsEnvelope;
+1
View File
@@ -14,6 +14,7 @@ export default defineConfig({
exclude: [ exclude: [
"**/node_modules/**", "**/node_modules/**",
"**/dist/**", "**/dist/**",
"**/.next/**",
"tests/integration/**", "tests/integration/**",
// Playwright E2E suite — driven separately via `pnpm e2e`. // Playwright E2E suite — driven separately via `pnpm e2e`.
"e2e/**", "e2e/**",