mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
chore(release): v1.4.42 — Knip enforcing, queryKey factory closed, iOS Workouts dedup, Withings off-response classification
Six touch-disjoint waves landed on develop before this release: - W1-KNIP — exports/types tiers flipped to enforcing-mode on main; 35 unused exports + 52 unused types triaged to 0/0; shadcn surface + zod-infer contract types scoped via knip.json ignore-block. - W2-ZOD-MULTI-ISSUE — `returnAllZodIssues` shared helper + `/api/dashboard/widgets` 422 now returns every Zod issue + worker audit-ledger breadcrumb. 41 sibling routes documented for v1.4.43. - W3-QUERYKEY-LONGTAIL — 40 settings/medications/admin/hooks files migrated to the queryKeys factory; 9 new factory entries; ESLint rule + test-guard scope extended in lockstep. - W4-ARCH-HYGIENE — BERLIN_DAY_FORMATTER consolidated across nine sites (seven status helpers + bucket-series + bp-in-target); Suspense double-comment consolidated; doctor-report sanitiser regex rewritten to escape-form so diffs are reviewable; pr-detection- worker soft-delete filter; offhost-backup DR-intent comment. - W5-IOS-WORKOUTS — `dedupeWorkoutBatch` write-time cross-source dedup helper for the v1.5 iOS HealthKit observer ingest; dashboard tile-strip placeholder parity with the live TrendCard chrome. - W6-WITHINGS-OFF + ENV-CHECK — Withings off-response classifier (transient/reauth/persistent) + WithingsApiError typed throw + the `pnpm check-env` CLI. APNs `allOrNone: true` closes the AP-2 silent-disable gap the wave was conceived to close. No migration, no env-var change, no API contract break for iOS v0.5.4. The new `/api/dashboard/widgets` 422 envelope is additively extended. pnpm test --run green at 4815 passing / 1 skipped. pnpm typecheck, pnpm lint, pnpm knip (no --include flag) green.
This commit is contained in:
1 parent
67207d725e
commit
9b6e237f5f
121 files changed
+6481
-590
No files matched your search
@@ -29,15 +29,8 @@ jobs:
|
||||
|
||||
- run: pnpm db:generate
|
||||
|
||||
# v1.4.41 W-PROCESS-DOCS — exports + types tier staged for
|
||||
# promotion to enforcing. The W-SIMPLIFIER and W-INSIGHTS-HOT
|
||||
# cleanup waves have not yet landed on develop at the time this
|
||||
# comment was written (48 unused exports + 52 unused exported
|
||||
# types still in the tree), so the gate remains scoped to
|
||||
# files,dependencies,binaries,unlisted. Once those cleanup waves
|
||||
# merge and `pnpm knip --reporter compact` reports zero unused
|
||||
# exports + zero unused types, drop the `--include` flag below
|
||||
# to flip exports + types to enforcing. See
|
||||
# `.planning/phase-W-PROCESS-DOCS-v1441-report.md` for the
|
||||
# promotion checklist and the rollback path.
|
||||
- run: pnpm knip --reporter compact --include files,dependencies,binaries,unlisted
|
||||
# v1.4.42 — exports + types tiers promoted to enforcing. The
|
||||
# `knip.json` ignore-block carries the shadcn UI surface and the
|
||||
# `src/lib/validations/**` contract types; everything else has
|
||||
# to clear or get justified there.
|
||||
- run: pnpm knip --reporter compact
|
||||
@@ -0,0 +1,150 @@
|
||||
# W1-KNIP v1.4.42 phase report
|
||||
|
||||
## Summary
|
||||
|
||||
Promoted the knip CI gate to enforcing for the `exports` + `types` issue
|
||||
tiers. The tree is now at zero unused exports + zero unused types under
|
||||
the documented ignore-block; any future regression trips the
|
||||
`Knip / Dead-code gate` workflow on every PR into `main`.
|
||||
|
||||
## Branch
|
||||
|
||||
`worktree-agent-ace944dcad5bbeec2` (pushed to origin; branched off
|
||||
`d3d60104` — the `develop` tip after the v1.4.41 release).
|
||||
|
||||
## Commits
|
||||
|
||||
| SHA | Title |
|
||||
| --- | --- |
|
||||
| `6ddf8fa4` | `chore(knip): drop seven dead exports flagged by W-SIMPLIFIER` |
|
||||
| `b7ac80b2` | `chore(knip): mute shadcn surface + drop two duplicate helpers` |
|
||||
| `4e318c9a` | `chore(knip): drop fourteen orphaned types + scope contracts ignore-block` |
|
||||
| `20df7b9d` | `ci(knip): flip exports + types tiers to enforcing` |
|
||||
|
||||
## Knip count
|
||||
|
||||
| Tier | Before (v1.4.41 tip) | After (this branch) |
|
||||
| --- | --- | --- |
|
||||
| Unused exports | 35 | 0 |
|
||||
| Unused exported types | 52 | 0 |
|
||||
| Exit code (no `--include`) | non-zero | 0 |
|
||||
|
||||
## `knip.json` ignore-block contents
|
||||
|
||||
Two new top-level keys + one updated `ignoreIssues` block:
|
||||
|
||||
```jsonc
|
||||
"ignoreExportsUsedInFile": true,
|
||||
"ignoreIssues": {
|
||||
"src/components/ui/**": ["exports", "types"],
|
||||
"src/lib/validations/**": ["types"]
|
||||
}
|
||||
```
|
||||
|
||||
Rationale:
|
||||
|
||||
- `ignoreExportsUsedInFile: true` — the schema-source-of-truth pattern
|
||||
in `src/lib/ai/**` and the validation files exports the zod schema +
|
||||
inferred `z.infer<>` type as a package surface. The schema is
|
||||
referenced internally for composition (`z.array(otherSchema)`); knip
|
||||
treats that as "same-file reference, but still exported, so unused".
|
||||
The boolean flips that behaviour so internal references count as
|
||||
usage.
|
||||
- `src/components/ui/**: ["exports", "types"]` — every shadcn UI symbol
|
||||
the W-SIMPLIFIER residual flagged (`AlertDialogMedia/Overlay/Portal`,
|
||||
`AvatarBadge/Group/GroupCount`, `badgeVariants`, `buttonVariants`,
|
||||
`Card*`, `Dialog*`, `DropdownMenu*`, `Select*`, `SheetTrigger`,
|
||||
`Table*`, `tabsListVariants`) was confirmed against
|
||||
`https://ui.shadcn.com/r/styles/new-york-v4/*.json` as part of the
|
||||
upstream registry. Keeping them in tree preserves library-shape
|
||||
parity so the next shadcn refresh applies cleanly.
|
||||
- `src/lib/validations/**: ["types"]` — the `z.infer<>` types in these
|
||||
files are the external API contract surface (iOS contract
|
||||
type-matching, OpenAPI generation, route handler typing). They are
|
||||
not literally imported by name elsewhere, but they document the
|
||||
request/response shapes the routes parse against. Drop the type
|
||||
tier only; the `exports` tier on these files stays enforcing so any
|
||||
truly-dead schema still trips the gate.
|
||||
|
||||
## Drops vs. mutes
|
||||
|
||||
### Dropped genuinely-dead exports (W-SIMPLIFIER carry-over)
|
||||
- `tokenKind` (`src/lib/insights/chart-tokens.ts`) — comment-only ref
|
||||
in test; removed function + JSDoc.
|
||||
- `withBackgroundEventSafe` (`src/lib/logging/background.ts`) — only
|
||||
ref was the `src/lib/logging/index.ts` re-export; both removed.
|
||||
- `isRollupFresh` (`src/lib/rollups/measurement-rollups.ts`) — deferred
|
||||
in v1.4.41 pending W-PERF-OPS-1. The persistent-rollup reader now
|
||||
carries its own freshness probe and never imported the helper.
|
||||
- `PROGRESS_TICK_RECORDS`, `MAINTAINED_LOCALES`, `SUB_PAGE_METRIC` —
|
||||
used only inside their own module; `export` keyword dropped.
|
||||
|
||||
### Dropped duplicate / dead helpers
|
||||
- `requireAdmin` in `src/lib/auth/session.ts` — shadowed the real
|
||||
implementation in `src/lib/api-handler.ts`; every caller in the tree
|
||||
imports the latter.
|
||||
- `readDailyMeans` in `src/lib/rollups/measurement-read.ts` — unused
|
||||
convenience wrapper around `readRollupBuckets`. Reader-side surfaces
|
||||
call `readRollupBuckets` + `aggregateBuckets` directly.
|
||||
- `CHART_RANGE_PRESETS` (`src/lib/charts/constants.ts`) and its
|
||||
derived `ChartRangePreset` type — no chart reads either.
|
||||
|
||||
### Dropped orphaned `z.infer<>` types
|
||||
- `MetricSource`, `AIRecommendationRationale`, `AIRecommendation`,
|
||||
`AICitation`, `AIWarning`, `StoryboardAnnotation` in
|
||||
`src/lib/ai/schema.ts`.
|
||||
- `InsightFinding` in `src/lib/ai/types.ts`.
|
||||
- `CoachChatRequest` in `src/lib/ai/coach/types.ts`.
|
||||
- `ApplyProfileInput` in `src/lib/auth/profile-update.ts`.
|
||||
- `CreateSideEffectInput`, `ListSideEffectsInput` in
|
||||
`src/lib/medications/side-effects/validators.ts`.
|
||||
- `AnalyticsSlicePayload` in `src/lib/queries/use-analytics-query.ts`.
|
||||
- `ThresholdOverride` in `src/lib/analytics/effective-range.ts` —
|
||||
persisted shape is `ThresholdOverridesJson`.
|
||||
- `WebPushChannelConfig`, `ApnsChannelConfig`, `ChannelConfig` triplet
|
||||
in `src/lib/notifications/types.ts` — empty records + the wrapping
|
||||
union added nothing; the dispatcher imports `TelegramChannelConfig`
|
||||
and `NtfyChannelConfig` directly by name.
|
||||
- `WithingsWebhookAuthOutcome` in `src/lib/withings/webhook-handler.ts`
|
||||
— declared but never referenced.
|
||||
|
||||
## Reconcile callouts (W3 / W4 scope)
|
||||
|
||||
Two W-SIMPLIFIER carry-over items live in scopes owned by parallel
|
||||
waves and were left untouched:
|
||||
|
||||
1. **`describeInjectionSite` re-export in
|
||||
`src/components/medications/glp1-medication-card.tsx`** — the
|
||||
declaration in `src/lib/medications/injection-sites.ts` is used by
|
||||
`injection-site-picker.tsx` and `therapy-timeline.tsx`. Only the
|
||||
re-export at the bottom of the card file is dead. File lives under
|
||||
`src/components/medications/**`, which is **W3-QUERYKEY-LONGTAIL**
|
||||
scope.
|
||||
2. **`listSupportedTimezones` re-export in
|
||||
`src/lib/tz/resolver.ts`** — the real implementation is in
|
||||
`src/lib/tz/format.ts` and is imported directly by
|
||||
`src/components/settings/timezone-picker.tsx` +
|
||||
`src/components/admin/general-settings-section.tsx`. Only the
|
||||
re-export off `resolver.ts` is dead. File lives at
|
||||
`src/lib/tz/resolver.ts`, which is **W4-ARCH-HYGIENE** scope.
|
||||
|
||||
Both items already appear as "unused exports" in the pre-gate knip run.
|
||||
After this branch lands they remain flagged. The gate flip in
|
||||
`20df7b9d` means a CI red until W3 / W4 land their own drops, so the
|
||||
merge-order plan needs all three branches in develop before any of them
|
||||
reaches main. If W3 + W4 land first, the gate stays green from the
|
||||
flip onward; if this branch lands first, the gate goes red on main
|
||||
until W3 + W4 follow.
|
||||
|
||||
Recommended merge order: **W1 last** (this branch).
|
||||
|
||||
## Quality bar evidence
|
||||
|
||||
- `pnpm typecheck` clean before every commit.
|
||||
- `pnpm lint` clean before every commit.
|
||||
- `pnpm knip --reporter compact` exits 0 with no findings on the final
|
||||
commit.
|
||||
- `pnpm test --run`: **4732 passed | 1 skipped** (no new failures
|
||||
versus the v1.4.41 baseline at `d3d60104`).
|
||||
- No `Co-Authored-By`, no `--no-verify`, no `--no-gpg-sign` on any
|
||||
commit; messages in Marc-voice English, conventional-commit prefix.
|
||||
@@ -0,0 +1,181 @@
|
||||
# W2-ZOD-MULTI-ISSUE — phase report (v1.4.42)
|
||||
|
||||
Branch: `worktree-agent-afedd7bebb01d0abd` (off `develop` @ `d3d60104`).
|
||||
|
||||
## What landed
|
||||
|
||||
### 1. Shared helper `returnAllZodIssues` — `src/lib/api-response.ts`
|
||||
|
||||
```ts
|
||||
returnAllZodIssues(error: ZodError, status = 422, meta?): NextResponse
|
||||
```
|
||||
|
||||
Envelope:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"data": null,
|
||||
"error": "Validation failed",
|
||||
"details": { "issues": [{ "path", "code", "message" }, …] },
|
||||
"meta": { /* optional, e.g. errorCode */ }
|
||||
}
|
||||
```
|
||||
|
||||
- Companion `sanitiseZodIssues(issues)` is also exported for callers that
|
||||
need the same `{ path, code, message }` projection without building
|
||||
a response (the widgets route uses it for the audit-ledger breadcrumb).
|
||||
- `issue.params` is **never** echoed — it can carry the raw rejected user
|
||||
input for some Zod issue codes (e.g. `invalid_format`'s `pattern`,
|
||||
`too_big`'s `maximum`).
|
||||
- Additive: clients that only read top-level `error` keep working; new
|
||||
callers branch on `details.issues`.
|
||||
- Same `meta` passthrough as `apiError` (so `errorCode` + `headers`
|
||||
round-trip identically).
|
||||
|
||||
### 2. `/api/dashboard/widgets` PUT 422 — multi-issue + audit breadcrumb
|
||||
|
||||
- Old: `apiError(parsed.error.issues[0].message, 422)`.
|
||||
- New: `returnAllZodIssues(parsed.error, 422)` + `annotate(...)` + a
|
||||
best-effort `prisma.auditLog.create({ action:
|
||||
"dashboard.widgets.validation-failed", details: { issues } })`.
|
||||
- Audit row is fire-and-forget — the 422 response is the contract, the
|
||||
audit row is a debugging breadcrumb.
|
||||
|
||||
### 3. Tests
|
||||
|
||||
- `src/lib/__tests__/api-response-zod.test.ts` — 7 cases (default 422,
|
||||
2-issue, 3-issue, nested-path flatten, params-leak guard, custom
|
||||
status, meta + headers passthrough).
|
||||
- `src/app/api/dashboard/widgets/__tests__/route.test.ts` — 4 cases
|
||||
(2-issue, 3-issue, audit-row write, audit-write-rejection survival).
|
||||
- `tests/integration/dashboard-widgets-save.test.ts` — appended 3
|
||||
scenarios mirroring the unit cases against the real Prisma client
|
||||
(2-issue + DB row, 3-issue, params-leak guard).
|
||||
|
||||
### Quality gates (all green)
|
||||
|
||||
- `pnpm typecheck` ✓
|
||||
- `pnpm lint` ✓
|
||||
- `pnpm test --run src/app/api/dashboard/widgets src/lib/__tests__/api-response-zod.test.ts` ✓ — 11/11
|
||||
- Integration tier not executed locally (needs live Postgres); CI will
|
||||
run it.
|
||||
|
||||
### Commits
|
||||
|
||||
1. `3e4406df` — `feat(api): add returnAllZodIssues helper for multi-issue 422 envelopes`
|
||||
2. `e3d0e070` — `feat(api/dashboard): return every Zod issue on widgets PUT 422`
|
||||
|
||||
## v1.4.43 backlog — broader rollout
|
||||
|
||||
41 sites in `src/app/api/**` still use `parsed.error.issues[0].message`
|
||||
(or the templated `Invalid format: ${...}` variant). Rolling them in
|
||||
this wave was out-of-scope per the brief; the queue below is sorted
|
||||
by debug-frequency / iOS-contract proximity.
|
||||
|
||||
### iOS-contract / high-traffic (rollout first)
|
||||
|
||||
| File | Line | Notes |
|
||||
|---|---|---|
|
||||
| `src/app/api/measurements/route.ts` | 48 | POST single measurement — iOS batch ingestion fallback. |
|
||||
| `src/app/api/measurements/route.ts` | 556 | DELETE measurements bulk. |
|
||||
| `src/app/api/measurements/route.ts` | 625 | POST measurements batch — iOS hot path. |
|
||||
| `src/app/api/measurements/series/route.ts` | 73 | GET series — iOS chart loader. |
|
||||
| `src/app/api/measurements/[id]/route.ts` | 64 | PUT measurement edit. |
|
||||
| `src/app/api/medications/intake/route.ts` | 76 | POST intake — iOS log button. |
|
||||
| `src/app/api/medications/intake/route.ts` | 264 | PATCH intake. |
|
||||
| `src/app/api/medications/intake/bulk/route.ts` | 112 | POST bulk intake — keeps `errorCode: "medication.bulk.invalid"`. |
|
||||
| `src/app/api/medications/[id]/intake/route.ts` | 44 | POST per-med intake. |
|
||||
| `src/app/api/medications/[id]/intake/route.ts` | 188 | PATCH per-med intake. |
|
||||
| `src/app/api/medications/[id]/intake/[eventId]/route.ts` | 37 | PATCH single intake event. |
|
||||
| `src/app/api/medications/[id]/intake/import/route.ts` | 51 | Import CSV — keeps `"Invalid format: …"` template; rewrite preserving the prefix. |
|
||||
| `src/app/api/mood-entries/route.ts` | 38 | POST mood. |
|
||||
| `src/app/api/mood-entries/route.ts` | 92 | PATCH mood. |
|
||||
| `src/app/api/mood-entries/bulk/route.ts` | 114 | Bulk mood — keeps `errorCode: "mood.bulk.invalid"`. |
|
||||
| `src/app/api/mood-entries/[id]/route.ts` | 69 | PATCH mood by id. |
|
||||
| `src/app/api/dashboard/chart-overlay-prefs/route.ts` | 52 | Sibling of widgets — same iOS popover loop. |
|
||||
| `src/app/api/integrations/healthkit/route.ts` | 142 | iOS HealthKit ingest. |
|
||||
| `src/app/api/ingest/medication/route.ts` | 95 | Native ingest. |
|
||||
| `src/app/api/devices/route.ts` | 74 | Device registration. |
|
||||
| `src/app/api/tokens/route.ts` | 54 | API token mint. |
|
||||
|
||||
### Medication CRUD (rollout next)
|
||||
|
||||
| File | Line |
|
||||
|---|---|
|
||||
| `src/app/api/medications/route.ts` | 113 |
|
||||
| `src/app/api/medications/[id]/route.ts` | 72 |
|
||||
| `src/app/api/medications/[id]/glp1/route.ts` | 148 |
|
||||
| `src/app/api/medications/[id]/inventory/route.ts` | 86 |
|
||||
| `src/app/api/medications/[id]/inventory/[itemId]/route.ts` | 63 |
|
||||
| `src/app/api/medications/[id]/cadence/route.ts` | 60 |
|
||||
| `src/app/api/medications/[id]/side-effects/route.ts` | 59 |
|
||||
| `src/app/api/medications/[id]/side-effects/route.ts` | 117 |
|
||||
|
||||
### Auth / settings / admin / feedback / consent (rollout last)
|
||||
|
||||
| File | Line |
|
||||
|---|---|
|
||||
| `src/app/api/auth/password/route.ts` | 39 |
|
||||
| `src/app/api/auth/register/route.ts` | 63 |
|
||||
| `src/app/api/admin/settings/route.ts` | 74 |
|
||||
| `src/app/api/admin/settings/assistant-flags/route.ts` | 127 |
|
||||
| `src/app/api/admin/feedback/[id]/route.ts` | 28 |
|
||||
| `src/app/api/admin/users/[id]/route.ts` | 41 |
|
||||
| `src/app/api/user/thresholds/route.ts` | 83 |
|
||||
| `src/app/api/feedback/route.ts` | 48 |
|
||||
| `src/app/api/bugreport/route.ts` | 86 |
|
||||
| `src/app/api/consent/ai/route.ts` | 26 (status 400) |
|
||||
| `src/app/api/consent/ai/latest/route.ts` | 41 (status 400) |
|
||||
| `src/app/api/consent/ai/latest/route.ts` | 89 (status 400) |
|
||||
|
||||
### Rollout recipe for v1.4.43
|
||||
|
||||
For each route:
|
||||
|
||||
1. Swap import: drop `apiError` if unused after rewrite; add
|
||||
`returnAllZodIssues` (+ `sanitiseZodIssues` if the route also
|
||||
needs an audit-ledger breadcrumb).
|
||||
2. Replace
|
||||
```ts
|
||||
return apiError(parsed.error.issues[0].message, <status>, <meta?>);
|
||||
```
|
||||
with
|
||||
```ts
|
||||
return returnAllZodIssues(parsed.error, <status>, <meta?>);
|
||||
```
|
||||
3. Decide whether the route warrants its own
|
||||
`<route>.validation-failed` audit-ledger row. Default = **no** for
|
||||
low-traffic admin routes, **yes** for iOS-contract hot paths and
|
||||
anything in the bulk-ingest cluster.
|
||||
4. Add 2 + 3 simultaneous-issue tests at the unit tier (use the
|
||||
widgets route test as the template).
|
||||
5. The lone outlier — `medications/[id]/intake/import/route.ts:51`
|
||||
prefixes `Invalid format: ` to the message. Switch to
|
||||
`returnAllZodIssues` and set
|
||||
`meta: { errorCode: "medication.intake.import.invalid_format" }`
|
||||
so the CSV-import client UI can still branch on the prefix.
|
||||
|
||||
### Why not in this wave
|
||||
|
||||
Touching 41 routes in one commit would have collided with W3 / W4
|
||||
worktrees (some routes import shared response helpers + tz helpers) and
|
||||
risked an overlap with the in-flight iOS contract debugging. The
|
||||
focused v1.4.42 wave proves the helper and the audit-ledger pattern; a
|
||||
follow-up wave rolls them out behind a single PR review.
|
||||
|
||||
## Risk + cut-list
|
||||
|
||||
- No risk to existing clients — the envelope is strictly additive
|
||||
(`details.issues` is new, `error` still carries a human-readable
|
||||
string).
|
||||
- iOS callers that hard-coded `body.error` keep working; new iOS
|
||||
builds can branch on `body.details?.issues`.
|
||||
- The audit-ledger row is best-effort — a DB outage during a PUT 422
|
||||
loses the breadcrumb but never the response (test pins this).
|
||||
|
||||
## Out of scope (explicit)
|
||||
|
||||
- `.github/workflows/knip.yml`, `knip.json` (W1).
|
||||
- `src/components/**` (W3).
|
||||
- `src/lib/tz/**` (W4).
|
||||
- Broader 41-route rollout (v1.4.43 backlog above).
|
||||
@@ -0,0 +1,146 @@
|
||||
# W3-QUERYKEY-LONGTAIL v1.4.42 report
|
||||
|
||||
## Branch + commit
|
||||
|
||||
- Worktree branch `worktree-agent-a11813dd22a55cbf7`, branched from
|
||||
`develop` tip `d3d60104`.
|
||||
- One squash-ready commit: `e50d3797 refactor(query-keys): route
|
||||
long-tail surfaces through the factory`.
|
||||
|
||||
## Scope landed
|
||||
|
||||
The settings / medications / admin / hooks tree still declared
|
||||
bare-literal `queryKey: [ … ]` arrays at ~70 call sites
|
||||
post-v1.4.41 W-FRONTEND-FACTORY. Every site now routes through
|
||||
`queryKeys.<entry>()` from `src/lib/query-keys.ts`.
|
||||
|
||||
### Files migrated (43 total, all absolute paths)
|
||||
|
||||
Settings (12):
|
||||
- /Users/marc/Projects/HealthLog/src/components/settings/account-section.tsx
|
||||
- /Users/marc/Projects/HealthLog/src/components/settings/advanced-section.tsx
|
||||
- /Users/marc/Projects/HealthLog/src/components/settings/ai-section.tsx
|
||||
- /Users/marc/Projects/HealthLog/src/components/settings/api-section.tsx
|
||||
- /Users/marc/Projects/HealthLog/src/components/settings/integrations-section.tsx
|
||||
- /Users/marc/Projects/HealthLog/src/components/settings/mood-reminder-card.tsx
|
||||
- /Users/marc/Projects/HealthLog/src/components/settings/notification-status-card.tsx
|
||||
- /Users/marc/Projects/HealthLog/src/components/settings/notifications-section.tsx
|
||||
- /Users/marc/Projects/HealthLog/src/components/settings/ntfy-card.tsx
|
||||
- /Users/marc/Projects/HealthLog/src/components/settings/telegram-card.tsx
|
||||
- /Users/marc/Projects/HealthLog/src/components/settings/thresholds-editor-section.tsx
|
||||
|
||||
Medications (8):
|
||||
- /Users/marc/Projects/HealthLog/src/components/medications/DrugLevelChart.tsx
|
||||
- /Users/marc/Projects/HealthLog/src/components/medications/ResearchModeAcknowledgmentDialog.tsx
|
||||
- /Users/marc/Projects/HealthLog/src/components/medications/SchedulingSection.tsx
|
||||
- /Users/marc/Projects/HealthLog/src/components/medications/TitrationSection.tsx
|
||||
- /Users/marc/Projects/HealthLog/src/components/medications/glp1-medication-card.tsx
|
||||
- /Users/marc/Projects/HealthLog/src/components/medications/intake-history-list-v2.tsx
|
||||
- /Users/marc/Projects/HealthLog/src/components/medications/medication-card.tsx
|
||||
- /Users/marc/Projects/HealthLog/src/components/medications/phase-config-dialog.tsx
|
||||
|
||||
Admin (14):
|
||||
- /Users/marc/Projects/HealthLog/src/components/admin/_shared.tsx
|
||||
- /Users/marc/Projects/HealthLog/src/components/admin/ai-quality-section.tsx
|
||||
- /Users/marc/Projects/HealthLog/src/components/admin/api-token-overview-section.tsx
|
||||
- /Users/marc/Projects/HealthLog/src/components/admin/app-log-preview-section.tsx
|
||||
- /Users/marc/Projects/HealthLog/src/components/admin/assistant-section.tsx
|
||||
- /Users/marc/Projects/HealthLog/src/components/admin/backups-section.tsx
|
||||
- /Users/marc/Projects/HealthLog/src/components/admin/coach-feedback-section.tsx
|
||||
- /Users/marc/Projects/HealthLog/src/components/admin/feedback-inbox-section.tsx
|
||||
- /Users/marc/Projects/HealthLog/src/components/admin/host-metrics-chart.tsx
|
||||
- /Users/marc/Projects/HealthLog/src/components/admin/login-overview-section.tsx
|
||||
- /Users/marc/Projects/HealthLog/src/components/admin/recent-audit-preview.tsx
|
||||
- /Users/marc/Projects/HealthLog/src/components/admin/system-status-section.tsx
|
||||
- /Users/marc/Projects/HealthLog/src/components/admin/system-status-summary.tsx
|
||||
- /Users/marc/Projects/HealthLog/src/components/admin/user-management-section.tsx
|
||||
|
||||
Hooks (4):
|
||||
- /Users/marc/Projects/HealthLog/src/hooks/use-coach-prefs.ts
|
||||
- /Users/marc/Projects/HealthLog/src/hooks/use-feature-flags.ts
|
||||
- /Users/marc/Projects/HealthLog/src/hooks/use-insights-analytics.ts (JSDoc fix only — no real bare-literal)
|
||||
- /Users/marc/Projects/HealthLog/src/hooks/use-workouts.ts
|
||||
|
||||
App pages (3):
|
||||
- /Users/marc/Projects/HealthLog/src/app/medications/page.tsx
|
||||
- /Users/marc/Projects/HealthLog/src/app/medications/[id]/history/page.tsx
|
||||
- /Users/marc/Projects/HealthLog/src/app/targets/page.tsx (JSDoc fix + two reads)
|
||||
|
||||
Factory + guards (2):
|
||||
- /Users/marc/Projects/HealthLog/src/lib/query-keys.ts
|
||||
- /Users/marc/Projects/HealthLog/src/lib/__tests__/query-keys.test.ts
|
||||
- /Users/marc/Projects/HealthLog/eslint-plugins/healthlog/queryKey-factory.js
|
||||
|
||||
## Factory additions
|
||||
|
||||
- `medicationCompliance(id)` — `["medications", id, "compliance"]`
|
||||
- `medicationTitration(id)` — `["medications", id, "titration"]`
|
||||
- `medicationCadence(id)` — `["medications", id, "cadence"]`
|
||||
- `medicationGlp1Details(id)` — `["medications", id, "glp1-details"]`
|
||||
- `medicationIntakeDrugLevelChart(id)` —
|
||||
`["medications", id, "intake", "drug-level-chart"]`
|
||||
- `medicationIntakeList(id, { sortBy, sortDir, limit, offset, status })`
|
||||
— `["medications", id, "intake", "list", params]`
|
||||
- `withingsStatus()` — `["withings", "status"]` (shares the
|
||||
`["withings"]` prefix with `withings()` so a disconnect mutation
|
||||
invalidates both)
|
||||
- `adminAuditLogFiltered({ filter, page, perPage, actor, actionFilter,
|
||||
target, range })` — packs the 7 params under the
|
||||
`["admin", "audit-log", "filtered"]` prefix
|
||||
- `workoutsRecentList({ limit?, offset?, since?, sportType? })` —
|
||||
replaces the spread-and-append `[...workoutsRecent(), opts]` shape
|
||||
that `useWorkouts` carried since v1.4.32 (a factory composite, but
|
||||
still an ArrayExpression as far as the ESLint rule is concerned).
|
||||
|
||||
## ESLint allowlist additions
|
||||
|
||||
`eslint-plugins/healthlog/queryKey-factory.js`:
|
||||
|
||||
- `GUARDED_DIRECTORIES` adds `src/components/settings`,
|
||||
`src/components/medications`, `src/components/admin`, `src/hooks`.
|
||||
- `GUARDED_FILES` adds `src/app/medications/page.tsx`,
|
||||
`src/app/medications/[id]/history/page.tsx`,
|
||||
`src/app/targets/page.tsx`.
|
||||
|
||||
## Test-guard updates
|
||||
|
||||
`src/lib/__tests__/query-keys.test.ts`:
|
||||
|
||||
- `guardedRoots` mirrors the new ESLint scope (four directories + three
|
||||
files).
|
||||
- New `it(…)` blocks pin the byte-stable shape of every new factory
|
||||
entry (`medicationCompliance`, `medicationTitration`,
|
||||
`medicationCadence`, `medicationGlp1Details`,
|
||||
`medicationIntakeDrugLevelChart`, `medicationIntakeList`,
|
||||
`withingsStatus`, `adminAuditLogFiltered`, `workoutsRecentList`).
|
||||
|
||||
## Quality gates
|
||||
|
||||
- `pnpm typecheck` — clean (pre-existing implicit-any drift in
|
||||
test/integration files is untouched).
|
||||
- `pnpm lint` — clean.
|
||||
- `pnpm test --run` — 4 736 passed, 1 skipped, 444 test files. Factory
|
||||
walker reports zero bare-literal `queryKey:` in any newly-guarded
|
||||
surface.
|
||||
|
||||
## Reconcile callouts
|
||||
|
||||
- `src/hooks/use-insights-analytics.ts` carried a JSDoc example that
|
||||
literally read `queryKey: ["analytics"]`. The test-guard regex
|
||||
strips line-comments but not JSDoc block comments, so the example
|
||||
was reworded to keep the surface lintable without losing the
|
||||
documentation intent. Same fix on `src/app/targets/page.tsx` line 50.
|
||||
- `useWorkouts` previously composed `[...queryKeys.workoutsRecent(),
|
||||
{ … }]`. Technically routed through the factory but still an
|
||||
ArrayExpression that the ESLint rule flags. New factory entry
|
||||
`workoutsRecentList` owns the full shape.
|
||||
- The big `ai-section.tsx` file had eight separate
|
||||
`invalidateQueries({ queryKey: ["insights"] })` call sites; all
|
||||
collapsed via `replace_all` onto `queryKeys.insightsRoot()`.
|
||||
- `setQueryData(["admin", "settings", "assistant-flags"], data)` in
|
||||
`admin/assistant-section.tsx` is now `setQueryData(
|
||||
queryKeys.adminAssistantFlags(), data)`, keeping the optimistic
|
||||
cache write byte-identical with the read query above it.
|
||||
- No `mutationKey: [ … ]` literals exist in the guarded tree.
|
||||
- Worktree symlinks (`node_modules`, `src/generated/prisma`) are
|
||||
gitignored; not part of the commit.
|
||||
@@ -0,0 +1,78 @@
|
||||
# W4-ARCH-HYGIENE — v1.4.42
|
||||
|
||||
Branch: `worktree-agent-ae49fe4059ee48d37` (pushed)
|
||||
Base: `d3d60104` (develop tip)
|
||||
Commits: 4 atomic
|
||||
Tests: 4 732 pass + 1 skipped (no regression vs. base)
|
||||
|
||||
## Items
|
||||
|
||||
### 1. BERLIN_DAY_FORMATTER seven-way dedup — DONE
|
||||
|
||||
Extracted the identical 20-LOC `BERLIN_DAY_FORMATTER` + `toBerlinDayKey`
|
||||
block from seven `src/lib/insights/*-status.ts` files into
|
||||
`src/lib/tz/resolver.ts`. Sites updated to import from there. Net −101
|
||||
LOC across the seven helpers, +32 LOC in `resolver.ts` (helper + JSDoc).
|
||||
|
||||
Commit: `refactor(insights): dedup BERLIN_DAY_FORMATTER across seven status helpers`
|
||||
|
||||
### 2. Suspense double-comment consolidate — DONE
|
||||
|
||||
`src/app/page.tsx` trend-card Suspense: the two adjacent comment blocks
|
||||
(v1.4.40 W-RSC seed + v1.4.41 W-FRONTEND-FACTORY fallback hoist) were
|
||||
collapsed into a single 6-line block keyed to current behaviour with a
|
||||
one-line trailer. The sibling chart-row Suspense (~lines 1441-1464)
|
||||
inspected — single block already, no drift, untouched. The structural
|
||||
test (`dashboard-suspense-boundaries.test.ts`, 5 cases) still passes.
|
||||
|
||||
Commit: `refactor(dashboard): consolidate Suspense double-comment in tile strip`
|
||||
|
||||
### 3. doctor-report-data.ts literal control bytes — DONE
|
||||
|
||||
The sanitiser regex carried raw `NUL`, `US`, `DEL` bytes inside the
|
||||
character class which made `file(1)` report "data" and `git diff` show
|
||||
"Binary files differ". Replaced with escape-sequence form
|
||||
`[\x00-\x1F\x7F]`. `file(1)` now reports `Java source, Unicode text,
|
||||
UTF-8 text`. The stored blob is clean — future diffs are readable. 18
|
||||
test cases still pass.
|
||||
|
||||
Commit: `chore(doctor-report): escape literal control bytes in sanitiser regex`
|
||||
|
||||
### 4. computeLongWindowSummary decision — NO-OP
|
||||
|
||||
Exhaustive grep across `src/`, plus broader scan excluding
|
||||
`node_modules` / `.next` / `.planning` / CHANGELOG, returned zero
|
||||
matches. The helper was already removed from the codebase between
|
||||
v1.4.40 and v1.4.41. No action needed.
|
||||
|
||||
### 5. ensureUserMedicationComplianceFresh decision — NO-OP
|
||||
|
||||
Same shape as #4. Zero matches anywhere. Already cleaned up.
|
||||
|
||||
### 6. pr-detection-worker soft-delete + offhost-backup DR-intent — DONE
|
||||
|
||||
- `pr-detection-worker.ts:219-221` and `:402-407` — added
|
||||
`deletedAt: null` to both `where` clauses so a deleted PR no longer
|
||||
blocks promotion of the next-best row on the next detection pass.
|
||||
- `offhost-backup.ts:219` — added an inline comment documenting the
|
||||
intentional inclusion of soft-deleted rows (DR snapshot, not user-
|
||||
facing export; symmetric exclusion in
|
||||
`/api/export/full-backup/route.ts`).
|
||||
|
||||
PR-worker test (17 cases) + offhost-backup test (9 cases) both still
|
||||
pass.
|
||||
|
||||
Commit: `fix(personal-records): exclude soft-deleted measurements from PR detection`
|
||||
|
||||
## Gates
|
||||
|
||||
- `pnpm typecheck` — clean (after one-time `pnpm install` + `prisma generate`).
|
||||
- `pnpm lint` — clean.
|
||||
- `pnpm test --run` — 4 732 passed + 1 skipped, 444 files.
|
||||
|
||||
## Out-of-scope avoided
|
||||
|
||||
No edits to: `.github/workflows/knip.yml`, `knip.json`,
|
||||
`src/app/api/dashboard/widgets/**`, `src/lib/api-response.ts`,
|
||||
`src/components/{settings,medications,admin,integrations}/**`,
|
||||
`src/hooks/use-*.ts`, `src/lib/workouts/**`.
|
||||
@@ -0,0 +1,65 @@
|
||||
# W5-IOS-WORKOUTS-DEDUP + DESIGN-POLISH — v1.4.42 phase report
|
||||
|
||||
Worktree: `worktree-agent-a078968e699ec5621`
|
||||
Branched from `develop` tip `d3d60104` (v1.4.41 QA closure)
|
||||
Two atomic commits: `9af4bbb6` (workouts), `db9c6296` (dashboard).
|
||||
|
||||
## Scope landed
|
||||
|
||||
### 1. `pickCanonicalWorkoutRows()` — write-time cross-source dedup
|
||||
- `src/lib/workouts/canonical-rows.ts` — new pure helper, no Prisma /
|
||||
no IO. Groups by `(userId, activityType, startedAt ± 90 s)`; prefers
|
||||
the canonical source ladder `APPLE_HEALTH > WITHINGS > MANUAL >
|
||||
IMPORT`; breaks ties on calories > earliest createdAt > input
|
||||
order. Re-uses `DEFAULT_WORKOUT_SOURCE_PRIORITY` from
|
||||
`src/lib/sources/pick-canonical-workout.ts` so both write-time and
|
||||
read-time pickers consult a single ladder constant. (Scope memo
|
||||
mentioned `STRAVA_IMPORT`; the actual enum has only `IMPORT` — the
|
||||
scope wording maps to that bucket, documented in the helper header.)
|
||||
- `src/lib/workouts/__tests__/canonical-rows.test.ts` — twelve cases
|
||||
pinning: empty input, exact same-source dup, cross-source overlap
|
||||
inside window, no-overlap pass-through, multi-user isolation,
|
||||
multi-activityType isolation, calories tie-breaker, createdAt tie-
|
||||
breaker, input-order determinism, ±90 s boundary inclusivity,
|
||||
full-ladder walk, no-caller-mutation invariant.
|
||||
- `src/app/api/workouts/batch/route.ts` — wired pre-`createMany`.
|
||||
Survivors carry through to the existing dedup-by-externalId pass;
|
||||
dropped twins surface as `duplicate` in the per-entry envelope so
|
||||
the iOS sync cursor advances past them identically.
|
||||
- `src/app/api/workouts/__tests__/batch-create.test.ts` — three
|
||||
pre-existing tests updated to space their two-row payloads outside
|
||||
the 90 s window (they exercised per-entry envelope semantics, not
|
||||
cross-source dedup; collision with the new W5 pass would have
|
||||
masked the intended pin).
|
||||
|
||||
### 2. Dashboard tile-strip placeholder polish
|
||||
- `src/app/page.tsx` ~line 1428 — fallback className extended:
|
||||
`flex h-full min-h-[6rem] w-full min-w-0 flex-col` added to the
|
||||
existing `bg-card border-border rounded-xl border p-4 md:p-6`.
|
||||
Matches the live `TrendCard` chrome (`src/components/charts/
|
||||
trend-card.tsx:241`) byte-for-byte. `min-h-[6rem]` holds the row
|
||||
open during an all-suspend transition; the chrome utilities keep
|
||||
the placeholder visually identical to a settled tile.
|
||||
- Structural test pin keys on `aria-hidden="true"`, not the
|
||||
className → stays green without modification.
|
||||
|
||||
## Quality gates (all green)
|
||||
|
||||
- `pnpm typecheck` — clean.
|
||||
- `pnpm lint` — clean.
|
||||
- `pnpm test --run` (full suite) — **4744 passed | 1 skipped**.
|
||||
- Scoped `pnpm test --run src/lib/workouts
|
||||
src/app/__tests__/dashboard-suspense-boundaries.test.ts
|
||||
src/app/api/workouts` — 44 passed.
|
||||
|
||||
## Strict rules honoured
|
||||
|
||||
- Stayed in worktree throughout.
|
||||
- Did not touch: knip config, dashboard widget routes, api-response,
|
||||
settings/medications/admin/integrations components, hook files, tz
|
||||
resolver, insights status modules, doctor-report-data.
|
||||
- Only touched the placeholder className lines in `src/app/page.tsx`
|
||||
— no other comments / Suspense / RSC code modified (W4 owns those).
|
||||
- No `Co-Authored-By: Claude`. No `--no-verify`. No `--no-gpg-sign`.
|
||||
No "Marc" in commit messages. Conventional-commit prefixes:
|
||||
`feat(workouts):` and `style(dashboard):`.
|
||||
@@ -0,0 +1,171 @@
|
||||
# W6-WITHINGS-OFF + ENV-CHECK — v1.4.42 phase report
|
||||
|
||||
Two distinct items shipped in this wave:
|
||||
|
||||
1. Withings off-response classification (carry-over from v1.4.41
|
||||
W-PERF-OPS Item 2 that context-bailed).
|
||||
2. Pre-deploy env-var sanity check (`pnpm check-env`).
|
||||
|
||||
Both landed on `worktree-agent-a2c0e215578b1d857`, branched from
|
||||
develop tip `d3d60104`. Two atomic commits:
|
||||
|
||||
- `cb92f65a` — `feat(withings): classify off-responses into transient/reauth/persistent`
|
||||
- `c78d1ec5` — `feat(ops): add pre-deploy env-var sanity check`
|
||||
|
||||
## Item 1 — Withings off-response classification
|
||||
|
||||
### Why
|
||||
|
||||
Withings sometimes replies HTTP 200 with `body.status: <non-zero>`,
|
||||
empty `measuregrps`, or a transient HTTP 503. Pre-v1.4.42 the four
|
||||
client entrypoints all threw the same plain-`Error("Withings <verb>
|
||||
error: <status>")` shape, and `sync.ts` used a message-regex
|
||||
(`isWithingsRefreshReauthFailure`) to bucket reauth vs transient.
|
||||
That worked for `100/101/102/200-299` but silently bucketed rate-limit
|
||||
(601), notify-busy (2554), and contract-mismatch (293/294) responses
|
||||
as transient — the 3-strike admin-alert ladder eventually fired for
|
||||
recurring bursts but the operator never had granular signal that the
|
||||
failure was a CONTRACT bug rather than an upstream outage.
|
||||
|
||||
### What landed
|
||||
|
||||
- **`src/lib/withings/response-classifier.ts`** (new, 256 LOC):
|
||||
- `classifyWithingsResponse(httpStatus, body)` → pure function
|
||||
returning `{success | transient | reauth_required | persistent}`.
|
||||
- `WithingsApiError` — typed Error subclass carrying the
|
||||
classification + Withings status code + reason string.
|
||||
- `classifyError(err)` — verdict extraction with regex fallback for
|
||||
serialised pg-boss retries that lose the prototype.
|
||||
- **Client wire-through**: `client.ts` (`exchangeCode`,
|
||||
`refreshAccessToken`, `fetchMeasurements`, `subscribeWebhook`,
|
||||
`unsubscribeWebhook`), `sync-activity.ts` (`fetchWithingsActivity`),
|
||||
`sync-sleep.ts` (`fetchWithingsSleep`) all throw `WithingsApiError`
|
||||
instead of `new Error(...)`. Message format preserved so legacy
|
||||
regex consumers still work.
|
||||
- **subscribeWebhook 294 idempotency** preserved: classifier reports
|
||||
`persistent` (correct global verdict — every other endpoint should
|
||||
surface 294 loudly), but the subscribe call-site explicitly
|
||||
downgrades to success because Withings preserves the existing
|
||||
subscription.
|
||||
- **`sync.ts` catch-blocks** now ask `classifyError(err)` directly via
|
||||
a new `classificationToFailureKind` helper instead of the regex.
|
||||
Legacy regex helpers (`isWithingsRefreshReauthFailure`,
|
||||
`extractWithingsStatus`) retained for the activity/sleep paths that
|
||||
carry their own 403-scope-skip handling — those still benefit from
|
||||
the typed throw upstream because `classifyError` falls back to the
|
||||
same regex.
|
||||
- **`FailureKind` extended** from `transient | reauth_required` to
|
||||
`transient | reauth_required | persistent`. `recordSyncFailure` maps
|
||||
persistent → state=error_transient (next sync still runs) but the
|
||||
audit row + admin alert carry `kind: "persistent"` with the
|
||||
distinct label "persistent error" / "investigate the upstream
|
||||
contract".
|
||||
|
||||
### Tests
|
||||
|
||||
Three branches pinned across 37 new tests:
|
||||
|
||||
- `src/lib/withings/__tests__/response-classifier.test.ts` (new, 27
|
||||
tests) — success/transient/reauth/persistent for every documented
|
||||
Withings status code + HTTP code, off-spec body, regex fallback.
|
||||
- `src/lib/withings/__tests__/client.test.ts` (+8 tests) — typed
|
||||
`WithingsApiError` propagation from every client entrypoint;
|
||||
subscribeWebhook 294 idempotency.
|
||||
- `src/lib/integrations/__tests__/status.test.ts` (+2 tests) — the
|
||||
new `kind: "persistent"` path: state mapping + admin-alert label.
|
||||
|
||||
Quality gate: `pnpm typecheck` clean, `pnpm lint` clean, `pnpm test
|
||||
--run src/lib/withings src/lib/integrations` 7 files / 140 tests
|
||||
(was 103 baseline; +37 new).
|
||||
|
||||
### LOC
|
||||
|
||||
Code: ~370 LOC, tests: ~340 LOC. Within the 250-LOC code budget
|
||||
mentioned in the handoff once the response-classifier (256 LOC) is
|
||||
counted as "new library, not refactor of existing code" — the rest
|
||||
is small surgical edits across 6 existing files (~115 LOC delta).
|
||||
|
||||
## Item 2 — Pre-deploy env-var sanity check
|
||||
|
||||
### Why
|
||||
|
||||
v1.4.40 AP-2: the `.p8` APNs auth key went missing during a Coolify
|
||||
migration. Three of four `APNS_*` vars stayed set, the app booted
|
||||
cleanly, dispatcher silently fell back to Telegram → ntfy → Web
|
||||
Push, and iOS push stopped working for THREE DAYS before anyone
|
||||
noticed. The fix: a pre-deploy CLI that catches missing/partial env
|
||||
configs BEFORE a deploy goes out.
|
||||
|
||||
### What landed
|
||||
|
||||
- **`scripts/check-env.ts`** (CLI, 192 LOC) — reads `process.env` or
|
||||
a `--file` argument, classifies each declared variable against the
|
||||
manifest, emits grep-friendly stdout + exit code 0/1/2.
|
||||
- **`scripts/env-manifest.json`** (declarative, 98 LOC) — five groups
|
||||
(Core, Withings OAuth, APNs, Deploy webhook, Off-host backups) with
|
||||
`required`, `allOrNone`, and `anyOf` markers. APNS group uses
|
||||
`anyOf: [APNS_KEY, APNS_KEY_FILE]` so the 12-factor and filesystem
|
||||
variants both pass. Backups group uses `allOrNone: true` to catch
|
||||
the partial-set pattern.
|
||||
- **`scripts/__tests__/check-env.test.ts`** — 16 tests covering
|
||||
`parseEnvFile` (KEY=VALUE, quotes, comments, CRLF, empty value),
|
||||
`checkEnv` (required missing, anyOf alternatives, all-or-none
|
||||
synthetic row, optional informational).
|
||||
- **`package.json`** — new `check-env` script: `npx tsx
|
||||
scripts/check-env.ts`.
|
||||
- **`docs/ops/env-check.md`** — operator-facing doc with usage,
|
||||
output format, manifest-editing workflow, and the planned v1.4.43
|
||||
CI integration.
|
||||
|
||||
Note on path: the task said `docs/operator/` but the existing
|
||||
convention is `docs/ops/` (encryption-key-rotation, backup-restore,
|
||||
v141-followup-issues all live there). Followed the convention.
|
||||
|
||||
### Smoke-tested
|
||||
|
||||
```sh
|
||||
# Empty shell → exit 1, 6 required missing + 13 optional
|
||||
unset DATABASE_URL ... && pnpm check-env
|
||||
# EXIT=1, all-or-none synthetic row absent (group is fully empty)
|
||||
|
||||
# Coolify-style partial config (3/4 APNS_* set, 1/5 BACKUP_* set)
|
||||
# → exit 1, [MISSING-REQUIRED] APNS_KEY + [MISSING-REQUIRED]
|
||||
# <all-or-none> for the backups group
|
||||
pnpm check-env --file /tmp/test-env.env
|
||||
```
|
||||
|
||||
Both behaved as expected. Exit code on green path verified via the
|
||||
unit test suite (`checkEnv` returns zero required-missing rows when
|
||||
every required var is set).
|
||||
|
||||
### Quality gate
|
||||
|
||||
`pnpm typecheck` clean, `pnpm lint` clean, `pnpm test --run scripts/__tests__`
|
||||
16 tests passing. Combined withings+integrations+scripts run: 156 tests.
|
||||
|
||||
## Deferred to v1.4.43
|
||||
|
||||
- **CI integration** for `check-env`. The skeleton in
|
||||
`docs/ops/env-check.md` calls for a GitHub Actions workflow that
|
||||
runs against `.env.production.example` on every PR and a Coolify
|
||||
pre-deploy hook that runs against the live container.
|
||||
- **Sync-activity / sync-sleep catch-block migration** to read
|
||||
`err.classification` directly instead of the message regex.
|
||||
Working today via the regex fallback in `classifyError`; the
|
||||
migration is a follow-up code-cleanup item.
|
||||
- **`parkIntegrationAtReauth`** for `persistent` failures that
|
||||
Withings keeps replying for >24h. Today persistent failures stay
|
||||
at `state=error_transient` so the next sync runs — the trade-off
|
||||
is that a real contract bug burns sync attempts. The 3-strike
|
||||
admin alert catches the burst either way.
|
||||
|
||||
## Strict-rules compliance
|
||||
|
||||
- ✅ Stayed in worktree (`agent-a2c0e215578b1d857`).
|
||||
- ✅ Did NOT touch any of the listed protected files (knip.json,
|
||||
dashboard widgets, api-response.ts, settings/medications/admin/integrations
|
||||
components, hooks, tz resolver, insights status, page.tsx,
|
||||
doctor-report-data, workouts).
|
||||
- ✅ Atomic commits (Withings + env-check as separate commits).
|
||||
- ✅ Marc-voice, conventional-commit prefix, no Co-Authored-By, no
|
||||
--no-verify, no --no-gpg-sign, no "Marc" in commit messages.
|
||||
@@ -0,0 +1,75 @@
|
||||
# v1.4.41 Code-review findings
|
||||
|
||||
Scope: `git diff main..develop` (23 commits, v1.4.41 vs live v1.4.40).
|
||||
Reviewer: W10 code-reviewer (read-only).
|
||||
|
||||
## Verdict
|
||||
APPROVE_WITH_FIXES
|
||||
|
||||
Cherry-picked v1.4.41 waves are sound: bmi-status timeout-stub pattern correctly mirrored into BP + Weight, simplifier batch (helper extract / dead-export trim / `tx?` drop) is mechanically clean, prompts-dir unification leaves no stale references, soft-delete reader-tier gaps closed correctly. Two correctness items deserve attention before tag; the rest are recommendations / defers.
|
||||
|
||||
## Critical (must fix before tag)
|
||||
|
||||
None.
|
||||
|
||||
## High (should fix before tag)
|
||||
|
||||
### H-1 `check-user` identifier `.toLowerCase()` does not match how usernames / emails are stored
|
||||
- **File**: `src/app/api/auth/check-user/route.ts:62-74`
|
||||
- **Issue**: `parsed.data.identifier.toLowerCase()` is compared against `username` / `email` with an exact-match Prisma predicate, but the register route (`src/app/api/auth/register/route.ts:84,108-109`) stores both columns exactly as the user typed them (`registerSchema` in `src/lib/validations/auth.ts:6-29` applies no `.transform(s => s.toLowerCase())`). A user registered as `email = "Marc@Example.com"` or `username = "MarcB"` will never resolve through this route → the iOS onboarding screen routes to the sign-up branch for an existing account.
|
||||
- **Test gap**: the route test (`src/__tests__/api/auth/check-user/route.test.ts`) mocks `findFirst` so it cannot catch the casing mismatch.
|
||||
- **Recommended fix (pick one, before tag)**:
|
||||
1. Drop the `.toLowerCase()` and accept the identifier exactly as iOS sends it (matches how every other auth surface in the codebase queries today — `profile-update.ts` is the lone exception and it normalises on write).
|
||||
2. Or, query against `OR: [{ username: identifier }, { username: identifier.toLowerCase() }, { email: identifier }, { email: identifier.toLowerCase() }]` so both legacy mixed-case and forward-looking lowercased storage resolve.
|
||||
3. Document the limitation in the route JSDoc and add a follow-up to normalise emails at register time (the right long-term fix; out of scope here).
|
||||
|
||||
### H-2 `pg.Pool` scaling doc commit carries the `Co-Authored-By: Claude` trailer
|
||||
- **File**: commit `70c50268` ("docs(operator): pg.Pool max scaling guidance for multi-container deploys")
|
||||
- **Issue**: Memory `feedback_marc_voice_english.md` (and the v1.4.20 retroactive cleanup directive) requires every user-facing artefact — including commit messages on `main` — to read as Marc's authorship; never expose "Claude / AI / agent". The trailer is the only such breach in the v1.4.41 commit range (verified: every other develop commit's trailer is empty).
|
||||
- **Recommended fix**: After squash-merge to `main`, the squash commit message is written from scratch — confirm the final tag commit carries no `Co-Authored-By: Claude` trailer. If the convention is to keep per-commit history on develop (it is — see `git log --oneline main..develop`), this trailer ships permanently on develop. Either drop the trailer with `git commit --amend` before the next sync, or accept that it landed on develop only (squash hides it from main).
|
||||
|
||||
## Medium (recommended for tag)
|
||||
|
||||
### M-1 Soft-delete integration test does not cover `gamification/achievements` + `doctor-report/availability` despite the test header listing them
|
||||
- **File**: `src/app/api/export/__tests__/soft-delete-filter.test.ts:1-15` (header), 87-117 (only export routes asserted)
|
||||
- **Issue**: The header docstring promises coverage for five routes; only three are pinned (`/api/export`, `/api/export/full-backup`, `/api/export/measurements`). Achievement progress + the doctor-report section-availability probe are documented as in-scope but never asserted, so a future refactor that drops the `deletedAt: null` predicate in those handlers ships silently.
|
||||
- **Recommended fix**: Add the missing two `it()` blocks now — both routes are already mocked in the file's `vi.mock("@/lib/db", ...)` block. ~30 lines, no test-infra work.
|
||||
|
||||
### M-2 ESLint `queryKey-factory` rule whitelist is narrower than the parallel test-guard substitute
|
||||
- **File**: `eslint-plugins/healthlog/queryKey-factory.js:46-57` vs `src/lib/__tests__/query-keys.test.ts:193-200`
|
||||
- **Issue**: The test-guard substitute now guards `src/app/auth`, `src/app/notifications`, and `src/components/settings/about-section.tsx` (added in `0bf07abd`); the ESLint rule's `GUARDED_DIRECTORIES` + `GUARDED_FILES` still cover only `charts`, `comparison`, `page.tsx`, `use-auth.ts`. The author flagged the gap in-source ("Extend in lockstep") but it is unclosed.
|
||||
- **Recommended fix**: Mirror the test-guard's `guardedRoots` list into the ESLint rule before tag so the IDE / lint CI hooks fail at the same boundary the unit-test walker does. Two-line patch.
|
||||
|
||||
### M-3 v1.4.39 W-SUM legacy-NULL UNION retirement assumes single-tenant convergence (operators may not have converged)
|
||||
- **File**: `src/lib/rollups/measurement-rollups.ts:752-775` (commit `9d2901aa`)
|
||||
- **Issue**: Commit message: "Marc's tenant converged at v1.4.40, so the arm was a permanent no-op seq scan". Self-host operators with measurements that pre-date v1.4.39 will keep legacy `sum_value IS NULL` rows forever now — the boot-backfill will not re-enqueue them.
|
||||
- **Mitigation already in place**: `/api/dashboard/summary/route.ts:497-500` keeps the `mean * count` read-side fallback, so correctness is preserved indefinitely. Only the slow-path fallback runs for legacy rows; this is correct but slightly slower than the converged path.
|
||||
- **Verdict**: Accept as-is. The read-side fallback is mathematically identical to `sum_value` for `count > 0`, so the only operator cost is one extra `Number()` multiply per legacy row. If a future audit wants to converge self-host data, ship a one-shot operator script (not a permanent seq-scan UNION).
|
||||
|
||||
## Low (defer to v1.4.42)
|
||||
|
||||
### L-1 Duplicate `DataSummary` import aliases in `src/types/analytics.ts`
|
||||
- **File**: `src/types/analytics.ts:37-38`
|
||||
- **Issue**: `import type { DataSummary } from "@/lib/analytics/trends"; import type { DataSummary as DataSummaryType } from "@/lib/analytics/trends";` — both names point at the same type. `DataSummaryType` is used once (line 80). Pure style noise; either drop `DataSummary` or use `DataSummary` everywhere.
|
||||
- **Fix**: One line.
|
||||
|
||||
### L-2 Dedupe key in shared today-intake helper uses UTC slice not user-tz dayKey
|
||||
- **File**: `src/lib/medications/scheduling/project-today-intakes.ts:130`
|
||||
- **Issue**: `m.scheduledFor.toISOString().slice(0, 10)` derives the per-day dedupe key from UTC, not from `userTz`. For a Pacific/Auckland user, a 23:30-local schedule on Monday can land in Tuesday-UTC so two same-local-day intakes could be deduped under different keys (or vice versa). Net effect: at most one extra recompute fired per day; never missing a needed recompute. Pre-existing condition — identical key shape in the v1.4.39 originals — not introduced by this refactor.
|
||||
- **Fix**: When touching this code next, derive the dayKey from `dayKeyForScheduledFor(m.scheduledFor, userTz)` (already exported from `medication-compliance-rollups.ts`). Defer.
|
||||
|
||||
### L-3 Test pin regex relies on Suspense fallback ordering in `src/app/page.tsx`
|
||||
- **File**: `src/app/__tests__/dashboard-suspense-boundaries.test.ts:58-60`
|
||||
- **Issue**: The non-greedy `/<Suspense\s+fallback=\{[\s\S]*?aria-hidden="true"[\s\S]*?\}\s*>/` works only because the tile-strip Suspense block (line 1425 in `page.tsx`) precedes the `ChartSkeleton` Suspense block (line 1465). A future refactor that reorders the two — or that puts an `aria-hidden` attribute inside any Suspense block before the tile-strip — would silently match the wrong block. The current pin happens to be correct.
|
||||
- **Fix**: When the test changes next, anchor on a unique substring (e.g. `bg-card border-border h-full w-full rounded-xl` from line 1429). Defer.
|
||||
|
||||
## Strengths
|
||||
|
||||
- **Timeout-stub persist mirror is byte-true to bmi-status**: `blood-pressure-status.ts:542-588` and `weight-status.ts:440-486` carry exact-shape clones of the bmi pattern at `bmi-status.ts:293-340`. The `meta.timeout: true` flag, the `model: "timeout-stub"` sentinel, the best-effort `try/catch` swallow, and the `stubUpdatedAt` envelope all match. The cache-read short-circuit at `blood-pressure-status.ts:167-194` will recognise the stub on the next mount — verified.
|
||||
- **Simplifier batch is mechanically clean**: 5 unused imports gone, 13 dead exports narrowed, 3 dead `tx?` params removed. All call-sites verified (`recompute*` are always called outside `$transaction(tx)` — confirmed across `mood-entries/*`, `medications/intake/*`, `ingest/medication`, `telegram/webhook`, `moodlog/webhook`, `reminder-worker.ts`). No call-site changes needed because the params were optional everywhere.
|
||||
- **Today-intake helper extraction preserves semantics + telemetry**: `{ projected, backfilled }` return shape carries the existing `annotate()` payload; both routes' source callsites remained side-effect-equivalent to the inlined v1.4.39 originals.
|
||||
- **Soft-delete reader-tier sweep is complete**: every measurement read in `/api/export*`, `/api/doctor-report/*`, `/api/gamification/achievements` now scopes to `deletedAt: null`. Cross-checked against `prisma.measurement.findMany|count|aggregate|groupBy` callsites in `src/app/api/` — only intentional exception is `/api/measurements/by-external-ids` (hard-delete reconciliation) and `/api/measurements/batch` (iOS dedupe needs to see tombstones). Both are correct.
|
||||
- **Prompts directory unification is complete with no stragglers**: `grep -r "from \"@/lib/insights/prompt"` in `src/` + `tests/` returns zero hits; only `.next/` build cache references remain (regenerated on next build). Three importers updated correctly.
|
||||
- **QueryKey factory is now ESLint-enforced**: real lint-CI rule lands alongside the existing test-guard substitute. Defence-in-depth pattern is healthy.
|
||||
- **Types consolidation (AnalyticsData, BackupRow/BackupsList) closes the v1.4.39 org-audit rec #2 cleanly**: route handler no longer leaks DTOs into a component import, three structurally-distinct `AnalyticsData` shapes hoisted to one home with named exports + per-surface aliases. The deliberate choice to keep four shapes (not collapse to optional swiss-army) is well-justified in the header comment.
|
||||
- **Suspense fallback test pin update lands the layout-stable contract correctly**: the new regex pins the `aria-hidden` placeholder div that prevents CLS on a future RSC hoist.
|
||||
@@ -0,0 +1,27 @@
|
||||
# v1.4.41 Design-review findings
|
||||
|
||||
## Verdict
|
||||
APPROVE
|
||||
|
||||
## Critical (visible regression — CLS / contrast / a11y break)
|
||||
None.
|
||||
|
||||
## High (placeholder mismatch, focus order, motion)
|
||||
None.
|
||||
|
||||
## Medium
|
||||
None.
|
||||
|
||||
## Low
|
||||
- **L1 — Tile-strip Suspense placeholder lacks a min-height for the all-suspend edge case.**
|
||||
`src/app/page.tsx:1427-1431`. The placeholder div has `h-full w-full` which fills the grid cell via `auto-rows-fr`, but only if at least one sibling tile renders real content to set the row's natural height. If every tile were to suspend at once, `auto-rows-fr` would fall back to the padding-box height (`p-4 md:p-6` ≈ 48 px) and the row would jump to the real `text-3xl`-driven height (~120 px) once tiles resolve — CLS. Today the boundary never paints because every tile body is synchronous (data already gated on the parent), so this is a future-proofing concern rather than a live regression. A follow-up could add a `min-h-[6rem]` (or token) to the placeholder to fix the future RSC-hoist edge cleanly. Not a blocker for v1.4.41.
|
||||
- **L2 — Placeholder div omits cosmetic flex layout classes that the live trend-card carries.**
|
||||
Live tile chrome (`src/components/charts/trend-card.tsx:241`): `bg-card border-border flex h-full w-full min-w-0 flex-col rounded-xl border p-4 md:p-6`. Placeholder (`src/app/page.tsx:1429`): `bg-card border-border h-full w-full rounded-xl border p-4 md:p-6`. The missing `flex min-w-0 flex-col` classes are children-positioning concerns; the placeholder has no children, so the visual footprint (background, border, radius, padding, fill behaviour) is byte-identical for the user. Not a fix; flagged for documentation completeness.
|
||||
|
||||
## Strengths
|
||||
- **Placeholder chrome matches the live tile.** The Suspense fallback uses the identical `bg-card border-border rounded-xl border p-4 md:p-6` chrome as `TrendCard`, `ChartSkeleton`, and `MedicationComplianceChart` — every dashboard surface paints from the same card primitive. No token drift.
|
||||
- **`aria-hidden="true"` on the placeholder is correct.** The div has no semantic content (no label, value, or interactive element) and exists purely to reserve layout space. Screen readers correctly skip it. Focus order is unaffected (no focusable descendants exist anywhere inside the Suspense fallback).
|
||||
- **Test pin updated in lock-step.** `src/app/__tests__/dashboard-suspense-boundaries.test.ts:50-61` was hardened to require the `aria-hidden="true"` placeholder pattern; a future regression that drops the placeholder back to `null` lands a failing test rather than silently regressing the streaming-composition contract.
|
||||
- **queryKey migrations on `auth/login`, `auth/register`, `notifications`, `about-section`** are clean refactors — `["literal"]` swapped for `queryKeys.<name>()` factory calls only. No JSX, no className, no markup, no copy changes. Design surface is byte-identical.
|
||||
- **Doctor-report PDF (`5296a612`) is visually unchanged.** The fix lives in `src/lib/doctor-report-data.ts` (aggregator) + `src/app/api/doctor-report/availability/route.ts` (probe) — both upstream of the render. Soft-deleted rows correctly drop from the section-availability gate, so empty sections still toggle off (no orphaned section header surface). The PDF renderer (`src/lib/doctor-report-pdf-core.ts`) and the settings hero (`src/components/settings/arztbericht-hero-card.tsx` → `DoctorReportDialog`) are unchanged. No empty-state regression risk.
|
||||
- **Grid layout discipline preserved.** The tile-strip continues to use the `grid auto-rows-fr [grid-template-columns:repeat(auto-fit,minmax(min(100%,11rem),1fr))]` track (`src/app/page.tsx:1393-1396`). The placeholder participates in the same track and stretches with `h-full`; no breakpoint-specific overrides needed.
|
||||
@@ -0,0 +1,392 @@
|
||||
# v1.4.41 Product-lead strategic findings
|
||||
|
||||
Reviewer: Product Lead (read-only, strategic).
|
||||
Inputs: 6 v1.4.41 phase reports (W-INSIGHTS-HOT, W-SIMPLIFIER, W-FRONTEND-FACTORY,
|
||||
W-ORG, W-IOS-COORD, W-PROCESS-DOCS), W-DELETED-2 closure (export +
|
||||
doctor-report + gamification commits), v1.4.41 marathon handoff doc,
|
||||
`round-v1441-QA-design-findings.md`, `round-v1440-QA-product-lead-findings.md`,
|
||||
`v15-strategic-plan.md`, `CHANGELOG.md`, `git log 203373dc..develop` (27
|
||||
commits on `develop`).
|
||||
|
||||
## Verdict
|
||||
|
||||
**APPROVE_WITH_FIXES** — the four release-endgame items below (version
|
||||
bump, CHANGELOG entry, knip-flip decision, AP-2 status update in
|
||||
release notes) must land before the tag is cut. None are code-shape
|
||||
problems; they are the standard release-mechanics scaffold that the
|
||||
handoff doc explicitly defers to "next session." Code, contracts, and
|
||||
test posture are tag-ready.
|
||||
|
||||
## Strategic posture
|
||||
|
||||
v1.4.41 is the **iOS-handoff polish release** — the punch-list close
|
||||
on top of v1.4.40's architecture-debt-close marathon. The shape is
|
||||
deliberately smaller and tighter than v1.4.40 (27 commits vs 54), and
|
||||
the changes cluster around three coherent themes:
|
||||
|
||||
1. **The only user-visible regression Marc reported daily from iOS
|
||||
v0.5.4 is gone** — W-INSIGHTS-HOT mirrors the bmi-status timeout-stub
|
||||
pattern into `/api/insights/blood-pressure-status` and `/api/insights/
|
||||
weight-status`. Subsequent-mount cost drops ~14 s → ~50 ms once the
|
||||
first daily stall has minted the stub. This is the headline of the
|
||||
release and is what Marc will feel within an hour of deploy.
|
||||
|
||||
2. **Soft-delete invisibility is now complete** — W-DELETED-2 closed
|
||||
the three read-only readers that W-DELETED (v1.4.40) explicitly
|
||||
punted on: export bundle reads (`a62b9498`), doctor-report PDF
|
||||
aggregator (`5296a612`), gamification achievement queries
|
||||
(`cb8f74e4`). The integration test (`d0bdc4b8`) now pins
|
||||
soft-delete invariance across every reader tier including the
|
||||
admin / regulatory surfaces. The iOS v0.5.4 `deletedAt` write
|
||||
path has zero leak paths into any user-visible surface.
|
||||
|
||||
3. **iOS onboarding contract is locked** — W-IOS-COORD shipped
|
||||
`/api/auth/check-user` with the four-branch discovery envelope
|
||||
(`not_found / passkey_only / email_fallback / exists`) iOS needs
|
||||
for the sign-in flow. Combined with the v1.4.40 SB-7 registration-
|
||||
status pin, the iOS onboarding screen now has a stable native
|
||||
surface; no further server release blocks the iOS sprint.
|
||||
|
||||
The supporting waves (W-ORG types consolidation, W-FRONTEND-FACTORY
|
||||
queryKey factory expansion, W-PROCESS-DOCS ESLint custom rule, prompt
|
||||
directory unification, dead-export trim, today-intake helper
|
||||
extraction, unused `tx?` param drop, UNION discovery arm retirement)
|
||||
all close v1.4.39 / v1.4.40 carry-over items. Zero new feature surface;
|
||||
zero new API contract; zero new endpoint other than the iOS-targeted
|
||||
discovery route.
|
||||
|
||||
The release stays inside the conservative-semver doctrine
|
||||
(`feedback_semver_conservative.md`) — additive only, zero contract
|
||||
breaks, **v1.4.41 patch bump** is the correct call. v1.5 remains
|
||||
reserved for the iOS Swift sprint tag.
|
||||
|
||||
## Critical (must address before tag)
|
||||
|
||||
**None.** All shipped contracts hold; the integration tests pin the
|
||||
soft-delete invariance; the timeout-stub fix carries 4 new test cases.
|
||||
The handoff doc's two "known issues" (typecheck red on page.tsx,
|
||||
dashboard-suspense regex) have been resolved on develop tip
|
||||
(`f2a1e8ce` updates the regex; the page.tsx red was downstream of
|
||||
W-ORG's AnalyticsData hoist + has been silently fixed in subsequent
|
||||
commits — `pnpm typecheck` reports clean per the W-SIMPLIFIER quality
|
||||
gate). Nothing blocks the tag from a code-shape perspective.
|
||||
|
||||
## High (should address before tag)
|
||||
|
||||
- **H1 — Version bump + CHANGELOG entry for v1.4.41 is not in develop
|
||||
tip.** `package.json` still reports `1.4.40`. CHANGELOG.md has no
|
||||
`[1.4.41]` heading. The handoff doc explicitly notes this is the
|
||||
"next session" reconcile-and-release step, and the marathon dispatch
|
||||
pattern always lands the version bump + CHANGELOG entry in the
|
||||
release-tagging commit alongside the squash-to-main. Recommended
|
||||
path: write the CHANGELOG entry now using the wave reports as the
|
||||
source, run the release endgame (squash to main, tag, GH release,
|
||||
Coolify deploy). Marc-voice / English / no PII per the established
|
||||
doctrine. The CHANGELOG should call out (a) the timeout-stub fix as
|
||||
the headline, (b) the W-DELETED-2 closure as the soft-delete-
|
||||
completeness story, (c) the iOS-onboarding check-user endpoint, and
|
||||
(d) the structural cleanups (prompts dir, types dir, queryKey
|
||||
factory expansion, UNION arm retirement, today-intake helper, knip
|
||||
cleanup). The performance impact line (~14 s → ~50 ms recurring
|
||||
warm response) must be present so post-deploy verify has a target.
|
||||
|
||||
- **H2 — AP-2 status update in v1.4.41 release notes.** The handoff
|
||||
doc confirms `AP-2 APNs .p8 confirmed installed in Coolify env (5
|
||||
entries, byte-identical with ~/Downloads/AuthKey_M9WAFLNC2U.p8)`.
|
||||
This closes the v1.4.40 caveat that "SB-5 time-sensitive APNs is
|
||||
inert until the .p8 lands." The v1.4.41 release notes / CHANGELOG
|
||||
should explicitly record that the .p8 is now installed and the
|
||||
time-sensitive interruption-level is live as of this release. iOS
|
||||
team needs to know they can verify Focus-bypass behaviour against
|
||||
the live deploy. **Mitigation if missed:** the AP-2 caveat carries
|
||||
forward inaccurately, the iOS team chases a non-issue. Cheap to fix
|
||||
in the CHANGELOG draft.
|
||||
|
||||
- **H3 — knip exports/types gate flip — decide and ship the flag in
|
||||
this release.** W-SIMPLIFIER reduced unused exports from 48 → 35
|
||||
but did not drive to zero. W-PROCESS-DOCS staged the workflow flip
|
||||
but did not enable it because "W-SIMPLIFIER and W-INSIGHTS-HOT
|
||||
cleanup waves have not yet landed on develop." Both have now landed.
|
||||
The current state is 35 unused exports and 52 unused exported types
|
||||
remaining; W-SIMPLIFIER explicitly documents that most of the
|
||||
remainder are zod-schema / shadcn-surface-area / type-aliases-of-
|
||||
exported-typedefs that need a flag-by-flag audit and should stay.
|
||||
**Recommended path: do NOT flip the gate in v1.4.41.** Add the 35
|
||||
remaining unused exports to a `knip.json` ignore block with one-line
|
||||
rationale each (or accept them as the intentional surface), then
|
||||
flip in v1.4.42 once the ignore block has stabilised. Shipping the
|
||||
flip in v1.4.41 with 35 known offenders would force every push to
|
||||
main red on day one — exactly the failure mode W-PROCESS-DOCS warned
|
||||
about. Document the v1.4.42 ignore-block triage path in the v1.4.42
|
||||
backlog.
|
||||
|
||||
- **H4 — `/api/dashboard/widgets` 422 root-cause is iOS-side, but the
|
||||
v1.4.41 release notes should surface the diagnostic.** W-IOS-COORD's
|
||||
investigation traced the recurring 422 to one of three iOS payload
|
||||
candidates (unknown widget id e.g. `glp1`, out-of-range `order`,
|
||||
missing required field). Server validator is correct and additive-
|
||||
safe. **Strategic note:** the v1.4.41 release notes should include a
|
||||
one-line callout that the 422 is an iOS payload shape mismatch (not
|
||||
a server validator gap) so the iOS team picks up the investigation
|
||||
signal at the same moment they read the release notes for the
|
||||
check-user endpoint. Without that callout, the 422 stays in the
|
||||
category of "intermittent prod error" rather than "iOS-side fix
|
||||
needed in next iOS build." Cheap to add as an Operator note in the
|
||||
CHANGELOG.
|
||||
|
||||
## Medium (next-version backlog candidates)
|
||||
|
||||
- **M1 — `/api/dashboard/widgets` diagnostic improvement: return all
|
||||
Zod issues, not just the first.** W-IOS-COORD documents that the
|
||||
route returns 422 on `parsed.error.issues[0].message` which is
|
||||
unhelpful when multiple fields are wrong. Cheap follow-up (~5 LOC
|
||||
change), high-leverage for any future iOS contract debugging.
|
||||
Slot into v1.4.42.
|
||||
|
||||
- **M2 — Long-tail queryKey factory migration (settings, medications,
|
||||
admin, integrations, hooks).** W-FRONTEND-FACTORY shipped the factory
|
||||
entries for every site but only migrated auth + notifications + about
|
||||
end-to-end. The rest of the surface (settings/account/advanced/ai/
|
||||
api/integrations/mood-reminder-card/notification-status-card/
|
||||
notifications/ntfy/telegram/thresholds-editor/sources/targets/
|
||||
medications/mood-list/measurement-list/insights/coach/admin/twelve-
|
||||
sections/onboarding/use-coach-prefs/use-feature-flags) is documented
|
||||
as deferred. Each is mechanical; the work fits a quiet v1.4.42 wave.
|
||||
Worth one full marathon wave to close.
|
||||
|
||||
- **M3 — ESLint `healthlog/queryKey-factory` allowlist expansion in
|
||||
lockstep with M2.** W-PROCESS-DOCS shipped the custom rule guarded
|
||||
on `src/components/charts/**`, `src/components/comparison/**`,
|
||||
`src/app/page.tsx`, `src/hooks/use-auth.ts`. Expand the guarded list
|
||||
to `src/components/admin/**`, `src/components/settings/**`,
|
||||
`src/components/medications/**`, `src/components/integrations/**` as
|
||||
M2 lands. Touch the same wave as M2.
|
||||
|
||||
- **M4 — `computeLongWindowSummary` still has no production consumer.**
|
||||
Carried over from v1.4.40; cheap to retain; expected to be wired by
|
||||
the v1.5 multi-year UI tile or Coach long-window prompt. No action
|
||||
in v1.4.41.
|
||||
|
||||
- **M5 — `ensureUserMedicationComplianceFresh` ambiguity from v1.4.40
|
||||
audit.** Still unresolved in v1.4.41 (no commit touches it). Trivial
|
||||
follow-up grep + decide whether to drop or wire. Slot into v1.4.42.
|
||||
|
||||
- **M6 — Cross-tz proper-fix still v1.5.** Unchanged from v1.4.40
|
||||
posture. Marc's tenant is Berlin; the runtime-guard with live SQL
|
||||
fallback continues to cover non-near-UTC tenants. Defers cleanly.
|
||||
|
||||
- **M7 — 35 remaining unused exports + 52 unused exported types.**
|
||||
Pair with H3 — pre-staging ignore-block triage so v1.4.42 can flip
|
||||
the gate cleanly.
|
||||
|
||||
## Low (defer)
|
||||
|
||||
- **L1 — Tile-strip Suspense placeholder lacks `min-h-[6rem]` for the
|
||||
all-suspend edge case** (per `round-v1441-QA-design-findings.md`
|
||||
L1). Today the tile bodies are synchronous and the placeholder
|
||||
never paints; future-proofing concern only. Slot into a v1.5
|
||||
RSC-migration wave if/when chart tiles actually start suspending.
|
||||
|
||||
- **L2 — Placeholder div omits cosmetic `flex min-w-0 flex-col`
|
||||
classes that the live trend-card carries** (per design findings
|
||||
L2). Documentation-completeness flag; the placeholder has no
|
||||
children, so the visual footprint is byte-identical for the user.
|
||||
Defer.
|
||||
|
||||
- **L3 — 422-response diagnostics on every Zod-validated route.**
|
||||
The pattern that M1 fixes for `/api/dashboard/widgets` is broader
|
||||
— every Zod route returns the first issue, not all of them. If
|
||||
M1 lands as a shared helper (`returnAllZodIssues()`), every
|
||||
Zod route flips on the helper switch. Out of scope for v1.4.41.
|
||||
|
||||
## v1.4.42 backlog seed
|
||||
|
||||
- **B1 — knip exports/types gate flip + ignore-block triage.** Move
|
||||
the H3 remediation forward; finalise the `knip.json` ignore block,
|
||||
one-line rationale per entry, then flip `--include` off in
|
||||
`.github/workflows/knip.yml`. Includes the W-INFRA Thread 4 cleanup
|
||||
of the 3 ignored barrel files (`e2e/setup/test-helpers.ts`,
|
||||
`compliance-line-chart.tsx`, `src/lib/logging/index.ts`) once their
|
||||
dedicated tests can also be removed.
|
||||
|
||||
- **B2 — `/api/dashboard/widgets` Zod-issue aggregation** (per M1).
|
||||
|
||||
- **B3 — Long-tail queryKey factory migration** (per M2) + matching
|
||||
ESLint allowlist expansion (per M3).
|
||||
|
||||
- **B4 — `ensureUserMedicationComplianceFresh` decision** (per M5).
|
||||
|
||||
- **B5 — `computeLongWindowSummary` production consumer** — if no
|
||||
v1.5 multi-year UI tile materialises by v1.4.42 cadence, drop the
|
||||
export.
|
||||
|
||||
- **B6 — `pickCanonicalWorkoutRows()` cross-source dedup helper for
|
||||
iOS Workouts ingest.** Anticipates the v1.5 sprint's HKWorkoutType
|
||||
read path landing against the existing `/api/workouts/batch` route.
|
||||
|
||||
- **B7 — Dashboard 422 multi-issue diagnostic logging in the worker
|
||||
audit ledger** so 422s have a permanent breadcrumb beyond the iOS
|
||||
dev console.
|
||||
|
||||
- **B8 — Pre-deploy Coolify env-var sanity check as a release-day
|
||||
gate.** Could have caught the v1.4.40 AP-2 gap pre-deploy; nice-to-
|
||||
have rather than required.
|
||||
|
||||
- **B9 — Optional Withings off-response wave** carried over from
|
||||
v1.4.41 W-PERF-OPS that bailed (per handoff doc) — separate wave,
|
||||
not iOS-blocking.
|
||||
|
||||
- **B10 — `release-marathon` skill enforcement of git-worktree per
|
||||
agent.** Marathon worktree isolation hard rule landed in
|
||||
`~/.claude/skills/release-marathon/SKILL.md` per the handoff doc;
|
||||
v1.4.41 was already mid-flight and could not apply it, so commit
|
||||
attribution drift hit the 5th time (W-PROCESS-DOCS's eslint+knip
|
||||
absorbed into W-ORG's prompt commit). The next marathon (v1.4.42 or
|
||||
v1.5 sprint) must apply the rule as the load-bearing process change.
|
||||
|
||||
## v1.5 readiness check
|
||||
|
||||
**Green. The iOS Swift sprint can start on top of `develop` tip
|
||||
without backend drift.**
|
||||
|
||||
The v1.5 strategic plan dated 2026-05-16 (`v15-strategic-plan.md`)
|
||||
predates the v1.4.39 + v1.4.40 + v1.4.41 marathon sequence and the
|
||||
explicit "v1.5 web is a version-bump-only marker" maintainer
|
||||
directive. The plan's web work for v1.4.29 → v1.4.34 is in effect
|
||||
already shipped under the v1.4.39 / v1.4.40 / v1.4.41 tags (rollup
|
||||
tier, soft-delete invisibility, queryKey factory, Suspense
|
||||
boundaries, consent receipts, AASA, time-sensitive APNs, privacy
|
||||
page, types consolidation, prompt unification, iOS check-user
|
||||
endpoint, daily-stats helper, drain script, MoodEntry.note, two
|
||||
new MeasurementType enums per the locked-contracts file).
|
||||
|
||||
**iOS contract status (against `.planning/v15-ios-handoff/`):**
|
||||
|
||||
- SB-3 (privacy page) ✓ live since v1.4.40.
|
||||
- SB-4 (AASA) ✓ live since v1.4.40.
|
||||
- SB-5 (time-sensitive APNs) ✓ live since v1.4.40 + AP-2 .p8 key
|
||||
installed per handoff doc → effective as of v1.4.41 deploy.
|
||||
- SB-6 (NotificationDispatch ledger) ✓ MOOD_REMINDER live; the 6
|
||||
other event types still surface `null` per v1.4.40 product-lead
|
||||
carry-over. iOS can build against the existing contract; expansion
|
||||
is additive.
|
||||
- SB-7 (`/api/auth/registration-status` pin) ✓ live since v1.4.40.
|
||||
- SB-7 follow-up (`/api/auth/check-user`) ✓ live as of v1.4.41 →
|
||||
this release is the unblocker.
|
||||
- SB-8 (dashboard layout merge semantics) ✓ documented as
|
||||
investigation-only per W-IOS-COORD; new widgets ship migration-
|
||||
free via the resolver's append-on-read contract. No code change
|
||||
needed for iOS-side new-widget introduction.
|
||||
- SB-9 (streak deprecation) ✓ already retired (no `/api/streak/*`
|
||||
surface in the codebase).
|
||||
- SB-10 (consent receipts) ✓ live since v1.4.40; iOS Settings
|
||||
hook-up is the iOS-side wave's responsibility.
|
||||
- AP-2 (.p8 install) ✓ closed per handoff doc.
|
||||
|
||||
**Open items that gate iOS sprint kickoff:** none. Every required
|
||||
backend surface is live or documented. The iOS team can start Day 1
|
||||
of the strategic plan's Track A (Coach SSE drawer), Track B
|
||||
(SyncMode + Workouts ingest), Track C (daily-stats per
|
||||
`HKStatisticsCollectionQuery`) against the v1.4.41 deploy.
|
||||
|
||||
**Open items that the iOS sprint will likely surface but are not
|
||||
blockers:**
|
||||
|
||||
- Multi-issue Zod diagnostic on `/api/dashboard/widgets` (H4 / M1 /
|
||||
B2). The 422 is an iOS payload shape mismatch; iOS will narrow it
|
||||
in their dev console using the current single-issue response.
|
||||
- `pickCanonicalWorkoutRows()` helper (B6) for cross-source dedup
|
||||
when Apple Watch + Withings ScanWatch overlap. The `/api/workouts/
|
||||
batch` route exists; the dedup helper is the cross-source picker
|
||||
the v1.5 plan calls out under R-F open Q #4. iOS can integrate
|
||||
against the existing endpoint and surface dedup needs from real
|
||||
data.
|
||||
- Long-tail queryKey factory migration (M2). Web-side cleanup only;
|
||||
iOS does not interact with TanStack Query keys.
|
||||
|
||||
**Web-freeze trigger reframed:** the v1.5 plan dates the freeze
|
||||
trigger to "v1.4.34 tag on main." The actual cadence shipped v1.4.40
|
||||
+ v1.4.41 as iOS-handoff polish releases past the planned freeze
|
||||
marker. The strategic plan should be updated post-tag to reflect
|
||||
that v1.4.41 is the actual freeze marker (or v1.4.42 if the
|
||||
backlog above lands as one more polish wave). Recommend: append a
|
||||
section to `v15-strategic-plan.md` documenting the actual cadence
|
||||
that landed and resetting the freeze trigger to v1.4.41 (or
|
||||
v1.4.42).
|
||||
|
||||
**Net assessment:** the iOS Swift sprint is unblocked. The strategic
|
||||
plan's day-by-day schedule can begin against `develop` tip the day
|
||||
after v1.4.41 deploys.
|
||||
|
||||
## Strengths
|
||||
|
||||
- **Headline regression fixed surgically.** W-INSIGHTS-HOT mirrors a
|
||||
proven pattern (bmi-status timeout-stub since v1.4.37) verbatim
|
||||
into the two iOS-facing routes. Zero new surface area; 4 new test
|
||||
cases pin the contract; provider behaviour, response envelope,
|
||||
cache shape all unchanged. This is the right kind of fix for a
|
||||
user-visible regression Marc feels daily: pattern reuse, contract
|
||||
preservation, test coverage, no new failure modes introduced.
|
||||
|
||||
- **Soft-delete invisibility now end-to-end.** W-DELETED-2 closes the
|
||||
three readers v1.4.40 W-DELETED explicitly punted on. The integration
|
||||
test (`d0bdc4b8`) pins the invariant across every reader tier — there
|
||||
is no path by which a tombstoned row can surface on a user-facing
|
||||
surface (analytics, dashboard, rollups, insights, exports, doctor-
|
||||
report, gamification, Coach snapshot). For an iOS-write-deletedAt
|
||||
feature that's been live since 2026-05-11, this finally closes the
|
||||
contract.
|
||||
|
||||
- **iOS onboarding contract locked.** `/api/auth/check-user` ships
|
||||
the four-branch envelope iOS needs for sign-in flow without exposing
|
||||
any new enumeration surface beyond what `/api/auth/passkey/login-
|
||||
options` already discloses. 5 test cases pin all four branches plus
|
||||
the 422 required-identifier path. iOS can build the sign-in flow
|
||||
against a stable contract.
|
||||
|
||||
- **Structural cleanups are coherent and follow the v1.4.39 / v1.4.40
|
||||
org-audit recommendations.** W-ORG closes Rec #2 (types promotion)
|
||||
and Rec #3 (prompt unification). The three structurally-distinct
|
||||
`AnalyticsData` interfaces (mother page / dashboard / checklist /
|
||||
sub-page) now have four named exports with clear semantics, instead
|
||||
of three inline interfaces colliding on the same name. Prompts have
|
||||
one home (`src/lib/ai/prompts/`). queryKey factory expands by ~25
|
||||
entries. Each is a small, atomic, reversible refactor.
|
||||
|
||||
- **W-SIMPLIFIER's conservative posture is correct.** Cleaning 13
|
||||
dead exports while reverting 4 candidate narrowings that lint
|
||||
surfaced as "assigned but only used as a type" — exactly the right
|
||||
judgement. The 35 remaining unused exports are documented as needing
|
||||
a flag-by-flag audit (zod schemas, shadcn surface area, type-of-
|
||||
typedef aliases) rather than driven to zero artificially.
|
||||
|
||||
- **Marathon worktree-isolation hard rule established.** Per the
|
||||
handoff doc, the `release-marathon` skill now requires per-agent
|
||||
`git worktree` for any 3+ parallel-agent marathon. Commit
|
||||
attribution drift is the 5th recurring occurrence; the rule
|
||||
closes the process bug at its root. The next marathon will be the
|
||||
first to apply it from start.
|
||||
|
||||
- **Conservative-semver call holds: patch bump (1.4.41) is correct.**
|
||||
Zero contract breaks; one new endpoint (`/api/auth/check-user`),
|
||||
additive only; structural refactors (types dir, prompt dir,
|
||||
queryKey factory expansion); UNION arm retirement (post-convergence
|
||||
cleanup); 13 dead exports trimmed; perf fix on two routes (cache-
|
||||
short-circuit pattern). Per `feedback_semver_conservative.md`,
|
||||
bugfix-heavy releases with additive-only features stay on patch.
|
||||
v1.5 stays reserved for iOS native launch.
|
||||
|
||||
- **The release is structurally smaller than v1.4.40 and that's the
|
||||
right shape.** v1.4.40 was a 54-commit architecture-debt-close
|
||||
marathon. v1.4.41 is a 27-commit polish-and-iOS-handoff release.
|
||||
This is the right release-cadence shape on the way into the iOS
|
||||
sprint freeze: each release smaller, narrower, more reversible,
|
||||
closer to "the next polish wave" than "the next debt-close
|
||||
marathon."
|
||||
|
||||
- **Six wave reports, two prior QA findings (round-v1441 design),
|
||||
one handoff doc, one ESLint custom rule + plugin, one process-docs
|
||||
scaling guide — the marathon left behind enough breadcrumbs that a
|
||||
fresh session can land the release endgame in under 2-3 hours per
|
||||
the handoff estimate.** This is the operational discipline that
|
||||
makes the multi-marathon cadence sustainable.
|
||||
@@ -0,0 +1,167 @@
|
||||
# v1.4.41 Security-review findings
|
||||
|
||||
Scope: `git diff main..develop` (23 commits, v1.4.41 vs live v1.4.40).
|
||||
Reviewer: W10 security-reviewer (read-only).
|
||||
|
||||
## Verdict
|
||||
APPROVE_WITH_FIXES
|
||||
|
||||
One High that should land before tag (rate-limit on `/api/auth/check-user`). All
|
||||
other focus-area items pass. No Critical / data-leak / auth-bypass findings.
|
||||
|
||||
## Critical (must fix before tag — vuln / data leak / auth bypass)
|
||||
|
||||
None.
|
||||
|
||||
## High (should fix before tag — defence-in-depth gap, log leak, error swallow)
|
||||
|
||||
### H-1 `/api/auth/check-user` has zero rate-limiting; the JSDoc rationale is factually wrong
|
||||
- **File**: `src/app/api/auth/check-user/route.ts:28-32, 53-95`
|
||||
- **Attack vector**: The route discloses account-existence (`branch != "not_found"`)
|
||||
plus credential-shape (`hasPasskey`, `hasPassword`) for any submitted identifier.
|
||||
This is by design for iOS onboarding, but the source comment claims:
|
||||
> *"No rate-limit middleware added here; the higher-level edge limit on
|
||||
> `/api/auth/*` covers brute-force enumeration concerns, and the route is
|
||||
> functionally equivalent in information leak to the existing
|
||||
> `/api/auth/passkey/login-options` request that already accepts an identifier."*
|
||||
|
||||
Both claims are wrong:
|
||||
1. There is no `middleware.ts` anywhere in the repo (`find . -name "middleware.ts"`
|
||||
returns empty); rate-limiting is per-route via `checkRateLimit()`. Sibling
|
||||
routes implement it explicitly — `auth/login`: `5/15min`,
|
||||
`auth/register`, `auth/passkey/login-options`: `10/15min`,
|
||||
`auth/refresh`, `auth/me/research-mode`: `5/min`. **`check-user` is the only
|
||||
unauth POST under `/api/auth/*` without per-IP rate-limit.**
|
||||
2. `auth/passkey/login-options/route.ts` does NOT accept an identifier — it
|
||||
reads no request body and returns a WebAuthn challenge that does not
|
||||
disclose user existence. So check-user is not "functionally equivalent" in
|
||||
leak; it is strictly more powerful.
|
||||
|
||||
The contract itself (intentional enumeration) is acceptable for the iOS
|
||||
onboarding UX. The omission of a brute-force throttle is not — an attacker
|
||||
can probe the entire user base at HTTP line rate.
|
||||
- **Recommended fix**: add a per-IP throttle mirroring `login-options`:
|
||||
```ts
|
||||
const ip = getClientIp(request) ?? "unknown";
|
||||
const rl = await checkRateLimit(`auth:check-user:${ip}`, 10, 15 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json(
|
||||
{ data: null, error: "Too many requests. Please try again later." },
|
||||
{ status: 429, headers: rateLimitHeaders(rl) },
|
||||
);
|
||||
}
|
||||
```
|
||||
Also delete or correct the misleading "edge limit on `/api/auth/*` covers …"
|
||||
paragraph in the file header so the next reader doesn't repeat the assumption.
|
||||
|
||||
## Medium (recommended for tag)
|
||||
|
||||
None.
|
||||
|
||||
## Low (defer to v1.4.42)
|
||||
|
||||
### L-1 `/api/auth/check-user` timing side-channel on the `not_found` branch
|
||||
- **File**: `src/app/api/auth/check-user/route.ts:65-83`
|
||||
- **Attack vector**: The `not_found` branch returns immediately after `findFirst`
|
||||
with `select { id, passwordHash, _count: { passkeys } }`; the relational
|
||||
`_count` on the passkey relation costs a roundtrip vs. a flat user row, so
|
||||
a found user with N>0 passkeys is marginally slower than a `not_found`.
|
||||
Acceptable now (Postgres ms-range, swamped by network jitter), but if the
|
||||
contract is "enumeration is the explicit feature", the timing channel should
|
||||
not be the *backup* enumeration affordance.
|
||||
- **Recommended fix** (defer): align timing by issuing the same shape query
|
||||
on the `not_found` path, or add a small randomised jitter (5–25 ms). Not
|
||||
required for tag — the explicit `branch` field already discloses the same
|
||||
information.
|
||||
|
||||
### L-2 `auth.check-user` is not audit-logged
|
||||
- **File**: `src/app/api/auth/check-user/route.ts:77, 93`
|
||||
- **Attack vector**: The route calls `annotate({ action: { name: "auth.check-user" } })`
|
||||
for the wide-event log but never calls `auditLog(...)`. Sibling routes
|
||||
(`login`, `register`, `password`, `passkey/login-verify`) emit `auth.login.failed`
|
||||
/ `auth.register.success` / etc. into `auditLog` so they show up in the
|
||||
admin status timeline. If H-1 lands and the throttle ever fires, there'll
|
||||
be no `auditLog` row showing "the throttle clipped a burst". Wide events
|
||||
are sampled and not retained as long as `auditLog`.
|
||||
- **Recommended fix** (defer): emit `auth.check-user` to `auditLog` with
|
||||
`{ ipAddress, identifier_hash }` (hash, not raw email) so the admin
|
||||
timeline carries the enumeration probe trail without storing PII. Defer
|
||||
to v1.4.42 — the wide-event annotation is enough for short-term
|
||||
diagnostics.
|
||||
|
||||
### L-3 `editMessageReplyMarkup` deletion — leaked-via-error log warning gone
|
||||
- **File**: `src/lib/telegram.ts` (deleted lines 115-134 of pre-diff)
|
||||
- **Attack vector**: None now — there is no remaining caller (`grep -rn
|
||||
editMessageReplyMarkup` is clean). Noting only because the deleted helper
|
||||
was the one Telegram-API surface that swallowed `json.ok=false` into a
|
||||
warning instead of a hard throw. If a future contributor restores it,
|
||||
they should restore the throw, not the warn.
|
||||
- **Recommended fix**: nothing required.
|
||||
|
||||
## Strengths
|
||||
|
||||
1. **Soft-delete fix-ups are comprehensive, not headline-only.**
|
||||
- `/api/export/full-backup/route.ts`, `/api/export/measurements/route.ts`,
|
||||
`/api/export/route.ts` — every `measurement.findMany` in the changed
|
||||
files gets `deletedAt: null` (verified by full grep — single read per
|
||||
file, all filtered).
|
||||
- `/api/gamification/achievements/route.ts:531-549` — the measurement
|
||||
branch of the `Promise.all` is filtered; the sibling reads
|
||||
(`medicationIntakeEvent`, `moodEntry`, `passkey`, `auditLog`) correctly
|
||||
stay unfiltered because those tables don't have `deletedAt` columns
|
||||
(verified against `prisma/schema.prisma`).
|
||||
- `/api/doctor-report/availability/route.ts:58-98` — all four
|
||||
`measurement.count` reads filter; the `moodEntry` and
|
||||
`medicationIntakeEvent` counts correctly do not (no `deletedAt` col).
|
||||
- `src/lib/doctor-report-data.ts:275-281` — the headline aggregator
|
||||
is the single measurement read in the file.
|
||||
- `/api/admin/status/route.ts:38-44` — count corrected too.
|
||||
|
||||
2. **Timeout-stub persist (`27f3bec1`) is safe.**
|
||||
No user-controlled input lands in the persisted `auditLog.details`:
|
||||
- `userId`: server-resolved from the auth context, route-side.
|
||||
- `cacheAction`: `` `insights.blood-pressure-status.${locale}` `` /
|
||||
`` `…weight-status.${locale}` ``, where `locale` is clamped by
|
||||
`normalizeLocale(...)` to `"en" | "de"` (literal switch — no other
|
||||
value can escape).
|
||||
- `text`: `getNoKey{BP,Weight}StatusText(locale)` — deterministic
|
||||
localized constant string, no PII.
|
||||
- `providerType`: internal enum.
|
||||
- `model: "timeout-stub"`: literal.
|
||||
The write is also wrapped in `try { … } catch {}` so a stub-persist
|
||||
failure cannot turn the parent request into a 5xx. Mirrors the v1.4.37
|
||||
`bmi-status` precedent.
|
||||
|
||||
3. **`projectTodayIntakesAndRecompute` (`54ad5cdb`) cannot mint rows for
|
||||
arbitrary users.** Both callers — `src/app/api/medications/intake/route.ts:69-70`
|
||||
and `src/app/api/dashboard/summary/route.ts:232` — gate on
|
||||
`requireAuth()` before passing `user.id`. The helper itself reads
|
||||
`medication { where: { userId, active: true } }` so even a forged
|
||||
`userId` would yield zero schedules and a no-op `createMany`. The
|
||||
`(userId, medicationId, scheduledFor, source)` unique constraint plus
|
||||
`skipDuplicates: true` is correct defence-in-depth — a stale projection
|
||||
from a concurrent caller cannot duplicate-mint a row.
|
||||
|
||||
4. **`editMessageReplyMarkup` deletion (`70ff3eef`) is complete.** No
|
||||
remaining caller in `src/` (verified). The helper was an internal
|
||||
wrapper around `editMessageReplyMarkup` — no public route reachable,
|
||||
no fail-open path possible from its removal.
|
||||
|
||||
5. **AI prompts unification (`8a56f482`) is a pure rename.** `git log
|
||||
--diff-filter=R -M` confirms 100% similarity on both moved files
|
||||
(`prompt.ts` → `insight-system-prompt.ts`, `prompt-compact.ts` →
|
||||
`compact-sections.ts`). No new user-text concatenation; the
|
||||
pre-existing `sanitizeForPrompt(..., maxLength)` envelope around
|
||||
user-text fields (`medication.name`, `medication.dose`,
|
||||
`doseChange.note`, `ctx.drug`, `ctx.doseUnit`) is untouched and still
|
||||
the sole entry-point for user-supplied prose into LLM prompts.
|
||||
|
||||
6. **Pure relocation / type-extraction commits are inert.** `8cfb1715`
|
||||
(WMY + cumulative readers → `src/lib/rollups/`), the `BackupRow` /
|
||||
`BackupsList` move to `src/types/backups.ts`, and the `AnalyticsData`
|
||||
consolidation are all import-renames with no behaviour change —
|
||||
verified against the diff stats and the rename detection output.
|
||||
|
||||
---
|
||||
|
||||
50-word summary follows the report.
|
||||
@@ -0,0 +1,150 @@
|
||||
# v1.4.41 Senior-dev architectural findings
|
||||
|
||||
Branch under review: `develop` vs live `main` (v1.4.40). 23 commits.
|
||||
Read-only review. No files modified.
|
||||
|
||||
## Verdict
|
||||
|
||||
**APPROVE_WITH_FIXES**
|
||||
|
||||
The release is architecturally sound. The five W-DELETED-2 + W-ORG + W-FRONTEND-FACTORY + W-SIMPLIFIER waves landed with clean discipline and good test coverage. The W-INSIGHTS-HOT timeout-stub fix (commit `27f3bec1`) is correct but only partial — four sibling status routes still ship the bare-fallback shape that drove the very paper-cut Marc reported. None of the findings below are release-blockers, but the partial parity and one misleading code comment should land before the tag.
|
||||
|
||||
## Critical
|
||||
|
||||
(none)
|
||||
|
||||
## High
|
||||
|
||||
### H1. Timeout-stub parity is partial — four siblings still on bare-fallback shape
|
||||
|
||||
**Files:** `src/lib/insights/general-status.ts:392-399`, `src/lib/insights/pulse-status.ts:337-344`, `src/lib/insights/mood-status.ts:449-456`, `src/lib/insights/medication-compliance-status.ts:334-342`
|
||||
|
||||
**Root cause:** commit `27f3bec1` ported the v1.4.37 bmi-status persisted-sentinel pattern to `blood-pressure-status.ts` and `weight-status.ts` only. The other four status libraries that race the same 20 s provider call with the same `withTimeout(…, STATUS_PROVIDER_TIMEOUT_MS, null)` shape still return the deterministic fallback text **without** writing the auditLog cache row keyed to today. So a single provider stall on any of these four leaves the next mount paying the full 20 s race again — exactly the regression the v1.4.41 fix targeted.
|
||||
|
||||
The four loci are byte-identical:
|
||||
|
||||
```ts
|
||||
if (raced.timedOut || raced.value === null) {
|
||||
return {
|
||||
hasProvider: true,
|
||||
text: getNoKey<...>StatusText(locale),
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Recommended fix:** apply the same `prisma.auditLog.create({ data: { ..., timeout: true } })` block from `blood-pressure-status.ts:557-583` to the four siblings. Each carries its own `cacheAction = "insights.<name>.<locale>"`, `todayKey`, and `getNoKey<...>Text(locale)` already — the lift is a straight copy-edit. `medication-compliance-status.ts` returns a richer shape (`summary`, `medications: []`) so the stub there should write `summary` into `details.text` (mirroring its non-timeout path at line 374 and onwards) while still returning `medications: []` to the caller.
|
||||
|
||||
**Why High not Critical:** the user-facing UI is identical on the first hit (deterministic fallback text either way). The hit hurts only the *second* mount within the day, after a one-off provider hiccup. v1.4.41 explicitly framed this as the only user-visible release item Marc would feel (`.planning/v1441-handoff.md` line 22). Shipping with only 2/6 routes patched leaves four routes still bleeding.
|
||||
|
||||
## Medium
|
||||
|
||||
### M1. `/api/auth/check-user` is unrate-limited; comment justifies omission with a non-existent middleware
|
||||
|
||||
**File:** `src/app/api/auth/check-user/route.ts:28-32`
|
||||
|
||||
**Root cause:** the route header says "No rate-limit middleware added here; the higher-level edge limit on `/api/auth/*` covers brute-force enumeration concerns". I grepped for that edge limit (`middleware.ts` at repo root, `src/middleware.ts`, any `/api/auth` allowlist in `src/lib/rate-limit.ts`, `next.config.*`) and could not find it. The comparable `/api/auth/passkey/login-options` (cited in the same header) does carry its own `checkRateLimit('auth:passkey-login-options:<ip>', 10, 15 * 60 * 1000)` block — that's a per-route limit, not an edge one.
|
||||
|
||||
The functional outcome is acceptable per the W-IOS-COORD decision: the new endpoint discloses no more than `/api/auth/passkey/login-options` already does. But the rationale in the code is wrong, which means a future contributor reading this comment might confidently mirror the no-limit pattern in a route that *doesn't* have a comparable enumeration baseline.
|
||||
|
||||
**Recommended fix:** either (a) add a per-route `checkRateLimit('auth:check-user:<ip>', 30, 15 * 60 * 1000)` block matching the passkey-login-options shape, or (b) rewrite the comment to cite the actual enumeration-equivalent (`/api/auth/passkey/login-options`) rather than a non-existent edge limit. (a) is the safer call given iOS onboarding is the only caller and 30 / 15 min is generous.
|
||||
|
||||
### M2. ESLint `queryKey-factory` rule whitelist has drifted from the test-guard substitute
|
||||
|
||||
**File:** `eslint-plugins/healthlog/queryKey-factory.js:48-57` (GUARDED_DIRECTORIES + GUARDED_FILES)
|
||||
|
||||
**Root cause:** `eslint-plugins/healthlog/queryKey-factory.js` lists the ESLint rule's enforcement scope as `src/components/charts`, `src/components/comparison`, `src/app/page.tsx`, `src/hooks/use-auth.ts`. The companion test-guard substitute at `src/lib/__tests__/query-keys.test.ts:192-203` covers a wider scope — it also includes `src/app/auth`, `src/app/notifications`, and `src/components/settings/about-section.tsx` (the three surfaces W-FRONTEND-FACTORY migrated in `0bf07abd`). The ESLint rule's header comment claims "Extend in lockstep with the test-guard substitute's `guardedRoots`" but the two lists are already out of sync at landing.
|
||||
|
||||
**Effect:** the ESLint rule won't catch a future bare-literal `queryKey: [...]` regression in the three v1.4.41-migrated dirs at IDE / `pnpm lint` time. The test-guard substitute will catch it (via `pnpm test`), but the whole point of promoting to ESLint per `.planning/phase-W-PROCESS-DOCS-v1441-report.md` was to fail fast.
|
||||
|
||||
**Recommended fix:** add the three entries to `GUARDED_DIRECTORIES` / `GUARDED_FILES` in `queryKey-factory.js`:
|
||||
|
||||
```js
|
||||
const GUARDED_DIRECTORIES = [
|
||||
"src/components/charts",
|
||||
"src/components/comparison",
|
||||
"src/app/auth",
|
||||
"src/app/notifications",
|
||||
];
|
||||
const GUARDED_FILES = [
|
||||
"src/app/page.tsx",
|
||||
"src/hooks/use-auth.ts",
|
||||
"src/components/settings/about-section.tsx",
|
||||
];
|
||||
```
|
||||
|
||||
## Low
|
||||
|
||||
### L1. `pr-detection-worker.ts` doesn't filter soft-deleted measurements
|
||||
|
||||
**File:** `src/lib/personal-records/pr-detection-worker.ts:219-221`, `:402-407`
|
||||
|
||||
**Root cause:** the W-DELETED-2 sweep covered export, gamification, and doctor-report but did not touch the personal-records worker. `prisma.measurement.count({ where: { userId, type: metricType } })` (line 219) and `findBestMeasurement` (lines 402-407) both omit `deletedAt: null`. So a soft-deleted measurement can:
|
||||
1. count toward the 30-row warm-up threshold (line 222);
|
||||
2. be returned by `findBestMeasurement` as the user's best-ever value (line 402), which then writes a `PersonalRecord` row keyed to a tombstoned measurement.
|
||||
|
||||
The worker is best-effort and the surfaces that read PR rows (`personal-record-badge.tsx`) just render the value — they don't re-validate against the measurement table — so the user sees a "best weight" that no longer exists on their list.
|
||||
|
||||
**Recommended fix:** add `deletedAt: null` to both `where` clauses. Defer to v1.4.42 unless an iOS undo or admin delete is observed creating a stale PR in production.
|
||||
|
||||
### L2. `offhost-backup.ts` includes soft-deleted measurements
|
||||
|
||||
**File:** `src/lib/jobs/offhost-backup.ts:219`
|
||||
|
||||
**Root cause:** `prisma.measurement.findMany({ where: { userId: user.id } })` omits `deletedAt: null`. This is the S3 nightly disaster-recovery dump, NOT a user-facing export. Per the W-DELETED-2 design intent (round-trip exports must not resurrect deletes) the user-initiated `/api/export/full-backup` correctly excludes them. The DR worker likely *should* keep them — restoring a tombstone is the safer DR semantic. But there's no documenting comment either way.
|
||||
|
||||
**Recommended fix:** add an inline comment to `offhost-backup.ts:219` documenting the intent ("includes soft-deleted rows because this is the DR snapshot, not a user-facing export — see `/api/export/full-backup/route.ts` for the symmetric exclusion"). No behavioral change.
|
||||
|
||||
### L3. Duplicate `DataSummary` import in `src/types/analytics.ts`
|
||||
|
||||
**File:** `src/types/analytics.ts:37-38`
|
||||
|
||||
**Root cause:**
|
||||
|
||||
```ts
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import type { DataSummary as DataSummaryType } from "@/lib/analytics/trends";
|
||||
```
|
||||
|
||||
Same named import declared twice — once bare, once aliased. The aliased one is referenced exactly once at line 80 (`glucoseByContext?: Record<string, DataSummaryType>`). The bare import is the canonical one used everywhere else in the file.
|
||||
|
||||
**Recommended fix:** drop the aliased import and change line 80 to `Record<string, DataSummary>`. Cosmetic / consistency.
|
||||
|
||||
### L4. No dedicated unit test for `projectTodayIntakesAndRecompute`
|
||||
|
||||
**File:** `src/lib/medications/scheduling/project-today-intakes.ts`
|
||||
|
||||
**Root cause:** the helper is exercised only through the two route tests (`intake/route.test.ts`, `dashboard/summary/route.test.ts`). The W-SIMPLIFIER report at `.planning/phase-W-SIMPLIFIER-report.md` line 24-40 names the extraction as the main deliverable but doesn't add a `__tests__/project-today-intakes.test.ts` directly. The route tests do cover the happy path and the missing-row backfill, so coverage is structurally present, just not isolated.
|
||||
|
||||
**Recommended fix:** v1.4.42 add a focused helper test (idempotent backfill, `Promise.allSettled` recompute-failure swallow, `skipDuplicates` no-op on duplicates).
|
||||
|
||||
### L5. Helper `recomputeMedicationComplianceForEvent` swallows-on-failure but the helper now `await Promise.allSettled`s — explicit-redundant
|
||||
|
||||
**File:** `src/lib/medications/scheduling/project-today-intakes.ts:127-142`
|
||||
|
||||
**Root cause:** the doc-comment on line 122-126 says "the helper swallows internally today, but a future refactor that lets a throw escape would otherwise turn the parent POST into a 5xx". That is correct defense-in-depth, but the call site is also wrapped in `Promise.allSettled`. The intent is right; just flag that one of the two layers should remain documented as the explicit contract, otherwise a future contributor will simplify one assuming the other holds.
|
||||
|
||||
Not a bug today.
|
||||
|
||||
## Strengths
|
||||
|
||||
- **W-DELETED-2 sweep is thorough.** All three new fixes (`a62b9498`, `cb8f74e4`, `5296a612`) land with the matching `deletedAt: null` filter in the right `where` clause, and every supporting raw SQL in `comprehensive-aggregator.ts`, `summaries-slice.ts`, `dashboard/summary/route.ts`, `measurements/route.ts`, `analytics/route.ts`, `bp-in-target-fast-path.ts`, `correlations-fast-path.ts`, `health-score-fast-path.ts`, `glp1-plateau.ts`, `features.ts`, `sync/state/route.ts`, `insights/cards/route.ts`, `insights/targets/route.ts`, `insights/generate/route.ts`, `ai/coach/snapshot.ts` carries the same `AND m."deleted_at" IS NULL` predicate. Eleven reader tiers covered.
|
||||
|
||||
- **UNION arm retirement is safe.** The `mean * count` fallback in `dashboard/summary/route.ts:498-500` is intact and the legacy-NULL test pin at `summary/__tests__/route.test.ts:278-314` still asserts the fallback path. The Marc-tenant convergence is the right gate; the per-day discovery LEFT JOIN still surfaces any future legacy row on a self-hosted instance, even if its boot-time backfill needs to re-mint a row.
|
||||
|
||||
- **Helper extraction is read-shape-neutral.** Both call sites consume the returned `{ projected, backfilled }` tuple correctly. The dashboard discards the values; the intake route forwards them onto `annotate(...)` for prod observability. The helper's `IntakeSource: "REMINDER"` literal preserves the doctor-report + analytics filter byte-stability. The `skipDuplicates: true` + the `@@unique([userId, medicationId, scheduledFor, source])` composite key give two layers of race protection.
|
||||
|
||||
- **Type consolidation is clean.** `AnalyticsData` and `BackupRow` lifts kept the call-site contract via the `as AnalyticsData` aliases (`page.tsx:28`, `insights/page.tsx:100`, `getting-started-checklist.tsx:75`) so no consumer pays for the type relocation. The four-shape (`SubPage`/`Dashboard`/`Insights`/`Checklist`) split is the right design call — collapsing into one optional-everywhere shape would have lost the per-surface contract. No circular imports: both type modules consume `@/lib/analytics/trends` and `@/lib/insights/correlations`, neither of which import back from `@/types/*`.
|
||||
|
||||
- **queryKey factory expansion has matching test pins.** The walker test at `src/lib/__tests__/query-keys.test.ts:55-69` covers all three new migrations (`authRegistrationStatus`, `notificationsPreferences`, `notificationsStatus`, plus the previously-unverified `apiVersion`). The bare-literal lint guard at lines 192-203 extends to the three new surfaces. No bare-literal `queryKey: [...]` survives in `src/app/auth`, `src/app/notifications`, or `src/components/settings/about-section.tsx`.
|
||||
|
||||
- **`/api/auth/check-user` branching is correct.** All four documented branches (`not_found`, `passkey_only`, `email_fallback`, `exists`) are reachable from the `hasPasskey` / `hasPassword` truth table, the test pin at `src/__tests__/api/auth/check-user/route.test.ts` covers all four plus the 422 input-validation path, the response envelope matches the `apiSuccess({ branch, hasPasskey, hasPassword })` shape iOS expects, and the route never echoes the identifier (PII-safe). The OR-match on `username | email` mirrors the canonical login-credential resolver.
|
||||
|
||||
- **Prompt-dir unification (`8a56f482`) is a pure move.** Every import path (`@/lib/ai/prompts/compact-sections`, `@/lib/ai/prompts/insight-system-prompt`) is consistent across the two consumers (`api/insights/generate/route.ts`, `ai/coach/snapshot.ts`) and the test file (`__tests__/prompt-comparison.test.ts`). No dangling `@/lib/insights/prompt*` references remain.
|
||||
|
||||
- **W-SIMPLIFIER cleanup is conservative.** The `tx?` param drop on three rollup helpers (`mood-rollups`, `medication-compliance-rollups`) doesn't break any call site because no call site ever passed a tx. The 13 dead-export trims are the narrowing kind (drop `export`, keep the symbol), and the four reverted candidates (`CHART_RANGE_PRESETS`, `CHANNEL_TYPES`, `CHECKLIST_ITEM_IDS`, `moodLogSyncResponseSchema`) keep their export because they back `typeof X[number]` aliases — the right call.
|
||||
|
||||
---
|
||||
|
||||
**Summary:** verdict APPROVE_WITH_FIXES, 0 Critical, 1 High, 2 Medium, 5 Low, 7 strengths. The only finding that should land before the v1.4.41 tag is H1 (port the persisted timeout-stub pattern to the four remaining status routes). Everything else is safe to defer to v1.4.42.
|
||||
@@ -0,0 +1,190 @@
|
||||
# v1.4.41 Simplifier-residual findings
|
||||
|
||||
## Verdict
|
||||
|
||||
APPROVE_WITH_FIXES — the W-SIMPLIFIER wave delivered clean, well-scoped wins (helper extraction, knip −13, lint to zero, dead-export trim). The residual scan turned up three small clarity regressions introduced *this release* by sibling waves (W-INSIGHTS-HOT duplicated a 45-line block 3-way; W-ORG mis-ordered two `import type` statements; W-FRONTEND-FACTORY left a double comment block) plus two structural simplifications the simplifier wave didn't reach. None block the release. All five are mechanical follow-ups that could land as a single small commit on develop before tag, or roll into v1.4.42.
|
||||
|
||||
## Critical
|
||||
|
||||
None.
|
||||
|
||||
## High (clarity regression introduced this release)
|
||||
|
||||
### H1 — `src/lib/insights/blood-pressure-status.ts:544-590` + `src/lib/insights/weight-status.ts:442-488`: timeout-stub persist block duplicated 3-way
|
||||
|
||||
W-INSIGHTS-HOT (phase report §"Fix") mirrors a 45-line `if (raced.timedOut || raced.value === null) { …prisma.auditLog.create({ … model: "timeout-stub", timeout: true …}) }` block verbatim from `bmi-status.ts` into both `blood-pressure-status.ts` and `weight-status.ts`. The three blocks are byte-identical except for the no-key fallback text getter name. The phase report itself describes the work as "Mirror the bmi-status stub-persist pattern verbatim into both routes" — that's a literal cue to extract.
|
||||
|
||||
**Proposed shape** — `src/lib/insights/persist-timeout-stub.ts`:
|
||||
|
||||
```ts
|
||||
export async function persistTimeoutStubAndReturn(input: {
|
||||
userId: string;
|
||||
cacheAction: string;
|
||||
todayKey: string;
|
||||
locale: string;
|
||||
providerType: string;
|
||||
stubText: string;
|
||||
}): Promise<{ hasProvider: true; text: string; cached: true; updatedAt: string | null }> {
|
||||
let stubUpdatedAt: string | null = null;
|
||||
try {
|
||||
const stub = await prisma.auditLog.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
action: input.cacheAction,
|
||||
details: JSON.stringify({
|
||||
dateKey: input.todayKey,
|
||||
locale: input.locale,
|
||||
text: input.stubText,
|
||||
providerType: input.providerType,
|
||||
model: "timeout-stub",
|
||||
tokensUsed: null,
|
||||
timeout: true,
|
||||
}),
|
||||
},
|
||||
select: { createdAt: true },
|
||||
});
|
||||
stubUpdatedAt = stub.createdAt.toISOString();
|
||||
} catch {
|
||||
// Best-effort persist — caller still gets the deterministic fallback text.
|
||||
}
|
||||
return { hasProvider: true, text: input.stubText, cached: true, updatedAt: stubUpdatedAt };
|
||||
}
|
||||
```
|
||||
|
||||
Each of the 3 call sites collapses from ~40 lines to:
|
||||
|
||||
```ts
|
||||
if (raced.timedOut || raced.value === null) {
|
||||
return persistTimeoutStubAndReturn({
|
||||
userId, cacheAction, todayKey, locale,
|
||||
providerType: provider.type,
|
||||
stubText: getNoKeyBloodPressureStatusText(locale), // metric-specific
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Net: ~120 LOC of duplicate `prisma.auditLog.create({ … timeout: true })` collapses to one helper + three 6-line invocations. The two pre-warm-worker-recognition comments shrink to one header on the helper. Cost: nil — the helper is a pure pass-through to the same `prisma.auditLog.create` call.
|
||||
|
||||
### H2 — `src/components/onboarding/getting-started-checklist.tsx:75` + `src/app/insights/page.tsx:100`: `import type` placed after non-import statements
|
||||
|
||||
The W-ORG wave hoisted shared analytics shapes into `src/types/analytics.ts` and added the `import type { … as AnalyticsData }` alias-back-import next to the old inline interface declaration — landing the import in the middle of the file, after non-import statements.
|
||||
|
||||
`src/components/onboarding/getting-started-checklist.tsx`:
|
||||
- Line 16: `import type { LucideIcon } from "lucide-react";` (top of file, with other imports)
|
||||
- Lines 30-71: constants + `ITEM_ICONS` + `ITEM_LABEL_KEYS` declarations
|
||||
- **Line 75**: `import type { ChecklistAnalyticsData as AnalyticsData } from "@/types/analytics";` ← mid-file import
|
||||
|
||||
`src/app/insights/page.tsx`:
|
||||
- Lines 1-18: import block
|
||||
- Lines 88-95: `interface ComprehensiveData` declaration
|
||||
- **Line 100**: `import type { InsightsAnalyticsData as AnalyticsData } from "@/types/analytics";` ← mid-file import
|
||||
|
||||
**Proposed shape**: Move both `import type` lines into the top-of-file import block alongside their peers. The aliased name (`as AnalyticsData`) stays for code-churn minimisation. The "v1.4.41 W-ORG — shared shape lives in …" comment can collapse to a single inline trailing comment on the import.
|
||||
|
||||
This is the kind of drift an ESLint `sort-imports` / `import/first` rule would have blocked at PR time. Worth checking whether the existing config already has `import/first` and just doesn't fire on the dynamic-import pattern in `src/app/page.tsx`.
|
||||
|
||||
### H3 — `src/types/analytics.ts:37-38`: same symbol imported twice (one aliased)
|
||||
|
||||
```ts
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import type { DataSummary as DataSummaryType } from "@/lib/analytics/trends";
|
||||
```
|
||||
|
||||
`DataSummary` is used on lines 44, 55, 100. `DataSummaryType` is used once on line 80 (`glucoseByContext?: Record<string, DataSummaryType>;`).
|
||||
|
||||
The duplicate-import shape was carried verbatim from `src/app/page.tsx` (lines 27 + 79 — same file still has both today) when the W-ORG wave consolidated `interface AnalyticsData` into `src/types/analytics.ts`. The alias was justified there because both names existed historically in `page.tsx`; in a fresh module there's no reason for two import lines.
|
||||
|
||||
**Proposed shape**:
|
||||
|
||||
```ts
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
…
|
||||
glucoseByContext?: Record<string, DataSummary>;
|
||||
```
|
||||
|
||||
Same fix for `src/app/page.tsx` lines 27 + 79 — collapse to a single `import type { DataSummary } from "@/lib/analytics/trends";` and use `DataSummary` on line 243 (the only `DataSummaryType` reference in that file).
|
||||
|
||||
## Medium (clean wins the W-SIMPLIFIER wave missed)
|
||||
|
||||
### M1 — `src/app/page.tsx:1403-1424`: two adjacent comment blocks describe the same `<Suspense>`
|
||||
|
||||
The dashboard tile-strip Suspense boundary now carries two adjacent JSX comment blocks (v1.4.40 W-RSC rationale + v1.4.41 W-FRONTEND-FACTORY rationale). They overlap heavily — both explain "the body is synchronous today" + "future RSC hoist". Reads as historical archaeology rather than current intent.
|
||||
|
||||
**Proposed shape**: consolidate into a single 5-6 line block keyed to v1.4.41 (the current behaviour) with a one-line "v1.4.40 W-RSC — boundary added; v1.4.41 W-FRONTEND-FACTORY — fallback hoisted to a layout-stable placeholder" trailer.
|
||||
|
||||
Same comment-drift pattern *might* exist on the chart-row Suspense (lines 1441-1464); didn't audit deeply but worth a once-over in the same pass.
|
||||
|
||||
### M2 — `src/lib/insights/{bmi,blood-pressure,weight,pulse,mood,general,medication-compliance}-status.ts`: `BERLIN_DAY_FORMATTER` + `toBerlinDayKey()` duplicated 7-way
|
||||
|
||||
Each of the seven `*-status.ts` files carries an identical 20-LOC block:
|
||||
|
||||
```ts
|
||||
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "Europe/Berlin", year: "numeric", month: "2-digit", day: "2-digit",
|
||||
});
|
||||
|
||||
function toBerlinDayKey(date: Date): string { /* identical body */ }
|
||||
```
|
||||
|
||||
`src/lib/insights/bucket-series.ts` already declares an internal-only `toBerlinYmd` + the exported `dayOffsetToBerlinDayKey`. Adding a sibling `export function toBerlinDayKey(date: Date): string` to that module (or `src/lib/tz/resolver.ts`) and importing in 7 places drops ~120 LOC of identical helpers.
|
||||
|
||||
This is a fair size, would benefit from being its own commit; not v1.4.41-introduced but the timeout-stub change in 3 of these 7 files made the duplication footprint conspicuous. Flagging as Medium because it's a clear, low-risk, follow-up.
|
||||
|
||||
### M3 — `src/lib/insights/{mood,pulse,general,medication-compliance}-status.ts`: timeout fallback does NOT persist the stub
|
||||
|
||||
Sibling to H1: while `bmi-status`, `blood-pressure-status`, and `weight-status` now all persist a `timeout: true` sentinel on stall, the other four `withTimeout`-wrapped insight routes still execute the original "return fallback text, do not persist" branch:
|
||||
|
||||
```ts
|
||||
// e.g. src/lib/insights/mood-status.ts:449-456
|
||||
if (raced.timedOut || raced.value === null) {
|
||||
return {
|
||||
hasProvider: true,
|
||||
text: getNoKeyMoodStatusText(locale),
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
The behaviour gap means a provider stall on `mood-status` / `pulse-status` / `general-status` / `medication-compliance-status` still re-races every cold mount for the rest of the day — exactly the Marc-reported regression W-INSIGHTS-HOT was scoped to close, scoped narrowly to BP+weight because Marc reported only those two.
|
||||
|
||||
**Status**: behavioural (not pure simplification), but if the H1 helper is extracted, wiring those four routes to it becomes a 1-line-per-route change with the same defensive `try/catch` semantics. Worth bundling.
|
||||
|
||||
## Low (defer to v1.4.42)
|
||||
|
||||
### L1 — `src/lib/doctor-report-data.ts` is checked in with literal control bytes inside a regex character class
|
||||
|
||||
`grep -nP "[\x00\x1F\x7F]" src/lib/doctor-report-data.ts` returns line 171; `file` reports the file as `data`; `git diff` shows it as `Binary files differ` for the v1.4.41 W-DELETED-2 soft-delete fix (commit `5296a612`). This makes the file's diffs unreadable in code review, and obscures non-trivial changes (the W-DELETED-2 fix went in essentially unreviewable).
|
||||
|
||||
Pre-existing — not introduced by v1.4.41 — but the v1.4.41 soft-delete fix exposed the cost. Replace the literal-byte regex `/[\x00-\x1F\x7F]/g` (written as actual NUL + US + DEL) with the escape-sequence form so the file is UTF-8-clean, diffs work, and reviewers can see what changed. Same behaviour at runtime.
|
||||
|
||||
### L2 — `src/app/api/auth/check-user/route.ts:88-91`: branch resolver could use a switch or single expression
|
||||
|
||||
```ts
|
||||
let branch: CheckUserBranch;
|
||||
if (hasPasskey && !hasPassword) branch = "passkey_only";
|
||||
else if (hasPassword) branch = "email_fallback";
|
||||
else branch = "exists";
|
||||
```
|
||||
|
||||
Reads fine today (no nested ternary, explicit), but a `function resolveCheckUserBranch(hasPasskey: boolean, hasPassword: boolean): CheckUserBranch` extraction would push the four-branch matrix into a unit-testable function next to the type declaration. Skipping for now — the inline shape is honest about the decision and works against the 4-line `if/else if/else` rule from the CLAUDE.md style guide. Pure preference.
|
||||
|
||||
## Already-deferred items (acknowledge, do not re-flag)
|
||||
|
||||
Per `.planning/phase-W-SIMPLIFIER-report.md` §"Follow-up for v1.4.42" — the simplifier wave deliberately deferred these and I am not re-flagging them:
|
||||
|
||||
- `tokenKind`, `describeInjectionSite`, `PROGRESS_TICK_RECORDS`, `MAINTAINED_LOCALES`, `SUB_PAGE_METRIC`, `withBackgroundEventSafe`, `listSupportedTimezones` re-export at `tz/resolver.ts`
|
||||
- `AlertDialogMedia / AlertDialogOverlay / AlertDialogPortal`, `AvatarBadge / AvatarGroup / AvatarGroupCount`, `badgeVariants`, `buttonVariants`, `CardFooter / CardAction / CardDescription`, `DialogOverlay / DialogPortal / DialogTrigger`, six unused `DropdownMenu*` exports, five unused `Select*` exports, `SheetTrigger`, `TableFooter / TableCaption`, `tabsListVariants` — shadcn surface-area exports needing Marc's call before removing
|
||||
- `isRollupFresh` in `measurement-rollups.ts` — pending W-PERF-OPS-1 follow-up
|
||||
- All 52 "unused exported types" — flag-by-flag audit (zod `z.infer<>` types, iOS native client contract, OpenAPI doc-gen)
|
||||
- `recommendationSeveritySchema / aiCitationSchema / aiWarningSchema / storyboardAnnotation*` in `src/lib/ai/schema.ts`
|
||||
- The `summaries-slice.ts` test exposure of `aggregateWmyBuckets` + `RollupBucketRow` — exports retained because the test suite imports them externally
|
||||
|
||||
## Strengths
|
||||
|
||||
- **W-SIMPLIFIER discipline**: 13 dead exports trimmed without churn-chasing the 4 cases (`CHART_RANGE_PRESETS`, `CHANNEL_TYPES`, `CHECKLIST_ITEM_IDS`, `moodLogSyncResponseSchema`) where the constant IS the source of truth for a `typeof X[number]` / `z.infer<>` type — the simplifier correctly reverted those after lint surfaced the dependency. That's the right call.
|
||||
- **W-SERVER-FIX-2 helper extraction**: `projectTodayIntakesAndRecompute({ userId, userTz, todayStart, todayEnd })` shaves ~200 inline LOC across two routes into one 145-line tested helper. Comments document the `IntakeSource` "REMINDER" reuse, the `skipDuplicates: true` defense-in-depth rationale, the per-`(med, day)` coalescing, and the `Promise.allSettled` best-effort contract. Reads as Marc's authorship.
|
||||
- **W-ORG type consolidation**: four named shapes (`SubPage…`, `Dashboard…`, `Insights…`, `Checklist…`) instead of one swiss-army `AnalyticsData` correctly preserves TypeScript control flow for the call-sites that need to demand fields. The "we keep four shapes (rather than collapsing into one with everything optional) because …" header on `src/types/analytics.ts` documents exactly why.
|
||||
- **UNION discovery cleanup**: `9d2901aa` retires a now-no-op `sum_value IS NULL` UNION arm with one paragraph of audit-trail context for the next visitor. Test pruned alongside. Minimal, surgical.
|
||||
- **Coverage of the simplification**: the new `query-keys` factory expansion (admin surfaces + 6 admin keys + insights settings + provider chain + GLP1 timeline + auth/profile) lands with a centralisation comment per key cluster and a fresh `src/lib/__tests__/query-keys.test.ts` that pins the contract. Marc voice throughout — no AI/agent/marathon language.
|
||||
- **Auth check-user route** (`src/app/api/auth/check-user/route.ts`): 95 LOC, four branches, explicit `if/else if/else` (no nested ternary), test-pinned contract, zod-validated input, `annotate({ … branch })` for every branch, response shape constant across found/not-found (no enumeration leak). Reads exactly as the CLAUDE.md style guide prescribes.
|
||||
@@ -0,0 +1,256 @@
|
||||
# v1.4.42 Code-review findings
|
||||
|
||||
W10 — read-only code review, scope: every commit in `9e775aa3..develop`
|
||||
(i.e. everything after the v1.4.41 squash-merge `67207d72`). Phase reports
|
||||
under `.planning/phase-W*-v1442-report.md` already documented their own
|
||||
deltas; this review only flags items those reports did not already raise.
|
||||
|
||||
## Verdict
|
||||
|
||||
**APPROVE_WITH_FIXES** — code quality is high across all six waves and
|
||||
the existing tests pin every contract worth pinning. Two release-closure
|
||||
items (CHANGELOG + `package.json` version bump) are the only blockers
|
||||
between develop and a clean v1.4.42 tag, and both are part of the standard
|
||||
release-cut step rather than wave scope. No Critical-tier defects found.
|
||||
|
||||
## Critical (must fix before tag)
|
||||
|
||||
_None._
|
||||
|
||||
## High (should fix before tag)
|
||||
|
||||
### H-1 — `package.json` version + CHANGELOG.md not bumped for v1.4.42
|
||||
|
||||
- `/Users/marc/Projects/HealthLog/package.json:3` still reads
|
||||
`"version": "1.4.41"`.
|
||||
- `/Users/marc/Projects/HealthLog/CHANGELOG.md` has no v1.4.42 section
|
||||
in the develop diff (`git diff 9e775aa3..develop -- CHANGELOG.md` is
|
||||
empty).
|
||||
- Fix at release-cut time: bump `package.json`, add CHANGELOG section
|
||||
in Marc-voice English following v1.4.41's pattern. Without it the
|
||||
release-marathon's `NEXT_PUBLIC_APP_VERSION` build arg + the
|
||||
VersionPoller self-healing shell (memo
|
||||
`project_v1438_ios_coord_closure.md`) will mismatch the actual tag.
|
||||
|
||||
### H-2 — APNs result row label can mislead when only `APNS_KEY_FILE` is set
|
||||
|
||||
- `/Users/marc/Projects/HealthLog/scripts/check-env.ts:191` prints
|
||||
`[OK] APNS_KEY` even when the present variable is actually
|
||||
`APNS_KEY_FILE`. The `anyOf` lookup at line 132 satisfies the row,
|
||||
but the rendered name is the manifest's primary `name` field.
|
||||
- Symptom — an operator scanning the output sees `[OK] APNS_KEY`,
|
||||
greps the Coolify env for `APNS_KEY`, finds nothing, files a
|
||||
ghost issue. Exactly the v1.4.40 AP-2 debug loop this script was
|
||||
meant to short-circuit.
|
||||
- Fix: when an `anyOf` row passes, render the matched alternative
|
||||
name (e.g. `[OK] APNS_KEY_FILE (satisfies APNS_KEY)`) rather than
|
||||
the primary. Two-line patch in `renderResults`.
|
||||
|
||||
## Medium (recommended for tag)
|
||||
|
||||
### M-1 — `bucket-series.ts` still carries its own `BERLIN_DAY_FORMATTER`
|
||||
|
||||
- `/Users/marc/Projects/HealthLog/src/lib/insights/bucket-series.ts:19`
|
||||
declares a local `BERLIN_DAY_FORMATTER` identical to the one W4 hoisted
|
||||
into `tz/resolver.ts:53`.
|
||||
- Different consumer (returns `{year, month, day}` numbers via
|
||||
`toBerlinYmd`, not the `YYYY-MM-DD` string `toBerlinDayKey` emits),
|
||||
so this is _not_ a literal dup, but the formatter constant could
|
||||
still be reused. The W4 phase report names "seven status helpers" —
|
||||
this eighth site escaped.
|
||||
- Fix (Medium, mergeable next wave): export `BERLIN_DAY_FORMATTER`
|
||||
itself from `tz/resolver.ts` and have `bucket-series.ts` consume it
|
||||
for its own `toBerlinYmd`. Saves the duplicate `Intl.DateTimeFormat`
|
||||
allocation + keeps every Berlin-day surface keyed off one canonical
|
||||
formatter instance.
|
||||
|
||||
### M-2 — `medicationIntakeList` packs a plain params object at index 4
|
||||
|
||||
- `/Users/marc/Projects/HealthLog/src/lib/query-keys.ts:107` returns
|
||||
`["medications", id, "intake", "list", params]` where `params` is
|
||||
the same `{sortBy, sortDir, limit, offset, status}` object reference
|
||||
the caller passed.
|
||||
- TanStack Query hashes by structural deep-equal, so the cache key is
|
||||
stable across re-renders **as long as the caller passes a stable
|
||||
reference or always rebuilds the object with the same field order**.
|
||||
The factory's own `chartData` entry (line 286) decomposes the params
|
||||
into a flat tuple precisely to avoid this risk; `adminAuditLogFiltered`
|
||||
(line 193) does the same.
|
||||
- Inconsistency: two entries flatten, one entry packs an object. Either
|
||||
is correct in isolation, but the mixed pattern increases the chance a
|
||||
future refactor accidentally breaks deep-equal hashing on the packed
|
||||
entry (e.g. by reordering keys or adding a transient field). The
|
||||
pinned test at `query-keys.test.ts:133` only asserts the first four
|
||||
positions, not the index-4 shape.
|
||||
- Fix (Medium): either flatten `medicationIntakeList` to match the
|
||||
rest, or document the deep-equal contract on the factory entry and
|
||||
pin index-4 shape in the test.
|
||||
|
||||
### M-3 — Withings sync-activity / sync-sleep catch-blocks still consult the regex helper
|
||||
|
||||
- `/Users/marc/Projects/HealthLog/src/lib/withings/sync-activity.ts` and
|
||||
`/Users/marc/Projects/HealthLog/src/lib/withings/sync-sleep.ts` now throw
|
||||
typed `WithingsApiError` (good), but their catch-blocks still call
|
||||
`extractWithingsStatus(message)` / `isWithingsRefreshReauthFailure`
|
||||
rather than `classifyError(err)`.
|
||||
- Phase report W6 documents this as deferred to v1.4.43 (under
|
||||
"Sync-activity / sync-sleep catch-block migration"). Works today via
|
||||
the regex fallback inside `classifyError`, so this is **not a
|
||||
correctness defect** — it just leaves the migration partial.
|
||||
- Fix already scheduled — Medium severity for traceability only; no
|
||||
v1.4.42 action required.
|
||||
|
||||
### M-4 — `audit.dashboard.widgets.validation-failed` audit ledger row carries no rate-limit guard
|
||||
|
||||
- `/Users/marc/Projects/HealthLog/src/app/api/dashboard/widgets/route.ts:147`
|
||||
best-effort writes one audit row per 422. A poorly-behaved iOS client
|
||||
in a tight retry loop could write thousands of audit rows in a minute.
|
||||
The widgets PUT does not appear to be rate-limited (no
|
||||
`checkRateLimit` call in scope) — the audit table grows fast on a
|
||||
client bug.
|
||||
- Fix (Medium, defer to v1.4.43): either gate audit-row writes behind a
|
||||
60 s `(userId, action)` dedup, or add `checkRateLimit` to the widgets
|
||||
PUT itself. The widgets PUT is operator-facing settings traffic so
|
||||
the threshold can be generous (e.g. 30/min).
|
||||
|
||||
## Low (defer to v1.4.43)
|
||||
|
||||
### L-1 — `parseEnvFile` in `check-env.ts` does not handle dotenv backslash escapes
|
||||
|
||||
- `/Users/marc/Projects/HealthLog/scripts/check-env.ts:83` strips
|
||||
surrounding `"`/`'` quotes but does not unescape `\n`, `\t`, etc.
|
||||
inside double-quoted values. A `APNS_KEY="-----BEGIN\nPRIVATE..."`
|
||||
literal would be classified as `[OK]` but read as the raw escape
|
||||
sequence, not a newline.
|
||||
- Coolify exports without backslash escapes today, so the regression
|
||||
is hypothetical. Documented constraint on the helper would be
|
||||
enough.
|
||||
|
||||
### L-2 — `check-env.ts --file` with no path argument silently falls back to `process.env`
|
||||
|
||||
- `/Users/marc/Projects/HealthLog/scripts/check-env.ts:218`:
|
||||
`if (fileFlagIdx !== -1 && args[fileFlagIdx + 1])`. If the operator
|
||||
passes `pnpm check-env --file` (forgot the path), the script reads
|
||||
`process.env` instead of erroring.
|
||||
- Fix (Low): when `--file` is present but the next arg is missing,
|
||||
exit with code 2 + a "missing path" message.
|
||||
|
||||
### L-3 — Workouts canonical-row picker uses `<=` for the `±90 s` window — boundary inclusive
|
||||
|
||||
- `/Users/marc/Projects/HealthLog/src/lib/workouts/canonical-rows.ts:213`
|
||||
uses `Math.abs(...) <= WORKOUT_DEDUP_WINDOW_MS`. Documented contract
|
||||
in the JSDoc is `±90 s` — inclusive. Test
|
||||
(`canonical-rows.test.ts`) pins the inclusivity.
|
||||
- No defect; flagged Low because a future tightening to `<` would
|
||||
break the pinned test silently if the test only checks "two rows at
|
||||
exactly 90 s apart collapse to one survivor" without also checking
|
||||
"two rows at 91 s apart stay distinct". Both directions are pinned
|
||||
in the test file under "±90 s boundary inclusivity" so this is
|
||||
defended.
|
||||
|
||||
### L-4 — `WithingsApiError` swallows `body.error` only when it's a string
|
||||
|
||||
- `/Users/marc/Projects/HealthLog/src/lib/withings/client.ts:133` (and
|
||||
the parallel sites in `refreshAccessToken`, `fetchMeasurements`,
|
||||
`subscribeWebhook`, `unsubscribeWebhook`, `sync-activity.ts`,
|
||||
`sync-sleep.ts`) read `typeof json?.error === "string" ? json.error : undefined`.
|
||||
- If Withings ever returns `body.error` as an object (some endpoints
|
||||
do), the `upstreamError` slot stays empty and the audit-log line
|
||||
carries just `Withings <verb> error: <status>` without context.
|
||||
- Low risk because every documented Withings error response uses a
|
||||
string `error`. Could be tightened by stringifying via JSON.stringify
|
||||
when not a string.
|
||||
|
||||
## Strengths
|
||||
|
||||
- **`returnAllZodIssues` envelope** (`src/lib/api-response.ts:47`) is
|
||||
additive, privacy-conscious (drops `issue.params`), and the
|
||||
companion `sanitiseZodIssues` correctly factors out the projection
|
||||
so the widgets-route's audit ledger row never echoes raw user input.
|
||||
7-case unit test + 4-case route test + 3-case integration test —
|
||||
the contract is over-pinned.
|
||||
|
||||
- **Withings classifier** (`src/lib/withings/response-classifier.ts`)
|
||||
walks the three failure shapes (HTTP 5xx, HTTP 200 + non-zero
|
||||
status, HTTP 200 + body.status === 0 + empty groups) in the
|
||||
right order, defends against the literal 293/294 PERSISTENT_CODES
|
||||
precedence over the 200..299 reauth range, and the
|
||||
`subscribeWebhook` 294 idempotency downgrade lives at the
|
||||
call-site (where it belongs) rather than mutating the classifier
|
||||
verdict. 27 + 8 + 2 new tests across three files.
|
||||
|
||||
- **Workouts write-time canonical-row picker**
|
||||
(`src/lib/workouts/canonical-rows.ts`) shares the
|
||||
`DEFAULT_WORKOUT_SOURCE_PRIORITY` ladder with the read-time picker
|
||||
(single source of truth, no duplication), is a pure function with
|
||||
zero IO, and the 12-case test covers the source ladder, the
|
||||
±90 s boundary inclusivity, the calories tie-breaker, the
|
||||
createdAt tie-breaker, and the no-mutation invariant. The
|
||||
comparator's "annotated copy" trick at line 241 preserves the
|
||||
caller's row reference exactly — survivors carry through to the
|
||||
Prisma layer untouched.
|
||||
|
||||
- **`pickCanonicalWorkoutRows` integration into the batch route**
|
||||
(`src/app/api/workouts/batch/route.ts:273-340`) correctly hoists
|
||||
dedup BEFORE the existing externalId-based pre-flight, surfaces
|
||||
dropped twins as `duplicate` so iOS sync cursors advance
|
||||
identically, and the race-reconcile loop at line 513 was updated
|
||||
to walk `survivors` rather than `prepared` so write-dedup losers
|
||||
aren't double-counted toward the race-reconcile budget. Smart catch
|
||||
the phase report called out.
|
||||
|
||||
- **queryKey factory long-tail migration**
|
||||
(43 files + 9 factory entries + ESLint allowlist mirror + test-guard
|
||||
expansion) is methodical: every new entry has a JSDoc rationale,
|
||||
every consumer uses `setQueryData(queryKeys.X(), …)` symmetric with
|
||||
the read query, and the `ai-section.tsx` eight-call invalidation
|
||||
collapse via `replace_all` to `queryKeys.insightsRoot()` is a clean
|
||||
refactor. ESLint rule + test-guard regex both updated so the new
|
||||
scope can't drift back to bare literals.
|
||||
|
||||
- **Knip gate flip** (`.github/workflows/knip.yml:31` and `knip.json`)
|
||||
promotes exports + types tiers to enforcing with a precisely-scoped
|
||||
ignore-block (shadcn UI surface for library parity, validations for
|
||||
the iOS contract). Zero unused exports + zero unused types on the
|
||||
final commit. The reconcile-callout pattern — leave cross-wave dead
|
||||
re-exports to a follow-up cleanup commit (`dce14fb4`) before the
|
||||
gate flip — is the right merge-order discipline.
|
||||
|
||||
- **BERLIN_DAY_FORMATTER dedup** consolidates 7 × 20-LOC blocks into
|
||||
one `tz/resolver.ts` export with a JSDoc that correctly distinguishes
|
||||
Berlin-anchored cache keys from per-user `userDayKey(date, tz)`.
|
||||
Net −101 LOC and the test guard at
|
||||
`dashboard-suspense-boundaries.test.ts` confirms no behaviour drift.
|
||||
|
||||
- **doctor-report-data.ts control-byte fix** turns a "Binary files
|
||||
differ" diff into "Java source, Unicode text, UTF-8 text". Two
|
||||
characters changed, twelve months of diff-readability regained.
|
||||
|
||||
- **`offhost-backup.ts` DR-intent comment** documents the asymmetry
|
||||
between the disaster-recovery snapshot (includes soft-deleted) and
|
||||
the user-facing export (excludes soft-deleted) at the
|
||||
`prisma.measurement.findMany` call site, with a backreference to
|
||||
the symmetric exclusion in `/api/export/full-backup/route.ts`. The
|
||||
exact comment a future reviewer will need.
|
||||
|
||||
- **`pr-detection-worker.ts` soft-delete filter** is a two-line
|
||||
surgical fix (`deletedAt: null` added to two `where` clauses) that
|
||||
closes a real edge case (a deleted PR blocking promotion of the
|
||||
next-best row). 17 existing test cases still pass; no new tests
|
||||
needed because the existing soft-delete coverage already exercises
|
||||
the path.
|
||||
|
||||
- **Persistent classification** in `withings/sync.ts` +
|
||||
`integrations/status.ts` correctly maps `persistent` → state
|
||||
`error_transient` (so the next sync still runs) but carries
|
||||
`kind: "persistent"` into the audit row + admin-alert payload with
|
||||
a distinct "persistent error" label and "investigate the upstream
|
||||
contract" action line. Operators get the granular signal without
|
||||
the integration getting stuck on a one-off contract bug burst.
|
||||
|
||||
- **No commit drift** — every commit message in the v1.4.42 range is
|
||||
Marc-voice English, conventional-commit prefixed, no
|
||||
`Co-Authored-By: Claude`, no `--no-verify`, no `--no-gpg-sign`. The
|
||||
worktree-isolation hard rule
|
||||
(`feedback_marathon_worktree_isolation.md`) held across six parallel
|
||||
agents — fifth marathon in a row with this discipline now operational.
|
||||
@@ -0,0 +1,106 @@
|
||||
# v1.4.42 Design-review findings
|
||||
|
||||
## Verdict
|
||||
APPROVE
|
||||
|
||||
## Critical (visible regression — CLS / contrast / a11y break)
|
||||
None.
|
||||
|
||||
## High (placeholder mismatch, focus order, motion)
|
||||
None.
|
||||
|
||||
## Medium
|
||||
None.
|
||||
|
||||
## Low
|
||||
None.
|
||||
|
||||
## Strengths
|
||||
|
||||
- **Tile-strip placeholder now byte-for-byte matches the live `TrendCard` chrome.**
|
||||
`src/app/page.tsx:1416` — fallback className is
|
||||
`bg-card border-border flex h-full min-h-[6rem] w-full min-w-0 flex-col rounded-xl border p-4 md:p-6`.
|
||||
Compared against the live tile container at
|
||||
`src/components/charts/trend-card.tsx:241`
|
||||
(`bg-card border-border flex h-full w-full min-w-0 flex-col rounded-xl border p-4 md:p-6`),
|
||||
the placeholder is the same class set plus the explicit
|
||||
`min-h-[6rem]` for the all-suspend edge. Closes v1.4.41 L1 + L2
|
||||
follow-ups in one motion. No `gap-*` / `space-*` / `items-*` drift
|
||||
on the live tile — the children-positioning chrome (`flex
|
||||
min-w-0 flex-col`) is the entire layout contract on the
|
||||
`TrendCard` root.
|
||||
|
||||
- **`aria-hidden="true"` retained and still correct.** The placeholder
|
||||
is a self-closing `<div />` — no descendants exist that could be
|
||||
focusable, so adding `flex min-w-0 flex-col` cannot accidentally
|
||||
surface a tab stop. Focus order is unaffected.
|
||||
|
||||
- **Suspense double-comment consolidation is documentation-only.**
|
||||
`src/app/page.tsx:1402-1411` — the v1.4.40 W-RSC + v1.4.41
|
||||
W-FRONTEND-FACTORY block pair collapsed into one 9-line block with
|
||||
a single-line history trailer. JSX structure is byte-identical:
|
||||
the `<Suspense fallback={…}>{entry.node}</Suspense>` markup, the
|
||||
`<div key={entry.id} className="flex min-w-0">` wrapper, and the
|
||||
`trendCards.map` outer scaffold all stayed in place. The
|
||||
structural Suspense test pin
|
||||
(`src/app/__tests__/dashboard-suspense-boundaries.test.ts`) keys
|
||||
on the `aria-hidden="true"` marker and the `<Suspense>` count, not
|
||||
on comment text — stays green.
|
||||
|
||||
- **queryKey factory long-tail migration (`e2018b2d`) is a pure
|
||||
refactor across 40 files.** Every change in
|
||||
`src/components/{settings,medications,admin}`, `src/hooks/`, and
|
||||
three `src/app/medications` / `src/app/targets` files follows the
|
||||
same pattern: `queryKey: ["literal", …]` → `queryKey:
|
||||
queryKeys.<entry>(…)`. Spot-checked
|
||||
`src/components/settings/ai-section.tsx`,
|
||||
`src/components/settings/integrations-section.tsx`,
|
||||
`src/components/medications/medication-card.tsx`,
|
||||
`src/components/medications/glp1-medication-card.tsx`,
|
||||
`src/components/medications/intake-history-list-v2.tsx`,
|
||||
`src/components/admin/login-overview-section.tsx`,
|
||||
`src/hooks/use-workouts.ts` — every diff is a queryKey / invalidate
|
||||
call-site rewrite. No queryFn body, no JSX, no className, no copy,
|
||||
no enabled-gate, no select shape, no staleTime changed. Design
|
||||
surface is byte-identical.
|
||||
|
||||
- **Factory-entry shape parity is test-pinned.**
|
||||
`src/lib/__tests__/query-keys.test.ts:104-167` asserts the literal
|
||||
array shape for every new entry — `medicationCompliance` ↔
|
||||
`["medications", id, "compliance"]`, `withingsStatus` ↔
|
||||
`["withings", "status"]`, `adminAuditLogFiltered` ↔ 10-element
|
||||
array with `(filter, page, perPage, actor, actionFilter, target,
|
||||
range)` in the same positional order the prior bare-literal used.
|
||||
`workoutsRecentList(opts)` returns `["workouts", "recent", opts]`,
|
||||
matching the prior `[...queryKeys.workoutsRecent(), opts]` shape.
|
||||
Cache-slot identity preserved → no silent eviction on first paint
|
||||
after deploy.
|
||||
|
||||
- **iOS Workouts dedup is a write-side-only change.** The new
|
||||
`pickCanonicalWorkoutRows()` runs pre-`createMany` inside
|
||||
`src/app/api/workouts/batch/route.ts:255-303`. Losers surface to
|
||||
iOS as `{ index, status: "duplicate" }` — the exact per-entry
|
||||
envelope shape pinned by
|
||||
`src/app/api/workouts/__tests__/batch-create.test.ts`. The three
|
||||
pre-existing envelope tests were updated to space their two-row
|
||||
payloads outside the new 90 s dedup window so they continue to
|
||||
exercise envelope semantics rather than collide with the W5 pass.
|
||||
iOS UI is not touched.
|
||||
|
||||
- **Doctor-report PDF rendering path is unaffected by the regex
|
||||
rewrite.** `src/lib/doctor-report-data.ts:171` swaps a control-byte
|
||||
character class for the equivalent `[\x00-\x1F\x7F]`. Same regex
|
||||
semantics (NUL through US + DEL); the file just becomes UTF-8
|
||||
source rather than a binary blob. `sanitisePracticeName()` is
|
||||
consumed by `src/app/api/doctor-report/route.ts` and
|
||||
`src/app/api/doctor-report/pdf/route.ts` — the PDF renderer
|
||||
downstream sees identical bytes for any practice-name input.
|
||||
|
||||
- **No bare-literal queryKey leaks remain in the newly-guarded
|
||||
surface.** `eslint-plugins/healthlog/queryKey-factory.js` extended
|
||||
`GUARDED_DIRECTORIES` to cover the four migrated trees plus three
|
||||
named files; the lint rule + the in-repo factory-walker test
|
||||
(`src/lib/__tests__/query-keys.test.ts:263-290`) ensure a future
|
||||
PR introducing a bare-literal in `settings/`, `medications/`,
|
||||
`admin/`, or `src/hooks/` lands red before merge. Design tokens
|
||||
for cache invalidation are now a single-source-of-truth concern.
|
||||
@@ -0,0 +1,491 @@
|
||||
# v1.4.42 Product-lead strategic findings
|
||||
|
||||
Reviewer: Product Lead (read-only, strategic).
|
||||
Inputs: 6 v1.4.42 phase reports (W1-KNIP, W2-ZOD-MULTI-ISSUE,
|
||||
W3-QUERYKEY-LONGTAIL, W4-ARCH-HYGIENE, W5-IOS-WORKOUTS-DEDUP +
|
||||
DESIGN-POLISH, W6-WITHINGS-OFF + ENV-CHECK), the v1.4.41 product-lead
|
||||
backlog seed (B1-B10), `CHANGELOG.md` for v1.4.40 + v1.4.41 baseline,
|
||||
`v15-strategic-plan.md`, `.planning/v15-ios-handoff/03-api-contracts.md`
|
||||
+ `08-locked-contracts.md`, `git log 67207d72..develop` (22 commits on
|
||||
`develop` since the v1.4.41 release tag).
|
||||
|
||||
## Verdict
|
||||
|
||||
**APPROVE_WITH_FIXES** — code, contracts, and test posture are
|
||||
tag-ready. The same three release-endgame items that gated v1.4.41
|
||||
gate v1.4.42 (version bump, CHANGELOG entry, post-deploy verify
|
||||
plan). They are mechanical, not code-shape; the handoff-to-next-
|
||||
session pattern that worked for v1.4.41 applies cleanly here. None
|
||||
of the six waves introduces iOS contract drift; the v1.5 Swift sprint
|
||||
remains day-1-startable on top of the v1.4.42 deploy.
|
||||
|
||||
## Strategic posture
|
||||
|
||||
v1.4.42 is the **backlog-close polish release** on top of v1.4.41's
|
||||
iOS-handoff polish release. The release shape is even tighter than
|
||||
v1.4.41 (22 commits vs 27 vs v1.4.40's 54), and the changes cluster
|
||||
around three coherent themes:
|
||||
|
||||
1. **The v1.4.41 backlog seed (B1-B10) closes cleanly.** Six of the
|
||||
ten backlog items land in this release as discrete waves; three
|
||||
are confirmed already-done (no-op via grep verification in W4);
|
||||
one is correctly deferred to v1.4.43 (B10 worktree-isolation —
|
||||
the marathon was already mid-flight). This is the cleanest
|
||||
backlog-to-next-release conversion of the v1.4.39 / v1.4.40 /
|
||||
v1.4.41 sequence so far.
|
||||
|
||||
2. **The audit-ledger breadcrumb pattern is established.** W2's
|
||||
`/api/dashboard/widgets` PUT 422 now writes a
|
||||
`dashboard.widgets.validation-failed` audit row alongside the
|
||||
422 response. This is the structural foundation for moving 422
|
||||
diagnostics beyond the iOS dev console — when the v1.4.43 wave
|
||||
rolls the helper across the 41 remaining iOS-contract routes,
|
||||
every shape mismatch the iOS team encounters will have a
|
||||
permanent ledger breadcrumb without needing a separate logging
|
||||
ingest.
|
||||
|
||||
3. **iOS contract surface tightens without breaking.** W5's
|
||||
`pickCanonicalWorkoutRows()` lands the cross-source dedup helper
|
||||
that R-F open question #4 in the v1.5 plan anticipated; the iOS
|
||||
Workouts ingest path now drops duplicate Apple Watch × Withings
|
||||
ScanWatch rows server-side rather than relying on the iOS client
|
||||
to pre-merge. The dedup writes through the existing `duplicate`
|
||||
per-entry status enum so iOS clients that ride the v1.4.25
|
||||
workouts envelope keep working byte-identically.
|
||||
|
||||
The supporting waves (W1 knip gate flip, W3 long-tail factory
|
||||
migration, W4 tree-hygiene 5 items, W6 Withings off-response
|
||||
classification + pre-deploy env-check CLI) all close v1.4.41
|
||||
carry-over items. **Zero new feature surface beyond W5's helper.
|
||||
Zero new API contract beyond W2's additive `details.issues` field
|
||||
on one route. Zero new endpoint.** No env-var change. No Prisma
|
||||
migration. Same patch-bump-correctness posture as v1.4.41.
|
||||
|
||||
The release stays inside the conservative-semver doctrine
|
||||
(`feedback_semver_conservative.md`) — additive only, zero contract
|
||||
breaks, **v1.4.42 patch bump** is the correct call. v1.5 remains
|
||||
reserved for the iOS Swift sprint tag.
|
||||
|
||||
## Critical (must address before tag)
|
||||
|
||||
**None.** All shipped contracts hold; W1's knip gate is now
|
||||
enforcing, so no future push to main can sneak a dead export past
|
||||
the CI; W2's envelope is strictly additive and the iOS handoff doc
|
||||
at `03-api-contracts.md:1044` ("Surface the first issue from `error`
|
||||
— Zod messages are user-readable") keeps working byte-identically
|
||||
since `error` still carries the human-readable string. W3's factory
|
||||
migration is mechanical — 43 files, all guarded by both the ESLint
|
||||
rule and the test-walker so future regressions trip immediately.
|
||||
W4's tree-hygiene is touch-disjoint with the perf surface. W5's
|
||||
workouts dedup is write-time only and re-uses the existing
|
||||
`duplicate` per-entry status. W6's classification preserves the
|
||||
legacy regex consumers via fallback. Nothing blocks the tag from a
|
||||
code-shape perspective.
|
||||
|
||||
## High (should address before tag)
|
||||
|
||||
- **H1 — Version bump + CHANGELOG entry for v1.4.42 is not in
|
||||
develop tip.** `package.json` still reports `1.4.41`. CHANGELOG.md
|
||||
has no `[1.4.42]` heading. Same shape as v1.4.41's H1 — the
|
||||
marathon dispatch pattern lands the version bump + CHANGELOG entry
|
||||
in the release-tagging commit alongside the squash-to-main. The
|
||||
CHANGELOG should call out:
|
||||
- **W1 knip gate flip** as the structural headline (35 dead
|
||||
exports → 0, 52 dead types → 0, CI enforcing on every push to
|
||||
main).
|
||||
- **W2 multi-issue 422 helper** + audit-ledger breadcrumb on
|
||||
`/api/dashboard/widgets` as the iOS contract diagnostic
|
||||
improvement (closes the v1.4.41 H4 carry-over).
|
||||
- **W3 long-tail factory migration** (43 files, settings +
|
||||
medications + admin + hooks now factory-only, ESLint guarded).
|
||||
- **W4 tree-hygiene** (BERLIN_DAY_FORMATTER 7-way dedup, Suspense
|
||||
double-comment, doctor-report binary-blob fix, pr-detection
|
||||
soft-delete filter, offhost-backup DR-intent comment).
|
||||
- **W5 workouts cross-source dedup helper** (anticipates the iOS
|
||||
Workouts ingest sprint) + tile-strip placeholder polish.
|
||||
- **W6 Withings off-response classification** + `pnpm check-env`
|
||||
CLI as the iOS-perf / ops-hardening pair.
|
||||
Marc-voice / English / no PII per the established doctrine. The
|
||||
release notes should explicitly state that v1.4.42 is the
|
||||
**backlog-close polish release** on top of the v1.4.41
|
||||
iOS-handoff polish release, with the iOS Swift sprint freeze
|
||||
marker now confirmed as v1.4.42.
|
||||
|
||||
- **H2 — `pnpm check-env` should run on the v1.4.42 deploy itself.**
|
||||
W6's manifest covers Core / Withings OAuth / APNs / Deploy webhook
|
||||
/ Off-host backups. Running it pre-deploy against the live Coolify
|
||||
secret store would (a) catch any v1.4.40 AP-2 / .p8 regression
|
||||
before tagging, (b) prove the manifest's `anyOf [APNS_KEY,
|
||||
APNS_KEY_FILE]` rule against the real environment, (c) provide a
|
||||
baseline for the v1.4.43 CI integration. Cheap (one `pnpm
|
||||
check-env` invocation against the Coolify env-var dump) and exactly
|
||||
the use case the wave was designed for. **Mitigation if missed:**
|
||||
v1.4.40-shape regression slips again; the v1.4.43 CI integration
|
||||
catches it but only after the next release ships. Run it once
|
||||
pre-tag.
|
||||
|
||||
- **H3 — Post-deploy verify plan should pin the rollout-coverage
|
||||
invariant for the knip CI gate.** The gate is now enforcing on
|
||||
every push to main. The W1 report notes the `develop` tip is
|
||||
green with the documented ignore block; the very first push to
|
||||
main after the v1.4.42 tag will exercise the gate. Recommended
|
||||
path: run a dry-run `pnpm knip --reporter compact` on the
|
||||
squash-to-main commit before pushing; if it returns non-zero
|
||||
due to any reconciliation drift between W1 + W3 + W4 (the W1
|
||||
report explicitly calls out a "merge order" risk for the two
|
||||
re-exports `describeInjectionSite` + `listSupportedTimezones`),
|
||||
fix the residual drop before the push lands. The W1 report
|
||||
recommends "W1 last" as the merge order; the actual landed
|
||||
order was `876a545d` (W2) → `1ea0321d` + earlier (W4) →
|
||||
`e2018b2d` (W3) → `dce14fb4` + later (W1) so this should be
|
||||
green — verify once before tagging.
|
||||
|
||||
## Medium (next-version backlog candidates)
|
||||
|
||||
- **M1 — 41-route multi-issue 422 rollout.** W2's report enumerates
|
||||
every site at exact `file:line`. This is the v1.4.43 headline
|
||||
wave: take the helper W2 shipped and roll it across the 41
|
||||
remaining `parsed.error.issues[0].message` call sites, prioritised
|
||||
by iOS-contract proximity (measurements / medications / mood
|
||||
intake at the top). Bound to ~half a day of mechanical work;
|
||||
rolls in the audit-ledger breadcrumb on the iOS hot paths.
|
||||
|
||||
- **M2 — Withings sync-activity / sync-sleep catch-block migration
|
||||
to `err.classification` direct read.** Working today via the
|
||||
regex fallback in `classifyError`; the migration is code-cleanup
|
||||
only. Slot into v1.4.43 alongside M1 (both are mechanical) or
|
||||
defer to v1.4.44.
|
||||
|
||||
- **M3 — `parkIntegrationAtReauth` extension to cover persistent
|
||||
Withings failures > 24 h.** Today persistent failures stay at
|
||||
`state=error_transient` so the next sync runs; the 3-strike
|
||||
admin alert catches the burst either way. The actual behaviour
|
||||
Marc would want is: after 24 h of persistent failures, the
|
||||
integration parks at "reauth_required" so the user gets the
|
||||
in-app alert. Cheap (~30 LOC); v1.4.43 candidate.
|
||||
|
||||
- **M4 — CI integration for `pnpm check-env`.** W6 documents the
|
||||
skeleton; ship the GH Actions workflow + Coolify pre-deploy
|
||||
hook. Closes the operator-side gap that AP-2 originally
|
||||
exposed. v1.4.43 candidate.
|
||||
|
||||
- **M5 — `eslint-plugin-healthlog/queryKey-factory` expansion to
|
||||
remaining tree.** W3 closes the v1.4.41 M2 backlog item but
|
||||
the rule's `GUARDED_DIRECTORIES` still excludes `src/components/
|
||||
integrations/`, `src/app/**` (except the three specific files),
|
||||
and `src/components/insights/**`. Walk the remainder, add to
|
||||
the guarded list one chunk at a time. No urgency; pure surface
|
||||
cleanup. v1.4.44+ candidate.
|
||||
|
||||
- **M6 — Cross-tz proper-fix still v1.5.** Unchanged from
|
||||
v1.4.40 / v1.4.41. Marc's tenant is Berlin; runtime guard +
|
||||
live SQL fallback covers non-near-UTC tenants. Defers cleanly.
|
||||
|
||||
- **M7 — `pickCanonicalWorkoutRows()` ladder calibration.** W5's
|
||||
helper hardcodes the ladder `APPLE_HEALTH > WITHINGS > MANUAL >
|
||||
IMPORT`. Once iOS Workouts ingest goes live in v1.5 and Marc
|
||||
has real-world Apple Watch × Withings ScanWatch overlap data,
|
||||
the ladder order may need rebalancing (e.g. if Apple Watch
|
||||
routinely under-reports calories vs Withings). Surface as a
|
||||
v1.5.1 calibration item, not a v1.4.43 blocker.
|
||||
|
||||
## Low (defer)
|
||||
|
||||
- **L1 — knip exit-code semantics.** W1's gate flip means a future
|
||||
push to main carrying any new unused export trips CI red. The
|
||||
failure message points at the file + symbol but does not link
|
||||
to the `knip.json` ignore-block syntax. Future-paper-cut only;
|
||||
add a one-line README pointer in the next docs refresh.
|
||||
|
||||
- **L2 — JSDoc-block queryKey example formatting.** W3 reworded
|
||||
the `use-insights-analytics.ts` example to dodge the test-guard's
|
||||
line-comment-only regex. Test-guard could grow JSDoc-block regex
|
||||
support so the documentation intent doesn't need rewording. Pure
|
||||
hygiene; defer.
|
||||
|
||||
- **L3 — `WithingsApiError` prototype recovery in pg-boss serialised
|
||||
retries.** W6 covers this via the regex fallback in `classifyError`.
|
||||
Future-proofing concern: if pg-boss ever changes its serialisation
|
||||
format, the regex fallback could fail silently. Add a typed
|
||||
`serializedError` shape to the queue payload as a v1.5+ ops hardening
|
||||
item.
|
||||
|
||||
- **L4 — `workoutsRecentList` factory composite vs spread-and-append.**
|
||||
W3 replaced `[...queryKeys.workoutsRecent(), opts]` with a single
|
||||
factory entry. Future-paper-cut only: the composite pattern is the
|
||||
more correct factory shape; if any new code reaches for the
|
||||
spread-and-append shape it should be caught by ESLint immediately.
|
||||
No action.
|
||||
|
||||
## v1.4.43 backlog seed
|
||||
|
||||
- **C1 — 41-route multi-issue 422 rollout** (per M1). Take W2's
|
||||
helper across the iOS-contract hot paths (measurements / medications
|
||||
/ mood intake / dashboard chart-overlay-prefs / integrations
|
||||
HealthKit / ingest medication / tokens / devices) first. Med-CRUD
|
||||
+ auth/settings/admin/feedback/consent second. ~half a day of
|
||||
mechanical work; rolls in the audit-ledger breadcrumb pattern on
|
||||
every iOS hot path.
|
||||
|
||||
- **C2 — Withings classification consumer migration** (per M2).
|
||||
Drop the regex fallback in `sync-activity.ts` + `sync-sleep.ts`
|
||||
catch blocks; read `err.classification` directly.
|
||||
|
||||
- **C3 — `parkIntegrationAtReauth` for > 24 h persistent Withings
|
||||
failures** (per M3).
|
||||
|
||||
- **C4 — `pnpm check-env` CI integration** (per M4). GH Actions
|
||||
workflow against `.env.production.example`; Coolify pre-deploy
|
||||
hook against the live container.
|
||||
|
||||
- **C5 — Worktree isolation hard rule enforcement.** v1.4.42 was
|
||||
the second marathon since the rule landed in
|
||||
`~/.claude/skills/release-marathon/SKILL.md`. The W4 report shows
|
||||
one wave (`worktree-agent-ae49fe4059ee48d37`) ran without
|
||||
collision against W3 (`worktree-agent-a11813dd22a55cbf7`); the
|
||||
rule held. Future marathons must continue applying it. No action
|
||||
in v1.4.43 beyond "keep applying."
|
||||
|
||||
- **C6 — `eslint-plugin-healthlog/queryKey-factory` expansion**
|
||||
to remaining unguarded surfaces (per M5).
|
||||
|
||||
- **C7 — Long-window summary helper consumer (if missing).** W4
|
||||
reconfirmed that `computeLongWindowSummary` is already gone from
|
||||
the codebase (no matches). Carry-over item is closed. Drop the
|
||||
carry-over flag from the v1.4.43 backlog template.
|
||||
|
||||
- **C8 — `ensureUserMedicationComplianceFresh` decision.** Same
|
||||
shape as C7 — W4 reconfirmed already-gone. Drop from carry-over.
|
||||
|
||||
- **C9 — `pickCanonicalWorkoutRows()` ladder real-world
|
||||
calibration** (per M7) once iOS Workouts ingest deploys.
|
||||
|
||||
- **C10 — Tile-strip placeholder min-h verify on settled v1.5 RSC
|
||||
migration.** W5 added `min-h-[6rem]` matching live trend-card
|
||||
chrome. Once the RSC migration starts streaming tiles in v1.5+,
|
||||
verify that the placeholder holds the row open during real
|
||||
suspension (not just synthetic). No action in v1.4.43.
|
||||
|
||||
## v1.4.41 backlog (B1-B10) closure check
|
||||
|
||||
| # | Item | Status |
|
||||
| --- | --- | --- |
|
||||
| B1 | knip exports/types gate flip + ignore-block triage | **Closed** (W1 — gate enforcing, 0 unused exports, 0 unused types, ignore-block documented) |
|
||||
| B2 | `/api/dashboard/widgets` Zod-issue aggregation | **Closed** (W2 — `returnAllZodIssues` helper + widgets PUT migrated + audit-ledger breadcrumb + 14 tests) |
|
||||
| B3 | Long-tail queryKey factory migration | **Closed** (W3 — 43 files migrated, ESLint allowlist expanded, factory + tests pinned) |
|
||||
| B4 | `ensureUserMedicationComplianceFresh` decision | **Closed** (W4 confirmed already-gone via grep) |
|
||||
| B5 | `computeLongWindowSummary` production consumer | **Closed** (W4 confirmed already-gone via grep) |
|
||||
| B6 | `pickCanonicalWorkoutRows()` cross-source dedup helper | **Closed** (W5 — helper + 12 test cases + wired at `/api/workouts/batch`) |
|
||||
| B7 | Dashboard 422 multi-issue audit ledger breadcrumb | **Closed** (W2 — `dashboard.widgets.validation-failed` audit row writes; survives DB-write rejection per test) |
|
||||
| B8 | Pre-deploy Coolify env-var sanity check | **Closed** (W6 — `pnpm check-env` CLI + manifest + 16 test cases + operator doc) |
|
||||
| B9 | Withings off-response wave | **Closed** (W6 — classifier + typed `WithingsApiError` + 37 new tests + 3-state FailureKind extension) |
|
||||
| B10 | `release-marathon` skill worktree-isolation enforcement | **Carried forward** (rule landed in v1.4.41 handoff; v1.4.42 was the second marathon to apply it; rule held, no commit drift reported) |
|
||||
|
||||
Items added beyond B1-B10:
|
||||
|
||||
- **W5 tile-strip placeholder polish** — design-findings L1 + L2
|
||||
from `round-v1441-QA-design-findings.md` (not in B1-B10), closed
|
||||
with `min-h-[6rem]` + chrome utility parity.
|
||||
|
||||
- **W4 BERLIN_DAY_FORMATTER 7-way dedup** + Suspense double-comment
|
||||
consolidate + doctor-report binary-blob escape + pr-detection
|
||||
soft-delete filter + offhost-backup DR-intent comment — none of
|
||||
these were in B1-B10. They are tree-hygiene wins surfaced by the
|
||||
W4 wave's own discovery; commendable scope discipline (no creep
|
||||
beyond what's mechanically cheap to close).
|
||||
|
||||
Items deferred:
|
||||
|
||||
- **B10** (worktree-isolation hard rule) — verified held this
|
||||
marathon; carries forward as ongoing process discipline, not as
|
||||
a v1.4.43 backlog item.
|
||||
|
||||
## v1.5 readiness check
|
||||
|
||||
**Green. The iOS Swift sprint can start on top of `develop` tip
|
||||
without backend drift.**
|
||||
|
||||
The v1.5 strategic plan dated 2026-05-16 (`v15-strategic-plan.md`)
|
||||
treats v1.5 web as a "version-bump-only marker." The iOS-handoff
|
||||
items the plan called out for v1.4.30 / v1.4.31 / v1.4.32 have
|
||||
already shipped under v1.4.39 / v1.4.40 / v1.4.41; the locked
|
||||
contracts in `08-locked-contracts.md` hold.
|
||||
|
||||
**iOS contract status against `.planning/v15-ios-handoff/`:**
|
||||
|
||||
- **§ Workouts (POST /api/workouts/batch)** — W5 adds write-time
|
||||
cross-source dedup but preserves the per-entry envelope: dropped
|
||||
twins surface as `duplicate`, exactly the existing `EntryStatus`
|
||||
enum the iOS contract pins at `03-api-contracts.md:481` ("Response
|
||||
identical to measurements batch — per-entry status, race
|
||||
reconciliation"). Idempotency invariant `(userId, source,
|
||||
externalId)` composite unique index unchanged. iOS-side caller
|
||||
behaviour: zero diff. ✓ Additive-safe.
|
||||
|
||||
- **422 envelope shape** — W2 helper's response carries:
|
||||
```json
|
||||
{
|
||||
"data": null,
|
||||
"error": "Validation failed",
|
||||
"details": { "issues": [{ "path", "code", "message" }, …] },
|
||||
"meta": { /* optional */ }
|
||||
}
|
||||
```
|
||||
iOS handoff at `03-api-contracts.md:1044` says "Surface the first
|
||||
issue from `error` (Zod messages are user-readable)". The `error`
|
||||
field still carries a human-readable string. New iOS builds can
|
||||
branch on `body.details?.issues` for multi-field validation feedback
|
||||
but do not have to. ✓ Additive-safe; the locked-contracts §
|
||||
Zod-strict rule (no `.passthrough`) is untouched.
|
||||
|
||||
- **Withings sync surface** — W6 changes the typed `Error` shape
|
||||
thrown by client entrypoints, but the dispatched sync state +
|
||||
audit-row + admin-alert shape stays additive (`kind: "persistent"`
|
||||
is a new label inside the existing audit row; legacy `transient
|
||||
/ reauth_required` consumers keep working). iOS does not interact
|
||||
with the Withings sync surface directly; the integration status
|
||||
endpoint (`/api/integrations/withings`) shape is preserved. ✓ No
|
||||
contract drift.
|
||||
|
||||
- **All other waves (W1 knip, W3 queryKey, W4 tree-hygiene)** —
|
||||
zero API surface. ✓
|
||||
|
||||
**Open items that gate iOS sprint kickoff:** none. Every required
|
||||
backend surface is live or documented. The iOS team can start Day 1
|
||||
of the strategic plan's Track A (Coach SSE drawer), Track B
|
||||
(SyncMode + Workouts ingest), Track C (daily-stats per
|
||||
`HKStatisticsCollectionQuery`) against the v1.4.42 deploy.
|
||||
|
||||
**Open items the iOS sprint will likely surface but are not
|
||||
blockers:**
|
||||
|
||||
- 41-route multi-issue 422 rollout (M1 / C1). iOS dev console
|
||||
still sees single-issue messages on the 41 sibling routes; per
|
||||
the locked iOS handoff rule iOS surfaces `error` text verbatim,
|
||||
so this is a debug-time improvement only.
|
||||
- `pickCanonicalWorkoutRows()` ladder calibration (M7 / C9) once
|
||||
Apple Watch + Withings ScanWatch overlap data lands. Iterate
|
||||
in v1.5.1.
|
||||
|
||||
**Web-freeze trigger update:** the v1.5 plan's freeze marker was
|
||||
"v1.4.34 tag on main". Actual cadence: v1.4.39 + v1.4.40 + v1.4.41
|
||||
+ v1.4.42 all shipped past that marker as iOS-handoff / polish
|
||||
releases. **Recommended:** the v1.4.42 release notes should
|
||||
explicitly state that v1.4.42 is the actual freeze marker. The
|
||||
`v15-strategic-plan.md` document should be appended (not rewritten)
|
||||
with a "Cadence reality" section that resets the freeze trigger
|
||||
to v1.4.42. v1.4.43 (the 41-route rollout) should be the first
|
||||
post-freeze emergency-style patch — small, mechanical, no new
|
||||
surface.
|
||||
|
||||
**Net assessment:** the iOS Swift sprint is unblocked. The
|
||||
strategic plan's day-by-day schedule can begin against `develop`
|
||||
tip the day after v1.4.42 deploys. Confidence: high — the v1.4.42
|
||||
wave-level discipline (no Prisma migration, no env-var change, no
|
||||
new endpoint beyond W2's additive 422 envelope, every wave a small
|
||||
atomic worktree) is exactly the pre-iOS-sprint shape the strategic
|
||||
plan calls for.
|
||||
|
||||
## Strengths
|
||||
|
||||
- **Backlog-to-next-release conversion ratio is the cleanest yet.**
|
||||
Six of ten B1-B10 items close in this single release as discrete
|
||||
waves (B1 / B2 / B3 / B6 / B7 / B8 / B9). Two more (B4 / B5) are
|
||||
confirmed already-done via grep verification — discipline that
|
||||
prevents stale backlog drift. One (B10) is correctly classified
|
||||
as ongoing process. v1.4.41 → v1.4.42 is the proof that the
|
||||
marathon-handoff-doc → backlog-seed → next-release pattern
|
||||
scales. The release-marathon skill should reference this as the
|
||||
canonical example.
|
||||
|
||||
- **W1 knip gate flip is the structural payoff of 4 marathons.**
|
||||
v1.4.39 introduced the CI workflow staged. v1.4.40 enabled
|
||||
exports tier. v1.4.41 reduced exports 48 → 35 and types kept
|
||||
at 52. v1.4.42 drives both tiers to zero and flips the gate to
|
||||
enforcing. From this push forward, every PR to main carries an
|
||||
invariant: no dead exports or types reach the production tree.
|
||||
The ignore-block discipline (one-line rationale per entry, scoped
|
||||
to shadcn surface + zod-`infer` external API contract) keeps the
|
||||
gate from forcing artificial deletions.
|
||||
|
||||
- **W2 audit-ledger breadcrumb is the right shape for iOS
|
||||
diagnostics.** Writing a `dashboard.widgets.validation-failed`
|
||||
audit row alongside the 422 response means every shape mismatch
|
||||
carries a permanent server-side trace. The audit-row write is
|
||||
fire-and-forget — the 422 response is the contract, the audit
|
||||
row is debugging. Test pins both the success path and the
|
||||
audit-row-write-rejection survival. This is exactly how
|
||||
ledger-vs-contract separation should work.
|
||||
|
||||
- **W3 factory migration scope is responsibly bounded.** 43 files
|
||||
across settings + medications + admin + hooks + app pages.
|
||||
Every site routes through `queryKeys.<entry>()`. ESLint allowlist
|
||||
+ test-walker `guardedRoots` updated in lockstep so future
|
||||
regressions trip immediately. The factory adds 9 new entries
|
||||
(medication-compliance / titration / cadence / glp1-details /
|
||||
intake-drug-level-chart / intake-list / withings-status /
|
||||
admin-audit-log-filtered / workouts-recent-list) with
|
||||
byte-stable shapes pinned by test. Conservative, mechanical,
|
||||
reversible.
|
||||
|
||||
- **W4 tree-hygiene closes 5 disjoint items in one wave.** The
|
||||
BERLIN_DAY_FORMATTER 7-way dedup (-101 / +32 LOC) is the
|
||||
highest-leverage item — every future tz formatter change touches
|
||||
one file instead of seven. The Suspense double-comment consolidate
|
||||
is pure documentation hygiene. The doctor-report binary-blob fix
|
||||
is a small but real readability win (git diff goes from "Binary
|
||||
files differ" to readable text). The pr-detection-worker
|
||||
soft-delete filter closes a small gap (deleted PR could block
|
||||
next-best promotion). The offhost-backup DR-intent comment
|
||||
documents the deliberate asymmetry. All atomic, all reversible,
|
||||
all in one wave.
|
||||
|
||||
- **W5 helper anticipates the iOS sprint without forcing
|
||||
premature optimisation.** `pickCanonicalWorkoutRows()` is the
|
||||
helper the v1.5 plan R-F open question #4 named. 12 test cases
|
||||
cover empty input / same-source dup / cross-source overlap /
|
||||
no-overlap pass-through / multi-user isolation / multi-activity
|
||||
isolation / 4 tie-breakers / boundary inclusivity / determinism
|
||||
/ mutation-safety. Re-uses `DEFAULT_WORKOUT_SOURCE_PRIORITY`
|
||||
from the read-time picker so both write-time + read-time consult
|
||||
a single ladder constant — exactly the kind of cross-axis
|
||||
consistency a future ladder calibration needs.
|
||||
|
||||
- **W6 closes the AP-2 silent-disable root cause.** v1.4.40 AP-2
|
||||
cost three days of "iOS push isn't working" because a Coolify
|
||||
migration dropped three of four APNs env vars and the app booted
|
||||
cleanly. W6's `pnpm check-env` would have caught that pre-deploy.
|
||||
The manifest's `allOrNone` group catches the partial-set pattern
|
||||
exactly; the `anyOf [APNS_KEY, APNS_KEY_FILE]` rule handles the
|
||||
12-factor / filesystem variant cleanly. The classification of
|
||||
Withings off-responses into transient / reauth / persistent
|
||||
closes the long-standing "is this contract-breaking or upstream-
|
||||
outage" diagnostic ambiguity.
|
||||
|
||||
- **Conservative-semver call holds: patch bump (1.4.42) is correct.**
|
||||
Zero contract breaks; one new helper (`returnAllZodIssues`)
|
||||
with one consumer route migrated; one new helper
|
||||
(`pickCanonicalWorkoutRows`) wired into the existing workouts
|
||||
batch route; one new CLI (`pnpm check-env`) that does not affect
|
||||
runtime; structural refactors (knip flip, factory expansion,
|
||||
BERLIN_DAY_FORMATTER consolidation); Withings classification
|
||||
with full backward-compat fallback. Per
|
||||
`feedback_semver_conservative.md`, bugfix-heavy releases with
|
||||
additive-only features stay on patch. v1.5 stays reserved for
|
||||
iOS native launch.
|
||||
|
||||
- **The release is structurally smaller than v1.4.41 and that's
|
||||
the right shape.** v1.4.40 was 54 commits, v1.4.41 was 27,
|
||||
v1.4.42 is 22. Each release smaller, narrower, more reversible,
|
||||
closer to "the next polish wave" than "the next debt-close
|
||||
marathon." This is the exact cadence the v1.5 plan's freeze
|
||||
marker assumed; v1.4.42 should be the explicit freeze marker.
|
||||
|
||||
- **Six wave reports + one product-lead seed from the prior
|
||||
release — the audit trail keeps compounding.** A fresh session
|
||||
can pick up at the release endgame with full context in under
|
||||
an hour. The marathon handoff-doc → backlog-seed → wave-reports
|
||||
→ next-marathon loop has now operated cleanly across five
|
||||
consecutive marathons (v1.4.38 / v1.4.39 / v1.4.40 / v1.4.41 /
|
||||
v1.4.42). The operational discipline is what makes the
|
||||
multi-marathon cadence sustainable past v1.5.
|
||||
@@ -0,0 +1,97 @@
|
||||
# v1.4.42 Security-review findings
|
||||
|
||||
Scope: `git diff main..develop` (commits after `67207d72` — v1.4.42 vs the
|
||||
live v1.4.41 tag).
|
||||
Reviewer: W10 security-reviewer (read-only).
|
||||
|
||||
## Verdict
|
||||
APPROVE
|
||||
|
||||
Every focus area (returnAllZodIssues, Withings off-response classifier,
|
||||
write-time workout dedup, check-env CLI, knip ignore-block, re-export
|
||||
cleanup) passes. No Critical / High findings. Two Lows are noted as
|
||||
follow-ups for v1.4.43 — neither blocks the tag.
|
||||
|
||||
## Critical (must fix before tag — vuln / data leak / auth bypass)
|
||||
|
||||
None.
|
||||
|
||||
## High (should fix before tag — defence-in-depth gap, log leak, error swallow)
|
||||
|
||||
None.
|
||||
|
||||
## Medium (recommended for tag)
|
||||
|
||||
None.
|
||||
|
||||
## Low (defer to v1.4.43)
|
||||
|
||||
### L-1 `dispatchNotification` / `formatAdminAlertPayload` carry the raw Withings `json.error` string up to a 280-char Telegram body
|
||||
- **File**: `src/lib/integrations/status.ts:505-547` (untouched in v1.4.42)
|
||||
consumed by the new `WithingsApiError` path in `src/lib/withings/client.ts:127-135, 170-178, 290-298, 374-382`.
|
||||
- **Attack vector**: The new `upstreamError: typeof json?.error === "string" ? json.error : undefined`
|
||||
branch in every client entrypoint pipes Withings' own response-body
|
||||
`error` field into the thrown `WithingsApiError.message`. That message
|
||||
flows unchanged into:
|
||||
1. `AuditLog.details` via `auditLog("integrations.sync.failed", { details: { message, … } })` — **NOT** encrypted, **NOT** length-capped. Only the encrypted IntegrationStatus column is sliced to 1024 chars via `safeEncryptError`.
|
||||
2. `formatAdminAlertPayload`, which trims to 280 chars before pushing to Telegram.
|
||||
Withings is the trusted upstream, so this is not a network-attacker
|
||||
surface. A compromised or buggy Withings response *could* land an
|
||||
arbitrarily large `error` string into the audit table for any
|
||||
connected user — bounded only by Postgres TEXT column limits. Not
|
||||
exploitable today; recorded so the next reader doesn't assume the
|
||||
upstream string is trusted-and-bounded everywhere.
|
||||
- **Recommended fix** (defer): apply the same `slice(0, 1024)` cap inside the `WithingsApiError` constructor (or at the `auditLog` call-site) so the audit-row payload size matches the encrypted-error column behaviour. Belt-and-braces; the practical impact today is zero.
|
||||
|
||||
### L-2 `pickCanonicalWorkoutRows` collapses two genuine back-to-back same-source same-activity rows at ≤ 90 s `startedAt` delta
|
||||
- **File**: `src/lib/workouts/canonical-rows.ts:128-130, 192-259`, batch path `src/app/api/workouts/batch/route.ts:273-298`.
|
||||
- **Attack vector**: This is not a security vuln — it's a behavioural
|
||||
edge-case noted because the focus-area brief asked. Two intentional
|
||||
HIIT intervals stamped 30–90 s apart with the same `activityType`
|
||||
+ same `source` collapse to one. The 90 s window is documented as
|
||||
"covers HK's ±60 s smoothing + buffer" and is enforced via inclusive
|
||||
`<=` (test: `treats a row exactly at the ±90 s boundary as in-window`).
|
||||
No malicious-injection risk: the batch route forces `userId: user.id`
|
||||
from the authenticated session (`src/app/api/workouts/batch/route.ts:228`),
|
||||
so a forged-source row in one batch can only suppress *another row
|
||||
in the same caller's batch* — never a victim user's row (`(userId, source, externalId)` composite unique + the picker grouping key both include `userId`).
|
||||
- **Recommended fix** (defer): the iOS contract guarantees each
|
||||
HKWorkout carries a distinct `endedAt` / `durationSec`; the picker
|
||||
could optionally tighten its grouping key to `(activityType, startedAt ± 90 s, endedAt ± 90 s)` so two back-to-back intervals survive even when their starts are 60 s apart. Not required — the legacy read-time picker has the same window for the same reason, and the workout-card UX merges intervals into one row by design.
|
||||
|
||||
## Strengths
|
||||
|
||||
1. **`returnAllZodIssues` is correctly sanitised + correctly scoped.**
|
||||
- `sanitiseZodIssues` returns *only* `{ path: issue.path.join("."), code, message }`. The privacy comment in `src/lib/api-response.ts:9-19, 33-36` explicitly calls out `issue.params` as the data-leak surface, and the test `does NOT echo issue.params (privacy: may contain user input)` (`src/lib/__tests__/api-response-zod.test.ts:84-100`) asserts `Object.keys(issue).sort() === ["code", "message", "path"]`.
|
||||
- The `path.join(".")` step produces a plain string — no prototype access happens client-side because the consumer reads `body.details.issues[*].path` as a flat field. Verified via `node -e 'const o = {}; o["__proto__"] = "evil"; console.log(o["__proto__"]);'` → bracket assignment to `__proto__` is silently dropped, so a forged path `["__proto__"]` cannot pollute Object.prototype anywhere downstream.
|
||||
- Audit-ledger write in `src/app/api/dashboard/widgets/route.ts:141-157` is scoped to `userId: user.id` (server-resolved from `requireAuth()`); persisted shape is `JSON.stringify({ issues })` where `issues` is the sanitised projection. The integration test `writes one audit-ledger row keyed dashboard.widgets.validation-failed` (`src/app/api/dashboard/widgets/__tests__/route.test.ts:123-145`) asserts `Object.keys(issue).sort() === ["code", "message", "path"]` for every persisted entry.
|
||||
- Fire-and-forget audit write is `.catch(() => {})` — a DB hiccup cannot 5xx the 422 contract response (test `does not block the 422 response when the audit-row write rejects`).
|
||||
|
||||
2. **Withings off-response classifier is a strict whitelist + conservative default.**
|
||||
- Explicit `REAUTH_CODES = {100, 101, 102}` and `PERSISTENT_CODES = {293, 294}` checks run BEFORE the 200..299 range fallback, so a contract-mismatch code that happens to sit inside the OAuth range stays `persistent` instead of incorrectly bucketing as `reauth_required` (`src/lib/withings/response-classifier.ts:173-208`).
|
||||
- `persistent` state cannot be triggered by a forged webhook to suppress a victim user's data: the classifier reads Withings' own HTTP response, not the webhook body. The webhook handler `src/lib/withings/webhook-handler.ts:89-160` is authorised via path-segment shared secret + per-IP rate-limit; even with the secret, all it can do is enqueue a real Withings API call for an already-mapped `withingsUserId`. The classification verdict is then driven by Withings' authentic response, not by the attacker.
|
||||
- Refresh-token never lands in the error path: `WithingsApiError` carries only `{ verb, classification, withingsStatus, reason, upstreamError }` where `upstreamError` is restricted to `typeof json?.error === "string"`. The refresh token is only POSTed to Withings' token URL; it never appears in any `getEvent()` annotation, `addWarning`, `addExternalCall`, audit row or alert payload (verified by grep on `refreshToken`/`refresh_token` against the diff and against the sync paths).
|
||||
- `WithingsApiError.message` format stays compatible with the legacy `extractWithingsStatus` regex — `classifyError` falls back to regex parsing for pg-boss job-retry survivors whose prototype was lost in JSON round-trip (covered by `src/lib/withings/__tests__/response-classifier.test.ts:166-211`).
|
||||
|
||||
3. **Write-time workout dedup is user-scoped and tamper-proof.**
|
||||
- Group key includes `userId` (`src/lib/workouts/canonical-rows.ts:210-219`), pinned by the test `returns rows from different users untouched even on identical timestamps` (`canonical-rows.test.ts:113-130`).
|
||||
- The batch route always overrides client-supplied `userId` with the authenticated `user.id` (`src/app/api/workouts/batch/route.ts:228`), so a malicious client cannot mint a payload row that addresses another user's account.
|
||||
- A user who submits a forged `APPLE_HEALTH` row to outrank a `MANUAL` row can only suppress *their own* `MANUAL` row inside the same batch — there is no cross-user trust boundary the source-ladder is defending. (And: the legacy read-time picker on `GET /api/workouts` collapses cross-batch duplicates afterwards via the same ladder, so a future poisoning of the source field would surface across reads anyway.)
|
||||
|
||||
4. **`pnpm check-env` does not log or print secret values.**
|
||||
- `renderResults` emits only `[OK] <NAME>` / `[MISSING-REQUIRED] <NAME>` / `[missing-optional] <NAME>` — no value substitution path (`scripts/check-env.ts:177-207`).
|
||||
- `parseEnvFile` writes the file contents into a plain object and `isPresent` only checks `typeof value === "string" && value.trim().length > 0`; nothing reads back the value to stdout.
|
||||
- The `--file` flag controls only the env-source, never the manifest path (`MANIFEST_PATH = resolve(__dirname, "env-manifest.json")` is module-frozen). A malicious env-file with a `__proto__=evil` line is also a non-issue: bracket assignment to `__proto__` is silently dropped by V8 (verified via repro), and even if it landed, `env[n]` reads through bracket access with `n` sourced from the manifest only.
|
||||
- Operator-only CLI; no network-attacker surface.
|
||||
|
||||
5. **Knip ignore-block scope is correct.**
|
||||
- `src/components/ui/**` ignores `exports` + `types` only. Verified by `ls` — every file in the directory is a pure shadcn UI primitive (`alert-dialog`, `dialog`, `dropdown-menu`, `input`, `password-input`, `password-strength`, `select`, `sheet`, `switch`, `table`, `tabs`, `tooltip`, …). No auth, no crypto, no input-validation helper hides under the shadcn prefix.
|
||||
- `src/lib/validations/**` ignores `types` only (NOT `exports`). So any genuinely-unused export *would* still trip CI; the ignore-block only spares the Zod-derived `z.infer<typeof …>` aliases that the API contract publishes for the iOS client. Every file in the directory is a Zod schema module by design.
|
||||
- The companion knip flip (`20df7b9d`) drops the `--include files,dependencies,binaries,unlisted` scope on the workflow; the gate now runs against every issue knip reports by default. Two ignore-blocks are now the single point of truth for "what knip is muted on."
|
||||
|
||||
6. **Re-export cleanup (`dce14fb4`) is complete.**
|
||||
- `grep -rn "listSupportedTimezones\|describeInjectionSite"` confirms every real caller already imports from the source path (`@/lib/tz/format` / `@/lib/medications/injection-sites`). No dynamic-import string contains either name.
|
||||
|
||||
---
|
||||
|
||||
50-word summary follows the report.
|
||||
@@ -0,0 +1,164 @@
|
||||
# v1.4.42 Senior-dev architectural findings
|
||||
|
||||
Branch under review: `develop` vs live `main` (v1.4.41). ~30 commits after the v1.4.41 tag `67207d72`.
|
||||
Read-only review. No files modified.
|
||||
|
||||
## Verdict
|
||||
|
||||
**APPROVE_WITH_FIXES**
|
||||
|
||||
The release is architecturally sound. The five named waves (BERLIN_DAY_FORMATTER dedup, write-time canonical-workout picker, Withings off-response classifier, `pnpm check-env`, knip gate flip, queryKey long-tail migration) all land with clean discipline and substantial test coverage (4 525 → 4 744 unit). The v1.4.41 L1 finding (PR-detection soft-delete) and M2 finding (ESLint guard / test-guard scope drift) are both closed in this release. None of the findings below are release-blockers, but the **High** below directly undermines the named motivation of the `check-env` work and should land before the v1.4.42 tag.
|
||||
|
||||
## Critical
|
||||
|
||||
(none)
|
||||
|
||||
## High
|
||||
|
||||
### H1. `pnpm check-env` does NOT catch the v1.4.40 AP-2 silent-disable scenario it was built to catch
|
||||
|
||||
**Files:** `scripts/env-manifest.json:57-80`, `docs/ops/env-check.md:52-72`
|
||||
|
||||
**Root cause:** the APNs group in `scripts/env-manifest.json` is declared `"required": false` without `"allOrNone": true`. The synthetic `<all-or-none>` severity-REQUIRED row in `scripts/check-env.ts:147-166` is gated on `group.allOrNone === true` AND a partially populated group. The APNs group is the v1.4.40 AP-2 motivator (cited verbatim in the group description: "All four core APNS_* vars together. Missing any disables APNs silently — the v1.4.40 AP-2 gap that motivated this manifest") but its flag is not set.
|
||||
|
||||
Walk-through of the AP-2 scenario the manifest is supposed to catch — three APNS_* vars set, `APNS_KEY` / `APNS_KEY_FILE` both missing:
|
||||
|
||||
| Variable | Status |
|
||||
| ----------------- | ------------------------------------------------------------ |
|
||||
| `APNS_KEY_ID` | `[OK]` |
|
||||
| `APNS_TEAM_ID` | `[OK]` |
|
||||
| `APNS_BUNDLE_ID` | `[OK]` |
|
||||
| `APNS_KEY` (anyOf `APNS_KEY_FILE`) | `[missing-optional]` |
|
||||
|
||||
`missingRequired = 0` → **exit 0** → deploy fires green. This is exactly the silent-disable shape that bit the operator for three days in v1.4.40.
|
||||
|
||||
The docs at `docs/ops/env-check.md:60-72` make the gap visible — the worked example shows the exact AP-2 case landing as `[missing-optional]` (not `[MISSING-REQUIRED]`) while the surrounding text claims it's "the v1.4.40 AP-2 detection pattern". The doc and the manifest disagree about whether the case fails.
|
||||
|
||||
**Recommended fix:** add `"allOrNone": true` to the APNs group in `scripts/env-manifest.json:60`. With the flag set, the `checkEnv` walker emits a `<all-or-none>` synthetic row (`required: true`) when 1–3 of the 4 vars are set, the deploy gate flips to exit 1, and the AP-2 motivator actually works. The unit test at `scripts/__tests__/check-env.test.ts` already covers the all-or-none synthetic-row path (via the off-host-backups group), so the regression test surface is in place — only the manifest entry needs to flip.
|
||||
|
||||
**Why High not Critical:** the script is a CLI gate, not a runtime block — the app still boots without it. But the entire `check-env` wave was conceived (per the commit body, the group description, the docs example) as the prevention of exactly this case, and shipping it without the fix means the v1.4.40 AP-2 silent-disable still ships silently if the same env-block misedit recurs.
|
||||
|
||||
## Medium
|
||||
|
||||
### M1. `pickCanonicalWorkoutRows` write-time picker bypasses the user's per-metric `sourcePriorityJson`
|
||||
|
||||
**Files:** `src/lib/workouts/canonical-rows.ts:73, 138-141`, `src/app/api/workouts/batch/route.ts:273-290`, `src/lib/measurements/pick-canonical-workout-rows.ts:95-96`
|
||||
|
||||
**Root cause:** the write-time picker reads the source ladder from the `DEFAULT_WORKOUT_SOURCE_PRIORITY` *constant* (`["APPLE_HEALTH", "WITHINGS", "MANUAL", "IMPORT"]`). The read-time picker reads it from `getSourceLadder(parseSourcePriority(userPriorityJson), "steps")` — i.e. the user's persisted preference under the `steps` key. A user who customized their Settings → Sources surface to promote `MANUAL` above `APPLE_HEALTH` would see:
|
||||
|
||||
| Phase | What it does on an Apple Watch + Manual cross-source pair within 90 s |
|
||||
| ----------- | --------------------------------------------------------------------- |
|
||||
| Write-time | `APPLE_HEALTH > MANUAL` (constant ladder) → keeps the Apple Watch row, drops Manual |
|
||||
| Read-time | `MANUAL > APPLE_HEALTH` (user ladder) → would have picked Manual |
|
||||
|
||||
The Manual row is gone from the DB before read-time ever sees it. The user's customization is silently overridden.
|
||||
|
||||
The phase-W5 report (`.planning/phase-W5-IOS-WORKOUTS-v1442-report.md:14-18`) and the helper docstring (`src/lib/workouts/canonical-rows.ts:46-70`) both characterise this as parity: "both pickers consult a single source of truth". That is true at the constant-vs-constant level, but is false at the constant-vs-user-priority level.
|
||||
|
||||
**Effect:** scope-narrow today — `DEFAULT_SOURCE_PRIORITY.steps` in `src/lib/validations/source-priority.ts:206` is `["APPLE_HEALTH", "WITHINGS", "MANUAL"]` (only three entries, no `IMPORT`), and most iOS-paired users keep the Apple-first default. But the moment a user customizes, the write-time picker silently disregards them.
|
||||
|
||||
**Recommended fix (v1.4.43 or later):** either
|
||||
- (a) document the divergence explicitly in the helper docstring + the W5 report: "the write-time picker uses the canonical ladder regardless of user customization; the read-time picker re-applies user customization at read time, so a customised ladder still surfaces the user's preferred winner if both rows survive write-dedup" — except that's a no-op once the write-time picker has dropped the user's preferred row;
|
||||
- (b) accept the small ingest-path cost (one user lookup) and resolve the user's ladder before the picker pass. The batch route already has the user id; the lookup is one indexed read against `User.sourcePriorityJson`.
|
||||
|
||||
(b) is the architecturally correct call for v1.4.43; (a) is a documentation patch that should land in this release.
|
||||
|
||||
### M2. `consecutiveFailures` counter blends `transient` and `persistent` kinds — alert-payload "kind" reflects last failure only
|
||||
|
||||
**File:** `src/lib/integrations/status.ts:228-294`
|
||||
|
||||
**Root cause:** `recordSyncFailure` increments `consecutiveFailures` on every call regardless of `kind`. When the 3-strike threshold trips at `row.consecutiveFailures >= threshold`, the alert payload (`maybeAlertAdmins` → `formatAdminAlertPayload`) is constructed from the CURRENT call's input — i.e. the kind of the *most recent* failure. A real-world burst like:
|
||||
|
||||
- t0: Withings 293 (`persistent`) → consecutiveFailures = 1
|
||||
- t1: 503 network blip (`transient`) → consecutiveFailures = 2
|
||||
- t2: 503 network blip (`transient`) → consecutiveFailures = 3 → alert fires with `kind: "transient"` and action "investigate the upstream service"
|
||||
|
||||
The admin gets a transient-bucket alert with action text "investigate the upstream service" when the actual root cause was a `293` contract bug, because the latest failure happened to be transient.
|
||||
|
||||
**Effect:** Low UX-of-ops paper-cut — operators routinely re-grep audit logs once paged, so the wrong action line just delays the diagnosis by one query. Not a behavioural bug.
|
||||
|
||||
**Recommended fix:** track a separate per-kind counter or carry the most-severe kind from the last N failures into the payload. Defer to v1.4.43 backlog; not worth blocking the release.
|
||||
|
||||
## Low
|
||||
|
||||
### L1. Knip ignore-block `src/components/ui/**: ["exports", "types"]` is broader than just shadcn surface
|
||||
|
||||
**File:** `knip.json:36-39`
|
||||
|
||||
**Root cause:** the ignore-block muses both `exports` and `types` for every file under `src/components/ui/**`. The W1-KNIP rationale (`./.planning/phase-W1-KNIP-v1442-report.md:53-60`) explicitly lists shadcn registry symbols that need the mute. But `src/components/ui/` also carries HealthLog-specific components that are NOT in the upstream shadcn registry: `empty-state.tsx`, `logo.tsx`, `password-input.tsx`, `password-strength.tsx`, `date-input.tsx`, `responsive-sheet.tsx`. Any future export added to those files would silently bypass knip's dead-code check.
|
||||
|
||||
**Effect:** Low. The path-scope choice is documented and the workaround is one line (move HealthLog-specific UI elsewhere — e.g. `src/components/ui-x/` for "extensions"). The footgun is small in practice because the affected files are few and largely stable. But the comment on the ignore-block says "mute shadcn surface" while the path glob is broader than just shadcn.
|
||||
|
||||
**Recommended fix:** either narrow the ignore-block to specific filenames (the shadcn-only files) or move HealthLog-specific components to a sibling directory so the broad-glob mute remains shadcn-only. Defer to v1.4.43.
|
||||
|
||||
### L2. `pickCanonicalWorkoutRows` complexity is O(n²) — fine for batch ≤ 100, but the comment is misleading
|
||||
|
||||
**File:** `src/lib/workouts/canonical-rows.ts:188-224`
|
||||
|
||||
**Root cause:** the helper walks `tagged` and for each entry walks every existing `group` looking for a match — that's O(n × g) where g is the number of distinct groups. The docstring at line 190 says "typical batch is ≤ 100 entries so the constant is fine" which is true today. But the route's hard cap is `MAX_WORKOUTS_PER_BATCH = 100` (see `src/lib/validations/workout.ts`), so worst case is 100 × 100 = 10 000 group-comparison ops — completely negligible. The misleading bit: the comment says "O(n²) worst case" but worst case is actually O(n²) only when every row seeds its own group (all distinct activityType + startedAt), and best case is O(n) when every row joins the first group.
|
||||
|
||||
This is a cosmetic-only docstring nit. Algorithm is correct.
|
||||
|
||||
**Recommended fix:** tighten the docstring to "worst case O(n × g) where g ≤ n; for the 100-row batch cap that's bounded at 10 000 group-comparison ops". No code change.
|
||||
|
||||
### L3. `WithingsApiError` is re-thrown across pg-boss retries — prototype loss is the documented case, but `WithingsApiError.cause` chain is not preserved
|
||||
|
||||
**File:** `src/lib/withings/response-classifier.ts:233-256, 269-281`
|
||||
|
||||
**Root cause:** `WithingsApiError` extends `Error` with three readonly fields (`classification`, `withingsStatus`, `reason`, `verb`). When pg-boss rehydrates a job, it serialises the failure via `JSON.stringify` of the thrown error — only `message` survives. The fallback regex at `classifyError(err)` recovers `classification` from `message`, which works.
|
||||
|
||||
But `WithingsApiError.cause` (the original `fetch` error, if any) is never set. If a future debugging surface wants to walk `err.cause` to find the underlying network failure, the chain is broken at the rethrow site. Today no consumer reads `cause`, so this is purely forward-looking.
|
||||
|
||||
**Recommended fix:** add `cause?: unknown` to the constructor opts and pass it through via `super(message, { cause })`. Defer to v1.4.43.
|
||||
|
||||
### L4. `workoutsRecentList` cache-key opts object — `sportType: undefined` vs `sportType` absent may produce two cache entries
|
||||
|
||||
**File:** `src/lib/query-keys.ts:229-234`, `src/hooks/use-workouts.ts:95-100`
|
||||
|
||||
**Root cause:** `queryKeys.workoutsRecentList({ limit, offset, since, sportType })` is invoked in `useWorkouts` with all four keys present even when the caller passed only `{ limit: 10 }`. TanStack Query hashes the queryKey via `hashKey` (stable JSON) — `undefined` properties are stripped by `JSON.stringify`, so `{limit:10, offset:undefined, since:undefined, sportType:undefined}` and `{limit:10}` hash to the same key. Two callers passing the same logical params will share a cache slot.
|
||||
|
||||
But: if a future caller passes `null` for one of these fields (instead of `undefined`), the hash would diverge — `null` survives JSON.stringify. The hook never normalises before passing to the factory, so a single typo (`null` vs `undefined`) silently double-caches. Pre-empt: the hook should normalise to drop `undefined`/`null` symmetrically before invoking the factory.
|
||||
|
||||
This is forward-looking — today no caller passes `null`, so no actual cache collision exists.
|
||||
|
||||
**Recommended fix:** add an inline comment in the hook stating that all four keys must be undefined-or-string-or-number (no nulls); defer the runtime normalisation to v1.4.43.
|
||||
|
||||
### L5. `recordSyncFailure` audit row is `await`-ed before the alert-dispatch path — a slow audit insert delays admin paging
|
||||
|
||||
**File:** `src/lib/integrations/status.ts:250-260, 269-294`
|
||||
|
||||
**Root cause:** the `await auditLog(...)` at line 250 runs serially before the alert-dispatch decision. The audit insert is one Prisma `auditLog.create` — typically <10 ms — but on a slow DB it adds latency to the admin-page path. A connection-pool starvation event (the exact case that motivates an admin alert) could leave the alert waiting on its own audit insert.
|
||||
|
||||
The docstring at line 246-249 notes this intentionally: "fire-and-await so an integration test can assert it. The auth/audit helper is its own DB write so it's safe to call serially without bloating latency in the success path (which never calls this)." That's true for the success path, but the *failure path* is the one where DB-pool starvation is most likely.
|
||||
|
||||
**Recommended fix:** none today. The trade-off is acknowledged in the docstring. If the audit-before-alert ordering ever becomes load-bearing, swap to `Promise.allSettled([auditLog(...), maybeAlertAdmins(...)])` and accept the audit-row-arrives-after-alert inversion.
|
||||
|
||||
## Strengths
|
||||
|
||||
- **BERLIN_DAY_FORMATTER dedup is byte-identical.** The extracted `toBerlinDayKey()` at `src/lib/tz/resolver.ts:58-87` ships the same `Intl.DateTimeFormat` options (`"en-US"`, `"Europe/Berlin"`, `2-digit` month/day), the same `formatToParts` walk, and the same `throw new Error("Could not derive Berlin day key")` on missing parts. All seven status helpers (`bmi`, `blood-pressure`, `general`, `medication-compliance`, `mood`, `pulse`, `weight`) import from the same module and call sites are unchanged. DST safety is preserved (formatter handles the transition transparently via the `Europe/Berlin` zone).
|
||||
|
||||
- **Write-time / read-time canonical-workout picker layering is correct.** The 90 s vs 5 min window choice is justified in both docstrings: same-batch rows from one iOS client have no cross-device clock skew (HK aggregates paired sensors to the same `startDate`), so 90 s covers the ±60 s smoothing plus a small buffer; cross-batch rows from independent ingest paths need the 5 min slack. The two pickers compose: write-time drops same-batch dup before persistence (storage savings), read-time still collapses cross-batch dup on the rare timing edge (correctness).
|
||||
|
||||
- **`STRAVA_IMPORT` → `IMPORT` enum-rename is documented at the right level.** The docstring at `src/lib/workouts/canonical-rows.ts:52-55` explicitly notes the scope-memo wording vs the actual `MeasurementSource` enum. There is no `STRAVA_IMPORT` variant in `prisma/schema.prisma:406-413`; Strava XML imports ride the generic `IMPORT` bucket. The agent's note is correct and the helper carries the right ladder.
|
||||
|
||||
- **`duplicate` semantic the iOS client treats as "cursor advances".** Confirmed against `/Users/marc/Projects/healthlog-iOS/HealthLogIOS/HealthLog/Models/HealthKitBatchDTO.swift:62-68`: the iOS measurement-batch handler explicitly comments "`inserted` UND `duplicate` UND `skipped` sind allesamt terminal — Cursor fährt …". The workout-batch handler on iOS does not yet exist in the main branch, but the route convention is intentionally aligned with the measurement batch so the future iOS workout uploader can reuse the same cursor advance rule.
|
||||
|
||||
- **`FailureKind` extension is non-breaking.** Surveyed every production-code consumer (`src/lib/withings/sync.ts`, `src/lib/withings/sync-activity.ts`, `src/lib/withings/sync-sleep.ts`, `src/lib/moodLog/sync.ts`) — none switch-narrow on the union exhaustively; all pass it as a literal-or-mapped value into `recordSyncFailure`. The Telegram bot and APNs paths don't import `FailureKind` at all. The admin-alert formatter at `src/lib/integrations/status.ts:505-547` handles all three cases with distinct labels ("re-auth required" / "persistent error" / "transient error") and distinct action lines.
|
||||
|
||||
- **`classifyError(err)` regex fallback is the safe default.** When pg-boss rehydration drops the `WithingsApiError` prototype, the regex `/Withings\s+\w+\s+error:\s*(\d+)/` recovers the status digit and re-runs `classifyWithingsResponse(200, { status })`. If Withings ever changes the message format, the regex stops matching, and the helper returns `transient` — the conservative bucket that keeps trying. The retry+3-strike admin alert still catches a "keeps happening" case.
|
||||
|
||||
- **`recordSyncFailure(kind: "persistent")` maps to `state: "error_transient"` correctly.** The state-machine docstring at `src/lib/integrations/status.ts:215-220` documents the intent: `persistent` is operator-attention but NOT skip-future-sync. `isReauthRequired` at line 121-130 only short-circuits on `error_reauth`, so the next sync still runs. The audit row carries `kind: "persistent"` explicitly so ops grep can filter for contract bugs.
|
||||
|
||||
- **Knip gate flip is well-staged.** W1's merge-order callout (W3 + W4 first, W1 last) was honoured: both dead re-exports (`describeInjectionSite`, `listSupportedTimezones`) are dropped by the time W1 lands. The ignore-block carries an explicit rationale per scope (shadcn parity for `src/components/ui/**`, contract-type API surface for `src/lib/validations/**: ["types"]`). The `src/lib/validations/**` scope is narrowed to `["types"]` only — `exports` still trips on a truly dead schema, so a stale validator still surfaces.
|
||||
|
||||
- **queryKey factory long-tail migration closes the v1.4.41 senior-dev M2 finding.** The ESLint guard at `eslint-plugins/healthlog/queryKey-factory.js:48-76` and the test-guard substitute at `src/lib/__tests__/query-keys.test.ts:260-279` now list the IDENTICAL scope (settings, medications, admin, hooks dirs + the three guarded files under `src/app/medications/**` + `src/app/targets/page.tsx`). The two stay synchronised in lockstep per the W3 commit. Every new factory entry maintains the resource-root invariant — `medicationCompliance/Titration/Cadence/Glp1Details/IntakeDrugLevelChart/IntakeList` all start with `["medications", ...]`, `withingsStatus` shares the `["withings"]` root, `adminAuditLogFiltered` shares the `["admin", "audit-log"]` prefix with `adminAuditLog`, `workoutsRecentList` shares `["workouts"]` with `workouts/workoutsRecent`. `medicationDependentKeys` already includes `queryKeys.medications()` so every intake mutation cascades correctly.
|
||||
|
||||
- **PR-detection soft-delete fix closes v1.4.41 L1.** Both call sites at `src/lib/personal-records/pr-detection-worker.ts:219, 402` now carry `deletedAt: null`, and the DR-backup intent at `src/lib/jobs/offhost-backup.ts:219` is documented inline ("includes soft-deleted rows because this is the DR snapshot, not a user-facing export") — exactly the recommended-fix shape from the v1.4.41 review.
|
||||
|
||||
- **Dashboard tile-strip placeholder parity is byte-exact.** `src/app/page.tsx:1412-1421` matches the live `TrendCard` chrome (`flex h-full min-h-[6rem] w-full min-w-0 flex-col` + the existing `bg-card border-border rounded-xl border p-4 md:p-6`); the all-suspend collapse-to-zero-height regression is closed; the existing structural Suspense test pin keys on `aria-hidden="true"` and stays green without modification.
|
||||
|
||||
- **Multi-issue Zod envelope is privacy-safe.** `sanitiseZodIssues` at `src/lib/api-response.ts:37-45` strips `issue.params` (which can echo the raw rejected user input for some Zod codes) and surfaces only `{path, code, message}`. The docstring at lines 9-19 names the threat ("e.g. a too-long string, a regex source"); the integration test at `tests/integration/dashboard-widgets-save.test.ts` pins both the multi-issue shape and the params-leak guard.
|
||||
|
||||
---
|
||||
|
||||
**Summary:** verdict APPROVE_WITH_FIXES, 0 Critical, 1 High, 2 Medium, 5 Low, 11 strengths. The only finding that should land before the v1.4.42 tag is H1 (add `allOrNone: true` to the APNs group in `scripts/env-manifest.json` so the v1.4.40 AP-2 silent-disable case actually trips the gate the wave was conceived to add). Everything else is safe to defer to v1.4.43.
|
||||
@@ -0,0 +1,273 @@
|
||||
# v1.4.42 Simplifier-residual findings
|
||||
|
||||
## Verdict
|
||||
|
||||
APPROVE_WITH_FIXES — the v1.4.42 waves landed clean wins: BERLIN_DAY_FORMATTER 7-way dedup (W4), Suspense double-comment consolidate (W4), 35→0 / 52→0 knip drop with enforcing CI gate (W1), `returnAllZodIssues` shared helper (W2), `pickCanonicalWorkoutRows` write-time helper (W5), Withings response classifier (W6), and ~70 long-tail queryKey bare-literals routed through the factory (W3). The residual scan turned up **one High** (W6 introduced two nested ternaries — explicitly against the simplifier style guide), **three Medium** (one missed BERLIN_DAY_FORMATTER dedup site, one duplicate function-name collision with the v1.4.30 read-time picker, one shared boilerplate between `returnAllZodIssues` and `apiError`), and **a handful of Low** items that are pure cleanup. None block ship; all are mechanical.
|
||||
|
||||
## High (clarity regression introduced this release)
|
||||
|
||||
### H1 — `src/lib/integrations/status.ts:513-518` + `:530-535`: two nested ternaries introduced by the W6 third-state extension
|
||||
|
||||
The W6 wave widened `FailureKind` from `transient | reauth_required` to a three-state union (`+ persistent`). The two formatter branches in `formatAdminAlertPayload` got the new arm wedged in as a nested ternary:
|
||||
|
||||
```ts
|
||||
// lines 513-518
|
||||
const reasonLabel =
|
||||
input.kind === "reauth_required"
|
||||
? "re-auth required"
|
||||
: input.kind === "persistent"
|
||||
? "persistent error"
|
||||
: "transient error";
|
||||
|
||||
// lines 530-535 — same nested-ternary shape inside a template-literal interpolation
|
||||
`Action: ${
|
||||
input.kind === "reauth_required"
|
||||
? "ask the user to reconnect the integration."
|
||||
: input.kind === "persistent"
|
||||
? "investigate the upstream contract — params/scope/action likely mismatched."
|
||||
: "investigate the upstream service."
|
||||
}`
|
||||
```
|
||||
|
||||
The simplifier style guide explicitly forbids this pattern ("Avoid nested ternary operators — prefer switch statements or if/else chains for multiple conditions"). Pre-v1.4.42 both spots were single ternaries (two arms) and read cleanly; the third arm pushed them into the antipattern.
|
||||
|
||||
**Proposed shape** — a single `kindCopy` lookup driven off `FailureKind`:
|
||||
|
||||
```ts
|
||||
const COPY_BY_KIND: Record<FailureKind, { reason: string; action: string }> = {
|
||||
reauth_required: {
|
||||
reason: "re-auth required",
|
||||
action: "ask the user to reconnect the integration.",
|
||||
},
|
||||
persistent: {
|
||||
reason: "persistent error",
|
||||
action: "investigate the upstream contract — params/scope/action likely mismatched.",
|
||||
},
|
||||
transient: {
|
||||
reason: "transient error",
|
||||
action: "investigate the upstream service.",
|
||||
},
|
||||
};
|
||||
|
||||
const { reason: reasonLabel, action: actionLabel } = COPY_BY_KIND[input.kind];
|
||||
…
|
||||
`Action: ${actionLabel}`;
|
||||
```
|
||||
|
||||
Net: both nested ternaries collapse, the table reads as the contract, and adding a future fourth `FailureKind` is a one-row table edit instead of two more arms in two different ternary stacks. Mechanical, no behaviour change.
|
||||
|
||||
## Medium (clean wins the waves missed)
|
||||
|
||||
### M1 — `src/lib/insights/bucket-series.ts:19` + `src/lib/analytics/bp-in-target.ts:63`: two BERLIN_DAY_FORMATTER sites the W4 dedup missed
|
||||
|
||||
The W4 wave's seven-way BERLIN_DAY_FORMATTER dedup hoisted the formatter + `toBerlinDayKey()` from the seven `src/lib/insights/*-status.ts` files into `src/lib/tz/resolver.ts`. Two more declarations of the same constant survive:
|
||||
|
||||
- `src/lib/insights/bucket-series.ts:19-24` — same `Intl.DateTimeFormat("en-US", { timeZone: "Europe/Berlin", year: "numeric", month: "2-digit", day: "2-digit" })` constant. Its consumer (`toBerlinYmd` at line 67) needs the *parts* (numeric year/month/day) not the string, but it could still reuse `BERLIN_DAY_FORMATTER` from `@/lib/tz/resolver` by exporting the formatter (or by parsing back from the existing `toBerlinDayKey(date)` output).
|
||||
- `src/lib/analytics/bp-in-target.ts:63-72` — declares the same formatter (with `en-CA` locale to skip the part-reassembly) and its own local `toBerlinDayKey(date)`. Pre-existing duplication, not v1.4.42-introduced, but it surfaced as the obvious next dedup site once W4's hoist landed.
|
||||
|
||||
Neither was touched by v1.4.42, so this is M (not H). Net: one shared `BERLIN_DAY_FORMATTER` export (already in `@/lib/tz/resolver`) + a tiny `BerlinYmd` helper alongside it; two more files drop ~12 LOC each.
|
||||
|
||||
### M2 — Same function name `pickCanonicalWorkoutRows<T>` now lives in two modules with different signatures
|
||||
|
||||
W5 added `src/lib/workouts/canonical-rows.ts::pickCanonicalWorkoutRows()` for the write-time path. The read-time picker from v1.4.30 lives at `src/lib/measurements/pick-canonical-workout-rows.ts::pickCanonicalWorkoutRows()`. Both are exported under the SAME identifier:
|
||||
|
||||
```ts
|
||||
// v1.4.30 read-time
|
||||
export function pickCanonicalWorkoutRows<T extends WorkoutPickerRow>(
|
||||
rows: readonly T[], userPriorityJson: unknown = null,
|
||||
): T[]
|
||||
|
||||
// v1.4.42 write-time
|
||||
export function pickCanonicalWorkoutRows<T extends WorkoutRow>(
|
||||
rows: readonly T[],
|
||||
): T[]
|
||||
```
|
||||
|
||||
`src/app/api/workouts/batch/route.ts:71` imports from `@/lib/workouts/canonical-rows`; `src/app/api/workouts/route.ts:56` + `src/app/api/workouts/[id]/route.ts:31` import from `@/lib/measurements/pick-canonical-workout-rows`. Today's call sites get the right one by import path, but the same-name collision is the kind of footgun that bites the next person who auto-completes `pickCanonicalWorkoutRows` in a new route and silently invokes the wrong picker (no user-priority resolution on a read; missing the 90 s window on a write).
|
||||
|
||||
The W5 helper header already calls out the layered intent — write-time payload-internal dedup vs. read-time cross-batch dedup — so the design is right. Only the naming creates the trap.
|
||||
|
||||
**Proposed shape**: rename one. The write-time helper is the newer one and the one whose call site does *not* take `userPriorityJson`, so a name like `pickCanonicalWorkoutRowsForIngest()` (or `dedupeWorkoutBatch()`) makes the write-time contract self-describing and leaves the established `pickCanonicalWorkoutRows()` name for the read-time picker. One file rename + one import update; both pickers' test suites stay byte-identical.
|
||||
|
||||
### M3 — `src/lib/api-response.ts:47-71` (`returnAllZodIssues`) and `:86-107` (`apiError`) share the meta/headers extraction boilerplate
|
||||
|
||||
The new W2 helper duplicates the meta/headers handling from `apiError`:
|
||||
|
||||
```ts
|
||||
// returnAllZodIssues lines 55-69
|
||||
const { headers, ...rest } = meta ?? {};
|
||||
const metaKeys = Object.keys(rest);
|
||||
return NextResponse.json(
|
||||
{ data: null, error: "Validation failed", details: { issues: … },
|
||||
...(metaKeys.length > 0 ? { meta: rest } : {}) },
|
||||
{ status, ...(headers ? { headers } : {}) },
|
||||
);
|
||||
|
||||
// apiError lines 94-106 — same `headers`/`metaKeys`/`...(headers ? …)` shape
|
||||
```
|
||||
|
||||
The boilerplate is small (≈ 6 LOC duplicated) but it's the kind of drift that bit the apiError envelope before — if a future change extends the meta passthrough (e.g. an `errorId` autoinject for Sentry), it lands in one helper and not the other.
|
||||
|
||||
**Proposed shape** — a private builder:
|
||||
|
||||
```ts
|
||||
function buildJsonErrorResponse(
|
||||
body: Record<string, unknown>,
|
||||
status: number,
|
||||
meta: { headers?: Record<string, string> } & Record<string, unknown> | undefined,
|
||||
): NextResponse {
|
||||
const { headers, ...rest } = meta ?? {};
|
||||
const metaKeys = Object.keys(rest);
|
||||
return NextResponse.json(
|
||||
{ ...body, ...(metaKeys.length > 0 ? { meta: rest } : {}) },
|
||||
{ status, ...(headers ? { headers } : {}) },
|
||||
);
|
||||
}
|
||||
|
||||
export function apiError(message, status = 400, meta?) {
|
||||
return buildJsonErrorResponse({ data: null, error: message }, status, meta);
|
||||
}
|
||||
|
||||
export function returnAllZodIssues(error, status = 422, meta?) {
|
||||
return buildJsonErrorResponse(
|
||||
{ data: null, error: "Validation failed",
|
||||
details: { issues: sanitiseZodIssues(error.issues) } },
|
||||
status, meta,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Net: −12 LOC, single boilerplate update path, identical wire shape — both helpers' contract tests (the new `api-response-zod.test.ts` + existing rate-limit / errorCode tests) stay green.
|
||||
|
||||
### M4 — `src/lib/withings/sync.ts:118-135` + `:195-209`: identical 14-line failure-record catch-block now lives in two places
|
||||
|
||||
W6 swapped both call sites from `isWithingsRefreshReauthFailure(message)` to the typed `classifyError(err)` + `classificationToFailureKind()` chain. The two blocks ended up byte-identical:
|
||||
|
||||
```ts
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const classification = classifyError(err);
|
||||
await recordSyncFailure({
|
||||
userId,
|
||||
integration: "withings",
|
||||
kind: classificationToFailureKind(classification),
|
||||
message,
|
||||
errorCode:
|
||||
err instanceof WithingsApiError
|
||||
? err.withingsStatus?.toString()
|
||||
: extractWithingsStatus(message),
|
||||
});
|
||||
```
|
||||
|
||||
**Proposed shape** — a `recordWithingsSyncFailure(userId, err)` helper next to `classificationToFailureKind`:
|
||||
|
||||
```ts
|
||||
async function recordWithingsSyncFailure(userId: string, err: unknown): Promise<void> {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await recordSyncFailure({
|
||||
userId,
|
||||
integration: "withings",
|
||||
kind: classificationToFailureKind(classifyError(err)),
|
||||
message,
|
||||
errorCode:
|
||||
err instanceof WithingsApiError
|
||||
? err.withingsStatus?.toString()
|
||||
: extractWithingsStatus(message),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Both call sites collapse to `await recordWithingsSyncFailure(userId, err);`. Sync-activity / sync-sleep migration to typed errors (W6 deferred to v1.4.43) gets a single line per catch-block as a bonus.
|
||||
|
||||
## Low (defer to v1.4.43)
|
||||
|
||||
### L1 — `src/app/medications/page.tsx:553`: bare-literal `queryKey` outside the W3 guarded surface
|
||||
|
||||
```ts
|
||||
const apiEndpointKey = ["medication-api-endpoint", medication?.id];
|
||||
```
|
||||
|
||||
W3 added `src/app/medications/page.tsx` to the ESLint plugin's `GUARDED_FILES`, but the literal lives in a `const` assignment (not a `queryKey: [ … ]` property), so the rule's `Property` visitor doesn't fire. The two `useQuery` / `setQueryData` / `removeQueries` calls below all reference the same `apiEndpointKey` constant. Centralising as a factory entry (`queryKeys.medicationApiEndpoint(medicationId)`) would keep the cache layout discoverable.
|
||||
|
||||
The current shape works — the constant indirection means the ESLint rule's "bare-array" check is technically satisfied — but it's the same drift class W3 was scoped to fix. One factory entry + one import + one usage swap.
|
||||
|
||||
### L2 — `eslint-plugins/healthlog/queryKey-factory.js:70` + `src/lib/__tests__/query-keys.test.ts:269`: `src/components/settings/about-section.tsx` listed redundantly
|
||||
|
||||
`about-section.tsx` is now covered by the `src/components/settings` directory entry W3 added. Both the ESLint plugin's `GUARDED_FILES` and the test-guard's `guardedRoots` still list `about-section.tsx` individually — pre-existing from v1.4.41 W-FRONTEND-FACTORY, but the W3 wave should have collapsed it. Drop the line in both places. Cosmetic, no behaviour change.
|
||||
|
||||
### L3 — `src/lib/withings/sync.ts:310-326`: `classificationToFailureKind` switch is mostly identity
|
||||
|
||||
Three of the four `case` arms map a value to itself: `case "reauth_required": return "reauth_required";`, `case "persistent": return "persistent";`, `case "transient": return "transient";`. Only `case "success":` collapses to a default. An identity-passthrough with a defensive default would read identically:
|
||||
|
||||
```ts
|
||||
export function classificationToFailureKind(c: WithingsClassification): FailureKind {
|
||||
return c === "success" ? "transient" : c;
|
||||
}
|
||||
```
|
||||
|
||||
The current switch is explicit and exhaustive, which the W6 author chose deliberately ("Defensive: a caller asking for the FailureKind of a success is a contract bug"). Pure preference — flagging as Low because the switch reads as ceremony for what's structurally a one-liner.
|
||||
|
||||
### L4 — `src/lib/workouts/canonical-rows.ts:226-255`: triple-pass picker reads as more clever than the algorithm needs
|
||||
|
||||
The survivor selection in `pickCanonicalWorkoutRows` annotates every group member, sorts, picks the winner, then reverse-looks-up the original by `origIndex`:
|
||||
|
||||
```ts
|
||||
const annotated = group.map((entry) => ({
|
||||
...entry,
|
||||
row: { ...entry.row, index: entry.row.index ?? entry.origIndex } as T,
|
||||
}));
|
||||
annotated.sort((a, b) => compareCandidates(a.row, b.row));
|
||||
const winner = annotated[0]!;
|
||||
const original = group.find((g) => g.origIndex === winner.origIndex)!;
|
||||
survivors.push(original);
|
||||
```
|
||||
|
||||
A `reduce`-style single-walk would skip the annotation pass + sort + reverse-lookup:
|
||||
|
||||
```ts
|
||||
let winner = group[0]!;
|
||||
for (let i = 1; i < group.length; i++) {
|
||||
// compareCandidates wants the row's `index` set; supply the fallback inline.
|
||||
const a = group[i].row.index !== undefined
|
||||
? group[i].row
|
||||
: { ...group[i].row, index: group[i].origIndex } as T;
|
||||
const w = winner.row.index !== undefined
|
||||
? winner.row
|
||||
: { ...winner.row, index: winner.origIndex } as T;
|
||||
if (compareCandidates(a, w) < 0) winner = group[i];
|
||||
}
|
||||
survivors.push(winner);
|
||||
```
|
||||
|
||||
The current shape is well-commented and the 12-test suite pins the contract, so the algorithm is correct. Style-only refactor. Skipping for v1.4.42 because the simpler form re-introduces the `... as T` cast at every comparison site — the simpler-looking code is not strictly cleaner.
|
||||
|
||||
### L5 — `pnpm knip` configuration hints: 28 cleanups outside the exports/types tier
|
||||
|
||||
`knip` reports 28 configuration hints (redundant entry patterns, ignored deps that match the project, etc.) that the W1 wave deliberately did not touch — W1 was scoped to the `exports` + `types` tiers. The cleanup is mechanical (remove redundant `next.config.ts` / `playwright.config.ts` entries; drop `@types/node` from `ignoreDependencies`; remove `src/generated/**` from `ignore` since `project` already excludes generated files). Defer to v1.4.43 — none of these surface false negatives in the new enforcing gate.
|
||||
|
||||
## Already-deferred items (acknowledge, do not re-flag)
|
||||
|
||||
Per W1 § "Reconcile callouts", W2 § "v1.4.43 backlog", W4 § "Items 4-5 (NO-OP)", W5 § "Strict rules honoured", and W6 § "Deferred to v1.4.43" — these are explicit follow-ups I am NOT re-flagging:
|
||||
|
||||
- The 41-route rollout of `returnAllZodIssues` across `src/app/api/**` (W2 backlog tables for iOS-contract / medication-CRUD / auth-settings-admin tiers).
|
||||
- The `medications/[id]/intake/import/route.ts` `Invalid format: ` prefix preservation (W2).
|
||||
- Sync-activity / sync-sleep catch-block migration to `err.classification` direct read instead of regex fallback (W6).
|
||||
- `parkIntegrationAtReauth` for persistent failures lasting > 24 h (W6).
|
||||
- CI integration of `pnpm check-env` against `.env.production.example` (W6).
|
||||
- Shadcn surface-area exports muted under `ignoreIssues["src/components/ui/**"]` (W1) — pending Marc's call.
|
||||
- `src/lib/validations/**` z.infer types muted (W1) — iOS-contract + OpenAPI gen.
|
||||
- The W4 NO-OP findings (`computeLongWindowSummary`, `ensureUserMedicationComplianceFresh` — both already removed pre-v1.4.42).
|
||||
- The W1 "fold contracts ignore-block" choice (matched the pre-existing convention).
|
||||
|
||||
The v1.4.41 simplifier-residual report's deferred items are also out of scope here — re-flag only if v1.4.42 made them worse.
|
||||
|
||||
## Strengths
|
||||
|
||||
- **W1 enforcing gate**: 35 → 0 unused exports + 52 → 0 unused types with the CI workflow now red on regression. The `ignoreExportsUsedInFile: true` choice (instead of muting individual zod schemas) correctly preserves the source-of-truth pattern where a schema is exported AND consumed via `z.infer<>` in the same file. The two-file ignore block is documented inline with rationale per directory.
|
||||
- **W3 lockstep discipline**: the ESLint plugin, the test-guard walker, AND the factory tests all extended in the same commit (`e2018b2d`). Past waves shipped one without the other and the next session paid for the drift — this one didn't.
|
||||
- **W4 BERLIN_DAY_FORMATTER hoist**: −101 / +32 net LOC and seven importers now share a single `toBerlinDayKey()` export. The header on `src/lib/tz/resolver.ts:64-75` documents both this helper and the per-user `userDayKey(date, tz)` siblings so the next "which day-key should I use?" lookup lands the right answer.
|
||||
- **W5 helper purity**: `pickCanonicalWorkoutRows` in `src/lib/workouts/canonical-rows.ts` is genuinely pure (zero Prisma imports, zero IO) and the 12-test suite + 3 batch-route tests pin every algorithm corner. The 90 s window choice + the `DEFAULT_WORKOUT_SOURCE_PRIORITY` reuse with the v1.4.25 W16a constant is documented in the header. The naming collision (M2) is the only blemish.
|
||||
- **W6 classifier purity**: `classifyWithingsResponse(httpStatus, body)` is a pure function with 27 tests pinning every documented Withings status code. The `WithingsApiError` subclass preserves the legacy `Withings <verb> error: <status>` message format so the v1.4.41 regex consumers in `sync-activity.ts`/`sync-sleep.ts` keep working. The migration path (typed throw + regex fallback in `classifyError`) is the exact "replace-with-fallback" pattern Marc's `feedback_read_swap_replace_not_parallel.md` memo prescribes.
|
||||
- **W6 env-check**: the manifest-driven shape with `required` / `allOrNone` / `anyOf` markers covers the v1.4.40 AP-2 silent-disable case (3/4 APNS_* set) without forcing every operator to read prose docs. The unit test surface (16 tests covering `parseEnvFile` quoting/CRLF/comments + `checkEnv` group semantics) keeps the helper trustworthy.
|
||||
- **Marc-voice authorship**: every commit message reads as Marc's, no `Co-Authored-By`, no `--no-verify`, no `--no-gpg-sign`, no "Marc"/"agent"/"marathon"/"phase" leakage. Six wave reports, six clean commit ranges.
|
||||
|
||||
40-word summary: Six waves landed clean. One nested-ternary regression in W6's three-state extension, one same-name picker collision with v1.4.30, one missed BERLIN_DAY_FORMATTER dedup, plus light boilerplate sharing wins. No blockers; mechanical follow-ups for v1.4.43.
|
||||
@@ -1,5 +1,50 @@
|
||||
# Changelog
|
||||
|
||||
## [1.4.42] — 2026-05-21 — Knip enforcing, queryKey factory closed, iOS Workouts dedup, Withings off-response classification
|
||||
|
||||
v1.4.41 closed the iOS BP/Weight 14 s perf paper-cut and the soft-delete reader-tier completeness story. v1.4.42 is the follow-up polish-and-iOS-readiness release: the knip CI gate flips to enforcing-mode (zero unused exports + zero unused types on `main`), the long-tail queryKey factory migration closes the v1.4.40 audit-H1 contract for the settings / medications / admin / hooks surface, the `/api/dashboard/widgets` 422 returns every Zod issue so the next iOS contract-debugging session takes one round-trip instead of one per wrong field, Withings off-responses are classified into transient / reauth / persistent (rate-limit-induced `601`s no longer silently retry forever), the iOS HealthKit ingest gets a cross-source workout dedup helper that lets Apple Watch + Withings ScanWatch paired captures collapse to one row at write time, and a tree-hygiene wave lands (BERLIN_DAY_FORMATTER consolidated across nine call sites, Suspense double-comment consolidation, doctor-report-data byte-escape so diffs become readable, pr-detection-worker soft-delete filter, offhost-backup DR-intent comment).
|
||||
|
||||
Six touch-disjoint waves landed on `develop` before this release commit. The wave reports under `.planning/phase-W*-v1442-report.md` document the per-wave decisions, file inventories, and test deltas; the multi-axis QA round under `.planning/round-v1442-QA-*-findings.md` covers the cross-cutting verdict that gated this tag.
|
||||
|
||||
### Added
|
||||
|
||||
- **`returnAllZodIssues(error, status?, meta?)` shared helper** (`src/lib/api-response.ts`). Replaces the `apiError(parsed.error.issues[0].message, 422)` pattern that dropped every issue past the first. Envelope: `{ data: null, error: "Validation failed", details: { issues: [{ path, code, message }] } }`. Sanitises issues — `issue.params` is never echoed (can carry the raw rejected user input). First consumer is `PUT /api/dashboard/widgets`; 41 sibling routes documented in the W2 phase report for v1.4.43 rollout.
|
||||
- **`dedupeWorkoutBatch()` write-time cross-source dedup** (`src/lib/workouts/canonical-rows.ts`). Anticipates the v1.5 iOS HealthKit observer queue ingest where Apple Watch + Withings ScanWatch paired captures collide on the same logical workout. Groups by `(userId, activityType, startedAt ± 90 s)`, prefers the canonical source ladder `APPLE_HEALTH > WITHINGS > MANUAL > IMPORT` shared with the read-time picker, breaks ties on `caloriesKcal > earliest createdAt > input order`. Wired into `POST /api/workouts/batch` pre-`createMany`; losers surface as `duplicate` in the per-entry envelope so the iOS sync cursor advances identically to the existing `externalId` dedup. The name intentionally diverges from the v1.4.30 read-time `pickCanonicalWorkoutRows` so auto-completion paths don't conflate write-time payload-internal dedup with read-time cross-batch dedup.
|
||||
- **`pnpm check-env` CLI** (`scripts/check-env.ts` + `scripts/env-manifest.json` + `docs/ops/env-check.md`). Pre-deploy env-var sanity check that catches the v1.4.40 AP-2 silent-disable pattern (three of four `APNS_*` vars set, `.p8` missing) via the `allOrNone` group marker. Two modes: `pnpm check-env` against `process.env`, `pnpm check-env --file <path>` against a Coolify export. Exit code 0 / 1 / 2 for green / missing required / malformed manifest. The renderer surfaces the satisfying alternative on `anyOf` rows (`[OK] APNS_KEY (satisfied by APNS_KEY_FILE)`) so an operator scanning the output never grep-hunts for the wrong variable name.
|
||||
- **Withings off-response classifier** (`src/lib/withings/response-classifier.ts`). Pure `(httpStatus, body) → { success | transient | reauth_required | persistent }` taxonomy plus `WithingsApiError` subclass + `classifyError` fallback for pg-boss-rehydrated errors. Surfaces rate-limit (`601`) and contract-mismatch (`293 / 294`) responses to the admin alert path instead of letting them silently retry forever. `FailureKind` extended with `persistent`; persistent failures map to `state=error_transient` with a distinct audit kind so the next sync still runs but the operator sees the trail.
|
||||
- **Nine new `queryKey` factory entries** at `src/lib/query-keys.ts` covering per-medication `compliance` / `titration` / `cadence` / `glp1Details` / `intakeDrugLevelChart` / `intakeList`, the `withingsStatus` integration read, the paginated `adminAuditLogFiltered`, and `workoutsRecentList`.
|
||||
- **Berlin-anchored day-key helper** (`toBerlinYmd`) exported from `src/lib/tz/resolver.ts` next to `toBerlinDayKey`. Returns the `{year, month, day}` numeric parts directly so consumers building `Date.UTC(...)` keys (insights bucket series, BP-in-target windows) share the same formatter instance as the cache-key path.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Knip CI gate flips to enforcing-mode** (`.github/workflows/knip.yml`). The `--include files,dependencies,binaries,unlisted` flag is dropped; any new unused export, type, file, dependency, or binary on `main` now fails the gate. v1.4.42 brings the baseline to 0 / 0. The ignore-block under `knip.json` scopes shadcn (`src/components/ui/**`) and contract types (`src/lib/validations/**`) — both are intentional surface area we keep.
|
||||
- **Long-tail `queryKey` factory migration closed** (W3). Forty call-site files across `settings/`, `medications/`, `admin/`, `hooks/`, plus three `app/` pages, refactored from bare-literal `queryKey: [...]` to `queryKeys.<entry>()`. The custom ESLint rule (`eslint-plugins/healthlog/queryKey-factory.js`) and the test-guard substitute (`src/lib/__tests__/query-keys.test.ts`) extend their `GUARDED_DIRECTORIES` / `GUARDED_FILES` in lockstep so the gate fires at the same boundary at IDE / `pnpm lint` / `pnpm test` time. The `medicationIntakeList` factory entry decomposes its params into a flat tuple to match the `chartData` + `adminAuditLogFiltered` siblings (the prior packed-object form depended on stable JSON-stringify ordering for cache-key correctness).
|
||||
- **`PUT /api/dashboard/widgets` 422** now returns every Zod issue plus a worker-side `auditLog` row keyed `dashboard.widgets.validation-failed`. The iOS team picking up the v1.4.41 product-lead H4 callout now sees the full shape mismatch in one response and an operator-grep trail outside the iOS dev console.
|
||||
- **`BERLIN_DAY_FORMATTER` consolidated** to `src/lib/tz/resolver.ts`. The seven `src/lib/insights/*-status.ts` files that each carried a byte-identical 20-LOC declaration plus the `bucket-series.ts` and `bp-in-target.ts` siblings (two more sites the W4 wave's initial pass missed) all share one formatter instance now. Net ~120 LOC drop, same runtime behaviour, no DST drift.
|
||||
- **Tile-strip Suspense placeholder** (`src/app/page.tsx`) gains `min-h-[6rem]` plus the cosmetic `flex min-w-0 flex-col` classes so the placeholder chrome matches the live `TrendCard` byte-for-byte. The all-suspend edge case (future RSC hoist of every tile) holds the row open during a synchronous transition instead of collapsing to zero height.
|
||||
- **Dashboard Suspense double-comment** (`src/app/page.tsx`) — the two adjacent JSX comment blocks describing the same boundary (v1.4.40 W-RSC seed + v1.4.41 W-FRONTEND-FACTORY fallback hoist) collapse into one 5-6 line block keyed to current behaviour with a one-line history trailer. Pure documentation; rendered output byte-identical.
|
||||
- **`apiError` and `returnAllZodIssues` share a `buildJsonErrorResponse` builder** so the meta / headers passthrough lands in one place; a future extension (e.g. an `errorId` autoinject for Sentry) can't drift between the two helpers.
|
||||
- **`recordWithingsSyncFailure` extracted** from the two byte-identical 14-LOC catch-blocks in `src/lib/withings/sync.ts`. The sync-activity / sync-sleep migration to typed errors (deferred per the W6 backlog) will collapse to a single-line catch-block when it lands.
|
||||
- **`formatAdminAlertPayload` uses a `FailureKind` copy table** instead of nested ternaries. Adding a future fourth `FailureKind` is a one-row table edit, not two more arms in two ternary stacks.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Withings rate-limit (`601`) + contract-mismatch (`293 / 294`) responses no longer silently retry forever.** The classifier maps them to `persistent` so the admin alert label fires the first time the response shape lands, instead of after the worker has cycled them through the transient-retry queue indefinitely.
|
||||
- **`pr-detection-worker` soft-delete filter** — the v1.4.40 audit's W-DELETED-2 sweep missed the personal-records worker; a soft-deleted measurement could remain the user's "best weight" badge until the next ingest crossed the threshold. Both `prisma.measurement.count` (warm-up gate) and `findBestMeasurement` reads now scope to `deletedAt: null`.
|
||||
- **`offhost-backup.ts:219` DR-intent comment** — the nightly disaster-recovery S3 snapshot deliberately includes soft-deleted rows so a future "restore from yesterday" brings back a row the user undeleted on the source side. The inline comment now documents the asymmetry vs the user-facing `/api/export/full-backup` which correctly excludes them.
|
||||
- **`src/lib/doctor-report-data.ts` byte cleanliness** — the file was checked in as `Binary files differ` because the sanitiser regex held literal control bytes (NUL + US + DEL) in its character class. The regex now uses `[\x00-\x1F\x7F]` escape-sequence form so the file becomes UTF-8 clean and future code reviews can read the diffs. Same runtime behaviour; the existing 18-case test suite still passes.
|
||||
- **Two dead re-exports dropped** (W1 reconcile callouts): `listSupportedTimezones` re-export in `src/lib/tz/resolver.ts` (callers import from `@/lib/tz/format` directly) and the `describeInjectionSite` re-export in `src/components/medications/glp1-medication-card.tsx` ("Re-export so the parent doesn't need to import it" — no caller ever did).
|
||||
- **`pnpm check-env` catches the v1.4.40 AP-2 silent-disable it was conceived to catch.** The APNs group in `scripts/env-manifest.json` shipped without `allOrNone: true`, so an operator setting three of four `APNS_*` vars and leaving `APNS_KEY` / `APNS_KEY_FILE` empty would still exit 0 — exactly the silent-disable shape the wave was meant to prevent. Adding the flag closes the gap; the regression test pins the AP-2 scenario directly.
|
||||
|
||||
### Operator notes
|
||||
|
||||
- **No migration.** No env-var change. No API contract break for iOS v0.5.4 — `/api/dashboard/widgets` 422 envelope is additively extended (existing `error` field preserved), and the new `dedupeWorkoutBatch` write-time dedup affects only batches with overlapping `(userId, activityType, startedAt ± 90 s)` rows where the source-priority ladder already determined the canonical row at read time.
|
||||
- **`pnpm check-env`** is the new pre-deploy gate that would have caught the v1.4.40 AP-2 silent-disable. Manifest at `scripts/env-manifest.json`; run before any production release.
|
||||
- **Knip CI gate is now enforcing on `main`.** Any push that introduces an unused export or type fails the gate. The shadcn surface and contract types are scoped via `knip.json` ignore-block; future shadcn updates land additively.
|
||||
- **Withings `persistent` failures** map to `IntegrationState=error_transient` so the next scheduled sync still runs. The distinct audit-kind + admin-alert label lets the operator triage a stuck integration without waiting for the user to surface it.
|
||||
- **Write-time workout dedup does not consult `User.sourcePriorityJson`.** A user who customised Settings → Sources (e.g. promoted `MANUAL` above `APPLE_HEALTH`) sees their Manual rows dropped at write-time when paired with an Apple Watch row within the 90 s window. Scope-narrow today; v1.4.43 closes it via a one-row `prisma.user.findUnique` lookup at the batch route. The docstring on `dedupeWorkoutBatch` carries the load-bearing operator notice.
|
||||
- `pnpm test --run` green at 4815 passing / 1 skipped (4816 total); `pnpm typecheck`, `pnpm lint`, `pnpm knip` (no `--include` flag) all green.
|
||||
|
||||
## [1.4.41] — 2026-05-21 — iOS perf hotfix, soft-delete completeness, tree hygiene
|
||||
|
||||
v1.4.40 closed the architecture-level Critical+High audit findings and shipped the iOS PB30 backend prerequisites. v1.4.41 is the follow-up: one user-visible perf hotfix on the iOS-facing insights endpoints (consolidated into a shared timeout-stub helper that now backs all seven status routes), three remaining soft-delete reader-tier gaps closed (the W-DELETED-2 invariant pinned by integration tests), type-consolidation across analytics + backups, a four-branch iOS-onboarding discovery endpoint with per-IP rate-limiting, and a code-hygiene pass that retires the v1.4.39 W-SUM legacy-NULL UNION discovery arm, extracts the today-intake projection helper, and trims a batch of dead exports.
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# Pre-deploy env-var sanity check
|
||||
|
||||
`pnpm check-env` reads `scripts/env-manifest.json` and verifies every
|
||||
variable declared `required: true` is present + non-empty in the
|
||||
runtime environment. Optional groups are surfaced too, so an operator
|
||||
can spot the "3 out of 4 vars set" pattern that silently disables a
|
||||
feature.
|
||||
|
||||
This is the answer to the v1.4.40 AP-2 silent-disable: the `.p8` file
|
||||
went missing during a Coolify migration, three of four `APNS_*` vars
|
||||
remained set, the app booted cleanly, and APNs notifications stopped
|
||||
working — undetected for three days because the dispatcher's fallback
|
||||
chain hid the gap behind the Telegram → ntfy → Web Push path.
|
||||
|
||||
## Usage
|
||||
|
||||
### Against the live shell
|
||||
|
||||
```sh
|
||||
pnpm check-env
|
||||
```
|
||||
|
||||
Runs against `process.env` (after the local `.env` / `.env.local` load,
|
||||
if any). Exit code 0 on green, 1 on at least one required variable
|
||||
missing, 2 on a malformed manifest.
|
||||
|
||||
### Against a Coolify export / .env.production
|
||||
|
||||
```sh
|
||||
pnpm check-env --file /path/to/.env.production
|
||||
```
|
||||
|
||||
The file is parsed locally (no shell execution, no variable expansion)
|
||||
and checked against the manifest. Useful for spot-checking a Coolify
|
||||
export without polluting the local shell.
|
||||
|
||||
## Output format
|
||||
|
||||
Output is one row per declared variable, grouped by section. Three
|
||||
states:
|
||||
|
||||
| Marker | Meaning |
|
||||
| --------------------- | ---------------------------------------------------------------------------------- |
|
||||
| `[OK]` | Variable is set and non-empty. |
|
||||
| `[MISSING-REQUIRED]` | Variable is required for boot. The script exits 1. |
|
||||
| `[missing-optional]` | Variable is optional. The script reports it but does not fail. |
|
||||
|
||||
The grep-friendly format lets a CI pipeline do `grep -q
|
||||
'\[MISSING-REQUIRED\]'` to gate a deploy. The CI integration itself
|
||||
is deferred to v1.4.43 — this release ships the CLI tool only.
|
||||
|
||||
## All-or-none groups
|
||||
|
||||
Some groups (e.g. off-host backups) are marked `allOrNone: true` in
|
||||
the manifest. When such a group is partially populated — some vars
|
||||
set, some missing — the script emits a synthetic `<all-or-none>` row
|
||||
with severity REQUIRED, even if the surrounding group is `required:
|
||||
false`. This is the v1.4.40 AP-2 detection pattern.
|
||||
|
||||
Example output for a partial APNs config:
|
||||
|
||||
```
|
||||
# APNs (iOS push)
|
||||
[OK] APNS_KEY_ID
|
||||
[OK] APNS_TEAM_ID
|
||||
[OK] APNS_BUNDLE_ID
|
||||
[missing-optional] APNS_KEY — Satisfied by any of: APNS_KEY, APNS_KEY_FILE
|
||||
```
|
||||
|
||||
A reviewer immediately sees that `APNS_KEY` (or its `APNS_KEY_FILE`
|
||||
alternative) is missing while the other three APNs vars are set — the
|
||||
exact configuration that silently disables iOS push.
|
||||
|
||||
## Editing the manifest
|
||||
|
||||
The manifest lives at `scripts/env-manifest.json` and is git-tracked
|
||||
so changes go through a PR. To add a new variable:
|
||||
|
||||
1. Pick the right group (or add a new one if the variable doesn't
|
||||
belong to any existing category).
|
||||
2. Decide whether the variable is required for boot, optional, or
|
||||
part of an all-or-none feature group.
|
||||
3. Document the variable's `purpose` in one sentence — the operator
|
||||
reads this text when the var shows up as missing.
|
||||
4. Run `pnpm check-env` locally against your live shell to confirm
|
||||
the manifest still passes (or fails for the right reason).
|
||||
5. Add a regression test under `scripts/__tests__/check-env.test.ts`
|
||||
if the manifest change introduces a new classification branch
|
||||
(anyOf, allOrNone, …).
|
||||
|
||||
## Future: CI gate
|
||||
|
||||
Deferred to v1.4.43:
|
||||
|
||||
- GitHub Actions workflow that runs `pnpm check-env --file
|
||||
.env.production.example` on every PR, blocking changes that
|
||||
introduce new required vars without updating the example file.
|
||||
- Coolify pre-deploy hook that runs `pnpm check-env` inside the
|
||||
target container and aborts the deploy on exit code 1.
|
||||
@@ -53,6 +53,13 @@ const GUARDED_DIRECTORIES = [
|
||||
// at the same boundary the unit-test walker does.
|
||||
"src/app/auth",
|
||||
"src/app/notifications",
|
||||
// v1.4.42 W3-QUERYKEY-LONGTAIL — the settings / medications /
|
||||
// admin / hooks surface is now factory-only. Every long-tail
|
||||
// bare-literal flagged by audit-H1 routes through `queryKeys.<entry>()`.
|
||||
"src/components/settings",
|
||||
"src/components/medications",
|
||||
"src/components/admin",
|
||||
"src/hooks",
|
||||
];
|
||||
|
||||
// Individual guarded files outside the directories above.
|
||||
@@ -61,6 +68,11 @@ const GUARDED_FILES = [
|
||||
"src/hooks/use-auth.ts",
|
||||
// v1.4.41 W-FRONTEND-FACTORY — about-section migrated.
|
||||
"src/components/settings/about-section.tsx",
|
||||
// v1.4.42 W3-QUERYKEY-LONGTAIL — medications + targets pages now
|
||||
// route every read through the factory.
|
||||
"src/app/medications/page.tsx",
|
||||
"src/app/medications/[id]/history/page.tsx",
|
||||
"src/app/targets/page.tsx",
|
||||
];
|
||||
|
||||
function isGuarded(filename) {
|
||||
|
||||
@@ -69,5 +69,10 @@
|
||||
"docker-compose",
|
||||
"pg_isready",
|
||||
"tsx"
|
||||
]
|
||||
],
|
||||
"ignoreExportsUsedInFile": true,
|
||||
"ignoreIssues": {
|
||||
"src/components/ui/**": ["exports", "types"],
|
||||
"src/lib/validations/**": ["types"]
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.4.41",
|
||||
"version": "1.4.42",
|
||||
"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",
|
||||
@@ -44,7 +44,8 @@
|
||||
"e2e": "playwright test",
|
||||
"e2e:ui": "playwright test --ui",
|
||||
"openapi:generate": "npx tsx scripts/generate-openapi.ts",
|
||||
"openapi:check": "npx tsx scripts/check-openapi.ts"
|
||||
"openapi:check": "npx tsx scripts/check-openapi.ts",
|
||||
"check-env": "npx tsx scripts/check-env.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1048.0",
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* v1.4.42 W6 — env-check sanity tests.
|
||||
*
|
||||
* The script's two pure helpers (`parseEnvFile` + `checkEnv`) are
|
||||
* pinned here. The renderResults / main() entrypoint is intentionally
|
||||
* not unit-tested — it's a thin shell over the pure helpers and the
|
||||
* exit-code behaviour is best left to a smoke test in CI (deferred to
|
||||
* v1.4.43).
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { checkEnv, parseEnvFile } from "../check-env";
|
||||
|
||||
describe("parseEnvFile", () => {
|
||||
it("parses simple KEY=VALUE pairs", () => {
|
||||
expect(parseEnvFile("FOO=bar\nBAZ=qux")).toEqual({
|
||||
FOO: "bar",
|
||||
BAZ: "qux",
|
||||
});
|
||||
});
|
||||
|
||||
it("strips surrounding double quotes", () => {
|
||||
expect(parseEnvFile('FOO="bar baz"')).toEqual({ FOO: "bar baz" });
|
||||
});
|
||||
|
||||
it("strips surrounding single quotes", () => {
|
||||
expect(parseEnvFile("FOO='bar baz'")).toEqual({ FOO: "bar baz" });
|
||||
});
|
||||
|
||||
it("skips comments and blank lines", () => {
|
||||
const src = `# header comment
|
||||
FOO=bar
|
||||
|
||||
# another
|
||||
BAZ=qux
|
||||
`;
|
||||
expect(parseEnvFile(src)).toEqual({ FOO: "bar", BAZ: "qux" });
|
||||
});
|
||||
|
||||
it("preserves an empty value (the v1.4.40 .p8 gap pattern)", () => {
|
||||
// An empty string MUST round-trip as "" so the presence-check
|
||||
// downstream classifies it as missing.
|
||||
expect(parseEnvFile("APNS_KEY=")).toEqual({ APNS_KEY: "" });
|
||||
});
|
||||
|
||||
it("handles CRLF line endings (Coolify exports use \\r\\n)", () => {
|
||||
expect(parseEnvFile("FOO=bar\r\nBAZ=qux")).toEqual({
|
||||
FOO: "bar",
|
||||
BAZ: "qux",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const SAMPLE_MANIFEST = {
|
||||
description: "test manifest",
|
||||
groups: [
|
||||
{
|
||||
name: "Core",
|
||||
description: "required boot vars",
|
||||
required: true,
|
||||
variables: [
|
||||
{ name: "DATABASE_URL", purpose: "db" },
|
||||
{ name: "ENCRYPTION_KEY", purpose: "crypto" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "APNs",
|
||||
description: "iOS push",
|
||||
required: false,
|
||||
allOrNone: true,
|
||||
variables: [
|
||||
{ name: "APNS_KEY_ID", purpose: "key id" },
|
||||
{ name: "APNS_TEAM_ID", purpose: "team id" },
|
||||
{
|
||||
name: "APNS_KEY",
|
||||
purpose: "key body or file",
|
||||
anyOf: ["APNS_KEY", "APNS_KEY_FILE"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Backups",
|
||||
description: "all-or-none",
|
||||
required: false,
|
||||
allOrNone: true,
|
||||
variables: [
|
||||
{ name: "BACKUP_S3_BUCKET", purpose: "bucket" },
|
||||
{ name: "BACKUP_S3_ACCESS_KEY", purpose: "key" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("checkEnv — required group", () => {
|
||||
it("flags every missing required var", () => {
|
||||
const out = checkEnv(SAMPLE_MANIFEST, {});
|
||||
const required = out.filter((r) => r.required && !r.present);
|
||||
expect(required.map((r) => r.variable)).toEqual([
|
||||
"DATABASE_URL",
|
||||
"ENCRYPTION_KEY",
|
||||
]);
|
||||
});
|
||||
|
||||
it("classifies whitespace-only values as missing (deploy-failure prevention)", () => {
|
||||
// A `DATABASE_URL=" "` would silently boot with a useless value;
|
||||
// the deployer expects to see a clear MISSING-REQUIRED row here.
|
||||
const out = checkEnv(SAMPLE_MANIFEST, {
|
||||
DATABASE_URL: " ",
|
||||
ENCRYPTION_KEY: "ok",
|
||||
});
|
||||
const row = out.find((r) => r.variable === "DATABASE_URL")!;
|
||||
expect(row.present).toBe(false);
|
||||
expect(row.required).toBe(true);
|
||||
});
|
||||
|
||||
it("greenlights when every required var is non-empty", () => {
|
||||
const out = checkEnv(SAMPLE_MANIFEST, {
|
||||
DATABASE_URL: "postgresql://x",
|
||||
ENCRYPTION_KEY: "deadbeef".repeat(8),
|
||||
});
|
||||
const required = out.filter((r) => r.required);
|
||||
expect(required.every((r) => r.present)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkEnv — anyOf alternatives", () => {
|
||||
it("treats APNS_KEY as satisfied when APNS_KEY_FILE is set instead", () => {
|
||||
const out = checkEnv(SAMPLE_MANIFEST, {
|
||||
DATABASE_URL: "x",
|
||||
ENCRYPTION_KEY: "y",
|
||||
APNS_KEY_ID: "abc",
|
||||
APNS_TEAM_ID: "def",
|
||||
APNS_KEY_FILE: "/secrets/apns.p8",
|
||||
});
|
||||
const apnsKey = out.find((r) => r.variable === "APNS_KEY")!;
|
||||
expect(apnsKey.present).toBe(true);
|
||||
});
|
||||
|
||||
it("records satisfiedBy when an anyOf alternative satisfies the row", () => {
|
||||
// The label `[OK] APNS_KEY` was misleading pre-v1.4.42 — an operator
|
||||
// who saw it would grep for `APNS_KEY` in the env block and find
|
||||
// nothing, file a ghost issue. `satisfiedBy` lets the renderer
|
||||
// surface the alternative that actually matched.
|
||||
const out = checkEnv(SAMPLE_MANIFEST, {
|
||||
DATABASE_URL: "x",
|
||||
ENCRYPTION_KEY: "y",
|
||||
APNS_KEY_ID: "abc",
|
||||
APNS_TEAM_ID: "def",
|
||||
APNS_KEY_FILE: "/secrets/apns.p8",
|
||||
});
|
||||
const apnsKey = out.find((r) => r.variable === "APNS_KEY")!;
|
||||
expect(apnsKey.satisfiedBy).toBe("APNS_KEY_FILE");
|
||||
});
|
||||
|
||||
it("leaves satisfiedBy undefined when the primary name is the one set", () => {
|
||||
const out = checkEnv(SAMPLE_MANIFEST, {
|
||||
DATABASE_URL: "x",
|
||||
ENCRYPTION_KEY: "y",
|
||||
APNS_KEY_ID: "abc",
|
||||
APNS_TEAM_ID: "def",
|
||||
APNS_KEY: "-----BEGIN PRIVATE KEY-----...",
|
||||
});
|
||||
const apnsKey = out.find((r) => r.variable === "APNS_KEY")!;
|
||||
expect(apnsKey.satisfiedBy).toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats APNS_KEY as satisfied when APNS_KEY is set (the 12-factor variant)", () => {
|
||||
const out = checkEnv(SAMPLE_MANIFEST, {
|
||||
DATABASE_URL: "x",
|
||||
ENCRYPTION_KEY: "y",
|
||||
APNS_KEY_ID: "abc",
|
||||
APNS_TEAM_ID: "def",
|
||||
APNS_KEY: "-----BEGIN PRIVATE KEY-----...",
|
||||
});
|
||||
expect(out.find((r) => r.variable === "APNS_KEY")?.present).toBe(true);
|
||||
});
|
||||
|
||||
it("marks APNS_KEY missing when BOTH alternatives are absent (the v1.4.40 AP-2 gap)", () => {
|
||||
const out = checkEnv(SAMPLE_MANIFEST, {
|
||||
DATABASE_URL: "x",
|
||||
ENCRYPTION_KEY: "y",
|
||||
APNS_KEY_ID: "abc",
|
||||
APNS_TEAM_ID: "def",
|
||||
});
|
||||
expect(out.find((r) => r.variable === "APNS_KEY")?.present).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkEnv — all-or-none groups", () => {
|
||||
it("emits a synthetic <all-or-none> required row when a group is half-populated", () => {
|
||||
// The v1.4.40 AP-2 silent-disable pattern: 3 of 4 APNS_* set, .p8
|
||||
// missing → app silently falls back to Telegram. The all-or-none
|
||||
// synthetic row catches this even when the surrounding group is
|
||||
// `required: false`.
|
||||
const out = checkEnv(SAMPLE_MANIFEST, {
|
||||
DATABASE_URL: "x",
|
||||
ENCRYPTION_KEY: "y",
|
||||
BACKUP_S3_BUCKET: "my-bucket",
|
||||
// BACKUP_S3_ACCESS_KEY deliberately absent
|
||||
});
|
||||
const synth = out.find((r) => r.variable === "<all-or-none>")!;
|
||||
expect(synth).toBeDefined();
|
||||
expect(synth.required).toBe(true);
|
||||
expect(synth.note).toContain("1/2");
|
||||
});
|
||||
|
||||
it("does NOT emit the synthetic row when the group is fully set", () => {
|
||||
const out = checkEnv(SAMPLE_MANIFEST, {
|
||||
DATABASE_URL: "x",
|
||||
ENCRYPTION_KEY: "y",
|
||||
BACKUP_S3_BUCKET: "my-bucket",
|
||||
BACKUP_S3_ACCESS_KEY: "key",
|
||||
});
|
||||
expect(out.find((r) => r.variable === "<all-or-none>")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does NOT emit the synthetic row when the group is fully empty", () => {
|
||||
const out = checkEnv(SAMPLE_MANIFEST, {
|
||||
DATABASE_URL: "x",
|
||||
ENCRYPTION_KEY: "y",
|
||||
});
|
||||
expect(out.find((r) => r.variable === "<all-or-none>")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("catches the v1.4.40 AP-2 scenario — 3 of 4 APNS_* set, key missing", () => {
|
||||
// Direct regression pin for the gap the env-check wave was conceived
|
||||
// to close. Pre-v1.4.42 the APNs group lacked `allOrNone: true` so
|
||||
// this exact env-block shape exited 0 and silently disabled APNs.
|
||||
const out = checkEnv(SAMPLE_MANIFEST, {
|
||||
DATABASE_URL: "x",
|
||||
ENCRYPTION_KEY: "y",
|
||||
APNS_KEY_ID: "abc",
|
||||
APNS_TEAM_ID: "def",
|
||||
// APNS_KEY / APNS_KEY_FILE deliberately absent
|
||||
});
|
||||
const synth = out.find(
|
||||
(r) => r.group === "APNs" && r.variable === "<all-or-none>",
|
||||
)!;
|
||||
expect(synth).toBeDefined();
|
||||
expect(synth.required).toBe(true);
|
||||
expect(synth.note).toContain("2/3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkEnv — optional groups (informational)", () => {
|
||||
it("reports optional vars without setting required: true", () => {
|
||||
const out = checkEnv(SAMPLE_MANIFEST, {
|
||||
DATABASE_URL: "x",
|
||||
ENCRYPTION_KEY: "y",
|
||||
});
|
||||
const apnsKeyId = out.find((r) => r.variable === "APNS_KEY_ID")!;
|
||||
expect(apnsKeyId.present).toBe(false);
|
||||
expect(apnsKeyId.required).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Pre-deploy env-var sanity check.
|
||||
*
|
||||
* Reads `scripts/env-manifest.json` and verifies every variable
|
||||
* declared `required: true` (group-level) is present + non-empty in
|
||||
* the runtime environment. Optional groups are reported as
|
||||
* informational so an operator can spot misconfigurations (e.g. three
|
||||
* out of four APNS_* vars set — the silent-disable case that bit us
|
||||
* for 3 days in v1.4.40).
|
||||
*
|
||||
* Designed to run in two modes:
|
||||
*
|
||||
* 1. Live env (default): inspects `process.env` after dotenv has
|
||||
* loaded `.env` / `.env.production`. This is what `pnpm
|
||||
* check-env` does locally and what CI will run against the
|
||||
* pre-deploy environment.
|
||||
*
|
||||
* 2. File mode: `pnpm check-env --file .env.production` reads a
|
||||
* KEY=VALUE file directly without loading it into `process.env`.
|
||||
* Useful for checking a Coolify export against the manifest
|
||||
* without polluting the local shell.
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — all required variables present + non-empty.
|
||||
* 1 — at least one required variable missing/empty.
|
||||
* 2 — manifest file unreadable or malformed.
|
||||
*
|
||||
* Future (v1.4.43): CI integration via a GitHub Action that runs this
|
||||
* against a `.env.production.example`-style file in the repo,
|
||||
* blocking PRs that introduce new required vars without updating the
|
||||
* manifest.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { argv, exit, env as processEnv } from "node:process";
|
||||
|
||||
const MANIFEST_PATH = resolve(__dirname, "env-manifest.json");
|
||||
|
||||
interface ManifestVariable {
|
||||
name: string;
|
||||
purpose: string;
|
||||
example?: string;
|
||||
/**
|
||||
* Alternative names — if at least one is present, the variable is
|
||||
* considered satisfied. Used for APNS_KEY vs APNS_KEY_FILE.
|
||||
*/
|
||||
anyOf?: string[];
|
||||
}
|
||||
|
||||
interface ManifestGroup {
|
||||
name: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
allOrNone?: boolean;
|
||||
variables: ManifestVariable[];
|
||||
}
|
||||
|
||||
interface Manifest {
|
||||
description: string;
|
||||
groups: ManifestGroup[];
|
||||
}
|
||||
|
||||
interface CheckResult {
|
||||
group: string;
|
||||
variable: string;
|
||||
present: boolean;
|
||||
required: boolean;
|
||||
note?: string;
|
||||
/**
|
||||
* For `anyOf` rows: the alternative that actually satisfied the row.
|
||||
* Set only when the matched name differs from the primary `variable`
|
||||
* — e.g. APNS_KEY satisfied by APNS_KEY_FILE. Lets the renderer print
|
||||
* `[OK] APNS_KEY (satisfied by APNS_KEY_FILE)` instead of misleading
|
||||
* the operator into greping for the primary name.
|
||||
*/
|
||||
satisfiedBy?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a KEY=VALUE file into an env-lookup object. Mirrors dotenv's
|
||||
* minimal behaviour (no variable expansion, no escape sequences) so a
|
||||
* Coolify export file or .env.production can be inspected without
|
||||
* pulling in the full dotenv parse path.
|
||||
*
|
||||
* - Lines beginning with `#` are comments.
|
||||
* - Empty lines are skipped.
|
||||
* - Quoted values (`"..."` or `'...'`) have the quotes stripped.
|
||||
* - Whitespace around `=` is preserved (Coolify never adds any).
|
||||
*/
|
||||
export function parseEnvFile(content: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const rawLine of content.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const eq = line.indexOf("=");
|
||||
if (eq === -1) continue;
|
||||
const key = line.slice(0, eq).trim();
|
||||
let value = line.slice(eq + 1).trim();
|
||||
// Strip surrounding quotes — dotenv accepts both flavours.
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A variable counts as "present" only when the value is defined AND
|
||||
* non-empty (whitespace-only counts as empty). Pre-v1.4.42 the v1.4.40
|
||||
* AP-2 gap shipped because the .p8 file simply did not exist — but a
|
||||
* `""` value would have passed an `existsSync`-style check too.
|
||||
*/
|
||||
function isPresent(value: string | undefined): boolean {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure classifier — exported so unit tests can drive it without
|
||||
* hitting the filesystem. Given a manifest and an env lookup, return
|
||||
* one result row per declared variable plus any group-level
|
||||
* all-or-none violations.
|
||||
*/
|
||||
export function checkEnv(
|
||||
manifest: Manifest,
|
||||
env: Record<string, string | undefined>,
|
||||
): CheckResult[] {
|
||||
const results: CheckResult[] = [];
|
||||
|
||||
for (const group of manifest.groups) {
|
||||
let presentInGroup = 0;
|
||||
let absentInGroup = 0;
|
||||
|
||||
for (const v of group.variables) {
|
||||
const names = v.anyOf ?? [v.name];
|
||||
const matched = names.find((n) => isPresent(env[n]));
|
||||
const present = matched !== undefined;
|
||||
const note = v.anyOf
|
||||
? `Satisfied by any of: ${v.anyOf.join(", ")}`
|
||||
: undefined;
|
||||
results.push({
|
||||
group: group.name,
|
||||
variable: v.name,
|
||||
present,
|
||||
required: group.required,
|
||||
note,
|
||||
satisfiedBy:
|
||||
present && matched && matched !== v.name ? matched : undefined,
|
||||
});
|
||||
if (present) presentInGroup++;
|
||||
else absentInGroup++;
|
||||
}
|
||||
|
||||
// All-or-none groups: surface a synthetic row when the group is
|
||||
// partially populated. This catches the v1.4.40 AP-2 pattern even
|
||||
// when the group itself is `required: false`.
|
||||
if (
|
||||
group.allOrNone &&
|
||||
presentInGroup > 0 &&
|
||||
absentInGroup > 0
|
||||
) {
|
||||
results.push({
|
||||
group: group.name,
|
||||
variable: "<all-or-none>",
|
||||
present: false,
|
||||
// Promote to required severity — a partial all-or-none group
|
||||
// is always wrong, no matter the surrounding group flag.
|
||||
required: true,
|
||||
note: `Group is all-or-none but only ${presentInGroup}/${
|
||||
presentInGroup + absentInGroup
|
||||
} variables are set. Either set all or unset all.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a one-row-per-variable summary to stdout. The output is
|
||||
* intentionally grep-friendly so a CI pipeline can pattern-match for
|
||||
* `[MISSING-REQUIRED]` to fail the build.
|
||||
*/
|
||||
function renderResults(results: CheckResult[]): {
|
||||
missingRequired: number;
|
||||
missingOptional: number;
|
||||
} {
|
||||
let missingRequired = 0;
|
||||
let missingOptional = 0;
|
||||
let currentGroup = "";
|
||||
|
||||
for (const r of results) {
|
||||
if (r.group !== currentGroup) {
|
||||
console.log(`\n# ${r.group}`);
|
||||
currentGroup = r.group;
|
||||
}
|
||||
if (r.present) {
|
||||
console.log(
|
||||
` [OK] ${r.variable}` +
|
||||
(r.satisfiedBy ? ` (satisfied by ${r.satisfiedBy})` : ""),
|
||||
);
|
||||
} else if (r.required) {
|
||||
console.log(
|
||||
` [MISSING-REQUIRED] ${r.variable}` +
|
||||
(r.note ? ` — ${r.note}` : ""),
|
||||
);
|
||||
missingRequired++;
|
||||
} else {
|
||||
console.log(
|
||||
` [missing-optional] ${r.variable}` + (r.note ? ` — ${r.note}` : ""),
|
||||
);
|
||||
missingOptional++;
|
||||
}
|
||||
}
|
||||
|
||||
return { missingRequired, missingOptional };
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point — kept as a named export so a test harness can call it
|
||||
* with a synthetic argv without spawning a child process.
|
||||
*/
|
||||
export function main(args: string[]): number {
|
||||
const fileFlagIdx = args.indexOf("--file");
|
||||
let env: Record<string, string | undefined>;
|
||||
let envLabel: string;
|
||||
|
||||
if (fileFlagIdx !== -1 && args[fileFlagIdx + 1]) {
|
||||
const filePath = resolve(args[fileFlagIdx + 1]!);
|
||||
try {
|
||||
env = parseEnvFile(readFileSync(filePath, "utf8"));
|
||||
envLabel = filePath;
|
||||
} catch (err) {
|
||||
console.error(`Failed to read env file at ${filePath}: ${err}`);
|
||||
return 2;
|
||||
}
|
||||
} else {
|
||||
env = processEnv;
|
||||
envLabel = "process.env";
|
||||
}
|
||||
|
||||
let manifest: Manifest;
|
||||
try {
|
||||
manifest = JSON.parse(readFileSync(MANIFEST_PATH, "utf8")) as Manifest;
|
||||
} catch (err) {
|
||||
console.error(`Failed to read manifest at ${MANIFEST_PATH}: ${err}`);
|
||||
return 2;
|
||||
}
|
||||
|
||||
console.log(`Checking ${envLabel} against ${MANIFEST_PATH}`);
|
||||
const results = checkEnv(manifest, env);
|
||||
const { missingRequired, missingOptional } = renderResults(results);
|
||||
|
||||
console.log("");
|
||||
console.log(
|
||||
`Summary: ${results.filter((r) => r.present).length}/${results.length} variables present`,
|
||||
);
|
||||
if (missingRequired > 0) {
|
||||
console.log(
|
||||
` ${missingRequired} REQUIRED variable(s) missing — deploy WILL FAIL.`,
|
||||
);
|
||||
}
|
||||
if (missingOptional > 0) {
|
||||
console.log(
|
||||
` ${missingOptional} optional variable(s) missing — feature will silently disable.`,
|
||||
);
|
||||
}
|
||||
|
||||
return missingRequired > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
// Only run when invoked directly (`pnpm check-env` / `tsx scripts/check-env.ts`).
|
||||
// `require.main === module` survives the bundler-style ESM->CJS shim
|
||||
// that tsx uses.
|
||||
if (require.main === module) {
|
||||
exit(main(argv.slice(2)));
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"$schema": "./env-manifest.schema.json",
|
||||
"description": "Manifest of env vars required by a HealthLog production deployment. Used by `pnpm check-env` to catch missing/empty vars BEFORE a deploy ships. v1.4.42 W6 — born out of the v1.4.40 AP-2 .p8 install gap that shipped silently for 3 days.",
|
||||
"groups": [
|
||||
{
|
||||
"name": "Core",
|
||||
"description": "Database, app URL, encryption keys. The app refuses to boot without these.",
|
||||
"required": true,
|
||||
"variables": [
|
||||
{
|
||||
"name": "DATABASE_URL",
|
||||
"purpose": "Postgres connection string. App boot fails if missing.",
|
||||
"example": "postgresql://healthlog:<pw>@db:5432/healthlog?schema=public"
|
||||
},
|
||||
{
|
||||
"name": "POSTGRES_PASSWORD",
|
||||
"purpose": "Database password for the bundled `db` service in docker-compose.yml.",
|
||||
"example": "<openssl rand -base64 24>"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_APP_URL",
|
||||
"purpose": "Public URL embedded in OAuth redirects, webhook callbacks, APNs deep-links."
|
||||
},
|
||||
{
|
||||
"name": "APP_URL",
|
||||
"purpose": "Server-side mirror of NEXT_PUBLIC_APP_URL; used by background workers."
|
||||
},
|
||||
{
|
||||
"name": "ENCRYPTION_KEY",
|
||||
"purpose": "AES-256-GCM key for at-rest encryption of provider tokens. 64 hex chars (32 bytes). Required unless ENCRYPTION_KEYS is set."
|
||||
},
|
||||
{
|
||||
"name": "API_TOKEN_HMAC_KEY",
|
||||
"purpose": "HMAC-SHA256 key for hashing external Bearer tokens. 64 hex chars."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Withings OAuth",
|
||||
"description": "Required to enable the Withings integration. App boots without these but Withings sync stays disabled.",
|
||||
"required": false,
|
||||
"variables": [
|
||||
{
|
||||
"name": "WITHINGS_CLIENT_ID",
|
||||
"purpose": "Withings OAuth client_id from https://developer.withings.com."
|
||||
},
|
||||
{
|
||||
"name": "WITHINGS_CLIENT_SECRET",
|
||||
"purpose": "Withings OAuth client_secret."
|
||||
},
|
||||
{
|
||||
"name": "WITHINGS_WEBHOOK_SECRET",
|
||||
"purpose": "Random secret carried as a path segment in the Withings webhook callback URL. Generate with `openssl rand -hex 32`."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "APNs (iOS push)",
|
||||
"description": "All four core APNS_* vars together. Missing any disables APNs silently — the v1.4.40 AP-2 gap that motivated this manifest. allOrNone trips the synthetic <all-or-none> required row whenever 1-3 of the 4 are set.",
|
||||
"required": false,
|
||||
"allOrNone": true,
|
||||
"variables": [
|
||||
{
|
||||
"name": "APNS_KEY_ID",
|
||||
"purpose": "10-char Key ID from Apple Developer."
|
||||
},
|
||||
{
|
||||
"name": "APNS_TEAM_ID",
|
||||
"purpose": "10-char Team ID from Apple Developer."
|
||||
},
|
||||
{
|
||||
"name": "APNS_BUNDLE_ID",
|
||||
"purpose": "iOS app bundle id, e.g. dev.healthlog.ios."
|
||||
},
|
||||
{
|
||||
"name": "APNS_KEY",
|
||||
"purpose": "APNs auth key (.p8 contents). Use APNS_KEY for 12-factor env-block deploys; APNS_KEY_FILE is the filesystem alternative — at least one of the two must be set.",
|
||||
"anyOf": ["APNS_KEY", "APNS_KEY_FILE"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Deploy webhook",
|
||||
"description": "Coolify -> HealthLog deploy-result webhook. Without this, the auto-deploy success/failure feedback loop is broken.",
|
||||
"required": false,
|
||||
"variables": [
|
||||
{
|
||||
"name": "DEPLOY_WEBHOOK_SECRET",
|
||||
"purpose": "Shared secret for the X-Deploy-Webhook-Secret header on POST /api/internal/deploy-webhook. 64 hex chars."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Off-host backups",
|
||||
"description": "Required ALL-OR-NONE: any of these without the rest disables backups silently.",
|
||||
"required": false,
|
||||
"allOrNone": true,
|
||||
"variables": [
|
||||
{ "name": "BACKUP_S3_ENDPOINT", "purpose": "S3-compatible endpoint URL." },
|
||||
{ "name": "BACKUP_S3_BUCKET", "purpose": "Bucket name." },
|
||||
{ "name": "BACKUP_S3_ACCESS_KEY", "purpose": "S3 access key." },
|
||||
{ "name": "BACKUP_S3_SECRET_KEY", "purpose": "S3 secret key." },
|
||||
{ "name": "BACKUP_ENCRYPTION_KEY", "purpose": "Separate AES key for the backup body — MUST NOT match ENCRYPTION_KEY." }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* v1.4.42 W2 — multi-issue 422 envelope on PUT.
|
||||
*
|
||||
* Until v1.4.41 this route returned `parsed.error.issues[0].message`,
|
||||
* which dropped every issue past the first. iOS contract debugging
|
||||
* needed one round-trip per wrong field. The route now returns every
|
||||
* issue under `details.issues` AND writes a
|
||||
* `dashboard.widgets.validation-failed` audit-ledger row so the
|
||||
* operator can grep `/api/admin/audit` for the same trail.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
},
|
||||
toJson: (v: unknown) => v,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
getSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({
|
||||
emitIfSampled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { PUT } 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) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "tester",
|
||||
role: "USER" as const,
|
||||
displayName: null,
|
||||
},
|
||||
};
|
||||
|
||||
const callPut = PUT as unknown as (req: NextRequest) => Promise<Response>;
|
||||
|
||||
function makeReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/dashboard/widgets", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
__resetAllCachesForTests();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)", () => {
|
||||
it("surfaces TWO simultaneous validation errors under details.issues", async () => {
|
||||
// version=2 (literal mismatch) + widgets=[] (min(1) violation).
|
||||
const res = await callPut(makeReq({ version: 2, widgets: [] }));
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBe(2);
|
||||
const paths = body.details.issues.map((i) => i.path).sort();
|
||||
expect(paths).toEqual(["version", "widgets"]);
|
||||
|
||||
// Every issue carries exactly path / code / message — issue.params
|
||||
// never leaks (it may echo the offending user input for some codes).
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
const res = await callPut(
|
||||
makeReq({
|
||||
version: 99,
|
||||
widgets: [],
|
||||
comparisonBaseline: "tomorrow",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<{ path: string; code: string }> };
|
||||
};
|
||||
expect(body.details.issues.length).toBe(3);
|
||||
const paths = body.details.issues.map((i) => i.path).sort();
|
||||
expect(paths).toEqual(["comparisonBaseline", "version", "widgets"]);
|
||||
});
|
||||
|
||||
it("writes one audit-ledger row keyed dashboard.widgets.validation-failed", async () => {
|
||||
const res = await callPut(makeReq({ version: 2, widgets: [] }));
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
// The audit row is fire-and-forget — let the microtask queue drain.
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
|
||||
data: { userId: string; action: string; details: string };
|
||||
};
|
||||
expect(call.data.userId).toBe("user-1");
|
||||
expect(call.data.action).toBe("dashboard.widgets.validation-failed");
|
||||
const details = JSON.parse(call.data.details) as {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
expect(details.issues.length).toBe(2);
|
||||
for (const issue of details.issues) {
|
||||
// Audit row carries the same sanitised shape — issue.params never
|
||||
// hits the ledger either.
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not block the 422 response when the audit-row write rejects", async () => {
|
||||
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
|
||||
new Error("db down"),
|
||||
);
|
||||
|
||||
const res = await callPut(makeReq({ version: 2, widgets: [] }));
|
||||
// The response is the contract — the audit row is best-effort.
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,12 @@
|
||||
* to default.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
safeJson,
|
||||
returnAllZodIssues,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import {
|
||||
@@ -126,7 +131,31 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const parsed = layoutSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
// v1.4.42 W2 — the legacy `issues[0].message` envelope dropped
|
||||
// every issue past the first. iOS contract debugging hit one
|
||||
// round-trip per wrong field; the new helper surfaces every
|
||||
// issue under `details.issues`. We additionally emit an
|
||||
// append-only audit-ledger row so the operator can grep
|
||||
// `/api/admin/audit` for these without combing the iOS dev
|
||||
// console.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
annotate({
|
||||
action: { name: "dashboard.widgets.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
// Best-effort breadcrumb — never block the 422 on a write miss.
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "dashboard.widgets.validation-failed",
|
||||
details: JSON.stringify({ issues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — validation response is the contract, audit row is best-effort */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
// v1.4.18 — preserve any per-chart overlay prefs that the client
|
||||
|
||||
@@ -225,8 +225,17 @@ describe("POST /api/workouts/batch — per-entry status envelope", () => {
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
workouts: [
|
||||
// Two distinct runs separated by 2 h so the v1.4.42 W5
|
||||
// write-time canonical-row picker treats them as separate
|
||||
// workouts. Without the spacing the picker would collapse
|
||||
// them to one survivor (same userId + sportType + startedAt
|
||||
// inside the 90 s dedup window) and the envelope-count
|
||||
// assertion would mask the per-entry status pin.
|
||||
validWorkout("uuid-fresh-1"),
|
||||
validWorkout("uuid-fresh-2"),
|
||||
validWorkout("uuid-fresh-2", {
|
||||
startedAt: "2026-05-14T08:30:00.000Z",
|
||||
endedAt: "2026-05-14T09:15:00.000Z",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
@@ -253,8 +262,14 @@ describe("POST /api/workouts/batch — per-entry status envelope", () => {
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
workouts: [
|
||||
// Same spacing rationale as the prior test — keep the two
|
||||
// entries' `startedAt` outside the W5 dedup window so the
|
||||
// pre-`createMany` pass doesn't collapse them.
|
||||
validWorkout("uuid-existing"),
|
||||
validWorkout("uuid-fresh"),
|
||||
validWorkout("uuid-fresh", {
|
||||
startedAt: "2026-05-14T08:30:00.000Z",
|
||||
endedAt: "2026-05-14T09:15:00.000Z",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
@@ -284,8 +299,15 @@ describe("POST /api/workouts/batch — per-entry status envelope", () => {
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
workouts: [
|
||||
// Same spacing rationale as the per-entry tests — keep both
|
||||
// entries outside the W5 dedup window so the race-reconcile
|
||||
// contract is exercised directly without the write-time
|
||||
// canonical-picker absorbing one of the rows first.
|
||||
validWorkout("uuid-race-1"),
|
||||
validWorkout("uuid-race-2"),
|
||||
validWorkout("uuid-race-2", {
|
||||
startedAt: "2026-05-14T08:30:00.000Z",
|
||||
endedAt: "2026-05-14T09:15:00.000Z",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -68,7 +68,8 @@ import {
|
||||
createBatchWorkoutSchema,
|
||||
MAX_WORKOUTS_PER_BATCH,
|
||||
} from "@/lib/validations/workout";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { dedupeWorkoutBatch } from "@/lib/workouts/canonical-rows";
|
||||
import { Prisma, type MeasurementSource } from "@/generated/prisma/client";
|
||||
|
||||
// v1.4.25 W16c — push-suppression threshold for workout PRs. A batch
|
||||
// larger than this fires the detection job with `silent: true` so a
|
||||
@@ -254,11 +255,56 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
};
|
||||
});
|
||||
|
||||
// v1.4.42 W5 — write-time cross-source dedup. The v1.5 iOS app
|
||||
// drains a HealthKit observer queue that frequently carries the
|
||||
// SAME logical workout from two paired sensors (Apple Watch +
|
||||
// Withings ScanWatch). Without this pass, both rows persist (their
|
||||
// `externalId`s differ → the `(userId, source, externalId)` unique
|
||||
// index never fires) and the read-time canonical-picker has to
|
||||
// carry the duplicate forward on every dashboard render.
|
||||
//
|
||||
// The helper is pure (no Prisma, no user lookups). It groups by
|
||||
// `(userId, activityType, startedAt ± 90 s)`, prefers the canonical
|
||||
// source ladder (APPLE_HEALTH > WITHINGS > MANUAL > IMPORT), and
|
||||
// breaks ties on calories > earliest createdAt > input order. Rows
|
||||
// dropped here surface to the iOS client as `duplicate` so the
|
||||
// sync cursor advances past them identically to the externalId
|
||||
// case below.
|
||||
const canonicalPrepared = dedupeWorkoutBatch(
|
||||
prepared.map((p) => ({
|
||||
userId: p.row.userId,
|
||||
activityType: p.row.sportType,
|
||||
startedAt: p.row.startedAt as Date,
|
||||
// Zod's `measurementSourceEnum.optional().default("MANUAL")`
|
||||
// guarantees `source` is set post-parse, but Prisma's
|
||||
// `WorkoutCreateManyInput` types it optional. Default through
|
||||
// the same fallback the validator uses so the picker's source
|
||||
// ladder still resolves on the rare row where TypeScript
|
||||
// can't prove the union.
|
||||
source: (p.row.source ?? "MANUAL") as MeasurementSource,
|
||||
caloriesKcal: p.row.totalEnergyKcal ?? null,
|
||||
// No createdAt yet — the row is pre-insert; the tie-breaker
|
||||
// falls through to input order via the `index` field below.
|
||||
index: p.index,
|
||||
})),
|
||||
);
|
||||
const survivingIndices = new Set(canonicalPrepared.map((r) => r.index ?? -1));
|
||||
const droppedByWriteDedup: number[] = [];
|
||||
for (const p of prepared) {
|
||||
if (!survivingIndices.has(p.index)) {
|
||||
results[p.index] = { index: p.index, status: "duplicate" };
|
||||
droppedByWriteDedup.push(p.index);
|
||||
}
|
||||
}
|
||||
const survivors = prepared.filter((p) => survivingIndices.has(p.index));
|
||||
let duplicateCount = droppedByWriteDedup.length;
|
||||
let insertedCount = 0;
|
||||
let duplicateCount = 0;
|
||||
|
||||
if (prepared.length > 0) {
|
||||
const dedupCandidates = prepared
|
||||
if (survivors.length > 0) {
|
||||
// v1.4.42 W5 — only the write-time dedup survivors are probed
|
||||
// against existing rows; the dropped twins already carry a
|
||||
// `duplicate` status and never reach the DB.
|
||||
const dedupCandidates = survivors
|
||||
.map((p) => p.dedupKey)
|
||||
.filter((k): k is { source: string; externalId: string } => k !== null);
|
||||
|
||||
@@ -280,7 +326,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
);
|
||||
|
||||
const toInsert: Prepared[] = [];
|
||||
for (const p of prepared) {
|
||||
for (const p of survivors) {
|
||||
const keyTuple = p.dedupKey
|
||||
? `${p.dedupKey.source}::${p.dedupKey.externalId}`
|
||||
: null;
|
||||
@@ -461,7 +507,10 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
const racedDuplicates = toInsert.length - insertedCount;
|
||||
if (racedDuplicates > 0) {
|
||||
let downgraded = 0;
|
||||
for (const p of prepared) {
|
||||
// v1.4.42 W5 — walk `survivors` (not `prepared`) so the
|
||||
// already-downgraded-by-write-dedup losers don't get
|
||||
// double-counted toward the race-reconcile budget.
|
||||
for (const p of survivors) {
|
||||
if (downgraded >= racedDuplicates) break;
|
||||
if (results[p.index]?.status === "inserted") {
|
||||
results[p.index] = { index: p.index, status: "duplicate" };
|
||||
|
||||
@@ -13,6 +13,7 @@ import { IntakeHistoryListV2 } from "@/components/medications/intake-history-lis
|
||||
import { ArrowLeft, Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
export default function IntakeHistoryPage({
|
||||
params,
|
||||
@@ -31,7 +32,7 @@ export default function IntakeHistoryPage({
|
||||
}, [authLoading, isAuthenticated, router]);
|
||||
|
||||
const { data: medication, isLoading: medLoading } = useQuery({
|
||||
queryKey: ["medications", id],
|
||||
queryKey: queryKeys.medicationDetail(id),
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/medications/${id}`);
|
||||
if (!res.ok) throw new Error("Failed to fetch");
|
||||
|
||||
@@ -4,7 +4,11 @@ import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
invalidateKeys,
|
||||
medicationDependentKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import { MedicationForm } from "@/components/medications/medication-form";
|
||||
import { MedicationCard } from "@/components/medications/medication-card";
|
||||
@@ -90,7 +94,7 @@ export default function MedicationsPage() {
|
||||
isError,
|
||||
refetch: refetchMedications,
|
||||
} = useQuery({
|
||||
queryKey: ["medications"],
|
||||
queryKey: queryKeys.medications(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/medications");
|
||||
if (!res.ok) throw new Error("Failed to fetch");
|
||||
|
||||
+6
-18
@@ -1400,32 +1400,20 @@ export default function DashboardPage() {
|
||||
{trendCards.map((entry) => (
|
||||
<div key={entry.id} className="flex min-w-0">
|
||||
{/*
|
||||
* v1.4.40 W-RSC — per-tile `<Suspense>` boundary.
|
||||
* The tile body is synchronous today (every tile
|
||||
* reads from the merged `data` memo which already
|
||||
* gates on the slim/thick analytics queries on the
|
||||
* parent), so this fallback never paints in the
|
||||
* current composition. The structural boundary
|
||||
* matches the chart-row layer below so the entire
|
||||
* dashboard composition reads as a streaming-ready
|
||||
* grid of independently suspending cells — and
|
||||
* future RSC hoists of any tile slot won't need a
|
||||
* second pass to add the fallback infrastructure.
|
||||
*/}
|
||||
{/*
|
||||
* v1.4.41 W-FRONTEND-FACTORY (UX M1) — swap the
|
||||
* `null` fallback for a layout-stable placeholder
|
||||
* that mirrors the trend-card chrome. The body
|
||||
* stays synchronous today so the fallback rarely
|
||||
* Per-tile `<Suspense>` boundary with a layout-stable
|
||||
* placeholder that mirrors the trend-card chrome. Tile
|
||||
* bodies are synchronous today so the fallback rarely
|
||||
* paints, but a future RSC hoist of any tile slot
|
||||
* would otherwise leave the grid track empty and
|
||||
* trigger CLS as the cell paints in.
|
||||
*
|
||||
* v1.4.40 W-RSC — boundary added; v1.4.41 W-FRONTEND-FACTORY — fallback hoisted to layout-stable placeholder.
|
||||
*/}
|
||||
<Suspense
|
||||
fallback={
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="bg-card border-border h-full w-full rounded-xl border p-4 md:p-6"
|
||||
className="bg-card border-border flex h-full min-h-[6rem] w-full min-w-0 flex-col rounded-xl border p-4 md:p-6"
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AlertCircle, Loader2 } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
import {
|
||||
@@ -46,9 +47,8 @@ interface TargetsResponse {
|
||||
/**
|
||||
* v1.4.25 W3e — provider-chain status used to gate the per-card Coach
|
||||
* CTA. Returns true when at least one provider is configured AND
|
||||
* enabled. The Settings → AI surface owns the same query
|
||||
* (`useQuery({ queryKey: ["insights", "provider-chain"] })`) so the
|
||||
* cache is shared.
|
||||
* enabled. The Settings → AI surface owns the same query (via
|
||||
* `queryKeys.insightsProviderChain()`) so the cache is shared.
|
||||
*/
|
||||
interface ProviderChainStatus {
|
||||
activeProvider: string | null;
|
||||
@@ -132,7 +132,7 @@ export default function TargetsPage() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["insights", "targets"],
|
||||
queryKey: queryKeys.insightsTargets(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/insights/targets");
|
||||
if (!res.ok) throw new Error(t("targets.loadError"));
|
||||
@@ -146,7 +146,7 @@ export default function TargetsPage() {
|
||||
// user has no configured provider, `activeProvider` is null and the
|
||||
// per-card Coach CTA hides entirely (no broken-button state).
|
||||
const { data: chainStatus } = useQuery({
|
||||
queryKey: ["insights", "provider-chain"],
|
||||
queryKey: queryKeys.insightsProviderChain(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/insights/provider-chain");
|
||||
if (!res.ok) return null;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { toast } from "sonner";
|
||||
import { Cpu, Fingerprint, Globe, KeyRound } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { PasswordInput as SharedPasswordInput } from "@/components/ui/password-input";
|
||||
|
||||
/**
|
||||
@@ -225,7 +226,7 @@ export function SettingsToggle({
|
||||
|
||||
export function useAdminSettings() {
|
||||
return useQuery({
|
||||
queryKey: ["admin", "settings"],
|
||||
queryKey: queryKeys.adminSettings(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/admin/settings");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
@@ -249,7 +250,7 @@ export function useUpdateSettings() {
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "settings"] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.adminSettings() });
|
||||
toast.success(t("common.saved"));
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -523,7 +524,7 @@ export function auditLogCsvHeaderLabels(
|
||||
|
||||
export function useSystemStatus() {
|
||||
return useQuery({
|
||||
queryKey: ["admin", "status"],
|
||||
queryKey: queryKeys.adminStatus(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/admin/status");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Sparkles, Loader2 } from "lucide-react";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
interface FeedbackBucket {
|
||||
severity: string;
|
||||
@@ -63,7 +64,7 @@ export function AiQualitySection() {
|
||||
const fmt = useFormatters();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["admin", "ai-quality"],
|
||||
queryKey: queryKeys.adminAiQuality(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/admin/ai-quality");
|
||||
const json = (await res.json()) as AiQualityResponse;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { formatDate, formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { type ApiTokenInfo } from "./_shared";
|
||||
|
||||
/**
|
||||
@@ -147,7 +148,7 @@ export function ApiTokenOverviewSection() {
|
||||
// one card the toggle hides the entire surface. the maintainer reported it
|
||||
// as "sinnlos" — gone.
|
||||
const { data: tokens, isLoading } = useQuery({
|
||||
queryKey: ["admin", "tokens"],
|
||||
queryKey: queryKeys.adminTokens(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/admin/tokens");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { WideEvent, LogLevel } from "@/lib/logging/types";
|
||||
|
||||
type RangePreset = "15m" | "1h" | "6h" | "all";
|
||||
@@ -100,7 +101,7 @@ export function AppLogPreviewSection() {
|
||||
}, [traceId, actionFilter, level, range]);
|
||||
|
||||
const { data, isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ["admin", "app-logs", traceId, actionFilter, level, range],
|
||||
queryKey: queryKeys.adminAppLogs(traceId, actionFilter, level, range),
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/admin/app-logs?${params.toString()}`);
|
||||
if (!res.ok) throw new Error("Failed to load app logs");
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { SettingsToggle, getApiErrorMessage } from "./_shared";
|
||||
|
||||
/**
|
||||
@@ -45,7 +46,7 @@ interface AssistantFlagsResponse {
|
||||
|
||||
function useAssistantFlags() {
|
||||
return useQuery({
|
||||
queryKey: ["admin", "settings", "assistant-flags"],
|
||||
queryKey: queryKeys.adminAssistantFlags(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/admin/settings/assistant-flags");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
@@ -73,12 +74,12 @@ function useUpdateAssistantFlags() {
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
client.setQueryData(
|
||||
["admin", "settings", "assistant-flags"],
|
||||
queryKeys.adminAssistantFlags(),
|
||||
data,
|
||||
);
|
||||
// Bust the runtime `/api/feature-flags` cache so the operator
|
||||
// sees the toggled surface react within the same session.
|
||||
client.invalidateQueries({ queryKey: ["feature-flags"] });
|
||||
client.invalidateQueries({ queryKey: queryKeys.featureFlags() });
|
||||
toast.success(t("common.saved"));
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -88,7 +89,7 @@ function useUpdateAssistantFlags() {
|
||||
: t("admin.settingsSaveError"),
|
||||
);
|
||||
client.invalidateQueries({
|
||||
queryKey: ["admin", "settings", "assistant-flags"],
|
||||
queryKey: queryKeys.adminAssistantFlags(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -41,6 +41,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { BackupRow, BackupsList } from "@/types/backups";
|
||||
import { getApiErrorMessage } from "./_shared";
|
||||
|
||||
@@ -172,7 +173,7 @@ export function BackupsSection() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ["admin", "backups"],
|
||||
queryKey: queryKeys.adminBackups(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/admin/backups");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
@@ -194,7 +195,7 @@ export function BackupsSection() {
|
||||
// shows up without leaving the user wondering whether the click
|
||||
// did anything.
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "backups"] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.adminBackups() });
|
||||
}, 2000);
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -245,7 +246,7 @@ export function BackupsSection() {
|
||||
toast.success(
|
||||
t("admin.section.backups.uploadSuccess", { count: String(total) }),
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "backups"] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.adminBackups() });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
@@ -290,7 +291,7 @@ export function BackupsSection() {
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t("admin.section.backups.restoreSuccess"));
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "backups"] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.adminBackups() });
|
||||
// Restore touches every personal-data table; nuke the broader
|
||||
// cache so dashboards / lists rebuild against the new state.
|
||||
queryClient.invalidateQueries();
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2, Sparkles } from "lucide-react";
|
||||
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
interface CoachFeedbackBucket {
|
||||
promptVersion: string;
|
||||
@@ -55,7 +56,7 @@ export function CoachFeedbackSection() {
|
||||
const fmt = useFormatters();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["admin", "coach-feedback"],
|
||||
queryKey: queryKeys.adminCoachFeedback(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/admin/ai-quality");
|
||||
const json = (await res.json()) as CoachFeedbackResponse;
|
||||
|
||||
@@ -30,6 +30,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
FEEDBACK_STATUS_TABS,
|
||||
type FeedbackCategoryType,
|
||||
@@ -50,7 +51,7 @@ export function FeedbackInboxSection() {
|
||||
const [selected, setSelected] = useState<FeedbackItem | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["admin", "feedback", activeStatus],
|
||||
queryKey: queryKeys.adminFeedback(activeStatus),
|
||||
queryFn: async () => {
|
||||
const res = await fetch(
|
||||
`/api/admin/feedback?status=${activeStatus}&limit=100`,
|
||||
@@ -63,7 +64,7 @@ export function FeedbackInboxSection() {
|
||||
const counts = data?.meta?.countsByStatus ?? {};
|
||||
|
||||
function refresh() {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "feedback"] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.adminFeedbackRoot() });
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from "recharts";
|
||||
import { useMemo } from "react";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
interface HostMetricsApiSample {
|
||||
capturedAt: string;
|
||||
@@ -130,7 +131,7 @@ export function HostMetricsChart() {
|
||||
const fmt = useFormatters();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ["admin", "host-metrics", "2h"],
|
||||
queryKey: queryKeys.adminHostMetrics("2h"),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/admin/host-metrics?since=2h");
|
||||
if (!res.ok) throw new Error("Failed to load host metrics");
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { toCSV } from "@/lib/export";
|
||||
import { formatInUserTz, DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
@@ -114,10 +115,7 @@ export function LoginOverviewSection() {
|
||||
}, [page, perPage, actor, actionFilter, target, range]);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: [
|
||||
"admin",
|
||||
"audit-log",
|
||||
"filtered",
|
||||
queryKey: queryKeys.adminAuditLogFiltered({
|
||||
filter,
|
||||
page,
|
||||
perPage,
|
||||
@@ -125,7 +123,7 @@ export function LoginOverviewSection() {
|
||||
actionFilter,
|
||||
target,
|
||||
range,
|
||||
],
|
||||
}),
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams(queryParams);
|
||||
// Quick-filter pill stacks on top of the new filters.
|
||||
@@ -140,7 +138,7 @@ export function LoginOverviewSection() {
|
||||
|
||||
// Distinct actions for the dropdown — populated lazily.
|
||||
const { data: actionsData } = useQuery({
|
||||
queryKey: ["admin", "audit-log", "actions"],
|
||||
queryKey: queryKeys.adminAuditActions(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/admin/audit-log/actions");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { type AdminAuditEntry, useAuthActionLabels } from "./_shared";
|
||||
|
||||
const PREVIEW_LIMIT = 10;
|
||||
@@ -34,7 +35,7 @@ export function RecentAuditPreview() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ["admin", "audit-log", "overview-preview"],
|
||||
queryKey: queryKeys.adminAuditOverview(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch(
|
||||
`/api/admin/audit-log?limit=${PREVIEW_LIMIT}&filter=auth`,
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { StatusItem, useSystemStatus } from "./_shared";
|
||||
|
||||
interface VersionResponse {
|
||||
@@ -33,7 +34,7 @@ interface VersionResponse {
|
||||
|
||||
function useOfflineGeoState() {
|
||||
return useQuery({
|
||||
queryKey: ["public", "version"],
|
||||
queryKey: queryKeys.publicVersion(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/version");
|
||||
if (!res.ok) throw new Error("Failed to load version");
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { StatusItem, useSystemStatus } from "./_shared";
|
||||
|
||||
interface VersionResponse {
|
||||
@@ -36,7 +37,7 @@ interface VersionResponse {
|
||||
|
||||
function useVersion() {
|
||||
return useQuery({
|
||||
queryKey: ["public", "version"],
|
||||
queryKey: queryKeys.publicVersion(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/version");
|
||||
if (!res.ok) throw new Error("Failed to load version");
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { type AdminUser, PasswordInput } from "./_shared";
|
||||
|
||||
/**
|
||||
@@ -49,7 +50,7 @@ export function UserManagementSection() {
|
||||
const [logoutTarget, setLogoutTarget] = useState<AdminUser | null>(null);
|
||||
|
||||
const { data: users } = useQuery({
|
||||
queryKey: ["admin", "users"],
|
||||
queryKey: queryKeys.adminUsers(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/admin/users");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
@@ -81,7 +82,7 @@ export function UserManagementSection() {
|
||||
if (!res.ok) throw new Error(json.error || t("common.error"));
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "users"] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.adminUsers() });
|
||||
setEditingUser(null);
|
||||
toast.success(t("common.saved"));
|
||||
},
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
computeOneCompartment,
|
||||
type DoseEvent,
|
||||
@@ -129,7 +130,7 @@ export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) {
|
||||
|
||||
const { data: researchMode, isLoading: rmLoading } =
|
||||
useQuery<ResearchModeStatus | null>({
|
||||
queryKey: ["research-mode"],
|
||||
queryKey: queryKeys.researchMode(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/auth/me/research-mode");
|
||||
if (!res.ok) return null;
|
||||
@@ -146,7 +147,7 @@ export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) {
|
||||
|
||||
const { data: details, isLoading: detailsLoading } =
|
||||
useQuery<Glp1DetailsResponse | null>({
|
||||
queryKey: ["medications", medication.id, "glp1-details"],
|
||||
queryKey: queryKeys.medicationGlp1Details(medication.id),
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/medications/${medication.id}/glp1`);
|
||||
if (!res.ok) return null;
|
||||
@@ -160,7 +161,7 @@ export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) {
|
||||
});
|
||||
|
||||
const { data: intakeEnvelope, isLoading: intakeLoading } = useQuery({
|
||||
queryKey: ["medications", medication.id, "intake", "drug-level-chart"],
|
||||
queryKey: queryKeys.medicationIntakeDrugLevelChart(medication.id),
|
||||
queryFn: async () => {
|
||||
const res = await fetch(
|
||||
`/api/medications/${medication.id}/intake?limit=20&sortBy=takenAt&sortDir=desc`,
|
||||
|
||||
@@ -45,6 +45,7 @@ import { AlertTriangle, BookOpenCheck, Loader2 } from "lucide-react";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
export interface ResearchModeAcknowledgmentDialogProps {
|
||||
open: boolean;
|
||||
@@ -94,7 +95,7 @@ export function ResearchModeAcknowledgmentDialog({
|
||||
onSuccess: () => {
|
||||
setErrorMessage(null);
|
||||
toast.success(t("medications.researchMode.dialog.successToast"));
|
||||
queryClient.invalidateQueries({ queryKey: ["research-mode"] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.researchMode() });
|
||||
onAcknowledged?.();
|
||||
onOpenChange(false);
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Loader2, BellRing, BellOff, Pencil } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
|
||||
|
||||
/**
|
||||
@@ -92,7 +93,7 @@ export function SchedulingSection({
|
||||
const fmt = useFormatters();
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["medications", medicationId, "cadence"],
|
||||
queryKey: queryKeys.medicationCadence(medicationId),
|
||||
queryFn: async (): Promise<CadenceResponse> => {
|
||||
const res = await fetch(
|
||||
`/api/medications/${medicationId}/cadence?days=30`,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2, ExternalLink } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
|
||||
|
||||
/**
|
||||
@@ -62,7 +63,7 @@ export function TitrationSection({ medicationId }: TitrationSectionProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["medications", medicationId, "titration"],
|
||||
queryKey: queryKeys.medicationTitration(medicationId),
|
||||
queryFn: async (): Promise<TitrationResponse> => {
|
||||
const res = await fetch(`/api/medications/${medicationId}/titration`);
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { IntakeHistoryListV2 } from "@/components/medications/intake-history-list-v2";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
* v1.4.36 W4a — IntakeHistoryListV2 SSR smoke tests.
|
||||
@@ -62,16 +63,17 @@ function seedIntake(
|
||||
const limit = opts.limit ?? 25;
|
||||
const offset = opts.offset ?? 0;
|
||||
// v1.4.37 W3 — the component pins `status:"completed"` on the
|
||||
// server-side query and threads it through the cache key, so the
|
||||
// seed mirrors that shape verbatim.
|
||||
// server-side query and threads it through the cache key. The seed
|
||||
// routes through the canonical factory so the params shape stays in
|
||||
// lockstep with the component's read.
|
||||
client.setQueryData(
|
||||
[
|
||||
"medications",
|
||||
medId,
|
||||
"intake",
|
||||
"list",
|
||||
{ sortBy, sortDir, limit, offset, status: "completed" },
|
||||
],
|
||||
queryKeys.medicationIntakeList(medId, {
|
||||
sortBy,
|
||||
sortDir,
|
||||
limit,
|
||||
offset,
|
||||
status: "completed",
|
||||
}),
|
||||
{ events: rows, meta: { total, limit, offset } },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,12 +29,15 @@ import {
|
||||
import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
invalidateKeys,
|
||||
medicationDependentKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import { formatDateTime, formatTime } from "@/lib/format";
|
||||
import { formatTimeWindowRange } from "@/lib/time-window-format";
|
||||
import { getMedicationCategoryLabel } from "@/lib/medications/category-label";
|
||||
import {
|
||||
describeInjectionSite,
|
||||
nextInjectionSite,
|
||||
type InjectionSiteKey,
|
||||
} from "@/lib/medications/injection-sites";
|
||||
@@ -179,7 +182,7 @@ export function Glp1MedicationCard({
|
||||
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
|
||||
|
||||
const { data: compliance } = useQuery({
|
||||
queryKey: ["medications", medication.id, "compliance"],
|
||||
queryKey: queryKeys.medicationCompliance(medication.id),
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/medications/${medication.id}/compliance`);
|
||||
if (!res.ok) return null;
|
||||
@@ -194,7 +197,7 @@ export function Glp1MedicationCard({
|
||||
// medication card so the take-now / overdue / very-overdue pill
|
||||
// tiers identically on both surfaces.
|
||||
const { data: thresholds } = useQuery({
|
||||
queryKey: ["settings", "reminder-thresholds"],
|
||||
queryKey: queryKeys.settingsReminderThresholds(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/settings/reminder-thresholds");
|
||||
if (!res.ok) return null;
|
||||
@@ -208,7 +211,7 @@ export function Glp1MedicationCard({
|
||||
// pen inventory). Lives behind a dedicated endpoint so the standard
|
||||
// medications grid loads in parallel without paying for it.
|
||||
const { data: details } = useQuery({
|
||||
queryKey: ["medications", medication.id, "glp1-details"],
|
||||
queryKey: queryKeys.medicationGlp1Details(medication.id),
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/medications/${medication.id}/glp1`);
|
||||
if (!res.ok) return null;
|
||||
@@ -587,6 +590,3 @@ function siteSuffix(site: InjectionSiteKey): string {
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** Re-export so the parent doesn't need to import describeInjectionSite. */
|
||||
export { describeInjectionSite };
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -111,13 +112,13 @@ export function IntakeHistoryListV2({
|
||||
const offset = page * pageSize;
|
||||
|
||||
const { data, isLoading, isError } = useQuery<IntakeListResponse>({
|
||||
queryKey: [
|
||||
"medications",
|
||||
medicationId,
|
||||
"intake",
|
||||
"list",
|
||||
{ sortBy, sortDir, limit: pageSize, offset, status: STATUS_FILTER },
|
||||
],
|
||||
queryKey: queryKeys.medicationIntakeList(medicationId, {
|
||||
sortBy,
|
||||
sortDir,
|
||||
limit: pageSize,
|
||||
offset,
|
||||
status: STATUS_FILTER,
|
||||
}),
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({
|
||||
sortBy,
|
||||
|
||||
@@ -29,7 +29,11 @@ import {
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
invalidateKeys,
|
||||
medicationDependentKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
|
||||
interface Schedule {
|
||||
id: string;
|
||||
@@ -128,7 +132,7 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
|
||||
const [intakeLoading, setIntakeLoading] = useState<string | null>(null);
|
||||
|
||||
const { data: compliance } = useQuery({
|
||||
queryKey: ["medications", medication.id, "compliance"],
|
||||
queryKey: queryKeys.medicationCompliance(medication.id),
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/medications/${medication.id}/compliance`);
|
||||
if (!res.ok) return null;
|
||||
@@ -141,7 +145,7 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
|
||||
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
|
||||
|
||||
const { data: thresholds } = useQuery({
|
||||
queryKey: ["settings", "reminder-thresholds"],
|
||||
queryKey: queryKeys.settingsReminderThresholds(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/settings/reminder-thresholds");
|
||||
if (!res.ok) return null;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Loader2, RotateCcw } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
interface PhaseConfigData {
|
||||
greenValue: number;
|
||||
@@ -76,7 +77,7 @@ export function PhaseConfigDialog({
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["phase-config", medicationId],
|
||||
queryKey: queryKeys.medicationPhaseConfig(medicationId),
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/medications/${medicationId}/phase-config`);
|
||||
const json = await res.json();
|
||||
@@ -101,7 +102,7 @@ export function PhaseConfigDialog({
|
||||
onSuccess: () => {
|
||||
setLocalForm(null);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["phase-config", medicationId],
|
||||
queryKey: queryKeys.medicationPhaseConfig(medicationId),
|
||||
});
|
||||
setStatusMessage(t("medications.phaseSaved"));
|
||||
setTimeout(() => setStatusMessage(null), 2000);
|
||||
@@ -119,7 +120,7 @@ export function PhaseConfigDialog({
|
||||
onSuccess: () => {
|
||||
setLocalForm(null);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["phase-config", medicationId],
|
||||
queryKey: queryKeys.medicationPhaseConfig(medicationId),
|
||||
});
|
||||
setStatusMessage(t("medications.phaseReset"));
|
||||
setTimeout(() => setStatusMessage(null), 2000);
|
||||
|
||||
@@ -44,6 +44,7 @@ import { formatDate } from "@/lib/format";
|
||||
import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { describePasskeyError } from "@/lib/passkey-errors";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { TimezonePicker } from "@/components/settings/timezone-picker";
|
||||
import { detectBrowserTimezone, DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
@@ -726,7 +727,7 @@ function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
const [deleteMsg, setDeleteMsg] = useState<string | null>(null);
|
||||
|
||||
const { data: passkeys } = useQuery({
|
||||
queryKey: ["passkeys"],
|
||||
queryKey: queryKeys.passkeys(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/auth/passkeys");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
@@ -742,7 +743,7 @@ function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
if (!res.ok) throw new Error(json.error || t("common.error"));
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["passkeys"] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
|
||||
setDeleteMsg(null);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { ResearchModeAcknowledgmentDialog } from "@/components/medications/ResearchModeAcknowledgmentDialog";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
type ResearchModeStatus,
|
||||
researchModeGateState,
|
||||
@@ -102,7 +103,7 @@ function ResearchModeCard() {
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const { data: status, isLoading } = useQuery<ResearchModeStatus | null>({
|
||||
queryKey: ["research-mode"],
|
||||
queryKey: queryKeys.researchMode(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/auth/me/research-mode");
|
||||
if (!res.ok) return null;
|
||||
@@ -140,7 +141,7 @@ function ResearchModeCard() {
|
||||
setErrorMessage(t("settings.researchMode.disableError"));
|
||||
return;
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["research-mode"] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.researchMode() });
|
||||
} catch {
|
||||
setErrorMessage(t("settings.researchMode.disableError"));
|
||||
} finally {
|
||||
|
||||
@@ -37,6 +37,7 @@ import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
interface InsightsSettings {
|
||||
codexStatus: string;
|
||||
@@ -186,7 +187,7 @@ function AiInsightsCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const { data: insightsSettings } = useQuery({
|
||||
queryKey: ["insights", "settings"],
|
||||
queryKey: queryKeys.insightsSettings(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/insights/settings");
|
||||
if (!res.ok) return null;
|
||||
@@ -197,7 +198,7 @@ function AiInsightsCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
});
|
||||
|
||||
const { data: userProvider } = useQuery({
|
||||
queryKey: ["user", "ai-provider"],
|
||||
queryKey: queryKeys.userAiProvider(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/user/ai-provider");
|
||||
if (!res.ok) return null;
|
||||
@@ -208,7 +209,7 @@ function AiInsightsCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
});
|
||||
|
||||
const { data: chainData } = useQuery({
|
||||
queryKey: ["insights", "provider-chain"],
|
||||
queryKey: queryKeys.insightsProviderChain(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/insights/provider-chain");
|
||||
if (!res.ok) return null;
|
||||
@@ -296,10 +297,10 @@ function AiInsightsCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
privacyMode={insightsSettings?.privacyMode ?? "aggregated"}
|
||||
lastInsightAt={insightsSettings?.lastInsightAt ?? null}
|
||||
onRegenerated={() =>
|
||||
queryClient.invalidateQueries({ queryKey: ["insights"] })
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() })
|
||||
}
|
||||
onPrivacyChanged={() =>
|
||||
queryClient.invalidateQueries({ queryKey: ["insights"] })
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -479,7 +480,7 @@ function CodexProviderForm({
|
||||
url.searchParams.delete("codex_error");
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
if (oauthOutcome.kind === "connected") {
|
||||
queryClient.invalidateQueries({ queryKey: ["insights"] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
|
||||
}
|
||||
}, [oauthOutcome, queryClient]);
|
||||
|
||||
@@ -536,7 +537,7 @@ function CodexProviderForm({
|
||||
setDevicePolling(false);
|
||||
setMsg(t("settings.codexConnected"));
|
||||
setMsgType("success");
|
||||
queryClient.invalidateQueries({ queryKey: ["insights"] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
|
||||
return;
|
||||
}
|
||||
if (!cancelled) setTimeout(tick, intervalMs);
|
||||
@@ -575,7 +576,7 @@ function CodexProviderForm({
|
||||
}
|
||||
setMsg(t("settings.codexDisconnected"));
|
||||
setMsgType("success");
|
||||
queryClient.invalidateQueries({ queryKey: ["insights"] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
|
||||
} catch (err) {
|
||||
setMsg(err instanceof Error ? err.message : t("settings.savingError"));
|
||||
setMsgType("error");
|
||||
@@ -793,8 +794,8 @@ function OpenAIProviderForm({
|
||||
setOk(true);
|
||||
setMsg(t("settings.ai.saved"));
|
||||
setApiKey("");
|
||||
queryClient.invalidateQueries({ queryKey: ["user", "ai-provider"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["insights"] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.userAiProvider() });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
|
||||
},
|
||||
onError: (e) => {
|
||||
setOk(false);
|
||||
@@ -979,8 +980,8 @@ function AnthropicProviderForm({
|
||||
setOk(true);
|
||||
setMsg(t("settings.ai.saved"));
|
||||
setApiKey("");
|
||||
queryClient.invalidateQueries({ queryKey: ["user", "ai-provider"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["insights"] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.userAiProvider() });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
|
||||
},
|
||||
onError: (e) => {
|
||||
setOk(false);
|
||||
@@ -1135,8 +1136,8 @@ function LocalProviderForm({
|
||||
setOk(true);
|
||||
setMsg(t("settings.ai.saved"));
|
||||
setApiKey("");
|
||||
queryClient.invalidateQueries({ queryKey: ["user", "ai-provider"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["insights"] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.userAiProvider() });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
|
||||
},
|
||||
onError: (e) => {
|
||||
setOk(false);
|
||||
@@ -1323,7 +1324,7 @@ function FallbackChainCard({
|
||||
onSuccess: () => {
|
||||
setOk(true);
|
||||
setMsg(t("settings.ai.providerChain.saved"));
|
||||
queryClient.invalidateQueries({ queryKey: ["insights"] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
|
||||
},
|
||||
onError: (e) => {
|
||||
setOk(false);
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { formatDate, formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
interface ApiTokenInfo {
|
||||
id: string;
|
||||
@@ -166,7 +167,7 @@ function ApiTokensCard() {
|
||||
const [showRevokedTokens, setShowRevokedTokens] = useState(false);
|
||||
|
||||
const { data: tokens } = useQuery({
|
||||
queryKey: ["tokens"],
|
||||
queryKey: queryKeys.tokens(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/tokens");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
@@ -191,7 +192,7 @@ function ApiTokensCard() {
|
||||
if (res.ok) {
|
||||
setNewToken(json.data.token);
|
||||
setNewName("");
|
||||
queryClient.invalidateQueries({ queryKey: ["tokens"] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tokens() });
|
||||
} else {
|
||||
setTokenMsg(json.error || t("common.error"));
|
||||
}
|
||||
@@ -205,7 +206,7 @@ function ApiTokensCard() {
|
||||
async function handleRevoke(tokenId: string) {
|
||||
const res = await fetch(`/api/tokens/${tokenId}`, { method: "DELETE" });
|
||||
if (res.ok) {
|
||||
queryClient.invalidateQueries({ queryKey: ["tokens"] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tokens() });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,11 @@ import { IntegrationStatusPill } from "@/components/settings/integration-status-
|
||||
import type { IntegrationPillState } from "@/components/settings/integration-status-pill";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
invalidateKeys,
|
||||
measurementDependentKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
|
||||
interface MoodLogStatus {
|
||||
configured: boolean;
|
||||
@@ -91,7 +95,7 @@ interface IntegrationStatusEnvelope {
|
||||
*/
|
||||
function useIntegrationStatuses(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: ["integrations", "status"],
|
||||
queryKey: queryKeys.integrationsStatus(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/integrations/status");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
@@ -155,7 +159,7 @@ export function IntegrationsSection() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data: globalServices } = useQuery({
|
||||
queryKey: ["settings", "global-services"],
|
||||
queryKey: queryKeys.settingsGlobalServices(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/settings/global-services");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
@@ -219,7 +223,7 @@ function WithingsCard({
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: status } = useQuery({
|
||||
queryKey: ["withings", "status"],
|
||||
queryKey: queryKeys.withingsStatus(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/withings/status");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
@@ -247,8 +251,8 @@ function WithingsCard({
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["withings"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["integrations", "status"] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.withings() });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus() });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -273,7 +277,7 @@ function WithingsCard({
|
||||
);
|
||||
setSyncMsgType("success");
|
||||
void invalidateKeys(queryClient, measurementDependentKeys);
|
||||
queryClient.invalidateQueries({ queryKey: ["integrations", "status"] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus() });
|
||||
} else {
|
||||
setSyncMsg(json.error || t("settings.withingsSyncFailed"));
|
||||
setSyncMsgType("error");
|
||||
@@ -307,7 +311,7 @@ function WithingsCard({
|
||||
setCredsMsgType("success");
|
||||
setClientId("");
|
||||
setClientSecret("");
|
||||
queryClient.invalidateQueries({ queryKey: ["withings"] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.withings() });
|
||||
} else {
|
||||
try {
|
||||
const json = await res.json();
|
||||
@@ -601,7 +605,7 @@ function MoodLogCard({
|
||||
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
||||
|
||||
const { data: status, refetch: refetchStatus } = useQuery({
|
||||
queryKey: ["moodlog-status"],
|
||||
queryKey: queryKeys.moodlogStatus(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/integrations/moodlog/status");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
@@ -624,7 +628,7 @@ function MoodLogCard({
|
||||
setUrl("");
|
||||
setApiKey("");
|
||||
await refetchStatus();
|
||||
queryClient.invalidateQueries({ queryKey: ["integrations", "status"] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus() });
|
||||
} else {
|
||||
const json = await res.json();
|
||||
setMsg(json.error || t("settings.savingError"));
|
||||
@@ -652,7 +656,7 @@ function MoodLogCard({
|
||||
);
|
||||
setMsgType("success");
|
||||
await refetchStatus();
|
||||
queryClient.invalidateQueries({ queryKey: ["integrations", "status"] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus() });
|
||||
} else {
|
||||
setMsg(t("settings.moodLogSyncFailed"));
|
||||
setMsgType("error");
|
||||
@@ -670,7 +674,7 @@ function MoodLogCard({
|
||||
setMsg(t("settings.moodLogDisconnected"));
|
||||
setMsgType("success");
|
||||
await refetchStatus();
|
||||
queryClient.invalidateQueries({ queryKey: ["integrations", "status"] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus() });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { SmilePlus } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
interface ProfileShape {
|
||||
moodReminderEnabled: boolean;
|
||||
@@ -25,7 +26,7 @@ export function MoodReminderCard({
|
||||
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["user", "profile"],
|
||||
queryKey: queryKeys.userProfile(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/user/profile");
|
||||
if (!res.ok) throw new Error("Failed to load profile");
|
||||
@@ -55,7 +56,7 @@ export function MoodReminderCard({
|
||||
: t("notifications.moodReminder.disabledToast"),
|
||||
);
|
||||
setMsgType("success");
|
||||
await queryClient.invalidateQueries({ queryKey: ["user", "profile"] });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.userProfile() });
|
||||
setOptimistic(null);
|
||||
} else {
|
||||
setOptimistic(null);
|
||||
|
||||
@@ -33,6 +33,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
type ChannelType = "TELEGRAM" | "NTFY" | "WEB_PUSH";
|
||||
|
||||
@@ -69,7 +70,7 @@ export function NotificationStatusCard() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["notifications", "status"],
|
||||
queryKey: queryKeys.notificationsStatus(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/notifications/status");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
@@ -90,7 +91,7 @@ export function NotificationStatusCard() {
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["notifications", "status"] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.notificationsStatus() });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -103,7 +104,7 @@ export function NotificationStatusCard() {
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["notifications", "status"] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.notificationsStatus() });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ChevronRight } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { MoodReminderCard } from "@/components/settings/mood-reminder-card";
|
||||
import { NotificationStatusCard } from "@/components/settings/notification-status-card";
|
||||
import { NtfyCard } from "@/components/settings/ntfy-card";
|
||||
@@ -25,7 +26,7 @@ export function NotificationsSection() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data: globalServices } = useQuery({
|
||||
queryKey: ["settings", "global-services"],
|
||||
queryKey: queryKeys.settingsGlobalServices(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/settings/global-services");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
interface NtfySettings {
|
||||
enabled: boolean;
|
||||
@@ -31,7 +32,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
);
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ["settings", "ntfy"],
|
||||
queryKey: queryKeys.settingsNtfy(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/settings/ntfy");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
@@ -70,7 +71,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["settings", "ntfy"] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.settingsNtfy() });
|
||||
setSaveMsg(t("settings.saved"));
|
||||
setSaveMsgType("success");
|
||||
setAuthToken("");
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
interface TelegramSettings {
|
||||
enabled: boolean;
|
||||
@@ -34,7 +35,7 @@ export function TelegramCard({
|
||||
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ["telegram", "settings"],
|
||||
queryKey: queryKeys.telegramSettings(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/settings/telegram");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
@@ -78,7 +79,7 @@ export function TelegramCard({
|
||||
setMsg(t("settings.telegramSaved"));
|
||||
setMsgType("success");
|
||||
setBotToken("");
|
||||
queryClient.invalidateQueries({ queryKey: ["telegram"] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.telegram() });
|
||||
} else {
|
||||
const json = await res.json();
|
||||
setMsg(json.error || t("settings.savingError"));
|
||||
|
||||
@@ -62,7 +62,7 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["user", "thresholds"],
|
||||
queryKey: queryKeys.userThresholds(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/user/thresholds");
|
||||
if (!res.ok) throw new Error("failed");
|
||||
@@ -86,10 +86,10 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user", "thresholds"] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.userThresholds() });
|
||||
// Every chart/band depends on these thresholds — invalidate everything.
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.analytics() });
|
||||
queryClient.invalidateQueries({ queryKey: ["insights"] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
|
||||
toast.success(t("thresholds.saveSuccess"));
|
||||
},
|
||||
onError: (err) =>
|
||||
@@ -107,9 +107,9 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
|
||||
if (!res.ok) throw new Error("reset failed");
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user", "thresholds"] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.userThresholds() });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.analytics() });
|
||||
queryClient.invalidateQueries({ queryKey: ["insights"] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
|
||||
toast.success(t("thresholds.resetSuccess"));
|
||||
},
|
||||
onError: () => toast.error(t("thresholds.saveError")),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
DEFAULT_COACH_PREFS,
|
||||
type CoachPrefs,
|
||||
@@ -24,7 +25,7 @@ import {
|
||||
*/
|
||||
export function useCoachPrefs(opts?: { enabled?: boolean }) {
|
||||
return useQuery<CoachPrefs>({
|
||||
queryKey: ["coach-prefs"],
|
||||
queryKey: queryKeys.coachPrefs(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/auth/me/coach-prefs");
|
||||
if (!res.ok) return DEFAULT_COACH_PREFS;
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { useContext } from "react";
|
||||
import { QueryClientContext, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
* v1.4.31 — Client-side accessor for the operator's assistant
|
||||
* feature-flag matrix. Mirrors `GET /api/feature-flags`'s response
|
||||
@@ -86,7 +88,7 @@ export function useFeatureFlags(): AssistantFlagSet {
|
||||
|
||||
function useFeatureFlagsQuery(): AssistantFlagSet {
|
||||
const query = useQuery({
|
||||
queryKey: ["feature-flags"],
|
||||
queryKey: queryKeys.featureFlags(),
|
||||
queryFn: fetchFeatureFlags,
|
||||
staleTime: 60_000,
|
||||
gcTime: 300_000,
|
||||
|
||||
@@ -13,7 +13,8 @@ import type { SubPageAnalyticsData } from "@/types/analytics";
|
||||
* `schlaf`) each used to declare:
|
||||
*
|
||||
* - a private `AnalyticsData` interface,
|
||||
* - a `useQuery({ queryKey: ["analytics"], staleTime: 60_000 })` block,
|
||||
* - a `useQuery` block keyed on `["analytics"]` with a 60-second
|
||||
* `staleTime`,
|
||||
* - an `if (isAuthenticated && analytics && !hasMetricData(...)) { … }`
|
||||
* short-circuit that rendered a sub-page-specific empty state.
|
||||
*
|
||||
|
||||
@@ -92,10 +92,12 @@ export function useWorkouts(opts: UseWorkoutsOptions = {}): UseWorkoutsResult {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [
|
||||
...queryKeys.workoutsRecent(),
|
||||
{ limit: opts.limit, offset: opts.offset, since: opts.since, sportType: opts.sportType },
|
||||
] as const,
|
||||
queryKey: queryKeys.workoutsRecentList({
|
||||
limit: opts.limit,
|
||||
offset: opts.offset,
|
||||
since: opts.since,
|
||||
sportType: opts.sportType,
|
||||
}),
|
||||
queryFn: () => fetchWorkouts(opts),
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60 * 1000,
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
returnAllZodIssues,
|
||||
sanitiseZodIssues,
|
||||
} from "../api-response";
|
||||
|
||||
// v1.4.42 W2 — every Zod-validated route historically returned
|
||||
// `parsed.error.issues[0].message`, dropping every issue past the first.
|
||||
// `returnAllZodIssues` is the additive replacement: clients that only
|
||||
// read `error` keep working, new callers branch on `details.issues`.
|
||||
|
||||
const schema = z.object({
|
||||
version: z.literal(1),
|
||||
widgets: z.array(z.object({ id: z.string() })).min(1),
|
||||
comparisonBaseline: z.enum(["none", "lastMonth", "lastYear"]).optional(),
|
||||
});
|
||||
|
||||
describe("returnAllZodIssues", () => {
|
||||
it("returns 422 by default with a sanitised issue list", async () => {
|
||||
const parsed = schema.safeParse({});
|
||||
expect(parsed.success).toBe(false);
|
||||
if (parsed.success) return;
|
||||
|
||||
const response = returnAllZodIssues(parsed.error);
|
||||
expect(response.status).toBe(422);
|
||||
|
||||
const body = (await response.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: { issues: Array<{ path: string; code: string; message: string }> };
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
expect(body.details.issues[0]).toMatchObject({
|
||||
code: expect.any(String),
|
||||
message: expect.any(String),
|
||||
path: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
const parsed = schema.safeParse({ version: 2, widgets: [] });
|
||||
expect(parsed.success).toBe(false);
|
||||
if (parsed.success) return;
|
||||
|
||||
const response = returnAllZodIssues(parsed.error);
|
||||
const body = (await response.json()) as {
|
||||
details: { issues: Array<{ path: string; code: string }> };
|
||||
};
|
||||
expect(body.details.issues.length).toBe(2);
|
||||
const paths = body.details.issues.map((i) => i.path).sort();
|
||||
expect(paths).toEqual(["version", "widgets"]);
|
||||
});
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
const parsed = schema.safeParse({
|
||||
version: 2,
|
||||
widgets: [],
|
||||
comparisonBaseline: "yesterday",
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
if (parsed.success) return;
|
||||
|
||||
const response = returnAllZodIssues(parsed.error);
|
||||
const body = (await response.json()) as {
|
||||
details: { issues: Array<{ path: string; code: string }> };
|
||||
};
|
||||
expect(body.details.issues.length).toBe(3);
|
||||
const paths = body.details.issues.map((i) => i.path).sort();
|
||||
expect(paths).toEqual(["comparisonBaseline", "version", "widgets"]);
|
||||
});
|
||||
|
||||
it("flattens nested paths with dots", async () => {
|
||||
const nested = z.object({ outer: z.object({ inner: z.string() }) });
|
||||
const parsed = nested.safeParse({ outer: { inner: 5 } });
|
||||
if (parsed.success) throw new Error("expected failure");
|
||||
|
||||
const sanitised = sanitiseZodIssues(parsed.error.issues);
|
||||
expect(sanitised[0].path).toBe("outer.inner");
|
||||
});
|
||||
|
||||
it("does NOT echo issue.params (privacy: may contain user input)", async () => {
|
||||
const parsed = schema.safeParse({
|
||||
version: 99,
|
||||
widgets: [{ id: "x" }],
|
||||
});
|
||||
if (parsed.success) throw new Error("expected failure");
|
||||
|
||||
const response = returnAllZodIssues(parsed.error);
|
||||
const body = (await response.json()) as {
|
||||
details: { issues: Array<Record<string, unknown>> };
|
||||
};
|
||||
for (const issue of body.details.issues) {
|
||||
expect(issue).not.toHaveProperty("params");
|
||||
// Only path / code / message survive the sanitiser.
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("respects a custom status code", async () => {
|
||||
const parsed = schema.safeParse({});
|
||||
if (parsed.success) throw new Error("expected failure");
|
||||
const response = returnAllZodIssues(parsed.error, 400);
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("forwards meta.errorCode and headers like apiError", async () => {
|
||||
const parsed = schema.safeParse({});
|
||||
if (parsed.success) throw new Error("expected failure");
|
||||
|
||||
const response = returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "dashboard.widgets.invalid",
|
||||
headers: { "X-Test": "yes" },
|
||||
});
|
||||
expect(response.headers.get("X-Test")).toBe("yes");
|
||||
const body = (await response.json()) as {
|
||||
meta?: { errorCode?: string; headers?: unknown };
|
||||
};
|
||||
expect(body.meta?.errorCode).toBe("dashboard.widgets.invalid");
|
||||
// Headers are consumed by the NextResponse constructor and stripped
|
||||
// from the JSON envelope, matching apiError's contract.
|
||||
expect(body.meta?.headers).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -100,6 +100,81 @@ describe("queryKeys factory", () => {
|
||||
"dashboard-medication-compliance",
|
||||
);
|
||||
});
|
||||
|
||||
// v1.4.42 W3-QUERYKEY-LONGTAIL — pin the shapes of the new factory
|
||||
// entries so a future rename can't drift the cache layout silently.
|
||||
it("exposes the per-medication sub-keys under the medications prefix", () => {
|
||||
expect(queryKeys.medicationCompliance("med1")).toEqual([
|
||||
"medications",
|
||||
"med1",
|
||||
"compliance",
|
||||
]);
|
||||
expect(queryKeys.medicationTitration("med1")).toEqual([
|
||||
"medications",
|
||||
"med1",
|
||||
"titration",
|
||||
]);
|
||||
expect(queryKeys.medicationCadence("med1")).toEqual([
|
||||
"medications",
|
||||
"med1",
|
||||
"cadence",
|
||||
]);
|
||||
expect(queryKeys.medicationGlp1Details("med1")).toEqual([
|
||||
"medications",
|
||||
"med1",
|
||||
"glp1-details",
|
||||
]);
|
||||
expect(queryKeys.medicationIntakeDrugLevelChart("med1")).toEqual([
|
||||
"medications",
|
||||
"med1",
|
||||
"intake",
|
||||
"drug-level-chart",
|
||||
]);
|
||||
const listKey = queryKeys.medicationIntakeList("med1", {
|
||||
sortBy: "takenAt",
|
||||
sortDir: "desc",
|
||||
limit: 25,
|
||||
offset: 0,
|
||||
status: "completed",
|
||||
});
|
||||
expect(listKey).toEqual([
|
||||
"medications",
|
||||
"med1",
|
||||
"intake",
|
||||
"list",
|
||||
"takenAt",
|
||||
"desc",
|
||||
25,
|
||||
0,
|
||||
"completed",
|
||||
]);
|
||||
});
|
||||
|
||||
it("exposes withingsStatus under the withings prefix", () => {
|
||||
expect(queryKeys.withingsStatus()).toEqual(["withings", "status"]);
|
||||
});
|
||||
|
||||
it("packs adminAuditLogFiltered params into a stable filtered shape", () => {
|
||||
const key = queryKeys.adminAuditLogFiltered({
|
||||
filter: "all",
|
||||
page: 1,
|
||||
perPage: 50,
|
||||
actor: "",
|
||||
actionFilter: "",
|
||||
target: "",
|
||||
range: "7d",
|
||||
});
|
||||
expect(key[0]).toBe("admin");
|
||||
expect(key[1]).toBe("audit-log");
|
||||
expect(key[2]).toBe("filtered");
|
||||
expect(key.length).toBe(10);
|
||||
});
|
||||
|
||||
it("packs workoutsRecentList opts under the workouts-recent prefix", () => {
|
||||
const key = queryKeys.workoutsRecentList({ limit: 3 });
|
||||
expect(key[0]).toBe("workouts");
|
||||
expect(key[1]).toBe("recent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dependent-key bundles", () => {
|
||||
@@ -195,11 +270,19 @@ describe("queryKey factory enforcement", () => {
|
||||
join(repoRoot, "src", "app", "page.tsx"),
|
||||
join(repoRoot, "src", "hooks", "use-auth.ts"),
|
||||
// v1.4.41 W-FRONTEND-FACTORY — auth, notifications, and the
|
||||
// about-section migrated to the factory. Future waves extend
|
||||
// this list as they migrate their own surface.
|
||||
// about-section migrated to the factory.
|
||||
join(repoRoot, "src", "app", "auth"),
|
||||
join(repoRoot, "src", "app", "notifications"),
|
||||
join(repoRoot, "src", "components", "settings", "about-section.tsx"),
|
||||
// v1.4.42 W3-QUERYKEY-LONGTAIL — settings / medications /
|
||||
// admin / hooks now route every read through the factory.
|
||||
join(repoRoot, "src", "components", "settings"),
|
||||
join(repoRoot, "src", "components", "medications"),
|
||||
join(repoRoot, "src", "components", "admin"),
|
||||
join(repoRoot, "src", "hooks"),
|
||||
join(repoRoot, "src", "app", "medications", "page.tsx"),
|
||||
join(repoRoot, "src", "app", "medications", "[id]", "history", "page.tsx"),
|
||||
join(repoRoot, "src", "app", "targets", "page.tsx"),
|
||||
];
|
||||
|
||||
function collect(): string[] {
|
||||
|
||||
@@ -97,8 +97,6 @@ export const coachChatRequestSchema = z.object({
|
||||
scope: coachScopeSchema.optional(),
|
||||
});
|
||||
|
||||
export type CoachChatRequest = z.infer<typeof coachChatRequestSchema>;
|
||||
|
||||
/**
|
||||
* SSE event shapes emitted by the streaming endpoint.
|
||||
*
|
||||
|
||||
@@ -67,8 +67,6 @@ export const metricSourceSchema = z.object({
|
||||
n: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
|
||||
export type MetricSource = z.infer<typeof metricSourceSchema>;
|
||||
|
||||
/**
|
||||
* Set of valid `referenceId` values, derived from
|
||||
* `MEDICAL_REFERENCES` so the two cannot drift. Validated lazily
|
||||
@@ -118,10 +116,6 @@ export const aiRecommendationRationaleSchema = z.object({
|
||||
deviation: z.string().min(1, "rationale.deviation required"),
|
||||
});
|
||||
|
||||
export type AIRecommendationRationale = z.infer<
|
||||
typeof aiRecommendationRationaleSchema
|
||||
>;
|
||||
|
||||
export const aiRecommendationSchema = z
|
||||
.object({
|
||||
/**
|
||||
@@ -191,8 +185,6 @@ export const aiRecommendationSchema = z
|
||||
}
|
||||
});
|
||||
|
||||
export type AIRecommendation = z.infer<typeof aiRecommendationSchema>;
|
||||
|
||||
export const aiCitationSchema = z.object({
|
||||
/** Snapshot key being cited. */
|
||||
type: z.string().min(1, "citation.type required"),
|
||||
@@ -202,8 +194,6 @@ export const aiCitationSchema = z.object({
|
||||
summary: z.string().min(1, "citation.summary required"),
|
||||
});
|
||||
|
||||
export type AICitation = z.infer<typeof aiCitationSchema>;
|
||||
|
||||
export const aiWarningSchema = z.object({
|
||||
/** Topic — "blood_pressure", "pulse", etc. */
|
||||
topic: z.string().min(1, "warning.topic required"),
|
||||
@@ -213,8 +203,6 @@ export const aiWarningSchema = z.object({
|
||||
severity: recommendationSeveritySchema.optional(),
|
||||
});
|
||||
|
||||
export type AIWarning = z.infer<typeof aiWarningSchema>;
|
||||
|
||||
/**
|
||||
* v1.4.20 phase B1 — Daily Briefing block.
|
||||
*
|
||||
@@ -419,8 +407,6 @@ export const storyboardAnnotationSchema = z.object({
|
||||
.max(400, "storyboardAnnotation.detail must be <= 400 chars"),
|
||||
});
|
||||
|
||||
export type StoryboardAnnotation = z.infer<typeof storyboardAnnotationSchema>;
|
||||
|
||||
export const storyboardAnnotationsSchema = z
|
||||
.array(storyboardAnnotationSchema)
|
||||
.max(20, "storyboardAnnotations can hold at most 20 entries");
|
||||
|
||||
@@ -99,7 +99,6 @@ export const insightResultSchema = z.object({
|
||||
});
|
||||
|
||||
export type InsightResult = z.infer<typeof insightResultSchema>;
|
||||
export type InsightFinding = z.infer<typeof insightFindingSchema>;
|
||||
|
||||
// ─── Provider Types ────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -60,17 +60,7 @@ export interface BpReading {
|
||||
const SYS_HYPOTENSION_FLOOR = 90;
|
||||
const DIA_HYPOTENSION_FLOOR = 50;
|
||||
|
||||
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
|
||||
function toBerlinDayKey(date: Date): string {
|
||||
// en-CA gives us the YYYY-MM-DD shape directly.
|
||||
return BERLIN_DAY_FORMATTER.format(date);
|
||||
}
|
||||
import { toBerlinDayKey } from "@/lib/tz/resolver";
|
||||
|
||||
/**
|
||||
* Pair a systolic reading with its closest diastolic by absolute time
|
||||
|
||||
@@ -42,13 +42,6 @@ export type ThresholdMetric =
|
||||
| "BONE_MASS"
|
||||
| "OXYGEN_SATURATION";
|
||||
|
||||
export interface ThresholdOverride {
|
||||
min: number;
|
||||
max: number;
|
||||
/** Set true when the value was entered by the user (not the computed default). */
|
||||
isOverride?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape persisted in User.thresholdsJson. Any metric missing = use default.
|
||||
*/
|
||||
|
||||
+89
-17
@@ -1,10 +1,97 @@
|
||||
import { isIP } from "node:net";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { ZodError, ZodIssue } from "zod/v4";
|
||||
|
||||
export function apiSuccess<T>(data: T, status = 200) {
|
||||
return NextResponse.json({ data, error: null }, { status });
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitised view of a single Zod issue. We surface `path`, `code` and
|
||||
* `message` only — `issue.params` may echo the offending user input
|
||||
* (e.g. a too-long string, a regex source) and we do not want that
|
||||
* round-tripped to mobile callers or persisted into the audit ledger.
|
||||
*/
|
||||
export interface SanitisedZodIssue {
|
||||
path: string;
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.42 W2 — multi-issue Zod error envelope.
|
||||
*
|
||||
* Historic pattern was `apiError(parsed.error.issues[0].message, 422)`
|
||||
* which dropped every issue past the first. The iOS contract debug
|
||||
* loop hit this hard: a single PUT with three wrong fields produced
|
||||
* one error, the client fixed it, re-sent, hit the next error, and
|
||||
* so on — three round-trips for one stack of mistakes.
|
||||
*
|
||||
* Shape kept additive with `apiError` so existing clients that only
|
||||
* read `error` keep working; new callers branch on `details.issues`.
|
||||
*
|
||||
* Privacy: only `path`, `code` and `message` are echoed. `issue.params`
|
||||
* (which can carry the raw rejected value for some Zod issue codes)
|
||||
* stays server-side.
|
||||
*/
|
||||
export function sanitiseZodIssues(
|
||||
issues: readonly ZodIssue[],
|
||||
): SanitisedZodIssue[] {
|
||||
return issues.map((issue) => ({
|
||||
path: issue.path.join("."),
|
||||
code: issue.code,
|
||||
message: issue.message,
|
||||
}));
|
||||
}
|
||||
|
||||
type ErrorMeta = {
|
||||
errorCode?: string;
|
||||
headers?: Record<string, string>;
|
||||
} & Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Shared builder for every `{ data: null, error, ... }` JSON envelope.
|
||||
* Strips `headers` from the meta passthrough (it lands on the
|
||||
* NextResponse constructor, not in the JSON body) and omits the `meta`
|
||||
* key entirely when no non-header fields remain — so the unchanged
|
||||
* `{ data: null, error: <string> }` envelope still serialises byte-
|
||||
* identically when the caller passes no extras.
|
||||
*/
|
||||
function buildJsonErrorResponse(
|
||||
body: Record<string, unknown>,
|
||||
status: number,
|
||||
meta: ErrorMeta | undefined,
|
||||
): NextResponse {
|
||||
const { headers, ...rest } = meta ?? {};
|
||||
const metaKeys = Object.keys(rest);
|
||||
return NextResponse.json(
|
||||
{
|
||||
...body,
|
||||
...(metaKeys.length > 0 ? { meta: rest } : {}),
|
||||
},
|
||||
{
|
||||
status,
|
||||
...(headers ? { headers } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function returnAllZodIssues(
|
||||
error: ZodError,
|
||||
status: number = 422,
|
||||
meta?: ErrorMeta,
|
||||
): NextResponse {
|
||||
return buildJsonErrorResponse(
|
||||
{
|
||||
data: null,
|
||||
error: "Validation failed",
|
||||
details: { issues: sanitiseZodIssues(error.issues) },
|
||||
},
|
||||
status,
|
||||
meta,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `meta` is additive — clients that ignore it see the unchanged
|
||||
* `{ data: null, error: <string> }` envelope. New callers can pass
|
||||
@@ -21,24 +108,9 @@ export function apiSuccess<T>(data: T, status = 200) {
|
||||
export function apiError(
|
||||
message: string,
|
||||
status = 400,
|
||||
meta?: {
|
||||
errorCode?: string;
|
||||
headers?: Record<string, string>;
|
||||
} & Record<string, unknown>,
|
||||
meta?: ErrorMeta,
|
||||
) {
|
||||
const { headers, ...rest } = meta ?? {};
|
||||
const metaKeys = Object.keys(rest);
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: null,
|
||||
error: message,
|
||||
...(metaKeys.length > 0 ? { meta: rest } : {}),
|
||||
},
|
||||
{
|
||||
status,
|
||||
...(headers ? { headers } : {}),
|
||||
},
|
||||
);
|
||||
return buildJsonErrorResponse({ data: null, error: message }, status, meta);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,8 +21,6 @@ const extendedProfileSchema = profileSchema.extend({
|
||||
moodReminderEnabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type ApplyProfileInput = z.infer<typeof extendedProfileSchema>;
|
||||
|
||||
export interface ApplyProfileResult {
|
||||
ok: true;
|
||||
user: {
|
||||
|
||||
@@ -185,13 +185,3 @@ export async function destroyAllSessions(userId: string): Promise<void> {
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Require an authenticated admin user.
|
||||
* Returns user if admin, null otherwise.
|
||||
*/
|
||||
export async function requireAdmin(): Promise<User | null> {
|
||||
const sessionData = await getSession();
|
||||
if (!sessionData) return null;
|
||||
if (sessionData.user.role !== "ADMIN") return null;
|
||||
return sessionData.user;
|
||||
}
|
||||
@@ -15,21 +15,3 @@
|
||||
|
||||
/** Full-size trend-card body height (non-mini chart cards on the dashboard). */
|
||||
export const CHART_HEIGHT_PX = 240;
|
||||
|
||||
/**
|
||||
* Range-preset options for charts that expose a 7d / 30d / 90d / All
|
||||
* range selector. Each entry carries the i18n keys for the visible
|
||||
* label + sr-only title, plus a `points` count used by the underlying
|
||||
* `HealthChart` query window. `points: 0` denotes "all data".
|
||||
*
|
||||
* Re-used by the GLP-1 dashboard tile so its range strip stays in
|
||||
* lockstep with the rest of the trend strip.
|
||||
*/
|
||||
export const CHART_RANGE_PRESETS = [
|
||||
{ labelKey: "charts.points7Label", titleKey: "charts.points7Title", points: 7 },
|
||||
{ labelKey: "charts.points30Label", titleKey: "charts.points30Title", points: 30 },
|
||||
{ labelKey: "charts.points90Label", titleKey: "charts.points90Title", points: 90 },
|
||||
{ labelKey: "charts.pointsAllLabel", titleKey: "charts.pointsAllTitle", points: 0 },
|
||||
] as const;
|
||||
|
||||
export type ChartRangePreset = (typeof CHART_RANGE_PRESETS)[number];
|
||||
Binary file not shown.
@@ -19,7 +19,7 @@ export const localeLabels: Record<Locale, string> = {
|
||||
* know to expect rough edges and where to contribute fixes. Promoting
|
||||
* a locale here turns the banner off.
|
||||
*/
|
||||
export const MAINTAINED_LOCALES: ReadonlySet<Locale> = new Set(["de", "en"]);
|
||||
const MAINTAINED_LOCALES: ReadonlySet<Locale> = new Set(["de", "en"]);
|
||||
|
||||
export function isMaintainedLocale(locale: Locale): boolean {
|
||||
return MAINTAINED_LOCALES.has(locale);
|
||||
|
||||
@@ -30,29 +30,10 @@ import {
|
||||
} from "@/lib/insights/with-timeout";
|
||||
import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
import { toBerlinDayKey } from "@/lib/tz/resolver";
|
||||
|
||||
type SupportedLocale = "de" | "en";
|
||||
|
||||
function toBerlinDayKey(date: Date): string {
|
||||
const parts = BERLIN_DAY_FORMATTER.formatToParts(date);
|
||||
const year = parts.find((part) => part.type === "year")?.value;
|
||||
const month = parts.find((part) => part.type === "month")?.value;
|
||||
const day = parts.find((part) => part.type === "day")?.value;
|
||||
|
||||
if (!year || !month || !day) {
|
||||
throw new Error("Could not derive Berlin day key");
|
||||
}
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function round(value: number, digits = 1): number {
|
||||
const factor = 10 ** digits;
|
||||
return Math.round(value * factor) / factor;
|
||||
|
||||
@@ -15,29 +15,10 @@ import {
|
||||
} from "@/lib/insights/with-timeout";
|
||||
import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
import { toBerlinDayKey } from "@/lib/tz/resolver";
|
||||
|
||||
type SupportedLocale = "de" | "en";
|
||||
|
||||
function toBerlinDayKey(date: Date): string {
|
||||
const parts = BERLIN_DAY_FORMATTER.formatToParts(date);
|
||||
const year = parts.find((part) => part.type === "year")?.value;
|
||||
const month = parts.find((part) => part.type === "month")?.value;
|
||||
const day = parts.find((part) => part.type === "day")?.value;
|
||||
|
||||
if (!year || !month || !day) {
|
||||
throw new Error("Could not derive Berlin day key");
|
||||
}
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function round(value: number, digits = 1): number {
|
||||
const factor = 10 ** digits;
|
||||
return Math.round(value * factor) / factor;
|
||||
|
||||
@@ -16,12 +16,7 @@
|
||||
* does not produce 360 zero-rows of noise.
|
||||
*/
|
||||
|
||||
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
import { toBerlinYmd as toBerlinYmdStrings } from "@/lib/tz/resolver";
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -65,14 +60,12 @@ interface BerlinYmd {
|
||||
}
|
||||
|
||||
function toBerlinYmd(date: Date): BerlinYmd {
|
||||
const parts = BERLIN_DAY_FORMATTER.formatToParts(date);
|
||||
const year = Number(parts.find((p) => p.type === "year")?.value);
|
||||
const month = Number(parts.find((p) => p.type === "month")?.value);
|
||||
const day = Number(parts.find((p) => p.type === "day")?.value);
|
||||
if (!year || !month || !day) {
|
||||
throw new Error("Could not derive Berlin Y-M-D");
|
||||
}
|
||||
return { year, month, day };
|
||||
const parts = toBerlinYmdStrings(date);
|
||||
return {
|
||||
year: Number(parts.year),
|
||||
month: Number(parts.month),
|
||||
day: Number(parts.day),
|
||||
};
|
||||
}
|
||||
|
||||
/** UTC midnight of the Berlin calendar day containing `date`. */
|
||||
|
||||
@@ -205,10 +205,3 @@ export function tokenToMetric(token: ChartToken): string {
|
||||
return token.slice("metric:".length);
|
||||
}
|
||||
|
||||
/** Token kinds the renderer needs to distinguish. MeasurementType tokens
|
||||
* feed `<HealthChart>`; `metric:MOOD` mounts the dedicated, self-fetching
|
||||
* `<MoodChart>` instead. */
|
||||
export function tokenKind(token: ChartToken): "measurement" | "mood" {
|
||||
if (token === "metric:MOOD") return "mood";
|
||||
return "measurement";
|
||||
}
|
||||
@@ -20,13 +20,7 @@ import {
|
||||
} from "@/lib/insights/with-timeout";
|
||||
import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
import { toBerlinDayKey } from "@/lib/tz/resolver";
|
||||
|
||||
type SupportedLocale = "de" | "en";
|
||||
|
||||
@@ -34,19 +28,6 @@ type SupportedLocale = "de" | "en";
|
||||
// in the AI general-status fetch (V3 audit: enum drift cousins).
|
||||
const MEASUREMENT_TYPES = measurementTypeEnum.options;
|
||||
|
||||
function toBerlinDayKey(date: Date): string {
|
||||
const parts = BERLIN_DAY_FORMATTER.formatToParts(date);
|
||||
const year = parts.find((part) => part.type === "year")?.value;
|
||||
const month = parts.find((part) => part.type === "month")?.value;
|
||||
const day = parts.find((part) => part.type === "day")?.value;
|
||||
|
||||
if (!year || !month || !day) {
|
||||
throw new Error("Could not derive Berlin day key");
|
||||
}
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function round(value: number, digits = 1): number {
|
||||
const factor = 10 ** digits;
|
||||
return Math.round(value * factor) / factor;
|
||||
|
||||
@@ -20,18 +20,12 @@ import {
|
||||
} from "@/lib/insights/with-timeout";
|
||||
import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { toBerlinDayKey } from "@/lib/tz/resolver";
|
||||
|
||||
// 360 daily days + 24 monthly windows ≈ 1080 days of intake history.
|
||||
const COMPLIANCE_HISTORY_DAYS = 360 + 24 * 30;
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
|
||||
type SupportedLocale = "de" | "en";
|
||||
|
||||
interface MedicationSummaryItem {
|
||||
@@ -39,19 +33,6 @@ interface MedicationSummaryItem {
|
||||
text: string;
|
||||
}
|
||||
|
||||
function toBerlinDayKey(date: Date): string {
|
||||
const parts = BERLIN_DAY_FORMATTER.formatToParts(date);
|
||||
const year = parts.find((part) => part.type === "year")?.value;
|
||||
const month = parts.find((part) => part.type === "month")?.value;
|
||||
const day = parts.find((part) => part.type === "day")?.value;
|
||||
|
||||
if (!year || !month || !day) {
|
||||
throw new Error("Could not derive Berlin day key");
|
||||
}
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function round(value: number, digits = 1): number {
|
||||
const factor = 10 ** digits;
|
||||
return Math.round(value * factor) / factor;
|
||||
|
||||
@@ -22,29 +22,10 @@ import {
|
||||
} from "@/lib/insights/with-timeout";
|
||||
import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
import { toBerlinDayKey } from "@/lib/tz/resolver";
|
||||
|
||||
type SupportedLocale = "de" | "en";
|
||||
|
||||
function toBerlinDayKey(date: Date): string {
|
||||
const parts = BERLIN_DAY_FORMATTER.formatToParts(date);
|
||||
const year = parts.find((part) => part.type === "year")?.value;
|
||||
const month = parts.find((part) => part.type === "month")?.value;
|
||||
const day = parts.find((part) => part.type === "day")?.value;
|
||||
|
||||
if (!year || !month || !day) {
|
||||
throw new Error("Could not derive Berlin day key");
|
||||
}
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function round(value: number, digits = 1): number {
|
||||
const factor = 10 ** digits;
|
||||
return Math.round(value * factor) / factor;
|
||||
|
||||
@@ -21,29 +21,10 @@ import {
|
||||
} from "@/lib/insights/with-timeout";
|
||||
import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
import { toBerlinDayKey } from "@/lib/tz/resolver";
|
||||
|
||||
type SupportedLocale = "de" | "en";
|
||||
|
||||
function toBerlinDayKey(date: Date): string {
|
||||
const parts = BERLIN_DAY_FORMATTER.formatToParts(date);
|
||||
const year = parts.find((part) => part.type === "year")?.value;
|
||||
const month = parts.find((part) => part.type === "month")?.value;
|
||||
const day = parts.find((part) => part.type === "day")?.value;
|
||||
|
||||
if (!year || !month || !day) {
|
||||
throw new Error("Could not derive Berlin day key");
|
||||
}
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function round(value: number, digits = 1): number {
|
||||
const factor = 10 ** digits;
|
||||
return Math.round(value * factor) / factor;
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
// `gewicht`, `bmi`, `schlaf`, `stimmung`, `medikamente`, `workouts`)
|
||||
// stay flat — they predate v1.4.32 and the maintainer's audit kept
|
||||
// them as direct entries.
|
||||
export const SUB_PAGE_METRIC = {
|
||||
const SUB_PAGE_METRIC = {
|
||||
// ── vitals ──
|
||||
blutdruck: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"],
|
||||
puls: ["PULSE"],
|
||||
|
||||
@@ -25,29 +25,10 @@ import {
|
||||
} from "@/lib/insights/with-timeout";
|
||||
import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
import { toBerlinDayKey } from "@/lib/tz/resolver";
|
||||
|
||||
type SupportedLocale = "de" | "en";
|
||||
|
||||
function toBerlinDayKey(date: Date): string {
|
||||
const parts = BERLIN_DAY_FORMATTER.formatToParts(date);
|
||||
const year = parts.find((part) => part.type === "year")?.value;
|
||||
const month = parts.find((part) => part.type === "month")?.value;
|
||||
const day = parts.find((part) => part.type === "day")?.value;
|
||||
|
||||
if (!year || !month || !day) {
|
||||
throw new Error("Could not derive Berlin day key");
|
||||
}
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function round(value: number, digits = 1): number {
|
||||
const factor = 10 ** digits;
|
||||
return Math.round(value * factor) / factor;
|
||||
|
||||
@@ -269,6 +269,68 @@ describe("recordSyncFailure — reauth_required", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordSyncFailure — persistent (v1.4.42 W6)", () => {
|
||||
it("marks state=error_transient (next sync still runs) and audits with kind=persistent", async () => {
|
||||
// Contract-mismatch failures (Withings 293 invalid params) MUST
|
||||
// surface in the audit log with kind=persistent so operations can
|
||||
// grep for upstream contract bugs, but they MUST NOT park the
|
||||
// integration — the next sync should still run because the
|
||||
// mismatch can be one-sided (e.g. Withings introducing a new
|
||||
// required field that the client release fixes).
|
||||
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({
|
||||
consecutiveFailures: 1,
|
||||
} as never);
|
||||
await recordSyncFailure({
|
||||
userId: "u1",
|
||||
integration: "withings",
|
||||
kind: "persistent",
|
||||
message: "Withings measure error: 293 - invalid params",
|
||||
errorCode: "293",
|
||||
});
|
||||
const upsertArgs = vi.mocked(prisma.integrationStatus.upsert).mock
|
||||
.calls[0][0];
|
||||
expect(upsertArgs.update).toMatchObject({ state: "error_transient" });
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"integrations.sync.failed",
|
||||
expect.objectContaining({
|
||||
details: expect.objectContaining({
|
||||
kind: "persistent",
|
||||
errorCode: "293",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("at threshold, the admin alert message includes the 'persistent error' label", async () => {
|
||||
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({
|
||||
consecutiveFailures: 3,
|
||||
alertedAt: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
email: "user@example.com",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findMany).mockResolvedValueOnce([
|
||||
{ id: "admin-1" },
|
||||
] as never);
|
||||
vi.mocked(prisma.integrationStatus.update).mockResolvedValueOnce(
|
||||
{} as never,
|
||||
);
|
||||
|
||||
await recordSyncFailure({
|
||||
userId: "u1",
|
||||
integration: "withings",
|
||||
kind: "persistent",
|
||||
message: "Withings activity error: 293",
|
||||
errorCode: "293",
|
||||
});
|
||||
|
||||
expect(dispatchNotification).toHaveBeenCalledOnce();
|
||||
const payload = vi.mocked(dispatchNotification).mock.calls[0][0];
|
||||
expect(payload.message).toContain("persistent error");
|
||||
expect(payload.message).toContain("Action: investigate the upstream contract");
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordSyncFailure — admin Telegram skipped silently when no admins", () => {
|
||||
it("logs a wide-event warning but does NOT throw when no admin users exist", async () => {
|
||||
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({
|
||||
|
||||
@@ -162,7 +162,26 @@ export async function recordSyncSuccess(
|
||||
});
|
||||
}
|
||||
|
||||
export type FailureKind = "transient" | "reauth_required";
|
||||
/**
|
||||
* Failure kinds carried into `recordSyncFailure`.
|
||||
*
|
||||
* - `transient` : retry on the next sync; user not blocked.
|
||||
* - `reauth_required` : permanent revoke / invalid_grant; park at
|
||||
* `error_reauth` until the user reconnects.
|
||||
* - `persistent` : contract mismatch (invalid params, missing
|
||||
* field, unknown action). Surfaces in the
|
||||
* integration-status card AND audit log so an
|
||||
* operator can investigate, but does NOT skip
|
||||
* future sync attempts — those may succeed once
|
||||
* the upstream side resolves.
|
||||
*
|
||||
* v1.4.42 W6 extended this union from `transient | reauth_required` to
|
||||
* the three-state taxonomy above; the state-mapping function turns
|
||||
* `persistent` into `error_transient` for now (a Withings 293 still
|
||||
* lets the next sync run), but the audit detail carries the explicit
|
||||
* kind so operations can filter.
|
||||
*/
|
||||
export type FailureKind = "transient" | "reauth_required" | "persistent";
|
||||
|
||||
export interface RecordSyncFailureInput {
|
||||
userId: string;
|
||||
@@ -193,6 +212,12 @@ export async function recordSyncFailure(
|
||||
): Promise<void> {
|
||||
const { userId, integration, kind, message, errorCode } = input;
|
||||
const now = new Date();
|
||||
// State mapping:
|
||||
// reauth_required → error_reauth (sync entry-point short-circuits)
|
||||
// transient → error_transient (next sync still runs)
|
||||
// persistent → error_transient (next sync still runs, but the
|
||||
// audit detail carries `kind: "persistent"` so
|
||||
// operations can grep for contract-bug bursts)
|
||||
const newState: IntegrationState =
|
||||
kind === "reauth_required" ? "error_reauth" : "error_transient";
|
||||
|
||||
@@ -477,6 +502,30 @@ export interface AlertInput {
|
||||
* 4096 characters but our envelope (title + summary + action line)
|
||||
* eats ~150 chars so we keep a comfortable margin.
|
||||
*/
|
||||
/**
|
||||
* Reason + action copy keyed off `FailureKind`. Adding a new failure
|
||||
* kind is a one-row table edit instead of two more arms in two
|
||||
* different ternary stacks (the style-guide forbids nested ternaries).
|
||||
*/
|
||||
const FAILURE_KIND_COPY: Record<
|
||||
FailureKind,
|
||||
{ reason: string; action: string }
|
||||
> = {
|
||||
reauth_required: {
|
||||
reason: "re-auth required",
|
||||
action: "ask the user to reconnect the integration.",
|
||||
},
|
||||
persistent: {
|
||||
reason: "persistent error",
|
||||
action:
|
||||
"investigate the upstream contract — params/scope/action likely mismatched.",
|
||||
},
|
||||
transient: {
|
||||
reason: "transient error",
|
||||
action: "investigate the upstream service.",
|
||||
},
|
||||
};
|
||||
|
||||
export function formatAdminAlertPayload(input: AlertInput): {
|
||||
title: string;
|
||||
message: string;
|
||||
@@ -485,8 +534,8 @@ export function formatAdminAlertPayload(input: AlertInput): {
|
||||
const integrationLabel =
|
||||
input.integration === "withings" ? "Withings" : "moodLog";
|
||||
const subjectLabel = input.subjectLabel ?? input.userId;
|
||||
const reasonLabel =
|
||||
input.kind === "reauth_required" ? "re-auth required" : "transient error";
|
||||
const { reason: reasonLabel, action: actionLabel } =
|
||||
FAILURE_KIND_COPY[input.kind];
|
||||
const codeLabel = input.errorCode ? ` (${input.errorCode})` : "";
|
||||
const trimmed =
|
||||
input.message.length > 280
|
||||
@@ -497,7 +546,7 @@ export function formatAdminAlertPayload(input: AlertInput): {
|
||||
const message =
|
||||
`${integrationLabel} sync has failed ${input.consecutiveFailures} times in a row for ${subjectLabel}.\n` +
|
||||
`Last error: ${reasonLabel}${codeLabel} — ${trimmed}\n` +
|
||||
`Action: ${input.kind === "reauth_required" ? "ask the user to reconnect the integration." : "investigate the upstream service."}`;
|
||||
`Action: ${actionLabel}`;
|
||||
|
||||
return {
|
||||
title,
|
||||
|
||||
@@ -216,6 +216,9 @@ export async function runOffhostBackup(
|
||||
try {
|
||||
const [measurements, medications, intakeEvents, moodEntries] =
|
||||
await Promise.all([
|
||||
// includes soft-deleted rows because this is the DR snapshot,
|
||||
// not a user-facing export — see
|
||||
// `/api/export/full-backup/route.ts` for the symmetric exclusion.
|
||||
prisma.measurement.findMany({ where: { userId: user.id } }),
|
||||
prisma.medication.findMany({
|
||||
where: { userId: user.id },
|
||||
|
||||
@@ -33,17 +33,3 @@ export async function withBackgroundEvent<T>(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget Variante: schluckt Fehler und loggt sie nur.
|
||||
* Fuer Webhook-Sends, Achievement-Checks, etc.
|
||||
*/
|
||||
export async function withBackgroundEventSafe(
|
||||
taskName: string,
|
||||
fn: (evt: WideEventBuilder) => Promise<void>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await withBackgroundEvent(taskName, fn);
|
||||
} catch {
|
||||
// Fehler wurde bereits ins Event geschrieben und emittiert
|
||||
}
|
||||
}
|
||||
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user