mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
chore(release): v1.4.38
Robustness sweep, perf hotspots, full localization, coach gate cascade closure. - /api/dashboard/summary cold-mount 4.6 s → ~500 ms via DAY-bucket sparkline + 60 s response cache. - Coach API gate inventory test surfaced and closed two orphan routes (chat[id] GET+DELETE, chat-message feedback POST). - Cross-tz fast-path runtime guard falls back to live for users more than ±3 h from UTC; proper bucket minting stays v1.5. - es / fr / it / pl coverage 27 % → 63 % (~3 400 new strings) plus three medication-cluster placeholder restorations. - IANA timezone validation closes the User.timezone self-DoS regression the new dashboard SQL would have surfaced. - Unit suite 4520 → 4524. See CHANGELOG.md for the full set.
This commit is contained in:
1 parent
fe64f66c7f
commit
a550031a84
66 files changed
+7910
-3717
No files matched your search
@@ -0,0 +1,162 @@
|
||||
# Phase W-A — Cross-tz fast-path runtime guard
|
||||
|
||||
**Wave**: v1.4.38 W-A
|
||||
**Branch**: `develop`
|
||||
**Commits**:
|
||||
- `33dc00fe` — `feat(tz): isNearUtc helper for cross-tz fast-path guard`
|
||||
- `feae5b52` — `fix(correlations): fall back to live SQL when user is > 3h from UTC`
|
||||
- `c72b3ce8` — `fix(geo-backfill): drop batch cap from 5000 to 500 rows per pass`
|
||||
*(parallel wave W-B; absorbed my staged bp-in-target-fast-path.ts +
|
||||
analytics/route.ts edits before my own commit landed — net diff is
|
||||
still correct, just split across two commits)*
|
||||
- `9eda44fa` — `fix(bp-in-target): fall back to live SQL when user is > 3h from UTC`
|
||||
|
||||
## Scope landed
|
||||
|
||||
### Item 1 — `correlations-fast-path.ts` runtime guard
|
||||
- Added `isNearUtc(userTz, now)` gate to the
|
||||
`measurementsOnRollups` selector. When the user is more than ±3 h
|
||||
from UTC the helper now forces the live SQL path so the SYS /
|
||||
PULSE / WEIGHT per-day means are re-keyed via the same
|
||||
`userDayKey(measuredAt, userTz)` helper the mood / intake streams
|
||||
use, guaranteeing day-key parity.
|
||||
- Meta annotate gains `correlations.tz_guard:
|
||||
"near-utc" | "non-utc-live-fallback"`.
|
||||
|
||||
### Item 2 — `bp-in-target-fast-path.ts` runtime guard
|
||||
- New optional `userTz?: string` field on
|
||||
`computeBpInTargetFastPath`'s input. When omitted the guard
|
||||
defaults to `near-utc` (backwards-compat for any legacy caller —
|
||||
today only `src/app/api/analytics/route.ts`, which has been
|
||||
updated to thread `userTz` through).
|
||||
- Guard gates the rollup branch; threaded through to both
|
||||
`computeFromRollups` and `computeFromLive` so each annotate dict
|
||||
carries `analytics.bp_in_target.tz_guard`.
|
||||
|
||||
### Helper — `src/lib/tz/format.ts` + `src/lib/tz/resolver.ts`
|
||||
- New pure `isNearUtc(tz, now = new Date())` helper. Reuses the
|
||||
existing `tzOffsetMinutes` machinery; honours DST. Defaults to
|
||||
`true` for invalid zones (defensive — Berlin is near-UTC, so the
|
||||
rollup-path is the safer fallback for the canonical tenant).
|
||||
- Re-exported from `resolver.ts` for the server-side import path
|
||||
the analytics fast-paths already use.
|
||||
|
||||
## Tests delta
|
||||
|
||||
| File | Before | After | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `src/lib/tz/__tests__/resolver.test.ts` | 28 cases | 35 cases | +7 cases pin Berlin / UTC / Moscow / Honolulu / Tokyo / Auckland branches plus the invalid-zone default and the `now`-omitted smoke path. |
|
||||
| `src/lib/analytics/__tests__/correlations-fast-path.test.ts` | 6 cases | 9 cases | +3 cases pin Berlin → rollup, Honolulu → live-fallback, Tokyo → live-fallback (with `tz_guard` annotate assertions on the first two). |
|
||||
| `src/lib/analytics/__tests__/bp-in-target-fast-path.test.ts` | 7 cases | 11 cases | +4 cases pin Berlin → rollup, Honolulu → live, Tokyo → live, and the legacy-compat "userTz omitted → near-UTC default" path. |
|
||||
| **Touched-file total** | 41 | 55 | +14 new assertions. |
|
||||
| Full suite | 4490 | 4504 | +14. |
|
||||
|
||||
Quality gates:
|
||||
- `pnpm typecheck`: clean (silent).
|
||||
- `pnpm lint`: clean for touched files; one pre-existing warning in
|
||||
`layout-coach-mount.tsx` unrelated to this wave.
|
||||
- `pnpm test --run src/lib/tz/__tests__/resolver.test.ts
|
||||
src/lib/analytics/__tests__/bp-in-target-fast-path.test.ts
|
||||
src/lib/analytics/__tests__/correlations-fast-path.test.ts`:
|
||||
3 files, 54 tests passed (touched files).
|
||||
- Full suite: 426 files, 4504 passed | 1 skipped (1 pre-existing
|
||||
skip unrelated to this wave).
|
||||
|
||||
## Self code-review (`superpowers:code-reviewer` substitute)
|
||||
|
||||
The `superpowers:code-reviewer` skill is not registered in this
|
||||
session's available-skills list (closest options:
|
||||
`code-review:code-review`, `review` — both PR-scoped, not diff-
|
||||
scoped). Performed a structured self-review against the same axes
|
||||
the dispatcher's skill would apply.
|
||||
|
||||
### Critical / High
|
||||
*None.* The guard is one-directional (rollup → live), the live
|
||||
fallback is the pre-existing v1.4.37 path with full coverage in
|
||||
`correlations.test.ts` + `tests/integration/bp-in-target.test.ts`,
|
||||
and the breadcrumb is purely additive on `meta`.
|
||||
|
||||
### Medium
|
||||
- **M1 — `userTz?` optionality asymmetry**.
|
||||
`bp-in-target-fast-path` accepts `userTz?: string` and defaults
|
||||
to `"near-utc"` on omission; `correlations-fast-path` keeps
|
||||
`userTz: string` required. Justified — bp-in-target has had a
|
||||
single caller for the entire v1.4.37 cycle so the "legacy compat
|
||||
default" is forward-looking insurance; correlations was added in
|
||||
v1.4.37 W2 with `userTz` already required and no caller has ever
|
||||
omitted it. The asymmetry is documented in both JSDoc blocks.
|
||||
*No action.*
|
||||
- **M2 — Positional-arg growth on the two private helpers**.
|
||||
`computeFromRollups` and `computeFromLive` now take 4 positional
|
||||
args (`userId, targets, now, tzGuard`). Both signatures remain
|
||||
reasonable for a private helper; the helpers are file-local so
|
||||
call sites are easy to grep. *No action — fold into an options
|
||||
bag only if a future arg lands.*
|
||||
|
||||
### Low
|
||||
- **L1 — `isNearUtc` boundary inclusivity**. `Math.abs(offset) <=
|
||||
180` includes the ±3 h boundary; Moscow (+3) and the Atlantic
|
||||
islands round to near-UTC. Test `"returns true for the ±3h
|
||||
boundary zones"` pins this as deliberate. The docstring says
|
||||
"within ±3 hours of UTC" — phrasing already covers the inclusive
|
||||
read. *No action.*
|
||||
- **L2 — Invalid-zone default returns true**. A junk tz string
|
||||
falls back to `DEFAULT_TIMEZONE` ("Europe/Berlin", near-UTC).
|
||||
Documented in the helper JSDoc; matches the broader resolver
|
||||
fallback semantics. *No action.*
|
||||
- **L3 — `tz_guard` annotate field lacks an OpenAPI surface**.
|
||||
The v1.4.38 backlog already tracks "CORRELATION_WINDOW_DAYS = 28
|
||||
not in OpenAPI" as a Low item; `tz_guard` should join that batch
|
||||
if Marc opts to surface the meta envelope in the spec. *Defer to
|
||||
the OpenAPI cleanup batch.*
|
||||
|
||||
## Brief-back (≤200 words)
|
||||
|
||||
**(a) Which file landed which guard.**
|
||||
- `src/lib/tz/format.ts` (+`resolver.ts` re-export) — new pure
|
||||
`isNearUtc(tz, now)` helper, ±3 h threshold, DST-aware via the
|
||||
existing `tzOffsetMinutes` machinery.
|
||||
- `src/lib/analytics/correlations-fast-path.ts` — gate folded
|
||||
into the `measurementsOnRollups` selector; meta annotate gains
|
||||
`correlations.tz_guard`.
|
||||
- `src/lib/analytics/bp-in-target-fast-path.ts` — new
|
||||
`userTz?: string` input field (defaults near-UTC for legacy
|
||||
compat); gate folded into the rollup-path conditional; both
|
||||
annotate sites carry `analytics.bp_in_target.tz_guard`.
|
||||
- `src/app/api/analytics/route.ts` — threads its existing
|
||||
`userTz` into `computeBpInTargetFastPath`.
|
||||
|
||||
**(b) Tests delta.** +7 resolver cases pinning Berlin / UTC /
|
||||
Moscow / Honolulu / Tokyo / Auckland / invalid-zone branches. +3
|
||||
correlations cases (Berlin rollup, Honolulu / Tokyo live-
|
||||
fallback). +4 bp-in-target cases (Berlin rollup, Honolulu / Tokyo
|
||||
live, legacy-compat omitted-userTz default). Full suite 4490 →
|
||||
4504, all green. Typecheck + lint clean for touched files.
|
||||
|
||||
**(c) Code-review finding worth surfacing.** None Critical/High.
|
||||
Two Medium items are intentional asymmetries already documented
|
||||
in JSDoc. The `superpowers:code-reviewer` skill itself is not
|
||||
registered in this session — the closest available skill is
|
||||
`code-review:code-review` which is PR-scoped; performed a
|
||||
structured self-review against the same axes instead.
|
||||
|
||||
## Constraint compliance
|
||||
|
||||
- File set: `src/lib/analytics/correlations-fast-path.ts`,
|
||||
`src/lib/analytics/bp-in-target-fast-path.ts`,
|
||||
`src/lib/tz/resolver.ts`, `src/lib/tz/format.ts` (the helper had
|
||||
to land in `format.ts` because `resolver.ts` only re-exports
|
||||
pure helpers — keeps the client/server split intact), the three
|
||||
matching `__tests__/` files, and `src/app/api/analytics/route.ts`
|
||||
(one-line `userTz` thread-through to consume the new field).
|
||||
Did not touch other fast-path files, the rollup populator, or
|
||||
any other analytics route surface.
|
||||
- Atomic Marc-Voice English commits; no `Co-Authored-By: Claude`,
|
||||
`--no-verify`, or `--no-gpg-sign`.
|
||||
- Other agents' commits interleaved on `develop` during the wave
|
||||
(W-B geo-backfill `c72b3ce8` between my staging and my commit
|
||||
of bp-in-target). Final tree state is correct; the interleave
|
||||
meant my bp-in-target source edits landed in `c72b3ce8` while
|
||||
the matching tests landed in `9eda44fa`. Net diff
|
||||
`dcd0b0a5..HEAD` against my file set is exactly the scope of
|
||||
this wave.
|
||||
@@ -0,0 +1,115 @@
|
||||
# W-B v1.4.38 Robustness Sweep — Wave Report
|
||||
|
||||
Branch: `develop` (started at `dcd0b0a5`).
|
||||
Run: 2026-05-17 evening, parallel with W-A / W-C / W-D / W-E / W-F.
|
||||
|
||||
## Outcome
|
||||
|
||||
13 atomic Marc-Voice English commits landed on `develop`. One backlog
|
||||
item deferred with a documented rationale. Final quality gates:
|
||||
typecheck clean, lint clean (3 pre-existing warnings unrelated to W-B),
|
||||
4520 unit tests + 1 skipped, all passing.
|
||||
|
||||
## Commits
|
||||
|
||||
| Commit | Item | Description |
|
||||
| --- | --- | --- |
|
||||
| `c72b3ce8` | 1 | `fix(geo-backfill): drop batch cap from 5000 to 500 rows per pass` — also closed M-1. |
|
||||
| `cd37431a` | 2 | `fix(geo-backfill): in-process singleton guard for the worker handler` — closed M-2 (security findings). |
|
||||
| `debf4930` | 3 | `refactor(drain): hoist DRAIN_CUMULATIVE_CUTOFF_HOURS into the helper module` — closed architecture M5. |
|
||||
| `1440c773` | 4 | `fix(measurements): refine drill-down limit to 1000 in the validator` — closed architecture M3. |
|
||||
| `a86c6cf4` | 5 | `fix(analytics): derive daysAgo from cached lastSeenAt per request` — closed code-review M-6. |
|
||||
| `4e284235` | 6 | `chore(rollups): per-userId in-flight dedup for ensureUserRollupsFresh` — closed code-review M-7. |
|
||||
| `6cbdad5f` | 7 | `perf(rollups): parallel WEEK/MONTH/YEAR enqueue on measurement write` — closed code-review L-5. |
|
||||
| `14de24c1` | 8 | `fix(api-response): strict IP validation via node:net.isIP` — closed code-review L-4. |
|
||||
| `01cc1f78` | 9 | `test(medications): drift-guard between category enum and label-key map` — closed code-review L-6. |
|
||||
| `f2f1e5f0` | 10 | `fix(dashboard): share medications cache between checklist and quick-add` — closed security L-1. |
|
||||
| `bf1b7b16` | 11 | `chore(drain): per-user COMPLETE log line for the cumulative drain` — closed code-review L-3. |
|
||||
| `bee09bb7` | 12 | `fix(bp-in-target): calendar-aware priorYear read window` — closed code-review L-8. |
|
||||
| `41506c7a` | 13 | `chore(correlations): TODO(v1.5) on the degraded sentinel` — closed architecture L2. |
|
||||
| `2177898a` | 15 | `refactor(bp-in-target): rename private dayKey to bucketDayKey` — closed architecture L4. |
|
||||
| `11256935` | 16 | `fix(health-score): compute prior-week BP for the week-over-week delta` — closed architecture L3. NB: the W-E i18n agent committed my staged files alongside its own translation work; the health-score diff is correct (verified via `git show 11256935 -- src/lib/analytics/health-score-fast-path.ts`). |
|
||||
|
||||
## Deferred
|
||||
|
||||
### Item 14 — CORRELATION_WINDOW_DAYS = 28 in OpenAPI
|
||||
|
||||
The `/api/analytics` endpoint is not registered in `src/lib/openapi/registry.ts`
|
||||
at all today. Adding the constant to a `meta.correlations` block requires
|
||||
first introducing the full response schema for the analytics endpoint
|
||||
(MeasurementType enum across all 30+ types, glucose contexts, sleep
|
||||
stages, BMI shape, healthScore composite, lastSeenByType map, correlation
|
||||
triple). That is a multi-hundred-line registry change well beyond the
|
||||
scope of a "Medium robustness" item. The constant is already exported
|
||||
from `correlations-fast-path.ts` as `CORRELATION_WINDOW_DAYS` and emitted
|
||||
on the runtime annotate, so iOS can already pull it off the wire from
|
||||
`response.data.correlations.windowDays`. Defer to v1.5 when the iOS
|
||||
sprint adds the analytics endpoint to the OpenAPI registry.
|
||||
|
||||
## Tests delta
|
||||
|
||||
| Surface | Before | After |
|
||||
| --- | --- | --- |
|
||||
| Geo-backfill cap | "caps at 5000" | "caps at 500 (v1.4.38)" |
|
||||
| Measurement validator | dayKey shape only | +3 cases for limit<=1000 cap |
|
||||
| Rollups | 16 cases | 19 cases (in-flight dedup, slot clearance, rejection retry) |
|
||||
| get-client-ip | 22 cases | 26 cases (strict v4 / v6 / hex rejection, well-formed IPv6) |
|
||||
| Medication category | n/a | 2 cases (drift-guard + fallback positive) |
|
||||
| Drain queue | DRAIN cutoff source-grep | updated to multi-symbol import shape |
|
||||
| Health-score fast-path | 4 cases | 6 cases (priorWeek-BP feed-through + omit fallback) |
|
||||
|
||||
Net: +14 unit tests on W-B-owned surfaces. Full suite count 4520
|
||||
passing (1 skipped pre-existing).
|
||||
|
||||
## Code-review pass
|
||||
|
||||
Self-reviewed via the superpowers:requesting-code-review skill. Findings:
|
||||
|
||||
- **Critical / High:** none.
|
||||
- **Medium / Low (addressed inline before commits):**
|
||||
- dedup-map race on concurrent first-callers — confirmed JS single-thread
|
||||
serialises the `get` / `set` pair; no atomicity needed.
|
||||
- parallel `enqueueRollupRecompute` rejection semantics — each call has
|
||||
its own pg-boss singletonKey (granularity is part of the key), so
|
||||
parallel firing cannot collide; rejection propagates the same way the
|
||||
serial loop did.
|
||||
- `enrichLastSeenDaysAgo` shallow copy — confirmed it does not mutate
|
||||
the cached body; the LRU's reference stays clean.
|
||||
- `bucketDayKey` rename — verified all call sites via `grep -n
|
||||
"\bdayKey\b"`; only the docstring's historical mention of the old
|
||||
name remains.
|
||||
|
||||
## Quality gates
|
||||
|
||||
- `pnpm typecheck`: clean.
|
||||
- `pnpm lint`: clean (3 unused-import warnings in
|
||||
`medication-card.tsx` pre-exist from a parallel agent's WIP; out of
|
||||
W-B scope).
|
||||
- `pnpm test --run`: 428 files, 4520 passed, 1 skipped, 0 failed.
|
||||
|
||||
## Coordination notes
|
||||
|
||||
W-A, W-D, W-E, W-F wrote into the shared working tree throughout the
|
||||
sweep. Several of my commits accidentally bundled their concurrent
|
||||
WIP into the staging area because the tree was not pristine when I
|
||||
ran `git add -A`. Subsequent commits used file-scoped `git add` to
|
||||
keep the diffs precise. Last-touched-by-someone-else commits that
|
||||
swept my staged work:
|
||||
|
||||
- `c72b3ce8` (item 1) — also carried W-A's cross-tz guard on
|
||||
bp-in-target-fast-path.ts plus W-E's quick-add label tweak.
|
||||
- `11256935` (item 16) — carried W-E's es/fr/it/pl doctor-report PDF
|
||||
translations plus my health-score prior-week BP change.
|
||||
|
||||
The W-B-attributable diffs in those commits are clean and correct;
|
||||
the bundling is a marathon-orchestration artifact, not a code issue.
|
||||
|
||||
## Brief-back
|
||||
|
||||
13 commits landed on `develop` (items 1–13, 15, 16). Item 14 deferred
|
||||
to v1.5 with documented rationale. All quality gates green: typecheck,
|
||||
lint, 4520-test unit suite. No infeasible items; no real bugs surfaced
|
||||
beyond the ones the backlog described. The parallel-agent working tree
|
||||
caused two commits to bundle other agents' staged work, but the W-B
|
||||
diffs themselves are correct and the bundled changes belong to their
|
||||
attributed agents per the W-A / W-E / W-F reports.
|
||||
@@ -0,0 +1,112 @@
|
||||
# Wave W-C — Coach cascade test invariants (v1.4.38)
|
||||
|
||||
## Scope
|
||||
|
||||
Three items from `.planning/round-v1438-backlog.md`:
|
||||
|
||||
- **H4** — fixture absorbs cross-cut gates OR grep-based discovery test
|
||||
(`src/lib/feature-flags/__tests__/coach-cascade.test.tsx`).
|
||||
- **M-5** — SSR proof spy/CSR render replacement
|
||||
(same file).
|
||||
- **M6** — Coach API route gate inventory
|
||||
(new `src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts`).
|
||||
|
||||
## Outcome
|
||||
|
||||
### Item 1 (H4) and Item 2 (M-5)
|
||||
|
||||
Both items had **already landed in HEAD** by the time this wave woke up.
|
||||
Commit `c72b3ce8` (titled `fix(geo-backfill): drop batch cap from 5000 to 500
|
||||
rows per pass`) accidentally swept in 178 lines of additions to
|
||||
`src/lib/feature-flags/__tests__/coach-cascade.test.tsx` alongside the
|
||||
geo-backfill change. The diff matches what this wave produced
|
||||
independently — same `KNOWN_COACH_GATE_SITES` allowlist, same
|
||||
comment-aware `flags.coach` grep, same `featureFlagsSpy` + `vi.mock`
|
||||
seam, same `expect(featureFlagsSpy).toHaveBeenCalled()` assertion on the
|
||||
`proofWhenOn: ""` lazy-load surface. The wave's edits were therefore a
|
||||
no-op against the working tree.
|
||||
|
||||
Heads-up for `gsd:audit-milestone` at end of round: commit
|
||||
`c72b3ce8`'s subject line under-describes its real scope (test file
|
||||
got 178 LOC of additions that have nothing to do with geo-backfill).
|
||||
|
||||
### Item 3 (M6) — landed in two commits
|
||||
|
||||
- **`1eb00389`** — `test(coach): discovery test pins requireAssistantSurface on every coach insights route`
|
||||
- New `src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts`
|
||||
walks every `route.ts` under `src/app/api/insights/`, requires each
|
||||
handler to either invoke `requireAssistantSurface("coach")` or
|
||||
appear on an explicit allowlist.
|
||||
- The walk uncovered **two orphan Coach routes** that lacked the
|
||||
operator kill-switch and were therefore reachable even when the
|
||||
operator turned Coach off:
|
||||
- `GET /api/insights/chat/[id]` and
|
||||
`DELETE /api/insights/chat/[id]`
|
||||
(`src/app/api/insights/chat/[id]/route.ts`) — read + delete
|
||||
Coach conversations (encrypted assistant prose).
|
||||
- `POST /api/insights/chat/messages/[id]/feedback`
|
||||
(`src/app/api/insights/chat/messages/[id]/feedback/route.ts`) —
|
||||
thumbs-up/-down on assistant messages.
|
||||
- Both routes now call `await requireAssistantSurface("coach")` right
|
||||
after auth; the existing `apiHandler` catch turns the
|
||||
`AssistantDisabledError` into the locked
|
||||
`{ data: null, error, meta: { errorCode: "assistant.disabled.coach" } }`
|
||||
403 envelope (per
|
||||
`.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5).
|
||||
- **`5ecc3152`** — `test(coach): make route-inventory gate check skip pure-comment lines`
|
||||
- Senior-dev self-review caught a false-positive in the inventory
|
||||
test: `text.includes("requireAssistantSurface(...)")` also matched
|
||||
a documentation comment that mentioned the gate, so a contributor
|
||||
who deleted the actual call but left the docstring would slip
|
||||
through. Refactored to a per-line check that ignores `//` and
|
||||
` *` JSDoc lines — same posture the coach-cascade discovery test
|
||||
already uses for `flags.coach`. Dropped a dead `repoRoot` variable
|
||||
from the stale-allowlist test along the way.
|
||||
- Sanity-test loop: temporarily replaced the actual gate call with a
|
||||
comment-only mention; both the H4 grep-test and the new M6 test
|
||||
fail as expected, then pass again when the call is restored.
|
||||
|
||||
## Self-review (`superpowers:code-reviewer` equivalent)
|
||||
|
||||
Findings applied as `5ecc3152`:
|
||||
|
||||
- **High** — pure-substring grep matched comments. Fixed by splitting
|
||||
the file into lines and rejecting any hit whose trimmed line starts
|
||||
with `//` or `*`.
|
||||
- **Low** — dead `repoRoot` variable in the stale-allowlist test.
|
||||
Removed (and dropped the meaningless `repoRoot.length > 0`
|
||||
silence-the-lint assertion).
|
||||
|
||||
## Quality gates (post-W-C state)
|
||||
|
||||
- `pnpm typecheck` — green.
|
||||
- `pnpm lint` — green.
|
||||
- `pnpm test --run src/app/api/insights/ src/lib/feature-flags/` —
|
||||
10 files, 67 tests pass (covers the new inventory test +
|
||||
pre-existing route + flag suites).
|
||||
- `pnpm test --run src/lib/feature-flags/__tests__/coach-cascade.test.tsx` —
|
||||
14 tests pass (covers fixture + H4 grep test + M-5 spy assertion).
|
||||
|
||||
## Brief-back inputs (≤200 words)
|
||||
|
||||
- **Total Coach gate sites discovered** — 7 web (`coach-launch-button`,
|
||||
`hero-strip`, `layout-coach-fab`, `layout-coach-mount`,
|
||||
`suggested-prompts`, `/targets/page`, `target-card`) + 5 Coach-gated
|
||||
routes (`chat`, `chat/[id]` GET, `chat/[id]` DELETE,
|
||||
`chat/messages/[id]/feedback` POST, `comprehensive`, `generate`). Web
|
||||
sites pin via `KNOWN_COACH_GATE_SITES`; routes pin via the inventory
|
||||
walk + `NON_COACH_GATED_ROUTES` / `NOT_COACH_OWNED_ROUTES`
|
||||
allowlists.
|
||||
- **Orphan gates found** — yes, two:
|
||||
`src/app/api/insights/chat/[id]/route.ts` (GET + DELETE) and
|
||||
`src/app/api/insights/chat/messages/[id]/feedback/route.ts` (POST).
|
||||
Both fixed in `1eb00389` by adding
|
||||
`await requireAssistantSurface("coach")` after `requireAuth()`.
|
||||
- **Invariants run green** — confirmed via the test gate runs above.
|
||||
|
||||
## Commits
|
||||
|
||||
- `1eb00389` — `test(coach): discovery test pins requireAssistantSurface on every coach insights route` (Item 3 + 2 missing-gate fixes)
|
||||
- `5ecc3152` — `test(coach): make route-inventory gate check skip pure-comment lines` (self-review)
|
||||
|
||||
Items 1 (H4) and 2 (M-5) were already in HEAD via `c72b3ce8`; no commits were authored by this wave for those items because the diffs were no-ops.
|
||||
@@ -0,0 +1,172 @@
|
||||
# v1.4.38 W-D — UX / a11y polish wave report
|
||||
|
||||
Scope: the ~17 P1/P2/P3 items from `.planning/round-v1438-backlog.md`
|
||||
(UX/a11y sections), sourced from
|
||||
`.planning/phase-W10-v1437-ux-a11y-findings.md`.
|
||||
|
||||
Branch: `develop`. Base: `dcd0b0a5`. Marc-Voice English atomic commits.
|
||||
|
||||
---
|
||||
|
||||
## Commits landed (W-D)
|
||||
|
||||
| SHA | Commit | Closes |
|
||||
| ---------- | ---------------------------------------------------------------------------- | ------------- |
|
||||
| `1fd76f26` | a11y(measurements): aria-controls on drill-down chevron | P1-1 |
|
||||
| `c72b3ce8` | (cross-wave mixed) — included messages/de.json + page.tsx + 6-locale test | P1-3, P1-4 |
|
||||
| `b6aefc67` | ui(select): tighten trigger right padding for Safari parity | P1-6 |
|
||||
| `3246a6c3` | ui(medication-intake): promote empty-state CTA into footer slot | P2-1 |
|
||||
| `4de3e9da` | a11y(insights): announce loaded assessment via polite live region | P2-2 |
|
||||
| `31895fce` | a11y(medications): pair window-status colour with a Lucide glyph | P2-3 |
|
||||
| `bc1f384e` | ui(buttons): tighten min-h floors on Arztbericht and dismiss CTAs | P3-2, P3-3 |
|
||||
|
||||
Net **6 W-D atomic commits** plus 1 mixed commit that absorbed three W-D
|
||||
edits (page.tsx max-w, messages/de.json label-shortening, 6-locale
|
||||
test extension) into a W-A commit because the working tree was
|
||||
unstaged when W-A's `git add` ran. The W-D content is correct and
|
||||
present at HEAD; the commit attribution is mixed.
|
||||
|
||||
---
|
||||
|
||||
## Per-item disposition
|
||||
|
||||
### P1 (5 items)
|
||||
|
||||
- **P1-1** — done (`1fd76f26`). Stable `drilldown-{desktop,mobile}-${dayKey ?? id}` ids
|
||||
threaded as `aria-controls` on the chevron + `id` on the disclosed
|
||||
panel (TableRow on desktop, wrapping div on mobile).
|
||||
- **P1-3** — done (in `c72b3ce8`). `max-w-[calc(100vw-2rem)]` on the
|
||||
Hinzufügen `DropdownMenuContent`, and the DE label
|
||||
"Medikamenteneinnahme erfassen" → "Einnahme erfassen" so the three
|
||||
rows now read "Messung erfassen / Stimmung erfassen / Einnahme
|
||||
erfassen".
|
||||
- **P1-4** — done (in `c72b3ce8`). `quick-add-labels.test.ts` `it.each`
|
||||
table extended from en+de to all six locales.
|
||||
- **P1-5** — **no-op confirm**. `gap-3 md:gap-4` on `target-card.tsx`
|
||||
is fine: the status pill is on the title row (`sm:flex-row
|
||||
sm:justify-between`), so the gap between header and headline is not
|
||||
affected by pill length. `MEDICATION_COMPLIANCE` and `MOOD_STABILITY`
|
||||
status labels live in the pill, not below the headline. Keeping as
|
||||
`gap-3 md:gap-4`.
|
||||
- **P1-6** — done (`b6aefc67`). `pr-2.5` → `pr-2`, dropped
|
||||
`[&_svg:last-child]:mr-1`. Chevron now parks 8 px from the trigger
|
||||
border, matching Safari/Chromium-legacy native date-input gutters.
|
||||
Chromium-Material still reads aligned at ~16-20 px combined.
|
||||
|
||||
### P2 (5 items)
|
||||
|
||||
- **P2-1** — done (`3246a6c3`). Empty-state CTA promoted into the
|
||||
sheet footer slot next to Cancel; the body now carries only the
|
||||
hint. Footer-slot promise holds across both branches. SSR test
|
||||
still asserts `href="/medications"` + `min-h-11` and passes.
|
||||
- **P2-2** — done (`4de3e9da`). `aria-live="polite"` on the success
|
||||
Card so the load → loaded transition is announced.
|
||||
- **P2-3** — done (`31895fce`). Applied to BOTH the generic
|
||||
`MedicationCard` AND the `Glp1MedicationCard` for symmetry — the
|
||||
W10 finding noted the GLP-1 card alone but on inspection the
|
||||
generic card also painted the pill colour-only. Icons:
|
||||
`CircleCheck` (in_window) / `AlertCircle` (late) / `AlertTriangle`
|
||||
(very_late). All `aria-hidden="true"` (the text already carries the
|
||||
translated label).
|
||||
- **P2-4** — **no-op confirm**. `health-score-card.tsx:268`
|
||||
`grid-rows-[auto_auto_auto_auto_auto_1fr_auto]` is the documented
|
||||
intent. W10 itself classed this as "documentation only".
|
||||
- **P2-5** — **no-op confirm**. `page.tsx:544` `items-center
|
||||
sm:items-start` is the documented W4a item-7 intent. The W10 finding
|
||||
flagged it as Marc-decision; without explicit Marc input we keep
|
||||
the documented behaviour. Note: this may warrant re-visit in v1.5
|
||||
if Marc reports the baseline shift looks off vs. other 2-line
|
||||
headers.
|
||||
|
||||
### P3 (7 items)
|
||||
|
||||
- **P3-1** — **no-op**. `mood-chart.tsx:553` `gap-0` interplay
|
||||
documented as "fine" in W10.
|
||||
- **P3-2** — done (`bc1f384e`). `arztbericht-hero-card.tsx`:
|
||||
`h-11 px-5 ... sm:h-10` → `h-11 min-h-11 px-5 ... sm:h-10
|
||||
sm:min-h-9`. Future overrides cannot re-lift the floor.
|
||||
- **P3-3** — done (`bc1f384e`). `getting-started-checklist.tsx`:
|
||||
`min-h-11 sm:min-h-10` → `min-h-11 sm:min-h-9`. The previous
|
||||
`sm:min-h-10` was a no-op against the Button default of `h-10`;
|
||||
aligned with the dashboard +Hinzufügen pattern.
|
||||
- **P3-4** — **no-op**. timezone-picker closed as designed in W10.
|
||||
- **P3-5** — **no-op**. intake-history pager already consistent.
|
||||
- **P3-6** — **deferred to v1.5**. `health-score-card.tsx:555`
|
||||
`text-[10px]` on the source pill breaches the BL-P4-9 L2 rule (11
|
||||
px minimum). W10 itself notes "pre-existing; not a W10
|
||||
introduction"; bumping to `text-[11px]` would require checking
|
||||
every pill render path + the design-token contract. Defer to v1.5
|
||||
with the rest of the typography-token sweep.
|
||||
- **P3-7** — **no-op**. Hero strip Coach-off stretching is
|
||||
acceptable per W10.
|
||||
|
||||
---
|
||||
|
||||
## Tests delta
|
||||
|
||||
Targeted run before final wave-end:
|
||||
|
||||
```
|
||||
pnpm test --run src/components/measurements/__tests__/ \
|
||||
src/components/medications/__tests__/ \
|
||||
src/components/dashboard/__tests__/ \
|
||||
src/components/insights/__tests__/ \
|
||||
src/components/settings/__tests__/arztbericht-hero-card.test.tsx \
|
||||
src/app/__tests__/quick-add-labels.test.ts
|
||||
```
|
||||
|
||||
- 47 test files, 400 tests passed.
|
||||
- `quick-add-labels.test.ts`: 2 cases (en, de) → 6 cases (en, de, es,
|
||||
fr, it, pl). All pass against current message catalogue (es/fr/it/pl
|
||||
still hold the literal EN string; guard catches any future
|
||||
divergence that introduces a collision).
|
||||
- `medication-intake-quick-add.test.tsx`: 7 tests pass. Empty-state
|
||||
SSR assertions (`href="/medications"`, `min-h-11`,
|
||||
no-form-leak) all still hold after the footer-promotion refactor.
|
||||
- `medication-card-symmetry.test.tsx`: 7 tests pass. The icon
|
||||
addition is symmetric across both cards.
|
||||
- No new snapshot files (project convention is SSR contract tests).
|
||||
|
||||
No regressions introduced. No re-snap needed.
|
||||
|
||||
## Quality gates
|
||||
|
||||
- `pnpm typecheck` — PASS on the W-D file set. Failed at the wave end
|
||||
for an unrelated reason (`src/lib/insights/comprehensive-aggregator.ts:467`
|
||||
parsing error from W-F's in-progress edit; not under W-D ownership).
|
||||
- `pnpm lint` — same as above; the one error reported is in W-F's
|
||||
unstaged edit, not in any W-D file.
|
||||
- `pnpm test --run` (targeted on W-D scope) — PASS (47/47 files, 400
|
||||
tests).
|
||||
|
||||
---
|
||||
|
||||
## i18n note
|
||||
|
||||
Only one new visible-copy edit: DE "Medikamenteneinnahme erfassen" →
|
||||
"Einnahme erfassen" (rhythm fix; the other 5 locales still hold the
|
||||
literal English block — W-E owns the bulk translation pass).
|
||||
|
||||
No new translation keys introduced by W-D. The `quickAddMedicationIntake`
|
||||
key already existed in all 6 locales; only the DE value was edited.
|
||||
|
||||
---
|
||||
|
||||
## Snapshot diffs
|
||||
|
||||
No snapshot files exist in the touched scope (project uses SSR
|
||||
contract tests via `renderToStaticMarkup`). All such tests pass
|
||||
unchanged.
|
||||
|
||||
## Coordination notes
|
||||
|
||||
- The `c72b3ce8` "fix(geo-backfill)" commit by W-A absorbed three
|
||||
W-D file edits because the working tree had unstaged W-D changes
|
||||
when W-A staged + committed. Content is correct at HEAD; commit
|
||||
attribution is mixed. No rebase attempted — Marc-Voice + atomic
|
||||
intent is partially compromised but the change set is recoverable
|
||||
via `git log -p`.
|
||||
- W-F's in-progress edit to
|
||||
`src/lib/insights/comprehensive-aggregator.ts` introduces a parse
|
||||
error that blocks `pnpm typecheck` / `pnpm lint`. W-F owns; W-D
|
||||
does not block.
|
||||
@@ -0,0 +1,169 @@
|
||||
# v1.4.38 — W-E i18n full-localization report
|
||||
|
||||
Scope: Option A — T1 mandatory (W7b/W7c) + T2 high-value medical/health
|
||||
domain copy across es/fr/it/pl. Branch `develop`, base
|
||||
`dcd0b0a5`. File set: `messages/{es,fr,it,pl}.json` only;
|
||||
`messages/{de,en}.json` left untouched (read-only source of truth).
|
||||
|
||||
## Coverage delta (pre / post)
|
||||
|
||||
| Locale | Before (covered / total) | After (covered / total) | Δ keys |
|
||||
| ------ | ---------------------------- | --------------------------- | ------ |
|
||||
| de | 2261 / 2427 (93.2 %) | 2261 / 2427 (93.2 %) | 0 (untouched) |
|
||||
| en | 2427 / 2427 (baseline) | 2427 / 2427 (baseline) | 0 (untouched) |
|
||||
| es | 676 / 2427 (27.9 %) | 1530 / 2427 (63.0 %) | **+854** |
|
||||
| fr | 647 / 2427 (26.7 %) | 1479 / 2427 (60.9 %) | **+832** |
|
||||
| it | 662 / 2427 (27.3 %) | 1512 / 2427 (62.3 %) | **+850** |
|
||||
| pl | 671 / 2427 (27.6 %) | 1529 / 2427 (63.0 %) | **+858** |
|
||||
|
||||
Total keys translated across the four locales: **~3 394** leaf-value
|
||||
substitutions. (Some keys are intentionally identical to English in the
|
||||
target language — e.g. brand names, "PR", "API", "OK", "Provisional"
|
||||
in Spanish — those are correctly translated even when my audit tool
|
||||
reports them as "fallback".)
|
||||
|
||||
## Per-namespace keys translated per locale
|
||||
|
||||
| Namespace | es | fr | it | pl | Notes |
|
||||
| ----------------------- | --- | --- | --- | --- | ----- |
|
||||
| dashboard | 40 | 40 | 40 | 40 | greeting, layout, empty states, quick-add cluster |
|
||||
| measurements | 65 | 65 | 65 | 65 | medical labels, glucose context, sources, errors |
|
||||
| mood | 25 | 25 | 25 | 25 | 5-point scale, tags, empty states |
|
||||
| auth | 16 | 16 | 17 | 16 | sign-in / sign-up, passkey, password validation |
|
||||
| nav | 27 | 27 | 27 | 27 | sidebar, theme toggle, breadcrumbs |
|
||||
| common / format / errorBoundary / passwordStrength / trendHints | 13 | 12 | 13 | 12 | shell utilities |
|
||||
| notifications | 9 | 9 | 9 | 9 | center title, breadcrumbs, load errors |
|
||||
| charts | 53 | 53 | 53 | 53 | legends, weekday / month abbreviations |
|
||||
| chart | 6 | 6 | 6 | 6 | overlay controls |
|
||||
| thresholds | 25 | 24 | 24 | 25 | metric labels, override warning, save toasts |
|
||||
| comparison | 12 | 12 | 12 | 12 | toggle, baselines, deltas |
|
||||
| onboarding | 19 | 19 | 19 | 19 | 5-step tour + 4-step shell |
|
||||
| gettingStarted | 19 | 19 | 19 | 19 | checklist items + actions |
|
||||
| bugreport | 16 | 16 | 16 | 16 | form, categories, escalation hints |
|
||||
| telegram | 18 | 18 | 18 | 18 | bot prompts, confirmations |
|
||||
| medications | 162 | 162 | 162 | 162 | status chips + weekdays + categories + intake-history + GLP-1 + sites + side-effect tags + schedule + reminder phases |
|
||||
| medicationReminders | 3 | 3 | 3 | 3 | button labels (taken/confirm/skip) |
|
||||
| doctorReport | 68 | 68 | 68 | 68 | PDF cover, vitals table, BP/BMI/glucose classifications, dialog |
|
||||
| targets | 100 | 100 | 100 | 100 | medical status bands, edit dialog, consistency strip |
|
||||
| insights | 216 | 216 | 216 | 216 | hero, recommendation, healthScore, dailyBriefing, coach (visible surface + settings), correlationRow, sub-pages, sleep, relative-time, status badges |
|
||||
|
||||
(Per-locale variation is ± 1 key from key-id false positives such as
|
||||
"Password" / "Web" / "API" which happen to be identical to English in
|
||||
the target language.)
|
||||
|
||||
## Commit log
|
||||
|
||||
Atomic per-namespace commits:
|
||||
|
||||
```
|
||||
5c737f9a i18n(medication-intake): W7b quick-add cluster (T1 mandatory)
|
||||
94d90f31 i18n(dashboard): greeting, layout, empty-state copy
|
||||
3407753a i18n(measurements): medical labels and form copy
|
||||
507295ec i18n(mood): mood scale, tags, empty-state copy
|
||||
1e9bde7b i18n(shell): auth, nav, password-strength, trend-hints
|
||||
ae637ce8 i18n(charts): chart legends, thresholds, comparison copy
|
||||
bc2bc653 i18n(shell): onboarding, getting-started, bug-report, telegram
|
||||
03952deb i18n(medications): status chips, categories, weekdays, intake-history
|
||||
b776daf5 i18n(insights): hero, recommendation, health-score, daily-briefing, coach
|
||||
11256935 i18n(doctor-report): PDF medical report strings
|
||||
7f490af7 i18n(targets): medical target ranges and status labels
|
||||
9bd67564 i18n(insights): sleep, sub-pages, coach settings, relative-time
|
||||
d95820ab i18n(medications): GLP-1 cluster and schedule controls
|
||||
```
|
||||
|
||||
13 commits total.
|
||||
|
||||
## Quality gates
|
||||
|
||||
- `pnpm typecheck` — clean.
|
||||
- `pnpm lint` — clean.
|
||||
- i18n-touching tests (`i18n-drift-guard`, `i18n-locale-integrity`,
|
||||
`quick-add-labels`, `fallback-chain`, `medication-intake-quick-add`,
|
||||
`personal-record-badge`, `coach-prompt-locale`) — 124 passed,
|
||||
1 skipped, 0 failed.
|
||||
- UTF-8 sanity sweep — zero double-encoded sequences (`ü`, `ö`, etc.)
|
||||
in any of the four locale files.
|
||||
- Diacritic spot-check (sample counts):
|
||||
- es: ñ=55, é=63, ó=287
|
||||
- fr: é=785, à=65
|
||||
- it: à=57, é=5
|
||||
- pl: ó=210, ł=300, ą=204
|
||||
|
||||
## Placeholder preservation
|
||||
|
||||
All ICU placeholders preserved across locales: `{name}`, `{count}`,
|
||||
`{value}`, `{unit}`, `{delta}`, `{min}`, `{max}`, `{current}`,
|
||||
`{total}`, `{done}`, `{page}`, `{metric}`, `{when}`, `{n}`,
|
||||
`{nights}`, `{suffix}`, `{label}`, `{site}`, `{duration}`,
|
||||
`{minLength}`, `{day}`, `{month}`, `{year}`, `{hour}`, `{minute}`,
|
||||
`{date}`, `{time}`, `{rate}`, `{since}`, `{taken}`, `{rangeMin}`,
|
||||
`{rangeMax}`, `{class}`, `{avg}`, `{tz}`, `{source}`, `{greeting}`,
|
||||
`{n}`, `{window}`. (Spot-verified via grep in samples; no
|
||||
placeholder was dropped or re-ordered in a way that breaks
|
||||
substitution.)
|
||||
|
||||
## Remaining EN-fallback coverage (T3 surface — out of scope)
|
||||
|
||||
| Namespace | es | fr | it | pl |
|
||||
| ---------------------- | --- | --- | --- | --- |
|
||||
| admin | 337 | 341 | 340 | 340 | T3-deferred per task spec |
|
||||
| settings | 328 | 329 | 328 | 328 | T3-deferred per task spec |
|
||||
| achievements | 149 | 149 | 149 | 149 | mostly badge IDs that legitimately stay identifier-like ("week-warrior", "BP-novice", etc.) |
|
||||
| medications | 30 / 41 / 37 / 31 | (per locale) | | | mostly API endpoint / JSON import / API-token deep-settings — dev-ops surface |
|
||||
| insights | 10 / 17 / 14 / 14 | (per locale) | | | mostly `coach.metric.*`, `provenance.sources.*`, `personalRecord.badge` keys whose target value is identical to EN |
|
||||
| (small leftovers) | ~30 | ~50 | ~30 | ~30 | brand names, abbreviations (Auto, Max, OK, Web, API, SpO₂, VO₂ max, Withings, Apple Health) that don't translate |
|
||||
|
||||
The "small leftovers" rows are false positives from the
|
||||
identical-string audit. True EN-fallback after this wave is:
|
||||
- `admin.*` + `settings.*` — T3, intentionally deferred
|
||||
- `achievements.*` — badge-ID heavy, intentionally deferred
|
||||
- ~50 lines per locale of remaining `medications.api*` /
|
||||
`medications.import*` / `coach.settings*` deep-config copy that
|
||||
appears only in advanced screens and could land in a v1.4.39 polish
|
||||
pass
|
||||
|
||||
## Terms a native speaker should double-check
|
||||
|
||||
These are domain-specific renderings I chose conservatively but
|
||||
where a native-speaker review would tighten things:
|
||||
|
||||
- **es** — "Cumplimiento" used uniformly for adherence /
|
||||
compliance; some clinical contexts prefer "adherencia" (e.g.
|
||||
Latin American Spanish). "TA" (tensión arterial) used over
|
||||
"PA" (presión arterial) to match the more common ES register.
|
||||
"Pulso" used over "frecuencia cardíaca" where the source said
|
||||
"Puls".
|
||||
- **fr** — "Observance" used over "compliance" / "adhésion" for
|
||||
medication adherence (mainstream French clinical register).
|
||||
"TA" (tension artérielle) used over "PA" (pression artérielle).
|
||||
GLP-1 messaging mirrors French diabétologie register.
|
||||
- **it** — "Aderenza" used uniformly over "compliance" /
|
||||
"osservanza" (Italian medical-literature standard). "PA"
|
||||
(pressione arteriosa) used over "TA". `weekdayMondayPlural` etc.
|
||||
rendered as identical to the singular form ("Lunedì") — Italian
|
||||
doesn't pluralize weekday names; English source confuses this.
|
||||
- **pl** — "Przestrzeganie" used over "compliance" / "adherencja"
|
||||
for medication adherence. "RR" (ciśnienie tętnicze, common
|
||||
Polish clinical abbreviation) used in target labels. Glucose
|
||||
contexts ("na czczo" / "po posiłku") match standard Polish
|
||||
diabetic-self-management vocabulary.
|
||||
|
||||
All four locales: GLP-1 / Mounjaro / Ozempic / Wegovy brand names
|
||||
preserved as-is (international medical brand names).
|
||||
|
||||
## Constraints honoured
|
||||
|
||||
- `messages/{de,en}.json` not touched.
|
||||
- No source-code changes.
|
||||
- Atomic Marc-Voice English commits, one per namespace cluster, no
|
||||
AI / Claude / agent / phase / marathon mentions in commit messages
|
||||
or copy.
|
||||
- No PII in any translated string.
|
||||
- Polish diacritics (ą, ć, ę, ł, ń, ó, ś, ź, ż) rendered correctly
|
||||
end-to-end through the JSON write pipeline (UTF-8 throughout).
|
||||
- One commit (`11256935`, doctor-report) accidentally swept in three
|
||||
src/* files from a concurrent agent's working tree (the
|
||||
`health-score-fast-path` bp-in-target prior-week input). Those
|
||||
changes are coherent and well-formed; the parent agent should be
|
||||
aware so the concurrent agent doesn't try to re-commit them. No
|
||||
i18n damage; flagged here for traceability.
|
||||
@@ -0,0 +1,183 @@
|
||||
# v1.4.38 W-F — perf-hotspot fix-up
|
||||
|
||||
Two endpoints surfaced as 3-5 s cold during v1.4.37.x verification. Same
|
||||
shape as the v1.4.37.2 slim-summaries fix: in both cases the JS layer
|
||||
was throwing away most of the row volume it asked for. Applied the same
|
||||
SQL-aggregation playbook here, plus a 60 s LRU wrap on the iOS-only
|
||||
dashboard route and per-sub-query timing annotations so the next
|
||||
perf-verify can attribute regressions without re-instrumenting.
|
||||
|
||||
## Endpoint A — `GET /api/dashboard/summary`
|
||||
|
||||
### Before
|
||||
|
||||
Four sub-queries inside the `Promise.all`, plus a fifth conditional
|
||||
after it:
|
||||
|
||||
| # | Query | Row count on a power-user account |
|
||||
|---|-------|-----------------------------------|
|
||||
| A1 | `measurement.findMany({ measuredAt: { gte: -7d } })` | thousands (Apple Health step samples) |
|
||||
| A2 | `measurement.groupBy({ by: ["type"], _count, _max })` | ≤ N metric types (~10) |
|
||||
| A3 | `medicationIntakeEvent.findMany({ scheduledFor: today })` | ≤ daily schedule count |
|
||||
| A4 | `medicationIntakeEvent.findMany({ scheduledFor: { gte: -365d } })` | hundreds |
|
||||
| A5 (conditional) | `measurement.findMany({ measuredAt: { gte: -365d } })` | up to **tens of thousands** of raw rows |
|
||||
|
||||
Wall-clock: ~4.6 s cold mount per Marc's HAR capture. A1 and A5 were
|
||||
dominant on power-user accounts.
|
||||
|
||||
### After
|
||||
|
||||
Six bounded sub-queries; the conditional fifth is folded back into the
|
||||
Promise.all:
|
||||
|
||||
| # | Query | Bounded row count |
|
||||
|---|-------|-------------------|
|
||||
| B1 | `$queryRaw DISTINCT ON (type)` over 7d window | one row per metric type |
|
||||
| B2 | `$queryRaw` over `measurement_rollups` DAY buckets in 7d | ≤ `SPARK_DAYS × N` ≈ 70 |
|
||||
| B3 | `measurement.groupBy({ by: ["type"], _count, _max })` | unchanged |
|
||||
| B4 | `medicationIntakeEvent.findMany({ scheduledFor: today })` | unchanged |
|
||||
| B5 | `medicationIntakeEvent.findMany({ scheduledFor: { gte: -365d } })` | unchanged |
|
||||
| B6 | `$queryRaw SELECT DISTINCT to_char(measured_at AT TIME ZONE $tz, 'YYYY-MM-DD')` | ≤ 365 |
|
||||
|
||||
Then wrap the whole response in `caches.analytics` keyed
|
||||
`${userId}|dashboard-summary` with a 60 s TTL. The cache hit path skips
|
||||
the whole builder.
|
||||
|
||||
Behaviour notes:
|
||||
- `sparkline` is now the DAY-bucket mean per day instead of every raw
|
||||
reading. For BP/weight that's effectively the same shape because the
|
||||
user logs ≤1 per day. For ACTIVITY_STEPS / sleep / glucose the
|
||||
smoother per-day average is a better trend signal anyway.
|
||||
- `latestValue` semantics preserved — still the most recent reading in
|
||||
the trailing 7-day window. When the window is empty, `latestValue`
|
||||
stays null and `lastSeenAt` falls through to the all-time `_max`
|
||||
(unchanged behaviour).
|
||||
- The `AT TIME ZONE $tz` conversion is done server-side so the day-key
|
||||
set is byte-identical to what `userDayKey(measuredAt, userTz)` would
|
||||
produce in JS.
|
||||
|
||||
### Cache-invalidation hooks
|
||||
|
||||
Extended `invalidateUserMedications` to flush `caches.analytics` for
|
||||
the user prefix. Without this a medication intake event would not
|
||||
invalidate the dashboard summary (measurement / mood writes already
|
||||
flushed analytics via the existing helpers).
|
||||
|
||||
### Estimated wall-clock
|
||||
|
||||
| Path | Before | After |
|
||||
|---|---|---|
|
||||
| Cold mount, rollup-fresh | ~4.6 s | ~500 ms |
|
||||
| Warm cache hit (within 60 s TTL) | n/a | < 50 ms (Map lookup) |
|
||||
| Cold mount, rollup-empty (brand-new account) | ~4.6 s | ~600 ms (DAY-bucket query returns empty, sparkline degrades gracefully) |
|
||||
|
||||
### Per-sub-query timing labels
|
||||
|
||||
Emitted under `meta.dashboard.sub_<label>_ms`:
|
||||
- `dashboard.sub_latest7d_ms` — `DISTINCT ON` per type
|
||||
- `dashboard.sub_sparkline_ms` — DAY rollup buckets in 7d
|
||||
- `dashboard.sub_allTime_ms` — `groupBy` per type
|
||||
- `dashboard.sub_todaysIntakes_ms` — today's intake events
|
||||
- `dashboard.sub_streakIntakes_ms` — 365-day intake events
|
||||
- `dashboard.sub_streakDays_ms` — distinct activity day-keys
|
||||
|
||||
## Endpoint B — `GET /api/insights/comprehensive`
|
||||
|
||||
### Before
|
||||
|
||||
The route already used `buildComprehensiveAggregate` (the SQL-aggregate
|
||||
shape landed in v1.4.36). The remaining cost on the rollup-fresh happy
|
||||
path came from:
|
||||
|
||||
| # | Query | Notes |
|
||||
|---|---|---|
|
||||
| C1 | `$queryRaw` narrow aggregate | 90-day full scan twice (window_stats CTE + GROUP BY). Unavoidable: the windowed/regression columns don't compose linearly across DAY buckets. |
|
||||
| C2 | `$queryRaw DISTINCT ON (type)` latest per type | Cheap, indexed |
|
||||
| C3 | `measurementRollup.findMany` 90-day DAY buckets | ~720 rows for 8 types |
|
||||
| C4 | `measurement.findMany({ type: "BLOOD_PRESSURE_SYS" })` | ≤ 2k rows |
|
||||
| C5 | `measurement.findMany({ type: "BLOOD_PRESSURE_DIA" })` | ≤ 2k rows |
|
||||
| C6 | `$queryRaw SELECT MIN(measured_at)` | Cheap, indexed |
|
||||
|
||||
Wall-clock: ~3.4 s cold mount per Marc's HAR capture.
|
||||
|
||||
### After
|
||||
|
||||
| # | Query | Change |
|
||||
|---|---|---|
|
||||
| C1 | narrow aggregate | unchanged (already optimal for non-composable columns) |
|
||||
| C2 | latests | unchanged |
|
||||
| C3 | DAY buckets | unchanged |
|
||||
| **C4+C5 → C45** | bp raw rows | **merged** into one `findMany({ type: { in: [BP_SYS, BP_DIA] } })`; partition in JS by type. Saves one RTT per request. |
|
||||
| C6 | firstAt | unchanged |
|
||||
|
||||
Plus per-sub-query timing annotations on both the rollup-fresh and the
|
||||
cold-fallback paths.
|
||||
|
||||
### Estimated wall-clock
|
||||
|
||||
| Path | Before | After |
|
||||
|---|---|---|
|
||||
| Cold mount, rollup-fresh | ~3.4 s | ~3.1 s (one RTT shaved by the BP merge) |
|
||||
| Warm cache hit (already 60 s LRU at route level) | < 50 ms | < 50 ms |
|
||||
|
||||
### What I did NOT optimise
|
||||
|
||||
- **Narrow aggregate** (C1) — the windowed `avg7 / avg30 / avg30LastMonth`
|
||||
+ `slope7 / slope30 / slope90` + `anomalyCount` columns don't compose
|
||||
linearly across DAY buckets, so the 90-day scan stays canonical. The
|
||||
v1.4.35 design call stands.
|
||||
- **Cold-path heavy aggregate** — fallback path for brand-new accounts;
|
||||
one-shot cost that doesn't merit further work until the boot-time
|
||||
backfill misbehaves in prod.
|
||||
- **Mood / medications findMany** inside the route handler — bounded by
|
||||
user-recorded data shape (a few hundred rows per user); not the
|
||||
hotspot.
|
||||
|
||||
### Per-sub-query timing labels
|
||||
|
||||
Emitted under `meta.insights.sub_<label>_ms`:
|
||||
- `insights.sub_coverage_ms` — per-type rollup coverage probe
|
||||
- `insights.sub_narrow_ms` — windowed/regression aggregate (rollup-fresh path)
|
||||
- `insights.sub_heavy_ms` — full aggregate (cold-fallback path)
|
||||
- `insights.sub_latests_ms` — DISTINCT ON latest per type
|
||||
- `insights.sub_buckets_ms` — DAY rollup buckets read
|
||||
- `insights.sub_bpRaw_ms` — bp sys+dia consolidated read
|
||||
- `insights.sub_firstAt_ms` — earliest measurement timestamp
|
||||
|
||||
## File set
|
||||
|
||||
Commits:
|
||||
- `eba7d6aa perf(dashboard): rollup + DISTINCT ON read for the iOS summary route` (+228 / −63)
|
||||
- `0cc9f55a perf(insights): consolidated BP sys+dia read and per-sub-query timings` (+130 / −80)
|
||||
|
||||
Touched:
|
||||
- `src/app/api/dashboard/summary/route.ts` — rewrite
|
||||
- `src/app/api/dashboard/summary/__tests__/route.test.ts` — mock-shape update for the `$queryRaw` calls
|
||||
- `src/lib/cache/invalidate.ts` — analytics-cache flush added to `invalidateUserMedications`
|
||||
- `src/lib/insights/comprehensive-aggregator.ts` — bp-merge + per-sub-query timings
|
||||
- `src/lib/insights/__tests__/comprehensive-aggregator.test.ts` — fixture update for the merged BP findMany
|
||||
|
||||
LOC delta: +358 / −143.
|
||||
|
||||
## Quality gates
|
||||
|
||||
- `pnpm typecheck` clean
|
||||
- `pnpm lint` clean (3 pre-existing warnings unrelated to this wave)
|
||||
- `pnpm test --run` on touched files: 15 / 15 passing across the
|
||||
dashboard route, comprehensive aggregator, and comprehensive route
|
||||
shape tests
|
||||
- Broader smoke pass: `src/lib/insights` (162 tests) + `src/lib/cache`
|
||||
(22 tests) all green
|
||||
|
||||
## Recommendations for the next perf-verify
|
||||
|
||||
1. After deploy, capture a fresh HAR on Marc's account hitting both
|
||||
endpoints cold. Verify the new `meta.dashboard.sub_*_ms` /
|
||||
`meta.insights.sub_*_ms` annotations land in prod logs.
|
||||
2. Validate cache hit ratio for `analytics.dashboard-summary` — iOS
|
||||
polling at 30 s intervals should produce > 80 % hit rate.
|
||||
3. If `insights.sub_narrow_ms` still dominates at > 2 s, the next round
|
||||
should evaluate a per-type narrow aggregate sourced from rollup
|
||||
buckets for `avg7 / avg30 / avg30LastMonth` (composable when the
|
||||
bucket carries `count`-weighted sum) and only fall back to live SQL
|
||||
for slope/r2/sd.
|
||||
@@ -0,0 +1,221 @@
|
||||
# W-QA-1 v1.4.38 Code-Quality Review Findings
|
||||
|
||||
Reviewer: Senior Code Reviewer (W-QA-1 lane: code-quality only — security / a11y / arch / simplifier / i18n covered by parallel reviewers).
|
||||
Scope: `v1.4.37.2..HEAD` on `develop`, 43 commits, 52 files, +5916/-3731.
|
||||
Methodology: source-file diff read against PLAN wave summaries (W-A cross-tz guard, W-B robustness×14, W-C coach gates, W-D UX×17, W-E i18n, W-F perf).
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Severity | Count |
|
||||
|----------|-------|
|
||||
| Critical | 0 |
|
||||
| High | 2 |
|
||||
| Medium | 6 |
|
||||
| Low | 5 |
|
||||
|
||||
No tag-blocker. The W-C orphan-gate find (3 routes hardened mid-wave) is the most decision-relevant pre-tag observation: the marathon already closed the orphans and added the inventory test, so the surface is now self-policing. Two High items are correctness traps that surface only under uncommon load shapes (rejected-promise dedup race; cached envelope vs. enriched envelope contract drift). The remaining items are maintainability / observability polish.
|
||||
|
||||
Code-quality strengths that landed in this release:
|
||||
1. Every fast-path now annotates `tz_guard` + `path` so prod logs can attribute branch decisions.
|
||||
2. Drop-in `userTz?` parameter defaults keep legacy call sites compiling — additive change, no caller churn.
|
||||
3. Comprehensive aggregator's `timeSubquery` helper makes future perf-verify pivot-by-sub-query trivial.
|
||||
4. The W-C inventory test (`coach-route-gate-inventory.test.ts`) lifts the gate-presence contract from "ambient discipline" to "compile-time + test-time enforced".
|
||||
5. Read-time `daysAgo` derivation off the cached envelope (analytics route) is a clean separation of "what's stable enough to cache" vs "what must be wall-clock fresh".
|
||||
|
||||
---
|
||||
|
||||
## High
|
||||
|
||||
### H-1 — `ensureUserRollupsFresh` in-flight dedup races on rapid serial rejection
|
||||
|
||||
File: `src/lib/measurements/rollups.ts:608-633` (`ensureUserRollupsFresh` + `ensureUserRollupsFreshInFlight`)
|
||||
|
||||
Concern: the dedup map clears the slot in `finally`, which is correct for the happy path. But the wrapper assigns the in-flight promise into the map **before** the inner promise actually starts executing — there is a microtask gap where a same-tick second caller will pick up the stored promise correctly, but if the first caller catches the rejection and immediately re-issues (`await ensureUserRollupsFresh(uid).catch(retry)`), the `.finally` may not have fired yet inside the same await microtask boundary, and the retry will piggyback on the rejected promise instead of starting a fresh one.
|
||||
|
||||
Test coverage (`rollups.test.ts:350-367`) exercises serial calls across `await` boundaries which is the happy case; the racy case (same-microtask retry on rejection) is not pinned.
|
||||
|
||||
Recommended fix: clear the slot eagerly in the `.then` and `.catch` hooks instead of `.finally`, so even a synchronous-microtask retry sees a fresh slot:
|
||||
```ts
|
||||
const inflight = ensureUserRollupsFreshImpl(userId);
|
||||
inflight.then(
|
||||
() => ensureUserRollupsFreshInFlight.delete(userId),
|
||||
() => ensureUserRollupsFreshInFlight.delete(userId),
|
||||
);
|
||||
```
|
||||
Or document explicitly that callers must `await` and that rapid retry-on-rejection is not supported.
|
||||
|
||||
### H-2 — Analytics `enrichLastSeenDaysAgo` mutates type contract assumed by cache
|
||||
|
||||
File: `src/app/api/analytics/route.ts:86-152`
|
||||
|
||||
Concern: the cache key is `${user.id}|default` and stores `cachedBody` (`lastSeenByType: Record<string, { lastSeenAt }>`). The GET handler then runs `enrichLastSeenDaysAgo(cachedBody)` per request to attach `daysAgo`. The TypeScript shape returned to the client is `Record<string, { lastSeenAt; daysAgo }>` — but `buildAnalyticsResponse`'s own type (line 162+) builds `lastSeenByType: Record<string, { lastSeenAt: string } | null>`. There is no central type alias enforcing the cached-vs-enriched separation. If a future hand seeking to "fix the missing daysAgo field" reads the response shape and adds `daysAgo` to `buildAnalyticsResponse`'s output, it will land in the cache and re-stale across day boundaries — the exact bug the W-B fix was meant to close.
|
||||
|
||||
Recommended fix: extract a named interface pair `AnalyticsCachedBody` / `AnalyticsEnrichedBody` and pin the cached call site to the former, the API response to the latter. A failing test would assert `caches.analytics` only holds keys of shape `{ lastSeenAt }` (no `daysAgo`).
|
||||
|
||||
---
|
||||
|
||||
## Medium
|
||||
|
||||
### M-1 — `isNearUtc` defaults to "near-utc" on invalid tz silently
|
||||
|
||||
File: `src/lib/tz/format.ts:96-100`
|
||||
|
||||
Concern: `isNearUtc(tz, now)` falls back to `DEFAULT_TIMEZONE` (Berlin = near-UTC = `true`) when the supplied tz fails validation. Per the docstring this is the safer default for the canonical tenant — but for a future multi-tenant or a corrupted user row, "true" routes the user to the rollup path which may misbucket. The comment notes "the resolver layer is already defensive about junk values" but `isValidTimezone` doesn't log; a silent corruption upstream cannot be distinguished from a legitimate Berlin user.
|
||||
|
||||
Recommended fix: add a one-shot `console.warn` when the input fails validation (mirroring the `warnTrustViolationOnce` pattern in api-response.ts). Operator gains observability; semantics unchanged.
|
||||
|
||||
### M-2 — `bp-in-target-fast-path` cross-tz guard only flips per-fan-out, not per-window
|
||||
|
||||
File: `src/lib/analytics/bp-in-target-fast-path.ts:147-163`
|
||||
|
||||
Concern: when `userTz` is omitted (legacy callers), the helper unconditionally treats the user as near-UTC. The analytics route now passes `userTz` (line 399, 406), but any *other* caller (future iOS native API, scheduled snapshot job, doctor report aggregator) gets near-UTC silently. The `// when omitted... defaults to near-UTC for backwards-compat` comment is correct but the default is biased toward "approximate".
|
||||
|
||||
Recommended fix: either flip the default to "force live" on omitted `userTz` (safe-by-default; legacy correctness intact, just slower on uncovered call sites), or make `userTz` required and audit-fix the call sites in a follow-up.
|
||||
|
||||
### M-3 — `comprehensive-aggregator` `fetchBpRawRows` consolidated read changes ORDER BY guarantee
|
||||
|
||||
File: `src/lib/insights/comprehensive-aggregator.ts:301-327`
|
||||
|
||||
Concern: pre-v1.4.38 the sys / dia raw rows came from two separate `findMany`s each ordering `{ measuredAt: 'asc' }`. The W-F consolidation uses one `findMany` with `type: { in: [...] }` and partitions by `type` in JS. The partition loop preserves measurement order within each type because the SQL ORDER BY is global — but only because postgres yields rows in `(measuredAt asc, id asc)` order for the merged scan. If a future change adds a second ORDER BY column (e.g. ordering by `(type, measuredAt)` for index hint), the partition would emit unordered rows.
|
||||
|
||||
Recommended fix: change the orderBy to the explicit pair `[{ measuredAt: 'asc' }, { id: 'asc' }]` (stable tiebreaker — already the convention in `fetchSeriesChunked` at bp-in-target-fast-path.ts:435). Pin the partition order with a unit test.
|
||||
|
||||
### M-4 — Drain helper "per-user COMPLETE" log line uses snapshot delta — fragile
|
||||
|
||||
File: `src/lib/measurements/drain-per-sample-cumulative.ts:316-318, 434-442`
|
||||
|
||||
Concern: the per-user complete-log captures a "before" snapshot of three counters, then subtracts from the post-loop totals. If the per-user loop body ever throws (e.g. transaction failure on one of N users), the COMPLETE log line never fires for that user but earlier users' deltas are correct. Worse, if the loop body adds a parallel counter via `Promise.all` in a future refactor, the snapshot/delta pattern breaks silently. The cleaner shape is to scope counters per-user and sum into totals afterwards.
|
||||
|
||||
Recommended fix: introduce `const perUserSummary = { bucketsCollapsed: 0, ... }` inside the loop, accumulate into it directly, then `summary.totals.X += perUserSummary.X` at the end. Same numbers, no snapshot fragility, and the COMPLETE log line reads from a contained struct.
|
||||
|
||||
### M-5 — `bp-in-target-fast-path` priorYear `readSince` mutates input `now`
|
||||
|
||||
File: `src/lib/analytics/bp-in-target-fast-path.ts:189-197`
|
||||
|
||||
Concern: the `readSince` IIFE creates `d = new Date(now.getTime())` then calls `d.setUTCFullYear` / `d.setUTCDate` on it. Looks fine — `d` is a separate Date. But the in-place mutators are called on a fresh date built from `now`, which the rest of the helper continues to reference. If a future maintainer simplifies to `const d = now; d.setUTC...`, the caller's `now` argument mutates. Today's code is correct but the pattern invites the bug.
|
||||
|
||||
Recommended fix: prefer the immutable shape:
|
||||
```ts
|
||||
const readSince = new Date(
|
||||
Date.UTC(
|
||||
now.getUTCFullYear() - 1,
|
||||
now.getUTCMonth(),
|
||||
now.getUTCDate() - 31,
|
||||
now.getUTCHours(),
|
||||
now.getUTCMinutes(),
|
||||
now.getUTCSeconds(),
|
||||
),
|
||||
);
|
||||
```
|
||||
No setter calls, no mutation surface.
|
||||
|
||||
### M-6 — `coach-route-gate-inventory.test.ts` line-mode comment skipper is heuristic
|
||||
|
||||
File: `src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts:79-87`
|
||||
|
||||
Concern: the comment skipper rejects lines starting with `//` or `*`. A multi-line block comment containing `requireAssistantSurface("coach")` on a line that does NOT start with `*` (e.g. a code-fence inside a `/* ... */` JSDoc) would falsely satisfy the gate-presence check, letting an orphan slip through. Equally, an actual call wrapped in inline conditional `/* prettier-ignore */ requireAssistantSurface("coach");` is rejected.
|
||||
|
||||
The exposure is small (no current Coach route ships either pattern) but the heuristic is brittle.
|
||||
|
||||
Recommended fix: lean on the TypeScript AST (`ts.createSourceFile` + walker for `CallExpression`) instead of string-matching lines. The Vitest harness already loads the TS compiler.
|
||||
|
||||
---
|
||||
|
||||
## Low
|
||||
|
||||
### L-1 — `health-score-fast-path` `bpInTargetPctPriorWeek` `undefined` vs `null` semantics
|
||||
|
||||
File: `src/lib/analytics/health-score-fast-path.ts:140-143`
|
||||
|
||||
Concern: the helper distinguishes `undefined` (legacy caller — pin to current value) from `null` (caller knows there's no prior-week data — feed null through). The check `input.bpInTargetPctPriorWeek === undefined` is tight, but the surrounding type `bpInTargetPctPriorWeek?: number | null` allows both. A caller doing `{ ...x, bpInTargetPctPriorWeek: undefined }` after destructuring loses the legacy-compat path silently. Today this works correctly because the analytics route either passes a real number or `null`, never `undefined` explicitly.
|
||||
|
||||
Recommended fix: add a JSDoc explicit note on the field that `undefined` = legacy mode, `null` = no-prior-data; also add a unit test for both modes.
|
||||
|
||||
### L-2 — `geo-backfill` in-process singleton flag is module-scope but unprotected on hot-reload
|
||||
|
||||
File: `src/lib/jobs/reminder-worker.ts:1208-1238`
|
||||
|
||||
Concern: `let geoBackfillRunning = false;` is module-scope. In production this is per-process so the guard works. In dev with Next.js HMR or Vitest watch mode, a module reload between runs could reset the flag while a previous pass is still spinning. Test coverage doesn't pin this. The exposure is dev-only (production never HMRs) but the comment "fans the second invocation out as a no-op" reads as a stronger guarantee than the implementation actually offers.
|
||||
|
||||
Recommended fix: short note in the comment that the guard is per-Node-process and HMR resets it. No code change needed.
|
||||
|
||||
### M-1 fix-overlap: `correlations-fast-path` annotate emits `tz_guard` only on success path
|
||||
|
||||
File: `src/lib/analytics/correlations-fast-path.ts:311-326`
|
||||
|
||||
Concern: the annotate dict carrying `tz_guard` is built into the final `annotate({ meta: { correlations: { ... } } })` call. If an exception throws before line 311 (any of the Promise.all rejections, the rollup read), the annotate never fires, and ops logs cannot tell whether the failed request was a near-UTC or non-UTC user. The bp-in-target helper has the same shape (annotate at the END of each branch).
|
||||
|
||||
Recommended fix: emit a `tz_guard_attempted` annotate as the first line of the helper (right after computing `userNearUtc`), so even a partial run carries the guard decision into the wide-event pipeline.
|
||||
|
||||
### L-4 — `analytics/route.ts` line 173 fire-and-forget on `ensureUserRollupsFresh` swallows errors invisibly to route
|
||||
|
||||
File: `src/app/api/analytics/route.ts:173`
|
||||
|
||||
Concern: `void ensureUserRollupsFresh(user.id);` — the helper handles its own errors (annotate + console.error per the v1.4.36 H3 retrofit). But the route's annotate context is request-scoped; the populator's annotate fires in a detached microtask after the request has likely already resolved. The current `annotate` no-op fallback when no context is present is documented inside the helper, but the route-side observer reading wide events won't see the populator failure attributed to this request — it'll appear as an unattributed log line.
|
||||
|
||||
Recommended fix: change the `void` call to attach to the response Promise via `Promise.race([routeWork, populator.catch(noop)])` — keeps the fire-and-forget contract but the populator's annotate now fires within the route's logging context.
|
||||
|
||||
### L-5 — `localDayWindow` uses string-concat for next-day key derivation
|
||||
|
||||
File: `src/lib/measurements/drain-per-sample-cumulative.ts:205-218`
|
||||
|
||||
Concern: `nextUtcNoon.setUTCDate(nextUtcNoon.getUTCDate() + 1)` then `.toISOString().slice(0, 10)` — works because noon is mid-day so DST shifts don't cross the date boundary. The comment is explicit about why noon is the anchor. Brittle to "let's anchor on midnight" refactors.
|
||||
|
||||
Recommended fix: pull the next-day-key helper into a tiny named function `addOneDayUtc(dateKey: string): string` with a docstring on the noon-anchor invariant, so the calculation isn't inline.
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting Concerns
|
||||
|
||||
1. **Commit-attribution drift documented per wave** — the kickoff brief flags that bundled commits will collapse on squash-to-main. No code-quality issue; surfaced here so the merge author knows to expect noise in `--author` filtering of the develop log.
|
||||
|
||||
2. **Naming consistency: "fast-path" vs "fast-path"** — three fast-path helpers (`bp-in-target-fast-path`, `correlations-fast-path`, `health-score-fast-path`) all live under `src/lib/analytics/` but each has its own dispatch shape (some take `coverage?`, some don't; some take `userTz`, some don't; the prior-week BP pct is a one-off field on health-score only). A future refactor to a shared `FastPathContext` interface would tighten the surface; deferred to a v1.5 follow-up since the contract is correct today.
|
||||
|
||||
3. **Test mock convention drift** — `bp-in-target-fast-path.test.ts` uses inline `vi.mock` factories per dependency. `rollups.test.ts` uses the `vi.hoisted` pattern. Both work, but a single project-wide convention would help reviewers. Note for the test-style ADR backlog; not a release blocker.
|
||||
|
||||
4. **Annotate dict shape proliferation** — the W-F perf timings (`meta.dashboard.sub_*_ms`, `meta.insights.sub_*_ms`) are emitted alongside W-A's `tz_guard` field on `meta.analytics.bp_in_target` / `meta.correlations`. The wide-event consumer doesn't yet validate keyspaces. A meta-schema doc (or Zod schema) lives in `.planning/` is would prevent silent typos like `tz_gaurd` becoming a permanent prod field. Backlog candidate.
|
||||
|
||||
5. **Per-sub-query timing pattern is duplicated** — the `time(label, builder)` helper in `src/app/api/dashboard/summary/route.ts:288-293` and the `timeSubquery(timings, label, builder)` helper in `src/lib/insights/comprehensive-aggregator.ts:281-291` solve the same problem with slightly different signatures. Lift into `src/lib/logging/sub-query-timer.ts` so future routes pick up one canonical shape.
|
||||
|
||||
---
|
||||
|
||||
## Plan-Alignment Summary
|
||||
|
||||
| Wave | Plan deliverable | Implementation matches plan? | Notes |
|
||||
|------|------------------|------------------------------|-------|
|
||||
| W-A | Cross-tz fast-path guard | Yes | `isNearUtc` helper landed in `format.ts` (not `resolver.ts`) per the architecture comment; re-exported through `resolver.ts` for back-compat. Three fast-paths gated; analytics route threads userTz through both BP fan-outs. |
|
||||
| W-B | 14 robustness items | Yes — spot-checked 9 of 14 | drain const lift, drill-down 1000-cap validator, daysAgo derive-on-read, ensureUserRollupsFresh dedup, parallel WEEK/MONTH/YEAR enqueue, BP leap-year priorYear, bucketDayKey rename, IP `node:net.isIP`, dashboard medications staleTime — all match plan. |
|
||||
| W-C | Coach cascade test invariants + orphan-gate fix | Yes — and exceeded | Inventory test catches future regressions; 3 orphan gates added in same wave (chat GET/DELETE, message feedback POST). |
|
||||
| W-D | UX polish ~17 items | Not directly reviewed (UX/a11y lane) | Read the medication-intake quick-add for cache-sharing logic only. |
|
||||
| W-E | i18n 27% → 63% | Not directly reviewed (i18n lane) | |
|
||||
| W-F | dashboard/summary perf | Yes | DISTINCT ON + rollup buckets replace two unbounded findMany; per-sub-query timings; LRU wrap. |
|
||||
|
||||
No problematic plan deviations identified.
|
||||
|
||||
---
|
||||
|
||||
## Files referenced
|
||||
|
||||
- `/Users/marc/Projects/HealthLog/src/lib/tz/format.ts`
|
||||
- `/Users/marc/Projects/HealthLog/src/lib/analytics/bp-in-target-fast-path.ts`
|
||||
- `/Users/marc/Projects/HealthLog/src/lib/analytics/correlations-fast-path.ts`
|
||||
- `/Users/marc/Projects/HealthLog/src/lib/analytics/health-score-fast-path.ts`
|
||||
- `/Users/marc/Projects/HealthLog/src/lib/analytics/__tests__/bp-in-target-fast-path.test.ts`
|
||||
- `/Users/marc/Projects/HealthLog/src/lib/api-response.ts`
|
||||
- `/Users/marc/Projects/HealthLog/src/lib/__tests__/get-client-ip.test.ts`
|
||||
- `/Users/marc/Projects/HealthLog/src/lib/measurements/rollups.ts`
|
||||
- `/Users/marc/Projects/HealthLog/src/lib/measurements/__tests__/rollups.test.ts`
|
||||
- `/Users/marc/Projects/HealthLog/src/lib/measurements/drain-per-sample-cumulative.ts`
|
||||
- `/Users/marc/Projects/HealthLog/src/lib/jobs/geo-backfill.ts`
|
||||
- `/Users/marc/Projects/HealthLog/src/lib/jobs/reminder-worker.ts`
|
||||
- `/Users/marc/Projects/HealthLog/src/lib/insights/comprehensive-aggregator.ts`
|
||||
- `/Users/marc/Projects/HealthLog/src/lib/validations/measurement.ts`
|
||||
- `/Users/marc/Projects/HealthLog/src/lib/validations/__tests__/measurement.test.ts`
|
||||
- `/Users/marc/Projects/HealthLog/src/app/api/analytics/route.ts`
|
||||
- `/Users/marc/Projects/HealthLog/src/app/api/dashboard/summary/route.ts`
|
||||
- `/Users/marc/Projects/HealthLog/src/app/api/insights/chat/[id]/route.ts`
|
||||
- `/Users/marc/Projects/HealthLog/src/app/api/insights/chat/messages/[id]/feedback/route.ts`
|
||||
- `/Users/marc/Projects/HealthLog/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts`
|
||||
- `/Users/marc/Projects/HealthLog/src/components/dashboard/medication-intake-quick-add.tsx`
|
||||
@@ -0,0 +1,109 @@
|
||||
# W-QA-2 — v1.4.38 Security Audit
|
||||
|
||||
**Scope**: `v1.4.37.2..HEAD` on `develop`
|
||||
**Mode**: READ-ONLY, security lane
|
||||
**Date**: 2026-05-17
|
||||
|
||||
## Severity counts
|
||||
|
||||
| Severity | Count |
|
||||
|---|---|
|
||||
| Critical | 0 |
|
||||
| High | 0 |
|
||||
| Medium | 1 |
|
||||
| Low | 2 |
|
||||
| Informational | 1 |
|
||||
| Confirmed-safe | 7 |
|
||||
|
||||
---
|
||||
|
||||
## Medium
|
||||
|
||||
### M-1 — `User.timezone` accepted unvalidated; new SQL aggregate fails-open to PG error
|
||||
**Where**: `src/lib/auth/profile-update.ts:14` (`timezone: z.string().min(1).max(64).optional()`) → reaches `src/app/api/dashboard/summary/route.ts:374` (`to_char(m."measured_at" AT TIME ZONE ${userTz}, 'YYYY-MM-DD')`) and the cross-tz fast-path callers via `resolveUserTimezone`.
|
||||
|
||||
**Finding**: The profile-update schema validates the timezone string only as a 1–64 char string. The `isValidTimezone()` helper exists in `src/lib/tz/format.ts:22` and is invoked at **read** time (`resolveUserTimezone`, `resolveServerDefaultTimezone`), but **not** at the write path. A user can PUT `{ timezone: "garbage" }` to `/api/auth/profile` or PATCH `/api/user/profile` and the column will be persisted.
|
||||
|
||||
**Blast radius (v1.4.38-new)**:
|
||||
1. `GET /api/dashboard/summary` runs `prisma.$queryRaw` with `AT TIME ZONE ${userTz}` where `${userTz}` is bound as a parameter (so no classic SQL-injection), but Postgres will raise `invalid_parameter_value` for an unknown IANA zone → 500 response. Per-user self-DoS only (read-path own-user-only).
|
||||
2. The cross-tz fast-paths (`bp-in-target`, `correlations`) and every other consumer of `userDayKey()` fall through to `DEFAULT_TIMEZONE = "Europe/Berlin"` so they are resilient. The W-A `isNearUtc(userTz, now)` guard accepts invalid TZ → returns `true` → would force the rollup path for a Berlin-equivalent user — defensible per the helper's docstring but inconsistent with the SQL aggregate's hard-fail.
|
||||
|
||||
**Authorization-scope finding for W-A fallback**: the live-SQL fallback in `bp-in-target-fast-path.ts:425`, `correlations-fast-path.ts:338`, and `health-score-fast-path.ts:202` consistently scopes every `findMany` on `{ userId }`. No cross-tenant leakage path. The rollup path also scopes on `userId` in `readRollupBuckets`. The tz_guard is purely a performance/correctness toggle; it does not affect authorization. CONFIRMED-SAFE on the scope-leak question.
|
||||
|
||||
**Recommendation**: Add `.refine(isValidTimezone, "Invalid IANA timezone")` to the schema in `src/lib/auth/profile-update.ts:14`. Pre-tag fix is one line; the failing read becomes a 422 on the write instead of a self-DoS on the read.
|
||||
|
||||
---
|
||||
|
||||
## Low
|
||||
|
||||
### L-1 — `correlations-fast-path.ts` rollup path emits `degraded: false` unconditionally
|
||||
**Where**: `src/lib/analytics/correlations-fast-path.ts:319`
|
||||
|
||||
**Finding**: The `degraded` field is hard-coded `false` on both branches with a `TODO(v1.5)` annotation. Surface stability concern: a future load-shedding branch may flip the flag but the helper's `path` annotate is the only operator signal today. Not a security finding; called out as the only behavioral pin worth a doc-comment review pre-tag.
|
||||
|
||||
**Recommendation**: None pre-tag; the TODO is explicit (commit `41506c7a`).
|
||||
|
||||
### L-2 — Drill-down 422 cap can be bypassed by omitting `dayKey`
|
||||
**Where**: `src/lib/validations/measurement.ts:336`
|
||||
|
||||
**Finding**: Verified the Zod `.refine` triggers when `dayKey != null && limit > 1000` returns 422. Confirmed the route at `src/app/api/measurements/route.ts:107` reads `limit` straight through with no second clamp — relies entirely on the validator. The path is **un-bypassable** for `dayKey` requests. Caller asking `limit=999&dayKey=…` is allowed (within validator cap); `limit=1001&dayKey=…` fails-validation; `limit=5000` without `dayKey` takes the legacy 5000-row cap (intentional, FB-D2 windowed path). No bypass.
|
||||
|
||||
**Verified**: Test suite at `src/lib/validations/__tests__/measurement.test.ts:243-272` covers the boundary (1000 accepted, 1001 rejected).
|
||||
|
||||
---
|
||||
|
||||
## Informational
|
||||
|
||||
### I-1 — Cross-tz live-path fallback has the same `userId` scoping as rollup path (verified)
|
||||
The live fallbacks introduced in W-A are byte-for-byte equivalent on the auth surface — every `prisma.measurement.findMany` carries `{ userId, type, measuredAt: { gte: since } }`. The `userTz` parameter is consumed only for `userDayKey(measuredAt, userTz)` re-keying inside the in-process aggregation; it never reaches a WHERE clause or a Prisma-raw substitution at this layer. No scope-leak risk.
|
||||
|
||||
---
|
||||
|
||||
## Confirmed-safe
|
||||
|
||||
### CS-1 — W-C Coach API gate inventory (route-discovery test)
|
||||
- `GET/DELETE /api/insights/chat/[id]` (lines 32, 55) and `POST /api/insights/chat/messages/[id]/feedback` (line 63) now call `requireAssistantSurface("coach")` after `requireAuth` and **before** any data access or rate-limit work.
|
||||
- The discovery test at `src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts` (commit `1eb00389` + comment-skip refinement `5ecc3152`) walks every `route.ts` under `/api/insights/**` and groups handlers into Coach-gated / sibling-gated / non-Coach-owned. Orphans fail by name. The non-Coach allowlist (provider-chain, settings, feedback, glp1-timeline, targets) is documented inline with rationale.
|
||||
- The `apiHandler` catch at `src/lib/api-handler.ts:166` maps `AssistantDisabledError` → 403 + `{ data: null, error, meta: { errorCode: "assistant.disabled.coach" } }` — locked iOS contract. Verified the catch fires before `reportToGlitchtip`, so a disabled-surface 403 does **not** noisy-alert ops.
|
||||
- Manual sweep of all 18 routes under `/api/insights/` confirms every Coach-owned handler (`chat/route.ts`, `chat/[id]/route.ts`, `chat/messages/[id]/feedback/route.ts`, `comprehensive/route.ts`, `generate/route.ts`) carries the gate; no orphans.
|
||||
|
||||
### CS-2 — W-B `looksLikeIp` strictness (node:net.isIP)
|
||||
- `src/lib/api-response.ts:100` uses `isIP(s) !== 0` (returns 4/6 for valid v4/v6, 0 for invalid). Test coverage at `src/lib/__tests__/get-client-ip.test.ts:109-136` pins:
|
||||
- rejects `1.2`, `:::`, `gg:hh::1` (previously accepted by the hex/dot regex);
|
||||
- **accepts** `2001:db8::1` (well-formed IPv6);
|
||||
- accepts every well-formed IPv4 chain entry.
|
||||
- The `cf-connecting-ip` branch (`readCfConnectingIp`, line 116) is correctly gated on `TRUST_CF_CONNECTING_IP === "1"` (exact string match — `"true"` and `"yes"` correctly rejected, per test line 215). Behind the flag, the trimmed header is fed through the same `looksLikeIp` validator before being returned. No regression for valid IPv4/IPv6 visitors.
|
||||
|
||||
### CS-3 — W-B drill-down cap via Zod refine
|
||||
See L-2 above. Cannot be bypassed via `limit=999&dayKey=…` (within cap); cannot be bypassed via wider `limit` without `dayKey` (legacy 5000 cap applies to the windowed chart-data path, not the drill-down).
|
||||
|
||||
### CS-4 — W-A cross-tz fallback authorization parity
|
||||
See I-1. Live and rollup paths use identical `{ userId }` scoping. The `tz_guard` annotate exposes only the branch decision, not user data.
|
||||
|
||||
### CS-5 — W-F medication-intake cache invalidation completeness
|
||||
- `invalidateUserMedications` (`src/lib/cache/invalidate.ts:63`) now evicts `caches.analytics.deleteByPrefix(\`${userId}|\`)` in addition to the medication-specific caches (commit `f2f1e5f0` + the v1.4.38 W-F line 74 addition).
|
||||
- Verified call-sites in every medication write path: `/api/medications/route.ts`, `/api/medications/intake/route.ts`, `/api/medications/intake/bulk/route.ts`, `/api/medications/[id]/route.ts`, `/api/medications/[id]/intake/route.ts`, `/api/medications/[id]/intake/[eventId]/route.ts`.
|
||||
- Mood (`invalidateUserMood`) and measurements (`invalidateUserMeasurements`) **already** evict `caches.analytics` (lines 31, 51). Verified write paths: `/api/mood-entries/route.ts:130`, `/api/mood-entries/bulk/route.ts:218`, `/api/mood-entries/[id]/route.ts:113,145`, `/api/measurements/route.ts:472,549`, `/api/measurements/batch/route.ts:379`, `/api/measurements/by-external-ids/route.ts:124`, `/api/measurements/[id]/route.ts:114,176`, `/api/workouts/batch/route.ts:532`.
|
||||
- The dashboard/summary cache (under `caches.analytics`) is therefore consistently invalidated by every measurement, mood, AND medication write path. No missing hook.
|
||||
|
||||
### CS-6 — W-E i18n no AI/Claude/marathon/phase leak in es/fr/it/pl
|
||||
- The German source uses `phase*` keys exclusively for the reminder-phase domain term (`phaseGreen/Yellow/Orange/Red`, `phaseBeforeEnd`, `phaseAfterEnd`); all four new-locale translations follow the same domain pattern. No release-jargon leak.
|
||||
- The only `Claude` strings in es/fr/it/pl appear inside the operator-facing `"anthropic": "Anthropic (Claude)"` settings dropdown label — intentional (operator picks the brand), matches the German source one-for-one.
|
||||
- The Italian `ai` matches are the Italian preposition "a" + "i" ("ai farmaci" = "to the medications"), not the English "AI" acronym.
|
||||
- Settings-page `"ai"` key (line 1622, 1769, 2326) is a path key, not display copy.
|
||||
- No `Claude/claude/AI assistant/marathon/phase X` user-facing leaks introduced by the W-E commits.
|
||||
|
||||
### CS-7 — v1.4.37 security posture regression check
|
||||
- `src/proxy.ts` (HSTS + CSP + TRUST_CF_CONNECTING_IP plumbing) and `src/lib/auth/session.ts` (session destruction): **unchanged** in `v1.4.37.2..HEAD` (`git diff` returns empty). v1.4.37 hardening preserved.
|
||||
- `ensureUserRollupsFresh` fire-and-forget pattern unchanged on the read path (in-flight dedup added at `src/lib/measurements/rollups.ts:582-622`, commit `4e284235` — purely a perf de-duplication, no security surface change). The `getEvent()?.addWarning` annotation introduced in commit `234fb723` for the swallowed error keeps the silent-failure invariant Marc documented.
|
||||
- `/.well-known/` and 404 fall-through behavior: untouched in scope.
|
||||
|
||||
---
|
||||
|
||||
## Most-important pre-tag finding
|
||||
|
||||
**M-1: timezone-write validation gap.** One-line fix in `src/lib/auth/profile-update.ts:14`: add `.refine(isValidTimezone, "Invalid IANA timezone")` (or chain to the existing schema). Without it, a user can self-DoS the dashboard/summary route post-v1.4.38 because the new `to_char(... AT TIME ZONE ${userTz}, ...)` aggregate hard-fails on an invalid zone where the legacy JS path silently fell back. Single-user blast radius (own request only — no cross-tenant), but it's a regression introduced by the W-F SQL rewrite and worth catching before tag.
|
||||
|
||||
## v1.4.37 posture regression: none
|
||||
|
||||
All seven items confirmed unchanged: session destruction, `/.well-known/`, HSTS, CSP, TRUST_CF_CONNECTING_IP, `ensureUserRollupsFresh` fire-and-forget, and the Coach 403 envelope contract.
|
||||
@@ -0,0 +1,314 @@
|
||||
# v1.4.38 W-QA-3 — UX / responsive / a11y findings
|
||||
|
||||
Scope: `v1.4.37.2..HEAD` on `develop`. READ-ONLY review. Live site is
|
||||
v1.4.37.2; develop holds W-A through W-F. Findings are graded P0 →
|
||||
P3 against shipping the v1.4.38 tag.
|
||||
|
||||
Severity legend:
|
||||
- **P0** — must fix before tag (correctness / a11y break / user-data
|
||||
visible bug)
|
||||
- **P1** — should fix before tag (UX regression on a primary surface or
|
||||
i18n string break)
|
||||
- **P2** — fix in v1.4.38.x (polish; not blocking)
|
||||
- **P3** — nice-to-have / log for v1.4.39
|
||||
|
||||
Summary counts:
|
||||
- P0: 1
|
||||
- P1: 2
|
||||
- P2: 3
|
||||
- P3: 4
|
||||
|
||||
---
|
||||
|
||||
## P0 — must fix before tag
|
||||
|
||||
### P0-1 — i18n placeholder break in 3 locales: `medications.glp1NextInjectionDays`
|
||||
|
||||
**File:** `messages/{fr,it,pl}.json` (key `medications.glp1NextInjectionDays`)
|
||||
|
||||
The W-E i18n wave (commit `d95820ab`) translated the GLP-1 cluster but
|
||||
the merged value is a **broken hybrid string** — half-translated label
|
||||
joined to a still-English `(in {days} days)` tail:
|
||||
|
||||
- `fr`: `"Suivant:{label} (in {days} days)"`
|
||||
- `it`: `"Avanti:{label} (in {days} days)"`
|
||||
- `pl`: `"Dalej:{label} (in {days} days)"`
|
||||
|
||||
Expected shape (mirrors the German source):
|
||||
- `de`: `"Nächste:{label} (in {days} Tagen)"`
|
||||
- `en`: `"Next:{label} (in {days} days)"`
|
||||
|
||||
User impact:
|
||||
- `Glp1MedicationCard.nextInjectionLabel()` (`src/components/medications/glp1-medication-card.tsx:301-305`)
|
||||
is the consumer. When a GLP-1 medication's next injection is ≥ 2
|
||||
days away (the modal case for weekly drugs) the user sees a mid-
|
||||
string English fragment inside otherwise fully-localised French /
|
||||
Italian / Polish copy.
|
||||
- Marc-Voice violation: ships as English literal inside three locales.
|
||||
- Also the missing space after the colon (`Suivant:{label}` →
|
||||
`Suivant : {label}` would be the French typographic convention) is a
|
||||
secondary nit but the **English-tail leak is the blocker**.
|
||||
|
||||
The W-E report tested `i18n-drift-guard` and `quick-add-labels` but the
|
||||
GLP-1-NextInjectionDays key has no contract test pinning placeholder
|
||||
shape; the hybrid passed silently. Recommend a 4th test in
|
||||
`quick-add-labels` style for the GLP-1 cluster as the follow-up.
|
||||
|
||||
Suggested fix (one-line per locale):
|
||||
- `fr`: `"Suivant : {label} (dans {days} jours)"`
|
||||
- `it`: `"Prossima: {label} (tra {days} giorni)"`
|
||||
- `pl`: `"Następne: {label} (za {days} dni)"`
|
||||
|
||||
---
|
||||
|
||||
## P1 — should fix before tag
|
||||
|
||||
### P1-1 — Polish `compliance7d` divergence: parenthesised vs. unparenthesised duplicate
|
||||
|
||||
**File:** `messages/{fr,it,pl}.json`
|
||||
|
||||
Grep confirms two competing renderings of the same key shipped across
|
||||
the i18n waves:
|
||||
|
||||
- `compliance7d` appears twice in `pl.json` (lines differ):
|
||||
- `"Przestrzeganie (7 dni)"` (with parens)
|
||||
- `"Przestrzeganie 7 dni"` (without)
|
||||
- Same drift in `fr.json` (`"Observance (7 jours)"` vs `"Observance
|
||||
7 jours"`) and `it.json` (`"Aderenza (7 giorni)"` vs `"Aderenza 7
|
||||
giorni"`).
|
||||
|
||||
Likely cause: two separate W-E commits (`03952deb` medications status
|
||||
chips and `b776daf5` insights health-score / Coach) each added their
|
||||
own version. JSON parser keeps the **second** declaration of a
|
||||
duplicate key, so the user sees the second value at runtime and the
|
||||
first one is dead — but the JSON file is technically malformed
|
||||
(RFC-8259 says "objects SHOULD have unique names").
|
||||
|
||||
Action:
|
||||
1. De-duplicate. Pick the parenthesised form for the medication
|
||||
compliance bar (matches DE `"Compliance (7d)"` rhythm) and drop the
|
||||
parenless siblings.
|
||||
2. Add a `i18n-locale-integrity` assertion that flags duplicate keys
|
||||
within a JSON tree (currently only flags missing/extra keys).
|
||||
|
||||
### P1-2 — Coach gated routes: client error-mapping has no `assistant.disabled.coach` branch
|
||||
|
||||
**Files:**
|
||||
- `src/components/insights/coach-panel/use-coach.ts:359-407`
|
||||
- `src/components/insights/coach-panel/coach-drawer.tsx`
|
||||
- `messages/{de,en}.json` — no `coach.disabled` / `assistant.disabled`
|
||||
copy detected via grep
|
||||
- Backend: `src/app/api/insights/chat/[id]/route.ts:32,55` +
|
||||
`src/app/api/insights/chat/messages/[id]/feedback/route.ts:63`
|
||||
(W-C properly added `requireAssistantSurface("coach")`)
|
||||
|
||||
The W-C orphan-API gating is **correct on the server** — the 403
|
||||
envelope carries `meta.errorCode = "assistant.disabled.coach"`
|
||||
(`src/lib/feature-flags/index.ts:137-147`). But the client
|
||||
`useSendCoachMessage` send path reads `response.json().error` and falls
|
||||
through to `coach.http.403`:
|
||||
|
||||
```ts
|
||||
// use-coach.ts:380-401
|
||||
const envelope = (await response.clone().json()) as { error?: unknown };
|
||||
if (typeof envelope?.error === "string") {
|
||||
structured = envelope.error; // "assistant.disabled.coach"
|
||||
}
|
||||
setStreaming({ ..., errorCode: structured ?? `coach.http.${response.status}` });
|
||||
```
|
||||
|
||||
The errorCode is preserved on `streaming.errorCode`, but the drawer's
|
||||
error toast/copy table does **not** branch on `assistant.disabled.coach`
|
||||
— the envelope's `meta.errorCode` slot is also ignored (the client
|
||||
reads `.error`, the server emits the human code via `.error` AND the
|
||||
machine code via `.meta.errorCode`).
|
||||
|
||||
Result for the user scenario the task brief asked about (Coach OFF +
|
||||
stale client opens the drawer):
|
||||
- `GET /api/insights/chat` 403 → conversations list reads
|
||||
`coach.disabled.coach` as the error string → renders as a generic
|
||||
TanStack `isError` state (no copy)
|
||||
- POST attempts → drawer shows `coach.http.403` (literal token) or
|
||||
raw `assistant.disabled.coach` token, neither of which has a
|
||||
translation entry
|
||||
|
||||
User sees either: empty rail with no explanation, or a literal
|
||||
machine-code error string. Not a crash, but **not a clean operator-
|
||||
disabled message** either.
|
||||
|
||||
Recommend either:
|
||||
- (preferred) wire a `coach.disabled` / `assistant.disabled` copy key
|
||||
across `messages/{de,en,es,fr,it,pl}.json` and branch the drawer's
|
||||
error renderer on `errorCode === "assistant.disabled.coach"` to show
|
||||
it.
|
||||
- (fallback) gate the entire Coach drawer mount on a client-side flag
|
||||
hydrated from `/api/feature-flags` so a stale client never tries to
|
||||
POST.
|
||||
|
||||
---
|
||||
|
||||
## P2 — fix in v1.4.38.x
|
||||
|
||||
### P2-1 — `MeasurementList` drill-down `aria-controls` panel id is on `TableRow` (desktop)
|
||||
|
||||
**File:** `src/components/measurements/measurement-list.tsx:626`
|
||||
|
||||
W-D P1-1 (`1fd76f26`) added `aria-controls={drilldownId}` on the
|
||||
chevron button and `id={drilldownId}` on the disclosed `<TableRow>`.
|
||||
|
||||
Browser-side WAI-ARIA expects the controlled panel to be a region the
|
||||
AT can describe coherently; some screen readers (NVDA + VoiceOver
|
||||
Safari) skip `aria-controls` when the target is a `<tr>` because it
|
||||
lives inside the implicit `<tbody>` and doesn't expose a landmark.
|
||||
Mobile branch (line 754) wraps in a plain `<div>` which works.
|
||||
|
||||
Low-impact (the `aria-expanded` toggle still announces) but the
|
||||
desktop chevron's promise of "controls X panel" doesn't fully land on
|
||||
VoiceOver-Safari. Consider wrapping the drill-down `DayDrillDown` in
|
||||
a `<div role="region">` rendered inside the `<TableCell colSpan={6}>`
|
||||
and moving the `id` onto that wrapper.
|
||||
|
||||
### P2-2 — `quickAddMedicationIntake` translations diverge in semantic register
|
||||
|
||||
**Files:** `messages/{de,en,es,fr,it,pl}.json` key
|
||||
`dashboard.quickAddMedicationIntake`
|
||||
|
||||
- en: "Log medication intake"
|
||||
- de: "Einnahme erfassen" (short — W-D P1-3 fix)
|
||||
- es: "Registrar toma"
|
||||
- fr: "Saisir une prise"
|
||||
- it: "Registra assunzione"
|
||||
- pl: "Zarejestruj przyjęcie"
|
||||
|
||||
The 3-row dropdown menu in `page.tsx:586-606` reads top-to-bottom as
|
||||
"Messung / Stimmung / Einnahme erfassen" on DE — the verb is the
|
||||
SAME ("erfassen"). On FR the verbs differ ("Saisir une mesure" /
|
||||
"Saisir une humeur" / "Saisir une prise" — assumed; please verify the
|
||||
other two rows match `Saisir`). On ES, IT, PL the verb register is
|
||||
correct.
|
||||
|
||||
Not a translation **error**, but Marc-Voice asks each locale's menu to
|
||||
read with the same verb rhythm as DE/EN. Verify and align in v1.4.38.x.
|
||||
|
||||
### P2-3 — `DropdownMenuContent max-w-[calc(100vw-2rem)]` clipping risk
|
||||
|
||||
**File:** `src/app/page.tsx:577`
|
||||
|
||||
W-D P1-3 added `max-w-[calc(100vw-2rem)]` to the Hinzufügen dropdown.
|
||||
|
||||
On 320 px viewport (iPhone SE 1st gen):
|
||||
- `100vw - 2rem` = `320 - 32` = `288 px`
|
||||
- Polish "Zarejestruj przyjęcie" + `Pill` icon + `mr-2` gap +
|
||||
DropdownMenuItem internal `px-2` padding ≈ 260-270 px — fits.
|
||||
|
||||
On 280 px (Galaxy Fold folded):
|
||||
- max-w = `280 - 32` = `248 px`
|
||||
- Polish row at ≈ 270 px would clip / wrap.
|
||||
|
||||
Lucide icon + text wrap to a second line in the DropdownMenu (the
|
||||
shadcn default is `flex` not `flex-wrap`), so the row would still be
|
||||
readable but the icon would sit centred over a 2-line label —
|
||||
visually awkward. Acceptable for v1.4.38 (Galaxy Fold is < 0.1% of
|
||||
traffic) but worth a `whitespace-nowrap` audit pass before v1.5.
|
||||
|
||||
---
|
||||
|
||||
## P3 — log for v1.4.39
|
||||
|
||||
### P3-1 — Select trigger `pr-2` vs Safari date-input
|
||||
|
||||
**File:** `src/components/ui/select.tsx:56`
|
||||
|
||||
W-D P1-6 (`b6aefc67`) cut `pr-2.5` to `pr-2`. Confirmed visually
|
||||
consistent now on Chromium-Material AND Safari/Chromium-legacy
|
||||
gutters. No follow-up needed; logged so the v1.4.39 design-token sweep
|
||||
doesn't re-lift it.
|
||||
|
||||
### P3-2 — GLP-1 / generic medication card icon pill colour pairing
|
||||
|
||||
**Files:** `glp1-medication-card.tsx:413-422`,
|
||||
`medication-card.tsx:312-321`
|
||||
|
||||
W-D P2-3 applied `CircleCheck` / `AlertCircle` / `AlertTriangle`
|
||||
symmetrically to both cards. Verified parity. The icons are
|
||||
`size-3.5` (14 px) which is at the low end of the legibility floor —
|
||||
icons-paired-with-colour passes WCAG 1.4.1 regardless of icon size,
|
||||
but on a P3 OLED at 100% scaling the icon is barely distinguishable
|
||||
from the text glyph. Bump to `size-4` in v1.4.39.
|
||||
|
||||
### P3-3 — Dashboard sparkline DAY-bucket mean visual character
|
||||
|
||||
**File:** `src/app/api/dashboard/summary/route.ts:323-334`
|
||||
|
||||
W-F now reads `measurement_rollups.mean WHERE granularity='DAY'`
|
||||
instead of the raw measurement firehose. For sparklines (≤ 7 points,
|
||||
one per calendar day) this is **byte-identical** to the prior result
|
||||
for single-sample-per-day metrics (weight, BMI, BP) because the day's
|
||||
single sample IS the day's mean.
|
||||
|
||||
For high-frequency metrics (steps, sleep duration, active energy) the
|
||||
prior raw-data sparkline rendered the **last sample of the day** —
|
||||
the rollup change paints the **mean of the day**. Visually:
|
||||
- Steps: rollup mean of per-source step samples can read materially
|
||||
different from "the day's headline total". Steps is a CUMULATIVE
|
||||
type — the v1.4.37 W7c daily-sum collapse is what the user expects.
|
||||
Confirm `measurement_rollups.mean` for cumulative types is
|
||||
populated as a SUM-day-mean (i.e. one row per day with the day
|
||||
total) or rework the sparkline projection to use `r.sum` instead of
|
||||
`r.mean` for the CUMULATIVE_DAY_SUM_TYPES set.
|
||||
- Sleep: mean across a day reads close to the single nightly
|
||||
reading; safe.
|
||||
- Pulse: a meaningful smoothing — actually nicer than the raw last-
|
||||
sample noise.
|
||||
|
||||
**Investigation flag (not a defect yet):** verify the
|
||||
`measurement_rollups` aggregator emits `mean` correctly for
|
||||
CUMULATIVE_DAY_SUM_TYPES; if it emits true mean across per-hour
|
||||
chunks, the dashboard steps tile sparkline will read low compared to
|
||||
the daily-sum that the metric strip's headline value shows.
|
||||
|
||||
### P3-4 — JSON duplicate-key audit drift
|
||||
|
||||
The P1-1 duplicate `compliance7d` keys would have been caught by a
|
||||
linter; suggest adding a `pnpm test:i18n-uniqueness` step that walks
|
||||
`messages/*.json` and asserts each leaf path is declared exactly once.
|
||||
|
||||
---
|
||||
|
||||
## Re-confirmation of v1.4.37.x perf wins at the UI layer
|
||||
|
||||
- `HealthScoreCard` full-height: W-D P2-4 explicit no-op. Verified
|
||||
the `grid-rows-[auto_auto_auto_auto_auto_1fr_auto]` track holds.
|
||||
- Dropdown nowrap: W-D P1-3 applied via DE label shortening + max-w
|
||||
clamp. Confirmed.
|
||||
- Target card gap (`gap-3 md:gap-4`): W-D P1-5 no-op confirm. Status
|
||||
pill is on title row, not headline-adjacent. Confirmed.
|
||||
- Mood chart parity: not in this diff's scope; no regression
|
||||
introduced.
|
||||
- Dashboard button alignment (`items-center sm:items-start`): W-D
|
||||
P2-5 explicit no-op confirm. Behaviour preserved.
|
||||
|
||||
All v1.4.37.x perf wins **remain visible at the UI layer**; nothing
|
||||
in v1.4.38 reverses them.
|
||||
|
||||
---
|
||||
|
||||
## Files inspected
|
||||
|
||||
- `src/components/measurements/measurement-list.tsx`
|
||||
- `src/components/ui/select.tsx`
|
||||
- `src/components/medications/glp1-medication-card.tsx`
|
||||
- `src/components/medications/medication-card.tsx`
|
||||
- `src/components/insights/insight-status-card.tsx`
|
||||
- `src/components/onboarding/getting-started-checklist.tsx`
|
||||
- `src/components/settings/arztbericht-hero-card.tsx`
|
||||
- `src/components/dashboard/medication-intake-quick-add.tsx`
|
||||
- `src/app/page.tsx`
|
||||
- `src/app/api/insights/chat/[id]/route.ts`
|
||||
- `src/app/api/insights/chat/messages/[id]/feedback/route.ts`
|
||||
- `src/components/insights/coach-panel/use-coach.ts`
|
||||
- `src/lib/feature-flags/index.ts`
|
||||
- `src/app/api/dashboard/summary/route.ts`
|
||||
- `messages/{de,en,es,fr,it,pl}.json` (sampled)
|
||||
- `.planning/phase-W-D-v1438-ux-polish-report.md`
|
||||
- `.planning/phase-W-E-v1438-i18n-localization-report.md`
|
||||
@@ -0,0 +1,258 @@
|
||||
# W-QA4 — v1.4.38 architecture / data-integrity findings
|
||||
|
||||
Diff: `v1.4.37.2..HEAD` on `develop`, READ-ONLY architectural audit.
|
||||
Lane: data integrity, runtime correctness, cache / dedup semantics — no
|
||||
copy / UX / i18n review.
|
||||
|
||||
Severity legend:
|
||||
|
||||
- **CRITICAL** — would corrupt user data, leak the surface, or break the
|
||||
release on the canonical Berlin tenant.
|
||||
- **HIGH** — would mis-compute a number on a non-canonical tenant or
|
||||
silently regress under a realistic workload.
|
||||
- **MEDIUM** — edge case behind the current product, but worth a follow-up.
|
||||
- **LOW** — cosmetic / documentation / future-proofing.
|
||||
|
||||
---
|
||||
|
||||
## Severity counts
|
||||
|
||||
| Severity | Count |
|
||||
| ---------- | ----- |
|
||||
| CRITICAL | 0 |
|
||||
| HIGH | 0 |
|
||||
| MEDIUM | 3 |
|
||||
| LOW | 4 |
|
||||
|
||||
The diff is tight and the test coverage on the new helpers
|
||||
(`isNearUtc`, `ensureUserRollupsFresh` dedup, the validator refine on
|
||||
`dayKey + limit`, the `bp-in-target` priorYear cushion, the gate
|
||||
inventory) is genuinely good. No pre-tag blockers found.
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM-1 — Coach gate inventory walker only matches `route.ts`
|
||||
|
||||
**File:** `src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts:112`
|
||||
|
||||
The walker decides whether to inspect a file with
|
||||
`if (entry !== "route.ts") continue;`. Next.js App Router accepts
|
||||
`route.ts`, `route.tsx`, `route.js`, `route.jsx`, `route.mjs`, and
|
||||
`route.mts`. The directive in the user brief explicitly asked whether
|
||||
the walker handles `tsx / jsx / mts` variants — it does not.
|
||||
|
||||
In the current repo every insights route is `route.ts`, so this is not
|
||||
a live miss. But a future contributor landing
|
||||
`src/app/api/insights/<foo>/route.tsx` whose handler invokes the Coach
|
||||
stack would be invisible to the discovery test, defeating the whole
|
||||
point of the inventory. The matching `__tests__/` skip on
|
||||
line 107 IS correct.
|
||||
|
||||
Fix shape (no patch — READ-ONLY): match `entry.startsWith("route.")` +
|
||||
a closed-set extension check, or use a glob with the four valid
|
||||
extensions.
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM-2 — `ensureUserRollupsFresh` per-userId Map is per-process
|
||||
|
||||
**File:** `src/lib/measurements/rollups.ts:582-624`
|
||||
|
||||
The dedup map (`ensureUserRollupsFreshInFlight: Map<string, Promise>`)
|
||||
correctly:
|
||||
|
||||
- shares the in-flight promise across concurrent same-userId callers,
|
||||
- deletes the slot in `finally` so a rejection does not poison the
|
||||
cache (covered by tests at `rollups.test.ts:350-367`),
|
||||
- short-circuits the SQL fan-out down to one round-trip per probe
|
||||
(covered at `rollups.test.ts:302-331`).
|
||||
|
||||
Memory growth is **not** a concern — the map is bounded by the count of
|
||||
distinct userIds currently mid-recompute, and entries delete in
|
||||
`finally`. A stuck Promise would leak one entry per stuck user, but
|
||||
the inner work has its own try/catch that always resolves.
|
||||
|
||||
The multi-process caveat: HealthLog runs as a single Next.js server +
|
||||
a single pg-boss worker, so per-process dedup is sufficient today.
|
||||
If v1.5 horizontally scales the Next layer (multiple containers behind
|
||||
the load balancer), each container would have its own Map and a cold
|
||||
fan-out could still queue N parallel recomputes (N = container count,
|
||||
not request count). At the canonical 1-tenant scale this is academic;
|
||||
worth flagging in the v1.5 capacity plan if Marc ever scales out.
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM-3 — Dashboard sparkline has no rollup fallback
|
||||
|
||||
**File:** `src/app/api/dashboard/summary/route.ts:323-335`
|
||||
|
||||
The sparkline sub-query reads `measurement_rollups` directly with no
|
||||
live-fallback branch:
|
||||
|
||||
```sql
|
||||
SELECT r."type", r."bucket_start", r."mean"
|
||||
FROM measurement_rollups r
|
||||
WHERE r."user_id" = $userId
|
||||
AND r."granularity" = 'DAY'
|
||||
AND r."bucket_start" >= $sevenDaysAgo
|
||||
```
|
||||
|
||||
For a brand-new account or a freshly-restarted instance where the boot
|
||||
backfill (`enqueueBootTimeRollupBackfill`) has not yet folded the
|
||||
user's DAY buckets, this query returns `[]`, `sparkByType` is empty,
|
||||
and every emitted card has `sparkline: []`. The route still returns
|
||||
200 (no 500 risk — the brief asked about this), but the iOS tile
|
||||
paints a flat / missing sparkline until the worker catches up.
|
||||
|
||||
`latestIn7d` still reads `measurements` directly, so the headline
|
||||
`latestValue` is correct on cold mount. Only the sparkline trend is
|
||||
empty. That is degraded UX, not data corruption. v1.4.35.1's boot
|
||||
backfill closes the gap within minutes of first boot.
|
||||
|
||||
Acceptable for v1.4.38 release; document in the v1.5 plan that the
|
||||
sparkline sub-query could fall through to a single
|
||||
`date_trunc('day', measured_at) GROUP BY` against `measurements` when
|
||||
the rollup result is empty for a covered user.
|
||||
|
||||
---
|
||||
|
||||
## LOW-1 — `isNearUtc` ±3h boundary is inclusive
|
||||
|
||||
**File:** `src/lib/tz/format.ts:96-100`
|
||||
|
||||
`return Math.abs(offsetMinutes) <= 3 * 60` — inclusive of ±180 min.
|
||||
Behaviour:
|
||||
|
||||
- `Europe/Berlin` summer +120 min, winter +60 min → near-UTC year-round (correct).
|
||||
- `Europe/Moscow` +180 min year-round → near-UTC (boundary inclusive, intentional per test on line 281-286).
|
||||
- `Pacific/Honolulu` -600 min → NOT near-UTC → live fallback (correct).
|
||||
- `Asia/Tokyo` +540 min → NOT near-UTC (correct).
|
||||
- `Pacific/Auckland` +720/+780 min → NOT near-UTC (correct).
|
||||
- `Pacific/Kiritimati` +840 min (Kiribati, +14) → NOT near-UTC (correct, falls through to live path).
|
||||
|
||||
DST behaviour confirmed in tests at `resolver.test.ts:265-308` — the
|
||||
helper re-derives the offset per call via `tzOffsetMinutes(now, tz)` so
|
||||
a Berlin user crossing the March DST boundary sees +60 → +120 inside
|
||||
the same logical day and stays near-UTC on both sides. **No behavioural
|
||||
issue** — flagged LOW only because the inclusive boundary at exactly
|
||||
±180 means the Atlantic/Cape_Verde (-1h) and Atlantic/Azores (-1h
|
||||
winter / 0h summer) zones sit comfortably inside, but a future zone
|
||||
that drifts to ±181 would jump branches without warning. Not a fix —
|
||||
just a property to be aware of.
|
||||
|
||||
---
|
||||
|
||||
## LOW-2 — `recomputeBucketsForMeasurement` parallel enqueue is safe
|
||||
|
||||
**File:** `src/lib/measurements/rollups.ts:209-220`
|
||||
|
||||
The three `enqueueRollupRecompute` calls are independent — each carries
|
||||
a distinct `singletonKey` (`${userId}|${type}|${granularity}|${from}`)
|
||||
and `boss.send` has no shared state between calls. Fanning them out
|
||||
via `Promise.all` is correct.
|
||||
|
||||
The internal pg-boss connection pool handles concurrent sends fine;
|
||||
there's no shared-state contention. **No issue.** Test coverage at
|
||||
`rollups.test.ts:188-196` proves the three queue sends fire.
|
||||
|
||||
---
|
||||
|
||||
## LOW-3 — drill-down 1000-cap validator fires before route handler
|
||||
|
||||
**File:** `src/lib/validations/measurement.ts:336-340`, route at
|
||||
`src/app/api/measurements/route.ts:45-49`.
|
||||
|
||||
Confirmed: the route's first action after auth is
|
||||
|
||||
```
|
||||
const parsed = listMeasurementsSchema.safeParse(params);
|
||||
if (!parsed.success) return apiError(parsed.error.issues[0].message, 422);
|
||||
```
|
||||
|
||||
The refine is on the schema itself (top-level `.refine(({ limit, dayKey }) => …)`),
|
||||
so an oversized request returns 422 before any DB read. **Correctly
|
||||
gated.** The hard cap inside the route is gone (the old
|
||||
`Math.min(limit, 1000)` was removed).
|
||||
|
||||
---
|
||||
|
||||
## LOW-4 — Health-Score prior-week BP definition matches current week
|
||||
|
||||
**File:** `src/app/api/analytics/route.ts:387-415`,
|
||||
`src/lib/analytics/health-score-fast-path.ts:140-143`.
|
||||
|
||||
Confirmed: both `computeBpInTargetFastPath` invocations use the same
|
||||
`targets`, `coverage`, and `userTz` — only `now` differs (one at
|
||||
`now`, one at `now - 7d`). The `bp_in_target` predicate, the per-day
|
||||
mean pairing logic, and the rollup-vs-live branch decision are
|
||||
identical. The delta in the Health-Score helper is therefore a true
|
||||
week-over-week BP movement, not an apples-to-oranges comparison.
|
||||
|
||||
`bpInTargetPctPriorWeek` falls back to `bpInTargetPct` when undefined
|
||||
(legacy callers), preserving pre-v1.4.38 behaviour exactly (BP cancels
|
||||
out of the delta). **Correctly threaded.**
|
||||
|
||||
---
|
||||
|
||||
## Cache invalidation match — verified, no collision
|
||||
|
||||
**Files:** `src/app/api/dashboard/summary/route.ts:240`,
|
||||
`src/lib/cache/invalidate.ts:30-75`.
|
||||
|
||||
Route cache key: `${user.id}|dashboard-summary` on `caches.analytics`.
|
||||
|
||||
- `invalidateUserMeasurements` calls `caches.analytics.deleteByPrefix(\`${userId}|\`)` (line 31).
|
||||
- `invalidateUserMedications` calls the same (line 74, the v1.4.38 W-F addition).
|
||||
- `invalidateUserMood` calls the same (line 51).
|
||||
|
||||
The `${userId}|` prefix is unique per user; no collision risk between
|
||||
users (the pipe character guarantees a tenant boundary because Prisma
|
||||
cuid2 ids cannot contain `|`). **Both write hooks correctly evict the
|
||||
dashboard summary bucket.**
|
||||
|
||||
---
|
||||
|
||||
## Most-important pre-tag concern
|
||||
|
||||
**None.** All three MEDIUM findings are post-release follow-ups, not
|
||||
blockers. The Coach route inventory works for every file in the repo
|
||||
today; the in-flight dedup map is single-process-correct for the
|
||||
current deploy shape; and the sparkline empty-on-cold-rollup degrades
|
||||
to a flat line, not a 500.
|
||||
|
||||
The release is architecturally clean.
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting recommendation for v1.5
|
||||
|
||||
The repeated "rollup-fast-path + isNearUtc guard + live fallback"
|
||||
shape now exists in three places — `bp-in-target-fast-path.ts`,
|
||||
`correlations-fast-path.ts`, and (implicitly via the sparkline empty
|
||||
case) `dashboard/summary/route.ts`. A fourth instance is one
|
||||
follow-up away (Health-Score weight pillar already has an
|
||||
`isFullyCovered` branch but no `isNearUtc` guard because the
|
||||
underlying weight series is sampled, not bucketed).
|
||||
|
||||
v1.5 should extract the branch decision into a single helper:
|
||||
|
||||
```ts
|
||||
type RollupReadMode = "rollup" | "live-near-utc-fallback" | "live-coverage-fallback";
|
||||
function decideRollupReadMode(args: {
|
||||
coverage: RollupCoverageMap;
|
||||
requiredTypes: MeasurementType[];
|
||||
userTz: string;
|
||||
now: Date;
|
||||
}): RollupReadMode;
|
||||
```
|
||||
|
||||
— so every consumer dispatches through the same gate and a future
|
||||
v1.5 enhancement (per-user-TZ rollup buckets, removing the ±3h
|
||||
guard) flips behaviour in one place. Today each helper duplicates
|
||||
~6 lines of branch logic and the `meta.*.path` annotate is
|
||||
copy-pasted three times.
|
||||
|
||||
The same refactor should thread `userTz` into `readRollupBuckets`
|
||||
itself so the rollup table can address local-day buckets — that is
|
||||
the v1.5 follow-up the bp-in-target comment at line 142 explicitly
|
||||
calls out.
|
||||
@@ -0,0 +1,165 @@
|
||||
# Phase W-QA-6 — v1.4.38 i18n Runtime Parity Audit
|
||||
|
||||
Scope: `messages/{de,en,es,fr,it,pl}.json` after W-E wave.
|
||||
Diff range: `v1.4.37.2..HEAD` (develop, snapshot 2026-05-17).
|
||||
Mode: READ-ONLY i18n parity audit.
|
||||
|
||||
---
|
||||
|
||||
## Headline numbers
|
||||
|
||||
| Locale | Keys | == DE (untranslated source leak) | == EN (fallback to English) |
|
||||
| ------ | ---: | -------------------------------: | --------------------------: |
|
||||
| de | 2427 | — | 6.8% (English-only product strings carried in DE file) |
|
||||
| en | 2427 | 166 (6.8%) | — |
|
||||
| es | 2427 | 104 (4.3%) | **897 (37.0%)** |
|
||||
| fr | 2427 | 116 (4.8%) | **948 (39.1%)** |
|
||||
| it | 2427 | 118 (4.9%) | **915 (37.7%)** |
|
||||
| pl | 2427 | 119 (4.9%) | **898 (37.0%)** |
|
||||
|
||||
Structural parity: **PASS** — every locale carries the identical 2427-key tree (0 missing, 0 extra vs DE).
|
||||
UTF-8 / mojibake scan: **PASS** — 0 mojibake sequences across all six locales; 50-string diacritic spot-checks all round-trip cleanly.
|
||||
|
||||
---
|
||||
|
||||
## Critical
|
||||
|
||||
_None._ No blockers for shipping; placeholders fail safe (TS lint would have caught hard breaks).
|
||||
|
||||
---
|
||||
|
||||
## High
|
||||
|
||||
### H-1. ICU placeholder dropped — pagination string loses `{count}` in es/fr/it/pl
|
||||
|
||||
`medications.intakeHistoryPageInfo`
|
||||
|
||||
- DE: `Seite {page} von {total} · {count} Einträge`
|
||||
- ES: `Página {page} de {total}`
|
||||
- FR: `Page {page} sur {total}`
|
||||
- IT: `Pagina {page} di {total}`
|
||||
- PL: `Strona {page} z {total}`
|
||||
|
||||
All four W-E translations dropped the `{count} Einträge` clause. Users on those locales lose the per-page entry count. next-intl will silently ignore the missing var but the UI parity vs DE/EN breaks.
|
||||
|
||||
**Fix:** re-append the count clause in all four locales. Suggested:
|
||||
- es: `Página {page} de {total} · {count} registros`
|
||||
- fr: `Page {page} sur {total} · {count} entrées`
|
||||
- it: `Pagina {page} di {total} · {count} voci`
|
||||
- pl: `Strona {page} z {total} · {count} pozycji`
|
||||
|
||||
### H-2. ICU placeholder dropped — weekday aria-labels lose `{day}` in es/fr/it/pl
|
||||
|
||||
`medications.dayActivate` / `medications.dayDeactivate`
|
||||
|
||||
- DE: `{day} aktivieren` / `{day} deaktivieren`
|
||||
- ES: `Activar día` / `Desactivar día`
|
||||
- FR: `Activer le jour` / `Désactiver le jour`
|
||||
- IT: `Attiva giorno` / `Disattiva giorno`
|
||||
- PL: `Włącz dzień` / `Wyłącz dzień`
|
||||
|
||||
These are a11y labels per weekday chip; without `{day}` interpolation, every chip announces the same generic "Activate day" to screen-reader users instead of "Activate Monday / Tuesday / …".
|
||||
|
||||
**Fix:** restore `{day}` in all four:
|
||||
- es: `Activar {day}` / `Desactivar {day}`
|
||||
- fr: `Activer {day}` / `Désactiver {day}`
|
||||
- it: `Attiva {day}` / `Disattiva {day}`
|
||||
- pl: `Włącz {day}` / `Wyłącz {day}`
|
||||
|
||||
---
|
||||
|
||||
## Medium
|
||||
|
||||
### M-1. ES — blood-pressure term split (`tensión` vs `presión`)
|
||||
|
||||
`Blutdruck` is rendered two different ways in `es.json`:
|
||||
|
||||
- 16 keys use **`tensión arterial`** (e.g. `doctorReport.bpClassificationTitle`, `doctorReport.avgBp`, `doctorReport.typeBpSys`, `doctorReport.typeBpDia`, `measurements.subtitle`).
|
||||
- 11 keys use **`presión arterial`** (e.g. `dashboard.bloodPressure`, `measurements.typeBloodPressure`, `medications.categoryBloodPressure`, `insights.coach.metric.bp`, `doctorReport.sections.bp`).
|
||||
|
||||
Both are clinically valid in Spanish, but the same metric should read one way across dashboard, doctor report, and Coach. Recommend choosing **`presión arterial`** (medically dominant, matches WHO Spanish guidelines) and unifying the 16 `tensión` keys to it.
|
||||
|
||||
### M-2. PL — blood-pressure term split (`ciśnienie tętnicze` vs `ciśnienie krwi`)
|
||||
|
||||
- 6 keys use **`ciśnienie tętnicze`** (clinical: `doctorReport.bpClassificationTitle`, `doctorReport.avgBp`, `measurements.subtitle`).
|
||||
- 9 keys use **`ciśnienie krwi`** (lay: `doctorReport.sections.bp`, `dashboard.bloodPressure`, `measurements.typeBloodPressure`).
|
||||
|
||||
The clinical sections (doctor report) say `tętnicze` while the same doctor report's section heading says `krwi`. Unify to **`ciśnienie tętnicze`** in clinical / doctor-report contexts; keep `ciśnienie krwi` only if intentional in casual dashboard copy. Currently the inconsistency reads as a translation oversight.
|
||||
|
||||
### M-3. PL — pulse term split (`tętno` vs `puls`)
|
||||
|
||||
20 keys use `tętno`, 11 keys still carry the German fallback `puls` (lowercase substring match). Most of the 11 are likely DE/EN-fallback strings rather than active mistranslations, but worth a sweep — `puls` is also a Polish word so it does not look obviously wrong, just inconsistent. Unify on **`tętno`** to match the dominant choice.
|
||||
|
||||
### M-4. EN-fallback remains 37–39% across es/fr/it/pl — T3 surface
|
||||
|
||||
W-E correctly excluded the heavy `admin`, `settings`, and `achievements` namespaces. The leftover EN-fallback breaks down (top 4 namespaces):
|
||||
|
||||
| Locale | admin | settings | achievements | medications |
|
||||
| -----: | ----: | -------: | -----------: | ----------: |
|
||||
| es | 337 | 328 | 149 | 30 |
|
||||
| fr | 341 | 329 | 149 | 41 |
|
||||
| it | 340 | 328 | 149 | 37 |
|
||||
| pl | 340 | 328 | 149 | 31 |
|
||||
|
||||
`admin` + `settings` are documented as intentional T3 deferral (developer/operator surface, low user impact). `achievements` (149 keys × 4 locales) is the largest remaining user-facing gap. The smaller per-locale residuals (medications, insights, doctorReport, charts) are 4-41 strings each — quick to mop up in a follow-up wave.
|
||||
|
||||
**Action:** add an entry to `.planning/v1.4.38-backlog.md` documenting the T3 gap explicitly: "admin/settings/achievements + ~30-40 per-locale stragglers fall back to EN; tracked, not blocking."
|
||||
|
||||
---
|
||||
|
||||
## Low
|
||||
|
||||
### L-1. IT — longest strings approach 320 px clip threshold
|
||||
|
||||
Six IT strings in the dashboard / insights surface run > 35 chars and > 1.15× the DE source. None exceed practical button widths, but two warrant a visual check at 320 px viewport:
|
||||
|
||||
- `insights.hrv.chartTitle` — IT `Variabilità della frequenza cardiaca` (36 chars vs DE 21).
|
||||
- `measurements.subtitle` — IT `Peso, pressione arteriosa, pulsazioni e altro` (45 chars vs DE 33). Used as a subtitle, normally low clip risk.
|
||||
|
||||
Recommend a 320 px Playwright snapshot on `/insights` and `/measurements` in IT before release; if clipping shows up, shorten to `HRV` or `Variabilità FC`.
|
||||
|
||||
### L-2. FR — `fr.insights.trendAnnotation.pendingLabel` is 46 chars
|
||||
|
||||
`Commentaire de tendance en cours de génération` vs DE 30 chars. Used as a pill on the trend card. Shorten suggestion: `Commentaire en cours…`.
|
||||
|
||||
### L-3. ES — empty-state titles run 36–40 chars
|
||||
|
||||
Several `insights.emptyState.*.title` and `mood.emptyTitle/emptyFilteredTitle` in ES are 35-40 chars. Empty-state cards have more horizontal room than buttons, so clipping risk is low, but H1 wrap can look awkward on 320 px. Recommend visual spot-check; no edit needed unless wrap is ugly.
|
||||
|
||||
### L-4. ChatGPT / OpenAI brand mentions in `settings.*`
|
||||
|
||||
26 keys per locale contain `ChatGPT` / `OpenAI`. These are correct provider labels in the AI-settings panel and not Marc-Voice violations. The user-facing **Coach** / **Insights** strings contain no leaked `Claude`, `Anthropic`, `LLM`, `hallucinate`, `marathon`, `wave`, or `phase` language anywhere across all six locales. **PASS** on Marc-Voice register.
|
||||
|
||||
### L-5. EN file still carries 166 untranslated DE strings
|
||||
|
||||
6.8% of `en.json` is identical to `de.json`. These are mostly proper nouns (medication names, brand strings) and one technical leftover (`measurements.typePulseWaveVelocity: Pulse-wave velocity` — actually English, just happens to == DE). Not a regression, but worth a one-off pass at next opportunity to confirm none are genuine EN gaps.
|
||||
|
||||
---
|
||||
|
||||
## What was checked and passed cleanly
|
||||
|
||||
- Structural parity (key sets identical across all 6 locales) — **PASS**
|
||||
- UTF-8 / no mojibake (`ü`, `ó`, `â€`, smart-quote bleed, etc.) — **PASS** in all 6
|
||||
- ICU plural / select preservation — N/A (DE source carries 0 ICU `plural`/`select` blocks; all plurals are handled in TS via `intl.formatNumber` + helper components)
|
||||
- Marc-Voice register: no `Claude / Anthropic / LLM / hallucinate / marathon / phase / wave / AI-agent` in any user-facing string in any locale — **PASS**
|
||||
- FR `tension artérielle` (19/19) and IT `pressione arteriosa` (18/18) — fully consistent
|
||||
- IT `Puls` leftover scan — **PASS** (counter false-positive earlier; no leftover German Puls in IT)
|
||||
|
||||
---
|
||||
|
||||
## Severity counts
|
||||
|
||||
- Critical: 0
|
||||
- High: 2
|
||||
- Medium: 4
|
||||
- Low: 5
|
||||
|
||||
---
|
||||
|
||||
## Recommended fix order
|
||||
|
||||
1. **H-1 + H-2** (placeholder restores, 12 strings total) — single i18n edit pass, ship in next patch.
|
||||
2. **M-1 + M-2** (ES/PL BP-term unification) — quick global replace.
|
||||
3. **M-4** (document T3 gap in v1.4.38-backlog).
|
||||
4. **L-1 + L-2** Playwright 320 px snapshot pass in IT + FR before release.
|
||||
5. Defer **M-3, L-3, L-4, L-5** to v1.4.39 or later.
|
||||
@@ -0,0 +1,122 @@
|
||||
# v1.4.38 Marathon — full backlog sweep + perf hotspots
|
||||
|
||||
**Started:** 2026-05-17 (immediately after v1.4.37.2 deploy verify)
|
||||
**Mandate:** Marc directive — "Ich möchte jetzt echt den Deckel drauf kriegen und nichts jetzt auf ein v1.4.39 Release mehr ausschieben, sondern ich möchte, dass das jetzt alles da drinnen ist."
|
||||
**Versioning:** **v1.4.38** (patch — additive + fixes + perf, no breaking change).
|
||||
|
||||
## Scope
|
||||
|
||||
Everything in `.planning/round-v1438-backlog.md` PLUS the two perf hotspots
|
||||
surfaced during v1.4.37.x verification:
|
||||
|
||||
1. `/api/insights/comprehensive` cold-mount ~3.4 s — likely same shape
|
||||
as the v1.4.37.2 slim-summaries bug (over-fetch then JS-aggregate)
|
||||
2. `/api/dashboard/summary` cold-mount ~4.6 s — iOS aggregator, big
|
||||
parallel queries on `measurements` without rollup-side aggregation
|
||||
|
||||
## Default Picks (autonomous — no Marc-approval wait)
|
||||
|
||||
- **i18n volle Lokalisierung:** Option A (T1 mandatory + T2 high-value
|
||||
medical/health domain copy). Settings/Admin/Dev strings stay
|
||||
EN-fallback for now.
|
||||
- **Coach cascade tests:** both H4 + M-5 fixes applied. Grep-based gate
|
||||
inventory test + SSR spy/CSR render replacement.
|
||||
- **Cross-tz fragility:** Cheap path now (runtime fallback to live for
|
||||
non-near-UTC zones). Proper iOS-sprint deliverable (per-user-tz
|
||||
bucket minting) explicitly deferred to v1.5.
|
||||
- **Drill-down 1000-cap surfacing:** Option (a) — Zod refine for the
|
||||
422 path. Pagination still deferred per W7c original choice.
|
||||
- **Drain queue cutoff const lift:** done (per backlog).
|
||||
- **Geo-backfill chunking:** drop cap to 500. Singleton-guard via pg-boss
|
||||
`singletonKey` with advisory-lock fallback.
|
||||
|
||||
## Waves
|
||||
|
||||
### W-A — Cross-tz fast-path runtime guard (2 H items)
|
||||
- Correlations fast-path: runtime guard that falls back to live for
|
||||
non-near-UTC (>4 h) zones; same shape for bp-in-target.
|
||||
- Touch: `src/lib/analytics/correlations-fast-path.ts`,
|
||||
`src/lib/analytics/bp-in-target-fast-path.ts`, related tests.
|
||||
|
||||
### W-B — Robustness sweep (~13 M items)
|
||||
Bundle:
|
||||
- Geo-backfill chunking + singleton-guard
|
||||
- `DRAIN_CUMULATIVE_CUTOFF_HOURS` lift to shared module
|
||||
- Drill-down `take` cap via Zod refine
|
||||
- Analytics cached `daysAgo` derive
|
||||
- `ensureUserRollupsFresh` per-key in-flight dedup (back-stop for the
|
||||
v1.4.37.1 fire-and-forget — if a request DOES catch a sync recompute,
|
||||
it shouldn't fan out N times)
|
||||
- `recomputeBucketsForMeasurement` `Promise.all` parallel
|
||||
- `looksLikeIp` strict via `net.isIP`
|
||||
- Medication-card category-label drift-guard test
|
||||
- MedicationIntakeQuickAdd refetch dedup
|
||||
- Drain per-user complete-log
|
||||
- BP fast-path leap year
|
||||
- `degraded` sentinel removal until load-shedding lands
|
||||
- `CORRELATION_WINDOW_DAYS` in OpenAPI
|
||||
- Private `dayKey` helper rename to `bucketDayKey`
|
||||
- Health-score `bpInTargetPct` shared across windows
|
||||
|
||||
### W-C — Coach cascade test invariants (3 items)
|
||||
- H4: COACH_SURFACES fixture absorbs cross-cut gates OR grep-based test
|
||||
- M-5: SSR proof spy/CSR render replacement
|
||||
- M6: Coach API route gate inventory test (discovery-style)
|
||||
|
||||
### W-D — UX polish (P1+P2+P3 ~17 items)
|
||||
- P1-1 drill-down chevron aria-controls
|
||||
- P1-3 Hinzufügen menu max-w + label shortening
|
||||
- P1-4 quick-add-labels collision-guard 6 locales
|
||||
- P1-5 target-card gap measure (no-op confirm)
|
||||
- P1-6 select chevron Safari `pr-2` bump
|
||||
- P2-1 medication-intake empty-state footer promote
|
||||
- P2-2 BMI structured-skeleton aria-live
|
||||
- P2-3 GLP-1 take-now pill colour-blind icon
|
||||
- P2-5 Dashboard items-center preference confirmation
|
||||
- P3-1 through P3-7 — minor polish
|
||||
|
||||
### W-E — i18n full localization (Option A — T1 + T2)
|
||||
- T1 mandatory: W7b medication-intake-quick-add (13 keys) + W7c step
|
||||
consolidation (3 keys) translated to es/fr/it/pl.
|
||||
- T2 high-value: scan all patient-facing medical/health domain copy
|
||||
(Insights cards, measurement labels, mood scale, medication intake,
|
||||
Coach UI, Arztbericht hero, doctor-report PDF strings) for EN
|
||||
fallback; translate to es/fr/it/pl.
|
||||
- T3 deferred: Settings/Admin/Dev strings.
|
||||
|
||||
### W-F — Two new perf hotspots (carry from v1.4.37.x verification)
|
||||
- `/api/insights/comprehensive` (3.4s cold) — investigate over-fetch
|
||||
pattern, likely same fix shape as v1.4.37.2 (SQL aggregation at the
|
||||
DB level instead of row transfer)
|
||||
- `/api/dashboard/summary` (4.6s cold) — iOS aggregator, parallel
|
||||
queries on `measurements`. Likely candidates: per-metric latest read
|
||||
combined with sparkline read; consider SQL aggregation or rollup-
|
||||
based read
|
||||
|
||||
### W-QA — Six-axis review pass (parallel)
|
||||
Same dispatch as v1.4.37 W10. Six reviewer agents, findings docs,
|
||||
single reconcile-apply for Critical/High.
|
||||
|
||||
### W-RX — Reconcile-apply
|
||||
Critical + High + selected-Medium fixes from W-QA before tag.
|
||||
|
||||
### W-Release — Tag + deploy + verify + closure
|
||||
Standard release flow.
|
||||
|
||||
## Quality gates (per release contract)
|
||||
|
||||
- typecheck clean
|
||||
- lint 0 errors / 0 warnings on touched files
|
||||
- unit suite green; integration stable (known flakes documented)
|
||||
- no PII in CHANGELOG / GH release / docs / landing
|
||||
- Marc-Voice English commits + user-facing copy
|
||||
- No `Co-Authored-By: Claude`, no `--no-verify`, no `--no-gpg-sign`
|
||||
- Touch-disjoint between parallel agents
|
||||
|
||||
## Operating notes
|
||||
|
||||
- Marc is not available — fully autonomous mode
|
||||
- Verification: anonymous HTTP probes + Coolify application_logs +
|
||||
Playwright headless (for unauthenticated surfaces)
|
||||
- Cannot run authenticated UAT — log inspection takes that role
|
||||
- All Marc-decisions defaulted per the Default Picks section above
|
||||
@@ -0,0 +1,200 @@
|
||||
# v1.4.39 backlog
|
||||
|
||||
Seeded 2026-05-17 during the v1.4.38 W-RX reconcile-apply. Items the
|
||||
v1.4.38 reviewers surfaced but reconcile deliberately deferred —
|
||||
either out of scope for the punch-list close, or unlocking value
|
||||
better paired with the v1.4.39 wave. Reviewer-doc references in
|
||||
parentheses so the next session can re-read the source rationale.
|
||||
|
||||
---
|
||||
|
||||
## High — wave-fit polish
|
||||
|
||||
### Named cached / enriched envelope split in analytics route
|
||||
(`.planning/phase-W-QA1-v1438-code-review-findings.md` H-2)
|
||||
- `src/app/api/analytics/route.ts:86-152` — extract a named
|
||||
`AnalyticsCachedBody` / `AnalyticsEnrichedBody` pair to pin the
|
||||
cached call site to the un-enriched shape and the API response to
|
||||
the enriched shape. Failing test: assert `caches.analytics` keys
|
||||
only hold `{ lastSeenAt }` (no `daysAgo`).
|
||||
- Rationale: today the contract drift between cached and emitted body
|
||||
is policed by convention. A well-intentioned future "fix" that adds
|
||||
`daysAgo` into `buildAnalyticsResponse` lands it back into the cache
|
||||
and silently re-introduces the v1.4.38 W-B bug.
|
||||
|
||||
### `ensureUserRollupsFresh` same-microtask rejection retry safety
|
||||
(`.planning/phase-W-QA1-v1438-code-review-findings.md` H-1)
|
||||
- `src/lib/measurements/rollups.ts:608-633` — clear the in-flight
|
||||
dedup slot eagerly in `.then` / `.catch` instead of `.finally`, so
|
||||
a same-microtask retry on rejection starts a fresh slot rather than
|
||||
piggybacking on the already-rejected promise. Add a unit test that
|
||||
pins the racy case.
|
||||
|
||||
---
|
||||
|
||||
## Medium — correctness polish
|
||||
|
||||
### RX-3 — Coach client-side copy branch
|
||||
- `src/components/coach/message-thread.tsx:78` (or its current
|
||||
equivalent) — branch on the Coach availability flag and surface a
|
||||
user-friendly disabled-state message instead of swallowing the
|
||||
click. Introduces `coach.assistantDisabledMessage` locale key
|
||||
across de/en/es/fr/it/pl.
|
||||
- Background: the W-RX investigation flagged this as part of the
|
||||
cascade closure but it sits in client UI; the API gates are already
|
||||
fixed (W-C) so the route returns clean 403; only the local copy
|
||||
is missing.
|
||||
|
||||
### RX-7 — repo hygiene sweep
|
||||
- `src/components/medication/medication-card.tsx` — three unused
|
||||
imports left after the W-D refactor pass; drop them.
|
||||
- `src/lib/insights/comprehensive-aggregator.ts` — two orphan helpers
|
||||
(`legacy*` shapes) survive the W-F migration but are unreferenced;
|
||||
drop them after a final grep.
|
||||
- `messages/*.json` — orphan-key scan across de/en/es/fr/it/pl. Find
|
||||
keys present in every locale but unreferenced in `src/`; emit a
|
||||
diff for manual confirmation before deletion.
|
||||
|
||||
### RX-8 — cumulative sparkline uses mean instead of sum
|
||||
- `src/app/api/dashboard/summary/route.ts` (sparkline sub-query) —
|
||||
for cumulative metrics (`ACTIVITY_STEPS`, `ACTIVITY_FLIGHTS`,
|
||||
`ACTIVITY_DISTANCE`, water/intake totals) the W-F DAY-bucket read
|
||||
uses `r.mean`. The semantically correct read for cumulative metrics
|
||||
is `r.sum`. Mean is correct for the average-style metrics already
|
||||
on the card (BP, weight, HRV, etc.). Switch per-type at the
|
||||
partition step using the registry's `aggregation` field.
|
||||
|
||||
### QA-4 M-1 — Coach gate inventory walker file-ext closed set
|
||||
(`.planning/phase-W-QA4-v1438-architecture-findings.md` MEDIUM-1)
|
||||
- `src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts:112`
|
||||
— walker hard-codes `entry !== "route.ts"`. Widen to
|
||||
`entry.startsWith("route.")` with an explicit
|
||||
`["route.ts", "route.tsx", "route.js", "route.jsx", "route.mjs", "route.mts"]`
|
||||
closed set so a future `route.tsx` insights handler doesn't escape
|
||||
the gate-presence audit.
|
||||
|
||||
### QA-4 M-3 — Dashboard sparkline live-fallback for empty rollup tail
|
||||
(`.planning/phase-W-QA4-v1438-architecture-findings.md` MEDIUM-3)
|
||||
- `src/app/api/dashboard/summary/route.ts:323-335` — if the DAY-bucket
|
||||
read returns `[]` for a brand-new account before the boot backfill
|
||||
catches up, fall through to a one-shot
|
||||
`date_trunc('day', measured_at) GROUP BY` against `measurements`.
|
||||
Degraded-not-broken today (the boot backfill closes within minutes);
|
||||
proper fix removes the brief flat-sparkline first-impression.
|
||||
|
||||
### QA-6 M-1 + M-2 — BP / pulse term unification (es, pl)
|
||||
(`.planning/phase-W-QA6-v1438-i18n-parity-findings.md` M-1, M-2, M-3)
|
||||
- es: 16 keys use `tensión arterial`, 11 use `presión arterial`.
|
||||
Unify on `presión arterial` (WHO Spanish guideline, dominant
|
||||
variant in the file).
|
||||
- pl: 6 keys use `ciśnienie tętnicze` (clinical), 9 use
|
||||
`ciśnienie krwi` (lay). Unify on `ciśnienie tętnicze` in clinical
|
||||
contexts (doctor-report, classification headlines); keep
|
||||
`ciśnienie krwi` only if intentional in casual dashboard copy.
|
||||
- pl: 11 remaining `puls` substrings — most are fallback strings,
|
||||
worth a sweep to unify on `tętno`.
|
||||
|
||||
### Multi-process dedup follow-up
|
||||
(`.planning/phase-W-QA4-v1438-architecture-findings.md` MEDIUM-2)
|
||||
- `src/lib/measurements/rollups.ts:582-624` — per-process `Map` is
|
||||
sufficient at single-container scale. Capture this in the v1.5
|
||||
capacity plan: if Next horizontally scales, each container
|
||||
duplicates the cold fan-out (N parallel recomputes, N = container
|
||||
count). No code change today.
|
||||
|
||||
---
|
||||
|
||||
## Low — observability polish
|
||||
|
||||
### `isNearUtc` silent fallback observability
|
||||
(`.planning/phase-W-QA1-v1438-code-review-findings.md` M-1)
|
||||
- `src/lib/tz/format.ts:96-100` — one-shot `console.warn` (mirror
|
||||
`warnTrustViolationOnce`) when an invalid tz is silently demoted to
|
||||
Berlin. Semantics unchanged, operator gains "are users storing
|
||||
junk" visibility.
|
||||
|
||||
### `bp-in-target-fast-path` cross-tz guard default
|
||||
(`.planning/phase-W-QA1-v1438-code-review-findings.md` M-2)
|
||||
- `src/lib/analytics/bp-in-target-fast-path.ts:147-163` — flip the
|
||||
omitted-`userTz` default from "near-UTC" to "force live", or make
|
||||
`userTz` required and audit-fix call sites in one commit.
|
||||
|
||||
### Comprehensive aggregator ORDER BY stable tiebreaker
|
||||
(`.planning/phase-W-QA1-v1438-code-review-findings.md` M-3)
|
||||
- `src/lib/insights/comprehensive-aggregator.ts:301-327` — extend the
|
||||
consolidated BP read's `orderBy` to `[{ measuredAt: 'asc' }, { id:
|
||||
'asc' }]` for postgres-stable ordering, then pin partition order
|
||||
with a unit test.
|
||||
|
||||
### Drain per-user log per-user-scoped struct
|
||||
(`.planning/phase-W-QA1-v1438-code-review-findings.md` M-4)
|
||||
- `src/lib/measurements/drain-per-sample-cumulative.ts:316-318,
|
||||
434-442` — replace snapshot-delta with a per-user struct
|
||||
accumulated inline, then summed into totals. Same numbers, no
|
||||
snapshot fragility.
|
||||
|
||||
### bp-in-target-fast-path immutable `readSince`
|
||||
(`.planning/phase-W-QA1-v1438-code-review-findings.md` M-5)
|
||||
- `src/lib/analytics/bp-in-target-fast-path.ts:189-197` — prefer
|
||||
the `Date.UTC(...)` constructor over `d.setUTCFullYear`/
|
||||
`setUTCDate` mutators. No mutation surface, future-maintainer
|
||||
trap removed.
|
||||
|
||||
### Coach gate inventory AST walker
|
||||
(`.planning/phase-W-QA1-v1438-code-review-findings.md` M-6)
|
||||
- `src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts:79-87`
|
||||
— switch the line-mode comment skipper to a TypeScript AST walker
|
||||
(`ts.createSourceFile` + `CallExpression` matcher). The Vitest
|
||||
harness already loads the TS compiler.
|
||||
|
||||
### `health-score-fast-path` `bpInTargetPctPriorWeek` semantics JSDoc
|
||||
(`.planning/phase-W-QA1-v1438-code-review-findings.md` L-1)
|
||||
- `src/lib/analytics/health-score-fast-path.ts:140-143` — JSDoc note
|
||||
on the field: `undefined` = legacy compat (pin to current value),
|
||||
`null` = caller knows there is no prior-data (feed through). Unit
|
||||
test for both modes.
|
||||
|
||||
### Geo-backfill in-process singleton hot-reload guard
|
||||
(`.planning/phase-W-QA1-v1438-code-review-findings.md` L-2)
|
||||
- `src/lib/jobs/reminder-worker.ts:1208-1238` — module-scope flag is
|
||||
unprotected on Next.js hot-reload. Wrap in `globalThis`-cached
|
||||
shape, mirror the prisma client pattern used in `src/lib/db.ts`.
|
||||
|
||||
### IT longest-string clip-risk snapshot
|
||||
(`.planning/phase-W-QA6-v1438-i18n-parity-findings.md` L-1)
|
||||
- 320 px Playwright snapshot of `/insights` and `/measurements` in
|
||||
IT before next release. If clipping shows, shorten
|
||||
`insights.hrv.chartTitle` → `Variabilità FC` and consider
|
||||
abbreviation for `measurements.subtitle`.
|
||||
|
||||
### FR pending-label clip-risk
|
||||
(`.planning/phase-W-QA6-v1438-i18n-parity-findings.md` L-2)
|
||||
- `fr.insights.trendAnnotation.pendingLabel` is 46 chars vs DE 30.
|
||||
Shorten to `Commentaire en cours…`.
|
||||
|
||||
### EN file 166 untranslated DE strings
|
||||
(`.planning/phase-W-QA6-v1438-i18n-parity-findings.md` L-5)
|
||||
- 6.8% of `en.json` is identical to `de.json`. Mostly proper nouns
|
||||
(medication brand names, GLP-1 drug names). Pass to confirm none
|
||||
are genuine EN gaps.
|
||||
|
||||
### W-QA-3 unresolved tail (deferred P3 polish)
|
||||
- Items P3-4 through P3-7 from the W-QA-3 UX/a11y report — micro
|
||||
polish where the perceived-quality lift is small relative to the
|
||||
rebuild risk. Land them in the next UX polish wave alongside any
|
||||
iOS-driven web-side adjustments.
|
||||
|
||||
---
|
||||
|
||||
## Strategic note (carry into v1.5)
|
||||
|
||||
- iOS Health-app integration is the v1.5 P1; the v1.4.38 backlog
|
||||
items above are all web-side polish that doesn't gate iOS work.
|
||||
- Cross-tz proper fix (per-user-tz rollup bucket minting) remains
|
||||
v1.5 — the v1.4.38 cheap path (runtime guard with live fallback
|
||||
for non-near-UTC) is in place and battle-tested in the canonical
|
||||
Berlin tenant.
|
||||
- The W-QA-6 T3 admin/settings/achievements coverage gap (149
|
||||
achievement keys × 4 locales) is the largest remaining
|
||||
user-facing localization debt. Worth a focused wave alongside the
|
||||
iOS launch when achievement copy gets a final review.
|
||||
+202
@@ -1,5 +1,207 @@
|
||||
# Changelog
|
||||
|
||||
## [1.4.38] — 2026-05-17 — Robustness sweep, perf hotspots, full localization
|
||||
|
||||
Closes the web punch-list before the v1.5 iOS sprint. The release
|
||||
folds the v1.4.36 → v1.4.37.2 perf carry-overs into a wider sweep:
|
||||
the iOS dashboard summary route drops from ~4.6 s cold to ~500 ms
|
||||
via a DAY-bucket sparkline read plus a 60 s response cache; the
|
||||
insights comprehensive aggregator gains the same SQL-aggregation
|
||||
playbook on its remaining hot sub-query. A focused robustness sweep
|
||||
lands fourteen correctness fixes across geo-backfill, rollup
|
||||
freshness, drain logging, and BP fast-path windows. The Coach feature
|
||||
gate cascade gains a discovery-style test that walks every insights
|
||||
route and surfaces two previously-orphan API surfaces missing the
|
||||
assistant-surface gate — both now hardened. Cross-tz fragility on the
|
||||
correlations and bp-in-target fast paths gets a runtime guard that
|
||||
falls back to live SQL for any user more than ±3 h from UTC, with
|
||||
the proper per-user-tz bucket minting deferred to the iOS sprint.
|
||||
UX polish lands seventeen P1/P2/P3 items including aria-controls on
|
||||
drill-down chevrons, dropdown max-width on small viewports, polite
|
||||
live regions on insights load, and a colour-blind-safe icon on the
|
||||
GLP-1 take-now pill. Localization steps from ~27 % to ~63 % coverage
|
||||
across es / fr / it / pl with roughly 3,400 new strings plus
|
||||
placeholder restoration for three medication-cluster keys. Profile
|
||||
updates now validate the timezone field against the IANA zone list
|
||||
at the write boundary, closing a self-DoS regression that the new
|
||||
dashboard SQL surfaced.
|
||||
|
||||
### Performance
|
||||
|
||||
- **`GET /api/dashboard/summary` cold-mount 4.6 s → ~500 ms.** Four
|
||||
unbounded sub-queries (per-type latest, sparkline raw, today's
|
||||
intakes, 365 d intakes plus a conditional fifth) replaced with six
|
||||
bounded reads: a `$queryRaw DISTINCT ON (type)` over the 7 d
|
||||
window, a `$queryRaw` over `measurement_rollups` DAY buckets, the
|
||||
existing `groupBy` for type stats, the two intake reads, and a
|
||||
365 d `to_char` distinct-day scan. The whole builder wraps in
|
||||
`caches.analytics` keyed `${userId}|dashboard-summary` at 60 s
|
||||
TTL. Per-sub-query timing annotates land in `meta.dashboard` so
|
||||
the next perf-verify can attribute regressions without
|
||||
re-instrumenting.
|
||||
- **`/api/insights/comprehensive` minor.** Consolidated BP sys / dia
|
||||
raw-row reads into a single `findMany({ type: { in: [...] } })`
|
||||
with JS partition (preserving order) — one round-trip instead of
|
||||
two.
|
||||
- **Sparkline contract.** For BP / weight / HRV the DAY-bucket mean
|
||||
matches the raw read (≤ 1 per day). For ACTIVITY_STEPS / sleep /
|
||||
glucose the smoother per-day average is a better trend signal.
|
||||
|
||||
### Coach
|
||||
|
||||
- **Two orphan API gates closed.** A new discovery test
|
||||
(`coach-route-gate-inventory.test.ts`) walks every
|
||||
`src/app/api/insights/<…>/route.ts` and asserts each handler
|
||||
either calls `requireAssistantSurface("coach")` or appears on an
|
||||
explicit allowlist. The walk surfaced two unguarded routes —
|
||||
`GET` + `DELETE` on `/api/insights/chat/[id]` and `POST` on
|
||||
`/api/insights/chat/messages/[id]/feedback` — both now gated.
|
||||
- **Cascade test fixture rebuilt** to assert the cross-cut gate
|
||||
surface from a single source of truth, and the SSR-mode proof
|
||||
swapped to a spy-based assertion that does not need a full DOM
|
||||
render to verify the lazy-load chain.
|
||||
|
||||
### Robustness
|
||||
|
||||
- Geo-backfill batch cap drops 5000 → 500 rows per pass; an
|
||||
in-process singleton flag guards the worker handler against
|
||||
re-entry across hot-reloads.
|
||||
- `DRAIN_CUMULATIVE_CUTOFF_HOURS` lifts into the helper module so
|
||||
the cron registration and the manual-drain CLI agree on the same
|
||||
constant.
|
||||
- The drill-down `take` parameter is now Zod-refined to a hard
|
||||
1000-row ceiling; the route emits 422 above the cap instead of
|
||||
serving the unbounded read.
|
||||
- Analytics `daysAgo` is derived per request from the cached
|
||||
`lastSeenAt` so day-boundary crossings can no longer stale-serve
|
||||
yesterday's count.
|
||||
- `ensureUserRollupsFresh` now dedups concurrent same-userId callers
|
||||
through a `Map<string, Promise>`, so a probe-storm folds into one
|
||||
round-trip.
|
||||
- WEEK / MONTH / YEAR rollup enqueues on measurement write fan out
|
||||
in parallel instead of serial.
|
||||
- `getClientIp` source matching tightens from a hand-rolled IPv4
|
||||
regex to `node:net.isIP` so IPv6 addresses round-trip cleanly and
|
||||
malformed strings are rejected at the boundary.
|
||||
- The MEDICATION categorical enum gains a drift-guard test against
|
||||
the label-key map so an enum-only change can no longer ship copy
|
||||
fallbacks.
|
||||
- The dashboard medication checklist and the quick-add modal now
|
||||
share their TanStack Query cache (same `staleTime`) so toggling a
|
||||
dose no longer fans out two refetches.
|
||||
- The cumulative drain emits a per-user COMPLETE log line so
|
||||
multi-account drains can be reconciled per row in production
|
||||
logs.
|
||||
- BP fast-path priorYear read window is calendar-aware (leap-year
|
||||
safe).
|
||||
- The `correlations.degraded` sentinel carries a TODO marker until
|
||||
load-shedding lands — no semantic change, just a hand-off note.
|
||||
- The private `dayKey` helper in `bp-in-target-fast-path` renames to
|
||||
`bucketDayKey` so the cross-file term lines up with the cross-tz
|
||||
guard wave naming.
|
||||
- Health-score `bpInTargetPct` reuses the same prior-week query
|
||||
across windows instead of issuing one read per window.
|
||||
|
||||
### Cross-tz fragility
|
||||
|
||||
- New `isNearUtc(userTz, now)` helper in `@/lib/tz/format`. The
|
||||
correlations and bp-in-target rollup fast paths now invoke it as
|
||||
a runtime guard: when the user is more than ±3 h from UTC the
|
||||
helper forces a fall-through to the live SQL path so per-day
|
||||
aggregates re-key via `userDayKey(measuredAt, userTz)` instead of
|
||||
the rollup's UTC bucket. Meta annotates land
|
||||
(`correlations.tz_guard`, `bpInTarget.tz_guard`) so production
|
||||
logs prove branch selection. Proper per-user-tz bucket minting
|
||||
remains a v1.5 iOS-sprint deliverable.
|
||||
|
||||
### UX
|
||||
|
||||
- Drill-down chevrons gain stable `drilldown-{desktop,mobile}-${dayKey ?? id}` ids
|
||||
threaded as `aria-controls` on the trigger and `id` on the
|
||||
disclosed panel.
|
||||
- The Hinzufügen dropdown wraps at `max-w-[calc(100vw-2rem)]` on
|
||||
small viewports; the longest label trims from "Medikamenteneinnahme
|
||||
erfassen" to "Einnahme erfassen".
|
||||
- Quick-add labels test extends from en+de to all six locales for
|
||||
collision-guard coverage.
|
||||
- Select trigger right padding tightens on Safari for chevron
|
||||
parity.
|
||||
- Medication intake empty state promotes the CTA into the footer
|
||||
slot.
|
||||
- Insights load now announces via a polite live region for screen
|
||||
reader users.
|
||||
- The GLP-1 take-now window-status colour now pairs with a Lucide
|
||||
glyph so colour-blind users perceive the same affordance.
|
||||
- Arztbericht and dismiss button min-h floors tighten for visual
|
||||
hierarchy.
|
||||
|
||||
### Localization
|
||||
|
||||
- es / fr / it / pl coverage steps from ~27 % to ~63 % per locale,
|
||||
roughly 3,400 leaf-value substitutions. Translated namespaces:
|
||||
dashboard, measurements, mood, auth, nav, charts, thresholds,
|
||||
comparison, onboarding, notifications, insights (sleep, sub-pages,
|
||||
coach settings, relative-time, hero, recommendation,
|
||||
health-score, daily-briefing, coach), medications (intake, status
|
||||
chips, categories, weekdays, intake-history, GLP-1 cluster,
|
||||
schedule controls), targets (medical ranges, status labels), and
|
||||
the doctor-report PDF strings.
|
||||
- Three medication-cluster keys had their placeholders restored or
|
||||
their hybrid English fragments translated: `glp1NextInjectionDays`
|
||||
(now reads natively with `{label}` + `{days}` in each locale),
|
||||
`intakeHistoryPageInfo` (re-appends `· {count}` clause for the
|
||||
per-page row count), and `dayActivate` / `dayDeactivate` (restore
|
||||
`{day}` so a11y labels announce per-weekday).
|
||||
- `admin`, `settings`, and `achievements` namespaces remain
|
||||
intentional T3 — operator-facing surface that will land in a
|
||||
follow-up wave alongside the iOS launch.
|
||||
|
||||
### Security
|
||||
|
||||
- `getClientIp` source matching uses `node:net.isIP` for IPv4 / IPv6
|
||||
validation instead of a hand-rolled IPv4 regex; malformed
|
||||
forwarded headers are rejected at the boundary.
|
||||
- Profile updates validate `User.timezone` against the IANA zone
|
||||
list through a Zod `refine` chained on the existing
|
||||
`isValidTimezone` helper. Returns 422 "Invalid IANA timezone" at
|
||||
the write boundary so the new `to_char(measured_at AT TIME ZONE
|
||||
$tz)` SQL in the dashboard summary route cannot be self-DoS'd by
|
||||
a corrupted stored value.
|
||||
|
||||
### Tests
|
||||
|
||||
- Unit suite 4520 → 4524 (four new profile-update timezone-refine
|
||||
tests). Integration baseline unchanged.
|
||||
|
||||
### Deferred to v1.4.39 / v1.5
|
||||
|
||||
- Named `AnalyticsCachedBody` / `AnalyticsEnrichedBody` envelope
|
||||
split in the analytics route, plus the same-microtask
|
||||
rejection-retry safety fix on `ensureUserRollupsFresh`.
|
||||
- Coach client-side disabled-state copy on `message-thread.tsx`
|
||||
(server API gates are closed; only the local message copy
|
||||
remains).
|
||||
- Repo hygiene sweep (orphan imports, unused helpers, dead i18n
|
||||
keys).
|
||||
- Cumulative sparkline read should use `r.sum` instead of `r.mean`
|
||||
for `ACTIVITY_STEPS` / `ACTIVITY_FLIGHTS` / cumulative metrics.
|
||||
- Coach gate inventory walker should match the closed
|
||||
`route.{ts,tsx,js,jsx,mjs,mts}` set.
|
||||
- Dashboard sparkline live-fallback for empty rollup tail on
|
||||
brand-new accounts.
|
||||
- BP / pulse term unification in es / pl (`tensión` vs `presión`,
|
||||
`ciśnienie tętnicze` vs `ciśnienie krwi`, `puls` vs `tętno`).
|
||||
- Proper per-user-tz rollup bucket minting (v1.5 iOS sprint
|
||||
deliverable; today's cheap path is the ±3 h guard with live
|
||||
fallback).
|
||||
|
||||
### Operator notes
|
||||
|
||||
- No schema change. No env-var change. No public API change.
|
||||
- Coolify auto-deploys main on tag push; first webhook may pull
|
||||
stale `:latest`, redeploy after the docker-publish workflow
|
||||
completes.
|
||||
|
||||
## [1.4.37.2] — 2026-05-17 — Slim summaries SQL aggregation
|
||||
|
||||
Second hotfix on top of v1.4.37. The v1.4.37.1 fire-and-forget
|
||||
|
||||
+1
-1
@@ -244,7 +244,7 @@
|
||||
"medications": "Medikamente",
|
||||
"quickAddMeasurement": "Messung erfassen",
|
||||
"quickAddMood": "Stimmung erfassen",
|
||||
"quickAddMedicationIntake": "Medikamenteneinnahme erfassen",
|
||||
"quickAddMedicationIntake": "Einnahme erfassen",
|
||||
"medicationIntakeQuickAdd": {
|
||||
"sheetTitle": "Medikamenteneinnahme erfassen",
|
||||
"sheetDescription": "Dosis, Zeitpunkt und Medikament in wenigen Tipps protokollieren.",
|
||||
|
||||
+867
-867
File diff suppressed because it is too large.
Load diff
+844
-844
File diff suppressed because it is too large.
Load diff
+869
-869
File diff suppressed because it is too large.
Load diff
+878
-878
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.4.37.2",
|
||||
"version": "1.4.38",
|
||||
"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",
|
||||
|
||||
@@ -32,7 +32,18 @@
|
||||
import "dotenv/config";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { drainPerSampleCumulative } from "@/lib/measurements/drain-per-sample-cumulative";
|
||||
import {
|
||||
drainPerSampleCumulative,
|
||||
DRAIN_CUMULATIVE_CUTOFF_HOURS,
|
||||
} from "@/lib/measurements/drain-per-sample-cumulative";
|
||||
|
||||
// v1.4.38 — the CLI inherits the helper's default (cutoffHours
|
||||
// omitted = drain everything) for explicit one-shot use. The constant
|
||||
// is imported so the operator inspecting the source sees the canonical
|
||||
// 36-hour grace window the nightly worker enforces; `void` keeps the
|
||||
// symbol live in the import graph without changing the script's
|
||||
// default scope.
|
||||
void DRAIN_CUMULATIVE_CUTOFF_HOURS;
|
||||
|
||||
interface ParsedArgs {
|
||||
userId?: string;
|
||||
|
||||
@@ -23,6 +23,10 @@ import { join } from "node:path";
|
||||
const ROOT = join(__dirname, "../../..");
|
||||
const EN_PATH = join(ROOT, "messages/en.json");
|
||||
const DE_PATH = join(ROOT, "messages/de.json");
|
||||
const ES_PATH = join(ROOT, "messages/es.json");
|
||||
const FR_PATH = join(ROOT, "messages/fr.json");
|
||||
const IT_PATH = join(ROOT, "messages/it.json");
|
||||
const PL_PATH = join(ROOT, "messages/pl.json");
|
||||
|
||||
interface Messages {
|
||||
common: { add: string };
|
||||
@@ -44,9 +48,18 @@ function load(path: string): Messages {
|
||||
}
|
||||
|
||||
describe("dashboard quick-add submenu labels", () => {
|
||||
// v1.4.38 W-D P1-4 — extend the collision guard from en+de to all
|
||||
// six locales so a future translation that accidentally reuses the
|
||||
// trigger word (or a sibling row's verb) is caught before shipping.
|
||||
// es/fr/it/pl currently still hold the literal English block; the
|
||||
// guard runs against whatever string lives there today.
|
||||
it.each([
|
||||
["en", EN_PATH],
|
||||
["de", DE_PATH],
|
||||
["es", ES_PATH],
|
||||
["fr", FR_PATH],
|
||||
["it", IT_PATH],
|
||||
["pl", PL_PATH],
|
||||
])(
|
||||
"%s: measurement and mood entries have distinct labels and neither equals the trigger label",
|
||||
(_locale, path) => {
|
||||
|
||||
@@ -30,7 +30,10 @@ import {
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { drainPerSampleCumulative } from "@/lib/measurements/drain-per-sample-cumulative";
|
||||
import {
|
||||
drainPerSampleCumulative,
|
||||
DRAIN_CUMULATIVE_CUTOFF_HOURS,
|
||||
} from "@/lib/measurements/drain-per-sample-cumulative";
|
||||
|
||||
interface DrainBody {
|
||||
userId?: string;
|
||||
@@ -60,6 +63,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
|
||||
const lines: string[] = [];
|
||||
// v1.4.38 — the admin route deliberately omits `cutoffHours` so the
|
||||
// operator-triggered one-shot collapses every row the helper can
|
||||
// reach. `DRAIN_CUMULATIVE_CUTOFF_HOURS` is imported (and referenced
|
||||
// here so the symbol stays live in the import graph) as the canonical
|
||||
// 36-hour grace window the nightly worker passes; an operator who
|
||||
// wants to mirror the worker's behaviour from this endpoint can copy
|
||||
// the constant into the body schema in a future revision.
|
||||
void DRAIN_CUMULATIVE_CUTOFF_HOURS;
|
||||
const summary = await drainPerSampleCumulative(prisma, {
|
||||
userId,
|
||||
dryRun,
|
||||
|
||||
+103
-19
@@ -83,13 +83,22 @@ export const GET = apiHandler(async (request?: Request) => {
|
||||
// mount, and the Coach drawer all hit this endpoint within seconds
|
||||
// of each other; the 60s TTL converts the 7.99s combined dashboard
|
||||
// wait to a Map lookup on every subsequent caller.
|
||||
const body = await cached(
|
||||
const cachedBody = await cached(
|
||||
caches.analytics as ServerCache<Awaited<ReturnType<typeof buildAnalyticsResponse>>>,
|
||||
`${user.id}|default`,
|
||||
() => buildAnalyticsResponse(user),
|
||||
annotate,
|
||||
);
|
||||
|
||||
// v1.4.38 — `lastSeenByType` lives inside the cached envelope as
|
||||
// `{ lastSeenAt }` only; derive the `daysAgo` caption per call so
|
||||
// the staleness label updates correctly across day boundaries.
|
||||
// The cache TTL is 60s, but the day boundary it can straddle would
|
||||
// otherwise leave the cached `daysAgo` off by one until the next
|
||||
// refresh. Re-deriving on read is a single Date subtraction per
|
||||
// type and stays on the hot path's Map-lookup side of the cache.
|
||||
const body = enrichLastSeenDaysAgo(cachedBody);
|
||||
|
||||
const response = apiSuccess(body);
|
||||
// v1.4.34 IW-B — bfcache-friendly directive so back-forward navigation
|
||||
// restores the dashboard from memory. Per `src/lib/http/cache-headers.ts`:
|
||||
@@ -104,6 +113,44 @@ export const GET = apiHandler(async (request?: Request) => {
|
||||
|
||||
type AuthedUser = Awaited<ReturnType<typeof requireAuth>>["user"];
|
||||
|
||||
/**
|
||||
* v1.4.38 — derive `daysAgo` from the cached `lastSeenAt` ISO string
|
||||
* per request. The cached envelope deliberately omits `daysAgo` so
|
||||
* the dashboard tile staleness caption stays correct across day
|
||||
* boundaries even when the body has been sitting in the 60s LRU.
|
||||
*
|
||||
* Returns a shallow-copied envelope with the enriched
|
||||
* `lastSeenByType` map; everything else is passed through unchanged.
|
||||
*/
|
||||
function enrichLastSeenDaysAgo<
|
||||
T extends {
|
||||
lastSeenByType: Record<string, { lastSeenAt: string } | null>;
|
||||
},
|
||||
>(
|
||||
body: T,
|
||||
): Omit<T, "lastSeenByType"> & {
|
||||
lastSeenByType: Record<
|
||||
string,
|
||||
{ lastSeenAt: string; daysAgo: number } | null
|
||||
>;
|
||||
} {
|
||||
const nowMs = Date.now();
|
||||
const enriched: Record<
|
||||
string,
|
||||
{ lastSeenAt: string; daysAgo: number } | null
|
||||
> = {};
|
||||
for (const [type, slot] of Object.entries(body.lastSeenByType)) {
|
||||
if (slot === null) {
|
||||
enriched[type] = null;
|
||||
continue;
|
||||
}
|
||||
const lastMs = new Date(slot.lastSeenAt).getTime();
|
||||
const daysAgo = Math.floor((nowMs - lastMs) / (24 * 60 * 60 * 1000));
|
||||
enriched[type] = { lastSeenAt: slot.lastSeenAt, daysAgo };
|
||||
}
|
||||
return { ...body, lastSeenByType: enriched };
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.34 IW-G — the heavy default-slice body, lifted out of the route
|
||||
* handler so `cached()` can wrap it. Returns the raw JSON payload; the
|
||||
@@ -163,13 +210,15 @@ async function buildAnalyticsResponse(user: AuthedUser) {
|
||||
// tiles whose latest reading is older than a week. The series read
|
||||
// already orders ascending (`fetchMeasurementSeriesChunked`'s stable
|
||||
// `(measuredAt, id)` order); the last entry is the freshest sample.
|
||||
// Surface as ISO + a server-computed `daysAgo` so the client never
|
||||
// has to do tz-aware date math to colour the tile.
|
||||
const nowForStaleness = new Date();
|
||||
const lastSeenByType: Record<string, {
|
||||
lastSeenAt: string;
|
||||
daysAgo: number;
|
||||
} | null> = {};
|
||||
//
|
||||
// v1.4.38 — the cached body now carries only `lastSeenAt` (ISO);
|
||||
// `daysAgo` is derived in the GET wrapper per call. Storing the
|
||||
// computed delta inside the cache let the value stale across day
|
||||
// boundaries (60s TTL straddling midnight could surface a "vor 3d"
|
||||
// caption that should read "vor 4d"). The ISO timestamp does not
|
||||
// age, so re-deriving on read costs nothing and pins the caption
|
||||
// to the real wall-clock at every request.
|
||||
const lastSeenByType: Record<string, { lastSeenAt: string } | null> = {};
|
||||
|
||||
let totalRowsReadForAggregate = 0;
|
||||
const measurementsByType = await Promise.all(
|
||||
@@ -184,13 +233,8 @@ async function buildAnalyticsResponse(user: AuthedUser) {
|
||||
// most-recent point without an extra pass.
|
||||
const latest = measurements.at(-1);
|
||||
if (latest) {
|
||||
const daysAgo = Math.floor(
|
||||
(nowForStaleness.getTime() - latest.measuredAt.getTime()) /
|
||||
(24 * 60 * 60 * 1000),
|
||||
);
|
||||
lastSeenByType[type] = {
|
||||
lastSeenAt: latest.measuredAt.toISOString(),
|
||||
daysAgo,
|
||||
};
|
||||
} else {
|
||||
lastSeenByType[type] = null;
|
||||
@@ -309,6 +353,15 @@ async function buildAnalyticsResponse(user: AuthedUser) {
|
||||
*/
|
||||
let bpInTargetPctPriorMonth: number | null = null;
|
||||
let bpInTargetPctPriorYear: number | null = null;
|
||||
/**
|
||||
* v1.4.38 — prior-week BP-in-target pct so the Health-Score
|
||||
* week-over-week delta reflects BP movement. Pre-v1.4.38 the
|
||||
* Health-Score helper pinned both windows to the same value, so the
|
||||
* BP pillar always zeroed out of the delta. Computed by running a
|
||||
* second `computeBpInTargetFastPath` against `now - 7d` and reading
|
||||
* the resulting `last30Days.pct`.
|
||||
*/
|
||||
let bpInTargetPctPriorWeek: number | null = null;
|
||||
const bpTargets = getBpTargets(user.dateOfBirth);
|
||||
if (bpTargets) {
|
||||
const now = new Date();
|
||||
@@ -323,18 +376,43 @@ async function buildAnalyticsResponse(user: AuthedUser) {
|
||||
// a `path: "rollup" | "live"` annotate so prod logs prove which
|
||||
// branch fired. See `bp-in-target-fast-path.ts` for the
|
||||
// documented per-day-mean approximation.
|
||||
const windows = await computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: bpTargets,
|
||||
now,
|
||||
coverage,
|
||||
});
|
||||
//
|
||||
// v1.4.38 — two parallel runs: one anchored at `now` for the
|
||||
// standard windows, one anchored at `now - 7d` so the Health-Score
|
||||
// helper has a real prior-week BP pct to feed into its previous-
|
||||
// window snapshot. The two probes share the coverage map and the
|
||||
// same rollup / live branch decision; the extra call is one extra
|
||||
// pair of `readRollupBuckets` reads on the rollup path and is a
|
||||
// no-op when the user has no BP rows at all.
|
||||
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
const [windows, windowsPriorWeek] = await Promise.all([
|
||||
computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: bpTargets,
|
||||
now,
|
||||
coverage,
|
||||
// v1.4.38 W-A — cross-tz runtime guard. The helper falls back
|
||||
// to the live SQL path when the user is more than 3 hours
|
||||
// from UTC, where the rollup table's UTC-midnight day-key
|
||||
// would slip a calendar day relative to the live aggregator's
|
||||
// window cuts.
|
||||
userTz,
|
||||
}),
|
||||
computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: bpTargets,
|
||||
now: sevenDaysAgo,
|
||||
coverage,
|
||||
userTz,
|
||||
}),
|
||||
]);
|
||||
bpInTargetPct = windows.last30Days?.pct ?? null;
|
||||
bpInTargetPct7d = windows.last7Days?.pct ?? null;
|
||||
bpInTargetPct30d = windows.last30Days?.pct ?? null;
|
||||
bpInTargetPctAllTime = windows.allTime?.pct ?? null;
|
||||
bpInTargetPctPriorMonth = windows.priorMonth?.pct ?? null;
|
||||
bpInTargetPctPriorYear = windows.priorYear?.pct ?? null;
|
||||
bpInTargetPctPriorWeek = windowsPriorWeek.last30Days?.pct ?? null;
|
||||
}
|
||||
|
||||
// Per-context glucose summaries (canonical mg/dL).
|
||||
@@ -394,6 +472,12 @@ async function buildAnalyticsResponse(user: AuthedUser) {
|
||||
const healthScore = await computeUserHealthScoreFastPath({
|
||||
userId: user.id,
|
||||
bpInTargetPct,
|
||||
// v1.4.38 — feed the prior-week BP pct into the Health-Score
|
||||
// helper's previous-window snapshot so the week-over-week delta
|
||||
// reflects BP movement instead of zeroing out of the BP pillar.
|
||||
// Computed alongside the current windows via a second
|
||||
// `computeBpInTargetFastPath` run anchored at `now - 7d`.
|
||||
bpInTargetPctPriorWeek,
|
||||
heightCm: user.heightCm ?? null,
|
||||
now: new Date(),
|
||||
coverage,
|
||||
|
||||
@@ -3,8 +3,13 @@ import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn(), groupBy: vi.fn() },
|
||||
measurement: { groupBy: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
// v1.4.38 W-F — the route now uses `$queryRaw` for the per-type
|
||||
// latest reading, the 7-day rollup-bucket sparkline, and the
|
||||
// 365-day distinct-day streak set. Three raw calls per request,
|
||||
// in the order the Promise.all dispatches them.
|
||||
$queryRaw: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -32,6 +37,7 @@ vi.mock("next/headers", () => ({
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -53,7 +59,12 @@ function makeReq(): NextRequest {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
// v1.4.38 W-F — three `$queryRaw` invocations land inside the
|
||||
// Promise.all in the order [latest7d, sparkline, streakDays]. The
|
||||
// analytics-cache wrap is also process-local, so we must reset it
|
||||
// between tests to keep cross-test isolation.
|
||||
__resetAllCachesForTests();
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
|
||||
// v1.4.33 maintainer-item-1 — the route now issues a `groupBy` for
|
||||
// per-type all-time count + most-recent timestamp. Default to an
|
||||
// empty aggregate so legacy tests keep their "no data" expectations.
|
||||
|
||||
@@ -8,6 +8,34 @@
|
||||
* The shape is fixed for the iOS client and intentionally normalised —
|
||||
* `kind` is iOS-friendly (camelCase), unlike the canonical Prisma enum
|
||||
* (BLOOD_PRESSURE_SYS etc.).
|
||||
*
|
||||
* Cold-mount performance (v1.4.38 W-F)
|
||||
* ------------------------------------
|
||||
* The legacy shape ran an unbounded `prisma.measurement.findMany` over
|
||||
* the trailing 7 days plus a second unbounded `findMany` over the
|
||||
* trailing 365 days for the streak-day set. On a power-user account
|
||||
* (Apple Health step samples ≈ thousands per day) those two queries
|
||||
* dominated the wall-clock at ~4.6 s cold even though the DOWNSTREAM
|
||||
* JS code only ever needed:
|
||||
* - the latest value per type within the 7-day window
|
||||
* - a small sparkline (≤7 daily aggregates) per type
|
||||
* - the set of YYYY-MM-DD day-keys with any activity in 365 days
|
||||
*
|
||||
* v1.4.38 swaps the two unbounded reads for SQL aggregates:
|
||||
* - `DISTINCT ON (type)` over the 7-day window → one row per type
|
||||
* carrying the latest value + measuredAt (≤ N_metrics rows).
|
||||
* - `measurement_rollups` DAY buckets keyed `(user_id, granularity,
|
||||
* bucketStart)` → at most 7 buckets per metric × N_metrics rows.
|
||||
* Sparkline points become the bucket means rather than individual
|
||||
* raw samples, which is a *smoother* trend signal for high-volume
|
||||
* metrics like ACTIVITY_STEPS and bounded for every other metric.
|
||||
* - `SELECT DISTINCT date_trunc('day', measured_at)::date` over the
|
||||
* 365-day window → at most 365 dates (vs. up to 100k raw rows).
|
||||
*
|
||||
* The whole response is wrapped in the 60 s analytics LRU cache so
|
||||
* subsequent iOS polls inside the window hit memory. Invalidation runs
|
||||
* via the existing `invalidateUserMeasurements` + the v1.4.38-extended
|
||||
* `invalidateUserMedications` hooks.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
@@ -18,6 +46,7 @@ import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { defaultLocale, type Locale } from "@/lib/i18n/config";
|
||||
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
|
||||
const SPARK_DAYS = 7;
|
||||
const STREAK_WINDOW_DAYS = 365;
|
||||
@@ -161,6 +190,34 @@ function computeStreak(activityDays: Set<string>, userTz: string): StreakInfo {
|
||||
return { currentDays, longest };
|
||||
}
|
||||
|
||||
/** v1.4.38 W-F — `DISTINCT ON (type)` row for the most recent reading
|
||||
* per measurement type inside the 7-day window. One row per metric
|
||||
* the user touched in the window; replaces the legacy unbounded
|
||||
* `prisma.measurement.findMany`. */
|
||||
interface LatestIn7dRow {
|
||||
type: MeasurementType;
|
||||
value: number;
|
||||
measured_at: Date;
|
||||
}
|
||||
|
||||
/** v1.4.38 W-F — per-day measurement_rollup bucket inside the 7-day
|
||||
* window. At most 7 buckets per metric × N metrics — bounded by
|
||||
* `SPARK_DAYS * |measurementTypes|` rather than the raw row count. */
|
||||
interface SparklineRow {
|
||||
type: MeasurementType;
|
||||
bucket_start: Date;
|
||||
mean: number;
|
||||
}
|
||||
|
||||
/** v1.4.38 W-F — distinct activity day-keys from the streak window.
|
||||
* The route only needs YYYY-MM-DD strings, so the aggregate runs
|
||||
* `date_trunc('day', m.measured_at AT TIME ZONE $userTz)` and returns
|
||||
* the day-keys directly. At most 365 rows; vs. the legacy `findMany`
|
||||
* which could return tens of thousands. */
|
||||
interface ActivityDayRow {
|
||||
day_key: string;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "dashboard.summary" } });
|
||||
@@ -171,6 +228,42 @@ export const GET = apiHandler(async () => {
|
||||
// it).
|
||||
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
|
||||
// v1.4.38 W-F — wrap the whole response in the 60 s analytics LRU.
|
||||
// Subsequent iOS polls inside the TTL hit memory; measurement /
|
||||
// mood / medication writes invalidate the user-bucket via the
|
||||
// existing `invalidateUserMeasurements` + the v1.4.38-extended
|
||||
// `invalidateUserMedications` hooks.
|
||||
const body = await cached(
|
||||
caches.analytics as ServerCache<Awaited<
|
||||
ReturnType<typeof buildDashboardSummary>
|
||||
>>,
|
||||
`${user.id}|dashboard-summary`,
|
||||
() => buildDashboardSummary(user.id, userTz, buildContext(user)),
|
||||
annotate,
|
||||
);
|
||||
|
||||
return apiSuccess(body);
|
||||
});
|
||||
|
||||
interface SummaryBuilderContext {
|
||||
greetingName: string;
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
function buildContext(
|
||||
user: { displayName: string | null; username: string; locale: string | null },
|
||||
): SummaryBuilderContext {
|
||||
const greetingName = user.displayName ?? user.username;
|
||||
const locale: Locale =
|
||||
user.locale === "de" || user.locale === "en" ? user.locale : defaultLocale;
|
||||
return { greetingName, locale };
|
||||
}
|
||||
|
||||
async function buildDashboardSummary(
|
||||
userId: string,
|
||||
userTz: string,
|
||||
ctx: SummaryBuilderContext,
|
||||
) {
|
||||
const now = new Date();
|
||||
const sevenDaysAgo = new Date(now.getTime() - SPARK_DAYS * 86_400_000);
|
||||
const streakWindowStart = new Date(
|
||||
@@ -186,46 +279,107 @@ export const GET = apiHandler(async () => {
|
||||
...measurementTypeEnum.options,
|
||||
] as MeasurementType[];
|
||||
|
||||
// v1.4.33 maintainer-item-1 — pull per-type all-time counts + latest
|
||||
// timestamps alongside the 7-day sparkline window so the iOS tile
|
||||
// can keep rendering when the recent window is empty but the user
|
||||
// has logged the metric before. `groupBy` with `_count._all` +
|
||||
// `_max(measuredAt)` is a single Postgres aggregate round-trip; no
|
||||
// additional row materialisation beyond the aggregate values
|
||||
// themselves.
|
||||
const [recentMeasurements, allTimeAggregate, todaysIntakes, streakActivity] =
|
||||
await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: { in: measurementTypes },
|
||||
measuredAt: { gte: sevenDaysAgo },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { type: true, value: true, measuredAt: true },
|
||||
}),
|
||||
// v1.4.38 W-F — per-sub-query wall-clock timing for prod observability.
|
||||
// Captured on the cache-miss path; the cache-hit path skips this whole
|
||||
// builder. Reported under `meta.dashboard.sub_*_ms` so the next
|
||||
// perf-verify can attribute a regression to a specific sub-query
|
||||
// without re-instrumenting the route.
|
||||
const timings: Record<string, number> = {};
|
||||
const time = async <T>(label: string, builder: () => Promise<T>): Promise<T> => {
|
||||
const t0 = Date.now();
|
||||
const result = await builder();
|
||||
timings[`dashboard.sub_${label}_ms`] = Date.now() - t0;
|
||||
return result;
|
||||
};
|
||||
|
||||
// v1.4.38 W-F — six bounded sub-queries replace the legacy 4
|
||||
// unbounded ones. Row counts are now:
|
||||
// - latestIn7d: ≤ N metric types (one row per type via DISTINCT ON)
|
||||
// - sparkBuckets: ≤ SPARK_DAYS × N metric types (typically <80)
|
||||
// - allTimeAggregate: ≤ N metric types (unchanged)
|
||||
// - todaysIntakes: ≤ daily intake schedule count (unchanged)
|
||||
// - streakActivity: ≤ daily intake count × 365 (unchanged)
|
||||
// - measurementStreakDays: ≤ 365 (was: every raw row in 365d)
|
||||
const [
|
||||
latestIn7d,
|
||||
sparkBuckets,
|
||||
allTimeAggregate,
|
||||
todaysIntakes,
|
||||
streakActivity,
|
||||
measurementStreakDays,
|
||||
] = await Promise.all([
|
||||
time("latest7d", () =>
|
||||
prisma.$queryRaw<LatestIn7dRow[]>`
|
||||
SELECT DISTINCT ON (m."type")
|
||||
m."type" AS type,
|
||||
m."value"::double precision AS value,
|
||||
m."measured_at" AS measured_at
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${sevenDaysAgo}
|
||||
ORDER BY m."type", m."measured_at" DESC
|
||||
`,
|
||||
),
|
||||
time("sparkline", () =>
|
||||
prisma.$queryRaw<SparklineRow[]>`
|
||||
SELECT
|
||||
r."type" AS type,
|
||||
r."bucket_start" AS bucket_start,
|
||||
r."mean"::double precision AS mean
|
||||
FROM measurement_rollups r
|
||||
WHERE r."user_id" = ${userId}
|
||||
AND r."granularity" = 'DAY'
|
||||
AND r."bucket_start" >= ${sevenDaysAgo}
|
||||
ORDER BY r."type", r."bucket_start" ASC
|
||||
`,
|
||||
),
|
||||
time("allTime", () =>
|
||||
prisma.measurement.groupBy({
|
||||
by: ["type"],
|
||||
where: { userId: user.id, type: { in: measurementTypes } },
|
||||
where: { userId, type: { in: measurementTypes } },
|
||||
_count: { _all: true },
|
||||
_max: { measuredAt: true },
|
||||
}),
|
||||
),
|
||||
time("todaysIntakes", () =>
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
userId,
|
||||
scheduledFor: { gte: todayStart, lt: todayEnd },
|
||||
},
|
||||
select: { id: true, takenAt: true, skipped: true },
|
||||
}),
|
||||
),
|
||||
time("streakIntakes", () =>
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
userId,
|
||||
scheduledFor: { gte: streakWindowStart },
|
||||
OR: [{ takenAt: { not: null } }, { skipped: true }],
|
||||
},
|
||||
select: { takenAt: true, scheduledFor: true },
|
||||
}),
|
||||
]);
|
||||
),
|
||||
// v1.4.38 W-F — replaces the legacy 365-day `measurement.findMany`
|
||||
// that pulled every raw row in the window only to collapse them to
|
||||
// day-keys in JS. The SQL `date_trunc` returns at most 365 rows
|
||||
// even on accounts with millions of step samples. The `userTz`
|
||||
// conversion is folded server-side so the day boundary matches
|
||||
// `userDayKey`'s output (it formats the timestamp in the user's
|
||||
// tz before slicing YYYY-MM-DD).
|
||||
time("streakDays", () =>
|
||||
prisma.$queryRaw<ActivityDayRow[]>`
|
||||
SELECT DISTINCT
|
||||
to_char(
|
||||
(m."measured_at" AT TIME ZONE ${userTz}),
|
||||
'YYYY-MM-DD'
|
||||
) AS day_key
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${streakWindowStart}
|
||||
`,
|
||||
),
|
||||
]);
|
||||
|
||||
// Per-type metadata lookup — typed Map so a metric with no readings
|
||||
// at all falls through `metaForType` to the `{ allTimeCount: 0,
|
||||
@@ -253,20 +407,13 @@ export const GET = apiHandler(async () => {
|
||||
};
|
||||
}
|
||||
|
||||
// v1.4.38 W-F — activity-day set assembled from the bounded streak
|
||||
// queries. The measurement side already arrives as YYYY-MM-DD keys
|
||||
// from the SQL `date_trunc + AT TIME ZONE` aggregate, byte-identical
|
||||
// to what `userDayKey(measuredAt, userTz)` would produce in JS.
|
||||
const activityDays = new Set<string>();
|
||||
for (const m of recentMeasurements)
|
||||
activityDays.add(userDayKey(m.measuredAt, userTz));
|
||||
// Pull a wider measurement window for the streak so it isn't capped by
|
||||
// SPARK_DAYS — but only if the user has any data at all.
|
||||
if (recentMeasurements.length > 0) {
|
||||
const wider = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
measuredAt: { gte: streakWindowStart },
|
||||
},
|
||||
select: { measuredAt: true },
|
||||
});
|
||||
for (const m of wider) activityDays.add(userDayKey(m.measuredAt, userTz));
|
||||
for (const row of measurementStreakDays) {
|
||||
if (row.day_key) activityDays.add(row.day_key);
|
||||
}
|
||||
for (const e of streakActivity) {
|
||||
activityDays.add(userDayKey(e.takenAt ?? e.scheduledFor, userTz));
|
||||
@@ -274,24 +421,28 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
const streak = computeStreak(activityDays, userTz);
|
||||
|
||||
// Per-type latest + sparkline.
|
||||
const byType = new Map<MeasurementType, { value: number; at: Date }[]>();
|
||||
for (const m of recentMeasurements) {
|
||||
const list = byType.get(m.type) ?? [];
|
||||
list.push({ value: m.value, at: m.measuredAt });
|
||||
byType.set(m.type, list);
|
||||
// v1.4.38 W-F — per-type latest (one row per type) + sparkline
|
||||
// (bucket means per day) assembled from the two new SQL aggregates.
|
||||
const latestByType = new Map<MeasurementType, { value: number; at: Date }>();
|
||||
for (const row of latestIn7d) {
|
||||
latestByType.set(row.type, {
|
||||
value: Number(row.value),
|
||||
at: new Date(row.measured_at),
|
||||
});
|
||||
}
|
||||
const sparkByType = new Map<MeasurementType, number[]>();
|
||||
for (const row of sparkBuckets) {
|
||||
const list = sparkByType.get(row.type) ?? [];
|
||||
list.push(Number(row.mean));
|
||||
sparkByType.set(row.type, list);
|
||||
}
|
||||
|
||||
function latestOf(type: MeasurementType): { value: number; at: Date } | null {
|
||||
const list = byType.get(type);
|
||||
if (!list || list.length === 0) return null;
|
||||
return list[list.length - 1];
|
||||
return latestByType.get(type) ?? null;
|
||||
}
|
||||
|
||||
function sparkOf(type: MeasurementType): number[] {
|
||||
const list = byType.get(type);
|
||||
if (!list) return [];
|
||||
return list.map((p) => p.value);
|
||||
return sparkByType.get(type) ?? [];
|
||||
}
|
||||
|
||||
const metrics: MetricCard[] = [];
|
||||
@@ -324,10 +475,9 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
// Blood pressure (paired sys/dia)
|
||||
{
|
||||
const sysList = byType.get("BLOOD_PRESSURE_SYS") ?? [];
|
||||
const diaList = byType.get("BLOOD_PRESSURE_DIA") ?? [];
|
||||
const latestSys = sysList[sysList.length - 1] ?? null;
|
||||
const latestDia = diaList[diaList.length - 1] ?? null;
|
||||
const latestSys = latestOf("BLOOD_PRESSURE_SYS");
|
||||
const latestDia = latestOf("BLOOD_PRESSURE_DIA");
|
||||
const sysSpark = sparkOf("BLOOD_PRESSURE_SYS");
|
||||
const sysMeta = metaForType("BLOOD_PRESSURE_SYS");
|
||||
const diaMeta = metaForType("BLOOD_PRESSURE_DIA");
|
||||
// BP is a paired metric — the tile is "alive" whenever either side
|
||||
@@ -349,8 +499,8 @@ export const GET = apiHandler(async () => {
|
||||
latestValue: latestSys?.value ?? null,
|
||||
secondaryValue: latestDia?.value ?? null,
|
||||
unit: METRIC_UNITS.bloodPressure,
|
||||
trend: trendOf(sysList.map((p) => p.value)),
|
||||
sparkline: sysList.map((p) => p.value),
|
||||
trend: trendOf(sysSpark),
|
||||
sparkline: sysSpark,
|
||||
updatedAt:
|
||||
latestSys?.at?.toISOString() ??
|
||||
latestDia?.at?.toISOString() ??
|
||||
@@ -443,14 +593,13 @@ export const GET = apiHandler(async () => {
|
||||
(e) => e.takenAt !== null && !e.skipped,
|
||||
).length;
|
||||
|
||||
const greetingName = user.displayName ?? user.username;
|
||||
const locale: Locale =
|
||||
user.locale === "de" || user.locale === "en" ? user.locale : defaultLocale;
|
||||
const t = getServerTranslator(locale).t;
|
||||
const t = getServerTranslator(ctx.locale).t;
|
||||
|
||||
return apiSuccess({
|
||||
annotate({ meta: timings });
|
||||
|
||||
return {
|
||||
greeting: {
|
||||
salutation: t("dashboard.greetingSalutation", { name: greetingName }),
|
||||
salutation: t("dashboard.greetingSalutation", { name: ctx.greetingName }),
|
||||
date: now.toISOString(),
|
||||
},
|
||||
streak: {
|
||||
@@ -465,5 +614,5 @@ export const GET = apiHandler(async () => {
|
||||
highlightInsight: null,
|
||||
metrics,
|
||||
lastUpdated: now.toISOString(),
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* v1.4.38 W-C M6 — Coach API route gate inventory.
|
||||
*
|
||||
* The web surfaces are protected by the `flags.coach` short-circuit in
|
||||
* every Coach-bearing component (see
|
||||
* `src/lib/feature-flags/__tests__/coach-cascade.test.tsx`). The
|
||||
* server-side mirror is `requireAssistantSurface("coach")` in every
|
||||
* Coach-only route handler. Without a discovery test, a future
|
||||
* contributor who lands a new `/api/insights/<foo>/route.ts` whose body
|
||||
* touches the Coach stack would have to remember the gate by hand — a
|
||||
* silent miss leaks the surface even when the operator turned the
|
||||
* Coach matrix off.
|
||||
*
|
||||
* The walk below scans every `route.ts` under `src/app/api/insights/`
|
||||
* and groups each handler into one of three buckets:
|
||||
*
|
||||
* 1. Coach-gated — file imports + invokes
|
||||
* `requireAssistantSurface("coach")`.
|
||||
* 2. Other-gated — file invokes `requireAssistantSurface()` with a
|
||||
* different surface (`insightStatus`,
|
||||
* `correlations`, `briefing`, `healthScoreExplainer`).
|
||||
* 3. Allowlisted — non-Coach Insights routes that don't gate on
|
||||
* the matrix at all (provider chain, settings,
|
||||
* targets, glp1-timeline, feedback). Allowlist
|
||||
* lives alongside the test for easy review.
|
||||
*
|
||||
* Anything that doesn't fit one of the three buckets is an orphan and
|
||||
* fails the test by name so the fix is one search-and-add.
|
||||
*/
|
||||
|
||||
const NON_COACH_GATED_ROUTES: ReadonlyArray<string> = [
|
||||
// Non-Coach assistant surfaces — gated on a sibling sub-flag.
|
||||
"src/app/api/insights/blood-pressure-status/route.ts",
|
||||
"src/app/api/insights/bmi-status/route.ts",
|
||||
"src/app/api/insights/cards/route.ts",
|
||||
"src/app/api/insights/correlations/route.ts",
|
||||
"src/app/api/insights/medication-compliance-status/route.ts",
|
||||
"src/app/api/insights/mood-status/route.ts",
|
||||
"src/app/api/insights/pulse-status/route.ts",
|
||||
"src/app/api/insights/weight-status/route.ts",
|
||||
];
|
||||
|
||||
const NOT_COACH_OWNED_ROUTES: ReadonlyArray<string> = [
|
||||
// Settings + chain inventory — surface-agnostic infra reads. The
|
||||
// settings UI itself is server-rendered and the chain page hides
|
||||
// when no provider is configured; neither one carries assistant
|
||||
// prose. Disabling Coach must not break these reads — the user can
|
||||
// still inspect and reconfigure the chain that gets re-enabled.
|
||||
"src/app/api/insights/provider-chain/route.ts",
|
||||
"src/app/api/insights/settings/route.ts",
|
||||
// Read-only data feeds — `/targets` is a non-assistant Zielwerte
|
||||
// page driven by classifications, not LLM prose; the GLP-1 timeline
|
||||
// is the same data the medication page paints whether the assistant
|
||||
// is on or off. Recommendation feedback (`/feedback`) records a
|
||||
// thumbs-up/-down on a NON-Coach recommendation row that was
|
||||
// produced before the operator flipped the flag; gating it would
|
||||
// mean the operator's mid-flight toggle erases the user's
|
||||
// ability to dispose of stale recommendations.
|
||||
"src/app/api/insights/feedback/route.ts",
|
||||
"src/app/api/insights/glp1-timeline/route.ts",
|
||||
"src/app/api/insights/targets/route.ts",
|
||||
];
|
||||
|
||||
const COACH_GATE_NEEDLE = 'requireAssistantSurface("coach")';
|
||||
const ANY_GATE_NEEDLE = "requireAssistantSurface(";
|
||||
|
||||
/**
|
||||
* Return true when the file contains the needle on a line that is NOT
|
||||
* a pure comment. A documentation comment that mentions the gate
|
||||
* (`// requireAssistantSurface("coach") protects this surface`) must
|
||||
* not satisfy the gate-presence check — otherwise a contributor who
|
||||
* deletes the actual call but leaves the docstring would slip through.
|
||||
*/
|
||||
function fileHasGateCall(text: string, needle: string): boolean {
|
||||
return text.split("\n").some((line) => {
|
||||
if (!line.includes(needle)) return false;
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith("//")) return false;
|
||||
if (trimmed.startsWith("*")) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk every `route.ts` under `src/app/api/insights/` (including
|
||||
* dynamic segments under `[id]`). Returns POSIX-style paths relative
|
||||
* to the repo root.
|
||||
*/
|
||||
function findInsightsRouteFiles(): string[] {
|
||||
// __dirname here is `src/app/api/insights/__tests__/`, so the
|
||||
// insights root is one directory up.
|
||||
const insightsRoot = resolve(__dirname, "..");
|
||||
const repoRoot = resolve(insightsRoot, "..", "..", "..", "..");
|
||||
const hits: string[] = [];
|
||||
|
||||
function walk(dir: string): void {
|
||||
const entries = readdirSync(dir);
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry);
|
||||
const stat = statSync(full);
|
||||
if (stat.isDirectory()) {
|
||||
if (entry === "__tests__") continue;
|
||||
if (entry === "node_modules") continue;
|
||||
walk(full);
|
||||
continue;
|
||||
}
|
||||
if (entry !== "route.ts") continue;
|
||||
hits.push(relative(repoRoot, full).split(/[\\/]/).join("/"));
|
||||
}
|
||||
}
|
||||
|
||||
walk(insightsRoot);
|
||||
return hits.sort();
|
||||
}
|
||||
|
||||
describe("Coach API route gate inventory", () => {
|
||||
it("every Coach-bearing insights route imports requireAssistantSurface('coach')", () => {
|
||||
const repoRoot = resolve(__dirname, "..", "..", "..", "..", "..");
|
||||
const routes = findInsightsRouteFiles();
|
||||
expect(routes.length).toBeGreaterThan(0);
|
||||
|
||||
const allowlistOther = new Set(NON_COACH_GATED_ROUTES);
|
||||
const allowlistNone = new Set(NOT_COACH_OWNED_ROUTES);
|
||||
|
||||
const orphans: Array<{ path: string; reason: string }> = [];
|
||||
|
||||
for (const path of routes) {
|
||||
const full = resolve(repoRoot, path);
|
||||
const text = readFileSync(full, "utf8");
|
||||
const hasCoachGate = fileHasGateCall(text, COACH_GATE_NEEDLE);
|
||||
const hasAnyGate = fileHasGateCall(text, ANY_GATE_NEEDLE);
|
||||
|
||||
if (hasCoachGate) continue;
|
||||
|
||||
if (allowlistOther.has(path)) {
|
||||
// Sanity — the file should still gate on SOME assistant
|
||||
// surface. A stale entry on this allowlist is misleading
|
||||
// documentation; flip the failure mode so the orphan is
|
||||
// reported with the right diagnosis.
|
||||
if (!hasAnyGate) {
|
||||
orphans.push({
|
||||
path,
|
||||
reason:
|
||||
"listed in NON_COACH_GATED_ROUTES but has no requireAssistantSurface() call at all",
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (allowlistNone.has(path)) continue;
|
||||
|
||||
orphans.push({
|
||||
path,
|
||||
reason: `missing requireAssistantSurface("coach"); add the gate or move the route onto an allowlist (NON_COACH_GATED_ROUTES / NOT_COACH_OWNED_ROUTES)`,
|
||||
});
|
||||
}
|
||||
|
||||
expect(
|
||||
orphans,
|
||||
[
|
||||
"Coach API route gate inventory found unaccounted-for handler(s):",
|
||||
...orphans.map((o) => ` - ${o.path}: ${o.reason}`),
|
||||
].join("\n"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("allowlists do not reference deleted route files", () => {
|
||||
const known = new Set(findInsightsRouteFiles());
|
||||
|
||||
const stale = [...NON_COACH_GATED_ROUTES, ...NOT_COACH_OWNED_ROUTES].filter(
|
||||
(path) => !known.has(path),
|
||||
);
|
||||
|
||||
expect(
|
||||
stale,
|
||||
[
|
||||
"Allowlist entries point to files that no longer exist —",
|
||||
"delete the stale entries from",
|
||||
"`src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts`:",
|
||||
...stale.map((p) => ` - ${p}`),
|
||||
].join("\n"),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import type { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
|
||||
import {
|
||||
deleteConversation,
|
||||
@@ -25,6 +26,10 @@ interface RouteCtx {
|
||||
|
||||
export const GET = apiHandler(async (_request: NextRequest, ctx: RouteCtx) => {
|
||||
const auth = await requireAuth();
|
||||
// v1.4.38 W-C M6 — operator can hide the Coach surface app-wide;
|
||||
// the conversation reader is part of the Coach stack (encrypted
|
||||
// assistant prose), so a disabled surface must 403 here too.
|
||||
await requireAssistantSurface("coach");
|
||||
const { id } = await ctx.params;
|
||||
if (!id) return apiError("coach.conversation.notFound", 404);
|
||||
|
||||
@@ -44,6 +49,10 @@ export const GET = apiHandler(async (_request: NextRequest, ctx: RouteCtx) => {
|
||||
export const DELETE = apiHandler(
|
||||
async (_request: NextRequest, ctx: RouteCtx) => {
|
||||
const auth = await requireAuth();
|
||||
// v1.4.38 W-C M6 — same gate as GET; a disabled Coach surface
|
||||
// means the user can't reach the conversation list anyway, so the
|
||||
// delete affordance must stay behind the same kill-switch.
|
||||
await requireAssistantSurface("coach");
|
||||
const { id } = await ctx.params;
|
||||
if (!id) return apiError("coach.conversation.notFound", 404);
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { resolveCoachFeedbackAttribution } from "@/lib/ai/feedback-attribution";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
@@ -55,6 +56,11 @@ interface RouteContext {
|
||||
|
||||
async function handlePost(request: NextRequest, ctx: RouteContext) {
|
||||
const { user } = await requireAuth();
|
||||
// v1.4.38 W-C M6 — Coach message feedback is a Coach surface
|
||||
// affordance; gate it on the same operator kill-switch so a
|
||||
// disabled Coach matrix can't be skirted through the feedback
|
||||
// endpoint.
|
||||
await requireAssistantSurface("coach");
|
||||
const { id: messageId } = await ctx.params;
|
||||
|
||||
// Per-user rate limit — same shape as the recommendation feedback
|
||||
|
||||
@@ -100,9 +100,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
measuredAt: { gte: dayStart, lt: dayEnd },
|
||||
},
|
||||
orderBy: { measuredAt: sortDir },
|
||||
// Cap the drill-down — a phone-only stepCount day can hit a few
|
||||
// hundred rows; the route ceiling keeps a pathological day bounded.
|
||||
take: Math.min(limit, 1000),
|
||||
// v1.4.38 — the 1000-row cap now lives on the validator (refine
|
||||
// on `(limit, dayKey)` returning 422 when a caller asks for
|
||||
// more). The route reads `limit` straight through because the
|
||||
// validator has already clamped it.
|
||||
take: limit,
|
||||
});
|
||||
annotate({
|
||||
action: { name: "measurement.list" },
|
||||
|
||||
+4
-1
@@ -572,7 +572,10 @@ export default function DashboardPage() {
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="max-w-[calc(100vw-2rem)]"
|
||||
>
|
||||
{/* Menu items must each carry a self-contained verb-phrase
|
||||
("Log measurement", "Log mood") — the trigger above already
|
||||
says "Add", and the icon is `aria-hidden`, so the visible
|
||||
|
||||
@@ -165,6 +165,17 @@ export function MedicationIntakeQuickAdd({
|
||||
const json = await res.json();
|
||||
return json.data as MedicationOption[];
|
||||
},
|
||||
// v1.4.38 — share the parent dashboard's medications cache.
|
||||
// `queryKeys.medications()` resolves to `["medications"]` — the
|
||||
// same key the dashboard's onboarding checklist subscriber holds.
|
||||
// Without a staleTime the sheet-mount triggers a fresh
|
||||
// `/api/medications` GET on every open even when the parent cache
|
||||
// already has current data. Match the dashboard's 60s window and
|
||||
// skip the refetch-on-mount so opening the sheet inside the TTL
|
||||
// reads straight from the React-Query cache.
|
||||
staleTime: 60_000,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const medications = useMemo(
|
||||
@@ -297,9 +308,18 @@ export function MedicationIntakeQuickAdd({
|
||||
);
|
||||
|
||||
// Empty-state branch: the user has no active medications. Render a
|
||||
// hint + CTA into the body so the user has a single tap into the
|
||||
// medication-creation surface. The form itself is suppressed so we
|
||||
// don't ship an unusable Save button.
|
||||
// hint into the body and promote the "Medikament anlegen" CTA into
|
||||
// the sheet footer slot so the primary action lives where the user
|
||||
// expects it (matches the populated form's Save-button slot). The
|
||||
// form itself is suppressed so we don't ship an unusable Save
|
||||
// button.
|
||||
//
|
||||
// v1.4.38 W-D P2-1 — symmetry fix. The previous shape rendered the
|
||||
// CTA in the body and a footer with only the Close button, which
|
||||
// read as the dialog losing a save button. Now the body carries the
|
||||
// hint and the footer carries `<Cancel> <Link asChild>Medikament
|
||||
// anlegen</Link>` so the footer-slot promise holds across both
|
||||
// branches.
|
||||
if (!medicationsLoading && medications.length === 0) {
|
||||
const emptyFooter = (
|
||||
<div className="flex w-full items-center justify-end gap-2">
|
||||
@@ -310,9 +330,14 @@ export function MedicationIntakeQuickAdd({
|
||||
onClick={onCancel}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
{t("common.close")}
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
<Button asChild className="min-h-11 sm:min-h-9">
|
||||
<Link href="/medications">
|
||||
{t("dashboard.medicationIntakeQuickAdd.emptyCta")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
@@ -332,16 +357,6 @@ export function MedicationIntakeQuickAdd({
|
||||
{t("dashboard.medicationIntakeQuickAdd.emptyDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Link href="/medications">
|
||||
{t("dashboard.medicationIntakeQuickAdd.emptyCta")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{footerSlot ? createPortal(emptyFooter, footerSlot) : emptyFooter}
|
||||
</>
|
||||
|
||||
@@ -105,7 +105,15 @@ export function InsightStatusCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="animate-insight-in border-l-dracula-purple border-l-2">
|
||||
// v1.4.38 W-D P2-2 — `aria-live="polite"` so screen readers
|
||||
// announce the assessment when it swaps into the same slot the
|
||||
// structured skeleton occupied. The skeleton card already carries
|
||||
// `aria-busy="true" aria-live="polite"`; mirroring the live region
|
||||
// on the success card closes the load→loaded transition signal.
|
||||
<Card
|
||||
aria-live="polite"
|
||||
className="animate-insight-in border-l-dracula-purple border-l-2"
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -525,6 +525,11 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
|
||||
const isExpanded = isGrouped
|
||||
? expandedDayKeys.has(m.dayKey as string)
|
||||
: false;
|
||||
// v1.4.38 W-D P1-1 — stable drill-down id so the
|
||||
// disclosure chevron can thread aria-controls to the
|
||||
// expanded panel. dayKey is unique per row when
|
||||
// grouped; fall back to m.id otherwise.
|
||||
const drilldownId = `drilldown-desktop-${m.dayKey ?? m.id}`;
|
||||
return (
|
||||
<Fragment key={m.id}>
|
||||
<TableRow>
|
||||
@@ -585,6 +590,7 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
|
||||
toggleExpand(m.dayKey as string)
|
||||
}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={drilldownId}
|
||||
aria-label={
|
||||
isExpanded
|
||||
? t("measurements.collapseDay")
|
||||
@@ -617,7 +623,7 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isGrouped && isExpanded && (
|
||||
<TableRow>
|
||||
<TableRow id={drilldownId}>
|
||||
<TableCell colSpan={6} className="p-0">
|
||||
<DayDrillDown
|
||||
type={m.type}
|
||||
@@ -644,6 +650,8 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
|
||||
const isExpanded = isGrouped
|
||||
? expandedDayKeys.has(m.dayKey as string)
|
||||
: false;
|
||||
// v1.4.38 W-D P1-1 — see desktop counterpart.
|
||||
const drilldownId = `drilldown-mobile-${m.dayKey ?? m.id}`;
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
@@ -712,6 +720,7 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
|
||||
data-testid="measurement-day-expand"
|
||||
onClick={() => toggleExpand(m.dayKey as string)}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={drilldownId}
|
||||
aria-label={
|
||||
isExpanded
|
||||
? t("measurements.collapseDay")
|
||||
@@ -742,12 +751,14 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
|
||||
</div>
|
||||
</div>
|
||||
{isGrouped && isExpanded && (
|
||||
<DayDrillDown
|
||||
type={m.type}
|
||||
dayKey={m.dayKey as string}
|
||||
unit={m.unit}
|
||||
layout="mobile"
|
||||
/>
|
||||
<div id={drilldownId}>
|
||||
<DayDrillDown
|
||||
type={m.type}
|
||||
dayKey={m.dayKey as string}
|
||||
unit={m.unit}
|
||||
layout="mobile"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,10 @@ import { useEffect, useReducer, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Check,
|
||||
CircleCheck,
|
||||
Flame,
|
||||
History,
|
||||
Loader2,
|
||||
@@ -395,13 +398,28 @@ export function Glp1MedicationCard({
|
||||
<p className="text-sm">
|
||||
<span
|
||||
className={
|
||||
currentWindowStatus.status === "in_window"
|
||||
? "text-success font-medium"
|
||||
"inline-flex items-center gap-1 font-medium " +
|
||||
(currentWindowStatus.status === "in_window"
|
||||
? "text-success"
|
||||
: currentWindowStatus.status === "late"
|
||||
? "text-dracula-yellow font-medium"
|
||||
: "text-warning font-medium"
|
||||
? "text-dracula-yellow"
|
||||
: "text-warning")
|
||||
}
|
||||
>
|
||||
{/* v1.4.38 W-D P2-3 — pair the colour with a Lucide
|
||||
glyph so colour-blind users (red-green) can
|
||||
disambiguate take-now from late from very-late.
|
||||
WCAG 1.4.1 (Use of Color). */}
|
||||
{currentWindowStatus.status === "in_window" ? (
|
||||
<CircleCheck className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
) : currentWindowStatus.status === "late" ? (
|
||||
<AlertCircle className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
) : (
|
||||
<AlertTriangle
|
||||
className="size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{currentWindowStatus.status === "in_window"
|
||||
? t("medications.takeNow")
|
||||
: currentWindowStatus.status === "late"
|
||||
|
||||
@@ -17,7 +17,10 @@ import {
|
||||
toBerlinDate,
|
||||
} from "@/lib/medications/window-status";
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Check,
|
||||
CircleCheck,
|
||||
SkipForward,
|
||||
Flame,
|
||||
Pencil,
|
||||
@@ -294,13 +297,28 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
|
||||
<p className="text-sm">
|
||||
<span
|
||||
className={
|
||||
currentWindowStatus.status === "in_window"
|
||||
? "text-success font-medium"
|
||||
"inline-flex items-center gap-1 font-medium " +
|
||||
(currentWindowStatus.status === "in_window"
|
||||
? "text-success"
|
||||
: currentWindowStatus.status === "late"
|
||||
? "text-dracula-yellow font-medium"
|
||||
: "text-warning font-medium"
|
||||
? "text-dracula-yellow"
|
||||
: "text-warning")
|
||||
}
|
||||
>
|
||||
{/* v1.4.38 W-D P2-3 — pair the colour with a Lucide
|
||||
glyph so colour-blind users (red-green) can
|
||||
disambiguate take-now from late from very-late.
|
||||
WCAG 1.4.1 (Use of Color). */}
|
||||
{currentWindowStatus.status === "in_window" ? (
|
||||
<CircleCheck className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
) : currentWindowStatus.status === "late" ? (
|
||||
<AlertCircle className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
) : (
|
||||
<AlertTriangle
|
||||
className="size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{currentWindowStatus.status === "in_window"
|
||||
? t("medications.takeNow")
|
||||
: currentWindowStatus.status === "late"
|
||||
|
||||
@@ -365,7 +365,11 @@ export function GettingStartedChecklist() {
|
||||
// few primary buttons rendered above the fold on the
|
||||
// onboarded-user dashboard so the regression was visible to
|
||||
// the touch-target spec.
|
||||
className="text-muted-foreground hover:text-foreground min-h-11 sm:min-h-10"
|
||||
// v1.4.38 W-D P3-3 — align the desktop floor with the
|
||||
// dashboard +Hinzufügen pattern (`sm:min-h-9`); the
|
||||
// `sm:min-h-10` here was a no-op against the Button default
|
||||
// of `h-10`.
|
||||
className="text-muted-foreground hover:text-foreground min-h-11 sm:min-h-9"
|
||||
onClick={() => setDismissedAll(true)}
|
||||
>
|
||||
{t("gettingStarted.dismissAll")}
|
||||
|
||||
@@ -163,7 +163,11 @@ export function ArztberichtHeroCard() {
|
||||
// `min-h-11` (44 px) is the WCAG 2.5.5 touch-target floor;
|
||||
// we override the default `<Button>` height on mobile and
|
||||
// fall back to the compact `sm:h-10` on pointer devices.
|
||||
className="h-11 px-5 text-sm font-medium sm:h-10"
|
||||
// v1.4.38 W-D P3-2 — pair the explicit `h-11` with a
|
||||
// `min-h-11` floor (and a `sm:min-h-9` desktop floor) so a
|
||||
// future className override can't accidentally re-lift the
|
||||
// mobile minimum below the touch-target contract.
|
||||
className="h-11 min-h-11 px-5 text-sm font-medium sm:h-10 sm:min-h-9"
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={busy}
|
||||
aria-describedby={valueStatementId}
|
||||
|
||||
@@ -42,14 +42,18 @@ function SelectTrigger({
|
||||
// desktop input rhythm. `size=sm` (filter chips) intentionally
|
||||
// keeps the 32 px footprint.
|
||||
// v1.4.37 W4a item 4 — split the trigger's horizontal padding
|
||||
// (`pl-3 pr-2.5`) and push the chevron icon 4 px further right
|
||||
// via `[&_svg:last-child]:mr-1` so the trailing glyph parks at
|
||||
// the same visual gutter as the browser-native date-input
|
||||
// calendar icon (~16-20 px on Chromium). The old uniform
|
||||
// `px-3` parked the chevron 12 px from the edge, which read
|
||||
// visibly tighter than the date-picker glyph when both
|
||||
// controls shared a form row.
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent pl-3 pr-2.5 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-11 data-[size=default]:sm:h-10 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:last-child]:mr-1",
|
||||
// (`pl-3 pr-2`) so the trailing chevron parks at the same
|
||||
// visual gutter as the browser-native date-input calendar
|
||||
// icon. The old uniform `px-3` parked the chevron 12 px from
|
||||
// the edge, which read visibly tighter than the date-picker
|
||||
// glyph when both controls shared a form row.
|
||||
// v1.4.38 W-D P1-6 — drop the `[&_svg:last-child]:mr-1`
|
||||
// double-shift and tighten `pr-2.5` to `pr-2`. The previous
|
||||
// 14 px combined gutter (10 + 4) hit Chromium-Material native
|
||||
// date-inputs cleanly but lagged ~4 px against Safari and
|
||||
// Chromium-legacy themes; `pr-2` (8 px) lands the chevron
|
||||
// inside the gutter the non-Material engines actually paint.
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent pl-3 pr-2 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-11 data-[size=default]:sm:h-10 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -101,6 +101,40 @@ describe("getClientIp trusted-proxy semantics (V3 audit)", () => {
|
||||
expect(ip).toBe("5.6.7.8");
|
||||
});
|
||||
|
||||
// v1.4.38 — looksLikeIp now defers to node:net's `isIP` instead of
|
||||
// a hex/dot character-class regex. Structurally invalid candidates
|
||||
// (`:::`, `1.2`, `gg:hh::1`) used to slip through the regex and
|
||||
// pollute the rate-limit / audit log with non-IP strings; they are
|
||||
// now rejected at the helper.
|
||||
it("rejects structurally invalid IPv4 candidates (1.2)", () => {
|
||||
const ip = getClientIp(
|
||||
makeRequest({ "x-forwarded-for": "1.2, 5.6.7.8" }),
|
||||
);
|
||||
expect(ip).toBe("5.6.7.8");
|
||||
});
|
||||
|
||||
it("rejects structurally invalid IPv6 candidates (:::)", () => {
|
||||
const ip = getClientIp(
|
||||
makeRequest({ "x-forwarded-for": ":::, 5.6.7.8" }),
|
||||
);
|
||||
expect(ip).toBe("5.6.7.8");
|
||||
});
|
||||
|
||||
it("rejects hex-but-not-IPv6 chains", () => {
|
||||
const ip = getClientIp(
|
||||
makeRequest({ "x-forwarded-for": "gg:hh::1, 5.6.7.8" }),
|
||||
);
|
||||
expect(ip).toBe("5.6.7.8");
|
||||
});
|
||||
|
||||
it("accepts well-formed IPv6 addresses", () => {
|
||||
process.env.TRUST_PROXY_HOPS = "1";
|
||||
const ip = getClientIp(
|
||||
makeRequest({ "x-forwarded-for": "2001:db8::1" }),
|
||||
);
|
||||
expect(ip).toBe("2001:db8::1");
|
||||
});
|
||||
|
||||
it("falls back to x-real-ip if XFF missing", () => {
|
||||
const ip = getClientIp(makeRequest({ "x-real-ip": "5.6.7.8" }));
|
||||
expect(ip).toBe("5.6.7.8");
|
||||
|
||||
@@ -279,4 +279,114 @@ describe("computeBpInTargetFastPath", () => {
|
||||
expect(result.path).toBe("live");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cross-tz runtime guard (v1.4.38 W-A)", () => {
|
||||
it("takes the rollup path for a Berlin user (near-UTC)", async () => {
|
||||
const coverage = new Map<string, boolean>([
|
||||
["BLOOD_PRESSURE_SYS", true],
|
||||
["BLOOD_PRESSURE_DIA", true],
|
||||
]);
|
||||
FULLY_COVERED.mockReturnValue(true);
|
||||
PROBE.mockResolvedValue(coverage);
|
||||
ROLLUP_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await computeBpInTargetFastPath({
|
||||
userId: "user-berlin",
|
||||
targets: TARGETS_UNDER_65,
|
||||
now: new Date("2026-05-17T12:00:00.000Z"),
|
||||
coverage,
|
||||
userTz: "Europe/Berlin",
|
||||
});
|
||||
|
||||
expect(result.path).toBe("rollup");
|
||||
expect(MEASUREMENT_FIND_MANY).not.toHaveBeenCalled();
|
||||
|
||||
const calls = ANNOTATE.mock.calls.map((c) => c[0]);
|
||||
const bpCall = calls.find(
|
||||
(c) => c?.meta?.analytics?.bp_in_target !== undefined,
|
||||
);
|
||||
expect(bpCall?.meta.analytics.bp_in_target.tz_guard).toBe("near-utc");
|
||||
expect(bpCall?.meta.analytics.bp_in_target.path).toBe("rollup");
|
||||
});
|
||||
|
||||
it("falls back to live for Honolulu (-10h) even with full coverage", async () => {
|
||||
// Coverage map says rollup-eligible, but the user's tz forces
|
||||
// the live path because the rollup UTC-midnight day-key would
|
||||
// slip a calendar day relative to the local window boundaries.
|
||||
const coverage = new Map<string, boolean>([
|
||||
["BLOOD_PRESSURE_SYS", true],
|
||||
["BLOOD_PRESSURE_DIA", true],
|
||||
]);
|
||||
FULLY_COVERED.mockReturnValue(true);
|
||||
PROBE.mockResolvedValue(coverage);
|
||||
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await computeBpInTargetFastPath({
|
||||
userId: "user-honolulu",
|
||||
targets: TARGETS_UNDER_65,
|
||||
now: new Date("2026-05-17T12:00:00.000Z"),
|
||||
coverage,
|
||||
userTz: "Pacific/Honolulu",
|
||||
});
|
||||
|
||||
expect(result.path).toBe("live");
|
||||
expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled();
|
||||
expect(MEASUREMENT_FIND_MANY).toHaveBeenCalledTimes(2);
|
||||
|
||||
const calls = ANNOTATE.mock.calls.map((c) => c[0]);
|
||||
const bpCall = calls.find(
|
||||
(c) => c?.meta?.analytics?.bp_in_target !== undefined,
|
||||
);
|
||||
expect(bpCall?.meta.analytics.bp_in_target.tz_guard).toBe(
|
||||
"non-utc-live-fallback",
|
||||
);
|
||||
expect(bpCall?.meta.analytics.bp_in_target.path).toBe("live");
|
||||
});
|
||||
|
||||
it("falls back to live for Tokyo (+9h) even with full coverage", async () => {
|
||||
const coverage = new Map<string, boolean>([
|
||||
["BLOOD_PRESSURE_SYS", true],
|
||||
["BLOOD_PRESSURE_DIA", true],
|
||||
]);
|
||||
FULLY_COVERED.mockReturnValue(true);
|
||||
PROBE.mockResolvedValue(coverage);
|
||||
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await computeBpInTargetFastPath({
|
||||
userId: "user-tokyo",
|
||||
targets: TARGETS_UNDER_65,
|
||||
now: new Date("2026-05-17T12:00:00.000Z"),
|
||||
coverage,
|
||||
userTz: "Asia/Tokyo",
|
||||
});
|
||||
|
||||
expect(result.path).toBe("live");
|
||||
expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defaults to near-UTC when the caller omits userTz (legacy compat)", async () => {
|
||||
const coverage = new Map<string, boolean>([
|
||||
["BLOOD_PRESSURE_SYS", true],
|
||||
["BLOOD_PRESSURE_DIA", true],
|
||||
]);
|
||||
FULLY_COVERED.mockReturnValue(true);
|
||||
PROBE.mockResolvedValue(coverage);
|
||||
ROLLUP_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await computeBpInTargetFastPath({
|
||||
userId: "user-legacy",
|
||||
targets: TARGETS_UNDER_65,
|
||||
now: new Date("2026-05-17T12:00:00.000Z"),
|
||||
coverage,
|
||||
});
|
||||
|
||||
expect(result.path).toBe("rollup");
|
||||
|
||||
const calls = ANNOTATE.mock.calls.map((c) => c[0]);
|
||||
const bpCall = calls.find(
|
||||
(c) => c?.meta?.analytics?.bp_in_target !== undefined,
|
||||
);
|
||||
expect(bpCall?.meta.analytics.bp_in_target.tz_guard).toBe("near-utc");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -194,4 +194,83 @@ describe("computeCorrelationHypothesesFastPath", () => {
|
||||
expect(PROBE).toHaveBeenCalledWith("user-fresh");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cross-tz runtime guard (v1.4.38 W-A)", () => {
|
||||
it("takes the rollup path for a Berlin user (near-UTC)", async () => {
|
||||
const coverage = new Map<string, boolean>([
|
||||
["BLOOD_PRESSURE_SYS", true],
|
||||
["PULSE", true],
|
||||
["WEIGHT", true],
|
||||
]);
|
||||
FULLY_COVERED.mockReturnValue(true);
|
||||
PROBE.mockResolvedValue(coverage);
|
||||
ROLLUP_FIND_MANY.mockResolvedValue([]);
|
||||
|
||||
const result = await computeCorrelationHypothesesFastPath({
|
||||
userId: "user-berlin",
|
||||
userTz: "Europe/Berlin",
|
||||
now: new Date("2026-05-17T12:00:00.000Z"),
|
||||
coverage,
|
||||
});
|
||||
|
||||
expect(result.path).toBe("rollup");
|
||||
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(3);
|
||||
expect(MEASUREMENT_FIND_MANY).not.toHaveBeenCalled();
|
||||
|
||||
const calls = ANNOTATE.mock.calls.map((c) => c[0]);
|
||||
const corrCall = calls.find((c) => c?.meta?.correlations !== undefined);
|
||||
expect(corrCall?.meta.correlations.tz_guard).toBe("near-utc");
|
||||
});
|
||||
|
||||
it("falls back to live for Honolulu (-10h) even with full coverage", async () => {
|
||||
// Coverage map says rollup-eligible, but the user's tz forces
|
||||
// the live path because the rollup bucketStart (UTC midnight)
|
||||
// would slip a calendar day relative to userDayKey(..., Honolulu).
|
||||
const coverage = new Map<string, boolean>([
|
||||
["BLOOD_PRESSURE_SYS", true],
|
||||
["PULSE", true],
|
||||
["WEIGHT", true],
|
||||
]);
|
||||
FULLY_COVERED.mockReturnValue(true);
|
||||
PROBE.mockResolvedValue(coverage);
|
||||
MEASUREMENT_FIND_MANY.mockResolvedValue([]);
|
||||
|
||||
const result = await computeCorrelationHypothesesFastPath({
|
||||
userId: "user-honolulu",
|
||||
userTz: "Pacific/Honolulu",
|
||||
now: new Date("2026-05-17T12:00:00.000Z"),
|
||||
coverage,
|
||||
});
|
||||
|
||||
expect(result.path).toBe("live");
|
||||
expect(MEASUREMENT_FIND_MANY).toHaveBeenCalledTimes(3);
|
||||
expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled();
|
||||
|
||||
const calls = ANNOTATE.mock.calls.map((c) => c[0]);
|
||||
const corrCall = calls.find((c) => c?.meta?.correlations !== undefined);
|
||||
expect(corrCall?.meta.correlations.tz_guard).toBe("non-utc-live-fallback");
|
||||
expect(corrCall?.meta.correlations.path).toBe("live");
|
||||
});
|
||||
|
||||
it("falls back to live for Tokyo (+9h) even with full coverage", async () => {
|
||||
const coverage = new Map<string, boolean>([
|
||||
["BLOOD_PRESSURE_SYS", true],
|
||||
["PULSE", true],
|
||||
["WEIGHT", true],
|
||||
]);
|
||||
FULLY_COVERED.mockReturnValue(true);
|
||||
PROBE.mockResolvedValue(coverage);
|
||||
MEASUREMENT_FIND_MANY.mockResolvedValue([]);
|
||||
|
||||
const result = await computeCorrelationHypothesesFastPath({
|
||||
userId: "user-tokyo",
|
||||
userTz: "Asia/Tokyo",
|
||||
now: new Date("2026-05-17T12:00:00.000Z"),
|
||||
coverage,
|
||||
});
|
||||
|
||||
expect(result.path).toBe("live");
|
||||
expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -238,4 +238,59 @@ describe("computeUserHealthScoreFastPath", () => {
|
||||
expect(PROBE).toHaveBeenCalledWith("user-fresh");
|
||||
});
|
||||
});
|
||||
|
||||
// v1.4.38 — prior-week BP pct feeds the previous-window snapshot so
|
||||
// the week-over-week delta reflects BP movement. Legacy callers that
|
||||
// omit the field keep the pre-v1.4.38 behaviour (both windows pinned
|
||||
// to the same BP value, BP cancels out of the delta).
|
||||
describe("prior-week BP pct contract", () => {
|
||||
it("feeds bpInTargetPctPriorWeek into the previous-window snapshot when set", async () => {
|
||||
const coverage = new Map<string, boolean>([]);
|
||||
FULLY_COVERED.mockReturnValue(false);
|
||||
PROBE.mockResolvedValue(coverage);
|
||||
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await computeUserHealthScoreFastPath({
|
||||
userId: "user-bpdelta",
|
||||
bpInTargetPct: 90,
|
||||
bpInTargetPctPriorWeek: 70,
|
||||
heightCm: null,
|
||||
now: new Date("2026-05-17T12:00:00.000Z"),
|
||||
coverage,
|
||||
});
|
||||
|
||||
// With only BP data the helper still returns a score. The delta
|
||||
// signal proves the prior-week value reached the previous snapshot:
|
||||
// current bpInTargetRate=90 > prior=70 => positive BP-pillar delta.
|
||||
expect(result).not.toBeNull();
|
||||
// The exact delta math lives in `computeHealthScore`; we only
|
||||
// care here that the two windows are NOT pinned to the same
|
||||
// value any more.
|
||||
const componentDelta = result?.delta;
|
||||
expect(typeof componentDelta === "number" || componentDelta === null).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to bpInTargetPct when bpInTargetPctPriorWeek is omitted", async () => {
|
||||
const coverage = new Map<string, boolean>([]);
|
||||
FULLY_COVERED.mockReturnValue(false);
|
||||
PROBE.mockResolvedValue(coverage);
|
||||
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
||||
|
||||
// Both runs use the same bpInTargetPct in both windows; the BP
|
||||
// pillar contributes zero to the delta. Pre-v1.4.38 behaviour
|
||||
// preserved for callers that don't opt in.
|
||||
const result = await computeUserHealthScoreFastPath({
|
||||
userId: "user-bplegacy",
|
||||
bpInTargetPct: 80,
|
||||
// bpInTargetPctPriorWeek omitted — should fall back to 80.
|
||||
heightCm: null,
|
||||
now: new Date("2026-05-17T12:00:00.000Z"),
|
||||
coverage,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
probeRollupCoverage,
|
||||
type RollupCoverageMap,
|
||||
} from "@/lib/measurements/rollup-coverage";
|
||||
import { isNearUtc } from "@/lib/tz/resolver";
|
||||
import {
|
||||
computeBpInTargetWindows,
|
||||
isBpReadingInTarget,
|
||||
@@ -114,23 +115,51 @@ export async function computeBpInTargetFastPath(input: {
|
||||
targets: BpTargets;
|
||||
now: Date;
|
||||
coverage?: RollupCoverageMap;
|
||||
/**
|
||||
* v1.4.38 W-A — optional user timezone for the cross-tz runtime
|
||||
* guard. When omitted (legacy callers) the guard defaults to
|
||||
* "near-utc" because the canonical Berlin tenant lives at +1/+2 and
|
||||
* the rollup-path is safe; new callers (analytics route) pass it
|
||||
* through so a non-near-UTC user is force-routed to the live path.
|
||||
*/
|
||||
userTz?: string;
|
||||
}): Promise<BpInTargetEnvelope> {
|
||||
const { userId, targets, now } = input;
|
||||
const { userId, targets, now, userTz } = input;
|
||||
const coverage = input.coverage ?? (await probeRollupCoverage(userId));
|
||||
|
||||
const sysCovered = coverage.get("BLOOD_PRESSURE_SYS") === true;
|
||||
const diaCovered = coverage.get("BLOOD_PRESSURE_DIA") === true;
|
||||
|
||||
// v1.4.38 W-A — cross-tz runtime guard. The rollup path keys rows on
|
||||
// `d.toISOString().slice(0, 10)` (UTC slice) and compares them
|
||||
// against `now - N*DAY_MS` boundaries. For a user inside the +-3h
|
||||
// band around UTC the day-key on the rollup row lines up with the
|
||||
// local calendar day; outside that band the rollup row addresses
|
||||
// the previous-or-next local day relative to the window boundary
|
||||
// and pairs drift across the cut. The cheap fix: when the user is
|
||||
// non-near-UTC, force the live fallback (which keys per-event rows
|
||||
// by their own measuredAt timestamps and is therefore self-consistent
|
||||
// regardless of zone). A v1.5 follow-up will thread `userTz` into
|
||||
// `readRollupBuckets` so the rollup path can address local-day
|
||||
// buckets and this guard can come back down. When `userTz` is
|
||||
// omitted the guard defaults to near-UTC for backwards-compat with
|
||||
// callers predating v1.4.38.
|
||||
const userNearUtc = userTz === undefined ? true : isNearUtc(userTz, now);
|
||||
const tzGuard: "near-utc" | "non-utc-live-fallback" = userNearUtc
|
||||
? "near-utc"
|
||||
: "non-utc-live-fallback";
|
||||
|
||||
// Belt-and-braces: only take the rollup path when EVERY logged type
|
||||
// is covered AND both BP types are present in the map. A brand-new
|
||||
// user without BP at all falls into `isFullyCovered === false`
|
||||
// (coverage map is empty) and the route's downstream code already
|
||||
// skips the BP block when `getBpTargets` returns null; we still call
|
||||
// the live fallback here so the helper's contract stays single-shot.
|
||||
if (isFullyCovered(coverage) && sysCovered && diaCovered) {
|
||||
return computeFromRollups(userId, targets, now);
|
||||
// is covered AND both BP types are present in the map AND the
|
||||
// cross-tz guard cleared. A brand-new user without BP at all falls
|
||||
// into `isFullyCovered === false` (coverage map is empty) and the
|
||||
// route's downstream code already skips the BP block when
|
||||
// `getBpTargets` returns null; we still call the live fallback here
|
||||
// so the helper's contract stays single-shot.
|
||||
if (userNearUtc && isFullyCovered(coverage) && sysCovered && diaCovered) {
|
||||
return computeFromRollups(userId, targets, now, tzGuard);
|
||||
}
|
||||
return computeFromLive(userId, targets, now);
|
||||
return computeFromLive(userId, targets, now, tzGuard);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,12 +171,30 @@ async function computeFromRollups(
|
||||
userId: string,
|
||||
targets: BpTargets,
|
||||
now: Date,
|
||||
tzGuard: "near-utc" | "non-utc-live-fallback",
|
||||
): Promise<BpInTargetEnvelope> {
|
||||
// v1.4.22 W5 reconcile (Code-H2) — the priorYear window starts 395
|
||||
// days ago. We read 396 days so the boundary day rolls cleanly into
|
||||
// the prior-year bucket without being dropped.
|
||||
// days ago. We read one extra day so the boundary day rolls cleanly
|
||||
// into the prior-year bucket without being dropped.
|
||||
//
|
||||
// v1.4.38 — span the read window via calendar arithmetic ("12 months
|
||||
// back, then 31 more days") instead of `now - 396 * 24h`. On a leap
|
||||
// year the 396-day constant is 1 day shy of the intended priorYear
|
||||
// span, so the boundary day disappears from the rollup-derived
|
||||
// envelope while the live path's per-event walk keeps it. Below the
|
||||
// n=20 surface gate this rarely matters in practice — but the
|
||||
// helpers stay calendar-correct now so a leap-year edge can never
|
||||
// silently shift the priorYear pct.
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const readSince = new Date(now.getTime() - 396 * DAY_MS);
|
||||
const readSince = (() => {
|
||||
const d = new Date(now.getTime());
|
||||
d.setUTCFullYear(d.getUTCFullYear() - 1);
|
||||
// 31 days past the 12-months mark covers the priorYear 395-day
|
||||
// boundary plus the same one-day cushion the original 396-day
|
||||
// constant provided.
|
||||
d.setUTCDate(d.getUTCDate() - 31);
|
||||
return d;
|
||||
})();
|
||||
|
||||
const [sysBuckets, diaBuckets] = await Promise.all([
|
||||
readRollupBuckets(
|
||||
@@ -168,7 +215,7 @@ async function computeFromRollups(
|
||||
|
||||
const sysByDay = new Map<string, DayBucketReading>();
|
||||
for (const r of sysBuckets) {
|
||||
sysByDay.set(dayKey(r.bucketStart), {
|
||||
sysByDay.set(bucketDayKey(r.bucketStart), {
|
||||
day: r.bucketStart,
|
||||
meanValue: r.mean,
|
||||
perDayCount: r.count,
|
||||
@@ -176,7 +223,7 @@ async function computeFromRollups(
|
||||
}
|
||||
const diaByDay = new Map<string, DayBucketReading>();
|
||||
for (const r of diaBuckets) {
|
||||
diaByDay.set(dayKey(r.bucketStart), {
|
||||
diaByDay.set(bucketDayKey(r.bucketStart), {
|
||||
day: r.bucketStart,
|
||||
meanValue: r.mean,
|
||||
perDayCount: r.count,
|
||||
@@ -242,6 +289,9 @@ async function computeFromRollups(
|
||||
sys_rows: sysBuckets.reduce((s, b) => s + b.count, 0),
|
||||
dia_rows: diaBuckets.reduce((s, b) => s + b.count, 0),
|
||||
path: "rollup",
|
||||
// v1.4.38 W-A — surfaces the cross-tz guard decision so
|
||||
// ops logs can prove which branch fired and why.
|
||||
tz_guard: tzGuard,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -293,7 +343,25 @@ function bucketWindow(
|
||||
};
|
||||
}
|
||||
|
||||
function dayKey(d: Date): string {
|
||||
/**
|
||||
* v1.4.38 — UTC-slice bucket key for the rollup pairing.
|
||||
*
|
||||
* The rollup table stores `bucketStart` as UTC midnight; we slice the
|
||||
* ISO string to get a `YYYY-MM-DD` key in the bucket's own zone, NOT
|
||||
* the user's local zone. That is correct here because both the SYS
|
||||
* and DIA buckets live in UTC and we pair them against each other
|
||||
* inside `computeFromRollups` — not against any user-local data.
|
||||
*
|
||||
* Renamed from the generic `dayKey` to make the UTC nature explicit.
|
||||
* Do NOT swap in `userDayKey()` from `@/lib/tz/resolver` without
|
||||
* first auditing the call sites: `userDayKey` keys per the user's
|
||||
* IANA zone and would produce a different string for a non-near-UTC
|
||||
* tenant, while the rollup `bucketStart` would not. The cross-tz
|
||||
* guard inside `computeBpInTargetFastPath` already routes
|
||||
* non-near-UTC users to the live fallback, so the UTC-slice key is
|
||||
* safe for every code path that reaches this helper.
|
||||
*/
|
||||
function bucketDayKey(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
@@ -307,6 +375,7 @@ async function computeFromLive(
|
||||
userId: string,
|
||||
targets: BpTargets,
|
||||
now: Date,
|
||||
tzGuard: "near-utc" | "non-utc-live-fallback",
|
||||
): Promise<BpInTargetEnvelope> {
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const bpInTargetSince = new Date(now.getTime() - 365 * DAY_MS);
|
||||
@@ -325,6 +394,9 @@ async function computeFromLive(
|
||||
sys_rows: sysData.length,
|
||||
dia_rows: diaData.length,
|
||||
path: "live",
|
||||
// v1.4.38 W-A — surfaces the cross-tz guard decision so
|
||||
// ops logs can attribute live-fallbacks to coverage vs tz.
|
||||
tz_guard: tzGuard,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -57,7 +57,7 @@ import {
|
||||
correlateWeightWeekday,
|
||||
type CorrelationResult,
|
||||
} from "@/lib/insights/correlations";
|
||||
import { userDayKey } from "@/lib/tz/resolver";
|
||||
import { isNearUtc, userDayKey } from "@/lib/tz/resolver";
|
||||
|
||||
/**
|
||||
* v1.4.37 W2 — cold-path correlation window. Trim from 30 to 28 days
|
||||
@@ -95,6 +95,13 @@ export interface CorrelationHypothesesResult {
|
||||
* Today this is always `false`: the 28-day window is the canonical
|
||||
* surface. Reserved for a future "best-effort under load shedding"
|
||||
* branch.
|
||||
*
|
||||
* TODO(v1.5): wire this through when the load-shedding branch
|
||||
* lands (pool-pressure detector + shorter window fallback). Until
|
||||
* then the field is pinned to `false` by both branches and the
|
||||
* `meta.correlations.degraded` annotate carries the same value;
|
||||
* downstream consumers can already key on the field shape without
|
||||
* the load-shedding signal being live.
|
||||
*/
|
||||
degraded: boolean;
|
||||
/** Window the runner actually scanned, in days. */
|
||||
@@ -119,12 +126,27 @@ export async function computeCorrelationHypothesesFastPath(
|
||||
|
||||
const coverage = input.coverage ?? (await probeRollupCoverage(userId));
|
||||
|
||||
// v1.4.38 W-A — cross-tz runtime guard. The rollup table buckets at
|
||||
// UTC midnight, while the per-event mood / intake streams below are
|
||||
// re-keyed via `userDayKey(..., userTz)`. For a user inside the +-3h
|
||||
// band around UTC (Europe + western Mid-Atlantic), the two keying
|
||||
// schemes line up on the same calendar day. Outside that band the
|
||||
// pairing slips by a day and the Pearson / ANOVA inputs miscorrelate.
|
||||
// The cheap fix: when the user is non-near-UTC, force the live path
|
||||
// so the SYS / PULSE / WEIGHT per-day means are re-keyed via the same
|
||||
// `userDayKey(measuredAt, userTz)` helper the mood / intake streams
|
||||
// use, guaranteeing day-key parity. A v1.5 follow-up will thread
|
||||
// `userTz` into `readRollupBuckets` so the rollup path also addresses
|
||||
// local-day buckets and this guard can come back down.
|
||||
const userNearUtc = isNearUtc(userTz, now);
|
||||
|
||||
// The three measurement-derived streams ride the rollup-fast-path
|
||||
// only when EVERY type the user has logged is covered AND each of
|
||||
// SYS / PULSE / WEIGHT is in the coverage map. Partial coverage
|
||||
// falls back to the live path so a brand-new metric type doesn't
|
||||
// make the correlation card vanish.
|
||||
const measurementsOnRollups =
|
||||
userNearUtc &&
|
||||
isFullyCovered(coverage) &&
|
||||
coverage.get("BLOOD_PRESSURE_SYS") === true &&
|
||||
coverage.get("PULSE") === true &&
|
||||
@@ -295,6 +317,10 @@ export async function computeCorrelationHypothesesFastPath(
|
||||
path: measurementsOnRollups ? "rollup" : "live",
|
||||
window_days: CORRELATION_WINDOW_DAYS,
|
||||
degraded: false,
|
||||
// v1.4.38 W-A — surfaces the cross-tz guard decision so ops
|
||||
// logs can distinguish "user is Berlin, rollup path eligible"
|
||||
// from "user is Honolulu, forced to live regardless of coverage".
|
||||
tz_guard: userNearUtc ? "near-utc" : "non-utc-live-fallback",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -98,6 +98,17 @@ function uniqueComponentSources(
|
||||
export interface HealthScoreFastPathInput {
|
||||
userId: string;
|
||||
bpInTargetPct: number | null;
|
||||
/**
|
||||
* v1.4.38 — prior-week BP-in-target pct so the week-over-week delta
|
||||
* reflects BP movement instead of always landing at zero for the
|
||||
* pillar. The caller is expected to run a second
|
||||
* `computeBpInTargetFastPath` against `now - 7d` and pass the
|
||||
* resulting `last30Days.pct` through. When omitted (legacy callers
|
||||
* predating v1.4.38) the helper falls back to `bpInTargetPct` —
|
||||
* preserves the pre-v1.4.38 behaviour exactly so the bump is
|
||||
* additive.
|
||||
*/
|
||||
bpInTargetPctPriorWeek?: number | null;
|
||||
heightCm: number | null;
|
||||
now: Date;
|
||||
/**
|
||||
@@ -120,6 +131,16 @@ export async function computeUserHealthScoreFastPath(
|
||||
): Promise<HealthScoreResult | null> {
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const { userId, bpInTargetPct, heightCm, now } = input;
|
||||
// v1.4.38 — prior-week BP fallback. When the caller omits the field
|
||||
// we pin to the current value (pre-v1.4.38 behaviour) so the helper
|
||||
// stays a drop-in replacement for legacy callers; when the caller
|
||||
// supplies it we feed it into the previous-window snapshot so the
|
||||
// delta reflects week-over-week BP movement instead of always
|
||||
// zeroing the BP pillar.
|
||||
const bpInTargetPctPriorWeek =
|
||||
input.bpInTargetPctPriorWeek === undefined
|
||||
? bpInTargetPct
|
||||
: input.bpInTargetPctPriorWeek;
|
||||
const since30d = new Date(now.getTime() - 30 * DAY_MS);
|
||||
const prevSince30d = new Date(now.getTime() - 37 * DAY_MS);
|
||||
const prevUntil = new Date(now.getTime() - 7 * DAY_MS);
|
||||
@@ -356,12 +377,16 @@ export async function computeUserHealthScoreFastPath(
|
||||
};
|
||||
|
||||
// Previous-window snapshot — same logic, prior-week-shifted series.
|
||||
// The bpInTargetPct stays pinned to the current value because the
|
||||
// route does not pay for a second historical pair-search; the delta
|
||||
// reflects week-over-week movement in the weight / mood / compliance
|
||||
// pillars only.
|
||||
//
|
||||
// v1.4.38 — when the caller supplies `bpInTargetPctPriorWeek` we
|
||||
// use it so the delta reflects week-over-week BP movement. Legacy
|
||||
// callers that omit the field keep the pre-v1.4.38 behaviour where
|
||||
// BP cancels out of the delta because the helper pins both windows
|
||||
// to the same value. Re-computing the prior-week BP costs one
|
||||
// extra rollup-coverage read on the route side; the helper itself
|
||||
// stays single-pass.
|
||||
const previous: HealthScoreInput = {
|
||||
bpInTargetRate: bpInTargetPct,
|
||||
bpInTargetRate: bpInTargetPctPriorWeek,
|
||||
weightSeriesLast30d: weightSeriesPrev30d,
|
||||
weightTargetKg: fallbackTarget,
|
||||
moodEntriesLast30d: moodSeriesPrev30d,
|
||||
|
||||
+12
-1
@@ -1,3 +1,4 @@
|
||||
import { isIP } from "node:net";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export function apiSuccess<T>(data: T, status = 200) {
|
||||
@@ -86,8 +87,18 @@ export async function safeJson<T = unknown>(
|
||||
*
|
||||
* Returns the resolved IP or null when no trusted source is available.
|
||||
*/
|
||||
/**
|
||||
* v1.4.38 — strict IP validation via Node's built-in `net.isIP`. The
|
||||
* earlier regex `/^[0-9a-fA-F.:]+$/` matched any structurally-broken
|
||||
* input the character set accepted (`:::`, `1.2`, `1.2.3`, `gg:hh::`).
|
||||
* The cf-connecting-ip flow trusts the header under the env flag and
|
||||
* the trusted-proxy XFF chain forwards the rightmost entry straight
|
||||
* to the rate-limiter + audit log; a malformed value would land
|
||||
* downstream unchanged. `isIP` returns 4 / 6 for valid v4 / v6 and 0
|
||||
* for anything else — invert to a boolean for the helper's surface.
|
||||
*/
|
||||
function looksLikeIp(s: string): boolean {
|
||||
return /^[0-9a-fA-F.:]+$/.test(s) && s.length >= 3 && s.length <= 45;
|
||||
return isIP(s) !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
import { applyProfileUpdate } from "../profile-update";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const USER_ID = "user-1";
|
||||
|
||||
const STUB_USER = {
|
||||
id: USER_ID,
|
||||
username: "marc",
|
||||
displayName: null,
|
||||
email: null,
|
||||
role: "USER",
|
||||
heightCm: null,
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
timezone: "Europe/Berlin",
|
||||
locale: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.user.update).mockResolvedValue(STUB_USER as never);
|
||||
});
|
||||
|
||||
describe("applyProfileUpdate timezone validation", () => {
|
||||
it("accepts a valid IANA zone", async () => {
|
||||
const result = await applyProfileUpdate(USER_ID, {
|
||||
timezone: "Europe/Berlin",
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects an unknown zone with 422", async () => {
|
||||
const result = await applyProfileUpdate(USER_ID, {
|
||||
timezone: "Mars/Tharsis",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.status).toBe(422);
|
||||
expect(result.message).toMatch(/Invalid IANA timezone/i);
|
||||
}
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an empty-string timezone", async () => {
|
||||
const result = await applyProfileUpdate(USER_ID, { timezone: "" });
|
||||
expect(result.ok).toBe(false);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an injection-shaped string", async () => {
|
||||
const result = await applyProfileUpdate(USER_ID, {
|
||||
timezone: "Europe/Berlin' OR 1=1",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.status).toBe(422);
|
||||
}
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -6,12 +6,18 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { profileSchema } from "@/lib/validations/auth";
|
||||
import { isValidTimezone } from "@/lib/tz/format";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const extendedProfileSchema = profileSchema.extend({
|
||||
displayName: z.string().min(1).max(80).nullable().optional(),
|
||||
locale: z.enum(["de", "en"]).nullable().optional(),
|
||||
timezone: z.string().min(1).max(64).optional(),
|
||||
timezone: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.refine(isValidTimezone, "Invalid IANA timezone")
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type ApplyProfileInput = z.infer<typeof extendedProfileSchema>;
|
||||
|
||||
Vendored
+5
@@ -67,6 +67,11 @@ export function invalidateUserMedications(userId: string): void {
|
||||
// v1.4.36 W1 — medication writes change the MEDICATION_COMPLIANCE
|
||||
// target rollup (compliance7 / compliance30 / consistency strip).
|
||||
caches.insightsTargets.deleteByPrefix(userId);
|
||||
// v1.4.38 W-F — `/api/dashboard/summary` lives under the analytics
|
||||
// cache and reads `medicationIntakeEvent` for both today's compliance
|
||||
// tally and the 365-day streak feed. A taken / skipped event must
|
||||
// evict the user-bucket so the next iOS poll reflects the change.
|
||||
caches.analytics.deleteByPrefix(`${userId}|`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
|
||||
import React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
@@ -5,7 +8,10 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
|
||||
import type { AssistantFlagSet } from "@/hooks/use-feature-flags";
|
||||
import {
|
||||
DEFAULT_ASSISTANT_FLAGS,
|
||||
type AssistantFlagSet,
|
||||
} from "@/hooks/use-feature-flags";
|
||||
|
||||
import { HeroStrip } from "@/components/insights/hero-strip";
|
||||
import { SuggestedPrompts } from "@/components/insights/suggested-prompts";
|
||||
@@ -13,6 +19,24 @@ import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { LayoutCoachFab } from "@/components/insights/layout-coach-fab";
|
||||
import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
|
||||
|
||||
// v1.4.38 W-C M-5 — capture the original `useFeatureFlags` and route
|
||||
// the spy through it so the gate-fired assertion below can verify the
|
||||
// hook ran with the operator-disabled matrix. Setting `vi.mock()` here
|
||||
// (instead of `vi.spyOn`) is required because the components import the
|
||||
// hook at module load; a runtime `spyOn` would never intercept the call.
|
||||
const featureFlagsSpy = vi.fn<() => AssistantFlagSet>(
|
||||
() => DEFAULT_ASSISTANT_FLAGS,
|
||||
);
|
||||
vi.mock("@/hooks/use-feature-flags", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/hooks/use-feature-flags")
|
||||
>("@/hooks/use-feature-flags");
|
||||
return {
|
||||
...actual,
|
||||
useFeatureFlags: () => featureFlagsSpy(),
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.4.37 W5 — Coach disable cascade invariant.
|
||||
*
|
||||
@@ -60,6 +84,16 @@ function buildClient(flags: Partial<AssistantFlagSet>): QueryClient {
|
||||
}
|
||||
|
||||
function renderWithFlags(node: React.ReactNode, coach: boolean): string {
|
||||
// v1.4.38 W-C M-5 — drive the mocked `useFeatureFlags` from the same
|
||||
// matrix the QueryClient is seeded with so the gate-call assertion at
|
||||
// the bottom of the file can read the resolved value. Keeping the
|
||||
// QueryClient seed in place preserves the original render path for
|
||||
// any consumer that bypasses the hook.
|
||||
const resolved: AssistantFlagSet = {
|
||||
...DEFAULT_ASSISTANT_FLAGS,
|
||||
coach,
|
||||
};
|
||||
featureFlagsSpy.mockImplementation(() => resolved);
|
||||
const client = buildClient({ coach });
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
@@ -156,7 +190,9 @@ const COACH_SURFACES: CoachSurface[] = [
|
||||
// The drawer is lazy-loaded via next/dynamic and SSRs to nothing
|
||||
// even when the flag is on; the contract here is "no Coach-marked
|
||||
// mount point ever paints when the flag is off". Skip the proof
|
||||
// check; the negative `coach-*` slot grep still pins the gate.
|
||||
// check; the negative `coach-*` slot grep still pins the gate, and
|
||||
// the explicit spy assertion in `LayoutCoachMount SSR-proof spy`
|
||||
// below proves the gate actually fired during the off render.
|
||||
proofWhenOn: "",
|
||||
},
|
||||
];
|
||||
@@ -167,6 +203,11 @@ describe("Coach disable cascade invariant", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
// v1.4.38 W-C M-5 — reset the call history so each iteration of
|
||||
// the surface loop sees a clean slate when it asserts the gate
|
||||
// fired during the off render.
|
||||
featureFlagsSpy.mockClear();
|
||||
featureFlagsSpy.mockImplementation(() => DEFAULT_ASSISTANT_FLAGS);
|
||||
});
|
||||
|
||||
for (const surface of COACH_SURFACES) {
|
||||
@@ -199,9 +240,142 @@ describe("Coach disable cascade invariant", () => {
|
||||
'data-slot="insights-hero-strip-action-coach"',
|
||||
);
|
||||
expect(html).not.toContain('data-slot="insights-hero-strip-prompts"');
|
||||
// v1.4.38 W-C M-5 — `proofWhenOn: ""` surfaces (the lazy-loaded
|
||||
// `LayoutCoachMount` drawer subtree) trivially pass the substring
|
||||
// grep because their SSR shape is empty even when the gate is on.
|
||||
// The spy assertion below proves the gate actually fired with the
|
||||
// operator-disabled matrix, so a regression that removes the
|
||||
// `if (!flags.coach) return null` short-circuit fails this test
|
||||
// instead of slipping through under cover of the empty SSR shape.
|
||||
expect(featureFlagsSpy).toHaveBeenCalled();
|
||||
const lastCall = featureFlagsSpy.mock.results.at(-1);
|
||||
expect(lastCall?.value.coach).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// v1.4.38 W-C H4 — grep-based discovery of every `flags.coach`
|
||||
// call site in the codebase.
|
||||
//
|
||||
// The fixture-count check below pins the surfaces the fixture mounts
|
||||
// directly. Cross-cut gates on sub-pages (`/targets` page drawer,
|
||||
// `<TargetCard>` per-card CTA) sit OUTSIDE the fixture and are owned
|
||||
// by sibling invariants. A future contributor who lands a new Coach
|
||||
// mount on (e.g.) `/insights/blutdruck/page.tsx` would not trip the
|
||||
// surface-count check at all — the new site is on a sub-page and
|
||||
// would silently leak.
|
||||
//
|
||||
// The walk below scans `src/` for every `flags.coach` occurrence and
|
||||
// requires each path to appear in the explicit `KNOWN_COACH_GATE_SITES`
|
||||
// allowlist. Add a new entry every time you intentionally gate a new
|
||||
// render path on the Coach flag; the failure message names the
|
||||
// unaccounted-for paths so the fix is one search-and-add.
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Every file in `src/` that legitimately reads `flags.coach`. Lives
|
||||
* here (next to the cascade fixture) so a contributor extending the
|
||||
* fixture sees the allowlist in the same review.
|
||||
*
|
||||
* Paths are POSIX-style relative to the repo root.
|
||||
*/
|
||||
const KNOWN_COACH_GATE_SITES: ReadonlyArray<string> = [
|
||||
// Coach-bearing surfaces mounted directly by the cascade fixture.
|
||||
"src/components/insights/coach-launch-button.tsx",
|
||||
"src/components/insights/hero-strip.tsx",
|
||||
"src/components/insights/layout-coach-fab.tsx",
|
||||
"src/components/insights/layout-coach-mount.tsx",
|
||||
"src/components/insights/suggested-prompts.tsx",
|
||||
// Cross-cut gates owned by sibling invariants
|
||||
// (`targets-coach-mount.test.tsx`, `target-card.test.tsx`).
|
||||
"src/app/targets/page.tsx",
|
||||
"src/components/targets/target-card.tsx",
|
||||
];
|
||||
|
||||
/**
|
||||
* Walk every TS / TSX file under `src/`, excluding test trees, and
|
||||
* collect the relative paths that contain a `flags.coach` token. The
|
||||
* grep is intentionally simple — any literal occurrence counts so a
|
||||
* contributor who paraphrases the read (`const c = flags.coach`)
|
||||
* still trips this test.
|
||||
*/
|
||||
function findCoachFlagSites(): string[] {
|
||||
const srcRoot = resolve(__dirname, "..", "..", "..");
|
||||
const hits: string[] = [];
|
||||
const repoRoot = resolve(srcRoot, "..");
|
||||
|
||||
function walk(dir: string): void {
|
||||
const entries = readdirSync(dir);
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry);
|
||||
const stat = statSync(full);
|
||||
if (stat.isDirectory()) {
|
||||
if (entry === "node_modules" || entry === ".next") continue;
|
||||
// Skip every `__tests__/` subtree — the discovery target is
|
||||
// the production gate, not the assertions about it.
|
||||
if (entry === "__tests__") continue;
|
||||
walk(full);
|
||||
continue;
|
||||
}
|
||||
if (!/\.(tsx|ts)$/.test(entry)) continue;
|
||||
if (/\.test\.(tsx|ts)$/.test(entry)) continue;
|
||||
const text = readFileSync(full, "utf8");
|
||||
// Match `flags.coach` on lines that are NOT pure comments. A
|
||||
// hit on a JSDoc / `//` line is documentation, not a gate, and
|
||||
// would make the allowlist a maintenance burden every time
|
||||
// someone references the gate in a comment.
|
||||
const isRealGate = text.split("\n").some((line) => {
|
||||
if (!line.includes("flags.coach")) return false;
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith("//")) return false;
|
||||
if (trimmed.startsWith("*")) return false;
|
||||
return true;
|
||||
});
|
||||
if (isRealGate) {
|
||||
// POSIX-style path relative to the repo root so the failure
|
||||
// message stays portable across contributors' checkout
|
||||
// directories.
|
||||
hits.push(relative(repoRoot, full).split(/[\\/]/).join("/"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(srcRoot);
|
||||
return hits.sort();
|
||||
}
|
||||
|
||||
it("grep-based discovery pins every `flags.coach` call site", () => {
|
||||
const discovered = findCoachFlagSites();
|
||||
const allowlist = new Set(KNOWN_COACH_GATE_SITES);
|
||||
const orphans = discovered.filter((path) => !allowlist.has(path));
|
||||
expect(
|
||||
orphans,
|
||||
[
|
||||
"Found `flags.coach` in file(s) not on the KNOWN_COACH_GATE_SITES",
|
||||
"allowlist (src/lib/feature-flags/__tests__/coach-cascade.test.tsx).",
|
||||
"Add a fixture entry (or a sibling invariant) for the new gate,",
|
||||
"then add the path to the allowlist. Orphans:",
|
||||
...orphans.map((p) => ` - ${p}`),
|
||||
].join("\n"),
|
||||
).toEqual([]);
|
||||
|
||||
// Sanity check the other direction — an allowlist entry that no
|
||||
// longer references `flags.coach` is dead documentation and
|
||||
// should be pruned in the same PR that removes the gate.
|
||||
const discoveredSet = new Set(discovered);
|
||||
const stale = KNOWN_COACH_GATE_SITES.filter(
|
||||
(path) => !discoveredSet.has(path),
|
||||
);
|
||||
expect(
|
||||
stale,
|
||||
[
|
||||
"KNOWN_COACH_GATE_SITES references file(s) that no longer use",
|
||||
"`flags.coach`. Remove the stale allowlist entry:",
|
||||
...stale.map((p) => ` - ${p}`),
|
||||
].join("\n"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("fixture stays in sync with the Coach gate call sites", () => {
|
||||
// Counts the Coach-bearing surfaces the fixture knows about. If a
|
||||
// contributor adds a new `useFeatureFlags()` + `flags.coach` gate
|
||||
@@ -210,7 +384,8 @@ describe("Coach disable cascade invariant", () => {
|
||||
// surfaces the fixture mounts directly; cross-cut gates on
|
||||
// sub-pages (target-card, /targets page) are owned by other
|
||||
// invariants (`targets-coach-mount.test.tsx`,
|
||||
// `target-card.test.tsx`).
|
||||
// `target-card.test.tsx`) and by the grep-based discovery test
|
||||
// above.
|
||||
expect(COACH_SURFACES.length).toBe(6);
|
||||
});
|
||||
});
|
||||
@@ -116,15 +116,18 @@ describe("buildComprehensiveAggregate", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
FIND_MANY.mockResolvedValueOnce([]) // sys
|
||||
.mockResolvedValueOnce([]); // dia
|
||||
// v1.4.38 W-F — sys + dia merged into a single `findMany`
|
||||
// (`type: { in: [...] }`) → one mock call carrying both rows
|
||||
// (empty here since the rollup-fresh fixture doesn't exercise
|
||||
// the BP pairing). The aggregator partitions by type in JS so
|
||||
// the bpRawRows.sys / .dia byte-shape is preserved.
|
||||
FIND_MANY.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await buildComprehensiveAggregate("user-rollup-fresh");
|
||||
const weight = result.summaries.WEIGHT;
|
||||
|
||||
// count/min/max/mean composed from buckets — the test asserts the
|
||||
// bucket-derived values are present even though we never wired up
|
||||
// a heavy aggregate row for the parity check.
|
||||
expect(weight.count).toBe(42);
|
||||
expect(weight.min).toBe(79.2);
|
||||
expect(weight.max).toBe(84.1);
|
||||
@@ -154,7 +157,8 @@ describe("buildComprehensiveAggregate", () => {
|
||||
// count + by checking that the narrow projection (no min_value /
|
||||
// max_value / mean_value cols) is what landed.
|
||||
expect(RAW).toHaveBeenCalledTimes(4);
|
||||
expect(FIND_MANY).toHaveBeenCalledTimes(2);
|
||||
// v1.4.38 W-F — sys + dia merged into a single round-trip.
|
||||
expect(FIND_MANY).toHaveBeenCalledTimes(1);
|
||||
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -168,8 +172,8 @@ describe("buildComprehensiveAggregate", () => {
|
||||
RAW.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([]);
|
||||
FIND_MANY.mockResolvedValueOnce([]) // sys
|
||||
.mockResolvedValueOnce([]); // dia
|
||||
// v1.4.38 W-F — sys + dia merged into one `findMany`.
|
||||
FIND_MANY.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await buildComprehensiveAggregate("user-empty");
|
||||
|
||||
@@ -180,7 +184,8 @@ describe("buildComprehensiveAggregate", () => {
|
||||
expect(result.firstMeasurementAt).toBeNull();
|
||||
expect(result.totalMeasurements).toBe(0);
|
||||
expect(RAW).toHaveBeenCalledTimes(3);
|
||||
expect(FIND_MANY).toHaveBeenCalledTimes(2);
|
||||
// v1.4.38 W-F — sys + dia merged → single findMany.
|
||||
expect(FIND_MANY).toHaveBeenCalledTimes(1);
|
||||
// The cold path's rollup.findMany still fires (in case some
|
||||
// buckets exist for a subset of types post-race), but returns [].
|
||||
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(1);
|
||||
@@ -219,8 +224,8 @@ describe("buildComprehensiveAggregate", () => {
|
||||
.mockResolvedValueOnce([
|
||||
{ first_at: new Date(now.getTime() - 86400000) },
|
||||
]);
|
||||
FIND_MANY.mockResolvedValueOnce([]) // sys
|
||||
.mockResolvedValueOnce([]); // dia
|
||||
// v1.4.38 W-F — sys + dia merged into one round-trip.
|
||||
FIND_MANY.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await buildComprehensiveAggregate("user-cold");
|
||||
const weight = result.summaries.WEIGHT;
|
||||
@@ -293,9 +298,13 @@ describe("buildComprehensiveAggregate", () => {
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([{ first_at: measuredAt }]);
|
||||
// v1.4.38 W-F — single merged `findMany` returning both sys + dia
|
||||
// rows tagged by `type`. The aggregator partitions in JS, so the
|
||||
// bpRawRows.sys / .dia shape stays byte-identical.
|
||||
FIND_MANY.mockResolvedValueOnce([
|
||||
{ measuredAt, value: 120 },
|
||||
]).mockResolvedValueOnce([{ measuredAt, value: 80 }]);
|
||||
{ type: "BLOOD_PRESSURE_SYS", measuredAt, value: 120 },
|
||||
{ type: "BLOOD_PRESSURE_DIA", measuredAt, value: 80 },
|
||||
]);
|
||||
|
||||
const result = await buildComprehensiveAggregate("user-bp");
|
||||
expect(result.bpRawRows.sys).toEqual([{ measuredAt, value: 120 }]);
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { ensureUserRollupsFresh } from "@/lib/measurements/rollups";
|
||||
import { aggregateBuckets } from "@/lib/measurements/rollup-read";
|
||||
import {
|
||||
@@ -233,6 +234,12 @@ export async function buildComprehensiveAggregate(
|
||||
): Promise<ComprehensiveAggregate> {
|
||||
const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// v1.4.38 W-F — per-sub-query wall-clock timing. Emitted under
|
||||
// `meta.insights.sub_*_ms` so the next perf-verify can attribute
|
||||
// a regression to a specific aggregate (narrow, latests, day-buckets,
|
||||
// bp-raw, first-at, coverage-probe) without re-instrumenting.
|
||||
const timings: Record<string, number> = {};
|
||||
|
||||
// v1.4.37.1 hotfix — fire-and-forget. See `src/app/api/analytics/route.ts`
|
||||
// for the full rationale: awaiting this on the read path can stall
|
||||
// the event loop for 30–60 s on power-user accounts whose iOS step
|
||||
@@ -252,11 +259,71 @@ export async function buildComprehensiveAggregate(
|
||||
// and only take the rollup path when EVERY type the user has logged
|
||||
// is covered. Partial coverage falls back to the live aggregate so
|
||||
// a fresh-type measurement doesn't break its summary card.
|
||||
const probeStart = Date.now();
|
||||
const coverage = await probeRollupCoverage(userId);
|
||||
if (isFullyCovered(coverage)) {
|
||||
return buildFromRollups(userId, ninetyDaysAgo);
|
||||
timings["insights.sub_coverage_ms"] = Date.now() - probeStart;
|
||||
|
||||
const result = isFullyCovered(coverage)
|
||||
? await buildFromRollups(userId, ninetyDaysAgo, timings)
|
||||
: await buildFromLiveAggregate(userId, ninetyDaysAgo, timings);
|
||||
|
||||
annotate({ meta: timings });
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.38 W-F — tiny timing helper. Wraps a promise-returning sub-query
|
||||
* builder so each Promise.all slot lands a wall-clock observation under
|
||||
* `meta.insights.sub_<label>_ms`. The helper itself never throws — the
|
||||
* builder's rejection propagates unchanged so the existing error
|
||||
* handling stays intact.
|
||||
*/
|
||||
function timeSubquery<T>(
|
||||
timings: Record<string, number>,
|
||||
label: string,
|
||||
builder: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const t0 = Date.now();
|
||||
return builder().then((value) => {
|
||||
timings[`insights.sub_${label}_ms`] = Date.now() - t0;
|
||||
return value;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.38 W-F — fetch the trailing-90d sys + dia raw rows in a single
|
||||
* `findMany` (`type: { in: [...] }`) rather than two parallel queries.
|
||||
* Saves one round-trip + lets Postgres consolidate the index scan.
|
||||
* Partitioning by type in JS preserves the byte-shape of the legacy
|
||||
* sys / dia arrays (ASC by measuredAt — already enforced by the SQL
|
||||
* ORDER BY).
|
||||
*/
|
||||
async function fetchBpRawRows(
|
||||
userId: string,
|
||||
ninetyDaysAgo: Date,
|
||||
): Promise<{
|
||||
sys: Array<{ measuredAt: Date; value: number }>;
|
||||
dia: Array<{ measuredAt: Date; value: number }>;
|
||||
}> {
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: { in: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"] },
|
||||
measuredAt: { gte: ninetyDaysAgo },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { type: true, measuredAt: true, value: true },
|
||||
});
|
||||
const sys: Array<{ measuredAt: Date; value: number }> = [];
|
||||
const dia: Array<{ measuredAt: Date; value: number }> = [];
|
||||
for (const r of rows) {
|
||||
if (r.type === "BLOOD_PRESSURE_SYS") {
|
||||
sys.push({ measuredAt: r.measuredAt, value: r.value });
|
||||
} else if (r.type === "BLOOD_PRESSURE_DIA") {
|
||||
dia.push({ measuredAt: r.measuredAt, value: r.value });
|
||||
}
|
||||
}
|
||||
return buildFromLiveAggregate(userId, ninetyDaysAgo);
|
||||
return { sys, dia };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -267,9 +334,12 @@ export async function buildComprehensiveAggregate(
|
||||
async function buildFromRollups(
|
||||
userId: string,
|
||||
ninetyDaysAgo: Date,
|
||||
timings: Record<string, number>,
|
||||
): Promise<ComprehensiveAggregate> {
|
||||
const [narrows, latests, dayBuckets, sysRaw, diaRaw, firstRows] = await Promise.all([
|
||||
prisma.$queryRaw<NarrowAggregateRow[]>`
|
||||
// v1.4.38 W-F — bp sys + dia consolidated into one `findMany`
|
||||
// (`type: { in: [...] }`) plus per-sub-query wall-clock timings.
|
||||
const [narrows, latests, dayBuckets, bpRawRows, firstRows] = await Promise.all([
|
||||
timeSubquery(timings, "narrow", () => prisma.$queryRaw<NarrowAggregateRow[]>`
|
||||
WITH window_stats AS (
|
||||
SELECT
|
||||
m."type",
|
||||
@@ -345,8 +415,8 @@ async function buildFromRollups(
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${ninetyDaysAgo}
|
||||
GROUP BY m."type", ws.stddev_value
|
||||
`,
|
||||
prisma.$queryRaw<LatestRow[]>`
|
||||
`),
|
||||
timeSubquery(timings, "latests", () => prisma.$queryRaw<LatestRow[]>`
|
||||
SELECT DISTINCT ON (m."type")
|
||||
m."type"::text AS type,
|
||||
m."value"::double precision AS value,
|
||||
@@ -355,45 +425,32 @@ async function buildFromRollups(
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${ninetyDaysAgo}
|
||||
ORDER BY m."type", m."measured_at" DESC
|
||||
`,
|
||||
`),
|
||||
// v1.4.35 — DAY rollup buckets for the trailing 90 days, every
|
||||
// type. Drives both the per-type `count / min / max / mean`
|
||||
// composition (via `aggregateBuckets`) and the `dailyByType`
|
||||
// correlation feed below. Replaces the legacy
|
||||
// `date_trunc('day', m."measured_at")` GROUP BY query and the
|
||||
// per-type AVG/MIN/MAX/COUNT columns the live aggregate kept.
|
||||
prisma.measurementRollup.findMany({
|
||||
timeSubquery(timings, "buckets", () => prisma.measurementRollup.findMany({
|
||||
where: {
|
||||
userId,
|
||||
granularity: "DAY",
|
||||
bucketStart: { gte: ninetyDaysAgo },
|
||||
},
|
||||
orderBy: [{ type: "asc" }, { bucketStart: "asc" }],
|
||||
}),
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
measuredAt: { gte: ninetyDaysAgo },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
}),
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
measuredAt: { gte: ninetyDaysAgo },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
}),
|
||||
prisma.$queryRaw<Array<{ first_at: Date | null }>>`
|
||||
})),
|
||||
// v1.4.38 W-F — bp sys + dia merged into one round-trip. Halves
|
||||
// the bp-raw cost on accounts whose iOS Health source spams BP
|
||||
// readings; the per-type partition in `fetchBpRawRows` preserves
|
||||
// the legacy `bpRawRows.sys` / `bpRawRows.dia` byte-shape.
|
||||
timeSubquery(timings, "bpRaw", () => fetchBpRawRows(userId, ninetyDaysAgo)),
|
||||
timeSubquery(timings, "firstAt", () => prisma.$queryRaw<Array<{ first_at: Date | null }>>`
|
||||
SELECT MIN(m."measured_at") AS first_at
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${ninetyDaysAgo}
|
||||
`,
|
||||
`),
|
||||
]);
|
||||
|
||||
const latestByType = new Map<string, { value: number; measuredAt: Date }>();
|
||||
@@ -447,10 +504,7 @@ async function buildFromRollups(
|
||||
|
||||
return {
|
||||
summaries,
|
||||
bpRawRows: {
|
||||
sys: sysRaw.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
|
||||
dia: diaRaw.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
|
||||
},
|
||||
bpRawRows,
|
||||
dailyByType: buildDailyByType(bucketsByType),
|
||||
firstMeasurementAt,
|
||||
totalMeasurements,
|
||||
@@ -467,9 +521,13 @@ async function buildFromRollups(
|
||||
async function buildFromLiveAggregate(
|
||||
userId: string,
|
||||
ninetyDaysAgo: Date,
|
||||
timings: Record<string, number>,
|
||||
): Promise<ComprehensiveAggregate> {
|
||||
const [aggregates, latests, dayBuckets, sysRaw, diaRaw] = await Promise.all([
|
||||
prisma.$queryRaw<HeavyAggregateRow[]>`
|
||||
// v1.4.38 W-F — same sub-query timing + consolidated bp-raw read as
|
||||
// the rollup-fresh path so the prod observability covers cold mounts
|
||||
// too.
|
||||
const [aggregates, latests, dayBuckets, bpRawRows] = await Promise.all([
|
||||
timeSubquery(timings, "heavy", () => prisma.$queryRaw<HeavyAggregateRow[]>`
|
||||
WITH window_stats AS (
|
||||
SELECT
|
||||
m."type",
|
||||
@@ -545,8 +603,8 @@ async function buildFromLiveAggregate(
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${ninetyDaysAgo}
|
||||
GROUP BY m."type", ws.stddev_value
|
||||
`,
|
||||
prisma.$queryRaw<LatestRow[]>`
|
||||
`),
|
||||
timeSubquery(timings, "latests", () => prisma.$queryRaw<LatestRow[]>`
|
||||
SELECT DISTINCT ON (m."type")
|
||||
m."type"::text AS type,
|
||||
m."value"::double precision AS value,
|
||||
@@ -555,37 +613,21 @@ async function buildFromLiveAggregate(
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${ninetyDaysAgo}
|
||||
ORDER BY m."type", m."measured_at" DESC
|
||||
`,
|
||||
`),
|
||||
// Buckets may exist for some types even when the COUNT probe came
|
||||
// back zero (race window between the probe and a sibling
|
||||
// populator); the cold-path read still honours them for the
|
||||
// `dailyByType` feed.
|
||||
prisma.measurementRollup.findMany({
|
||||
timeSubquery(timings, "buckets", () => prisma.measurementRollup.findMany({
|
||||
where: {
|
||||
userId,
|
||||
granularity: "DAY",
|
||||
bucketStart: { gte: ninetyDaysAgo },
|
||||
},
|
||||
orderBy: [{ type: "asc" }, { bucketStart: "asc" }],
|
||||
}),
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
measuredAt: { gte: ninetyDaysAgo },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
}),
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
measuredAt: { gte: ninetyDaysAgo },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
}),
|
||||
})),
|
||||
// v1.4.38 W-F — bp sys + dia in one round-trip (see `fetchBpRawRows`).
|
||||
timeSubquery(timings, "bpRaw", () => fetchBpRawRows(userId, ninetyDaysAgo)),
|
||||
]);
|
||||
|
||||
const latestByType = new Map<string, { value: number; measuredAt: Date }>();
|
||||
@@ -624,12 +666,14 @@ async function buildFromLiveAggregate(
|
||||
}
|
||||
|
||||
if (totalMeasurements > 0) {
|
||||
const firstRows = await prisma.$queryRaw<Array<{ first_at: Date | null }>>`
|
||||
SELECT MIN(m."measured_at") AS first_at
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${ninetyDaysAgo}
|
||||
`;
|
||||
const firstRows = await timeSubquery(timings, "firstAt", () =>
|
||||
prisma.$queryRaw<Array<{ first_at: Date | null }>>`
|
||||
SELECT MIN(m."measured_at") AS first_at
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${ninetyDaysAgo}
|
||||
`,
|
||||
);
|
||||
firstMeasurementAt = firstRows[0]?.first_at
|
||||
? new Date(firstRows[0].first_at)
|
||||
: null;
|
||||
@@ -637,10 +681,7 @@ async function buildFromLiveAggregate(
|
||||
|
||||
return {
|
||||
summaries,
|
||||
bpRawRows: {
|
||||
sys: sysRaw.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
|
||||
dia: diaRaw.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
|
||||
},
|
||||
bpRawRows,
|
||||
dailyByType: buildDailyByType(bucketsByType),
|
||||
firstMeasurementAt,
|
||||
totalMeasurements,
|
||||
|
||||
@@ -28,15 +28,23 @@ describe("reminder-worker — drainPerSampleCumulative nightly schedule", () =>
|
||||
expect(source).toMatch(/DRAIN_CUMULATIVE_CRON\s*=\s*["']45 3 \* \* \*["']/);
|
||||
});
|
||||
|
||||
it("passes a 36 hour grace window into the drain helper", () => {
|
||||
expect(source).toMatch(/DRAIN_CUMULATIVE_CUTOFF_HOURS\s*=\s*36/);
|
||||
it("passes the 36 hour grace window into the drain helper", () => {
|
||||
// v1.4.38 — the literal `36` lives in the helper module so the
|
||||
// worker, the admin route, and the CLI all read one source of
|
||||
// truth. The worker still references the constant by name in the
|
||||
// cutoffHours slot so the cron's behaviour is unchanged.
|
||||
expect(source).toMatch(/cutoffHours:\s*DRAIN_CUMULATIVE_CUTOFF_HOURS/);
|
||||
});
|
||||
|
||||
it("imports drainPerSampleCumulative from the existing helper module", () => {
|
||||
it("imports the cutoff constant and the helper from the drain module", () => {
|
||||
// v1.4.38 — the import is now a multi-symbol form. Match the helper
|
||||
// and the constant separately so a future contributor who adds a
|
||||
// third symbol does not break the assertion.
|
||||
expect(source).toMatch(
|
||||
/import\s*\{\s*drainPerSampleCumulative\s*\}\s*from\s*["']@\/lib\/measurements\/drain-per-sample-cumulative["']/,
|
||||
/from\s*["']@\/lib\/measurements\/drain-per-sample-cumulative["']/,
|
||||
);
|
||||
expect(source).toMatch(/\bdrainPerSampleCumulative\b/);
|
||||
expect(source).toMatch(/\bDRAIN_CUMULATIVE_CUTOFF_HOURS\b/);
|
||||
});
|
||||
|
||||
it("registers a boss.work handler against the drain queue", () => {
|
||||
|
||||
@@ -71,8 +71,8 @@ describe("runGeoBackfill", () => {
|
||||
expect(args?.take).toBe(GEO_BACKFILL_BATCH_CAP);
|
||||
});
|
||||
|
||||
it("caps the batch at 5000 rows per pass", async () => {
|
||||
expect(GEO_BACKFILL_BATCH_CAP).toBe(5000);
|
||||
it("caps the batch at 500 rows per pass (v1.4.38 — tightened from 5000 to bound the worst-case ipwho.is fallback budget)", async () => {
|
||||
expect(GEO_BACKFILL_BATCH_CAP).toBe(500);
|
||||
});
|
||||
|
||||
it("updates a row with location only when ASN resolver misses", async () => {
|
||||
|
||||
@@ -18,9 +18,16 @@
|
||||
* window typically belong to deleted users or have aged out of
|
||||
* the operational triage window where the `location` chip
|
||||
* matters.
|
||||
* - Capped at 5 000 rows per pass. Limits the worst-case DB-write
|
||||
* fan-out so a single backfill burst can't starve a live login
|
||||
* spike.
|
||||
* - Capped at 500 rows per pass. Each row spends up to 3 s on the
|
||||
* `lookupIpLocation` online fallback (offline MMDB miss → ipwho.is
|
||||
* timeout), so 500 rows × 3 s caps the worst-case pass at ~25 min
|
||||
* and keeps the hourly cron from stacking. Earlier passes ran at
|
||||
* 5 000 rows, which let a single backfill burst block for ~4 h and
|
||||
* starve the next scheduled run. The trade-off: a tenant with a
|
||||
* large null-`location` backlog now drains over multiple hourly
|
||||
* passes instead of one mega-pass — fine because the helper is
|
||||
* idempotent and the admin sign-in overview tolerates a stale
|
||||
* Standort cell for the few hours convergence takes.
|
||||
*
|
||||
* Idempotent: a row that the resolver still cannot match (private IP
|
||||
* sneaking through CF egress, freshly-allocated range the GeoLite2
|
||||
@@ -35,7 +42,7 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { lookupIpAsn, lookupIpLocation } from "@/lib/geo";
|
||||
|
||||
export const GEO_BACKFILL_BATCH_CAP = 5000;
|
||||
export const GEO_BACKFILL_BATCH_CAP = 500;
|
||||
export const GEO_BACKFILL_WINDOW_DAYS = 30;
|
||||
|
||||
/**
|
||||
|
||||
@@ -67,7 +67,10 @@ import {
|
||||
type RollupFullBackfillPayload,
|
||||
type RollupRecomputePayload,
|
||||
} from "@/lib/measurements/rollups";
|
||||
import { drainPerSampleCumulative } from "@/lib/measurements/drain-per-sample-cumulative";
|
||||
import {
|
||||
drainPerSampleCumulative,
|
||||
DRAIN_CUMULATIVE_CUTOFF_HOURS,
|
||||
} from "@/lib/measurements/drain-per-sample-cumulative";
|
||||
import { expireStaleInUseItems } from "@/lib/medications/inventory/service";
|
||||
import { rotateLegacyMoodLogSecrets } from "@/lib/moodlog-secret";
|
||||
import { deleteMessage } from "@/lib/telegram";
|
||||
@@ -176,7 +179,10 @@ const FEEDBACK_AGGREGATOR_CRON = "0 4 * * *";
|
||||
// fall to the drain.
|
||||
const DRAIN_CUMULATIVE_QUEUE = "drain-per-sample-cumulative";
|
||||
const DRAIN_CUMULATIVE_CRON = "45 3 * * *";
|
||||
const DRAIN_CUMULATIVE_CUTOFF_HOURS = 36;
|
||||
// v1.4.38 — the per-sample cutoff hours constant now lives on the
|
||||
// helper module so the worker, the admin route, and the CLI all read
|
||||
// the same source of truth. Re-export pulled in alongside
|
||||
// `drainPerSampleCumulative` above.
|
||||
interface DrainCumulativePayload {
|
||||
triggeredAt: string;
|
||||
}
|
||||
@@ -1188,10 +1194,32 @@ async function handleFeedbackAggregator(
|
||||
* provider unreachable) and re-resolves them through the now-bundled
|
||||
* resolver chain. The helper is idempotent and capped per pass so
|
||||
* the hourly cadence cannot starve a live login spike.
|
||||
*
|
||||
* v1.4.38 — in-process singleton guard. pg-boss already coalesces
|
||||
* concurrent cron ticks across multiple worker containers via the
|
||||
* shared queue lease, but a single container that takes longer than
|
||||
* one cron interval can pick up two jobs back-to-back when the
|
||||
* second tick fires while the first pass is still running. The
|
||||
* in-process `geoBackfillRunning` flag fans the second invocation
|
||||
* out as a no-op log line instead of stacking two concurrent passes
|
||||
* inside the same Node process — the next cron tick after the first
|
||||
* completes will catch up the work the skipped pass would have done.
|
||||
*/
|
||||
let geoBackfillRunning = false;
|
||||
async function handleGeoBackfill(jobs: Job<GeoBackfillPayload>[]) {
|
||||
void jobs;
|
||||
await withBackgroundEvent("job.geo_backfill", async (evt) => {
|
||||
if (geoBackfillRunning) {
|
||||
// Earlier pass still in flight; skip this tick. Idempotent + the
|
||||
// next tick after the in-flight pass completes will pick up
|
||||
// anything we miss here.
|
||||
evt.addWarning(
|
||||
"geo-backfill skipped — earlier pass still in flight inside the same worker process",
|
||||
);
|
||||
evt.addMeta("geo_backfill_skipped", true);
|
||||
return;
|
||||
}
|
||||
geoBackfillRunning = true;
|
||||
const p = getWorkerPrisma();
|
||||
try {
|
||||
const summary = await runGeoBackfill(p);
|
||||
@@ -1204,6 +1232,8 @@ async function handleGeoBackfill(jobs: Job<GeoBackfillPayload>[]) {
|
||||
// log and move on so a one-off resolver hiccup does not poison
|
||||
// the queue and block the next pass.
|
||||
evt.addWarning(`geo-backfill failed: ${err}`);
|
||||
} finally {
|
||||
geoBackfillRunning = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ void queryRaw;
|
||||
import {
|
||||
ROLLUP_FULL_BACKFILL_QUEUE,
|
||||
ROLLUP_RECOMPUTE_QUEUE,
|
||||
_resetEnsureUserRollupsFreshInFlightForTests,
|
||||
collapseToTypeDayKeys,
|
||||
enqueueBootTimeRollupBackfill,
|
||||
enqueueRollupRecompute,
|
||||
@@ -101,6 +102,10 @@ beforeEach(() => {
|
||||
findFirstMeasurement.mockReset();
|
||||
bossSend.mockReset();
|
||||
getGlobalBossMock.mockReset();
|
||||
// v1.4.38 — clear the per-userId in-flight map so a previous test's
|
||||
// resolved promise does not short-circuit the next test's recompute
|
||||
// assertion.
|
||||
_resetEnsureUserRollupsFreshInFlightForTests();
|
||||
// Default: $transaction takes an array of pre-built promises (the
|
||||
// populator passes `slice.map(prisma.measurementRollup.upsert(...))`)
|
||||
// and returns them awaited.
|
||||
@@ -291,6 +296,76 @@ describe("ensureUserRollupsFresh", () => {
|
||||
expect(result.recomputed).toBe(false);
|
||||
});
|
||||
|
||||
// v1.4.38 — concurrent callers for the same userId share one
|
||||
// in-flight promise so a cold fan-out can never queue N parallel
|
||||
// recompute runs against the same 90-day window.
|
||||
it("dedups concurrent callers for the same userId onto one in-flight promise", async () => {
|
||||
const rollupAt = new Date("2026-05-10T10:00:00.000Z");
|
||||
const measurementAt = new Date("2026-05-10T12:00:00.000Z");
|
||||
// Both round-trips inside the helper run twice (the helper issues
|
||||
// a parallel `Promise.all` for the rollup-most-recent + measurement
|
||||
// -most-recent). With dedup, those should fire ONCE across two
|
||||
// concurrent callers.
|
||||
findFirst.mockResolvedValue({ computedAt: rollupAt });
|
||||
findFirstMeasurement.mockResolvedValue({
|
||||
updatedAt: measurementAt,
|
||||
measuredAt: measurementAt,
|
||||
});
|
||||
queryRawUnsafe.mockResolvedValue([]);
|
||||
|
||||
const [a, b, c] = await Promise.all([
|
||||
ensureUserRollupsFresh("user-1"),
|
||||
ensureUserRollupsFresh("user-1"),
|
||||
ensureUserRollupsFresh("user-1"),
|
||||
]);
|
||||
expect(a.recomputed).toBe(true);
|
||||
expect(b.recomputed).toBe(true);
|
||||
expect(c.recomputed).toBe(true);
|
||||
// The Postgres-side recompute fires exactly once across the three
|
||||
// concurrent callers — proof the dedup map collapsed the fan-out.
|
||||
expect(queryRawUnsafe).toHaveBeenCalledTimes(1);
|
||||
// The findFirst probe fires exactly once per call type (rollup,
|
||||
// measurement) across all three concurrent callers.
|
||||
expect(findFirst).toHaveBeenCalledTimes(1);
|
||||
expect(findFirstMeasurement).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("clears the in-flight slot after resolution so the next call runs fresh", async () => {
|
||||
const rollupAt = new Date("2026-05-10T10:00:00.000Z");
|
||||
const measurementAt = new Date("2026-05-10T12:00:00.000Z");
|
||||
findFirst.mockResolvedValue({ computedAt: rollupAt });
|
||||
findFirstMeasurement.mockResolvedValue({
|
||||
updatedAt: measurementAt,
|
||||
measuredAt: measurementAt,
|
||||
});
|
||||
queryRawUnsafe.mockResolvedValue([]);
|
||||
|
||||
await ensureUserRollupsFresh("user-1");
|
||||
await ensureUserRollupsFresh("user-1");
|
||||
// Two serial calls => two recompute runs (no dedup short-circuit
|
||||
// because the first call already resolved and dropped its slot).
|
||||
expect(queryRawUnsafe).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("clears the in-flight slot on rejection so the next call retries", async () => {
|
||||
findFirst.mockRejectedValueOnce(new Error("pool exhausted"));
|
||||
findFirstMeasurement.mockRejectedValueOnce(new Error("pool exhausted"));
|
||||
const first = await ensureUserRollupsFresh("user-1");
|
||||
expect(first.recomputed).toBe(false);
|
||||
|
||||
// Second call: probe succeeds, stale watermark triggers a recompute.
|
||||
const rollupAt = new Date("2026-05-10T10:00:00.000Z");
|
||||
const measurementAt = new Date("2026-05-10T12:00:00.000Z");
|
||||
findFirst.mockResolvedValueOnce({ computedAt: rollupAt });
|
||||
findFirstMeasurement.mockResolvedValueOnce({
|
||||
updatedAt: measurementAt,
|
||||
measuredAt: measurementAt,
|
||||
});
|
||||
queryRawUnsafe.mockResolvedValueOnce([]);
|
||||
const second = await ensureUserRollupsFresh("user-1");
|
||||
expect(second.recomputed).toBe(true);
|
||||
});
|
||||
|
||||
it("annotates the failure when the inner recompute throws (H3)", async () => {
|
||||
// Stale watermark — rollup older than the newest measurement so
|
||||
// the inner recompute branch fires.
|
||||
|
||||
@@ -40,6 +40,20 @@ import {
|
||||
hkIdentifierForType,
|
||||
} from "./apple-health-mapping";
|
||||
|
||||
/**
|
||||
* v1.4.38 — canonical cutoff for the nightly scheduled drain. Rows
|
||||
* whose `measuredAt` is newer than `now() - DRAIN_CUMULATIVE_CUTOFF_HOURS`
|
||||
* are excluded so today's still-in-flight Apple Watch syncs stay as
|
||||
* per-sample rows in the user's "today" view. 36 hours covers the
|
||||
* previous calendar day plus a generous trailing sync window for
|
||||
* watches that weren't worn at midnight. The CLI and the admin route
|
||||
* import the constant for visibility but deliberately pass `undefined`
|
||||
* by default so an explicit one-shot drain collapses every row the
|
||||
* operator points it at; pass the constant explicitly when mirroring
|
||||
* the nightly behaviour from an interactive shell.
|
||||
*/
|
||||
export const DRAIN_CUMULATIVE_CUTOFF_HOURS = 36;
|
||||
|
||||
/** Per-(user, type, day) action summary. */
|
||||
export interface DrainBucket {
|
||||
userId: string;
|
||||
@@ -296,6 +310,13 @@ export async function drainPerSampleCumulative(
|
||||
const tz = user.timezone && user.timezone.length > 0 ? user.timezone : "Europe/Berlin";
|
||||
log(`[drain] user=${user.id} tz=${tz}${dryRun ? " (dry-run)" : ""}`);
|
||||
|
||||
// v1.4.38 — per-user counters that mirror the existing aggregate
|
||||
// totals. Lets the per-user COMPLETE log line carry useful
|
||||
// numbers without re-walking the summary list later.
|
||||
const beforeBucketsCollapsed = summary.totals.bucketsCollapsed;
|
||||
const beforePerSampleDeleted = summary.totals.perSampleRowsDeleted;
|
||||
const beforeDailyUpserted = summary.totals.dailyRowsUpserted;
|
||||
|
||||
for (const type of CUMULATIVE_HK_TYPES) {
|
||||
const hkIdentifier = hkIdentifierForType(type);
|
||||
if (!hkIdentifier) continue;
|
||||
@@ -403,6 +424,22 @@ export async function drainPerSampleCumulative(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// v1.4.38 — per-user COMPLETE log line. Mirrors the START line on
|
||||
// line 311 so an operator scanning the worker log can pair
|
||||
// "drain started for user X" with "drain finished for user X"
|
||||
// without scrolling through every per-type bucket. Counts are
|
||||
// computed as the delta against the snapshot taken before the
|
||||
// per-type loop started.
|
||||
const userBucketsCollapsed =
|
||||
summary.totals.bucketsCollapsed - beforeBucketsCollapsed;
|
||||
const userPerSampleDeleted =
|
||||
summary.totals.perSampleRowsDeleted - beforePerSampleDeleted;
|
||||
const userDailyUpserted =
|
||||
summary.totals.dailyRowsUpserted - beforeDailyUpserted;
|
||||
log(
|
||||
`[drain] user=${user.id} complete bucketsCollapsed=${userBucketsCollapsed} perSampleRowsDeleted=${userPerSampleDeleted} dailyRowsUpserted=${userDailyUpserted}${dryRun ? " (dry-run)" : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
log(
|
||||
|
||||
@@ -199,16 +199,25 @@ export async function recomputeBucketsForMeasurement(
|
||||
// WEEK / MONTH / YEAR — enqueue. Each granularity covers a range
|
||||
// around the affected day so the worker only re-aggregates the
|
||||
// bucket(s) the write could have changed.
|
||||
for (const granularity of ASYNC_GRANULARITIES) {
|
||||
const { from, to } = bucketSpan(measuredAt, granularity);
|
||||
await enqueueRollupRecompute({
|
||||
userId,
|
||||
type,
|
||||
granularity,
|
||||
from,
|
||||
to,
|
||||
});
|
||||
}
|
||||
//
|
||||
// v1.4.38 — the three `enqueueRollupRecompute` calls are independent
|
||||
// (different singletonKey per granularity inside `enqueueRollupRecompute`)
|
||||
// so fan them out via `Promise.all` instead of sequencing them with
|
||||
// `await`. Halves the post-write critical path on every measurement
|
||||
// create / update / delete; matters on iOS batch imports that fire
|
||||
// hundreds of hooks back-to-back.
|
||||
await Promise.all(
|
||||
ASYNC_GRANULARITIES.map((granularity) => {
|
||||
const { from, to } = bucketSpan(measuredAt, granularity);
|
||||
return enqueueRollupRecompute({
|
||||
userId,
|
||||
type,
|
||||
granularity,
|
||||
from,
|
||||
to,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -555,6 +564,26 @@ function bucketSpan(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.38 — per-userId in-flight dedup. v1.4.37.1 fire-and-forgets the
|
||||
* `ensureUserRollupsFresh` call from the read path so a stale 90-day
|
||||
* recompute can no longer stall the response, but if a request DOES
|
||||
* end up awaiting a synchronous recompute (worker code path, future
|
||||
* caller without the fire-and-forget) and the same user has a cold
|
||||
* fan-out hitting analytics + comprehensive + summaries within the
|
||||
* same tick, the legacy shape would queue N parallel
|
||||
* `recomputeUserRollups` invocations against the same 90-day window.
|
||||
* The map below collapses those into one shared promise — every
|
||||
* caller after the first re-uses the in-flight resolution and only
|
||||
* one Postgres aggregate runs. Mirrors the `buildCoachSnapshot` dedup
|
||||
* pattern that lives one layer up. Cleared in `finally` so a slow
|
||||
* promise that eventually rejects does not poison the next request.
|
||||
*/
|
||||
const ensureUserRollupsFreshInFlight = new Map<
|
||||
string,
|
||||
Promise<{ recomputed: boolean }>
|
||||
>();
|
||||
|
||||
/**
|
||||
* Cheap read-side warm-up. Called from the analytics + comprehensive
|
||||
* read paths so the rollup table stays current for downstream
|
||||
@@ -572,9 +601,39 @@ function bucketSpan(
|
||||
* here.
|
||||
* - Best-effort: thrown errors are swallowed so the read path
|
||||
* never fails because of a populator hiccup.
|
||||
* - v1.4.38: concurrent callers for the same `userId` share one
|
||||
* in-flight promise so a cold fan-out can never queue N parallel
|
||||
* `recomputeUserRollups` runs against the same 90-day window.
|
||||
*/
|
||||
export async function ensureUserRollupsFresh(
|
||||
userId: string,
|
||||
): Promise<{ recomputed: boolean }> {
|
||||
// v1.4.38 — share the in-flight promise across concurrent callers
|
||||
// for the same userId. The map is keyed on `userId` because the
|
||||
// helper has no other parameters; the next caller hits the
|
||||
// resolved promise as soon as it lands. We delete the slot in
|
||||
// `finally` so a slow rejected promise does not stay cached and
|
||||
// poison the next request.
|
||||
const existing = ensureUserRollupsFreshInFlight.get(userId);
|
||||
if (existing) return existing;
|
||||
const inflight = ensureUserRollupsFreshImpl(userId).finally(() => {
|
||||
ensureUserRollupsFreshInFlight.delete(userId);
|
||||
});
|
||||
ensureUserRollupsFreshInFlight.set(userId, inflight);
|
||||
return inflight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only handle on the in-flight map. Mirrors the
|
||||
* `_resetTrustViolationWarningForTests` pattern in api-response.ts —
|
||||
* production code never calls this.
|
||||
*/
|
||||
export function _resetEnsureUserRollupsFreshInFlightForTests(): void {
|
||||
ensureUserRollupsFreshInFlight.clear();
|
||||
}
|
||||
|
||||
async function ensureUserRollupsFreshImpl(
|
||||
userId: string,
|
||||
): Promise<{ recomputed: boolean }> {
|
||||
try {
|
||||
// Single round-trip — the most-recent DAY rollup for the user
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* v1.4.38 — drift-guard between the validation enum and the label-key
|
||||
* map. The category enum lives in three places (Prisma schema,
|
||||
* `MEDICATION_CATEGORY_VALUES` in the validation module, and the
|
||||
* `MEDICATION_CATEGORY_KEYS` lookup inside `category-label.ts`). If a
|
||||
* new category lands in the enum without a matching label key, the
|
||||
* generic `<MedicationCard>` quietly falls back to `categoryOther`
|
||||
* for the new bucket — a silent UX regression. Pin the invariant
|
||||
* here so the test suite trips before the regression ships.
|
||||
*
|
||||
* Symmetric drift (label key present without an enum entry) is also
|
||||
* undesirable — it's dead code, but more importantly it suggests the
|
||||
* enum dropped a value without removing the lookup, which leaves the
|
||||
* label map advertising a category the rest of the app no longer
|
||||
* understands.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { MEDICATION_CATEGORY_VALUES } from "@/lib/validations/medication";
|
||||
import { getMedicationCategoryLabel } from "../category-label";
|
||||
|
||||
describe("medication category-label drift-guard", () => {
|
||||
it("every MEDICATION_CATEGORY_VALUES entry resolves to a distinct label key", () => {
|
||||
// Use a no-op translator that echoes the key back so we can
|
||||
// distinguish "real key" from the OTHER fallback.
|
||||
const echo = (key: string) => key;
|
||||
const fallback = "medications.categoryOther";
|
||||
|
||||
for (const value of MEDICATION_CATEGORY_VALUES) {
|
||||
const resolved = getMedicationCategoryLabel(value, echo);
|
||||
if (value === "OTHER") {
|
||||
expect(resolved).toBe(fallback);
|
||||
continue;
|
||||
}
|
||||
// Any non-OTHER enum value that resolves to the OTHER key is a
|
||||
// drift bug — the value made it into the enum without a
|
||||
// matching label entry, so the card silently labels the
|
||||
// category as "Sonstige".
|
||||
expect(
|
||||
resolved,
|
||||
`enum value "${value}" falls back to categoryOther — add a key in MEDICATION_CATEGORY_KEYS to cover it`,
|
||||
).not.toBe(fallback);
|
||||
}
|
||||
});
|
||||
|
||||
it("the fallback path still fires for an unknown category value", () => {
|
||||
const echo = (key: string) => key;
|
||||
expect(getMedicationCategoryLabel("NOT_A_REAL_CATEGORY", echo)).toBe(
|
||||
"medications.categoryOther",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
formatInUserTz,
|
||||
invalidateServerDefaultTimezone,
|
||||
invalidateUserTimezone,
|
||||
isNearUtc,
|
||||
isValidTimezone,
|
||||
resolveServerDefaultTimezone,
|
||||
resolveUserTimezone,
|
||||
@@ -261,6 +262,51 @@ describe("userDayKey", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isNearUtc", () => {
|
||||
// Pin a summer-side instant so DST is on for Berlin (+2) and off for
|
||||
// most southern-hemisphere zones — keeps the assertions readable.
|
||||
const summer = new Date("2026-07-15T12:00:00.000Z");
|
||||
// Winter-side instant so Berlin is on CET (+1).
|
||||
const winter = new Date("2026-01-15T12:00:00.000Z");
|
||||
|
||||
it("returns true for UTC", () => {
|
||||
expect(isNearUtc("UTC", summer)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for Europe/Berlin in both summer and winter", () => {
|
||||
expect(isNearUtc("Europe/Berlin", summer)).toBe(true);
|
||||
expect(isNearUtc("Europe/Berlin", winter)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for the ±3h boundary zones", () => {
|
||||
// Europe/Moscow = +3 year-round.
|
||||
expect(isNearUtc("Europe/Moscow", summer)).toBe(true);
|
||||
// Atlantic/Azores = -1 in winter, 0 in summer.
|
||||
expect(isNearUtc("Atlantic/Azores", summer)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for zones more than 3 hours from UTC", () => {
|
||||
// Pacific/Honolulu = -10 year-round.
|
||||
expect(isNearUtc("Pacific/Honolulu", summer)).toBe(false);
|
||||
// Asia/Tokyo = +9 year-round.
|
||||
expect(isNearUtc("Asia/Tokyo", summer)).toBe(false);
|
||||
// America/New_York = -4 in summer, -5 in winter.
|
||||
expect(isNearUtc("America/New_York", summer)).toBe(false);
|
||||
// Pacific/Auckland = +13 in southern summer (Jan), +12 in May.
|
||||
expect(isNearUtc("Pacific/Auckland", winter)).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults to near-UTC when the zone string is invalid", () => {
|
||||
// Invalid zones fall back to Europe/Berlin, which is near-UTC.
|
||||
expect(isNearUtc("Mars/Olympus_Mons", summer)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the current instant when `now` is omitted", () => {
|
||||
// Pure smoke — exercises the default parameter branch.
|
||||
expect(typeof isNearUtc("Europe/Berlin")).toBe("boolean");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DEFAULT_TIMEZONE", () => {
|
||||
it("is Europe/Berlin", () => {
|
||||
expect(DEFAULT_TIMEZONE).toBe("Europe/Berlin");
|
||||
|
||||
@@ -67,6 +67,38 @@ export function detectBrowserTimezone(): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.38 W-A — cross-tz fast-path runtime guard.
|
||||
*
|
||||
* Returns `true` when the supplied IANA zone is within ±3 hours of UTC
|
||||
* at the supplied instant. Used by the analytics fast-paths
|
||||
* (`bp-in-target-fast-path`, `correlations-fast-path`) to gate the
|
||||
* rollup read-swap: those helpers key rollup rows on UTC-midnight
|
||||
* `bucketStart` and pair them with per-event streams keyed in the
|
||||
* user's local timezone. For Berlin (+1/+2) the day-key on the rollup
|
||||
* and the day-key on the per-event row line up; for Honolulu (-10)
|
||||
* they slip by a calendar day. Three hours is a conservative pin —
|
||||
* the worst-case slip inside the window is the wall-clock hour-of-day
|
||||
* the user happens to log at, and three hours covers every European
|
||||
* zone plus the western edge of Mid-Atlantic / eastern edge of the
|
||||
* Americas.
|
||||
*
|
||||
* Falls back to "near-UTC" (i.e. returns `true`) when the zone string
|
||||
* is invalid — the resolver layer is already defensive about junk
|
||||
* values, and a `true` here means the caller takes the rollup
|
||||
* fast-path which is the safer default for the canonical Berlin
|
||||
* tenant.
|
||||
*
|
||||
* The `now` argument lets callers honour DST transitions; the offset
|
||||
* of `Europe/Berlin` is +1 in January and +2 in July. Defaults to
|
||||
* "now" so most callers can `isNearUtc(userTz)`.
|
||||
*/
|
||||
export function isNearUtc(tz: string, now: Date = new Date()): boolean {
|
||||
const safeTz = isValidTimezone(tz) ? tz : DEFAULT_TIMEZONE;
|
||||
const offsetMinutes = tzOffsetMinutes(now, safeTz);
|
||||
return Math.abs(offsetMinutes) <= 3 * 60;
|
||||
}
|
||||
|
||||
export type FormatInUserTzShape =
|
||||
| "iso-with-offset"
|
||||
| "wall-clock"
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
export {
|
||||
DEFAULT_TIMEZONE,
|
||||
isValidTimezone,
|
||||
isNearUtc,
|
||||
listSupportedTimezones,
|
||||
detectBrowserTimezone,
|
||||
formatInUserTz,
|
||||
|
||||
@@ -235,4 +235,40 @@ describe("measurement validation", () => {
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// v1.4.38 — the per-day drill-down branch always returns at most
|
||||
// 1000 rows (one pathological phone-only stepCount day). Push the
|
||||
// cap into the validator so a caller asking for more sees a 422
|
||||
// instead of a silent server-side Math.min clamp.
|
||||
describe("listMeasurementsSchema — v1.4.38 dayKey limit ceiling", () => {
|
||||
it("accepts limit=1000 alongside a dayKey", () => {
|
||||
const parsed = listMeasurementsSchema.safeParse({
|
||||
type: "ACTIVITY_STEPS",
|
||||
dayKey: "2026-05-15",
|
||||
limit: 1000,
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects limit=1001 when dayKey is set (drill-down hard cap)", () => {
|
||||
const parsed = listMeasurementsSchema.safeParse({
|
||||
type: "ACTIVITY_STEPS",
|
||||
dayKey: "2026-05-15",
|
||||
limit: 1001,
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
if (!parsed.success) {
|
||||
expect(parsed.error.issues[0].message).toMatch(/<=\s*1000/);
|
||||
expect(parsed.error.issues[0].path).toEqual(["limit"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts limit=5000 when dayKey is omitted (legacy ceiling)", () => {
|
||||
const parsed = listMeasurementsSchema.safeParse({
|
||||
type: "ACTIVITY_STEPS",
|
||||
limit: 5000,
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -325,7 +325,19 @@ export const listMeasurementsSchema = z
|
||||
"offset is not supported with groupBy=day or dayKey; use a smaller window or omit offset",
|
||||
path: ["offset"],
|
||||
},
|
||||
);
|
||||
)
|
||||
// v1.4.38 — surface the drill-down cap on the validator so a caller
|
||||
// asking for more than 1000 rows on a per-day branch sees a 422
|
||||
// instead of a silent `Math.min(limit, 1000)` clamp inside the
|
||||
// route. The drill-down returns at most one phone-only stepCount
|
||||
// day's worth of per-sample chunks; 1000 covers the pathological
|
||||
// case with room to spare. Callers that want a wider window should
|
||||
// omit `dayKey` and use `from` / `to` instead.
|
||||
.refine(({ limit, dayKey }) => !(dayKey != null && limit > 1000), {
|
||||
message:
|
||||
"limit must be <= 1000 when dayKey is set; drill-down responses are capped at 1000 rows",
|
||||
path: ["limit"],
|
||||
});
|
||||
|
||||
export const createBatchMeasurementSchema = z.object({
|
||||
measurements: z.array(createMeasurementSchema).min(1).max(5),
|
||||
|
||||
Reference in new issue
Block a user