chore: sync develop with v1.4.24 release from main

v1.4.24 (security + accessibility hardening) shipped directly on main as
a hotfix-style release. Back-merging the two main-only commits
(ca03c60 chore(release): v1.4.24, 604009d per-user timezone proposal)
into develop so the v1.4.25 work continues on the standard branch model.

# Conflicts:
#	.planning/ROADMAP.md
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 11:32:28 +02:00
commit ac54bb26e8
62 files changed
+1257 -199

No files matched your search

+4 -1
View File
@@ -37,7 +37,10 @@ Reserved next strategic milestones:
alerts. Refreshed strategic plan with concrete file paths at
`.planning/phase-W6-v1423-product-lead-review.md`. P1 is now a
~5-day iOS Swift sprint because every server-side contract
landed in v1.4.23.
landed in v1.4.23. Per-user timezone foundation
(`.planning/feature-user-timezone.md`, issue #167) is now scoped
into v1.4.25 — Marc directive 2026-05-14, packs the iOS-launch
prep into the v1.4.25 polish window instead of slipping it.
- **v1.6+** — Auto-correlation discovery (FDR-controlled), Coach
full-page route at `/insights/coach`, conversation-driven goal
setting.
+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).
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
+345
View File
@@ -0,0 +1,345 @@
# Feature proposal — per-user timezone
**Status:** documented, not scheduled.
**Trigger:** GitHub issue
[#167](https://github.com/MBombeck/HealthLog/issues/167) (Warsaw user,
v1.4.23).
**Captured:** 2026-05-12, during the v1.4.24 release retrospective.
**Owner:** unassigned — picks up in v1.5 product-lead planning.
This document is the long-form record so a future planning pass does
not start from zero. Read together with
`.planning/v15-backlog.md` (which only carries the one-line entry).
---
## 1. Background
Issue #167 reports that a Warsaw user enters a measurement at 11:05
local time, then sees `09:05` in the CSV export. He set
`TZ=Europe/Warsaw` + `PGTZ=Europe/Warsaw` on the container; the
discrepancy persisted.
That is not a deployment bug. The export writes ISO-8601 with the `Z`
suffix (UTC), and his viewer (Excel / LibreOffice) drops the marker,
so the user reads UTC as local time. The CSV is technically correct,
the framing is hostile. See `src/lib/export.ts:53, :103, :104, :129`.
The interesting part of the issue is what it surfaces: HealthLog is
hardcoded to `Europe/Berlin` end-to-end. The Warsaw reporter is in the
same UTC offset as Berlin year-round (CET/CEST), so he is the easiest
case. The same code path mis-labels every reading for a user in
London, New York, Tokyo or São Paulo — and does it silently.
---
## 2. Current state — where Berlin is wired in
Hardcoded `"Europe/Berlin"` references at the time of writing
(`grep -rn "Europe/Berlin" src/`):
| File | Role |
| ----------------------------------------------------- | --------------------------------------------------------- |
| `src/lib/format-locale.ts:18` | `DISPLAY_TIMEZONE` constant — single source for display. |
| `src/lib/analytics/berlin-day.ts:17` | Day-bucket key for analytics (`YYYY-MM-DD` in Berlin). |
| `src/lib/analytics/correlations.ts:109, :117` | Weekly ISO-Monday bucketing for correlations. |
| `src/lib/analytics/bp-in-target.ts:64, :70, :160` | SYS/DIA pairing fallback when timestamps drift ≥ 5 min. |
| `src/lib/charts/bucket-time-series.ts:38` | Daily / weekly / monthly chart buckets. |
| `src/lib/charts/comparison-shift.ts` | `chartData_compare` overlay anchored at Berlin-day-noon. |
| `src/components/charts/health-chart.tsx:195` | X-axis tick formatter. |
| `src/components/medications/medication-card.tsx` | "Last taken" + next-window time labels. |
| `src/components/admin/api-token-overview-section.tsx` | "Created / last used" timestamps in the admin table. |
| `Dockerfile` | `ENV TZ=Europe/Berlin` — container TZ for the Node clock. |
| `CLAUDE.md` | Convention: "Berlin for display, UTC for storage". |
Reminder schedules:
| File | Role |
| ---------------------------------------------------------- | ----------------------------------------------------------------------------- |
| `src/lib/jobs/reminder-worker.ts` | pg-boss cron tick interpreted in container TZ. |
| `src/lib/medications/window-resolution.ts` (or equivalent) | `windowStart` / `windowEnd` strings (`"07:00"`–`"09:00"`) are container-time. |
| `ReminderPhaseConfig` rows in `prisma/schema.prisma:387` | Window thresholds per user, schema-naive ("just a string"). |
Persistence:
- `Measurement.measuredAt`, `MedicationIntakeEvent.scheduledFor` /
`.takenAt`, `MoodEntry.moodLoggedAt` are `DateTime` (`timestamptz`).
Postgres stores UTC; correct. No write-path changes needed.
- `MoodEntry.date` is a `String` formatted `YYYY-MM-DD` — anchored to
Berlin at the time of write. **This is the migration risk**: the
same calendar moment falls into different `date` strings depending
on the chosen anchor. See §7.
- `MedicationSchedule.windowStart` / `.windowEnd` are `String`
`"HH:MM"` — same problem: a "08:00" window is Berlin local today.
---
## 3. Symptom catalogue — beyond the CSV
Closing the CSV alone is a 4-files patch. The architectural problem is
broader. Each row below is a separate symptom that an end-user can
hit:
1. **CSV / JSON export reads as UTC.** Issue #167. Fix in `export.ts`.
2. **Reminders fire at Berlin time, not user time.** A Tokyo user sets
a 08:00 morning reminder; the pg-boss cron tick checks against
container TZ, so the message arrives at 16:00 Tokyo. Hidden today
because the only known users are CET/CEST.
3. **"Today" buckets misalign.** A user logs a 23:30 reading in their
local zone that maps to "tomorrow" UTC and "tomorrow" Berlin →
their day-summary card carries the reading on the wrong calendar
day. Same effect on the streak counter (achievements) and the
"today vs yesterday" comparison.
4. **Mood-entry date drift.** `MoodEntry.date` is a `String` written
in Berlin. A New York user entering at 21:00 EDT (= 03:00 next-day
Berlin) gets the wrong `date` string, which then disagrees with the
`moodLoggedAt` timestamp.
5. **Withings sync day-mapping.** Withings ships UTC timestamps;
`withings-sync` buckets them via `berlin-day.ts`. A reading taken
at 23:50 local in Tokyo (14:50 UTC) lands on yesterday-Berlin
instead of today-Tokyo. The user sees their evening reading on
yesterday's chart.
6. **Charts X-axis labels.** Mobile chart x-axis label formatter
passes `timeZone: "Europe/Berlin"` directly. A New York user sees
`Mai 11` for a reading they remember entering on `May 10` 22:30 EDT.
7. **Email / push notification timestamps.** Notification body
templates render times in the dispatcher, container TZ. Same
mis-attribution as #2 once #2 is fixed.
8. **PDF doctor report.** `src/app/api/doctor-report/pdf/route.ts`
renders rows with `formatInTimeZone` against Berlin. A PDF a US
user takes to their doctor lists every reading at a Berlin
timestamp.
9. **AI Coach + analytics context.** The Coach prompt context says
"yesterday" and "this week" against Berlin-anchored buckets. Same
day-misalignment problem as #3, but tucked inside the prompt where
nobody will see it until a user complains the recommendation is
for the wrong day.
10. **OpenAPI / iOS DTO contract.** `docs/api/openapi.yaml` ships
timestamps as `string($date-time)` without a contract on offset.
iOS will round-trip them as UTC. If the future iOS app renders
its own clock, it will diverge from the web UI until the web UI
learns user TZ too.
That is ten distinct surfaces, not one.
---
## 4. Design space
### Option A — leave display TZ alone, fix the export only
CSV/JSON write ISO-8601 with offset (`2026-05-11T11:05:00+02:00`)
instead of `Z`. Same instant, machine-parseable, human readable.
- Scope: 4 lines in `src/lib/export.ts` + 2 test updates.
- Closes issue #167.
- Does not fix the 9 other symptoms.
- Sensible if the product stays Europe-only.
- **Cost:** ~2 h.
### Option B — per-user `displayTimezone` setting, opt-in, default Berlin
Add a `User.displayTimezone` column (DEFAULT `"Europe/Berlin"`).
Surface a picker in `/profile`. Every display-side helper takes the
user pref instead of `DISPLAY_TIMEZONE`. Reminder cron + Withings
day-mapping + Coach context all become user-TZ-aware.
- Scope: large. ~30 files touch the constant, ~10 buckets need to
parametrise their bucketing helper, ~5 surfaces switch from `Intl`
literal to a hook/util.
- Existing data is unaffected by storage (still `timestamptz`).
- Existing `MoodEntry.date` strings and `MedicationSchedule.windowStart`
/ `.windowEnd` are the migration risk — see §7.
- Reminder worker rewrite: pg-boss cron is timezone-naive; choose
between (a) per-user cron rows, (b) global 1-minute tick with
in-app "is it time in user TZ?", (c) precompute the next absolute
UTC fire time per reminder on every settings change and on each fire.
- **Cost:** 1–2 weeks elapsed, conservative. The honest number when
every symptom from §3 is closed and the test suite is parametrised
per TZ branch.
- **Recommended.**
### Option C — auto-detect from browser
`Intl.DateTimeFormat().resolvedOptions().timeZone` from the client,
stored in a cookie or sent with every request. No user-visible
picker.
- Implicit: the user never opted in, never sees what they are set to,
cannot fix a wrong guess.
- Useless for the iOS app, CLI consumers, API-token clients.
- **Rejected** as a primary mechanism. Reasonable as the _default
value_ for the §B picker on signup.
---
## 5. Recommended path
1. **v1.4.25 quick fix** (Option A, separate from this proposal):
ship the ISO-8601-with-offset export so issue #167 closes
immediately. Adds an `aria-label` / footer note in the export-UI
that timestamps carry the user's display zone. Documented in the
v1.4.25 changelog as a follow-on; no roadmap impact.
2. **v1.5 (or later) full feature** (Option B): plan as a dedicated
wave. The migration risk in §7 dominates the schedule.
The two are independent — A can ship next week, B can sit on the
backlog without blocking it.
---
## 6. Migration strategy for Option B
### 6.1 Schema additions (forward-only, no breakage)
```prisma
model User {
...
displayTimezone String @default("Europe/Berlin")
...
}
```
Migration `00XX_user_display_timezone` — additive, DEFAULT covers
every existing row, no backfill needed.
### 6.2 Constant → user-pref accessor
Replace every direct `DISPLAY_TIMEZONE` import with a context-aware
helper:
```ts
// Server side
const tz = await getUserTimezone(); // reads from session.user.displayTimezone
// Client side
const tz = useUserTimezone(); // hook, reads from auth context
```
`format-locale.ts` keeps exporting `DISPLAY_TIMEZONE` as a _fallback_
for non-user-scoped surfaces (admin tables, audit log viewer,
analytics QA dashboard — every place where "whose timezone?" has no
answer). Worker code (`reminder-worker.ts`, `withings-sync`) reads the
target user's pref before any bucketing.
### 6.3 Per-surface fixes (order matches §3)
| # | Surface | Change |
| --- | ---------------------- | ---------------------------------------------------------------------- |
| 1 | CSV/JSON export | `formatInTimeZone(..., userTz, "yyyy-MM-dd'T'HH:mm:ssXXX")` |
| 2 | Reminder worker | recompute next-fire on settings change, in user TZ |
| 3 | "Today" buckets | `berlin-day.ts` → `user-day.ts` taking `tz` arg |
| 4 | `MoodEntry.date` drift | see §7 |
| 5 | Withings sync | bucket via user-tz instead of `BERLIN_TZ` |
| 6 | Chart x-axis | tick formatter takes `userTz` prop, falls back to Berlin |
| 7 | Notification body | dispatcher reads recipient's tz before formatting |
| 8 | PDF doctor report | header reads user's tz; if export is for a target user, use theirs |
| 9 | Coach prompt context | "yesterday" / "this week" computed in user tz |
| 10 | OpenAPI / iOS DTO | document timestamps as `string($date-time)` w/ note: always UTC offset |
### 6.4 Test strategy
`berlin-day.test.ts` has the right shape but pins to Berlin. The
parametrised version runs each test under 3–5 representative zones:
- `Europe/Berlin` — DST boundary cases stay green.
- `UTC` — sanity baseline.
- `America/New_York` — large negative offset, DST.
- `Asia/Tokyo` — large positive offset, no DST.
- `Pacific/Kiritimati` — UTC+14, extreme positive offset (catches
off-by-one-day bugs in date-only strings).
A test helper `withTimezone(tz, () => { ... })` wraps the existing
suites. Expect ~30 existing tests to be parametrised; total suite size
roughly doubles for the TZ-sensitive units. Acceptable.
---
## 7. The real risk — date-only strings
The only thing in the schema that is _not_ time-instant data is the
two strings:
- `MoodEntry.date` (`YYYY-MM-DD`) — written from
`formatInTimeZone(loggedAt, BERLIN_TZ, "yyyy-MM-dd")`.
- `MedicationSchedule.windowStart` / `.windowEnd` (`HH:MM`) — set by
the user via the medication form; they read as "Berlin local time"
today.
When the user switches their `displayTimezone` from `Europe/Berlin` to
`America/New_York`:
- **Mood entries:** every historical `MoodEntry.date` is now a Berlin
calendar day, but the UI will render its day-summary card by
New-York-calendar-day. Existing entries near midnight will appear
to "jump" between days.
- **Decision A — freeze:** TZ is captured at the time of write.
Schema gains `MoodEntry.tz TEXT NOT NULL DEFAULT 'Europe/Berlin'`,
UI groups by `(tz, date)`. Historical entries stay where the user
logged them. **Recommended.**
- **Decision B — recompute:** treat `date` as derived from
`moodLoggedAt`, redo the string under the new TZ. Cleaner code,
but historical "day" assignments shift, which can change streak
counts and break achievements awarded retroactively.
- **Medication windows:** "07:00" today means "07:00 in Berlin". A
user moving to Tokyo legitimately wants their morning dose at
Tokyo-07:00, not Berlin-07:00. **Decision A — freeze** is wrong
here, because the user's expectation is that their reminder follows
them. **Decision B — re-anchor** is correct: schedules are stored
as "wall clock in current display TZ" and reinterpreted under the
new TZ.
- The asymmetry is real: historical observations want freeze, future
schedules want re-anchor. The schema needs to express both
contracts.
### 7.1 DST edge cases
The existing `berlin-day.test.ts` already covers Berlin DST. The
parametrised version inherits this. **Open question:** what happens
when a reminder window straddles a DST boundary in the user's TZ?
Today this is a Berlin-only concern and pg-boss handles it implicitly
via container TZ. With per-user TZ, the resolver has to make a choice
("fire at the wall-clock time on both sides" vs "fire at the absolute
UTC time of the pre-DST occurrence"). Document and pick a convention
before implementation.
---
## 8. Out-of-scope for this proposal
- Per-tenant / per-org TZ: HealthLog is single-tenant.
- TZ-by-IP fallback for unauthenticated surfaces: pricing dashboards
and the auth pages stay Berlin.
- Localised number formats (German 1.234,56 vs US 1,234.56): tracked
separately under i18n.
- Calendar locale (Monday-start vs Sunday-start week): same.
---
## 9. Open questions for the v1.5 product-lead pass
1. Is per-user TZ a v1.5 commitment, or does it slip to v1.6+ behind
the iOS work?
2. If v1.5, does it ship before or after Apple Health import? Apple
Health entries carry their own source-TZ metadata — could simplify
the migration if we lift that as the canonical representation.
3. Decision A (freeze) vs Decision B (re-anchor) split per surface
needs a product call, not an engineering call. Pre-fill from §7.
4. Acceptance criterion for "done": pick 1–2 user complaints we are
willing to ship with, and 1–2 that block release. Probably "Coach
prompt context drift" is acceptable for an MVP; "reminder fires at
wrong wall-clock time" is not.
---
## 10. Pointers for the next agent
- This file lives at `.planning/feature-user-timezone.md`.
- One-line carry in `.planning/v15-backlog.md` under "From issue
triage".
- One-line carry in `.planning/ROADMAP.md` under v1.5 / v1.6+ reserved
themes.
- Triggering issue: GitHub #167 (Warsaw user, v1.4.23, CSV export
shows UTC).
- The v1.4.25 quick fix (Option A) is independent — file it as its
own backlog row, not blocked on this proposal.
+15 -15
View File
@@ -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**.
+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
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
+15
View File
@@ -384,6 +384,21 @@ of the strategic content there is the v1.4.20 plan (Insights redesign
commits (A4, A5, A6 in v1.4.19). v1.4.20 should require one
worktree per parallel agent.
## From issue triage
- **Per-user timezone (Option B in the proposal).** Issue #167
surfaced that the entire app is hardcoded to `Europe/Berlin` — UI
display, analytics buckets, reminder cron, Withings sync mapping,
PDF report, Coach context, ten distinct symptoms in total. The
full design space, migration strategy, schema changes, DST and
date-string risks (`MoodEntry.date` freeze vs `MedicationSchedule`
re-anchor — they need different contracts), test parametrisation
plan and product-call questions are written up in
`.planning/feature-user-timezone.md`. Sized L: 1–2 weeks elapsed.
Independent from the v1.4.25 quick fix (ISO-8601-with-offset CSV
export) which closes #167 on its own without touching this scope.
Triggering issue: GitHub #167.
## Process / meta
- **Cross-agent commit-message drift** — reduced materially in
+139 -14
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+60
View File
@@ -0,0 +1,60 @@
# HealthLog v1.4.22 — release summary
## Release brief
v1.4.22 is the polish release that closes loops the Insights redesign, the AI Coach, and the BD-Zielbereich tile have been carrying since v1.4.16. Two beats define it. The first is the Coach prose rewrite — the model used to open every reply with a number, which read clinical and database-cursor-shaped; v1.4.22 reframes the persona as warm, curious, and reserved, and moves every aggregate value out of the prose into a collapsible "Worauf bezieht sich das?" / "What I'm looking at" disclosure under each turn. The second is the long tail of small fixes that finally settle metrics, layout, and a11y where they should have landed two releases ago: the BD-Zielbereich headline re-anchored to the last-30-day window (with all-time as a sub-row), the Targets / Zielwerte page grew per-target sparklines and a Δ-vs-last-month caption, the sticky section navigation lifted above the Insights hero with proper `aria-current` + `motion-reduce` semantics, the comparison-overlay collapsed into a single global preference under Settings → Dashboard, and the admin / API-tokens horizontal scrollbar is gone on the fifth attempt. PROMPT_VERSION ratcheted 4.20.2 → 4.22.0 — the first minor-digit bump in the v1.4.x line, intentional because the persona, the sentinel-block contract, and the warmth of the prose all changed in one coherent step. v1.4.22 is the milestone that makes v1.5's iOS push start on a clean foundation instead of debt repayment.
## 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) |
## Smoke (no session)
`/api/version` → 200 (returns `1.4.22`). Every gated route (`/`, `/insights`, `/insights/report/2026-W19`, `/admin/api-tokens`, `/settings/integrations`, `/achievements`) returns 307 → `/auth/login`, confirming the proxy gate is alive on the new image.
## What shipped
**Insights surface polish.** Six changes that together move the page from "feature-rich and slightly cluttered" to "deliberate". The BD-Zielbereich tile got its fourth-attempt fix: the headline now reads `windows.last30Days?.pct` instead of the all-time aggregate, surfaces all-time as a sub-row, and gains a synthesised trend arrow plus a 7-day-trend chip. The on-surface comparison toggle retired from `/insights` because the canonical picker has lived in Settings → Dashboard since v1.4.16 (two surfaces violated the no-split-Settings rule). Three half-row layouts that left empty space got a row-fill rule. Trends-row card heights equalised. The "Muster" / "Patterns" row renamed to "Zusammenhänge" / "Relationships" because the row directly above already uses Trends. The sticky section navigation lifted above the hero with proper a11y semantics (`aria-current`, focus-visible ring, `motion-reduce`, observer-ratio sorting). And a single missing `stripChartTokens()` call upstream was producing raw `metric:WEIGHT` / `metric:BLOOD_PRESSURE_SYS` token leaks in recommendation prose; fix landed plus four DE Health-Score component labels normalised to German nouns.
**Coach prose rewrite + collapsible evidence.** PROMPT_VERSION 4.20.2 → 4.22.0. The Coach used to open every reply with a number; the new persona is warm, curious, reserved — a partner sitting alongside the user, not pushing data. Numbers move out of the prose into a `---KEYVALUES---` … `---END---` sentinel block that the chat route parses out server-side and renders as a `<details>` disclosure under each assistant turn, closed by default and hidden when no key-values came back. Three-way hard caps on the sentinel (1 KB payload, 8 lines max, per-line Zod) so a prompt-injection attempt can't grow the persisted envelope. The user avatar reuses the existing Gravatar URL at the same dimensions as the Coach avatar. The medical disclaimer pinned at the bottom of the message thread on every viewport (clinical-adjacent UI must not gate the disclaimer behind a chevron tray). The settings cog removed because it was a dead button in v1.4.21 — per-user prompt-tuning is deferred to v1.4.23. A graceful fallback covers the sentinel-only-output edge case so the model never surfaces a raw marker.
**Other surfaces + backlog cleanup.** The Targets / Zielwerte page stopped reading like a clinical cheat-sheet. Each `<TargetCard>` grew a 30-day inline SVG sparkline beneath the range bar plus a localised "Δ −2.3 kg vs. last month" caption; both null when either window has fewer than 3 readings so cold-start accounts don't see a misleading flat trace. The admin / API-tokens horizontal scrollbar fell to a live Playwright probe that traced the residual culprit to `whitespace-nowrap` on the date `<td>`s. The AuthShell post-hydration redirect on incomplete onboarding moved into `proxy.ts` so the dashboard flash is gone; the proxy reads a non-httpOnly `hl_onboarding` cookie that auth routes mirror from `onboardingCompletedAt`, with `SameSite=Strict` so cross-site requests can't see it. The `setOnboardingPendingCookie` write folded into `createSession()` so issuing a session without onboarding state is type-impossible. Twelve backlog items from `v1421-backlog.md` swept; hygiene continued (191 maintainer-name references in source comments swept, v1.4.14+v1.4.15 bilingual CHANGELOG entries normalised to English-only, the AI-vendor-specific contributor file retired in favour of `CONTRIBUTING.md`).
**Multi-leg QA + reconcile.** Six-leg review (code, security, design, senior-dev, simplify, product-lead) found 0 CRITICAL across all reviews. Seven HIGH findings — all applied inline. About six MEDs applied inline (judgment-pick high-leverage); the rest documented in `.planning/v1422-backlog.md` for v1.4.23. Highlights of the inline applications: BD-Zielbereich tile compareDelta now matches caption (subtracts `bpInTargetPctPriorMonth` instead of `bpInTargetPctAllTime`); `createSession()` requires `onboardingPending` as a parameter so the cookie-write fan-out is one site; `createSseStream` extracted to `src/lib/sse/create-stream.ts` ahead of v1.5 iOS streaming endpoints; `berlinDayKey()` lifted to a shared `src/lib/analytics/berlin-day.ts` helper so the targets sparkline and analytics route share the same bucketing; BP tile collapses to a single secondary row at `<sm`; sticky section nav got the full a11y polish in one commit; `<TokenStatusBadge>` extracted from the duplicated desktop + mobile api-token surfaces.
## Token-budget impact
Coach token budget unchanged from v1.4.21. The prose rewrite swaps inline numbers for a sentinel block that the route strips before persisting; the per-turn cost remains ≈3 000 tokens for a full-scope snapshot, ≈600–700 for a single-source last-7-days narrowed turn. The 25 000 tokens-per-user-per-day cap is unchanged; ≈8 turns/day for a heavy full-scope user, plenty of room for a single-metric narrowed conversation.
## Branch model
The `develop` → `main` model (introduced in v1.4.20 F1) carried its second release cleanly. Long-lived `develop` accumulated 35 commits between v1.4.21 and v1.4.22 (W1 research, W2/W3/W4 implementation, W5 reconcile, release-prep). The release-merge into `main` was a single `--no-ff` with the full list of commits in the merge body. Future GHCR builds run on `main` only.
## Quality
- W1 research + W2/W3/W4 implementation + W5 reconcile across 35 commits on `develop` (`8606767` → `b094eaa`)
- 1-commit prettier pass on W5 touchpoints (`bbdc3fb`)
- 1-commit release-prep on `develop` (`d71e879`)
- Release-merge `005fed0` on `main`
- Tests: 2026 → 2111 unit (+85), 81 → 89 integration (+8), typecheck clean, lint baseline (15 warnings, no new)
## Carry-overs
The Coolify image-digest auto-deploy bug is still present and the workflow's auto-deploy step skipped because the `COOLIFY_WEBHOOK` / `COOLIFY_TOKEN` repo secrets aren't configured. The `.planning/coolify-auto-deploy-howto.md` runbook landed in W4 C3 documents the one-time UI toggle; bumping the secrets and flipping the toggle should drop the host-side retag fallback for v1.4.23. Worked around with the host-side retag procedure (`docker pull :1.4.22 → docker tag :latest → docker compose up -d --force-recreate`) on apps-01. Same fault that hit v1.4.19, v1.4.20, and v1.4.21.
The full v1.4.23 carry-over list lives in `.planning/v1422-backlog.md`. Highlights: sentinel parser malformed-enum hardening (Sr-M5, ~30 LOC, highest signal-to-effort ratio of the deferred items); analytics-route unbounded `findMany` paging; targets-route 7-pass sparkline coalesce; `CoachDrawer key={prefill}` controlled-prop refactor (Sr-HIGH-4); per-user prompt-tuning surface; medication_schedules.days_of_week schema-drift cleanup.
## Strategic next
v1.5 plan in `.planning/phase-W5-v1422-product-lead-review.md`. Headline is the iOS native client + Apple Health ingest contract — six phases, P1 first launch (bearer + refresh-token end-to-end with one login page + one dashboard widget) through P6 cross-user feedback aggregator cron. New measurement types (HRV, Sleep, Resting HR, Steps, BodyFat, Glucose) extend the Coach to PROMPT_VERSION 5.0.0 with conservative phrasing rules around clinical sleep-stage conclusions and HRV claims. Per-metric APNs alerts add `src/lib/notifications/senders/apns.ts`. The Insights page split (the ~9-surface orchestrator) lands in v1.5 P5 alongside the Vitals tile row, not v1.4.23, so the split maps to the exact sub-trees the Apple Health work touches.
## Deploy path used
Host-side SSH retag fallback. The GHCR tag-build (workflow run `25639069964`) succeeded, but the auto-deploy step skipped because the `COOLIFY_WEBHOOK` / `COOLIFY_TOKEN` repo secrets aren't configured. A Coolify MCP `force=true` deploy was queued (`owrpyzoi1ijsvhge0hfv25bw`) but didn't refresh the digest within the 5-minute polling window — same `:latest` pinning bug from v1.4.21. The `ssh apps-01 'docker pull :1.4.22 → docker tag :latest → docker compose up -d --force-recreate'` procedure flipped `/api/version` from `1.4.21` to `1.4.22` at 22:43:50 +02:00.
+2
View File
@@ -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",
+2
View File
@@ -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
View File
@@ -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",
-4
View File
@@ -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] ?? "")}`,
);
-1
View File
@@ -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);
});
+1 -2
View File
@@ -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({
+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
* 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);
+1 -1
View File
@@ -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();
});
});
+4 -1
View File
@@ -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" } });
+1 -1
View File
@@ -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" } });
+2 -1
View File
@@ -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" } });
+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 { 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 });
+27 -1
View File
@@ -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
View File
@@ -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>
))}
+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"
>
<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;
+1 -8
View File
@@ -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>
);
+27 -17
View File
@@ -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>
+41 -34
View File
@@ -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"
+50 -22
View File
@@ -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",
},
},
};
+25 -1
View File
@@ -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);
+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 { 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
View File
@@ -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", {
+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) {
await prisma.session.delete({ where: { id: sessionId } });
await prisma.session
.deleteMany({ where: { id: sessionId } })
.catch(() => {});
}
cookieStore.delete(SESSION_COOKIE);
cookieStore.delete(ONBOARDING_COOKIE);
+9
View File
@@ -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",
};
}
+1 -1
View File
@@ -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
View File
@@ -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;
+1
View File
@@ -14,6 +14,7 @@ export default defineConfig({
exclude: [
"**/node_modules/**",
"**/dist/**",
"**/.next/**",
"tests/integration/**",
// Playwright E2E suite — driven separately via `pnpm e2e`.
"e2e/**",