mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
0dc0e1645c
commit
ca03c6099f
59 files changed
+841
-206
No files matched your search
+4
-3
@@ -216,9 +216,10 @@ MEDs, all LOWs, S-05 simplify, pre-existing `coach-prefs.test.ts` mock
|
||||
issue, sandbox `git commit` no-op).
|
||||
|
||||
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
|
||||
(+45 across the W5+W6 reconcile arc).
|
||||
|
||||
- `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
|
||||
(+45 across the W5+W6 reconcile arc).
|
||||
|
||||
## Wave 7 — Release v1.4.23 (next)
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@ six-review brief into shippable develop-branch state.
|
||||
|
||||
## Per-session SHA chain
|
||||
|
||||
| Session | Scope | Commit SHAs |
|
||||
| --- | --- | --- |
|
||||
| A | CRITICAL + HIGH 1-3 | `5486507`, `13977bc`, `d5202e5`, `a2dfc5e` |
|
||||
| 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` |
|
||||
| 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) |
|
||||
| Session | Scope | Commit SHAs |
|
||||
| ------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
|
||||
| A | CRITICAL + HIGH 1-3 | `5486507`, `13977bc`, `d5202e5`, `a2dfc5e` |
|
||||
| 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` |
|
||||
| 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) |
|
||||
|
||||
## Counts
|
||||
|
||||
@@ -28,14 +28,14 @@ six-review brief into shippable develop-branch state.
|
||||
|
||||
## Test count delta
|
||||
|
||||
| Snapshot | Unit count |
|
||||
| --- | --- |
|
||||
| W5 close (commit `72829b1`) | 2223 |
|
||||
| Session A close (CRIT + HIGH 1-3 added device + revoke tests) | 2227 |
|
||||
| Session B close (HIGH 4 + simplify + HIGH 6) | ~2229 |
|
||||
| Session D close (HIGH 5 reconfirm) | ~2229 |
|
||||
| Session E close (HIGH 7 + coach-feedback updates) | 2235 |
|
||||
| Session F close (forged X-Device-Id regression) | **2236** |
|
||||
| Snapshot | Unit count |
|
||||
| ------------------------------------------------------------- | ---------- |
|
||||
| W5 close (commit `72829b1`) | 2223 |
|
||||
| Session A close (CRIT + HIGH 1-3 added device + revoke tests) | 2227 |
|
||||
| Session B close (HIGH 4 + simplify + HIGH 6) | ~2229 |
|
||||
| Session D close (HIGH 5 reconfirm) | ~2229 |
|
||||
| Session E close (HIGH 7 + coach-feedback updates) | 2235 |
|
||||
| Session F close (forged X-Device-Id regression) | **2236** |
|
||||
|
||||
Net delta over the W6 reconcile arc: **+13 unit tests**.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ without re-reading the full review pack.
|
||||
the settings cog) duplicates intent. Defer to v1.4.24 once W5 H7
|
||||
surfaces enough thumbs data to know whether per-user prompt prefs
|
||||
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
|
||||
|
||||
|
||||
+139
-14
@@ -1,5 +1,130 @@
|
||||
# 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
|
||||
|
||||
### Added
|
||||
@@ -41,15 +166,15 @@
|
||||
- **OpenAPI 3.1 generator + drift CI gate.** `pnpm openapi:generate`
|
||||
reads Zod v4 `.meta()` annotations on the existing validation
|
||||
schemas and emits a byte-stable `docs/api/openapi.yaml` (`zod-openapi`
|
||||
+ `yaml@^2` with `sortMapEntries: true`). The eight iOS-critical
|
||||
routes are registered now: `auth/login`, passkey verify,
|
||||
`auth/refresh`, `measurements` GET + POST + batch, `devices` POST,
|
||||
and the comprehensive insights bundle. A new `pnpm openapi:check`
|
||||
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
|
||||
to hard-fail in v1.4.24. The legacy hand-maintained spec is
|
||||
preserved at `docs/api/openapi-v1422-legacy.yaml` so iOS DTO
|
||||
reference doesn't disappear during the incremental migration.
|
||||
- `yaml@^2` with `sortMapEntries: true`). The eight iOS-critical
|
||||
routes are registered now: `auth/login`, passkey verify,
|
||||
`auth/refresh`, `measurements` GET + POST + batch, `devices` POST,
|
||||
and the comprehensive insights bundle. A new `pnpm openapi:check`
|
||||
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
|
||||
to hard-fail in v1.4.24. The legacy hand-maintained spec is
|
||||
preserved at `docs/api/openapi-v1422-legacy.yaml` so iOS DTO
|
||||
reference doesn't disappear during the incremental migration.
|
||||
- **Device-management endpoints for native + web settings.**
|
||||
`GET /api/auth/me/devices` lists active devices with label, last-
|
||||
seen timestamp, channels (`web_push` / `apns`), and an `isCurrent`
|
||||
@@ -77,7 +202,7 @@
|
||||
endpoint `POST /api/insights/chat/messages/:id/feedback`. A new
|
||||
admin section `/admin/coach-feedback` renders helpful-rate buckets
|
||||
by (promptVersion, tone, verbosity) and gets a sidebar entry
|
||||
+ EN/DE i18n bundle.
|
||||
- EN/DE i18n bundle.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -117,7 +242,7 @@
|
||||
steps, active energy, flights, distance, VO2 max, body temp) as
|
||||
silent when the snapshot doesn't carry them. No apologetic openers
|
||||
about missing data. `aiInsightResponseSchema.dailyBriefing
|
||||
.keyFindings[].sourceMetric` and `trendAnnotations` enums extend
|
||||
.keyFindings[].sourceMetric` and `trendAnnotations` enums extend
|
||||
to admit the nine additive HealthKit categories.
|
||||
- **`medication_schedules.days_of_week` column deployed.** Migration
|
||||
`0039_medication_schedule_days_of_week` adds the nullable column
|
||||
@@ -135,7 +260,7 @@
|
||||
- **Partial unique index enforces `apns_token` global uniqueness.**
|
||||
App-layer guard catches the cross-user-hijack case; the DB-layer
|
||||
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.**
|
||||
Desktop list rendered the chip; the mobile card variant fell back
|
||||
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
|
||||
redacts the resolved user + device IDs before logging.
|
||||
- **Coolify webhook URL scrubbed from the deploy runbook.** Webhook
|
||||
+ token live in GH secrets; the runbook references the secret
|
||||
names rather than the raw URL.
|
||||
- token live in GH secrets; the runbook references the secret
|
||||
names rather than the raw URL.
|
||||
- **Coach prose encryption-at-rest restored.** The plaintext
|
||||
`content` column on `recommendation_feedback` was dropped after
|
||||
the feedback rows migrated to a `coach_messages` FK. Cypher text
|
||||
|
||||
+2
-2
@@ -55,13 +55,13 @@ COPY --from=builder /app/src/generated ./src/generated
|
||||
RUN mkdir -p /opt/pg-boss && \
|
||||
cd /opt/pg-boss && \
|
||||
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)
|
||||
RUN mkdir -p /opt/prisma-cli && \
|
||||
cd /opt/prisma-cli && \
|
||||
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)
|
||||
COPY docker-entrypoint.sh ./
|
||||
|
||||
+20
-10
@@ -210,7 +210,8 @@ components:
|
||||
- error
|
||||
type: object
|
||||
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).
|
||||
properties:
|
||||
apnsEnvironment:
|
||||
@@ -353,7 +354,8 @@ components:
|
||||
type: object
|
||||
InsightsComprehensiveResponse:
|
||||
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.
|
||||
properties:
|
||||
citations:
|
||||
@@ -707,7 +709,8 @@ openapi: 3.1.0
|
||||
paths:
|
||||
/api/auth/login:
|
||||
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."
|
||||
requestBody:
|
||||
content:
|
||||
@@ -755,7 +758,8 @@ paths:
|
||||
- Auth
|
||||
/api/auth/me/coach-prefs:
|
||||
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.
|
||||
responses:
|
||||
"200":
|
||||
@@ -771,7 +775,8 @@ paths:
|
||||
tags:
|
||||
- Auth
|
||||
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.
|
||||
requestBody:
|
||||
content:
|
||||
@@ -849,7 +854,8 @@ paths:
|
||||
- Auth
|
||||
/api/auth/refresh:
|
||||
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
|
||||
revoke-all-for-user.
|
||||
requestBody:
|
||||
@@ -873,7 +879,8 @@ paths:
|
||||
- Auth
|
||||
/api/devices:
|
||||
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.
|
||||
requestBody:
|
||||
content:
|
||||
@@ -902,7 +909,8 @@ paths:
|
||||
- Devices
|
||||
/api/insights/chat/messages/{id}/feedback:
|
||||
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,
|
||||
verbosity).
|
||||
requestBody:
|
||||
@@ -938,7 +946,8 @@ paths:
|
||||
- Insights
|
||||
/api/insights/comprehensive:
|
||||
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.
|
||||
responses:
|
||||
"200":
|
||||
@@ -1110,7 +1119,8 @@ paths:
|
||||
- Measurements
|
||||
/api/measurements/batch:
|
||||
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.
|
||||
requestBody:
|
||||
content:
|
||||
|
||||
@@ -6,14 +6,14 @@ v1.4.22 is the polish release that closes loops the Insights redesign, the AI Co
|
||||
|
||||
## Live state
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| URL | `https://healthlog.bombeck.io` |
|
||||
| `/api/version` | `1.4.22` |
|
||||
| Image digest | `sha256:865154614303fdc362ee3941776f73ec0f60e1f16112ec272a75cbbe28e2cffb` |
|
||||
| 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 |
|
||||
| Branch model | second release through the `develop` → `main` model (introduced in v1.4.20 F1) |
|
||||
| Field | Value |
|
||||
| ------------------ | ------------------------------------------------------------------------------ |
|
||||
| URL | `https://healthlog.bombeck.io` |
|
||||
| `/api/version` | `1.4.22` |
|
||||
| Image digest | `sha256:865154614303fdc362ee3941776f73ec0f60e1f16112ec272a75cbbe28e2cffb` |
|
||||
| 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 |
|
||||
| Branch model | second release through the `develop` → `main` model (introduced in v1.4.20 F1) |
|
||||
|
||||
## Smoke (no session)
|
||||
|
||||
|
||||
@@ -665,6 +665,7 @@
|
||||
"trendIndicator": "7-Tage-Trend",
|
||||
"trendArrow": "Trend-Pfeil",
|
||||
"targetRange": "Zielbereich",
|
||||
"comparisonBaseline": "Vergleich",
|
||||
"tooltip": {
|
||||
"openSettings": "Chart-Overlay-Einstellungen"
|
||||
}
|
||||
@@ -2492,6 +2493,7 @@
|
||||
"title": "Benachrichtigungen",
|
||||
"eventColumn": "Ereignis",
|
||||
"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.",
|
||||
"goToSettings": "Zu den Einstellungen",
|
||||
"channelDisabled": "Deaktiviert",
|
||||
|
||||
@@ -665,6 +665,7 @@
|
||||
"trendIndicator": "7-day trend",
|
||||
"trendArrow": "Trend arrow",
|
||||
"targetRange": "Target range",
|
||||
"comparisonBaseline": "Comparison",
|
||||
"tooltip": {
|
||||
"openSettings": "Chart overlay settings"
|
||||
}
|
||||
@@ -2492,6 +2493,7 @@
|
||||
"title": "Notifications",
|
||||
"eventColumn": "Event",
|
||||
"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.",
|
||||
"goToSettings": "Go to Settings",
|
||||
"channelDisabled": "Disabled",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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.",
|
||||
"license": "AGPL-3.0-only",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
@@ -22,11 +22,9 @@ function main(): void {
|
||||
});
|
||||
const committed = readFileSync(SPEC_PATH, "utf8");
|
||||
if (generated === committed) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("OpenAPI spec in sync with source schemas.");
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
"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);
|
||||
for (let i = 0; i < max; i++) {
|
||||
if (genLines[i] !== onDiskLines[i]) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`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;
|
||||
for (let j = i + 1; j < max && shown < 5; j++) {
|
||||
if (genLines[j] !== onDiskLines[j]) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`Line ${j + 1}:\n generated: ${JSON.stringify(genLines[j] ?? "")}\n committed: ${JSON.stringify(onDiskLines[j] ?? "")}`,
|
||||
);
|
||||
|
||||
@@ -28,7 +28,6 @@ function main(): void {
|
||||
lineWidth: 120,
|
||||
});
|
||||
writeFileSync(SPEC_PATH, yaml, "utf8");
|
||||
// eslint-disable-next-line no-console
|
||||
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.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";
|
||||
|
||||
function redact(text: string): string {
|
||||
return text.replace(/https?:\/\/\S+/gi, "[url]");
|
||||
}
|
||||
|
||||
function resolveUmamiSendUrls(scriptUrl: string | null): string[] {
|
||||
if (!scriptUrl) return [];
|
||||
try {
|
||||
@@ -112,6 +116,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
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);
|
||||
});
|
||||
@@ -308,10 +308,9 @@ async function computeCorrelationHypotheses(userId: string): Promise<{
|
||||
// HealthKit pulse samples) cannot allocate an unbounded buffer. The
|
||||
// helper still returns the full filtered series the Pearson runners
|
||||
// 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([
|
||||
fetchMeasurementSeriesChunked(userId, "BLOOD_PRESSURE_SYS", { since }),
|
||||
fetchMeasurementSeriesChunked(userId, "BLOOD_PRESSURE_DIA", { since }),
|
||||
fetchMeasurementSeriesChunked(userId, "PULSE", { since }),
|
||||
fetchMeasurementSeriesChunked(userId, "WEIGHT", { since }),
|
||||
prisma.moodEntry.findMany({
|
||||
|
||||
@@ -22,7 +22,8 @@ import { apiSuccess } from "@/lib/api-response";
|
||||
* persisted server-side via an encrypted httpOnly cookie scoped to a
|
||||
* 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 rl = await checkRateLimit(`codex-device-start:${user.id}`, 5, 60_000);
|
||||
|
||||
@@ -60,7 +60,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
if (appSettings?.githubIssueTokenEncrypted) {
|
||||
try {
|
||||
configuredToken = decrypt(appSettings.githubIssueTokenEncrypted);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
getEvent()?.addWarning("Failed to decrypt GitHub issue token");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,9 @@ const prefsSchema = z.object({
|
||||
showTrendIndicator: z.boolean(),
|
||||
showTrendArrow: 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);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -70,7 +70,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = importSchema.safeParse(body);
|
||||
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;
|
||||
|
||||
@@ -238,18 +238,16 @@ describe("POST /api/insights/generate — rate limit (v1.4.16 A7.1)", () => {
|
||||
makeWorkingProvider();
|
||||
// Simulate 10 successful checkRateLimit responses, then one denial.
|
||||
let callCount = 0;
|
||||
vi.mocked(checkRateLimit).mockImplementation(
|
||||
async (_key, limit, _windowMs) => {
|
||||
callCount += 1;
|
||||
// The route must pass `10` as the limit when the env var is unset.
|
||||
expect(limit).toBe(10);
|
||||
return {
|
||||
allowed: callCount <= 10,
|
||||
remaining: Math.max(0, 10 - callCount),
|
||||
resetAt: Date.now() + 3600_000,
|
||||
};
|
||||
},
|
||||
);
|
||||
vi.mocked(checkRateLimit).mockImplementation(async (_key, limit) => {
|
||||
callCount += 1;
|
||||
// The route must pass `10` as the limit when the env var is unset.
|
||||
expect(limit).toBe(10);
|
||||
return {
|
||||
allowed: callCount <= 10,
|
||||
remaining: Math.max(0, 10 - callCount),
|
||||
resetAt: Date.now() + 3600_000,
|
||||
};
|
||||
});
|
||||
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
const res = await POST(jsonRequest({ force: true }) as never);
|
||||
|
||||
@@ -48,7 +48,8 @@ function redact(text: string): string {
|
||||
.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();
|
||||
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();
|
||||
annotate({ action: { name: "integrations.withings.test" } });
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ export const GET = apiHandler(async () => {
|
||||
let categoryMap: Record<string, string> = {};
|
||||
try {
|
||||
categoryMap = await getMedicationCategories(medications.map((m) => m.id));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
getEvent()?.addWarning("Medication categories could not be loaded");
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
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();
|
||||
annotate({ action: { name: "monitoring.glitchtip.test" } });
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ function redact(text: string): string {
|
||||
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();
|
||||
annotate({ action: { name: "monitoring.umami.test" } });
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
channels.push(created);
|
||||
}
|
||||
} catch (err) {
|
||||
} catch {
|
||||
getEvent()?.addWarning("Legacy Telegram migration failed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ function redactPushError(text: string): string {
|
||||
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();
|
||||
annotate({ action: { name: "notifications.web-push.test" } });
|
||||
|
||||
|
||||
@@ -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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { apiError, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -68,6 +70,10 @@ function resolveUmamiSendUrls(scriptUrl: string | null): string[] {
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
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();
|
||||
if (!settings.umamiEnabled) {
|
||||
return NextResponse.json({}, { status: 204 });
|
||||
|
||||
@@ -45,7 +45,7 @@ export default function NotificationsPage() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ["notifications", "preferences"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/notifications/preferences");
|
||||
@@ -144,6 +144,27 @@ export default function NotificationsPage() {
|
||||
const eventTypes = data?.eventTypes ?? [];
|
||||
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
|
||||
if (activeChannels.length === 0) {
|
||||
return (
|
||||
@@ -236,9 +257,11 @@ export default function NotificationsPage() {
|
||||
{activeChannels.map((ch) => {
|
||||
const enabled = isEnabled(ch.id, eventType);
|
||||
const disabled = !ch.enabled;
|
||||
const switchLabel = `${t(nameKey)} - ${ch.label}`;
|
||||
return (
|
||||
<td key={ch.id} className="px-4 py-3 text-center">
|
||||
<Switch
|
||||
aria-label={switchLabel}
|
||||
checked={enabled && !disabled}
|
||||
disabled={disabled || toggleMutation.isPending}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
@@ -279,12 +302,14 @@ export default function NotificationsPage() {
|
||||
{activeChannels.map((ch) => {
|
||||
const enabled = isEnabled(ch.id, eventType);
|
||||
const disabled = !ch.enabled;
|
||||
const switchId = `notification-${eventType}-${ch.id}`;
|
||||
return (
|
||||
<div
|
||||
key={ch.id}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<Label
|
||||
htmlFor={switchId}
|
||||
className={`text-sm ${disabled ? "text-muted-foreground/70" : ""}`}
|
||||
>
|
||||
{ch.label}
|
||||
@@ -295,6 +320,7 @@ export default function NotificationsPage() {
|
||||
)}
|
||||
</Label>
|
||||
<Switch
|
||||
id={switchId}
|
||||
checked={enabled && !disabled}
|
||||
disabled={disabled || toggleMutation.isPending}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
|
||||
+6
-2
@@ -967,6 +967,7 @@ export default function DashboardPage() {
|
||||
node: (
|
||||
<HealthChart
|
||||
key="bmi-chart"
|
||||
chartKey="bmi"
|
||||
types={["WEIGHT"]}
|
||||
title={t("targets.bmi")}
|
||||
colors={["#f1fa8c"]}
|
||||
@@ -1032,6 +1033,7 @@ export default function DashboardPage() {
|
||||
node: (
|
||||
<HealthChart
|
||||
key="bodyFat-chart"
|
||||
chartKey="bodyFat"
|
||||
types={["BODY_FAT"]}
|
||||
title={t("dashboard.bodyFat")}
|
||||
colors={["#ffb86c"]}
|
||||
@@ -1064,6 +1066,7 @@ export default function DashboardPage() {
|
||||
node: (
|
||||
<HealthChart
|
||||
key="sleep-chart"
|
||||
chartKey="sleep"
|
||||
types={["SLEEP_DURATION"]}
|
||||
title={t("dashboard.sleep") ?? "Sleep"}
|
||||
colors={["#8be9fd"]}
|
||||
@@ -1081,6 +1084,7 @@ export default function DashboardPage() {
|
||||
node: (
|
||||
<HealthChart
|
||||
key="steps-chart"
|
||||
chartKey="steps"
|
||||
types={["ACTIVITY_STEPS"]}
|
||||
title={t("dashboard.steps") ?? "Steps"}
|
||||
colors={["#50fa7b"]}
|
||||
@@ -1186,13 +1190,13 @@ export default function DashboardPage() {
|
||||
// scrolling — the maintainer tested both and prefers the v1.3-era
|
||||
// wrap behaviour over the one-row scroll for the symmetry
|
||||
// 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-tour-id="dashboard-tile-strip"
|
||||
data-tile-count={trendCards.length}
|
||||
>
|
||||
{trendCards.map((entry) => (
|
||||
<div key={entry.id} className="flex">
|
||||
<div key={entry.id} className="flex min-w-0">
|
||||
{entry.node}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -165,6 +165,21 @@ export function AdminShell({ active, children }: AdminShellProps) {
|
||||
className="no-scrollbar -mx-4 mb-4 overflow-x-auto px-4 md:hidden"
|
||||
>
|
||||
<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) => {
|
||||
const isActive = section.slug === activeSlug;
|
||||
const Icon = section.icon;
|
||||
|
||||
@@ -2,14 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Loader2,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { Activity, Bell, CheckCircle2, Loader2, XCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -52,6 +52,7 @@ describe("<ChartOverlayControls>", () => {
|
||||
showTrendIndicator: false,
|
||||
showTrendArrow: false,
|
||||
showTargetRange: false,
|
||||
comparisonBaseline: "none",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,6 +79,7 @@ describe("<ChartOverlayControls>", () => {
|
||||
showTrendIndicator: false,
|
||||
showTrendArrow: false,
|
||||
showTargetRange: true,
|
||||
comparisonBaseline: "none",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -105,6 +105,18 @@ describe("<HealthChart mini>", () => {
|
||||
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", () => {
|
||||
const html = render(
|
||||
<HealthChart
|
||||
|
||||
@@ -47,6 +47,7 @@ const mockPrefs = vi.hoisted(() => ({
|
||||
showTrendIndicator: false,
|
||||
showTrendArrow: false,
|
||||
showTargetRange: false,
|
||||
comparisonBaseline: "none",
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -75,6 +76,7 @@ describe("MedicationComplianceChart overlay toggles", () => {
|
||||
showTrendIndicator: false,
|
||||
showTrendArrow: false,
|
||||
showTargetRange: false,
|
||||
comparisonBaseline: "none",
|
||||
};
|
||||
const html = await renderChart();
|
||||
expect(html).not.toContain("medication-trend-chip");
|
||||
@@ -85,6 +87,7 @@ describe("MedicationComplianceChart overlay toggles", () => {
|
||||
showTrendIndicator: true,
|
||||
showTrendArrow: false,
|
||||
showTargetRange: false,
|
||||
comparisonBaseline: "none",
|
||||
};
|
||||
const html = await renderChart();
|
||||
expect(html).toContain("medication-trend-chip");
|
||||
|
||||
@@ -122,3 +122,29 @@ describe("<TrendCard> directionSentiment", () => {
|
||||
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 { Button } from "@/components/ui/button";
|
||||
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.
|
||||
@@ -78,10 +82,17 @@ export function ChartOverlayControls({
|
||||
const trendArrowId = 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 });
|
||||
};
|
||||
|
||||
const setComparisonBaseline = (value: ComparisonBaseline): void => {
|
||||
onChange({ ...prefs, comparisonBaseline: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -118,7 +129,7 @@ export function ChartOverlayControls({
|
||||
id={trendIndicatorId}
|
||||
checked={prefs.showTrendIndicator}
|
||||
onCheckedChange={(value) =>
|
||||
setKey("showTrendIndicator", Boolean(value))
|
||||
setToggle("showTrendIndicator", Boolean(value))
|
||||
}
|
||||
data-slot="chart-overlay-toggle-trend-indicator"
|
||||
/>
|
||||
@@ -134,7 +145,7 @@ export function ChartOverlayControls({
|
||||
id={trendArrowId}
|
||||
checked={prefs.showTrendArrow}
|
||||
onCheckedChange={(value) =>
|
||||
setKey("showTrendArrow", Boolean(value))
|
||||
setToggle("showTrendArrow", Boolean(value))
|
||||
}
|
||||
data-slot="chart-overlay-toggle-trend-arrow"
|
||||
/>
|
||||
@@ -150,12 +161,39 @@ export function ChartOverlayControls({
|
||||
id={targetRangeId}
|
||||
checked={prefs.showTargetRange}
|
||||
onCheckedChange={(value) =>
|
||||
setKey("showTargetRange", Boolean(value))
|
||||
setToggle("showTargetRange", Boolean(value))
|
||||
}
|
||||
data-slot="chart-overlay-toggle-target-range"
|
||||
/>
|
||||
</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>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
@@ -388,6 +388,9 @@ export function HealthChart({
|
||||
const showMA = overlayPrefs.prefs.showTrendIndicator;
|
||||
const showTrend = overlayPrefs.prefs.showTrendArrow;
|
||||
const showBands = overlayPrefs.prefs.showTargetRange;
|
||||
const effectiveCompareBaseline = chartKey
|
||||
? overlayPrefs.prefs.comparisonBaseline
|
||||
: compareBaseline;
|
||||
|
||||
// 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
|
||||
@@ -605,7 +608,7 @@ export function HealthChart({
|
||||
// "Comparison unavailable — no data from last month yet" via the
|
||||
// hasComparisonData flag below).
|
||||
const chartDataWithCompare = useMemo(() => {
|
||||
if (!chartData || compareBaseline === "none") return chartData;
|
||||
if (!chartData || effectiveCompareBaseline === "none") return chartData;
|
||||
if (!data?.length) return chartData;
|
||||
|
||||
const shifted = shiftDailySeriesForward(
|
||||
@@ -615,7 +618,7 @@ export function HealthChart({
|
||||
types.map((type) => [type, row[type] as number | undefined]),
|
||||
),
|
||||
})),
|
||||
compareBaseline,
|
||||
effectiveCompareBaseline,
|
||||
);
|
||||
|
||||
// Index shifted rows by the same day-key the chart already uses.
|
||||
@@ -643,7 +646,7 @@ export function HealthChart({
|
||||
}
|
||||
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-
|
||||
@@ -654,7 +657,8 @@ export function HealthChart({
|
||||
* when they can clearly see SOME dimmed history.
|
||||
*/
|
||||
const hasComparisonData = useMemo(() => {
|
||||
if (compareBaseline === "none" || !chartDataWithCompare) return false;
|
||||
if (effectiveCompareBaseline === "none" || !chartDataWithCompare)
|
||||
return false;
|
||||
return chartDataWithCompare.some((point) =>
|
||||
types.some(
|
||||
(type) =>
|
||||
@@ -662,7 +666,7 @@ export function HealthChart({
|
||||
Number.isFinite(point[`${type}_compare`] as number),
|
||||
),
|
||||
);
|
||||
}, [chartDataWithCompare, compareBaseline, types]);
|
||||
}, [chartDataWithCompare, effectiveCompareBaseline, types]);
|
||||
|
||||
const activeBucket: ChartBucketType = useMemo(() => {
|
||||
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
|
||||
// overlay. Without this the dimmed line can clip outside the
|
||||
// visible range when last-month / last-year had a different scale.
|
||||
if (compareBaseline !== "none") {
|
||||
if (effectiveCompareBaseline !== "none") {
|
||||
keys.push(...types.map((type) => `${type}_compare`));
|
||||
}
|
||||
|
||||
@@ -707,7 +711,13 @@ export function HealthChart({
|
||||
const paddingBottom = Math.max(span * 0.08, 0.5);
|
||||
const paddingTop = Math.max(span * 0.16, 1);
|
||||
return [min - paddingBottom, max + paddingTop];
|
||||
}, [chartDataWithCompare, compareBaseline, showMA, showTrend, types]);
|
||||
}, [
|
||||
chartDataWithCompare,
|
||||
effectiveCompareBaseline,
|
||||
showMA,
|
||||
showTrend,
|
||||
types,
|
||||
]);
|
||||
|
||||
const visibleBands = useMemo(() => {
|
||||
if (!showBands || !valueBands?.length || !yDomain) return [];
|
||||
@@ -959,25 +969,25 @@ export function HealthChart({
|
||||
neutral muted styling so it doesn't read as an error.
|
||||
|
||||
v1.4.19 A2 — hidden on mobile to free up the title row. */}
|
||||
{compareBaseline !== "none" && hasComparisonData && (
|
||||
{effectiveCompareBaseline !== "none" && hasComparisonData && (
|
||||
<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"
|
||||
data-slot="chart-compare-caption"
|
||||
>
|
||||
{t(
|
||||
compareBaseline === "lastMonth"
|
||||
effectiveCompareBaseline === "lastMonth"
|
||||
? "comparison.captionLastMonth"
|
||||
: "comparison.captionLastYear",
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{compareBaseline !== "none" && !hasComparisonData && (
|
||||
{effectiveCompareBaseline !== "none" && !hasComparisonData && (
|
||||
<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"
|
||||
data-slot="chart-compare-unavailable"
|
||||
>
|
||||
{t(
|
||||
compareBaseline === "lastMonth"
|
||||
effectiveCompareBaseline === "lastMonth"
|
||||
? "comparison.unavailable.lastMonth"
|
||||
: "comparison.unavailable.lastYear",
|
||||
)}
|
||||
@@ -1291,13 +1301,13 @@ export function HealthChart({
|
||||
// have a prior value for this day. Otherwise fall
|
||||
// back to the existing baseline-delta path.
|
||||
const compareValue =
|
||||
compareBaseline !== "none" && hoverPoint
|
||||
effectiveCompareBaseline !== "none" && hoverPoint
|
||||
? (hoverPoint[`${dataKey}_compare`] as
|
||||
| number
|
||||
| undefined)
|
||||
: undefined;
|
||||
if (
|
||||
compareBaseline !== "none" &&
|
||||
effectiveCompareBaseline !== "none" &&
|
||||
typeof compareValue === "number" &&
|
||||
Number.isFinite(compareValue)
|
||||
) {
|
||||
@@ -1311,7 +1321,7 @@ export function HealthChart({
|
||||
1,
|
||||
)}${unit ? ` ${unit}` : ""}`;
|
||||
delta = t(
|
||||
compareBaseline === "lastMonth"
|
||||
effectiveCompareBaseline === "lastMonth"
|
||||
? "comparison.deltaVs.lastMonth"
|
||||
: "comparison.deltaVs.lastYear",
|
||||
).replace("{delta}", formatted);
|
||||
@@ -1345,7 +1355,7 @@ export function HealthChart({
|
||||
// numbers (current AND last-month / last-year)
|
||||
// alongside the delta.
|
||||
if (
|
||||
compareBaseline !== "none" &&
|
||||
effectiveCompareBaseline !== "none" &&
|
||||
typeof compareValue === "number" &&
|
||||
Number.isFinite(compareValue)
|
||||
) {
|
||||
@@ -1428,7 +1438,7 @@ export function HealthChart({
|
||||
stroke opacity (45%) and a thinner stroke (1.25)
|
||||
so the user reads the current line first and the
|
||||
overlay as orientation. */}
|
||||
{compareBaseline !== "none" &&
|
||||
{effectiveCompareBaseline !== "none" &&
|
||||
hasComparisonData &&
|
||||
types.map((type, i) => (
|
||||
<Line
|
||||
@@ -1436,7 +1446,7 @@ export function HealthChart({
|
||||
type="monotone"
|
||||
dataKey={`${type}_compare`}
|
||||
name={`${getTypeLabel(type, valueMode, t)} (${t(
|
||||
compareBaseline === "lastMonth"
|
||||
effectiveCompareBaseline === "lastMonth"
|
||||
? "comparison.captionLastMonth"
|
||||
: "comparison.captionLastYear",
|
||||
)})`}
|
||||
|
||||
@@ -169,7 +169,6 @@ interface MedicationComplianceChartProps {
|
||||
|
||||
export function MedicationComplianceChart({
|
||||
title,
|
||||
compareBaseline = "none",
|
||||
}: MedicationComplianceChartProps) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
@@ -182,6 +181,7 @@ export function MedicationComplianceChart({
|
||||
const overlayPrefs = useChartOverlayPrefs("medications");
|
||||
const showTrendChip = overlayPrefs.prefs.showTrendIndicator;
|
||||
const showTargetRange = overlayPrefs.prefs.showTargetRange;
|
||||
const effectiveCompareBaseline = overlayPrefs.prefs.comparisonBaseline;
|
||||
|
||||
// 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
|
||||
@@ -320,11 +320,11 @@ export function MedicationComplianceChart({
|
||||
v1.4.17 when the underlying intake-event window comparison
|
||||
ships). Without this caption the user toggles Vormonat and
|
||||
this card just stays static, which reads as a bug. */}
|
||||
{compareBaseline !== "none" && (
|
||||
{effectiveCompareBaseline !== "none" && (
|
||||
<p
|
||||
className="text-muted-foreground -mt-2 mb-3 text-xs"
|
||||
data-slot="medication-comparison-na"
|
||||
data-compare-baseline={compareBaseline}
|
||||
data-compare-baseline={effectiveCompareBaseline}
|
||||
>
|
||||
{t("comparison.notAvailableForCompliance")}
|
||||
</p>
|
||||
|
||||
@@ -289,6 +289,8 @@ export function MoodChart({
|
||||
const showMA = !mini && overlayPrefs.prefs.showTrendIndicator;
|
||||
const showTrend = !mini && overlayPrefs.prefs.showTrendArrow;
|
||||
const showBands = !mini && overlayPrefs.prefs.showTargetRange;
|
||||
const effectiveCompareBaseline =
|
||||
!mini && chartKey ? overlayPrefs.prefs.comparisonBaseline : compareBaseline;
|
||||
|
||||
// v1.4.19 A2 — viewport-aware tick density helper.
|
||||
const viewportWidth = useViewportWidth();
|
||||
@@ -411,7 +413,7 @@ export function MoodChart({
|
||||
* below renders the dimmed dashed overlay.
|
||||
*/
|
||||
const chartDataWithCompare = useMemo<ChartDataPoint[] | undefined>(() => {
|
||||
if (!chartData || compareBaseline === "none") return chartData;
|
||||
if (!chartData || effectiveCompareBaseline === "none") return chartData;
|
||||
if (!data?.entries?.length) return chartData;
|
||||
|
||||
const shifted = shiftDailySeriesForward(
|
||||
@@ -419,7 +421,7 @@ export function MoodChart({
|
||||
timestamp: dayKeyToTimestamp(entry.date),
|
||||
score: entry.score,
|
||||
})),
|
||||
compareBaseline,
|
||||
effectiveCompareBaseline,
|
||||
);
|
||||
|
||||
const shiftedByDay = new Map<string, number>();
|
||||
@@ -437,16 +439,17 @@ export function MoodChart({
|
||||
}
|
||||
return point;
|
||||
});
|
||||
}, [chartData, compareBaseline, data]);
|
||||
}, [chartData, effectiveCompareBaseline, data]);
|
||||
|
||||
const hasComparisonData = useMemo(() => {
|
||||
if (compareBaseline === "none" || !chartDataWithCompare) return false;
|
||||
if (effectiveCompareBaseline === "none" || !chartDataWithCompare)
|
||||
return false;
|
||||
return chartDataWithCompare.some((point) =>
|
||||
typeof point.scoreCompare === "number"
|
||||
? Number.isFinite(point.scoreCompare)
|
||||
: false,
|
||||
);
|
||||
}, [chartDataWithCompare, compareBaseline]);
|
||||
}, [chartDataWithCompare, effectiveCompareBaseline]);
|
||||
|
||||
// Mirror the activeBucket calculation from health-chart so the chip
|
||||
// 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.19 A2 — hidden on mobile to free up the title row. */}
|
||||
{!mini && compareBaseline !== "none" && hasComparisonData && (
|
||||
<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"
|
||||
data-slot="chart-compare-caption"
|
||||
>
|
||||
{t(
|
||||
compareBaseline === "lastMonth"
|
||||
? "comparison.captionLastMonth"
|
||||
: "comparison.captionLastYear",
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{!mini && compareBaseline !== "none" && !hasComparisonData && (
|
||||
<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"
|
||||
data-slot="chart-compare-unavailable"
|
||||
>
|
||||
{t(
|
||||
compareBaseline === "lastMonth"
|
||||
? "comparison.unavailable.lastMonth"
|
||||
: "comparison.unavailable.lastYear",
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{!mini &&
|
||||
effectiveCompareBaseline !== "none" &&
|
||||
hasComparisonData && (
|
||||
<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"
|
||||
data-slot="chart-compare-caption"
|
||||
>
|
||||
{t(
|
||||
effectiveCompareBaseline === "lastMonth"
|
||||
? "comparison.captionLastMonth"
|
||||
: "comparison.captionLastYear",
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{!mini &&
|
||||
effectiveCompareBaseline !== "none" &&
|
||||
!hasComparisonData && (
|
||||
<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"
|
||||
data-slot="chart-compare-unavailable"
|
||||
>
|
||||
{t(
|
||||
effectiveCompareBaseline === "lastMonth"
|
||||
? "comparison.unavailable.lastMonth"
|
||||
: "comparison.unavailable.lastYear",
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!mini && (
|
||||
<div
|
||||
@@ -733,11 +740,11 @@ export function MoodChart({
|
||||
continue;
|
||||
let delta: string | undefined;
|
||||
const compareValue =
|
||||
compareBaseline !== "none" && hoverPoint
|
||||
effectiveCompareBaseline !== "none" && hoverPoint
|
||||
? hoverPoint.scoreCompare
|
||||
: undefined;
|
||||
if (
|
||||
compareBaseline !== "none" &&
|
||||
effectiveCompareBaseline !== "none" &&
|
||||
typeof compareValue === "number" &&
|
||||
Number.isFinite(compareValue)
|
||||
) {
|
||||
@@ -750,7 +757,7 @@ export function MoodChart({
|
||||
1,
|
||||
)}`;
|
||||
delta = t(
|
||||
compareBaseline === "lastMonth"
|
||||
effectiveCompareBaseline === "lastMonth"
|
||||
? "comparison.deltaVs.lastMonth"
|
||||
: "comparison.deltaVs.lastYear",
|
||||
).replace("{delta}", formatted);
|
||||
@@ -777,7 +784,7 @@ export function MoodChart({
|
||||
delta,
|
||||
});
|
||||
if (
|
||||
compareBaseline !== "none" &&
|
||||
effectiveCompareBaseline !== "none" &&
|
||||
typeof compareValue === "number" &&
|
||||
Number.isFinite(compareValue)
|
||||
) {
|
||||
@@ -838,7 +845,7 @@ export function MoodChart({
|
||||
uses; mood is a single metric so we only render
|
||||
one overlay line. Suppressed when there's no prior
|
||||
data via hasComparisonData. */}
|
||||
{compareBaseline !== "none" && hasComparisonData && (
|
||||
{effectiveCompareBaseline !== "none" && hasComparisonData && (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="scoreCompare"
|
||||
|
||||
@@ -210,21 +210,21 @@ export function TrendCard({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border flex h-full w-full flex-col rounded-xl border p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
<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 min-w-0 items-start justify-between gap-2">
|
||||
<span className="text-muted-foreground min-w-0 text-xs leading-snug font-medium tracking-wide break-words uppercase">
|
||||
{label}
|
||||
</span>
|
||||
<Icon className="text-muted-foreground h-4 w-4" />
|
||||
<Icon className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
</div>
|
||||
<div className="mt-2 flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold tracking-tight tabular-nums">
|
||||
<div className="mt-2 flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<span className="min-w-0 text-3xl leading-none font-bold tracking-tight [overflow-wrap:anywhere] tabular-nums">
|
||||
{latest !== null ? renderPair(latest, secondary?.latest) : "—"}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-sm tabular-nums">
|
||||
<span className="text-muted-foreground text-sm [overflow-wrap:anywhere] tabular-nums">
|
||||
{unit}
|
||||
</span>
|
||||
{slope30 && <TrendIcon className={`h-4 w-4 ${trendColor}`} />}
|
||||
{slope30 && <TrendIcon className={`h-4 w-4 shrink-0 ${trendColor}`} />}
|
||||
</div>
|
||||
{/* v1.4.16 phase B8 — comparison delta callout. Sits on its own
|
||||
line below the latest value so the tile stays scannable on
|
||||
@@ -246,7 +246,7 @@ export function TrendCard({
|
||||
>
|
||||
<span
|
||||
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,
|
||||
)}
|
||||
data-slot="tile-compare-delta"
|
||||
@@ -266,14 +266,17 @@ export function TrendCard({
|
||||
</div>
|
||||
)}
|
||||
<TooltipProvider>
|
||||
<div className="text-muted-foreground mt-auto flex gap-3 pt-1 text-xs">
|
||||
<span>
|
||||
<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 className="max-w-full min-w-0 [overflow-wrap:anywhere]">
|
||||
{t(avg7LabelKey)}:{" "}
|
||||
{avg7Hint ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn("font-medium tabular-nums", avg7ColorClass)}
|
||||
className={cn(
|
||||
"font-medium [overflow-wrap:anywhere] tabular-nums",
|
||||
avg7ColorClass,
|
||||
)}
|
||||
>
|
||||
{avg7 !== null ? renderPair(avg7, secondary?.avg7) : "—"}
|
||||
</span>
|
||||
@@ -283,13 +286,21 @@ export function TrendCard({
|
||||
</TooltipContent>
|
||||
</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) : "—"}
|
||||
</span>
|
||||
)}
|
||||
{trend7Delta != null && (
|
||||
<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"
|
||||
aria-label={`7-day trend ${formatDelta(trend7Delta)}`}
|
||||
>
|
||||
@@ -297,13 +308,16 @@ export function TrendCard({
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
<span className="max-w-full min-w-0 [overflow-wrap:anywhere]">
|
||||
{t("charts.avg30dShort")}:{" "}
|
||||
{avg30Hint ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn("font-medium tabular-nums", avg30ColorClass)}
|
||||
className={cn(
|
||||
"font-medium [overflow-wrap:anywhere] tabular-nums",
|
||||
avg30ColorClass,
|
||||
)}
|
||||
>
|
||||
{avg30 !== null ? renderPair(avg30, secondary?.avg30) : "—"}
|
||||
</span>
|
||||
@@ -313,7 +327,12 @@ export function TrendCard({
|
||||
</TooltipContent>
|
||||
</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) : "—"}
|
||||
</span>
|
||||
)}
|
||||
@@ -328,10 +347,16 @@ export function TrendCard({
|
||||
combined secondary row below so the tile keeps the same
|
||||
vertical density as every other trend card. */}
|
||||
{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)}:{" "}
|
||||
<span
|
||||
className={cn("font-medium tabular-nums", avgAllTimeColorClass)}
|
||||
className={cn(
|
||||
"font-medium [overflow-wrap:anywhere] tabular-nums",
|
||||
avgAllTimeColorClass,
|
||||
)}
|
||||
>
|
||||
{avgAllTime !== null ? formatValue(avgAllTime) : "—"}
|
||||
</span>
|
||||
@@ -346,12 +371,15 @@ export function TrendCard({
|
||||
{avgAllTime !== undefined && (
|
||||
<div
|
||||
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>
|
||||
{t(avgAllTimeLabelKey)}:{" "}
|
||||
<span
|
||||
className={cn("font-medium tabular-nums", avgAllTimeColorClass)}
|
||||
className={cn(
|
||||
"font-medium [overflow-wrap:anywhere] tabular-nums",
|
||||
avgAllTimeColorClass,
|
||||
)}
|
||||
>
|
||||
{avgAllTime !== null ? formatValue(avgAllTime) : "—"}
|
||||
</span>
|
||||
@@ -361,7 +389,7 @@ export function TrendCard({
|
||||
<span aria-hidden="true"> · </span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium tabular-nums",
|
||||
"font-medium [overflow-wrap:anywhere] tabular-nums",
|
||||
comparisonDeltaColor,
|
||||
)}
|
||||
data-slot="tile-compare-delta-mobile"
|
||||
|
||||
@@ -172,7 +172,7 @@ export function CoachDrawer({
|
||||
}
|
||||
onOpenChange(next);
|
||||
},
|
||||
[onOpenChange],
|
||||
[onOpenChange, setInputValue],
|
||||
);
|
||||
|
||||
const { data: conversation } = useCoachConversation(currentConversationId);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
Bot,
|
||||
@@ -69,7 +69,10 @@ export function MessageThread({
|
||||
const { data: coachPrefs } = useCoachPrefs();
|
||||
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
|
||||
// invalidate-then-refetch pulls the persisted assistant message into
|
||||
// `conversation.messages`. The streaming bubble was still rendering
|
||||
|
||||
@@ -459,7 +459,7 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
className="h-10 w-10"
|
||||
onClick={() => startEdit(m)}
|
||||
aria-label={t("common.edit")}
|
||||
>
|
||||
@@ -536,6 +536,7 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<DeleteButton
|
||||
className="h-10 w-10"
|
||||
onConfirm={() => deleteMutation.mutate(m.id)}
|
||||
/>
|
||||
</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();
|
||||
return (
|
||||
<AlertDialog>
|
||||
@@ -782,7 +789,7 @@ function DeleteButton({ onConfirm }: { onConfirm: () => void }) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive h-8 w-8"
|
||||
className={`text-destructive ${className}`}
|
||||
aria-label={t("common.delete")}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -664,8 +664,9 @@ function MoodLogCard({
|
||||
|
||||
<form onSubmit={handleSave} className="space-y-3">
|
||||
<div>
|
||||
<Label>{t("settings.moodLogUrl")}</Label>
|
||||
<Label htmlFor="moodlog-url">{t("settings.moodLogUrl")}</Label>
|
||||
<Input
|
||||
id="moodlog-url"
|
||||
type="url"
|
||||
placeholder={t("settings.moodLogUrlPlaceholder")}
|
||||
value={url}
|
||||
@@ -673,8 +674,11 @@ function MoodLogCard({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t("settings.moodLogApiKey")}</Label>
|
||||
<Label htmlFor="moodlog-api-key">
|
||||
{t("settings.moodLogApiKey")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="moodlog-api-key"
|
||||
placeholder={
|
||||
status?.configured
|
||||
? t("settings.withingsCredentialsSavedPlaceholder")
|
||||
@@ -705,9 +709,12 @@ function MoodLogCard({
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
{status.webhookSecret && (
|
||||
<div>
|
||||
<Label>{t("settings.moodLogWebhookSecret")}</Label>
|
||||
<Label htmlFor="moodlog-webhook-secret">
|
||||
{t("settings.moodLogWebhookSecret")}
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="moodlog-webhook-secret"
|
||||
value={status.webhookSecret}
|
||||
readOnly
|
||||
className="font-mono text-xs"
|
||||
|
||||
@@ -54,9 +54,21 @@ export function NotificationsSection() {
|
||||
|
||||
<NotificationStatusCard />
|
||||
|
||||
{showTelegram && <TelegramCard isAuthenticated={isAuthenticated} />}
|
||||
{showNtfy && <NtfyCard isAuthenticated={isAuthenticated} />}
|
||||
{showWebPush && <WebPushCard />}
|
||||
{showTelegram && (
|
||||
<div id="telegram">
|
||||
<TelegramCard isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
)}
|
||||
{showNtfy && (
|
||||
<div id="ntfy">
|
||||
<NtfyCard isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
)}
|
||||
{showWebPush && (
|
||||
<div id="web-push">
|
||||
<WebPushCard />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -244,11 +244,25 @@ describe("resolveDashboardLayout() — chartOverlayPrefs (v1.4.18)", () => {
|
||||
showTrendIndicator: true,
|
||||
showTrendArrow: false,
|
||||
showTargetRange: true,
|
||||
comparisonBaseline: "lastMonth",
|
||||
},
|
||||
weight: {
|
||||
showTrendIndicator: false,
|
||||
showTrendArrow: true,
|
||||
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,
|
||||
showTrendArrow: false,
|
||||
showTargetRange: true,
|
||||
comparisonBaseline: "lastMonth",
|
||||
},
|
||||
weight: {
|
||||
showTrendIndicator: false,
|
||||
showTrendArrow: true,
|
||||
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",
|
||||
showTrendArrow: 1,
|
||||
showTargetRange: null,
|
||||
comparisonBaseline: "nextWeek",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -285,6 +314,7 @@ describe("resolveDashboardLayout() — chartOverlayPrefs (v1.4.18)", () => {
|
||||
showTrendIndicator: false,
|
||||
showTrendArrow: false,
|
||||
showTargetRange: false,
|
||||
comparisonBaseline: "none",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -297,11 +327,13 @@ describe("resolveDashboardLayout() — chartOverlayPrefs (v1.4.18)", () => {
|
||||
showTrendIndicator: true,
|
||||
showTrendArrow: false,
|
||||
showTargetRange: false,
|
||||
comparisonBaseline: "none",
|
||||
},
|
||||
not_a_real_chart: {
|
||||
showTrendIndicator: true,
|
||||
showTrendArrow: true,
|
||||
showTargetRange: true,
|
||||
comparisonBaseline: "lastMonth",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -81,7 +81,7 @@ describe("requireAuth — Bearer token path", () => {
|
||||
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
||||
id: "token-1",
|
||||
userId: "user-1",
|
||||
permissions: ["medication:ingest"],
|
||||
permissions: ["*"],
|
||||
revoked: false,
|
||||
expiresAt,
|
||||
} as never);
|
||||
@@ -198,6 +198,30 @@ describe("requireAuth — Bearer token path", () => {
|
||||
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 () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
setBearerHeader(null);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
|
||||
import type { AIProvider, CompletionResult } from "./types";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { CodexClient } from "./codex-client";
|
||||
@@ -19,9 +19,7 @@ const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; // 5 minutes
|
||||
class NoProvider implements AIProvider {
|
||||
readonly type = "none" as const;
|
||||
|
||||
async generateCompletion(
|
||||
_params: CompletionParams,
|
||||
): Promise<CompletionResult> {
|
||||
async generateCompletion(): Promise<CompletionResult> {
|
||||
throw new Error(
|
||||
"No AI provider configured. Connect ChatGPT or set an API key in settings.",
|
||||
);
|
||||
|
||||
+14
-1
@@ -222,9 +222,22 @@ async function authenticateBearer(
|
||||
// adopt requireAuth("scope:name"), the wildcard handling here keeps
|
||||
// the iOS app working while narrower-scoped tokens (e.g. ["medication:
|
||||
// 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 (
|
||||
requiredPermission &&
|
||||
!apiToken.permissions.includes("*") &&
|
||||
!hasWildcardPermission &&
|
||||
!apiToken.permissions.includes(requiredPermission)
|
||||
) {
|
||||
auditLog("auth.bearer.failure", {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -111,7 +111,9 @@ export async function getSession(): Promise<{
|
||||
|
||||
if (!session || session.expiresAt < new Date()) {
|
||||
if (session) {
|
||||
await prisma.session.delete({ where: { id: sessionId } });
|
||||
await prisma.session
|
||||
.deleteMany({ where: { id: sessionId } })
|
||||
.catch(() => {});
|
||||
}
|
||||
cookieStore.delete(SESSION_COOKIE);
|
||||
cookieStore.delete(ONBOARDING_COOKIE);
|
||||
|
||||
@@ -112,9 +112,13 @@ export function isComparisonBaseline(
|
||||
export const CHART_OVERLAY_KEYS = [
|
||||
"bp",
|
||||
"weight",
|
||||
"bmi",
|
||||
"pulse",
|
||||
"bodyFat",
|
||||
"mood",
|
||||
"medications",
|
||||
"sleep",
|
||||
"steps",
|
||||
] as const;
|
||||
export type ChartOverlayKey = (typeof CHART_OVERLAY_KEYS)[number];
|
||||
|
||||
@@ -122,12 +126,14 @@ export interface ChartOverlayPrefs {
|
||||
showTrendIndicator: boolean;
|
||||
showTrendArrow: boolean;
|
||||
showTargetRange: boolean;
|
||||
comparisonBaseline: ComparisonBaseline;
|
||||
}
|
||||
|
||||
export const DEFAULT_CHART_OVERLAY_PREFS: ChartOverlayPrefs = {
|
||||
showTrendIndicator: false,
|
||||
showTrendArrow: false,
|
||||
showTargetRange: false,
|
||||
comparisonBaseline: "none",
|
||||
};
|
||||
|
||||
export type ChartOverlayPrefsMap = Partial<
|
||||
@@ -145,6 +151,9 @@ function coerceChartOverlayPrefs(value: unknown): ChartOverlayPrefs {
|
||||
showTrendIndicator: candidate.showTrendIndicator === true,
|
||||
showTrendArrow: candidate.showTrendArrow === true,
|
||||
showTargetRange: candidate.showTargetRange === true,
|
||||
comparisonBaseline: isComparisonBaseline(candidate.comparisonBaseline)
|
||||
? candidate.comparisonBaseline
|
||||
: "none",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ export function loadApnsConfig(): ApnsConfig | null {
|
||||
signingKey = inlineKey.replace(/\\n/g, "\n");
|
||||
} else {
|
||||
try {
|
||||
signingKey = readFileSync(keyFile, "utf8");
|
||||
signingKey = readFileSync(/* turbopackIgnore: true */ keyFile, "utf8");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "read_failed";
|
||||
getEvent()?.addWarning(`APNs key file read failed: ${message}`);
|
||||
|
||||
+10
-3
@@ -90,6 +90,15 @@ const LEGACY_ADMIN_ANCHORS: Record<string, string> = {
|
||||
"/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) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
@@ -178,9 +187,7 @@ export function proxy(request: NextRequest) {
|
||||
// 128 bits of random, base64-encoded. randomUUID().toString() only carries
|
||||
// ~122 bits and has a predictable structure that base64-encodes to a
|
||||
// partially guessable pattern.
|
||||
const nonce = Buffer.from(
|
||||
crypto.getRandomValues(new Uint8Array(16)),
|
||||
).toString("base64");
|
||||
const nonce = generateNonce();
|
||||
const requestHeaders = new Headers(request.headers);
|
||||
requestHeaders.set("x-nonce", nonce);
|
||||
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
|
||||
// (~480 minutes total) plus a per-stage breakdown.
|
||||
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: "CORE", min: 220 },
|
||||
{ stage: "DEEP", min: 90 },
|
||||
@@ -128,9 +131,9 @@ describe("GET /api/analytics — sleep-stage aggregation", () => {
|
||||
// the inner handler ignores its arguments. Cast through `unknown`
|
||||
// because `apiHandler`'s narrowed type signature is the inner
|
||||
// handler's `()`, not the wrapper's `(NextRequest)`.
|
||||
const response = await (GET as unknown as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/analytics"),
|
||||
);
|
||||
const response = await (
|
||||
GET as unknown as (req: Request) => Promise<Response>
|
||||
)(new Request("http://localhost/api/analytics"));
|
||||
expect(response.status).toBe(200);
|
||||
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`
|
||||
// because `apiHandler`'s narrowed type signature is the inner
|
||||
// handler's `()`, not the wrapper's `(NextRequest)`.
|
||||
const response = await (GET as unknown as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/analytics"),
|
||||
);
|
||||
const response = await (
|
||||
GET as unknown as (req: Request) => Promise<Response>
|
||||
)(new Request("http://localhost/api/analytics"));
|
||||
expect(response.status).toBe(200);
|
||||
const envelope = (await response.json()) as AnalyticsEnvelope;
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export default defineConfig({
|
||||
exclude: [
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"**/.next/**",
|
||||
"tests/integration/**",
|
||||
// Playwright E2E suite — driven separately via `pnpm e2e`.
|
||||
"e2e/**",
|
||||
|
||||
Reference in new issue
Block a user