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