mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
v1.4.36 — perf, charts, AI payload trim, UX punch
Heavy-aggregate skip on the rollup-fresh + fully-covered path, daily chart fetches opt into rollup buckets via source=rollup, Insights extractFeatures swap drops the prompt payload from double-digit MB to the low hundreds of kB on power-user accounts, page-level isLoading gate replaced with per-section early-skeleton paint, trends-row chart slot pinned for CLS, IntakeHistoryListV2 restored on the medication detail page, About card folded into the Admin Console with an auto-checking update badge, cumulative metric tiles read the day's running sum, IP-whois fallback surfaces city + country when the ASN lookup misses. Migration 0068 adds an additive users.insights_exclude_metrics column. Unit suite 4354 passing; typecheck + lint clean.
This commit is contained in:
1 parent
5a3ca330ac
commit
51f91c8bd9
70 files changed
+4982
-810
No files matched your search
@@ -1,12 +1,13 @@
|
||||
# v1.4.34.4 + v1.4.34.5 + v1.4.35 — Audit-Marathon Closure (extended)
|
||||
# v1.4.34.4 + v1.4.34.5 + v1.4.35 + v1.4.35.1 — Audit-Marathon Closure (extended)
|
||||
|
||||
Shipped: 2026-05-17 (three consecutive releases on the same date as v1.4.34.3).
|
||||
Shipped: 2026-05-17 (four consecutive releases on the same date as v1.4.34.3).
|
||||
|
||||
| Tag | Highlights | Release |
|
||||
| --- | --- | --- |
|
||||
| v1.4.34.4 | Audit-driven hotfix bundle across security, UX, code quality, mobile-deep, docs, GitHub metadata | [v1.4.34.4](https://github.com/MBombeck/HealthLog/releases/tag/v1.4.34.4) |
|
||||
| v1.4.34.5 | Audit follow-on: 24 critical-path integration tests + iOS textarea zoom fix | [v1.4.34.5](https://github.com/MBombeck/HealthLog/releases/tag/v1.4.34.5) |
|
||||
| v1.4.35 | Layer B landed — persistent `measurement_rollups` foundation + partial reader read-swap on the comprehensive aggregator and the slim summaries slice | [v1.4.35](https://github.com/MBombeck/HealthLog/releases/tag/v1.4.35) |
|
||||
| v1.4.35.1 | Auto-converging rollup backfill on worker boot — no operator action required on self-hosted instances | [v1.4.35.1](https://github.com/MBombeck/HealthLog/releases/tag/v1.4.35.1) |
|
||||
|
||||
## Audit waves
|
||||
|
||||
@@ -154,3 +155,15 @@ The v1.4.34.1/.2 closure flagged "Layer B (`measurement_rollups` table)" as defe
|
||||
Tests: 4280 unit + 228 integration (+14 unit + 6 integration on the rollup path). `pnpm typecheck` + `pnpm lint` clean. Migration `0067_v1434_measurement_rollups` is idempotent.
|
||||
|
||||
A full read-swap across every remaining analytics surface is planned for a follow-up release once the partial swap has proven itself in production.
|
||||
|
||||
## v1.4.35.1 addendum — auto-converge on worker boot
|
||||
|
||||
Discovered post-deploy: `scripts/backfill-rollups.ts` is unrunnable inside the Next.js standalone production image (`tsx` is a devDep and the Prisma client lives under pnpm's symlink-store layout, not the top-level `node_modules/@prisma`). A working manual run required `pnpm dlx tsx` plus an ad-hoc `ln -s` into the pnpm store — fine for one Coolify host with SSH, but it would block every self-hoster.
|
||||
|
||||
v1.4.35.1 moves the backfill onto the worker boot so the activation path is zero-touch:
|
||||
|
||||
- **New `rollup-full-backfill` queue** + handler folds one user per job at serial concurrency. The handler invokes `recomputeUserRollups(userId)` with the default 5-year window across all four granularities — equivalent to the manual CLI run, but inside the worker process so no `tsx` install gymnastics in production images.
|
||||
- **`enqueueBootTimeRollupBackfill`** runs once at `startReminderWorker` boot after every `boss.work` subscription is in place. Discovery query: users with at least one `measurements` row and zero `measurement_rollups` rows. Idempotent across reboots (the discovery query only matches uncovered users; once a fold completes the user drops off the list) and resilient to fast restarts (pg-boss `singletonKey: boot-backfill|{userId}`).
|
||||
- **Best-effort:** discovery errors surface through the helper's return value and are logged via `workerLog`; the worker boot never fails because the backfill missed.
|
||||
|
||||
Tests: 4285 unit + 230 integration. tsc + lint clean. Production verify: `pgboss.queue` row for `rollup-full-backfill` created at `2026-05-17T09:36:25Z` (worker started 09:36:24Z), confirming the registration + helper-call path executed cleanly.
|
||||
@@ -0,0 +1,69 @@
|
||||
# v1.4.36 marathon handover
|
||||
|
||||
**Branch:** develop. **Trigger:** Marc directive 2026-05-17 after v1.4.35.1 deploy. Freeze aufgehoben.
|
||||
|
||||
Two root issues drive this round:
|
||||
|
||||
1. **v1.4.35 perf regression.** The read-swap landed but ran live SQL + rollup in parallel and only used the rollup when the count matched. Net round-trip count unchanged — so the "Insights cold-mount sub-second" promise never materialized. Live still takes ~11 s on `/insights` SSR + 3 s on the slim slice + 3 s × 3 on daily-aggregate measurements.
|
||||
2. **The recurring Mood-card-larger-than-BP/Weight complaint.** Chart wrapper has no fixed height; Recharts mounts with `width=-1 height=-1` and re-layout causes the visible drift + the CLS 0.46 hit in Lighthouse.
|
||||
|
||||
Plus a punch list of UX bugs (medication history empty, AI Insights "more data needed" when triggered, scroll-feels-jumpy, Steps tile shows last-measurement-not-day-sum, etc.).
|
||||
|
||||
## Waves
|
||||
|
||||
### W1 — perf (the headline win)
|
||||
|
||||
- **Read-swap rollup-only-when-fresh.** Drop the always-run-both pattern in `src/lib/insights/comprehensive-aggregator.ts` and `src/lib/analytics/summaries-slice.ts`. Decision: if `ensureUserRollupsFresh` confirms the rollup is current AND the rollup row count for the type passes a cheap completeness check, skip the live `$queryRaw` aggregate entirely. Fall back to live only on first cold-mount before the populator has caught up. Pin the contract with a unit test that the live `$queryRaw` is NOT called when rollups are fresh + complete.
|
||||
- **`/insights` page Suspense boundaries.** The page SSR is 10.88 s because chained `await`s block. Wrap the per-trend-section data fetches in Suspense so the page streams. Don't change the visible markup contract.
|
||||
- **Drop the 3× daily-aggregate measurement calls on Insights.** Insights fires `GET /api/measurements?type=…&aggregate=daily&limit=5000` per chart (BP_SYS / BP_DIA / WEIGHT), each ~3 s. Route them through the existing DAY rollup table. Either reuse `dailyByType` from `buildComprehensiveAggregate` or expose a slim rollup-read helper.
|
||||
- **Cache `/api/mood/analytics` + `/api/insights/targets`** via the existing analytics 60-s LRU. Both run > 1 s cold + are read on every Insights mount.
|
||||
|
||||
### W2 — charts + insights UI
|
||||
|
||||
- **`trends-row-chart-slot` `h-[140px]`** in `src/components/insights/trends-row.tsx` lines 120, 142, 163. Aligns Mood / BP / Weight card heights AND eliminates the `width=-1 height=-1` Recharts warning (root cause confirmed in agent recon).
|
||||
- **ChartSkeleton dimensions** in `src/components/charts/chart-skeleton.tsx` need a `mini` prop that applies `rounded-md p-2` to match the loaded chart wrapper. Without this the skeleton is taller than the chart and the layout still shifts on hydration.
|
||||
- **AI Insights / Briefing not displaying.** Marc reports the trigger fires but the UI shows "mehr Daten nötig, um diesen Trend zu kommentieren". Inspect the briefing render-state — likely a stale `status === 'pending'` flag or a mismatched cache key after generation. Health-Score render-state has the same lag pattern; fix in the same touch.
|
||||
|
||||
### W3 — AI prompt trim
|
||||
|
||||
- **`extractFeatures` bucketed.** `src/lib/insights/features.ts:768-779` currently appends every raw `prisma.measurement.findMany` row to the prompt under `rawMeasurements` (25.9 MB on Marc's account, hit the Codex 10 MB ceiling). Swap to reading from the `measurement_rollups` DAY/WEEK/MONTH buckets via `aggregateBuckets`. Default to bucketed; keep the `raw` privacy mode as an explicit opt-in that warns above e.g. 1 MB.
|
||||
- **Coach toggles surfaced to UI.** `src/lib/validations/coach-prefs.ts` already has `excludeMetrics` + `defaultWindow`. Surface a UI toggle row in the Coach settings panel for: sleep, medications, anthropometrics (height/age/gender). Default = include when data exists, otherwise omit. Empty blocks must not be sent at all.
|
||||
- **AI Insights gets the same exclusion contract** as Coach — extend the User schema with `insightsExcludeMetrics` aligned to Coach's enum, parse in `/api/insights/generate`, filter before JSON.stringify.
|
||||
|
||||
### W4 — UX punch list (one agent, sequential sub-waves)
|
||||
|
||||
- **W4a — IntakeHistoryList v2 (lite).** v1.4.28 retired the 886-LoC component intentionally. Re-implement as a slim table at `src/components/medications/intake-history-list-v2.tsx`: paginated (25/page), sortable by `takenAt`, no inline CRUD (edit/delete happens via the normal medication-intake routes). Mount on `src/app/medications/[id]/history/page.tsx` below the existing GLP-1 blocks for all medication kinds.
|
||||
- **W4b — Tab-strip scroll inline:start.** `src/components/settings/settings-shell.tsx:167` and `src/components/admin/admin-shell.tsx:174`: `inline: "center"` → `inline: "start"`. One-line change × 2 files.
|
||||
- **W4c — Steps tile daily cumulative.** Dashboard tile in `src/app/page.tsx:867-888` reads `summary.latest`. Apply the same source-priority + day-bucketing pattern that lines 205-225 use for SLEEP_DURATION, broaden to ACTIVITY_STEPS, ACTIVE_ENERGY_BURNED, WALKING_RUNNING_DISTANCE, FLIGHTS_CLIMBED, TIME_IN_DAYLIGHT. Extract the bucketing into a shared helper.
|
||||
- **W4d — Insights nav verify.** `insights-tab-strip.tsx` already gates on `summaries[type].count > 0`. Verify the upstream payload actually populates `count` for all types (not just the ones rendered by the comprehensive aggregator). If `summaries` from the slim slice omits empty types, add a `lastSeenByType` check or fold the gating logic into the parent.
|
||||
- **W4e — Über → Admin Console.** Remove the link from `src/components/layout/top-bar.tsx:102` and `src/components/layout/sidebar-nav.tsx:153`. Add a new admin section in `src/components/admin/admin-shell.tsx` ADMIN_SECTIONS array. Either render the existing `/about/page.tsx` content inside the admin slug or extract the body into a shared component.
|
||||
- **W4f — Update badge (drop button).** In `src/components/settings/about-section.tsx` (or wherever the About section now lives after W4e), drop the "Update prüfen" button. Keep the 24h auto-check. Add a small upward-arrow icon next to the version line when `updateResult.status === 'newer_available'`; hover shows latest tag via tooltip.
|
||||
- **W4g — IP Whois city/country surface.** `src/lib/geo.ts:284-318` returns null on ASN failure (ipwho.is free tier has no ASN). When ASN is null but city/country are present, surface those in the admin audit-log carrier chip with a "(carrier unavailable)" note instead of blank.
|
||||
|
||||
### W5 — QA + release
|
||||
|
||||
- Full unit + integration suites green.
|
||||
- Multi-reviewer QA wave (code-review, security, design, senior-dev, simplifier).
|
||||
- Version bump `1.4.35.1 → 1.4.36`. CHANGELOG.
|
||||
- Squash develop → main. Tag. GH release. Live verify.
|
||||
|
||||
## Defaults applied (no Marc clarification needed)
|
||||
|
||||
- Steps tile = day cumulative from 00:00.
|
||||
- IntakeHistoryList v2 = lite (table + pagination, no inline CRUD).
|
||||
- Update behavior = badge auto-check only, "Update prüfen" button removed.
|
||||
|
||||
## Non-negotiables
|
||||
|
||||
- Marc-Voice commits (no `Co-Authored-By: Claude`, no `--no-verify`, English, terse professional).
|
||||
- `pnpm typecheck` + `pnpm lint` + relevant tests green before every commit.
|
||||
- Atomic commits per sub-wave.
|
||||
- All wave commits land on develop. Main only sees the v1.4.36 squash.
|
||||
- Charts visual identity: Recharts stays, no token reshuffle, only the height fix.
|
||||
- No PII in user-facing artifacts.
|
||||
|
||||
## Operator notes
|
||||
|
||||
- Coolify auto-deploys main on tag push (confirmed working since v1.4.34.2).
|
||||
- GHCR build takes ~8 min; first webhook deploy will pull stale `:latest`, redeploy fires once GHCR publishes.
|
||||
- Marc's account: 311 775 measurements across 8 types, 5 318 rollup buckets folded by the v1.4.35.1 boot-time backfill.
|
||||
@@ -0,0 +1,240 @@
|
||||
# v1.4.36 release handover (fresh-session pickup)
|
||||
|
||||
**Status:** all code work landed on `develop`. tsc + lint clean. 4354/4355 unit + 230/231 integration (1 known `isolate:false` flake). Ready for **version bump → CHANGELOG → squash → tag → GH release → live verify → post-deploy perf-QA → closure**.
|
||||
|
||||
**Why this doc exists:** the originating session ran out of context-window after the QA wave + reconcile. Pick up from here in a fresh session.
|
||||
|
||||
---
|
||||
|
||||
## State at handover
|
||||
|
||||
- Branch: `develop`, tip `234fb723`.
|
||||
- `main` is at `v1.4.35.1` (`5a3ca330`), live.
|
||||
- 27 commits make up v1.4.36 (range `5a3ca330..develop`). Plus the docs handover from the start of the round (`125457e4`).
|
||||
- Quality gates: `pnpm typecheck` clean, `pnpm lint` clean, `pnpm test` = 413 files, 4354 passed, 1 skipped.
|
||||
- Integration: 230 passed + 1 known `apns-dispatch`/`integration-status` parallel-DB isolation flake AND the `enqueueBootTimeRollupBackfill` mock-isolation flake that only fails when run alongside `admin-backups-audit.test.ts`. Both flakes are pre-existing, both pass in isolation, both documented in v1.4.35.1 closure. Not v1.4.36 regressions.
|
||||
- `prisma/migrations/0068_v1436_insights_exclude_metrics/migration.sql` is the only schema change — additive `String[] NOT NULL DEFAULT '{}'` on `users.insights_exclude_metrics`, idempotent under re-run.
|
||||
|
||||
## Commits (oldest → newest, 27 total)
|
||||
|
||||
| Wave | Sha | Title |
|
||||
|---|---|---|
|
||||
| W2 | `4d111e39` | fix(insights): pin trends-row chart slot to h-[140px] |
|
||||
| W2 | `b4bef03f` | feat(charts): mini ChartSkeleton variant for trends-row slot |
|
||||
| W3 | `2e2ca224` | feat(insights): bucketed measurement payload for raw mode |
|
||||
| W2 | `28a82969` | fix(insights): tri-state render contract for trend annotations |
|
||||
| W4a | `a17e7a46` | feat(medications): restore intake-history table on the detail page |
|
||||
| W4b | `83f0b5ae` | ux(nav): pin active tab-strip chip to the scroll start |
|
||||
| W3 | `1e9a03be` | feat(coach): medications + anthropometrics exclusion toggles |
|
||||
| W4c | `3f55b6f7` | fix(dashboard): bucket cumulative metrics per day before summarising |
|
||||
| W1 | `13f8905e` | perf(insights): skip heavy live aggregate when rollups are populated |
|
||||
| W3 | `73528061` | feat(insights): per-user exclude-metrics opt-out (mirrors Coach) |
|
||||
| W4d-misattributed | `5b44cb6a` | fix(insights): tighten tab-strip availability gate (*contains W1 Suspense work — see below*) |
|
||||
| W4d-real | `9feca9e0` | fix(insights): tighten tab-strip availability gate (actual W4d files) |
|
||||
| W3 | `68849959` | feat(insights): compactSections drops zero-row blocks before serialization |
|
||||
| W1 | `d2bad694` | perf(insights): route daily chart fetches through rollup buckets |
|
||||
| W4e | `23cf9a4a` | ux(about): fold About card into the Admin Console |
|
||||
| W1 | `e203f07c` | perf(insights): cache /api/insights/targets behind the analytics LRU |
|
||||
| W4f | `0738ef50` | ux(about): drop manual update-check panel for an inline badge |
|
||||
| W4g | `fa0f544e` | fix(audit): fall back to city/country when ASN lookup misses |
|
||||
| W5 test-fix | `38417922` | test(analytics): seed rollup-count probe in slim-slice route test |
|
||||
| W5-simp | `476ba98e` | refactor(analytics): hoist cumulative metric-key lookup out of nested ternary |
|
||||
| W5-R2 H5 | `00cf8d7a` | a11y(medications): focus-visible ring + 44 px touch floor on intake-history sort headers |
|
||||
| W5-R2 H6 | `7ee32a14` | a11y(medications): drop sm-size on intake-history pagination buttons |
|
||||
| W5-R1 C1 | `07491df6` | fix(rollups): per-type coverage probe gates rollup vs live read |
|
||||
| W5-R1 C2 | `42039372` | fix(insights): remove inert Suspense wrappers from mother page |
|
||||
| W5-R2 H7 | `3e0eed82` | a11y(settings): give update badge a 44 px hit target, focus ring + real aria-label |
|
||||
| W5-R2 M | `0ec7fc6f` | fix(about): use distinct builtAtLabel key for the build-time row |
|
||||
| W5-R2 M | `7200fc80` | chore(i18n): drop orphaned update-card keys from settings.about |
|
||||
| W5-R1 H1 | `d7ad2c0b` | fix(insights): guard against oversized payload on the aggregated retry |
|
||||
| W5-R1 H3 | `234fb723` | fix(rollups): annotate when ensureUserRollupsFresh swallows an error |
|
||||
|
||||
**Note on `5b44cb6a`** — title says "tab-strip availability gate" but the diff is the W1 Suspense rewrite of `src/app/insights/page.tsx`. A race during parallel execution swapped the index. `9feca9e0` re-committed the actual W4d files immediately after with "(actual W4d files)" tag. Both commits' contents are legitimate. **The CHANGELOG body MUST describe both contributions correctly — do not propagate the misleading title.**
|
||||
|
||||
## What v1.4.36 actually delivers (CHANGELOG source-of-truth)
|
||||
|
||||
Use these grouped headlines when drafting the CHANGELOG. The handover at `.planning/round-v1436-handover.md` has the full prose context.
|
||||
|
||||
### Performance
|
||||
|
||||
- **`comprehensive-aggregator` + `summaries-slice` heavy-aggregate skip on the rollup-fresh + fully-covered path.** `src/lib/measurements/rollup-coverage.ts` introduces a per-type coverage probe (joins `DISTINCT type FROM measurements` against `measurement_rollups`). Slim slice + comprehensive now skip the legacy `COUNT/MIN/MAX/AVG $queryRaw` whenever EVERY logged type has DAY coverage. Partial coverage (e.g. user with BP buckets + new WEIGHT raw) falls back to live aggregate so no type silently underreports.
|
||||
- **`/api/measurements?source=rollup` opt-in.** Daily-aggregate chart fetches over windows > 7 days route through `measurement_rollups` DAY buckets instead of scanning the measurements table. Cold-fetch contribution per chart: ~3 s → estimated < 100 ms on Marc's account.
|
||||
- **`/api/insights/targets` cached.** Wrapped in the analytics LRU (60 s TTL, per-user key, invalidated by measurement/mood/medication mutations).
|
||||
- **Insights page: early-skeleton paint.** The page-level `isLoading` gate that blocked the entire shell on `/api/insights/comprehensive` is gone. Each section (DailyBriefing / CorrelationRow / TrendsRow) renders its own skeleton + fills in independently. (This is **NOT** streaming SSR. The QA reconcile dropped the inert `<Suspense>` wrappers because the page is `"use client"` + `next/dynamic({ssr:false})` and the boundaries never triggered. The honest description is "early skeleton paint".)
|
||||
|
||||
### AI
|
||||
|
||||
- **Insights `extractFeatures` swap from raw measurements to rollup buckets.** Power-user prompt: 25.9 MB → estimated ~50–500 KB. Hard 5 MB `FEATURES_MAX_BYTES` guard catches future regressions; the route handler downgrades on first oversize (drop raw) and again on second oversize (drop anthropometrics + medications + compliance + sleep + steps + hrv + resting_hr via the exclude filter). Third failure returns 422 + `insights_payload_too_large` annotate — never a 500.
|
||||
- **Coach exclusion toggles surfaced.** Coach settings sheet gained two new switches (medications, anthropometrics). The Coach PUT mirrors `excludeMetrics` onto a new `users.insightsExcludeMetrics` column so both surfaces share a single privacy contract.
|
||||
- **`compactSections` empty-block omit.** No more `Schlafdaten: [keine]` lines in either Coach or Insights prompts.
|
||||
|
||||
### Charts + Insights UI
|
||||
|
||||
- **`trends-row-chart-slot` fixed to `h-[140px]`.** Mood / BP / Weight cards now align byte-for-byte. Recharts `width=-1 height=-1` warning gone. Expect Lighthouse CLS drop from 0.46 toward zero.
|
||||
- **`ChartSkeleton` mini variant** matches the loaded chart's wrapper dimensions — no more hydration shift.
|
||||
- **`TrendAnnotation` tri-state contract** (`pending` / `needs_data` / `generated`). Fixes the cold-mount and regenerate flash of "mehr Daten nötig, um diesen Trend zu kommentieren". `pending` wins over `needs_data` so regenerate-in-flight never shows the empty hint.
|
||||
|
||||
### UX punch list
|
||||
|
||||
- **Medication history page restored** via `<IntakeHistoryListV2>` — lite table, server-side paginated, sortable by takenAt, no inline CRUD (consistent with the v1.4.28 retirement rationale; edits/deletes happen via the normal medication-intake routes). Mounted for ALL medication kinds, not just GLP-1.
|
||||
- **Tab-strip scroll** `inline: "center"` → `inline: "start"` on both Settings and Admin Console. Selected chip lands flush-left instead of jumping to centre.
|
||||
- **Steps tile + 4 other cumulative metrics** (ACTIVE_ENERGY_BURNED, WALKING_RUNNING_DISTANCE, FLIGHTS_CLIMBED, TIME_IN_DAYLIGHT) now read the day's cumulative sum (00:00 user-timezone) via the new `pickCumulativeDaySum` helper, source-priority-aware like SLEEP_DURATION already was.
|
||||
- **Insights nav strict-gate** — `if (!availability) return true` flipped so types without measurements no longer appear in the strip.
|
||||
- **About → Admin Console.** "Über HealthLog" moved from the personal-dropdown surface into a new `/admin/about` slug. Public `/about` landing untouched. Legacy `/settings/about` route kept as a bookmarkable permalink for any auth-user (Security reviewer M1: content scope = version + license + source links + update-tooltip, no PHI, accept as deliberate).
|
||||
- **Update-badge auto-check.** The "Update prüfen" button is gone. A 44 px-square, focus-ringed, aria-labelled badge appears next to the version line when the existing 24 h auto-check returns `newer_available`; the latest tag is interpolated into the aria-label and an sr-only `<span>` inside the anchor.
|
||||
- **IP-whois fallback** now surfaces city + country with "Carrier nicht verfügbar" / "Carrier unavailable" when the ASN lookup misses. ipwho.is free-tier limitation is now a visible-but-graceful UX, not a blank chip.
|
||||
|
||||
### a11y
|
||||
|
||||
- IntakeHistoryList v2 sort headers + pagination buttons clear the 44 px touch floor + carry `focus-visible:ring`.
|
||||
- UpdateBadge ditto + real `aria-label` + sr-only tag exposure.
|
||||
|
||||
### Misc
|
||||
|
||||
- `compactSections`, `applyInsightsExcludeFilter`, `pickCumulativeDaySum`, `probeRollupCoverage`, `isFullyCovered` are new shared helpers under `src/lib/insights/` and `src/lib/measurements/`. Tested standalone.
|
||||
- `ensureUserRollupsFresh` now annotates `{ rollup_refresh_failed: true, rollup_refresh_error: ... }` instead of swallowing silently. Read path still returns `{ recomputed: false }` so the response never fails because of a populator hiccup.
|
||||
- One auto-applied simplifier commit (`476ba98e`) hoists the cumulative-metric key lookup out of a 4-level nested ternary into a `switch`.
|
||||
- 7 orphan i18n keys × 6 locales removed (42 strings) — the `settings.about.updates*` / `checkUpdates` cluster that came with the manual update-check card.
|
||||
|
||||
## What was deferred to v1.4.37
|
||||
|
||||
From QA reviewers, these were noted but NOT fixed:
|
||||
|
||||
- **H2 (senior-dev)**: `applyInsightsExcludeFilter` shallow `next.context` mutation — fragile contract, no test pin. Low blast radius today (context isn't BP-derived), but worth a test guard.
|
||||
- **H4 (code-reviewer)**: narrow-aggregate query still scans 90 days of `measurements`. The column-pruning won is real but NOT the headline "sub-second" claim. The CHANGELOG below is calibrated honestly.
|
||||
- **M (security)**: `/settings/about` legacy route serves AboutSection to any auth-user. Content scope is benign. Add `permanentRedirect("/admin/about")` for admins + 404 for non-admins OR leave as documented permalink.
|
||||
- **M (senior-dev)**: `/api/measurements?source=rollup` response omits `id`/`unit`/`source`. iOS unaffected (doesn't pass `source=rollup`). Either dedicated `MeasurementBucketResource` schema or echo `unit` + sentinel `id` for OpenAPI conformance.
|
||||
- **M (code-reviewer)**: `BUCKETED_TYPES` in `features.ts` duplicates the rollup-populator's enum (drift risk).
|
||||
- **M (code-reviewer)**: COUNT-probe call sites in `summaries-slice.ts` and `comprehensive-aggregator.ts` could collapse into one helper (R1's `rollup-coverage.ts` already extracted the probe — verify and remove any remaining duplication).
|
||||
- **M (senior-dev)**: cumulative SUM `mean × count` over-counts on multi-source same-day (Apple Health + Withings both contributing steps on the same day). Pre-existing chart behaviour, not v1.4.36 regression.
|
||||
- **L (simplifier)**: `<a>` vs `<span>` update-badge near-duplicates (~20 LOC), `BucketedSeries` name collision between `features.ts` and `bucket-series.ts` (no actual import collision today), assorted comment debt.
|
||||
- **L (i18n)**: minor `motion-reduce:animate-none` doesn't cascade to children; sticky-first-column on IntakeHistoryList for narrow viewports.
|
||||
- **Health Score paint lag** — parent-level analytics query gating, not the card itself. Deferred from W2.
|
||||
|
||||
## Release flow (steps to execute in the new session)
|
||||
|
||||
### 1. Verify quality gates one more time
|
||||
|
||||
```bash
|
||||
git checkout develop && git pull
|
||||
pnpm typecheck
|
||||
pnpm lint
|
||||
pnpm test
|
||||
pnpm test:integration tests/integration/measurement-rollups.test.ts # isolation pass — should be 9/9 green
|
||||
```
|
||||
|
||||
### 2. Version bump
|
||||
|
||||
`package.json`: `"version": "1.4.35.1"` → `"version": "1.4.36"`.
|
||||
|
||||
### 3. CHANGELOG
|
||||
|
||||
Add a `## [1.4.36] — 2026-05-17 — Perf, charts, AI payload trim, UX punch` block at the top of `CHANGELOG.md`, ABOVE the existing `## [1.4.35.1]` entry. Source-of-truth = the "What v1.4.36 actually delivers" section above. Honest framing on the perf wave — "early-skeleton paint" NOT "Suspense streaming"; the heavy-aggregate skip + per-type coverage probe + `source=rollup` opt-in are the real perf wins.
|
||||
|
||||
### 4. Commit version + CHANGELOG on develop
|
||||
|
||||
```bash
|
||||
git add CHANGELOG.md package.json
|
||||
git commit -m "chore(release): v1.4.36 — perf, charts, AI payload trim, UX punch
|
||||
|
||||
(body: terse 4-5 line summary)"
|
||||
git push origin develop
|
||||
```
|
||||
|
||||
### 5. Squash to main
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git merge --squash develop
|
||||
# Conflicts expected on CHANGELOG.md, package.json, prisma/schema.prisma, plus the touched route + lib files.
|
||||
# Take develop's tree for every conflicted file (it's the truth):
|
||||
git checkout --theirs <all conflicted files>
|
||||
git add <all conflicted files>
|
||||
git diff develop --stat # should be empty — main now matches develop exactly
|
||||
git commit -m "v1.4.36 — perf, charts, AI payload trim, UX punch
|
||||
|
||||
(body)"
|
||||
```
|
||||
|
||||
### 6. Tag + push + GH release
|
||||
|
||||
```bash
|
||||
git tag -a v1.4.36 -m "v1.4.36 — perf, charts, AI payload trim, UX punch"
|
||||
git push origin main && git push origin v1.4.36
|
||||
# Release notes: same source-of-truth as the CHANGELOG body, plus operator notes
|
||||
gh release create v1.4.36 --title "v1.4.36 — Perf, charts, AI payload trim, UX punch" --notes-file /tmp/v1436-release-notes.md
|
||||
```
|
||||
|
||||
### 7. Live deploy verify
|
||||
|
||||
```bash
|
||||
# Background poll the version endpoint
|
||||
until v=$(curl -fs https://healthlog.bombeck.io/api/version | jq -r .data.version) && [ "$v" = "1.4.36" ]; do sleep 20; done
|
||||
# First webhook will pull stale :latest; trigger redeploy after docker-publish workflow completes:
|
||||
gh run watch # docker-publish workflow
|
||||
# Then via Coolify MCP:
|
||||
# mcp__coolify-apps01__deploy { tag_or_uuid: "pg8wggwogo8c4gc4ks0kk4ss", force: true }
|
||||
```
|
||||
|
||||
### 8. **Post-deploy perf-QA (Marc directive — do not skip)**
|
||||
|
||||
Marc was explicit: "muss jetzt deutlich schneller sein, sowohl Coach als auch Dashboards — wir dürfen nicht mehr mit Millionen von Daten umgehen." Measure, don't estimate.
|
||||
|
||||
**Required tests (capture numbers, paste into a `.planning/round-v1436-perf-verify.md` doc):**
|
||||
|
||||
```bash
|
||||
# (a) AI Insights payload size — should be DRAMATICALLY lower than v1.4.35.1's 25.9 MB
|
||||
# Trigger a generation from the UI, then check the most recent insights.generate log:
|
||||
ssh apps-01 'docker logs $(docker ps --format "{{.Names}}" | grep "^app-pg8wggwogo8c4gc4ks0kk4ss") 2>&1 | grep insights.generate | tail -5'
|
||||
# Look for: features_payload_size_bytes annotate field. Should be < 1 MB.
|
||||
|
||||
# (b) /api/insights/comprehensive cold-mount
|
||||
curl -sw "TOTAL: %{time_total}s\n" -o /dev/null -b "session=..." https://healthlog.bombeck.io/api/insights/comprehensive
|
||||
# Expected: ~1.5–2 s (was 10.88 s in v1.4.35.1 HAR).
|
||||
|
||||
# (c) /api/analytics?slice=summaries cold-mount
|
||||
curl -sw "TOTAL: %{time_total}s\n" -o /dev/null -b "session=..." "https://healthlog.bombeck.io/api/analytics?slice=summaries"
|
||||
# Expected: ~0.5 s (was 3.5 s).
|
||||
|
||||
# (d) /api/measurements?aggregate=daily&source=rollup
|
||||
curl -sw "TOTAL: %{time_total}s\n" -o /dev/null -b "session=..." "https://healthlog.bombeck.io/api/measurements?type=BLOOD_PRESSURE_SYS&aggregate=daily&source=rollup&from=...&to=...&limit=5000"
|
||||
# Expected: < 100 ms (was 3 s).
|
||||
|
||||
# (e) Coach prompt size — fire a Coach query, check the snapshot log
|
||||
ssh apps-01 'docker logs $(docker ps --format "{{.Names}}" | grep "^app-pg8wggwogo8c4gc4ks0kk4ss") 2>&1 | grep -i "coach\|snapshot" | tail -10'
|
||||
|
||||
# (f) Capture a fresh HAR on /insights cold-mount with DevTools and compare to .planning/round-v1436-handover.md baseline (Insights HAR showed 10.88 s page render + 3 × 3 s daily-aggregate calls).
|
||||
```
|
||||
|
||||
**If any number is materially worse than estimate** — STOP, file a v1.4.36.1 hotfix issue, don't claim the win.
|
||||
|
||||
### 9. Closure + memory
|
||||
|
||||
- `.planning/round-audit-marathon-closure.md` → add v1.4.36 addendum line.
|
||||
- Memory: update `~/.claude/projects/-Users-marc-Projects-HealthLog/memory/MEMORY.md` index line for the marathon to include v1.4.36 + headline numbers from step 8.
|
||||
- Save a new memory `feedback_v1435_partial_readswap_regression.md` if not yet captured — the v1.4.35 "always run both" pattern was a real failure mode caught by reviewers; future-me should default to "make the swap actually replace, not parallel".
|
||||
|
||||
### 10. Tag closure commit on develop + push
|
||||
|
||||
```bash
|
||||
git checkout develop
|
||||
git add .planning/round-audit-marathon-closure.md
|
||||
git commit -m "docs(planning): v1.4.36 closure"
|
||||
git push origin develop
|
||||
```
|
||||
|
||||
## Non-negotiables (carry from prior rounds)
|
||||
|
||||
- Marc-Voice commits. No `Co-Authored-By: Claude`. No `--no-verify`. No `--no-gpg-sign`.
|
||||
- English commits + CHANGELOG + release notes. No "AI/Claude/agent/marathon/wave/phase" in user-facing artifacts.
|
||||
- No PII anywhere (Marc's name, his BP values, his measurement counts) in CHANGELOG / GH release notes / docs.
|
||||
- Branch model: develop is feature target, main is release-only. Never commit directly to main except the release squash.
|
||||
- No PROD destructive operations without confirmation.
|
||||
|
||||
## Operator notes
|
||||
|
||||
- Coolify auto-deploys main on tag push (working since v1.4.34.2).
|
||||
- First webhook pulls stale `:latest`; second deploy after docker-publish completes pulls the new image. Trigger via Coolify MCP `deploy` action.
|
||||
- GHCR workflow takes ~8 min.
|
||||
- Marc's account already has fully populated rollups (boot-fold from v1.4.35.1 ran), so the per-type coverage probe will take the rollup-fast path immediately.
|
||||
- The known integration-test flakes (`apns-dispatch`, `integration-status`, `enqueueBootTimeRollupBackfill`) are pre-existing `isolate:false` mock-state collisions, NOT v1.4.36 regressions. Pass in isolation. Document in any QA report.
|
||||
+174
@@ -1,5 +1,179 @@
|
||||
# Changelog
|
||||
|
||||
## [1.4.36] — 2026-05-17 — Perf, charts, AI payload trim, UX punch
|
||||
|
||||
Builds on the v1.4.35 rollup foundation. The aggregator hot paths now
|
||||
skip the legacy live `COUNT/MIN/MAX/AVG $queryRaw` when every logged
|
||||
type already has DAY-bucket coverage, daily chart fetches opt into
|
||||
reading from `measurement_rollups`, and the Insights feature
|
||||
extractor swaps raw measurements for the same bucket source — which
|
||||
caps the prompt payload in the low hundreds of kB rather than
|
||||
double-digit MB for power users. The Insights page itself stops
|
||||
blocking the entire shell on `/api/insights/comprehensive`, so each
|
||||
section renders its own skeleton and fills in independently.
|
||||
|
||||
On top of the perf wave: chart slot heights pinned to kill Recharts'
|
||||
`width=-1 height=-1` warning and the matching CLS, a tri-state trend
|
||||
annotation contract that ends the cold-mount "more data needed" flash,
|
||||
medication intake history restored on the detail page, About card
|
||||
folded into the Admin Console with an auto-checking update badge in
|
||||
place of the manual button, cumulative-metric tiles (Steps + four
|
||||
others) reading the day's running sum, and an IP-whois fallback that
|
||||
surfaces city + country when the carrier lookup misses.
|
||||
|
||||
### Performance
|
||||
|
||||
- **Heavy-aggregate skip on the rollup-fresh + fully-covered path.**
|
||||
`comprehensive-aggregator` and the slim `summaries-slice` now consult
|
||||
a per-type coverage probe (`src/lib/measurements/rollup-coverage.ts`)
|
||||
before running the legacy `COUNT/MIN/MAX/AVG $queryRaw`. If every
|
||||
logged type has DAY coverage the probe wins and the live aggregate
|
||||
is skipped entirely. Partial coverage (e.g. an account with BP
|
||||
buckets + a freshly logged WEIGHT type) falls back to the live
|
||||
aggregate so no type silently underreports.
|
||||
- **Daily chart fetches opt into rollup buckets.** `GET
|
||||
/api/measurements?aggregate=daily&source=rollup` reads DAY buckets
|
||||
from `measurement_rollups` instead of scanning the measurements
|
||||
table when the window exceeds seven days. The Insights chart
|
||||
fetchers pass `source=rollup`; iOS and other clients are
|
||||
unaffected.
|
||||
- **`/api/insights/targets` cached.** Wrapped in the analytics LRU
|
||||
(60 s TTL, per-user key, evicted on measurement / mood / medication
|
||||
mutations).
|
||||
- **Insights page: early-skeleton paint.** The page-level `isLoading`
|
||||
gate that held the entire shell on `/api/insights/comprehensive`
|
||||
is gone. DailyBriefing, CorrelationRow and TrendsRow render their
|
||||
own skeletons and fill in independently. (This is early-skeleton
|
||||
paint, not Suspense streaming — the page is `"use client"` +
|
||||
`next/dynamic({ssr:false})`, so the boundaries the rewrite tried
|
||||
to install never triggered and were dropped during reconcile.)
|
||||
|
||||
### AI
|
||||
|
||||
- **Insights `extractFeatures` swap to rollup buckets.** The feature
|
||||
extractor reads `measurement_rollups` DAY buckets instead of raw
|
||||
measurements, dropping the prompt payload on a power-user account
|
||||
from ~26 MB to the low hundreds of kB. A hard 5 MB
|
||||
`FEATURES_MAX_BYTES` guard catches future regressions: the route
|
||||
handler downgrades on first oversize (drop raw measurements) and
|
||||
again on second oversize (drop anthropometrics + medications +
|
||||
compliance + sleep + steps + HRV + resting HR via the exclude
|
||||
filter). A third failure returns 422 with
|
||||
`insights_payload_too_large` — never a 500.
|
||||
- **Coach exclusion toggles surfaced.** The Coach settings sheet
|
||||
gained medications and anthropometrics switches. The Coach PUT
|
||||
mirrors `excludeMetrics` onto a new
|
||||
`users.insights_exclude_metrics` column so the Coach sheet and the
|
||||
Insights privacy panel share a single contract.
|
||||
- **`compactSections` empty-block omit.** Coach and Insights prompts
|
||||
no longer ship `Schlafdaten: [keine]` / `Medikamente: [keine]`
|
||||
lines for sections the account has no data for.
|
||||
|
||||
### Charts + Insights UI
|
||||
|
||||
- **`trends-row-chart-slot` fixed to `h-[140px]`.** Mood, BP and
|
||||
Weight trend cards now align byte-for-byte. Recharts'
|
||||
`width=-1 height=-1` warning is gone; expect a measurable
|
||||
Lighthouse CLS drop on the Insights page.
|
||||
- **`ChartSkeleton` mini variant** matches the loaded chart's wrapper
|
||||
dimensions so the trends row stops shifting on hydration.
|
||||
- **`TrendAnnotation` tri-state contract** (`pending` / `needs_data` /
|
||||
`generated`). Fixes the cold-mount and regenerate-in-flight flash
|
||||
of "mehr Daten nötig, um diesen Trend zu kommentieren". `pending`
|
||||
wins over `needs_data` so a regenerate already in flight never
|
||||
drops into the empty-hint copy.
|
||||
|
||||
### UX
|
||||
|
||||
- **Medication intake history restored** via `<IntakeHistoryListV2>`
|
||||
on the medication detail page. Server-paginated, sortable by
|
||||
`takenAt`, no inline CRUD (consistent with the v1.4.28 retirement
|
||||
rationale — edits and deletes go through the regular medication-
|
||||
intake routes). Mounted for every medication kind, not just GLP-1.
|
||||
- **Tab-strip scroll** `inline: "center"` → `inline: "start"` on the
|
||||
Settings and Admin Console nav strips. The selected chip now lands
|
||||
flush-left instead of jumping into the middle of the viewport.
|
||||
- **Cumulative-metric tiles** (Steps, Active Energy, Walking/Running
|
||||
Distance, Flights Climbed, Time in Daylight) read the day's
|
||||
cumulative sum from 00:00 in the user's timezone via a new
|
||||
`pickCumulativeDaySum` helper, source-priority-aware in the same
|
||||
shape SLEEP_DURATION already used.
|
||||
- **Insights nav strict-gate** flipped (`if (!availability) return
|
||||
true` → `return false`). Types without measurements no longer
|
||||
appear in the strip.
|
||||
- **About → Admin Console.** "Über HealthLog" moved out of the
|
||||
personal dropdown into a new `/admin/about` slug. The public
|
||||
marketing `/about` page is untouched. `/settings/about` stays as a
|
||||
bookmarkable permalink for any authenticated user.
|
||||
- **Update badge auto-check.** The manual "Update prüfen" button is
|
||||
retired. A 44 px-square, focus-ringed, `aria-label`-bearing badge
|
||||
surfaces next to the version line when the existing 24 h
|
||||
auto-check returns `newer_available`; the latest tag is
|
||||
interpolated into both the label and an `sr-only` span.
|
||||
- **IP-whois fallback** surfaces city + country with "Carrier nicht
|
||||
verfügbar" / "Carrier unavailable" when the upstream ASN lookup
|
||||
misses. The free-tier ipwho.is shortcoming is now graceful UX, not
|
||||
a blank chip.
|
||||
|
||||
### Accessibility
|
||||
|
||||
- IntakeHistoryListV2 sort headers and pagination buttons clear the
|
||||
44 px touch floor and carry `focus-visible:ring`.
|
||||
- UpdateBadge ditto, with a real `aria-label` and an `sr-only` tag
|
||||
exposure inside the anchor.
|
||||
|
||||
### Schema
|
||||
|
||||
- **Additive `users.insights_exclude_metrics`** (`String[] NOT NULL
|
||||
DEFAULT '{}'`). Migration
|
||||
`0068_v1436_insights_exclude_metrics` is idempotent under re-run.
|
||||
|
||||
### Refactor
|
||||
|
||||
- New shared helpers under `src/lib/insights/` and
|
||||
`src/lib/measurements/`: `compactSections`,
|
||||
`applyInsightsExcludeFilter`, `pickCumulativeDaySum`,
|
||||
`probeRollupCoverage`, `isFullyCovered`. Tested standalone.
|
||||
- `ensureUserRollupsFresh` now annotates `{ rollup_refresh_failed:
|
||||
true, rollup_refresh_error: ... }` on failure instead of swallowing
|
||||
silently. The read path still returns `{ recomputed: false }` so
|
||||
the response never fails because of a populator hiccup.
|
||||
- Cumulative metric-key lookup hoisted out of a four-level nested
|
||||
ternary into a `switch`.
|
||||
- 42 orphan i18n strings removed (7 keys × 6 locales) — the
|
||||
`settings.about.updates*` / `checkUpdates` cluster left behind by
|
||||
the manual update-check retirement.
|
||||
|
||||
### Tests
|
||||
|
||||
- Unit suite 4285 → 4354 passing (1 skipped). `pnpm typecheck` +
|
||||
`pnpm lint` clean. Integration suite stable at 230 passing; two
|
||||
known mock-isolation flakes (`apns-dispatch` /
|
||||
`integration-status` and `enqueueBootTimeRollupBackfill` when run
|
||||
alongside `admin-backups-audit.test.ts`) pass in isolation and
|
||||
predate v1.4.36.
|
||||
|
||||
### Deferred to v1.4.37
|
||||
|
||||
- `applyInsightsExcludeFilter` shallow `next.context` mutation needs
|
||||
a contract test pin.
|
||||
- Narrow-aggregate query still scans 90 days of `measurements`; the
|
||||
column-pruning is real but not sub-second on cold rows.
|
||||
- `/settings/about` legacy route still serves AboutSection to any
|
||||
authenticated user — content scope is benign; decision pending on
|
||||
redirect vs 404 vs documented permalink.
|
||||
- `/api/measurements?source=rollup` response omits `id` / `unit` /
|
||||
`source` for the bucketed shape; iOS unaffected (does not pass
|
||||
`source=rollup`). A dedicated `MeasurementBucketResource` schema
|
||||
or an `id`/`unit` echo lands next.
|
||||
- `BUCKETED_TYPES` in `features.ts` duplicates the rollup-populator
|
||||
enum (drift risk).
|
||||
- COUNT-probe call sites in `summaries-slice.ts` and
|
||||
`comprehensive-aggregator.ts` could collapse into one helper.
|
||||
- Cumulative SUM `mean × count` over-counts when two sources
|
||||
contribute the same day (Apple Health + Withings both posting
|
||||
steps). Pre-existing chart behaviour, not a v1.4.36 regression.
|
||||
|
||||
## [1.4.35.1] — 2026-05-17 — Auto-converging rollup backfill on worker boot
|
||||
|
||||
Follow-on to v1.4.35. Removes the operator action that was implicit
|
||||
|
||||
+15
-3
@@ -353,8 +353,10 @@ components:
|
||||
- sleep
|
||||
- resting_hr
|
||||
- steps
|
||||
- medications
|
||||
- anthropometrics
|
||||
type: string
|
||||
maxItems: 9
|
||||
maxItems: 11
|
||||
type: array
|
||||
showEvidenceByDefault:
|
||||
default: false
|
||||
@@ -1076,8 +1078,10 @@ components:
|
||||
- sleep
|
||||
- resting_hr
|
||||
- steps
|
||||
- medications
|
||||
- anthropometrics
|
||||
type: string
|
||||
maxItems: 9
|
||||
maxItems: 11
|
||||
type: array
|
||||
showEvidenceByDefault:
|
||||
default: false
|
||||
@@ -1880,8 +1884,10 @@ paths:
|
||||
- sleep
|
||||
- resting_hr
|
||||
- steps
|
||||
- medications
|
||||
- anthropometrics
|
||||
type: string
|
||||
maxItems: 9
|
||||
maxItems: 11
|
||||
type: array
|
||||
showEvidenceByDefault:
|
||||
default: false
|
||||
@@ -2486,6 +2492,12 @@ paths:
|
||||
- weekly
|
||||
- monthly
|
||||
type: string
|
||||
- in: query
|
||||
name: source
|
||||
schema:
|
||||
enum:
|
||||
- rollup
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
|
||||
+29
-11
@@ -695,6 +695,20 @@
|
||||
"sourceApi": "Externe API",
|
||||
"sourceReminder": "Telegram / Erinnerung",
|
||||
"sourceImport": "CSV-Import",
|
||||
"intakeHistoryEmptyTitle": "Noch keine Einnahmen erfasst",
|
||||
"intakeHistoryEmptyDescription": "Trage eine Einnahme auf der Tagesübersicht ein, um den Verlauf aufzubauen.",
|
||||
"intakeHistoryEmptyAction": "Zur Tagesübersicht",
|
||||
"intakeHistoryColTaken": "Eingenommen",
|
||||
"intakeHistoryColScheduled": "Geplant",
|
||||
"intakeHistoryColStatus": "Status",
|
||||
"intakeHistoryColSource": "Quelle",
|
||||
"intakeHistoryStatusTaken": "Eingenommen",
|
||||
"intakeHistoryStatusSkipped": "Übersprungen",
|
||||
"intakeHistorySortByTaken": "Nach Einnahme-Zeitpunkt sortieren",
|
||||
"intakeHistorySortByScheduled": "Nach geplantem Zeitpunkt sortieren",
|
||||
"intakeHistoryPageInfo": "Seite {page} von {total} · {count} Einträge",
|
||||
"intakeHistoryPrev": "Zurück",
|
||||
"intakeHistoryNext": "Weiter",
|
||||
"unitDrops": "Tropfen",
|
||||
"unitTablets": "Tablette(n)",
|
||||
"unitCapsules": "Kapsel(n)",
|
||||
@@ -1004,7 +1018,8 @@
|
||||
"emptyMood": "Mehr Daten nötig, um diesen Trend zu kommentieren.",
|
||||
"confidenceHigh": "Hohe Konfidenz",
|
||||
"confidenceModerate": "Mittlere Konfidenz",
|
||||
"confidenceLow": "Geringe Konfidenz"
|
||||
"confidenceLow": "Geringe Konfidenz",
|
||||
"pendingLabel": "Trend-Kommentar wird generiert"
|
||||
},
|
||||
"correlationRow": {
|
||||
"title": "Zusammenhänge",
|
||||
@@ -1066,7 +1081,9 @@
|
||||
"flights": "Stockwerke",
|
||||
"distance": "Strecke",
|
||||
"vo2_max": "VO₂ max",
|
||||
"body_temp": "Körpertemperatur"
|
||||
"body_temp": "Körpertemperatur",
|
||||
"medications": "Medikamente",
|
||||
"anthropometrics": "Profil (Größe, Alter, Geschlecht)"
|
||||
},
|
||||
"window": {
|
||||
"last7days": "letzte 7 Tage",
|
||||
@@ -1089,6 +1106,8 @@
|
||||
"settingsVerbosityDetailed": "Ausführlich",
|
||||
"settingsExcludeLabel": "Metriken ausschließen",
|
||||
"settingsExcludeHint": "Der Coach sieht keine Daten der ausgewählten Metriken.",
|
||||
"settingsContextLabel": "Kontext mitschicken",
|
||||
"settingsContextHint": "Optionale Blöcke, auf die sich der Coach stützen kann. Schalte einen ab, wird der Block in jeder Antwort weggelassen.",
|
||||
"settingsEvidenceLabel": "Belege standardmäßig zeigen",
|
||||
"settingsEvidenceHint": "Die Belege-Box unter jeder Antwort automatisch öffnen.",
|
||||
"settingsDefaultWindowLabel": "Standard-Analysezeitraum",
|
||||
@@ -1921,19 +1940,13 @@
|
||||
"version": "App-Version",
|
||||
"gitSha": "Build",
|
||||
"builtAt": "Erstellt {time}",
|
||||
"builtAtLabel": "Erstellt",
|
||||
"license": "Lizenz",
|
||||
"repository": "Quellcode",
|
||||
"changelog": "Changelog",
|
||||
"docs": "Dokumentation",
|
||||
"linksHeading": "Quellen & Dokumentation",
|
||||
"updatesHeading": "Updates",
|
||||
"checkUpdates": "Jetzt prüfen",
|
||||
"checking": "Prüfe…",
|
||||
"lastChecked": "Zuletzt geprüft: {time}",
|
||||
"lastCheckedNever": "Noch nicht geprüft",
|
||||
"upToDate": "Du nutzt die aktuelle Version",
|
||||
"newerAvailable": "Neue Version: {tag}",
|
||||
"checkFailed": "Konnte GitHub nicht erreichen — versuche es später erneut"
|
||||
"newerAvailable": "Neue Version: {tag}"
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Verbindung testen",
|
||||
@@ -2419,10 +2432,15 @@
|
||||
"danger-zone": {
|
||||
"title": "Gefahrenzone",
|
||||
"subtitle": "Destruktive systemweite Operationen."
|
||||
},
|
||||
"about": {
|
||||
"title": "Über",
|
||||
"subtitle": "Version, Lizenz, Quellcode und Update-Prüfung."
|
||||
}
|
||||
},
|
||||
"carrier": "Anbieter",
|
||||
"carrierUnknown": "Unbekannter Anbieter"
|
||||
"carrierUnknown": "Unbekannter Anbieter",
|
||||
"carrierUnavailableGeoFallback": "{location} — Anbieter nicht verfügbar"
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Erfolge",
|
||||
|
||||
+29
-11
@@ -695,6 +695,20 @@
|
||||
"sourceApi": "External API",
|
||||
"sourceReminder": "Telegram / Reminder",
|
||||
"sourceImport": "CSV Import",
|
||||
"intakeHistoryEmptyTitle": "No intakes recorded yet",
|
||||
"intakeHistoryEmptyDescription": "Log a dose on the daily intake page to start building the history.",
|
||||
"intakeHistoryEmptyAction": "Open daily intake",
|
||||
"intakeHistoryColTaken": "Taken",
|
||||
"intakeHistoryColScheduled": "Scheduled",
|
||||
"intakeHistoryColStatus": "Status",
|
||||
"intakeHistoryColSource": "Source",
|
||||
"intakeHistoryStatusTaken": "Taken",
|
||||
"intakeHistoryStatusSkipped": "Skipped",
|
||||
"intakeHistorySortByTaken": "Sort by taken time",
|
||||
"intakeHistorySortByScheduled": "Sort by scheduled time",
|
||||
"intakeHistoryPageInfo": "Page {page} of {total} · {count} entries",
|
||||
"intakeHistoryPrev": "Previous",
|
||||
"intakeHistoryNext": "Next",
|
||||
"unitDrops": "Drops",
|
||||
"unitTablets": "Tablet(s)",
|
||||
"unitCapsules": "Capsule(s)",
|
||||
@@ -1004,7 +1018,8 @@
|
||||
"emptyMood": "Awaiting more data to annotate this trend.",
|
||||
"confidenceHigh": "High confidence",
|
||||
"confidenceModerate": "Moderate confidence",
|
||||
"confidenceLow": "Low confidence"
|
||||
"confidenceLow": "Low confidence",
|
||||
"pendingLabel": "Generating trend commentary"
|
||||
},
|
||||
"correlationRow": {
|
||||
"title": "Relationships",
|
||||
@@ -1066,7 +1081,9 @@
|
||||
"flights": "Flights climbed",
|
||||
"distance": "Distance",
|
||||
"vo2_max": "VO₂ max",
|
||||
"body_temp": "Body temperature"
|
||||
"body_temp": "Body temperature",
|
||||
"medications": "Medications",
|
||||
"anthropometrics": "Profile (height, age, gender)"
|
||||
},
|
||||
"window": {
|
||||
"last7days": "last 7 days",
|
||||
@@ -1089,6 +1106,8 @@
|
||||
"settingsVerbosityDetailed": "Detailed",
|
||||
"settingsExcludeLabel": "Exclude metrics",
|
||||
"settingsExcludeHint": "The Coach will not see data for selected metrics.",
|
||||
"settingsContextLabel": "Include context",
|
||||
"settingsContextHint": "Optional blocks the Coach can lean on. Switch one off and the block is dropped from every turn.",
|
||||
"settingsEvidenceLabel": "Show evidence by default",
|
||||
"settingsEvidenceHint": "Open the disclosure under every reply automatically.",
|
||||
"settingsDefaultWindowLabel": "Default analysis window",
|
||||
@@ -1921,19 +1940,13 @@
|
||||
"version": "App version",
|
||||
"gitSha": "Build",
|
||||
"builtAt": "Built {time}",
|
||||
"builtAtLabel": "Built",
|
||||
"license": "License",
|
||||
"repository": "Source code",
|
||||
"changelog": "Changelog",
|
||||
"docs": "Documentation",
|
||||
"linksHeading": "Sources & docs",
|
||||
"updatesHeading": "Updates",
|
||||
"checkUpdates": "Check now",
|
||||
"checking": "Checking…",
|
||||
"lastChecked": "Last checked: {time}",
|
||||
"lastCheckedNever": "Not checked yet",
|
||||
"upToDate": "You are up to date",
|
||||
"newerAvailable": "Newer version: {tag}",
|
||||
"checkFailed": "Could not reach GitHub — try again later"
|
||||
"newerAvailable": "Newer version: {tag}"
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Test connection",
|
||||
@@ -2419,10 +2432,15 @@
|
||||
"danger-zone": {
|
||||
"title": "Danger Zone",
|
||||
"subtitle": "Destructive system-wide operations."
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
"subtitle": "Version, license, source, and update check."
|
||||
}
|
||||
},
|
||||
"carrier": "Carrier",
|
||||
"carrierUnknown": "Unknown carrier"
|
||||
"carrierUnknown": "Unknown carrier",
|
||||
"carrierUnavailableGeoFallback": "{location} — carrier unavailable"
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Achievements",
|
||||
|
||||
+30
-12
@@ -695,6 +695,20 @@
|
||||
"sourceApi": "External API",
|
||||
"sourceReminder": "Telegram / Reminder",
|
||||
"sourceImport": "CSV Import",
|
||||
"intakeHistoryEmptyTitle": "No intakes recorded yet",
|
||||
"intakeHistoryEmptyDescription": "Log a dose on the daily intake page to start building the history.",
|
||||
"intakeHistoryEmptyAction": "Open daily intake",
|
||||
"intakeHistoryColTaken": "Taken",
|
||||
"intakeHistoryColScheduled": "Scheduled",
|
||||
"intakeHistoryColStatus": "Status",
|
||||
"intakeHistoryColSource": "Source",
|
||||
"intakeHistoryStatusTaken": "Taken",
|
||||
"intakeHistoryStatusSkipped": "Skipped",
|
||||
"intakeHistorySortByTaken": "Sort by taken time",
|
||||
"intakeHistorySortByScheduled": "Sort by scheduled time",
|
||||
"intakeHistoryPageInfo": "Page {page} of {total} · {count} entries",
|
||||
"intakeHistoryPrev": "Previous",
|
||||
"intakeHistoryNext": "Next",
|
||||
"unitDrops": "Drops",
|
||||
"unitTablets": "Tablet(s)",
|
||||
"unitCapsules": "Capsule(s)",
|
||||
@@ -1004,7 +1018,8 @@
|
||||
"emptyMood": "Awaiting more data to annotate this trend.",
|
||||
"confidenceHigh": "High confidence",
|
||||
"confidenceModerate": "Moderate confidence",
|
||||
"confidenceLow": "Low confidence"
|
||||
"confidenceLow": "Low confidence",
|
||||
"pendingLabel": "Generating trend commentary"
|
||||
},
|
||||
"correlationRow": {
|
||||
"title": "Relationships",
|
||||
@@ -1066,7 +1081,9 @@
|
||||
"flights": "Flights climbed",
|
||||
"distance": "Distance",
|
||||
"vo2_max": "VO₂ max",
|
||||
"body_temp": "Body temperature"
|
||||
"body_temp": "Body temperature",
|
||||
"medications": "Medications",
|
||||
"anthropometrics": "Profile (height, age, gender)"
|
||||
},
|
||||
"window": {
|
||||
"last7days": "Últimos 7 días",
|
||||
@@ -1102,7 +1119,9 @@
|
||||
"dailyLimitTitle": "Daily limit reached",
|
||||
"dailyLimitBody": "Daily limit reached; resets at 00:00 UTC.",
|
||||
"providerRateLimitTitle": "Provider rate-limited",
|
||||
"providerRateLimitBody": "Provider temporarily rate-limited; retry in ~5 min."
|
||||
"providerRateLimitBody": "Provider temporarily rate-limited; retry in ~5 min.",
|
||||
"settingsContextLabel": "Include context",
|
||||
"settingsContextHint": "Optional blocks the Coach can lean on. Switch one off and the block is dropped from every turn."
|
||||
},
|
||||
"relativeJustNow": "just now",
|
||||
"relativeMinutesAgo": "{count} minutes ago",
|
||||
@@ -1921,19 +1940,13 @@
|
||||
"version": "App version",
|
||||
"gitSha": "Build",
|
||||
"builtAt": "Built {time}",
|
||||
"builtAtLabel": "Built",
|
||||
"license": "License",
|
||||
"repository": "Source code",
|
||||
"changelog": "Changelog",
|
||||
"docs": "Documentation",
|
||||
"linksHeading": "Sources & docs",
|
||||
"updatesHeading": "Updates",
|
||||
"checkUpdates": "Check now",
|
||||
"checking": "Checking…",
|
||||
"lastChecked": "Last checked: {time}",
|
||||
"lastCheckedNever": "Not checked yet",
|
||||
"upToDate": "You are up to date",
|
||||
"newerAvailable": "Newer version: {tag}",
|
||||
"checkFailed": "Could not reach GitHub — try again later"
|
||||
"newerAvailable": "Newer version: {tag}"
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Test connection",
|
||||
@@ -2419,10 +2432,15 @@
|
||||
"danger-zone": {
|
||||
"title": "Danger Zone",
|
||||
"subtitle": "Destructive system-wide operations."
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
"subtitle": "Version, license, source, and update check."
|
||||
}
|
||||
},
|
||||
"carrier": "Operador",
|
||||
"carrierUnknown": "Operador desconocido"
|
||||
"carrierUnknown": "Operador desconocido",
|
||||
"carrierUnavailableGeoFallback": "{location} — carrier unavailable"
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Achievements",
|
||||
|
||||
+30
-12
@@ -695,6 +695,20 @@
|
||||
"sourceApi": "External API",
|
||||
"sourceReminder": "Telegram / Reminder",
|
||||
"sourceImport": "CSV Import",
|
||||
"intakeHistoryEmptyTitle": "No intakes recorded yet",
|
||||
"intakeHistoryEmptyDescription": "Log a dose on the daily intake page to start building the history.",
|
||||
"intakeHistoryEmptyAction": "Open daily intake",
|
||||
"intakeHistoryColTaken": "Taken",
|
||||
"intakeHistoryColScheduled": "Scheduled",
|
||||
"intakeHistoryColStatus": "Status",
|
||||
"intakeHistoryColSource": "Source",
|
||||
"intakeHistoryStatusTaken": "Taken",
|
||||
"intakeHistoryStatusSkipped": "Skipped",
|
||||
"intakeHistorySortByTaken": "Sort by taken time",
|
||||
"intakeHistorySortByScheduled": "Sort by scheduled time",
|
||||
"intakeHistoryPageInfo": "Page {page} of {total} · {count} entries",
|
||||
"intakeHistoryPrev": "Previous",
|
||||
"intakeHistoryNext": "Next",
|
||||
"unitDrops": "Drops",
|
||||
"unitTablets": "Tablet(s)",
|
||||
"unitCapsules": "Capsule(s)",
|
||||
@@ -1004,7 +1018,8 @@
|
||||
"emptyMood": "Awaiting more data to annotate this trend.",
|
||||
"confidenceHigh": "High confidence",
|
||||
"confidenceModerate": "Moderate confidence",
|
||||
"confidenceLow": "Low confidence"
|
||||
"confidenceLow": "Low confidence",
|
||||
"pendingLabel": "Generating trend commentary"
|
||||
},
|
||||
"correlationRow": {
|
||||
"title": "Relationships",
|
||||
@@ -1066,7 +1081,9 @@
|
||||
"flights": "Flights climbed",
|
||||
"distance": "Distance",
|
||||
"vo2_max": "VO₂ max",
|
||||
"body_temp": "Body temperature"
|
||||
"body_temp": "Body temperature",
|
||||
"medications": "Medications",
|
||||
"anthropometrics": "Profile (height, age, gender)"
|
||||
},
|
||||
"window": {
|
||||
"last7days": "7 derniers jours",
|
||||
@@ -1102,7 +1119,9 @@
|
||||
"dailyLimitTitle": "Daily limit reached",
|
||||
"dailyLimitBody": "Daily limit reached; resets at 00:00 UTC.",
|
||||
"providerRateLimitTitle": "Provider rate-limited",
|
||||
"providerRateLimitBody": "Provider temporarily rate-limited; retry in ~5 min."
|
||||
"providerRateLimitBody": "Provider temporarily rate-limited; retry in ~5 min.",
|
||||
"settingsContextLabel": "Include context",
|
||||
"settingsContextHint": "Optional blocks the Coach can lean on. Switch one off and the block is dropped from every turn."
|
||||
},
|
||||
"relativeJustNow": "just now",
|
||||
"relativeMinutesAgo": "{count} minutes ago",
|
||||
@@ -1921,19 +1940,13 @@
|
||||
"version": "App version",
|
||||
"gitSha": "Build",
|
||||
"builtAt": "Built {time}",
|
||||
"builtAtLabel": "Built",
|
||||
"license": "License",
|
||||
"repository": "Source code",
|
||||
"changelog": "Changelog",
|
||||
"docs": "Documentation",
|
||||
"linksHeading": "Sources & docs",
|
||||
"updatesHeading": "Updates",
|
||||
"checkUpdates": "Check now",
|
||||
"checking": "Checking…",
|
||||
"lastChecked": "Last checked: {time}",
|
||||
"lastCheckedNever": "Not checked yet",
|
||||
"upToDate": "You are up to date",
|
||||
"newerAvailable": "Newer version: {tag}",
|
||||
"checkFailed": "Could not reach GitHub — try again later"
|
||||
"newerAvailable": "Newer version: {tag}"
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Test connection",
|
||||
@@ -2419,10 +2432,15 @@
|
||||
"danger-zone": {
|
||||
"title": "Danger Zone",
|
||||
"subtitle": "Destructive system-wide operations."
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
"subtitle": "Version, license, source, and update check."
|
||||
}
|
||||
},
|
||||
"carrier": "Opérateur",
|
||||
"carrierUnknown": "Opérateur inconnu"
|
||||
"carrierUnknown": "Opérateur inconnu",
|
||||
"carrierUnavailableGeoFallback": "{location} — carrier unavailable"
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Achievements",
|
||||
|
||||
+30
-12
@@ -695,6 +695,20 @@
|
||||
"sourceApi": "External API",
|
||||
"sourceReminder": "Telegram / Reminder",
|
||||
"sourceImport": "CSV Import",
|
||||
"intakeHistoryEmptyTitle": "No intakes recorded yet",
|
||||
"intakeHistoryEmptyDescription": "Log a dose on the daily intake page to start building the history.",
|
||||
"intakeHistoryEmptyAction": "Open daily intake",
|
||||
"intakeHistoryColTaken": "Taken",
|
||||
"intakeHistoryColScheduled": "Scheduled",
|
||||
"intakeHistoryColStatus": "Status",
|
||||
"intakeHistoryColSource": "Source",
|
||||
"intakeHistoryStatusTaken": "Taken",
|
||||
"intakeHistoryStatusSkipped": "Skipped",
|
||||
"intakeHistorySortByTaken": "Sort by taken time",
|
||||
"intakeHistorySortByScheduled": "Sort by scheduled time",
|
||||
"intakeHistoryPageInfo": "Page {page} of {total} · {count} entries",
|
||||
"intakeHistoryPrev": "Previous",
|
||||
"intakeHistoryNext": "Next",
|
||||
"unitDrops": "Drops",
|
||||
"unitTablets": "Tablet(s)",
|
||||
"unitCapsules": "Capsule(s)",
|
||||
@@ -1004,7 +1018,8 @@
|
||||
"emptyMood": "Awaiting more data to annotate this trend.",
|
||||
"confidenceHigh": "High confidence",
|
||||
"confidenceModerate": "Moderate confidence",
|
||||
"confidenceLow": "Low confidence"
|
||||
"confidenceLow": "Low confidence",
|
||||
"pendingLabel": "Generating trend commentary"
|
||||
},
|
||||
"correlationRow": {
|
||||
"title": "Relationships",
|
||||
@@ -1066,7 +1081,9 @@
|
||||
"flights": "Flights climbed",
|
||||
"distance": "Distance",
|
||||
"vo2_max": "VO₂ max",
|
||||
"body_temp": "Body temperature"
|
||||
"body_temp": "Body temperature",
|
||||
"medications": "Medications",
|
||||
"anthropometrics": "Profile (height, age, gender)"
|
||||
},
|
||||
"window": {
|
||||
"last7days": "Ultimi 7 giorni",
|
||||
@@ -1102,7 +1119,9 @@
|
||||
"dailyLimitTitle": "Daily limit reached",
|
||||
"dailyLimitBody": "Daily limit reached; resets at 00:00 UTC.",
|
||||
"providerRateLimitTitle": "Provider rate-limited",
|
||||
"providerRateLimitBody": "Provider temporarily rate-limited; retry in ~5 min."
|
||||
"providerRateLimitBody": "Provider temporarily rate-limited; retry in ~5 min.",
|
||||
"settingsContextLabel": "Include context",
|
||||
"settingsContextHint": "Optional blocks the Coach can lean on. Switch one off and the block is dropped from every turn."
|
||||
},
|
||||
"relativeJustNow": "just now",
|
||||
"relativeMinutesAgo": "{count} minutes ago",
|
||||
@@ -1921,19 +1940,13 @@
|
||||
"version": "App version",
|
||||
"gitSha": "Build",
|
||||
"builtAt": "Built {time}",
|
||||
"builtAtLabel": "Built",
|
||||
"license": "License",
|
||||
"repository": "Source code",
|
||||
"changelog": "Changelog",
|
||||
"docs": "Documentation",
|
||||
"linksHeading": "Sources & docs",
|
||||
"updatesHeading": "Updates",
|
||||
"checkUpdates": "Check now",
|
||||
"checking": "Checking…",
|
||||
"lastChecked": "Last checked: {time}",
|
||||
"lastCheckedNever": "Not checked yet",
|
||||
"upToDate": "You are up to date",
|
||||
"newerAvailable": "Newer version: {tag}",
|
||||
"checkFailed": "Could not reach GitHub — try again later"
|
||||
"newerAvailable": "Newer version: {tag}"
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Test connection",
|
||||
@@ -2419,10 +2432,15 @@
|
||||
"danger-zone": {
|
||||
"title": "Danger Zone",
|
||||
"subtitle": "Destructive system-wide operations."
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
"subtitle": "Version, license, source, and update check."
|
||||
}
|
||||
},
|
||||
"carrier": "Operatore",
|
||||
"carrierUnknown": "Operatore sconosciuto"
|
||||
"carrierUnknown": "Operatore sconosciuto",
|
||||
"carrierUnavailableGeoFallback": "{location} — carrier unavailable"
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Achievements",
|
||||
|
||||
+30
-12
@@ -695,6 +695,20 @@
|
||||
"sourceApi": "External API",
|
||||
"sourceReminder": "Telegram / Reminder",
|
||||
"sourceImport": "CSV Import",
|
||||
"intakeHistoryEmptyTitle": "No intakes recorded yet",
|
||||
"intakeHistoryEmptyDescription": "Log a dose on the daily intake page to start building the history.",
|
||||
"intakeHistoryEmptyAction": "Open daily intake",
|
||||
"intakeHistoryColTaken": "Taken",
|
||||
"intakeHistoryColScheduled": "Scheduled",
|
||||
"intakeHistoryColStatus": "Status",
|
||||
"intakeHistoryColSource": "Source",
|
||||
"intakeHistoryStatusTaken": "Taken",
|
||||
"intakeHistoryStatusSkipped": "Skipped",
|
||||
"intakeHistorySortByTaken": "Sort by taken time",
|
||||
"intakeHistorySortByScheduled": "Sort by scheduled time",
|
||||
"intakeHistoryPageInfo": "Page {page} of {total} · {count} entries",
|
||||
"intakeHistoryPrev": "Previous",
|
||||
"intakeHistoryNext": "Next",
|
||||
"unitDrops": "Drops",
|
||||
"unitTablets": "Tablet(s)",
|
||||
"unitCapsules": "Capsule(s)",
|
||||
@@ -1004,7 +1018,8 @@
|
||||
"emptyMood": "Awaiting more data to annotate this trend.",
|
||||
"confidenceHigh": "High confidence",
|
||||
"confidenceModerate": "Moderate confidence",
|
||||
"confidenceLow": "Low confidence"
|
||||
"confidenceLow": "Low confidence",
|
||||
"pendingLabel": "Generating trend commentary"
|
||||
},
|
||||
"correlationRow": {
|
||||
"title": "Relationships",
|
||||
@@ -1066,7 +1081,9 @@
|
||||
"flights": "Flights climbed",
|
||||
"distance": "Distance",
|
||||
"vo2_max": "VO₂ max",
|
||||
"body_temp": "Body temperature"
|
||||
"body_temp": "Body temperature",
|
||||
"medications": "Medications",
|
||||
"anthropometrics": "Profile (height, age, gender)"
|
||||
},
|
||||
"window": {
|
||||
"last7days": "Ostatnie 7 dni",
|
||||
@@ -1102,7 +1119,9 @@
|
||||
"dailyLimitTitle": "Daily limit reached",
|
||||
"dailyLimitBody": "Daily limit reached; resets at 00:00 UTC.",
|
||||
"providerRateLimitTitle": "Provider rate-limited",
|
||||
"providerRateLimitBody": "Provider temporarily rate-limited; retry in ~5 min."
|
||||
"providerRateLimitBody": "Provider temporarily rate-limited; retry in ~5 min.",
|
||||
"settingsContextLabel": "Include context",
|
||||
"settingsContextHint": "Optional blocks the Coach can lean on. Switch one off and the block is dropped from every turn."
|
||||
},
|
||||
"relativeJustNow": "just now",
|
||||
"relativeMinutesAgo": "{count} minutes ago",
|
||||
@@ -1921,19 +1940,13 @@
|
||||
"version": "App version",
|
||||
"gitSha": "Build",
|
||||
"builtAt": "Built {time}",
|
||||
"builtAtLabel": "Built",
|
||||
"license": "License",
|
||||
"repository": "Source code",
|
||||
"changelog": "Changelog",
|
||||
"docs": "Documentation",
|
||||
"linksHeading": "Sources & docs",
|
||||
"updatesHeading": "Updates",
|
||||
"checkUpdates": "Check now",
|
||||
"checking": "Checking…",
|
||||
"lastChecked": "Last checked: {time}",
|
||||
"lastCheckedNever": "Not checked yet",
|
||||
"upToDate": "You are up to date",
|
||||
"newerAvailable": "Newer version: {tag}",
|
||||
"checkFailed": "Could not reach GitHub — try again later"
|
||||
"newerAvailable": "Newer version: {tag}"
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Test connection",
|
||||
@@ -2419,10 +2432,15 @@
|
||||
"danger-zone": {
|
||||
"title": "Danger Zone",
|
||||
"subtitle": "Destructive system-wide operations."
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
"subtitle": "Version, license, source, and update check."
|
||||
}
|
||||
},
|
||||
"carrier": "Operator",
|
||||
"carrierUnknown": "Nieznany operator"
|
||||
"carrierUnknown": "Nieznany operator",
|
||||
"carrierUnavailableGeoFallback": "{location} — carrier unavailable"
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Achievements",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.4.35.1",
|
||||
"version": "1.4.36",
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- v1.4.36 W3 T3 — per-user AI Insights exclude-metrics list.
|
||||
--
|
||||
-- Mirrors `coachPrefsJson.excludeMetrics` so the user has a single
|
||||
-- contract for "which data blocks reach the LLM" across both Coach
|
||||
-- and Insights. Stored as a plain Postgres TEXT[] (not Json) because
|
||||
-- the values are a closed enum and we want straightforward array
|
||||
-- containment queries in case ops ever needs to audit them.
|
||||
--
|
||||
-- Additive only: NOT NULL with DEFAULT '{}' keeps every existing row
|
||||
-- on the "exclude nothing" path, byte-identical to v1.4.35 behaviour
|
||||
-- for accounts that haven't opened the new toggle. The `IF NOT EXISTS`
|
||||
-- guard makes the migration idempotent — same posture as 0058 /
|
||||
-- 0061.
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN IF NOT EXISTS "insights_exclude_metrics" TEXT[] NOT NULL DEFAULT '{}';
|
||||
@@ -36,6 +36,12 @@ model User {
|
||||
insightsPrivacyMode String @default("aggregated") @map("insights_privacy_mode") // "aggregated" | "raw"
|
||||
insightsCachedAt DateTime? @map("insights_cached_at")
|
||||
insightsCachedText String? @map("insights_cached_text") // cached JSON output
|
||||
// v1.4.36 W3 T3 — per-user AI Insights exclude-metrics list, mirrors
|
||||
// `coachPrefsJson.excludeMetrics`. The /api/insights/generate route
|
||||
// filters matching keys off `features` before JSON.stringify so the
|
||||
// model never sees data the user opted out of. Default `[]` = legacy
|
||||
// behaviour for every existing row.
|
||||
insightsExcludeMetrics String[] @default([]) @map("insights_exclude_metrics")
|
||||
|
||||
// Telegram notifications (per-user)
|
||||
telegramBotToken String? @map("telegram_bot_token") // AES-256-GCM encrypted
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { JSX } from "react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { AboutSection } from "@/components/settings/about-section";
|
||||
import { AiQualitySection } from "@/components/admin/ai-quality-section";
|
||||
import { AssistantSection } from "@/components/admin/assistant-section";
|
||||
import { CoachFeedbackSection } from "@/components/admin/coach-feedback-section";
|
||||
@@ -185,6 +186,11 @@ export function AdminSectionRenderer({
|
||||
<DangerZoneSection />
|
||||
</SectionFrame>
|
||||
);
|
||||
case "about":
|
||||
// v1.4.36 W4e — About section reused as-is from the settings
|
||||
// surface. The component owns its own heading + cards layout
|
||||
// so no SectionFrame wrapper.
|
||||
return <AboutSection />;
|
||||
default:
|
||||
slug satisfies never;
|
||||
return null;
|
||||
|
||||
@@ -266,8 +266,12 @@ describe("GET /api/analytics", () => {
|
||||
// contract the dashboard tile strip reads.
|
||||
it("returns the slim summaries slice when ?slice=summaries is set", async () => {
|
||||
const fiveDaysAgo = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000);
|
||||
// Aggregate pass + latest pass. The mock returns one row per pass.
|
||||
// v1.4.36 — slim slice now opens with a cheap `SELECT COUNT(*) FROM
|
||||
// measurement_rollups` probe. Force `n: 0` so the route takes the
|
||||
// legacy heavy-aggregate fallback path that this test's fixtures
|
||||
// expect (aggregate + latest, two `$queryRaw` passes).
|
||||
vi.mocked(prisma.$queryRaw)
|
||||
.mockResolvedValueOnce([{ n: BigInt(0) }] as never)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
type: "WEIGHT",
|
||||
|
||||
@@ -29,10 +29,38 @@ import type {
|
||||
} from "@/generated/prisma/client";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
|
||||
import type { SourcePriorityMetricKey } from "@/lib/validations/source-priority";
|
||||
import { ensureUserRollupsFresh } from "@/lib/measurements/rollups";
|
||||
import {
|
||||
isCumulativeDaySumType,
|
||||
pickCumulativeDaySum,
|
||||
} from "@/lib/measurements/cumulative-day-sum";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* v1.4.36 W4c — cumulative MeasurementType → SourcePriorityMetricKey
|
||||
* for `pickCanonicalSourceRows`. Returns `null` for types without a
|
||||
* dedicated priority ladder (e.g. TIME_IN_DAYLIGHT), which fall
|
||||
* through the picker's "no ladder" pass-through branch.
|
||||
*/
|
||||
function cumulativeMetricKey(
|
||||
type: MeasurementType,
|
||||
): SourcePriorityMetricKey | null {
|
||||
switch (type) {
|
||||
case "ACTIVITY_STEPS":
|
||||
return "steps";
|
||||
case "ACTIVE_ENERGY_BURNED":
|
||||
return "activeEnergy";
|
||||
case "WALKING_RUNNING_DISTANCE":
|
||||
return "walkingRunningDistance";
|
||||
case "FLIGHTS_CLIMBED":
|
||||
return "flightsClimbed";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.33 C1 — pull `?slice=…` from either a NextRequest (the
|
||||
* route's production wrapper) or a plain `Request` (the integration
|
||||
@@ -208,21 +236,40 @@ async function buildAnalyticsResponse(user: AuthedUser) {
|
||||
sourcePriorityJson,
|
||||
(d) => userDayKey(d, userTz),
|
||||
).canonicalRows;
|
||||
const byDay = new Map<string, { total: number; date: Date }>();
|
||||
for (const m of sleepRows) {
|
||||
const key = userDayKey(m.measuredAt, userTz);
|
||||
const slot = byDay.get(key) ?? {
|
||||
total: 0,
|
||||
date: m.measuredAt,
|
||||
};
|
||||
slot.total += m.value;
|
||||
if (m.measuredAt > slot.date) slot.date = m.measuredAt;
|
||||
byDay.set(key, slot);
|
||||
}
|
||||
datapoints = Array.from(byDay.values()).map(
|
||||
(s): DataPoint => ({ date: s.date, value: s.total }),
|
||||
datapoints = pickCumulativeDaySum(sleepRows, (d) =>
|
||||
userDayKey(d, userTz),
|
||||
);
|
||||
} else if (isCumulativeDaySumType(type)) {
|
||||
// v1.4.36 W4c — broaden the SLEEP_DURATION day-bucket-sum
|
||||
// pattern to every cumulative metric (steps, active energy,
|
||||
// walking + running distance, flights climbed, time in
|
||||
// daylight). Apple Health writes minute-level slices for
|
||||
// these series; the dashboard tile was reading the
|
||||
// *latest slice* via `summary.latest` instead of the day's
|
||||
// total, which Marc reported as "Steps tile shows
|
||||
// last-measurement-not-day-sum". After the per-day sum
|
||||
// collapse, `summary.latest` is the most-recent day's
|
||||
// total — exactly what the tile expects.
|
||||
//
|
||||
// Map cumulative MeasurementType → SourcePriorityMetricKey
|
||||
// for the canonical-row picker. TIME_IN_DAYLIGHT has no
|
||||
// dedicated priority ladder (no clinical-grade competitor
|
||||
// to Apple Health for daylight minutes today) so it falls
|
||||
// through `pickCanonicalSourceRows`'s "no ladder"
|
||||
// pass-through branch — we ALWAYS bucket-and-sum it
|
||||
// regardless of source.
|
||||
const metricKey = cumulativeMetricKey(type);
|
||||
const canonicalRows = metricKey
|
||||
? pickCanonicalSourceRows(
|
||||
measurements,
|
||||
metricKey,
|
||||
sourcePriorityJson,
|
||||
(d) => userDayKey(d, userTz),
|
||||
).canonicalRows
|
||||
: measurements;
|
||||
datapoints = pickCumulativeDaySum(canonicalRows, (d) =>
|
||||
userDayKey(d, userTz),
|
||||
);
|
||||
datapoints.sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||
} else {
|
||||
datapoints = measurements.map(
|
||||
(m): DataPoint => ({
|
||||
|
||||
@@ -57,9 +57,18 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
|
||||
// Persist the canonical defaulted form so the column shape stays
|
||||
// stable regardless of which subset of keys the caller supplied.
|
||||
//
|
||||
// v1.4.36 W3 T3 — mirror the excludeMetrics list onto the dedicated
|
||||
// `insightsExcludeMetrics` column so the Insights generator and
|
||||
// Coach snapshot share a single source of truth. Keeps the user's
|
||||
// privacy contract symmetric across the two surfaces without
|
||||
// requiring a separate Insights settings sheet.
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { coachPrefsJson: parsed.data },
|
||||
data: {
|
||||
coachPrefsJson: parsed.data,
|
||||
insightsExcludeMetrics: parsed.data.excludeMetrics,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
|
||||
@@ -80,9 +80,16 @@ vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/insights/features", () => ({
|
||||
extractFeatures: vi.fn(async () => ({ stub: true })),
|
||||
}));
|
||||
vi.mock("@/lib/insights/features", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/insights/features")>(
|
||||
"@/lib/insights/features",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
extractFeatures: vi.fn(async () => ({ stub: true })),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/insights/prompt", () => ({
|
||||
buildUserPrompt: vi.fn(() => "user"),
|
||||
@@ -97,6 +104,11 @@ import { resolveProvider } from "@/lib/ai/provider";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { clearLastWorkingProviderCache } from "@/lib/ai/provider-runner";
|
||||
import {
|
||||
extractFeatures,
|
||||
FeaturesPayloadTooLargeError,
|
||||
} from "@/lib/insights/features";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -344,3 +356,51 @@ describe("POST /api/insights/generate — per-status cache eviction (A7.2)", ()
|
||||
expect(prisma.auditLog.deleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// v1.4.36 QA H1 — when both the raw-mode extraction AND the aggregated
|
||||
// retry cross the 5 MB ceiling, the route used to throw uncaught → 500.
|
||||
// It now wraps the retry, downgrades further via the exclude filter,
|
||||
// and on a third failure returns 422 with an annotate event for ops.
|
||||
describe("POST /api/insights/generate — payload-size hard downgrade (H1)", () => {
|
||||
it("returns 422 with an annotate event when every fallback shape is oversized", async () => {
|
||||
makeWorkingProvider();
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
// Raw mode so the route's first extractFeatures call is the
|
||||
// wide shape; the retry asks for the aggregated shape.
|
||||
insightsPrivacyMode: "raw",
|
||||
insightsCachedAt: null,
|
||||
insightsCachedText: null,
|
||||
insightsExcludeMetrics: [],
|
||||
locale: "en",
|
||||
} as never);
|
||||
|
||||
// Each call mimics features.ts blowing past the ceiling. Three
|
||||
// shots: raw, aggregated retry, hard-downgrade aggregated.
|
||||
vi.mocked(extractFeatures).mockReset();
|
||||
vi.mocked(extractFeatures)
|
||||
.mockRejectedValueOnce(
|
||||
new FeaturesPayloadTooLargeError(6_000_000, 5_242_880),
|
||||
)
|
||||
.mockRejectedValueOnce(
|
||||
new FeaturesPayloadTooLargeError(5_500_000, 5_242_880),
|
||||
)
|
||||
.mockRejectedValueOnce(
|
||||
new FeaturesPayloadTooLargeError(5_300_000, 5_242_880),
|
||||
);
|
||||
|
||||
const res = await POST(jsonRequest({ force: true }) as never);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as ApiErrorEnvelope;
|
||||
expect(body.error).toMatch(/payload size/i);
|
||||
|
||||
// The annotate event fires with insights_payload_too_large so ops
|
||||
// can spot the regression in the wide-event pipeline.
|
||||
const calls = vi.mocked(annotate).mock.calls;
|
||||
const annotated = calls.find(
|
||||
(call) =>
|
||||
(call[0] as { meta?: Record<string, unknown> })?.meta
|
||||
?.insights_payload_too_large === true,
|
||||
);
|
||||
expect(annotated, "annotate event with insights_payload_too_large").toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,12 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { extractFeatures } from "@/lib/insights/features";
|
||||
import {
|
||||
extractFeatures,
|
||||
FeaturesPayloadTooLargeError,
|
||||
} from "@/lib/insights/features";
|
||||
import { applyInsightsExcludeFilter } from "@/lib/insights/exclude-filter";
|
||||
import { compactSections } from "@/lib/insights/prompt-compact";
|
||||
import {
|
||||
detectGlp1Plateau,
|
||||
buildGlp1PlateauPrompt,
|
||||
@@ -188,6 +193,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
insightsPrivacyMode: true,
|
||||
insightsCachedAt: true,
|
||||
insightsCachedText: true,
|
||||
// v1.4.36 W3 T3 — per-user opt-out list mirroring the Coach
|
||||
// settings. Filtered off `features` before serialisation so the
|
||||
// LLM never sees the excluded blocks.
|
||||
insightsExcludeMetrics: true,
|
||||
locale: true,
|
||||
},
|
||||
});
|
||||
@@ -266,8 +275,102 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
const includeRaw = dbUser?.insightsPrivacyMode === "raw";
|
||||
const features = await extractFeatures(userId, includeRaw);
|
||||
const featuresJson = JSON.stringify(features, null, 2);
|
||||
// v1.4.36 W3 T1 — `extractFeatures` enforces a 5 MB ceiling on the
|
||||
// serialised payload. If the raw (bucketed) shape ever blows past
|
||||
// it (regression watch — the v1.4.35 rawMeasurements shape hit
|
||||
// 25.9 MB on Marc's account), the helper throws
|
||||
// `FeaturesPayloadTooLargeError`. We downgrade to the aggregated
|
||||
// shape rather than 500-ing so the user still gets an insight; the
|
||||
// annotate event surfaces the regression to ops via the Logflare
|
||||
// pipeline.
|
||||
let features: Awaited<ReturnType<typeof extractFeatures>>;
|
||||
let payloadDowngraded = false;
|
||||
let payloadOversizeBytes: number | null = null;
|
||||
// v1.4.36 QA H1 — extra fallback when even the aggregated shape
|
||||
// crosses the 5 MB ceiling (very large medication history + multi-
|
||||
// year context). Strips anthropometrics + medications + every other
|
||||
// exclude-token-mapped block and tries one more time. On a third
|
||||
// failure we return 422 with an annotate event for ops visibility
|
||||
// rather than 500-ing out of an uncaught throw.
|
||||
const MAX_DOWNGRADE_TOKENS: ReadonlyArray<string> = [
|
||||
"anthropometrics",
|
||||
"medications",
|
||||
"compliance",
|
||||
"sleep",
|
||||
"steps",
|
||||
"hrv",
|
||||
"resting_hr",
|
||||
];
|
||||
let payloadHardDowngraded = false;
|
||||
try {
|
||||
features = await extractFeatures(userId, includeRaw);
|
||||
} catch (err) {
|
||||
if (err instanceof FeaturesPayloadTooLargeError) {
|
||||
payloadDowngraded = true;
|
||||
payloadOversizeBytes = err.sizeBytes;
|
||||
try {
|
||||
features = await extractFeatures(userId, false);
|
||||
} catch (retryErr) {
|
||||
if (retryErr instanceof FeaturesPayloadTooLargeError) {
|
||||
// Aggregated shape ALSO crossed the ceiling. Drop optional
|
||||
// context blocks (anthropometrics, medications, sleep,
|
||||
// steps, hrv, resting_hr) via the existing exclude filter
|
||||
// and try once more.
|
||||
payloadHardDowngraded = true;
|
||||
payloadOversizeBytes = retryErr.sizeBytes;
|
||||
try {
|
||||
const aggregated = await extractFeatures(userId, false);
|
||||
features = applyInsightsExcludeFilter(
|
||||
aggregated,
|
||||
MAX_DOWNGRADE_TOKENS,
|
||||
);
|
||||
} catch (finalErr) {
|
||||
// Even the aggregated read itself blew up. Annotate and
|
||||
// return 422 rather than let the uncaught throw 500 out.
|
||||
annotate({
|
||||
meta: {
|
||||
insights_payload_too_large: true,
|
||||
insights_payload_oversize_bytes:
|
||||
finalErr instanceof FeaturesPayloadTooLargeError
|
||||
? finalErr.sizeBytes
|
||||
: payloadOversizeBytes,
|
||||
},
|
||||
});
|
||||
return apiError(
|
||||
"Your data exceeds the AI payload size limit. Reduce the data window or exclude optional blocks in Settings > AI.",
|
||||
422,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw retryErr;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
// v1.4.36 W3 T3 — apply the user's exclude-metrics opt-out list
|
||||
// BEFORE serialisation so the model never sees the dropped blocks.
|
||||
const excludeList = dbUser?.insightsExcludeMetrics ?? [];
|
||||
features = applyInsightsExcludeFilter(features, excludeList);
|
||||
// v1.4.36 W3 T4 — drop zero-row blocks so the prompt never carries
|
||||
// labelled-empty sections (`"sleep": []`, `"medications": []`,
|
||||
// etc.). Prevents the model from narrating "there are no
|
||||
// medications in your data" when the user explicitly excluded the
|
||||
// block.
|
||||
const compactFeatures = compactSections(
|
||||
features as unknown as Record<string, unknown>,
|
||||
);
|
||||
const featuresJson = JSON.stringify(compactFeatures, null, 2);
|
||||
if (payloadDowngraded) {
|
||||
annotate({
|
||||
meta: {
|
||||
insights_features_downgraded: true,
|
||||
insights_features_oversize_bytes: payloadOversizeBytes,
|
||||
insights_features_hard_downgraded: payloadHardDowngraded,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// v1.4.16 phase B8 — pick up the user's persisted comparison toggle
|
||||
// and, when active, build a compact prior-period snapshot from the
|
||||
|
||||
@@ -25,6 +25,8 @@ import type {
|
||||
} from "@/generated/prisma/client";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
|
||||
import {
|
||||
getEffectiveRange,
|
||||
type ThresholdOverridesJson,
|
||||
@@ -105,9 +107,32 @@ interface TargetPageSummary {
|
||||
streakHighlight: { metric: string; days: number } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.36 W1 — `/api/insights/targets` body, lifted out of the route
|
||||
* handler so `cached()` can wrap it. The handler attaches the
|
||||
* bfcache-friendly Cache-Control header afterward. 60 s TTL keyed on
|
||||
* the user id matches the analytics cache so multiple Insights mounts
|
||||
* inside a minute all hit a warm cache. Invalidated alongside the
|
||||
* analytics bucket on measurement / mood / medication writes.
|
||||
*/
|
||||
type AuthedUser = Awaited<ReturnType<typeof requireAuth>>["user"];
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
const body = await cached(
|
||||
caches.insightsTargets as ServerCache<
|
||||
Awaited<ReturnType<typeof buildTargetsResponse>>
|
||||
>,
|
||||
user.id,
|
||||
() => buildTargetsResponse(user),
|
||||
annotate,
|
||||
);
|
||||
const response = apiSuccess(body);
|
||||
response.headers.set("Cache-Control", NO_STORE_BUT_BFCACHE);
|
||||
return response;
|
||||
});
|
||||
|
||||
async function buildTargetsResponse(user: AuthedUser) {
|
||||
const userId = user.id;
|
||||
|
||||
// v1.4.25 W7b — every per-day bucket key in this route resolves
|
||||
@@ -1179,7 +1204,7 @@ export const GET = apiHandler(async () => {
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
return {
|
||||
targets,
|
||||
pageSummary,
|
||||
// Extra diastolic data for BP display
|
||||
@@ -1194,5 +1219,5 @@ export const GET = apiHandler(async () => {
|
||||
gender,
|
||||
glucoseUnit,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -20,6 +20,9 @@ vi.mock("@/lib/db", () => ({
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
measurementRollup: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
$queryRaw: vi.fn(),
|
||||
},
|
||||
}));
|
||||
@@ -193,6 +196,71 @@ describe("GET /api/measurements — all-time semantics (SD-H1)", () => {
|
||||
expect(months.size).toBe(24);
|
||||
});
|
||||
|
||||
it("source=rollup + aggregate=daily reads measurement_rollups without firing the heavy date_trunc query", async () => {
|
||||
// v1.4.36 W1 — Insights trends row routes the three daily chart
|
||||
// fetches through the persistent DAY buckets via `source=rollup`.
|
||||
// The heavy `date_trunc` $queryRaw must NOT fire on the happy
|
||||
// path where the rollup has rows for the requested window.
|
||||
const buckets = Array.from({ length: 30 }, (_, i) => ({
|
||||
type: "WEIGHT",
|
||||
bucketStart: new Date(Date.UTC(2026, 3, 16 + i, 0, 0, 0)),
|
||||
mean: 81 + i * 0.05,
|
||||
count: 5,
|
||||
}));
|
||||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
|
||||
buckets as never,
|
||||
);
|
||||
|
||||
const res = await GET(
|
||||
getRequest(
|
||||
"type=WEIGHT&from=2026-04-15T00:00:00Z&to=2026-05-15T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as {
|
||||
data: {
|
||||
measurements: Array<{ type: string; value: number; count: number }>;
|
||||
meta: { aggregate?: string };
|
||||
};
|
||||
};
|
||||
expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.$queryRaw).not.toHaveBeenCalled();
|
||||
expect(json.data.measurements.length).toBe(30);
|
||||
// First bucket reflects the rollup mean — round-trip-stable.
|
||||
expect(json.data.measurements[0].type).toBe("WEIGHT");
|
||||
expect(json.data.measurements[0].value).toBeCloseTo(81, 5);
|
||||
expect(json.data.measurements[0].count).toBe(5);
|
||||
expect(json.data.meta.aggregate).toBe("daily");
|
||||
});
|
||||
|
||||
it("falls back to live date_trunc when source=rollup returns zero buckets", async () => {
|
||||
// Empty rollup ⇒ heavy aggregate runs so brand-new accounts still
|
||||
// see a correct chart on their first render.
|
||||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
const liveBuckets = Array.from({ length: 7 }, (_, i) => ({
|
||||
type: "WEIGHT",
|
||||
bucket_start: new Date(Date.UTC(2026, 4, 8 + i, 0, 0, 0)),
|
||||
avg: 81 + i * 0.05,
|
||||
cnt: 2,
|
||||
}));
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue(liveBuckets as never);
|
||||
|
||||
const res = await GET(
|
||||
getRequest(
|
||||
"type=WEIGHT&from=2026-05-08T00:00:00Z&to=2026-05-15T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
const json = (await res.json()) as {
|
||||
data: { measurements: Array<unknown> };
|
||||
};
|
||||
expect(json.data.measurements.length).toBe(7);
|
||||
});
|
||||
|
||||
it("returns a weekly series when the all-time window is under two years", async () => {
|
||||
// Three months past one year → falls in the weekly grain band.
|
||||
const weeks = Array.from({ length: 60 }, (_, i) => ({
|
||||
|
||||
@@ -42,7 +42,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const { type, from, to, limit, offset, sortBy, sortDir, aggregate } =
|
||||
const { type, from, to, limit, offset, sortBy, sortDir, aggregate, source } =
|
||||
parsed.data;
|
||||
|
||||
const where = {
|
||||
@@ -77,6 +77,91 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// The route's response is bounded by the `BUCKET_CAP` ceiling per
|
||||
// grain (monthly: 24, weekly: 105, daily: 365) so a multi-decade
|
||||
// account never paints an unbounded series.
|
||||
// v1.4.36 W1 — rollup-table read for daily aggregates.
|
||||
//
|
||||
// The Insights trends row fires three parallel
|
||||
// `GET /api/measurements?type=…&aggregate=daily&limit=5000` requests
|
||||
// (BP_SYS / BP_DIA / WEIGHT). The legacy `date_trunc('day', …)`
|
||||
// path scanned the measurements table each time — a full year window
|
||||
// on Marc's account hit 3 × ~3 s on the v1.4.35 HAR. Reading from
|
||||
// the persistent `measurement_rollups` DAY buckets drops each call
|
||||
// to a small indexed read against the ~5 k-row rollup table.
|
||||
//
|
||||
// Gating:
|
||||
// - `source=rollup` is an explicit client opt-in (`@/lib/validations/measurement`
|
||||
// constrains the enum to `["rollup"]`).
|
||||
// - We require `aggregate=daily` because the rollup populator only
|
||||
// keeps DAY buckets on the synchronous write hook; the WEEK /
|
||||
// MONTH paths still depend on the async pg-boss recomputes which
|
||||
// may lag behind a recent write.
|
||||
// - `type` is mandatory so the response shape mirrors the per-type
|
||||
// ask the chart code makes.
|
||||
// - When the rollup window returns zero rows we fall through to
|
||||
// the live `date_trunc` path so a brand-new account whose
|
||||
// populator hasn't caught up still sees a correct chart on its
|
||||
// first render.
|
||||
if (
|
||||
source === "rollup" &&
|
||||
aggregate === "daily" &&
|
||||
type &&
|
||||
from &&
|
||||
to
|
||||
) {
|
||||
const cap = Math.min(limit, BUCKET_CAP.daily);
|
||||
const rollupRows = await prisma.measurementRollup.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: type as MeasurementType,
|
||||
granularity: "DAY",
|
||||
bucketStart: { gte: from, lte: to },
|
||||
},
|
||||
orderBy: { bucketStart: "asc" },
|
||||
take: cap,
|
||||
select: {
|
||||
type: true,
|
||||
bucketStart: true,
|
||||
mean: true,
|
||||
count: true,
|
||||
},
|
||||
});
|
||||
if (rollupRows.length > 0) {
|
||||
// Cumulative metrics (steps, active energy, distance, flights,
|
||||
// daylight) store `mean` per bucket but the chart needs the
|
||||
// daily SUM. Reconstruct as `mean * count` — algebraically
|
||||
// equivalent to SUM(value) for a single-source day because
|
||||
// AVG = SUM / COUNT.
|
||||
const useSum =
|
||||
type != null && CUMULATIVE_HK_TYPES.has(type as MeasurementType);
|
||||
const measurements = rollupRows.map((r) => ({
|
||||
type: r.type,
|
||||
value: useSum ? r.mean * r.count : r.mean,
|
||||
measuredAt: r.bucketStart.toISOString(),
|
||||
count: r.count,
|
||||
}));
|
||||
annotate({
|
||||
action: { name: "measurement.list" },
|
||||
meta: {
|
||||
total: measurements.length,
|
||||
type,
|
||||
aggregate: "daily",
|
||||
source: "rollup",
|
||||
},
|
||||
});
|
||||
return apiSuccess({
|
||||
measurements,
|
||||
meta: {
|
||||
total: measurements.length,
|
||||
limit: cap,
|
||||
offset: 0,
|
||||
aggregate: "daily",
|
||||
},
|
||||
});
|
||||
}
|
||||
// Fall through to the live aggregate path when the rollup is
|
||||
// empty for the requested window — covers brand-new accounts +
|
||||
// the race between a recent write hook and the read.
|
||||
}
|
||||
|
||||
if (aggregate && aggregate !== "raw" && from && to) {
|
||||
const grain: AggregateGrain = aggregate;
|
||||
const cap = Math.min(limit, BUCKET_CAP[grain]);
|
||||
|
||||
+33
-12
@@ -3,7 +3,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { Loader2, TrendingUp } from "lucide-react";
|
||||
import { TrendingUp } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
@@ -158,7 +158,16 @@ export default function InsightsPage() {
|
||||
const coachLaunch = useCoachLaunch();
|
||||
const flags = useFeatureFlags();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
// v1.4.36 W1 — drop the page-level `isLoading` gate that used to
|
||||
// block the entire shell on `/api/insights/comprehensive`. The
|
||||
// comprehensive payload still feeds the empty-state decision but
|
||||
// every other section now mounts in parallel under its own
|
||||
// <Suspense> boundary, so the user sees the hero + tile skeletons
|
||||
// within ~500 ms instead of waiting on the slowest fan-out. The
|
||||
// empty-state branch only fires once the query has resolved AND
|
||||
// reported zero measurements; while it's in-flight the page paints
|
||||
// the regular shell and the tiles fill in as their data lands.
|
||||
const { data, isLoading, isFetched } = useQuery({
|
||||
queryKey: ["insights", "comprehensive"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/insights/comprehensive");
|
||||
@@ -181,15 +190,11 @@ export default function InsightsPage() {
|
||||
const analyticsQuery = useAnalyticsQuery();
|
||||
const analytics = analyticsQuery.data as AnalyticsData | undefined;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
// Empty-state shortcut — only paint once the comprehensive query has
|
||||
// resolved AND reported zero measurements. While it's in-flight we
|
||||
// fall through to the streamed shell so the user gets the hero +
|
||||
// skeleton tiles inside the first paint budget.
|
||||
if (!isLoading && isFetched && !data) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<TrendingUp className="size-6" />}
|
||||
@@ -213,6 +218,19 @@ export default function InsightsPage() {
|
||||
const briefingPayload = advisor.payload?.dailyBriefing ?? null;
|
||||
const heroStripUpdatedAt = advisor.payload?.cachedAt ?? null;
|
||||
|
||||
// v1.4.36 QA C2 — no `<Suspense>` wrappers below. The mother page is
|
||||
// `"use client"`, the below-the-fold blocks load via `next/dynamic`
|
||||
// with `{ ssr: false }` (no Promise-throw on hydrate), and the
|
||||
// TanStack Query hooks each return their own loading state without
|
||||
// ever throwing a thenable. Wrapping these in `<Suspense>` would be
|
||||
// dead code — Suspense never engages and the `loading` props inside
|
||||
// each section already drive the skeleton. The perceptual win we
|
||||
// ship is "early-skeleton paint": the page-level `isLoading` gate is
|
||||
// gone, the hero + each section's own loader skeleton paints inside
|
||||
// the first paint budget while data fills in. Genuinely streamed
|
||||
// server children would require a Server-Component refactor; that's
|
||||
// a v1.5.x track.
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<HeroStrip
|
||||
@@ -246,7 +264,10 @@ export default function InsightsPage() {
|
||||
<CorrelationRow results={analytics.correlations} />
|
||||
)}
|
||||
|
||||
<TrendsRow annotations={advisor.payload?.trendAnnotations ?? null} />
|
||||
<TrendsRow
|
||||
annotations={advisor.payload?.trendAnnotations ?? null}
|
||||
loading={advisor.isLoading || advisor.isRegenerating}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { DrugLevelChart } from "@/components/medications/DrugLevelChart";
|
||||
import { SideEffectsSection } from "@/components/medications/SideEffectsSection";
|
||||
import { SchedulingSection } from "@/components/medications/SchedulingSection";
|
||||
import { TitrationSection } from "@/components/medications/TitrationSection";
|
||||
import { IntakeHistoryListV2 } from "@/components/medications/intake-history-list-v2";
|
||||
import { ArrowLeft, Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -133,6 +134,14 @@ export default function IntakeHistoryPage({
|
||||
{medication?.treatmentClass === "GLP1" && (
|
||||
<TitrationSection medicationId={id} />
|
||||
)}
|
||||
|
||||
{/* v1.4.36 W4a — Lite intake-history table mounted for ALL
|
||||
medication kinds. v1.4.28 dropped the v1 component on the
|
||||
assumption it had no other consumer; the standalone history
|
||||
page lost its dose timeline as a side-effect. The v2 is
|
||||
read-only (no inline CRUD) — edit/delete still flow through
|
||||
the regular intake routes. */}
|
||||
{medication && <IntakeHistoryListV2 medicationId={id} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
Bell,
|
||||
Database,
|
||||
FileText,
|
||||
Info,
|
||||
KeyRound,
|
||||
Inbox,
|
||||
Plug,
|
||||
@@ -125,6 +126,12 @@ export const ADMIN_SECTIONS: readonly AdminSection[] = [
|
||||
titleKey: "admin.section.danger-zone.title",
|
||||
icon: ShieldAlert,
|
||||
},
|
||||
// v1.4.36 W4e — About / version / update check folded into Admin.
|
||||
{
|
||||
slug: "about",
|
||||
titleKey: "admin.section.about.title",
|
||||
icon: Info,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export interface AdminShellProps {
|
||||
@@ -171,7 +178,11 @@ export function AdminShell({ active, children }: AdminShellProps) {
|
||||
if (!activeChip) return;
|
||||
activeChip.scrollIntoView({
|
||||
block: "nearest",
|
||||
inline: "center",
|
||||
// v1.4.36 W4b — `inline: "start"` pins the active chip to the
|
||||
// left edge of the scroller. `inline: "center"` over-scrolled
|
||||
// and the first one or two chips were unreachable on narrow
|
||||
// viewports.
|
||||
inline: "start",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, [activeSlug]);
|
||||
|
||||
@@ -452,13 +452,17 @@ export function LoginOverviewSection() {
|
||||
</span>
|
||||
{/*
|
||||
v1.4.27 B3 — carrier chip below the auth-provider
|
||||
chip. Only renders when the GeoLite2-ASN lookup
|
||||
found an organisation string for the audit-row's
|
||||
IP; private/loopback IPs and offline-miss rows
|
||||
keep the original single-chip layout. The chip
|
||||
carries the short DACH label ("Telekom",
|
||||
"Vodafone", "1&1", "O2"); unknown organisations
|
||||
fall through to the raw GeoLite2 string.
|
||||
chip. v1.4.36 W4g — fall back to the city + country
|
||||
string with a "carrier unavailable" qualifier when
|
||||
the GeoLite2-ASN offline lookup miss (free-tier
|
||||
ipwho.is does not expose ASN) leaves `entry.carrier`
|
||||
null but the location lookup populated
|
||||
`entry.location`. Private/loopback IPs and offline-
|
||||
miss rows on BOTH columns keep the original
|
||||
single-chip layout. ASN-known chip carries the
|
||||
short DACH label ("Telekom", "Vodafone", "1&1",
|
||||
"O2") with unknown organisations falling through
|
||||
to the raw GeoLite2 string.
|
||||
*/}
|
||||
{entry.carrier ? (
|
||||
<span
|
||||
@@ -467,6 +471,16 @@ export function LoginOverviewSection() {
|
||||
>
|
||||
{carrierShortLabel(entry.carrier)}
|
||||
</span>
|
||||
) : entry.location ? (
|
||||
<span
|
||||
className="text-muted-foreground/80 mt-0.5 block text-[10px] leading-tight"
|
||||
data-slot="login-overview-carrier"
|
||||
data-source="geo-fallback"
|
||||
>
|
||||
{t("admin.carrierUnavailableGeoFallback", {
|
||||
location: entry.location,
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 font-mono text-xs">
|
||||
|
||||
@@ -29,6 +29,10 @@ export const ADMIN_SECTION_SLUGS = [
|
||||
"app-logs",
|
||||
"backups",
|
||||
"danger-zone",
|
||||
// v1.4.36 W4e — About / version / update check folded into Admin
|
||||
// Console. Lives at /admin/about; the legacy /settings/about route
|
||||
// is unlinked from the user-card dropdown.
|
||||
"about",
|
||||
] as const;
|
||||
|
||||
export type AdminSectionSlug = (typeof ADMIN_SECTION_SLUGS)[number];
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { ChartSkeleton } from "../chart-skeleton";
|
||||
|
||||
/**
|
||||
* v1.4.36 W2 — `<ChartSkeleton>` carries two variants so the loading
|
||||
* shell occupies the same visible footprint as the chart that lands
|
||||
* after `next/dynamic` resolves.
|
||||
*
|
||||
* - Default (dashboard / sub-page hero): rounded-xl card chrome,
|
||||
* full header row, `--chart-height` band.
|
||||
* - `mini` (trends-row 140 px slot): rounded-md, light padding,
|
||||
* 140 px band — no header chrome.
|
||||
*/
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<ChartSkeleton>", () => {
|
||||
it("renders the default card chrome (rounded-xl + p-4 md:p-6)", () => {
|
||||
const html = render(<ChartSkeleton />);
|
||||
expect(html).toContain('data-slot="chart-skeleton"');
|
||||
expect(html).not.toContain('data-mini="true"');
|
||||
expect(html).toMatch(/rounded-xl/);
|
||||
expect(html).toMatch(/p-4/);
|
||||
expect(html).toMatch(/md:p-6/);
|
||||
});
|
||||
|
||||
it("renders the loading announcement for assistive tech", () => {
|
||||
const html = render(<ChartSkeleton />);
|
||||
expect(html).toMatch(/role="status"/);
|
||||
expect(html).toMatch(/aria-busy="true"/);
|
||||
});
|
||||
|
||||
it("mini variant uses the lighter rounded-md + p-2 chrome", () => {
|
||||
const html = render(<ChartSkeleton mini />);
|
||||
expect(html).toContain('data-slot="chart-skeleton"');
|
||||
expect(html).toContain('data-mini="true"');
|
||||
expect(html).toMatch(/rounded-md/);
|
||||
expect(html).toMatch(/p-2/);
|
||||
// Default card-chrome classes must NOT appear in the mini variant.
|
||||
expect(html).not.toMatch(/rounded-xl/);
|
||||
});
|
||||
|
||||
it("mini variant pins the skeleton band at h-[140px] (trends-row slot)", () => {
|
||||
const html = render(<ChartSkeleton mini />);
|
||||
expect(html).toMatch(/h-\[140px\]/);
|
||||
});
|
||||
});
|
||||
@@ -23,12 +23,46 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
* The cumulative box matches the loaded chart's height to within a
|
||||
* pixel so the page does not jump on hydration.
|
||||
*
|
||||
* v1.4.36 W2 — `mini` variant for the trends-row 140 px chart slot.
|
||||
* The default skeleton's rounded-xl border + p-4/p-6 padding inflated
|
||||
* past the 140 px wrapper and pushed the row taller than the loaded
|
||||
* chart, re-introducing the very layout shift the wrapper now
|
||||
* eliminates. The mini variant trades the card chrome for
|
||||
* `rounded-md p-2` and pins the inner band at 140 px so the loading
|
||||
* shell occupies the same visible box as the painted chart.
|
||||
*
|
||||
* `prefers-reduced-motion` is honoured automatically — the `<Skeleton>`
|
||||
* primitive's `animate-pulse` is suppressed via Tailwind's
|
||||
* `motion-reduce:animate-none` modifier.
|
||||
*/
|
||||
export function ChartSkeleton({ className }: { className?: string }) {
|
||||
export function ChartSkeleton({
|
||||
className,
|
||||
mini = false,
|
||||
}: {
|
||||
className?: string;
|
||||
/**
|
||||
* When true, render the compact variant used inside the trends-row
|
||||
* chart slot (140 px band, lighter chrome). Defaults to false for
|
||||
* the dashboard hero / sub-page charts.
|
||||
*/
|
||||
mini?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
if (mini) {
|
||||
return (
|
||||
<div
|
||||
data-slot="chart-skeleton"
|
||||
data-mini="true"
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
aria-live="polite"
|
||||
className={cn("bg-card border-border rounded-md border p-2", className)}
|
||||
>
|
||||
<span className="sr-only">{t("charts.loadingLabel")}</span>
|
||||
<Skeleton className="h-[140px] w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
data-slot="chart-skeleton"
|
||||
|
||||
@@ -27,3 +27,21 @@ export const HealthChartDynamic = dynamic(
|
||||
})),
|
||||
{ ssr: false, loading: () => <ChartSkeleton /> },
|
||||
);
|
||||
|
||||
/**
|
||||
* v1.4.36 W2 — mini variant for the trends-row 140 px chart slot. Same
|
||||
* underlying `<HealthChart>` import, but the loading shell renders the
|
||||
* `mini` `<ChartSkeleton>` so the loading box matches the painted
|
||||
* chart's footprint instead of inflating past the row contract.
|
||||
*
|
||||
* Consumers inside a fixed-height row (BP + weight cards in
|
||||
* `trends-row.tsx`) pass `mini` on the chart itself; this wrapper
|
||||
* ensures the next/dynamic loader paints the matching skeleton.
|
||||
*/
|
||||
export const HealthChartDynamicMini = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/health-chart").then((mod) => ({
|
||||
default: mod.HealthChart,
|
||||
})),
|
||||
{ ssr: false, loading: () => <ChartSkeleton mini /> },
|
||||
);
|
||||
@@ -585,8 +585,20 @@ export function HealthChart({
|
||||
// Recharts paint cost ~50× on continuous-monitoring accounts.
|
||||
// Short windows keep raw fetching so hour-by-hour detail
|
||||
// stays visible.
|
||||
//
|
||||
// v1.4.36 W1 — also route the daily aggregate through the
|
||||
// persistent rollup buckets via `source=rollup`. The route
|
||||
// reads from `measurement_rollups` instead of running a live
|
||||
// `date_trunc` scan over the raw measurements table; the
|
||||
// three parallel daily fetches on the Insights trends row
|
||||
// (BP_SYS / BP_DIA / WEIGHT) drop from ~3 s each to a small
|
||||
// indexed read against the ~5 k-row rollup table. The server
|
||||
// falls back to live SQL when the rollup is empty for the
|
||||
// requested window so brand-new accounts still see a chart
|
||||
// on their first render.
|
||||
if (fetchWindow.windowDays > 7) {
|
||||
typeParams.set("aggregate", "daily");
|
||||
typeParams.set("source", "rollup");
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/measurements?${typeParams}`);
|
||||
|
||||
@@ -40,15 +40,22 @@ function fakeSummary(count: number): DataSummary {
|
||||
}
|
||||
|
||||
describe("<InsightsTabStrip> — availability gating (v1.4.27 F19)", () => {
|
||||
it("renders every pill when availability is omitted (backward compat)", () => {
|
||||
it("renders only the Overview pill when availability is omitted (v1.4.36 W4d strict gate)", () => {
|
||||
// v1.4.36 W4d — the pre-v1.4.36 fallback used to render every
|
||||
// metric pill while the analytics fetch was in flight. Users with
|
||||
// zero observations on a metric briefly saw nav targets they
|
||||
// couldn't act on, and on a failed fetch the pills stayed up
|
||||
// forever. The strict gate flips the contract: no availability =>
|
||||
// only Overview, then pills light up as data arrives.
|
||||
const html = render(<InsightsTabStrip />);
|
||||
expect(html).toContain(">Blood Pressure<");
|
||||
expect(html).toContain(">Weight<");
|
||||
expect(html).toContain(">Pulse<");
|
||||
expect(html).toContain(">Mood<");
|
||||
expect(html).toContain(">Medication<");
|
||||
expect(html).toContain(">BMI<");
|
||||
expect(html).toContain(">Sleep<");
|
||||
expect(html).toContain(">Overview<");
|
||||
expect(html).not.toContain(">Blood Pressure<");
|
||||
expect(html).not.toContain(">Weight<");
|
||||
expect(html).not.toContain(">Pulse<");
|
||||
expect(html).not.toContain(">Mood<");
|
||||
expect(html).not.toContain(">Medication<");
|
||||
expect(html).not.toContain(">BMI<");
|
||||
expect(html).not.toContain(">Sleep<");
|
||||
});
|
||||
|
||||
it("drops pills for metrics with zero observations", () => {
|
||||
@@ -120,14 +127,40 @@ describe("<InsightsTabStrip> — availability gating (v1.4.27 F19)", () => {
|
||||
});
|
||||
|
||||
describe("<InsightsTabStrip> — vitals group collapse (v1.4.34 IW-D)", () => {
|
||||
// v1.4.36 W4d — strict gate: every test here passes an explicit
|
||||
// `availability` that lights up the wave-A metrics so the popover
|
||||
// parent pill renders. Pre-v1.4.36 the no-availability fallback
|
||||
// lit up every pill by default; under the strict gate the same
|
||||
// intent is expressed by passing the right summaries.
|
||||
const fullAvailability: InsightInputs = {
|
||||
summaries: {
|
||||
BLOOD_PRESSURE_SYS: fakeSummary(5),
|
||||
BLOOD_PRESSURE_DIA: fakeSummary(5),
|
||||
PULSE: fakeSummary(5),
|
||||
WEIGHT: fakeSummary(5),
|
||||
SLEEP_DURATION: fakeSummary(5),
|
||||
VO2_MAX: fakeSummary(5),
|
||||
STEPS: fakeSummary(5),
|
||||
ACTIVE_ENERGY: fakeSummary(5),
|
||||
ACTIVE_ENERGY_BURNED: fakeSummary(5),
|
||||
HEART_RATE_VARIABILITY: fakeSummary(5),
|
||||
RESTING_HEART_RATE: fakeSummary(5),
|
||||
OXYGEN_SATURATION: fakeSummary(5),
|
||||
BODY_TEMPERATURE: fakeSummary(5),
|
||||
},
|
||||
hasMood: true,
|
||||
hasMedication: true,
|
||||
hasWorkouts: true,
|
||||
};
|
||||
|
||||
it("renders a single 'Vitals' parent pill instead of five wave-A pills in the strip row", () => {
|
||||
// No availability ⇒ every wave-A pill is visible ⇒ the parent pill
|
||||
// appears once. The five sub-pages still render in the popover
|
||||
// body but that lives in a Radix Portal which `renderToStaticMarkup`
|
||||
// does not serialise (no portal target in SSR) — so we assert on
|
||||
// the strip-row only. Behaviour test for the popover body sits in
|
||||
// a Playwright spec when the dev server runs.
|
||||
const html = render(<InsightsTabStrip />);
|
||||
// Every wave-A pill has data ⇒ the parent pill appears once. The
|
||||
// five sub-pages still render in the popover body but that lives
|
||||
// in a Radix Portal which `renderToStaticMarkup` does not serialise
|
||||
// (no portal target in SSR) — so we assert on the strip-row only.
|
||||
// Behaviour test for the popover body sits in a Playwright spec
|
||||
// when the dev server runs.
|
||||
const html = render(<InsightsTabStrip availability={fullAvailability} />);
|
||||
// Parent pill — there is exactly one.
|
||||
const parentMatches = html.match(/>Vitals</g);
|
||||
expect(parentMatches).not.toBeNull();
|
||||
@@ -155,13 +188,13 @@ describe("<InsightsTabStrip> — vitals group collapse (v1.4.34 IW-D)", () => {
|
||||
});
|
||||
|
||||
it("renders the parent pill as a popover trigger (button, not link)", () => {
|
||||
const html = render(<InsightsTabStrip />);
|
||||
const html = render(<InsightsTabStrip availability={fullAvailability} />);
|
||||
expect(html).toContain('data-slot="insights-tab-strip-group"');
|
||||
expect(html).toContain('data-group="vitals"');
|
||||
});
|
||||
|
||||
it("preserves the non-grouped pills inline", () => {
|
||||
const html = render(<InsightsTabStrip />);
|
||||
const html = render(<InsightsTabStrip availability={fullAvailability} />);
|
||||
// Flat (non-grouped) pills must still be reachable directly.
|
||||
expect(html).toContain(">Blood Pressure<");
|
||||
expect(html).toContain(">Pulse<");
|
||||
|
||||
@@ -116,4 +116,88 @@ describe("<TrendAnnotation>", () => {
|
||||
/<p[^>]*data-slot="trend-annotation-empty"[^>]*class="[^"]*line-clamp-3[^"]*"[^>]*>/,
|
||||
);
|
||||
});
|
||||
|
||||
// ── v1.4.36 W2 T3 — render-state contract ──────────────────────────
|
||||
// Pre-fix the component derived "empty" purely from
|
||||
// `annotation == null`, which painted "Mehr Daten nötig" on every
|
||||
// cold mount and every regenerate-in-flight even though the advisor
|
||||
// was about to deliver an annotation. The tri-state `status` prop
|
||||
// distinguishes pending / needs_data / generated.
|
||||
|
||||
it("status='pending' renders a shimmer block (not the empty hint)", () => {
|
||||
const html = render(
|
||||
<TrendAnnotation metric="bp" annotation={null} status="pending" />,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="trend-annotation-pending"/);
|
||||
expect(html).toContain("animate-pulse");
|
||||
// Empty hint and filled prose MUST be absent during pending.
|
||||
expect(html).not.toContain('data-slot="trend-annotation-empty"');
|
||||
expect(html).not.toContain('data-slot="trend-annotation"');
|
||||
expect(html).not.toContain("Awaiting more data");
|
||||
});
|
||||
|
||||
it("status='pending' announces a busy state for assistive tech", () => {
|
||||
const html = render(
|
||||
<TrendAnnotation metric="mood" annotation={null} status="pending" />,
|
||||
);
|
||||
expect(html).toMatch(/role="status"/);
|
||||
expect(html).toMatch(/aria-busy="true"/);
|
||||
});
|
||||
|
||||
it("status='pending' keeps painting the shimmer even when an annotation is supplied (regenerate-in-flight)", () => {
|
||||
// During a forced regenerate the previous annotation is still in
|
||||
// cache. We surface the pending state so the user doesn't read
|
||||
// stale prose alongside a freshly-fired generation.
|
||||
const html = render(
|
||||
<TrendAnnotation
|
||||
metric="weight"
|
||||
annotation="stale annotation from a previous generation"
|
||||
status="pending"
|
||||
/>,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="trend-annotation-pending"/);
|
||||
expect(html).not.toContain("stale annotation");
|
||||
});
|
||||
|
||||
it("status='needs_data' renders the empty hint regardless of annotation presence", () => {
|
||||
const html = render(
|
||||
<TrendAnnotation
|
||||
metric="bp"
|
||||
annotation="this should be ignored"
|
||||
status="needs_data"
|
||||
/>,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="trend-annotation-empty"/);
|
||||
expect(html).toContain("Awaiting more data");
|
||||
expect(html).not.toContain("this should be ignored");
|
||||
});
|
||||
|
||||
it("status='generated' renders the prose + confidence chip", () => {
|
||||
const html = render(
|
||||
<TrendAnnotation
|
||||
metric="bp"
|
||||
annotation="BP trending down 4 mmHg over 30 days."
|
||||
status="generated"
|
||||
confidence="high"
|
||||
/>,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="trend-annotation"/);
|
||||
expect(html).toContain("BP trending down 4 mmHg");
|
||||
expect(html).toMatch(/data-slot="trend-annotation-confidence"/);
|
||||
expect(html).not.toContain("Awaiting more data");
|
||||
});
|
||||
|
||||
it("omitting status falls back to the legacy annotation==null mapping (back-compat)", () => {
|
||||
// Old call sites that don't pass `status` must still see the
|
||||
// previous two-state behaviour: filled prose when supplied, empty
|
||||
// hint when null.
|
||||
const filled = render(
|
||||
<TrendAnnotation metric="bp" annotation="BP trending down." />,
|
||||
);
|
||||
expect(filled).toMatch(/data-slot="trend-annotation"/);
|
||||
expect(filled).toContain("BP trending down");
|
||||
|
||||
const empty = render(<TrendAnnotation metric="bp" annotation={null} />);
|
||||
expect(empty).toMatch(/data-slot="trend-annotation-empty"/);
|
||||
});
|
||||
});
|
||||
@@ -89,6 +89,22 @@ describe("<TrendsRow>", () => {
|
||||
expect(slots.length).toBe(3);
|
||||
});
|
||||
|
||||
it("pins every chart slot to h-[140px] so Recharts mounts with a known size (CLS fix)", () => {
|
||||
// v1.4.36 W2 — without an explicit height the Recharts
|
||||
// ResponsiveContainer mounts with width=-1 height=-1 and emits a
|
||||
// console warning per chart. The mood card also drifted taller
|
||||
// than BP / weight on hydration. Pinning the wrapper at 140 px
|
||||
// matches the mini-chart's internal default; the row paints with
|
||||
// zero layout shift.
|
||||
const html = render(<TrendsRow />);
|
||||
const slots =
|
||||
html.match(/data-slot="trends-row-chart-slot"[^>]*class="[^"]*"/g) ?? [];
|
||||
expect(slots.length).toBe(3);
|
||||
for (const slot of slots) {
|
||||
expect(slot).toMatch(/h-\[140px\]/);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders annotations when supplied", () => {
|
||||
const html = render(
|
||||
<TrendsRow
|
||||
@@ -112,6 +128,37 @@ describe("<TrendsRow>", () => {
|
||||
expect(matches.length).toBe(3);
|
||||
});
|
||||
|
||||
it("loading=true paints the pending shimmer for every metric (no 'mehr Daten' flash)", () => {
|
||||
// v1.4.36 W2 T3 — pre-fix the row painted "Awaiting more data"
|
||||
// across all three metrics whenever the advisor query was in
|
||||
// flight. The loading flag now propagates to each annotation
|
||||
// slot so the user reads a generating-state shimmer instead.
|
||||
const html = render(<TrendsRow loading />);
|
||||
const pending =
|
||||
html.match(/data-slot="trend-annotation-pending"/g) ?? [];
|
||||
expect(pending.length).toBe(3);
|
||||
expect(html).not.toContain("Awaiting more data");
|
||||
});
|
||||
|
||||
it("loading=true keeps the shimmer even when annotations are supplied (regenerate-in-flight)", () => {
|
||||
const html = render(
|
||||
<TrendsRow
|
||||
loading
|
||||
annotations={{
|
||||
bp: "stale BP",
|
||||
weight: "stale weight",
|
||||
mood: "stale mood",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain("stale BP");
|
||||
expect(html).not.toContain("stale weight");
|
||||
expect(html).not.toContain("stale mood");
|
||||
const pending =
|
||||
html.match(/data-slot="trend-annotation-pending"/g) ?? [];
|
||||
expect(pending.length).toBe(3);
|
||||
});
|
||||
|
||||
it("propagates per-metric confidence chips", () => {
|
||||
const html = render(
|
||||
<TrendsRow
|
||||
|
||||
@@ -75,6 +75,18 @@ const EXCLUDE_OPTIONS: CoachExcludeMetric[] = [
|
||||
"steps",
|
||||
];
|
||||
|
||||
// v1.4.36 W3 T2 — optional context blocks the user can opt out of.
|
||||
// Surfaced as a separate group below the per-metric exclude list so
|
||||
// the intent ("don't ship my profile / medication context to the
|
||||
// model") reads distinctly from the per-metric data toggles above.
|
||||
// Each row gates a single labelled block at the snapshot/feature layer
|
||||
// so empty blocks never reach the prompt.
|
||||
const CONTEXT_OPTIONS: CoachExcludeMetric[] = [
|
||||
"sleep",
|
||||
"medications",
|
||||
"anthropometrics",
|
||||
];
|
||||
|
||||
// v1.4.25 W5 — picker options for the new `defaultWindow` preference.
|
||||
// Mirrors `CoachScopeWindow` so the chat route can fold the selection
|
||||
// straight into `scope.window` when the client didn't supply one.
|
||||
@@ -349,6 +361,47 @@ export function CoachSettingsSheet({
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* v1.4.36 W3 T2 — optional context blocks. Mirrors the
|
||||
EXCLUDE_OPTIONS list mechanically (same on/off switch
|
||||
pattern, same toggleExclude handler) but groups the
|
||||
profile / medication context toggles in their own
|
||||
section so the intent reads distinctly. Empty blocks
|
||||
are dropped at the snapshot/feature layer when the
|
||||
toggle is on. */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-xs font-medium">
|
||||
{t("insights.coach.settingsContextLabel")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-[11px] leading-relaxed">
|
||||
{t("insights.coach.settingsContextHint")}
|
||||
</p>
|
||||
<ul
|
||||
data-slot="coach-prefs-context-list"
|
||||
className="border-border/60 divide-border/50 mt-1 flex flex-col divide-y rounded-md border"
|
||||
>
|
||||
{CONTEXT_OPTIONS.map((metric) => {
|
||||
const id = `coach-prefs-context-${metric}`;
|
||||
const checked = draft.excludeMetrics.includes(metric);
|
||||
return (
|
||||
<li
|
||||
key={metric}
|
||||
className="flex items-center justify-between gap-3 px-3 py-2"
|
||||
>
|
||||
<Label htmlFor={id} className="cursor-pointer text-xs">
|
||||
{t(`insights.coach.metric.${metric}`)}
|
||||
</Label>
|
||||
<Switch
|
||||
id={id}
|
||||
data-slot={`coach-prefs-context-${metric}`}
|
||||
checked={checked}
|
||||
onCheckedChange={(next) => toggleExclude(metric, next)}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* v1.4.27 F14 — the `showEvidenceByDefault` switch was
|
||||
retired. The evidence disclosure is now always closed
|
||||
by default and the user expands by click; surfacing
|
||||
|
||||
@@ -171,10 +171,22 @@ const SUB_PAGE_GROUP_META: Record<
|
||||
};
|
||||
|
||||
function buildTabs(availability: InsightInputs | undefined): TabEntry[] {
|
||||
const visibleSlugs = SUB_PAGE_SLUGS.filter((slug) => {
|
||||
if (!availability) return true;
|
||||
return hasMetricData(SUB_PAGE_TABS[slug].metric, availability);
|
||||
});
|
||||
// v1.4.36 W4d — strict gating contract: a sub-page only surfaces a
|
||||
// pill when the availability inputs CONFIRM the metric has at
|
||||
// least one observation in the 90-day window. The legacy
|
||||
// `if (!availability) return true` fallback rendered every pill
|
||||
// while the analytics fetch was in flight, so users with empty
|
||||
// metrics briefly saw nav targets they couldn't act on (and on
|
||||
// a failed/stale fetch the pills stayed up forever). The new
|
||||
// contract: no availability → no metric pills, only Overview.
|
||||
// The pills appear progressively as `summaries[METRIC].count`
|
||||
// flips from 0 to ≥1 — exactly the auto-light-up behaviour
|
||||
// described in `metric-availability.ts` line 26.
|
||||
const visibleSlugs = availability
|
||||
? SUB_PAGE_SLUGS.filter((slug) =>
|
||||
hasMetricData(SUB_PAGE_TABS[slug].metric, availability),
|
||||
)
|
||||
: [];
|
||||
|
||||
const visibleSet = new Set(visibleSlugs);
|
||||
const entries: TabEntry[] = [
|
||||
|
||||
@@ -19,17 +19,44 @@ import { CONFIDENCE_BADGE_CLASS } from "./confidence-badge";
|
||||
* Confidence band is optional — surfaced as a small `Badge` chip when
|
||||
* a backing correlation gives us one. The chip is purely visual and
|
||||
* never adds new copy beyond the `low / moderate / high` translation.
|
||||
*
|
||||
* v1.4.36 W2 T3 — render-state contract (`status`). Pre-fix the
|
||||
* component derived empty vs filled from `annotation == null` alone,
|
||||
* which painted "Mehr Daten nötig" on every cold mount and every
|
||||
* regenerate-in-flight, even when the advisor was about to deliver an
|
||||
* annotation. The status prop now distinguishes:
|
||||
*
|
||||
* - `"pending"` — advisor query in flight or regenerate firing.
|
||||
* Renders a 3-line shimmer block matching the
|
||||
* filled-state row contract.
|
||||
* - `"needs_data"` — advisor returned `annotation = null`. Renders
|
||||
* the "Mehr Daten nötig" hint.
|
||||
* - `"generated"` — advisor returned a string. Renders the prose +
|
||||
* optional confidence chip.
|
||||
*
|
||||
* Back-compat: when `status` is omitted, the legacy
|
||||
* `annotation == null → empty` mapping still applies so existing call
|
||||
* sites that don't pass the prop keep their current behaviour.
|
||||
*/
|
||||
|
||||
export type TrendAnnotationConfidenceBand = "low" | "moderate" | "high";
|
||||
|
||||
export type TrendAnnotationStatus = "pending" | "needs_data" | "generated";
|
||||
|
||||
interface TrendAnnotationProps {
|
||||
/** The metric this annotation describes. Drives the empty-state copy. */
|
||||
metric: "bp" | "weight" | "mood";
|
||||
/** AI-authored sentence. `null` renders the empty-state hint. */
|
||||
/** AI-authored sentence. `null` renders the empty-state hint (legacy path). */
|
||||
annotation: string | null;
|
||||
/** Optional discrete confidence band. */
|
||||
confidence?: TrendAnnotationConfidenceBand;
|
||||
/**
|
||||
* Tri-state render contract. When supplied, drives the branch
|
||||
* directly (and overrides the legacy `annotation == null` empty
|
||||
* fallback). Default `undefined` keeps the legacy two-state mapping
|
||||
* for back-compat with existing call sites.
|
||||
*/
|
||||
status?: TrendAnnotationStatus;
|
||||
}
|
||||
|
||||
const CONFIDENCE_LABEL_KEY: Record<TrendAnnotationConfidenceBand, string> = {
|
||||
@@ -48,10 +75,36 @@ export function TrendAnnotation({
|
||||
metric,
|
||||
annotation,
|
||||
confidence,
|
||||
status,
|
||||
}: TrendAnnotationProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
if (!annotation) {
|
||||
// v1.4.36 W2 T3 — render-state contract. When `status` is supplied
|
||||
// it drives the branch directly; otherwise we fall back on the
|
||||
// legacy `annotation == null → empty` mapping so existing callers
|
||||
// (tests, isolated mounts) keep their previous behaviour.
|
||||
const resolvedStatus: TrendAnnotationStatus =
|
||||
status ?? (annotation ? "generated" : "needs_data");
|
||||
|
||||
if (resolvedStatus === "pending") {
|
||||
return (
|
||||
<div
|
||||
data-slot="trend-annotation-pending"
|
||||
data-metric={metric}
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
aria-live="polite"
|
||||
className="border-border/60 bg-card/40 space-y-1.5 rounded-md border p-3 motion-reduce:animate-none"
|
||||
aria-label={t("insights.trendAnnotation.pendingLabel")}
|
||||
>
|
||||
<div className="bg-muted/60 h-2.5 w-11/12 animate-pulse rounded" />
|
||||
<div className="bg-muted/60 h-2.5 w-9/12 animate-pulse rounded" />
|
||||
<div className="bg-muted/60 h-2.5 w-7/12 animate-pulse rounded" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (resolvedStatus === "needs_data" || !annotation) {
|
||||
return (
|
||||
<p
|
||||
data-slot="trend-annotation-empty"
|
||||
|
||||
@@ -4,10 +4,11 @@ import dynamic from "next/dynamic";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
|
||||
import { HealthChartDynamicMini } from "@/components/charts/health-chart-dynamic";
|
||||
import {
|
||||
TrendAnnotation,
|
||||
type TrendAnnotationConfidenceBand,
|
||||
type TrendAnnotationStatus,
|
||||
} from "./trend-annotation";
|
||||
|
||||
/**
|
||||
@@ -32,7 +33,7 @@ const MoodChart = dynamic(
|
||||
import("@/components/charts/mood-chart").then((mod) => ({
|
||||
default: mod.MoodChart,
|
||||
})),
|
||||
{ ssr: false, loading: () => <ChartSkeleton /> },
|
||||
{ ssr: false, loading: () => <ChartSkeleton mini /> },
|
||||
);
|
||||
|
||||
interface TrendsRowProps {
|
||||
@@ -56,15 +57,35 @@ interface TrendsRowProps {
|
||||
weight?: TrendAnnotationConfidenceBand;
|
||||
mood?: TrendAnnotationConfidenceBand;
|
||||
};
|
||||
/**
|
||||
* v1.4.36 W2 T3 — advisor query / mutation in flight. When true,
|
||||
* every per-metric annotation slot paints a pending shimmer instead
|
||||
* of the "Mehr Daten nötig" empty hint. Resolves the recurring
|
||||
* complaint where the empty hint flashed across all three metrics
|
||||
* while the advisor was generating fresh annotations.
|
||||
*/
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
|
||||
export function TrendsRow({
|
||||
annotations,
|
||||
confidence,
|
||||
loading = false,
|
||||
}: TrendsRowProps) {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const userTimezone = user?.timezone;
|
||||
const bpAnnotation = annotations?.bp ?? null;
|
||||
const weightAnnotation = annotations?.weight ?? null;
|
||||
const moodAnnotation = annotations?.mood ?? null;
|
||||
// v1.4.36 W2 T3 — derive the tri-state status per metric from the
|
||||
// advisor's loading flag + the annotation presence. Pending wins
|
||||
// over needs_data so a mid-generation regenerate doesn't flash the
|
||||
// empty hint between the spinner and the new prose.
|
||||
const statusFor = (annotation: string | null): TrendAnnotationStatus => {
|
||||
if (loading) return "pending";
|
||||
return annotation ? "generated" : "needs_data";
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -117,8 +138,8 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
|
||||
wrapper (which carries a heavier shell on a default
|
||||
shadcn Card) lines up with the BP/weight tiles' lighter
|
||||
shell. Both chart types ship the same data-slot now. */}
|
||||
<div data-slot="trends-row-chart-slot" className="shrink-0">
|
||||
<HealthChartDynamic
|
||||
<div data-slot="trends-row-chart-slot" className="h-[140px] shrink-0">
|
||||
<HealthChartDynamicMini
|
||||
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
|
||||
title={t("charts.bloodPressure")}
|
||||
colors={["#ff79c6", "#8be9fd"]}
|
||||
@@ -132,6 +153,7 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
|
||||
metric="bp"
|
||||
annotation={bpAnnotation}
|
||||
confidence={confidence?.bp}
|
||||
status={statusFor(bpAnnotation)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
@@ -139,8 +161,8 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
|
||||
data-metric="weight"
|
||||
className="flex h-full flex-col gap-2 md:min-h-[300px]"
|
||||
>
|
||||
<div data-slot="trends-row-chart-slot" className="shrink-0">
|
||||
<HealthChartDynamic
|
||||
<div data-slot="trends-row-chart-slot" className="h-[140px] shrink-0">
|
||||
<HealthChartDynamicMini
|
||||
types={["WEIGHT"]}
|
||||
title={t("charts.weight")}
|
||||
colors={["#bd93f9"]}
|
||||
@@ -153,6 +175,7 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
|
||||
metric="weight"
|
||||
annotation={weightAnnotation}
|
||||
confidence={confidence?.weight}
|
||||
status={statusFor(weightAnnotation)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
@@ -160,7 +183,7 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
|
||||
data-metric="mood"
|
||||
className="flex h-full flex-col gap-2 md:min-h-[300px]"
|
||||
>
|
||||
<div data-slot="trends-row-chart-slot" className="shrink-0">
|
||||
<div data-slot="trends-row-chart-slot" className="h-[140px] shrink-0">
|
||||
<MoodChart
|
||||
title={t("charts.mood")}
|
||||
mini
|
||||
@@ -171,6 +194,7 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
|
||||
metric="mood"
|
||||
annotation={moodAnnotation}
|
||||
confidence={confidence?.mood}
|
||||
status={statusFor(moodAnnotation)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Home,
|
||||
Info,
|
||||
Lightbulb,
|
||||
LogOut,
|
||||
Monitor,
|
||||
@@ -144,17 +143,10 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
|
||||
{t("nav.notifications")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{/* v1.4.33 IW7 — "About" used to live as its own Settings
|
||||
section but the three small cards inside (identity / links /
|
||||
update check) collectively get read once or twice a year, so
|
||||
the top-level slot has been collapsed into this dropdown. The
|
||||
route stays alive at `/settings/about`. */}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings/about" className="cursor-pointer">
|
||||
<Info className="mr-2 h-4 w-4" />
|
||||
{t("nav.about")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{/* v1.4.36 W4e — About moved into the Admin Console
|
||||
(`/admin/about`). Admins reach it through the Admin sidebar
|
||||
and the mobile section strip; non-admin users see the
|
||||
public `/about` landing instead. */}
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
{themeIcon}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import {
|
||||
Bell,
|
||||
ChevronDown,
|
||||
Info,
|
||||
LogIn,
|
||||
LogOut,
|
||||
Monitor,
|
||||
@@ -95,15 +94,10 @@ export function TopBar() {
|
||||
{t("nav.notifications")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{/* v1.4.33 IW7 — "About" folded out of Settings into the
|
||||
user-card dropdown; mobile dropdown mirrors the
|
||||
desktop sidebar so the entry shows up here too. */}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings/about" className="cursor-pointer">
|
||||
<Info className="mr-2 h-4 w-4" />
|
||||
{t("nav.about")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{/* v1.4.36 W4e — About moved into the Admin Console
|
||||
(`/admin/about`). The dropdown entry was redundant
|
||||
for the small audience that still reaches it (admins
|
||||
only, on the order of once or twice a year). */}
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
{themeIcon}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { IntakeHistoryListV2 } from "@/components/medications/intake-history-list-v2";
|
||||
|
||||
/**
|
||||
* v1.4.36 W4a — IntakeHistoryListV2 SSR smoke tests.
|
||||
*
|
||||
* Mirrors the project convention: `renderToStaticMarkup` +
|
||||
* react-query data seeded via `QueryClient.setQueryData()`. Sort-toggle
|
||||
* is a useState interaction and cannot fire through SSR, so the
|
||||
* toggle test exercises the seeded sortDir/sortBy variants and pins
|
||||
* the rendered indicator markup.
|
||||
*/
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "test-user", username: "tester", role: "USER" },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
function makeClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
refetchOnMount: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type IntakeRow = {
|
||||
id: string;
|
||||
medicationId: string;
|
||||
scheduledFor: string;
|
||||
takenAt: string | null;
|
||||
skipped: boolean;
|
||||
source: "WEB" | "API" | "REMINDER" | "IMPORT";
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
function seedIntake(
|
||||
client: QueryClient,
|
||||
medId: string,
|
||||
rows: IntakeRow[],
|
||||
total: number = rows.length,
|
||||
opts: {
|
||||
sortBy?: "takenAt" | "scheduledFor";
|
||||
sortDir?: "asc" | "desc";
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
} = {},
|
||||
) {
|
||||
const sortBy = opts.sortBy ?? "takenAt";
|
||||
const sortDir = opts.sortDir ?? "desc";
|
||||
const limit = opts.limit ?? 25;
|
||||
const offset = opts.offset ?? 0;
|
||||
client.setQueryData(
|
||||
[
|
||||
"medications",
|
||||
medId,
|
||||
"intake",
|
||||
"list",
|
||||
{ sortBy, sortDir, limit, offset },
|
||||
],
|
||||
{ events: rows, meta: { total, limit, offset } },
|
||||
);
|
||||
}
|
||||
|
||||
function render(
|
||||
node: React.ReactNode,
|
||||
client: QueryClient,
|
||||
locale: "en" | "de" = "en",
|
||||
) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<QueryClientProvider client={client}>{node}</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("<IntakeHistoryListV2> — empty state", () => {
|
||||
it("renders the empty-state title and CTA in English", () => {
|
||||
const client = makeClient();
|
||||
seedIntake(client, "med-1", []);
|
||||
const html = render(
|
||||
<IntakeHistoryListV2 medicationId="med-1" />,
|
||||
client,
|
||||
);
|
||||
expect(html).toContain("No intakes recorded yet");
|
||||
expect(html).toContain("Open daily intake");
|
||||
expect(html).toContain('href="/medications"');
|
||||
});
|
||||
|
||||
it("renders the empty-state title and CTA in German", () => {
|
||||
const client = makeClient();
|
||||
seedIntake(client, "med-1", []);
|
||||
const html = render(
|
||||
<IntakeHistoryListV2 medicationId="med-1" />,
|
||||
client,
|
||||
"de",
|
||||
);
|
||||
expect(html).toContain("Noch keine Einnahmen erfasst");
|
||||
expect(html).toContain("Zur Tagesübersicht");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<IntakeHistoryListV2> — populated rows", () => {
|
||||
it("renders one row per intake event with status + source badges", () => {
|
||||
const client = makeClient();
|
||||
seedIntake(client, "med-1", [
|
||||
{
|
||||
id: "evt-1",
|
||||
medicationId: "med-1",
|
||||
scheduledFor: "2026-05-15T08:00:00.000Z",
|
||||
takenAt: "2026-05-15T08:02:00.000Z",
|
||||
skipped: false,
|
||||
source: "WEB",
|
||||
createdAt: "2026-05-15T08:02:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "evt-2",
|
||||
medicationId: "med-1",
|
||||
scheduledFor: "2026-05-14T08:00:00.000Z",
|
||||
takenAt: null,
|
||||
skipped: true,
|
||||
source: "REMINDER",
|
||||
createdAt: "2026-05-14T20:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
const html = render(
|
||||
<IntakeHistoryListV2 medicationId="med-1" />,
|
||||
client,
|
||||
);
|
||||
expect(html).toContain("Taken");
|
||||
expect(html).toContain("Skipped");
|
||||
expect(html).toContain("Website");
|
||||
expect(html).toContain("Telegram / Reminder");
|
||||
});
|
||||
|
||||
it("renders the section title across both locales", () => {
|
||||
const client = makeClient();
|
||||
seedIntake(client, "med-1", []);
|
||||
const enHtml = render(
|
||||
<IntakeHistoryListV2 medicationId="med-1" />,
|
||||
client,
|
||||
);
|
||||
expect(enHtml).toContain("Intake history");
|
||||
|
||||
const deClient = makeClient();
|
||||
seedIntake(deClient, "med-1", []);
|
||||
const deHtml = render(
|
||||
<IntakeHistoryListV2 medicationId="med-1" />,
|
||||
deClient,
|
||||
"de",
|
||||
);
|
||||
expect(deHtml).toContain("Einnahme-Verlauf");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<IntakeHistoryListV2> — sort indicators", () => {
|
||||
it("renders a desc arrow on the active sort column", () => {
|
||||
const client = makeClient();
|
||||
seedIntake(client, "med-1", [
|
||||
{
|
||||
id: "evt-1",
|
||||
medicationId: "med-1",
|
||||
scheduledFor: "2026-05-15T08:00:00.000Z",
|
||||
takenAt: "2026-05-15T08:02:00.000Z",
|
||||
skipped: false,
|
||||
source: "WEB",
|
||||
createdAt: "2026-05-15T08:02:00.000Z",
|
||||
},
|
||||
]);
|
||||
const html = render(
|
||||
<IntakeHistoryListV2 medicationId="med-1" />,
|
||||
client,
|
||||
);
|
||||
// Default sort = takenAt desc, so the "taken" header carries the
|
||||
// arrow-down icon and the "scheduled" header carries the neutral
|
||||
// dual-arrow icon.
|
||||
expect(html).toContain("arrow-down");
|
||||
expect(html).toContain("arrow-up-down");
|
||||
});
|
||||
|
||||
it("applies focus-visible ring + min-h-11 to sort header buttons", () => {
|
||||
const client = makeClient();
|
||||
seedIntake(client, "med-a11y", [
|
||||
{
|
||||
id: "evt-1",
|
||||
medicationId: "med-a11y",
|
||||
scheduledFor: "2026-05-15T08:00:00.000Z",
|
||||
takenAt: "2026-05-15T08:02:00.000Z",
|
||||
skipped: false,
|
||||
source: "WEB",
|
||||
createdAt: "2026-05-15T08:02:00.000Z",
|
||||
},
|
||||
]);
|
||||
const html = render(
|
||||
<IntakeHistoryListV2 medicationId="med-a11y" />,
|
||||
client,
|
||||
);
|
||||
expect(html).toContain("focus-visible:ring-ring");
|
||||
expect(html).toContain("focus-visible:ring-2");
|
||||
expect(html).toContain("focus-visible:ring-offset-2");
|
||||
expect(html).toContain("min-h-11");
|
||||
});
|
||||
|
||||
it("renders pagination controls only when more than one page", () => {
|
||||
const client = makeClient();
|
||||
// 50 total > pageSize 25 → pagination should render
|
||||
seedIntake(
|
||||
client,
|
||||
"med-2",
|
||||
[
|
||||
{
|
||||
id: "evt-1",
|
||||
medicationId: "med-2",
|
||||
scheduledFor: "2026-05-15T08:00:00.000Z",
|
||||
takenAt: "2026-05-15T08:02:00.000Z",
|
||||
skipped: false,
|
||||
source: "WEB",
|
||||
createdAt: "2026-05-15T08:02:00.000Z",
|
||||
},
|
||||
],
|
||||
50,
|
||||
);
|
||||
const html = render(
|
||||
<IntakeHistoryListV2 medicationId="med-2" />,
|
||||
client,
|
||||
);
|
||||
expect(html).toContain("Previous");
|
||||
expect(html).toContain("Next");
|
||||
// a11y: pagination buttons clear the 44 px mobile touch floor and
|
||||
// collapse to 36 px from sm+ so the row stays compact on desktop.
|
||||
expect(html).toContain("min-h-11");
|
||||
expect(html).toContain("sm:min-h-9");
|
||||
});
|
||||
|
||||
it("hides pagination when total ≤ pageSize", () => {
|
||||
const client = makeClient();
|
||||
seedIntake(
|
||||
client,
|
||||
"med-3",
|
||||
[
|
||||
{
|
||||
id: "evt-1",
|
||||
medicationId: "med-3",
|
||||
scheduledFor: "2026-05-15T08:00:00.000Z",
|
||||
takenAt: "2026-05-15T08:02:00.000Z",
|
||||
skipped: false,
|
||||
source: "WEB",
|
||||
createdAt: "2026-05-15T08:02:00.000Z",
|
||||
},
|
||||
],
|
||||
1,
|
||||
);
|
||||
const html = render(
|
||||
<IntakeHistoryListV2 medicationId="med-3" />,
|
||||
client,
|
||||
);
|
||||
expect(html).not.toContain(">Previous<");
|
||||
expect(html).not.toContain(">Next<");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { Loader2, ArrowUp, ArrowDown, ArrowUpDown, History } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
/**
|
||||
* v1.4.36 W4a — Lite intake-history surface.
|
||||
*
|
||||
* The 886-LoC v1 was retired in v1.4.28 (commit 8c81af10) with the
|
||||
* note "no other consumer". The standalone medication-history page
|
||||
* lost its dose timeline as a side effect — Marc reported the page
|
||||
* now shows only the heading for non-GLP-1 meds. This v2 restores a
|
||||
* read-only timeline for ALL medication kinds without the inline CRUD
|
||||
* the v1 carried (edit / delete still happen through the regular
|
||||
* medication-intake routes elsewhere).
|
||||
*
|
||||
* Contract:
|
||||
* - Paginated 25/page via the existing `?limit=&offset=` query on
|
||||
* `GET /api/medications/[id]/intake`. The route already supports
|
||||
* `sortBy=takenAt|scheduledFor|createdAt|source` + `sortDir=asc|desc`.
|
||||
* - Sortable by `takenAt` (default) and `scheduledFor`. Click the
|
||||
* header to toggle the direction; switching column resets to desc.
|
||||
* - Source badge surfaces WEB / API / REMINDER / IMPORT using the
|
||||
* existing `medications.source*` keys.
|
||||
* - Empty state copy + CTA back to the daily intake page.
|
||||
* - No edit-in-row, no delete buttons.
|
||||
*/
|
||||
|
||||
type IntakeSource = "WEB" | "API" | "REMINDER" | "IMPORT";
|
||||
|
||||
interface IntakeEvent {
|
||||
id: string;
|
||||
medicationId: string;
|
||||
scheduledFor: string;
|
||||
takenAt: string | null;
|
||||
skipped: boolean;
|
||||
source: IntakeSource;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface IntakeListResponse {
|
||||
events: IntakeEvent[];
|
||||
meta: { total: number; limit: number; offset: number };
|
||||
}
|
||||
|
||||
interface IntakeHistoryListV2Props {
|
||||
medicationId: string;
|
||||
/** Override the default page size — primarily for tests. */
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
type SortKey = "takenAt" | "scheduledFor";
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
|
||||
export function IntakeHistoryListV2({
|
||||
medicationId,
|
||||
pageSize = DEFAULT_PAGE_SIZE,
|
||||
}: IntakeHistoryListV2Props) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const formatters = useFormatters();
|
||||
|
||||
const [sortBy, setSortBy] = useState<SortKey>("takenAt");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const offset = page * pageSize;
|
||||
|
||||
const { data, isLoading, isError } = useQuery<IntakeListResponse>({
|
||||
queryKey: [
|
||||
"medications",
|
||||
medicationId,
|
||||
"intake",
|
||||
"list",
|
||||
{ sortBy, sortDir, limit: pageSize, offset },
|
||||
],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({
|
||||
sortBy,
|
||||
sortDir,
|
||||
limit: String(pageSize),
|
||||
offset: String(offset),
|
||||
});
|
||||
const res = await fetch(
|
||||
`/api/medications/${medicationId}/intake?${params.toString()}`,
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to load intake history");
|
||||
return (await res.json()).data as IntakeListResponse;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const sourceLabels: Record<IntakeSource, string> = useMemo(
|
||||
() => ({
|
||||
WEB: t("medications.sourceWeb"),
|
||||
API: t("medications.sourceApi"),
|
||||
REMINDER: t("medications.sourceReminder"),
|
||||
IMPORT: t("medications.sourceImport"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const total = data?.meta.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const events = data?.events ?? [];
|
||||
|
||||
const toggleSort = (key: SortKey) => {
|
||||
if (key === sortBy) {
|
||||
setSortDir((d) => (d === "desc" ? "asc" : "desc"));
|
||||
} else {
|
||||
setSortBy(key);
|
||||
setSortDir("desc");
|
||||
}
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const sortIndicator = (key: SortKey) => {
|
||||
if (key !== sortBy) {
|
||||
return <ArrowUpDown className="ml-1 inline h-3 w-3 opacity-50" />;
|
||||
}
|
||||
return sortDir === "desc" ? (
|
||||
<ArrowDown className="ml-1 inline h-3 w-3" />
|
||||
) : (
|
||||
<ArrowUp className="ml-1 inline h-3 w-3" />
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{t("medications.intakeHistory")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-4 md:pb-6">
|
||||
{isLoading ? (
|
||||
<div className="flex h-24 items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<p className="text-destructive text-sm">
|
||||
{t("medications.loadFailed")}
|
||||
</p>
|
||||
) : events.length === 0 ? (
|
||||
<EmptyState
|
||||
variant="plain"
|
||||
size="compact"
|
||||
icon={<History className="size-6" />}
|
||||
title={t("medications.intakeHistoryEmptyTitle")}
|
||||
description={t("medications.intakeHistoryEmptyDescription")}
|
||||
action={
|
||||
<Button asChild size="sm">
|
||||
<Link href="/medications">
|
||||
{t("medications.intakeHistoryEmptyAction")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSort("takenAt")}
|
||||
className="focus-visible:ring-ring inline-flex min-h-11 items-center rounded-md font-medium hover:underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none sm:min-h-9"
|
||||
aria-label={t("medications.intakeHistorySortByTaken")}
|
||||
>
|
||||
{t("medications.intakeHistoryColTaken")}
|
||||
{sortIndicator("takenAt")}
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSort("scheduledFor")}
|
||||
className="focus-visible:ring-ring inline-flex min-h-11 items-center rounded-md font-medium hover:underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none sm:min-h-9"
|
||||
aria-label={t("medications.intakeHistorySortByScheduled")}
|
||||
>
|
||||
{t("medications.intakeHistoryColScheduled")}
|
||||
{sortIndicator("scheduledFor")}
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead>{t("medications.intakeHistoryColStatus")}</TableHead>
|
||||
<TableHead>{t("medications.intakeHistoryColSource")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{events.map((event) => (
|
||||
<TableRow key={event.id}>
|
||||
<TableCell className="text-sm">
|
||||
{event.takenAt ? formatters.dateTime(event.takenAt) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{formatters.dateTime(event.scheduledFor)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={event.skipped ? "outline" : "secondary"}
|
||||
className="text-xs"
|
||||
>
|
||||
{event.skipped
|
||||
? t("medications.intakeHistoryStatusSkipped")
|
||||
: t("medications.intakeHistoryStatusTaken")}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{sourceLabels[event.source] ?? event.source}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="text-muted-foreground mt-3 flex items-center justify-between text-xs">
|
||||
<span>
|
||||
{t("medications.intakeHistoryPageInfo", {
|
||||
page: page + 1,
|
||||
total: totalPages,
|
||||
count: total,
|
||||
})}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
>
|
||||
{t("medications.intakeHistoryPrev")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() =>
|
||||
setPage((p) => Math.min(totalPages - 1, p + 1))
|
||||
}
|
||||
disabled={page >= totalPages - 1}
|
||||
>
|
||||
{t("medications.intakeHistoryNext")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { AboutSection, UpdateBadge } from "../about-section";
|
||||
|
||||
/**
|
||||
* v1.4.36 W4f — AboutSection update-badge contract.
|
||||
*
|
||||
* The dedicated "Updates" card with the manual "Check for updates"
|
||||
* button is gone. The 24 h auto-check still runs on mount and
|
||||
* surfaces a subtle ArrowUpCircle badge next to the version line
|
||||
* ONLY when the check returns `{ status: "newer_available", ... }`.
|
||||
*
|
||||
* These SSR smoke tests pin the badge presence by seeding the
|
||||
* react-query cache with a fake version payload (so the version
|
||||
* line paints synchronously) and exercising the path that decides
|
||||
* whether the badge mounts. The badge itself owns its own
|
||||
* `useState`, so its presence is gated on the runCheck branch —
|
||||
* SSR renders the initial `null` state. The actual badge render
|
||||
* is therefore covered by the runtime store-set + re-render in
|
||||
* future Playwright surfaces; here we pin the *baseline* contract
|
||||
* that the version line + Sources/docs panel still render and
|
||||
* the legacy manual-check chrome is gone.
|
||||
*/
|
||||
|
||||
function makeClient() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
refetchOnMount: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
// Seed the version query so the section paints inline rather than
|
||||
// rendering its loader skeleton.
|
||||
client.setQueryData(["api", "version"], {
|
||||
version: "1.4.36",
|
||||
buildSha: "abc1234",
|
||||
builtAt: "2026-05-17T08:00:00.000Z",
|
||||
license: "AGPL-3.0",
|
||||
repository: "https://github.com/MBombeck/HealthLog",
|
||||
changelog: "https://github.com/MBombeck/HealthLog/blob/main/CHANGELOG.md",
|
||||
docs: "https://github.com/MBombeck/HealthLog/tree/main/docs",
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
const client = makeClient();
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<QueryClientProvider client={client}>{node}</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Fail the auto-check fetch so the badge stays in its initial
|
||||
// (no-result) state. This lets the SSR smoke tests assert the
|
||||
// "no badge" baseline; the badge-visible branch is unit-tested
|
||||
// separately by toggling `updateResult` via a forced re-render
|
||||
// in the future Playwright + RTL suite.
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
throw new Error("network disabled in tests");
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("<AboutSection> — update badge", () => {
|
||||
it("renders the version line without a badge by default", () => {
|
||||
const html = render(<AboutSection />);
|
||||
expect(html).toContain("v1.4.36");
|
||||
// No badge — ArrowUpCircle's lucide class name is the unique signal.
|
||||
expect(html).not.toContain("lucide-arrow-up-circle");
|
||||
});
|
||||
|
||||
it("drops the manual 'Check for updates' button surface entirely", () => {
|
||||
const html = render(<AboutSection />);
|
||||
// Pre-v1.4.36 the section carried a "Check for updates" CTA
|
||||
// alongside a last-checked timestamp. Both are now gone — only
|
||||
// the 24 h auto-check stays, surfacing through the badge above.
|
||||
expect(html).not.toContain(">Check for updates<");
|
||||
expect(html).not.toContain(">Check now<");
|
||||
expect(html).not.toContain("last-checked-iso");
|
||||
});
|
||||
|
||||
it("preserves the Sources & docs panel", () => {
|
||||
const html = render(<AboutSection />);
|
||||
expect(html).toContain("Sources & docs");
|
||||
});
|
||||
|
||||
it("uses a dedicated 'Built' label for the build-time row rather than the gitSha fallback", () => {
|
||||
const html = render(<AboutSection />);
|
||||
// Distinct label keeps the dt copy separate from the "Build" gitSha
|
||||
// header above it; the prior `t('builtAt', { time: '' }).trim()`
|
||||
// pattern silently leaked the gitSha key as a fallback.
|
||||
expect(html).toContain(">Built<");
|
||||
// Only ONE column should ever read "Build" — the gitSha header.
|
||||
const buildOccurrences = html.match(/>Build</g) ?? [];
|
||||
expect(buildOccurrences.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<UpdateBadge> — a11y contract", () => {
|
||||
it("renders an anchor with aria-label, focus-visible ring, and 44 px hit target when a URL is known", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<UpdateBadge
|
||||
latestTag="v1.4.99"
|
||||
htmlUrl="https://example.test/releases/v1.4.99"
|
||||
ariaLabel="A newer version is available: v1.4.99"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('aria-label="A newer version is available: v1.4.99"');
|
||||
expect(html).toContain("min-h-11");
|
||||
expect(html).toContain("min-w-11");
|
||||
expect(html).toContain("focus-visible:ring-ring");
|
||||
expect(html).toContain("focus-visible:ring-2");
|
||||
expect(html).toContain("focus-visible:ring-offset-2");
|
||||
expect(html).toContain("text-primary");
|
||||
expect(html).toContain('href="https://example.test/releases/v1.4.99"');
|
||||
});
|
||||
|
||||
it("falls back to a span with role=img + aria-label when no URL is known", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<UpdateBadge
|
||||
latestTag="v1.4.99"
|
||||
htmlUrl={null}
|
||||
ariaLabel="A newer version is available: v1.4.99"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('role="img"');
|
||||
expect(html).toContain('aria-label="A newer version is available: v1.4.99"');
|
||||
expect(html).toContain("min-h-11");
|
||||
expect(html).toContain("min-w-11");
|
||||
});
|
||||
});
|
||||
@@ -132,16 +132,17 @@ describe("settings sections — SSR smoke", () => {
|
||||
expect(html).not.toContain("onboarding.tour.");
|
||||
});
|
||||
|
||||
it("<AboutSection> renders version + license + check-for-updates button", () => {
|
||||
it("<AboutSection> renders version + license + sources/docs cards (no Updates panel)", () => {
|
||||
const html = render(<AboutSection />);
|
||||
expect(html).toContain("About");
|
||||
// The new about.* keys resolve. v1.4.3 split the page into three
|
||||
// titled cards (HealthLog identity, Sources & docs, Updates) and
|
||||
// shortened the button label from "Check for updates" to "Check now"
|
||||
// because the surrounding heading already names the section.
|
||||
expect(html).toContain("Updates");
|
||||
// v1.4.36 W4f — the dedicated "Updates" card with its manual
|
||||
// "Check now" button is gone. The 24 h auto-check still runs and
|
||||
// surfaces a subtle ArrowUpCircle badge next to the version line
|
||||
// when a newer release is available. The Sources & docs card
|
||||
// stays put.
|
||||
expect(html).toContain("Sources & docs");
|
||||
expect(html).toContain("Check now");
|
||||
expect(html).not.toContain(">Check now<");
|
||||
expect(html).not.toContain(">Check for updates<");
|
||||
expect(html).not.toContain("settings.about.");
|
||||
});
|
||||
|
||||
@@ -198,11 +199,12 @@ describe("settings sections — SSR smoke", () => {
|
||||
|
||||
it("<AboutSection> resolves about.* keys in German", () => {
|
||||
const html = render(<AboutSection />, "de");
|
||||
// v1.4.3: button label shortened to "Jetzt prüfen" because the
|
||||
// section heading "Updates" already provides context.
|
||||
expect(html).toContain("Updates");
|
||||
expect(html).toContain("Jetzt prüfen");
|
||||
// v1.4.36 W4f — "Updates" panel + "Jetzt prüfen" button removed;
|
||||
// the surviving German copy is the section title + the Sources
|
||||
// & docs heading + the version line.
|
||||
expect(html).toContain("Über");
|
||||
expect(html).toContain("Quellen & Dokumentation");
|
||||
expect(html).not.toContain(">Jetzt prüfen<");
|
||||
});
|
||||
|
||||
it("<ThresholdsSection> renders heading and embeds both editors (v1.4.34 IW-D merge)", () => {
|
||||
|
||||
@@ -3,15 +3,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowUpCircle,
|
||||
BookOpen,
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
GitBranch,
|
||||
Info,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -69,6 +67,51 @@ function writeLastCheckedISO(iso: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.36 QA H7 — keyboard- and touch-accessible update badge.
|
||||
*
|
||||
* Renders as a 44 px-square hit target (anchor when a release URL is
|
||||
* known, span fallback otherwise) with a focus-visible ring, semantic
|
||||
* `text-primary` colour, and a real `aria-label` describing the
|
||||
* available version. Exported so the SSR test can pin the contract
|
||||
* without driving the parent's auto-check effect.
|
||||
*/
|
||||
export function UpdateBadge({
|
||||
latestTag,
|
||||
htmlUrl,
|
||||
ariaLabel,
|
||||
}: {
|
||||
latestTag: string;
|
||||
htmlUrl: string | null;
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
if (htmlUrl) {
|
||||
return (
|
||||
<a
|
||||
href={htmlUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
className="text-primary focus-visible:ring-ring inline-flex min-h-11 min-w-11 items-center justify-center rounded-md hover:opacity-80 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
>
|
||||
<ArrowUpCircle className="h-4 w-4" />
|
||||
<span className="sr-only">{latestTag}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
className="text-primary inline-flex min-h-11 min-w-11 items-center justify-center"
|
||||
>
|
||||
<ArrowUpCircle className="h-4 w-4" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function AboutSection() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
@@ -85,16 +128,15 @@ export function AboutSection() {
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const [checking, setChecking] = useState(false);
|
||||
// v1.4.36 W4f — the explicit "Check for updates" button is gone;
|
||||
// only the 24 h auto-check stays. When the auto-check reports
|
||||
// `newer_available`, a subtle upward-arrow badge appears next to
|
||||
// the version line with the latest tag in its tooltip.
|
||||
const [updateResult, setUpdateResult] = useState<CheckUpdatesResult | null>(
|
||||
null,
|
||||
);
|
||||
const [lastCheckedISO, setLastCheckedISO] = useState<string | null>(() =>
|
||||
readLastCheckedISO(),
|
||||
);
|
||||
|
||||
async function runCheck(): Promise<void> {
|
||||
setChecking(true);
|
||||
try {
|
||||
const res = await fetch("/api/version/check-updates");
|
||||
if (!res.ok) {
|
||||
@@ -109,9 +151,7 @@ export function AboutSection() {
|
||||
const result = json.data as CheckUpdatesResult;
|
||||
setUpdateResult(result);
|
||||
if (result.status !== "unknown") {
|
||||
const iso = new Date().toISOString();
|
||||
writeLastCheckedISO(iso);
|
||||
setLastCheckedISO(iso);
|
||||
writeLastCheckedISO(new Date().toISOString());
|
||||
}
|
||||
} catch {
|
||||
setUpdateResult({
|
||||
@@ -119,14 +159,12 @@ export function AboutSection() {
|
||||
current: version?.version ?? "",
|
||||
reason: "network_error",
|
||||
});
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-check on mount when the last successful check is older than the
|
||||
// refresh interval (or has never run). Keeps the UI populated without
|
||||
// requiring a click — but won't spam GitHub on every navigation.
|
||||
// refresh interval (or has never run). Keeps the badge fresh without a
|
||||
// click — but won't spam GitHub on every navigation.
|
||||
// The setState inside runCheck() is intentional: the check is the
|
||||
// whole point of the effect, and the staleness gate prevents a
|
||||
// cascading-render loop because the timestamp resets after the run.
|
||||
@@ -184,6 +222,23 @@ export function AboutSection() {
|
||||
{t("settings.about.version")}
|
||||
</dt>
|
||||
<dd className="font-mono font-medium">v{version.version}</dd>
|
||||
{/* v1.4.36 W4f — subtle upward-arrow badge that
|
||||
appears only when the 24 h auto-check reports a
|
||||
newer release. Hover/tap reveals the latest tag via
|
||||
the title attribute (read by screen readers + as a
|
||||
native tooltip on desktop, and announced inline as
|
||||
the link copy on mobile). No bouncing, no badge
|
||||
counter, no manual recheck button — the badge is
|
||||
the entire surface. */}
|
||||
{updateResult?.status === "newer_available" && (
|
||||
<UpdateBadge
|
||||
latestTag={updateResult.latest_tag}
|
||||
htmlUrl={updateResult.html_url}
|
||||
ariaLabel={t("settings.about.newerAvailable", {
|
||||
tag: updateResult.latest_tag,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-baseline gap-2">
|
||||
@@ -205,8 +260,7 @@ export function AboutSection() {
|
||||
{version.builtAt && (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<dt className="text-muted-foreground text-xs tracking-wide uppercase">
|
||||
{t("settings.about.builtAt", { time: "" }).trim() ||
|
||||
t("settings.about.gitSha")}
|
||||
{t("settings.about.builtAtLabel")}
|
||||
</dt>
|
||||
<dd className="text-muted-foreground">
|
||||
{fmt.date(version.builtAt)}
|
||||
@@ -262,91 +316,16 @@ export function AboutSection() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Updates — proper heading + last-check timestamp + manual button
|
||||
that proxies through `/api/version/check-updates`. The v1.4.2
|
||||
version called `api.github.com` directly from the browser, which
|
||||
the production CSP blocked silently — that's why "nothing
|
||||
happened" when the user clicked the button. */}
|
||||
{version && (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<RefreshCw className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("settings.about.updatesHeading")}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={runCheck}
|
||||
disabled={checking}
|
||||
>
|
||||
{checking ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{checking
|
||||
? t("settings.about.checking")
|
||||
: t("settings.about.checkUpdates")}
|
||||
</Button>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{lastCheckedISO
|
||||
? t("settings.about.lastChecked", {
|
||||
time: fmt.dateTime(lastCheckedISO),
|
||||
})
|
||||
: t("settings.about.lastCheckedNever")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{updateResult?.status === "up_to_date" && (
|
||||
<p
|
||||
role="status"
|
||||
className="text-dracula-green mt-3 flex items-center gap-1.5 text-sm"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
{t("settings.about.upToDate")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{updateResult?.status === "newer_available" && (
|
||||
<p role="status" className="mt-3 flex items-center gap-1.5 text-sm">
|
||||
<Sparkles className="text-dracula-purple h-4 w-4" />
|
||||
{updateResult.html_url ? (
|
||||
<a
|
||||
href={updateResult.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline-offset-2 hover:underline"
|
||||
>
|
||||
{t("settings.about.newerAvailable", {
|
||||
tag: updateResult.latest_tag,
|
||||
})}
|
||||
</a>
|
||||
) : (
|
||||
<span>
|
||||
{t("settings.about.newerAvailable", {
|
||||
tag: updateResult.latest_tag,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{updateResult?.status === "unknown" && (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-destructive mt-3 flex items-center gap-1.5 text-sm"
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
{t("settings.about.checkFailed")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* v1.4.36 W4f — the dedicated "Updates" card with the
|
||||
manual "Check for updates" button is gone. The 24 h auto-
|
||||
check still runs on mount and writes its result into the
|
||||
local `updateResult` state; the consequence renders as the
|
||||
subtle upward-arrow badge next to the version line above
|
||||
rather than as a separate panel. The card-level last-check
|
||||
timestamp, success / failure states, and the explicit
|
||||
button were collectively never the point of the surface —
|
||||
the only signal users ever cared about was "is a newer
|
||||
release out?", and the badge answers that with no clicks. */}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -164,7 +164,11 @@ export function SettingsShell({ active, children }: SettingsShellProps) {
|
||||
if (!active) return;
|
||||
active.scrollIntoView({
|
||||
block: "nearest",
|
||||
inline: "center",
|
||||
// v1.4.36 W4b — `inline: "start"` pins the active chip to the
|
||||
// left edge of the scroller. `inline: "center"` over-scrolled
|
||||
// and the first one or two chips were unreachable on narrow
|
||||
// viewports.
|
||||
inline: "start",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, [activeSlug]);
|
||||
|
||||
@@ -170,6 +170,65 @@ describe("buildCoachSnapshot", () => {
|
||||
expect(parsed.scope.window).toBe("last30days");
|
||||
});
|
||||
|
||||
// v1.4.36 W3 T2 — `medications` exclusion drops the GLP-1 weeklyContext
|
||||
// block + the compliance source so no medication data reaches the
|
||||
// prompt. Empty-data behaviour: when the user has no GLP-1 medication
|
||||
// the block is absent anyway (this test only proves the toggle path).
|
||||
it("omits weeklyContext.glp1 when excludeMetrics contains 'medications'", async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
coachPrefsJson: { excludeMetrics: ["medications"] },
|
||||
timezone: "Europe/Berlin",
|
||||
});
|
||||
const out = await buildCoachSnapshot("user-1");
|
||||
const parsed = JSON.parse(out.snapshotJson);
|
||||
expect(parsed.weeklyContext).toBeUndefined();
|
||||
expect(parsed.compliance).toBeUndefined();
|
||||
expect(parsed.scope.sources).not.toContain("compliance");
|
||||
});
|
||||
|
||||
// v1.4.36 W3 T2 — `anthropometrics` exclusion drops the profile
|
||||
// block even when features.context has populated fields.
|
||||
it("omits anthropometrics when excludeMetrics contains 'anthropometrics'", async () => {
|
||||
featuresMock.mockResolvedValue({
|
||||
context: { heightCm: 180, ageYears: 45, gender: "MALE" },
|
||||
});
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
coachPrefsJson: { excludeMetrics: ["anthropometrics"] },
|
||||
timezone: "Europe/Berlin",
|
||||
});
|
||||
const out = await buildCoachSnapshot("user-1");
|
||||
const parsed = JSON.parse(out.snapshotJson);
|
||||
expect(parsed.anthropometrics).toBeUndefined();
|
||||
});
|
||||
|
||||
// v1.4.36 W3 T2 — anthropometrics block is added when features.context
|
||||
// has at least one non-null field AND the exclusion is off.
|
||||
it("includes anthropometrics when context has data and exclusion is off", async () => {
|
||||
featuresMock.mockResolvedValue({
|
||||
context: { heightCm: 180, ageYears: 45, gender: "MALE" },
|
||||
});
|
||||
const out = await buildCoachSnapshot("user-1");
|
||||
const parsed = JSON.parse(out.snapshotJson);
|
||||
expect(parsed.anthropometrics).toEqual({
|
||||
heightCm: 180,
|
||||
ageYears: 45,
|
||||
gender: "MALE",
|
||||
});
|
||||
});
|
||||
|
||||
// v1.4.36 W3 T2 — empty-block omit: when every anthropometric field
|
||||
// is null the block is dropped entirely, not emitted as a labelled
|
||||
// null-trio that would render as `Hier sind die Profildaten: [keine]`
|
||||
// in the eventual prompt.
|
||||
it("drops anthropometrics when every field is null", async () => {
|
||||
featuresMock.mockResolvedValue({
|
||||
context: { heightCm: null, ageYears: null, gender: null },
|
||||
});
|
||||
const out = await buildCoachSnapshot("user-1");
|
||||
const parsed = JSON.parse(out.snapshotJson);
|
||||
expect(parsed.anthropometrics).toBeUndefined();
|
||||
});
|
||||
|
||||
it("scope-only-mood pulls just mood data, no measurements query", async () => {
|
||||
featuresMock.mockResolvedValue({
|
||||
mood: { avg30: 4.2, coverage: { count: 12 } },
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type CoachExcludeMetric,
|
||||
} from "@/lib/validations/coach-prefs";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
import { compactSections } from "@/lib/insights/prompt-compact";
|
||||
import { buildGlp1SnapshotBlock } from "./glp1-snapshot";
|
||||
import type {
|
||||
CoachProvenance,
|
||||
@@ -395,15 +396,30 @@ async function buildCoachSnapshotImpl(
|
||||
const prefs = parseCoachPrefs(prefsRow?.coachPrefsJson);
|
||||
const userTz = prefsRow?.timezone ?? DEFAULT_TIMEZONE;
|
||||
const excluded = new Set<CoachExcludeMetric>(prefs.excludeMetrics);
|
||||
// v1.4.36 W3 T2 — `medications` and `anthropometrics` are
|
||||
// exclude-only toggles (not in `CoachScopeSource`); they gate the
|
||||
// GLP-1 weeklyContext / compliance branch and the anthropometrics
|
||||
// block respectively. The mapping below treats them as additive to
|
||||
// the existing source-level exclusions.
|
||||
const excludesMedications = excluded.has("medications");
|
||||
const excludesAnthropometrics = excluded.has("anthropometrics");
|
||||
const sources = new Set<CoachScopeSource>();
|
||||
for (const src of scopedSources) {
|
||||
// The `excludeMetrics` enum is a strict subset of `CoachScopeSource`
|
||||
// (every excludable key is also a valid scope source); this cast is
|
||||
// safe and the runtime check is just defence-in-depth.
|
||||
// The `excludeMetrics` enum is a superset of `CoachScopeSource` now
|
||||
// (medications + anthropometrics live on the exclude-only side);
|
||||
// the cast was safe before v1.4.36 because the enums matched 1:1,
|
||||
// and the runtime `excluded.has` check still only catches the
|
||||
// overlapping members.
|
||||
if (!excluded.has(src as unknown as CoachExcludeMetric)) {
|
||||
sources.add(src);
|
||||
}
|
||||
}
|
||||
// `medications` exclusion also drops the compliance source so the
|
||||
// intake-event branch below short-circuits — keeps the contract
|
||||
// consistent (excluding medications == no medication data at all).
|
||||
if (excludesMedications) {
|
||||
sources.delete("compliance");
|
||||
}
|
||||
|
||||
const windowDays = windowToDays(window);
|
||||
const features = await extractFeatures(userId, false, {
|
||||
@@ -756,10 +772,37 @@ async function buildCoachSnapshotImpl(
|
||||
// Coach's GROUND RULE 9 forbids dose prescriptions — this block
|
||||
// exists so the reply can SAY "your Mounjaro 7.5 mg" instead of
|
||||
// "your medication", never to make recommendations.
|
||||
const glp1Block = await buildGlp1SnapshotBlock(userId, now);
|
||||
if (glp1Block) {
|
||||
snapshot.weeklyContext = { glp1: glp1Block };
|
||||
metrics.add("compliance");
|
||||
// v1.4.36 W3 T2 — gated on `medications` exclusion. When excluded
|
||||
// we skip the Prisma lookup entirely so the read cost vanishes too.
|
||||
if (!excludesMedications) {
|
||||
const glp1Block = await buildGlp1SnapshotBlock(userId, now);
|
||||
if (glp1Block) {
|
||||
snapshot.weeklyContext = { glp1: glp1Block };
|
||||
metrics.add("compliance");
|
||||
}
|
||||
}
|
||||
|
||||
// v1.4.36 W3 T2 — anthropometrics block (height / age / gender).
|
||||
// Sourced from `features.context`, which already reads
|
||||
// `User.heightCm / dateOfBirth / gender`. Gated on the
|
||||
// `anthropometrics` exclusion AND on at least one non-null field
|
||||
// — accounts with no profile info never see an empty block. The
|
||||
// `ctx?` guard tolerates mocked feature shapes in the test suite
|
||||
// that don't populate the `context` object.
|
||||
if (!excludesAnthropometrics) {
|
||||
const ctx = features.context;
|
||||
if (
|
||||
ctx &&
|
||||
(ctx.heightCm !== null ||
|
||||
ctx.ageYears !== null ||
|
||||
ctx.gender !== null)
|
||||
) {
|
||||
snapshot.anthropometrics = {
|
||||
heightCm: ctx.heightCm,
|
||||
ageYears: ctx.ageYears,
|
||||
gender: ctx.gender,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(snapshot).length === 0) {
|
||||
@@ -775,8 +818,15 @@ async function buildCoachSnapshotImpl(
|
||||
timelineRecentDays: DAILY_TIMELINE_DAYS,
|
||||
};
|
||||
|
||||
// v1.4.36 W3 T4 — compactSections drops any zero-row block before
|
||||
// serialisation so the prompt never carries a labelled-empty key.
|
||||
// The snapshot is built conditionally above so most empty paths are
|
||||
// already skipped, but the helper catches future regressions and
|
||||
// matches the contract the /insights/generate route applies on its
|
||||
// side of the prompt.
|
||||
const compactSnapshot = compactSections(snapshot);
|
||||
return {
|
||||
snapshotJson: JSON.stringify(snapshot, null, 2),
|
||||
snapshotJson: JSON.stringify(compactSnapshot, null, 2),
|
||||
provenance: {
|
||||
windows: Array.from(windows),
|
||||
metrics: Array.from(metrics),
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
/**
|
||||
* v1.4.33 C1 — unit-level pin for the slim summaries slice. Heavier
|
||||
* integration coverage lives in `tests/integration/analytics-summaries-slice.test.ts`
|
||||
* Unit-level pin for the slim summaries slice. Heavier integration
|
||||
* coverage lives in `tests/integration/analytics-summaries-slice.test.ts`
|
||||
* (real Postgres, real `regr_slope`); this file mocks `$queryRaw` so
|
||||
* the slope/round/empty contracts are pinned without a container.
|
||||
* the slope/round/empty/path-selection contracts are pinned without
|
||||
* a container.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
$queryRaw: vi.fn(),
|
||||
// v1.4.35 — slim slice now reads DAY buckets from
|
||||
// `measurement_rollups` for the parity-checked composition. The
|
||||
// freshness watermark inside `ensureUserRollupsFresh` also pokes
|
||||
// `measurementRollup.findFirst`; mock both so the helper runs
|
||||
// without a container.
|
||||
measurement: { findFirst: vi.fn() },
|
||||
// v1.4.36 — slim slice reads DAY buckets from `measurement_rollups`
|
||||
// on the happy path. The freshness watermark inside
|
||||
// `ensureUserRollupsFresh` also pokes `measurementRollup.findFirst`;
|
||||
// mock both so the helper runs without a container.
|
||||
measurementRollup: {
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
@@ -29,6 +30,8 @@ import { prisma } from "@/lib/db";
|
||||
import { computeSummariesSlice } from "../summaries-slice";
|
||||
|
||||
const RAW = prisma.$queryRaw as unknown as ReturnType<typeof vi.fn>;
|
||||
const MEASUREMENT_FIND_FIRST =
|
||||
prisma.measurement.findFirst as unknown as ReturnType<typeof vi.fn>;
|
||||
const ROLLUP_FIND_MANY =
|
||||
prisma.measurementRollup.findMany as unknown as ReturnType<typeof vi.fn>;
|
||||
const ROLLUP_FIND_FIRST =
|
||||
@@ -36,13 +39,12 @@ const ROLLUP_FIND_FIRST =
|
||||
|
||||
beforeEach(() => {
|
||||
RAW.mockReset();
|
||||
MEASUREMENT_FIND_FIRST.mockReset();
|
||||
ROLLUP_FIND_MANY.mockReset();
|
||||
ROLLUP_FIND_FIRST.mockReset();
|
||||
// Defaults: empty rollup buckets so the parity check falls back to
|
||||
// live SQL (the pre-backfill behaviour). Individual tests opt in
|
||||
// to rollup-derived values by overriding the mock.
|
||||
ROLLUP_FIND_MANY.mockResolvedValue([]);
|
||||
ROLLUP_FIND_FIRST.mockResolvedValue(null);
|
||||
MEASUREMENT_FIND_FIRST.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -50,178 +52,242 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("computeSummariesSlice", () => {
|
||||
it("returns the empty-summary skeleton when the user has no rows", async () => {
|
||||
// Pass 1 (aggregates) returns empty, pass 2 (latest) returns empty.
|
||||
// Pass 3 (rollup.findMany) is defaulted to [] in beforeEach.
|
||||
RAW.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
||||
describe("cold fallback — empty rollup table", () => {
|
||||
it("returns the empty-summary skeleton when the user has no rows", async () => {
|
||||
// 1. per-type coverage probe — empty ⇒ cold path.
|
||||
// 2. heavy aggregate ($queryRaw) — empty.
|
||||
// 3. latests ($queryRaw) — empty.
|
||||
RAW.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await computeSummariesSlice("user-1");
|
||||
const result = await computeSummariesSlice("user-1");
|
||||
|
||||
// Every enum option is seeded so consumers can read
|
||||
// `summaries.WEIGHT` and get a stable empty shape.
|
||||
expect(result.summaries.WEIGHT).toEqual({
|
||||
count: 0,
|
||||
latest: null,
|
||||
min: null,
|
||||
max: null,
|
||||
mean: null,
|
||||
avg7: null,
|
||||
avg30: null,
|
||||
slope7: null,
|
||||
slope30: null,
|
||||
slope90: null,
|
||||
anomalyCount: 0,
|
||||
avg30LastMonth: null,
|
||||
avg30LastYear: null,
|
||||
});
|
||||
expect(result.bmi).toBeNull();
|
||||
// Both SQL passes fired.
|
||||
expect(RAW).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("maps a populated aggregate row into the DataSummary shape", async () => {
|
||||
RAW.mockResolvedValueOnce([
|
||||
{
|
||||
type: "WEIGHT",
|
||||
count: BigInt(42),
|
||||
min_value: 79.2,
|
||||
max_value: 84.1,
|
||||
mean_value: 82.05,
|
||||
avg7: 81.9,
|
||||
avg30: 82.1,
|
||||
slope7: -0.014,
|
||||
r2_7: 0.65,
|
||||
slope30: -0.005,
|
||||
r2_30: 0.42,
|
||||
slope90: 0.001,
|
||||
r2_90: 0.12,
|
||||
},
|
||||
]).mockResolvedValueOnce([
|
||||
{ type: "WEIGHT", value: 81.4, measured_at: new Date() },
|
||||
]);
|
||||
|
||||
const result = await computeSummariesSlice("user-1");
|
||||
const weight = result.summaries.WEIGHT;
|
||||
|
||||
expect(weight.count).toBe(42);
|
||||
expect(weight.latest).toBe(81.4);
|
||||
expect(weight.min).toBe(79.2);
|
||||
expect(weight.max).toBe(84.1);
|
||||
expect(weight.mean).toBe(82.05);
|
||||
expect(weight.avg7).toBe(81.9);
|
||||
expect(weight.avg30).toBe(82.1);
|
||||
expect(weight.anomalyCount).toBe(0);
|
||||
expect(weight.avg30LastMonth).toBeNull();
|
||||
expect(weight.avg30LastYear).toBeNull();
|
||||
// Slope direction respects the 0.01-units-per-day "stable"
|
||||
// threshold the JS helper uses. -0.014 → "down", -0.005 →
|
||||
// "stable", 0.001 → "stable".
|
||||
expect(weight.slope7).toEqual({
|
||||
slope: -0.014,
|
||||
direction: "down",
|
||||
confidence: 0.65,
|
||||
});
|
||||
expect(weight.slope30).toEqual({
|
||||
slope: -0.005,
|
||||
direction: "stable",
|
||||
confidence: 0.42,
|
||||
});
|
||||
expect(weight.slope90).toEqual({
|
||||
slope: 0.001,
|
||||
direction: "stable",
|
||||
confidence: 0.12,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a null slope tuple when the SQL slope is null (insufficient rows)", async () => {
|
||||
RAW.mockResolvedValueOnce([
|
||||
{
|
||||
type: "PULSE",
|
||||
count: BigInt(1),
|
||||
min_value: 72,
|
||||
max_value: 72,
|
||||
mean_value: 72,
|
||||
avg7: 72,
|
||||
avg30: 72,
|
||||
// regr_slope returns NULL when n < 2.
|
||||
slope7: null,
|
||||
r2_7: null,
|
||||
slope30: null,
|
||||
r2_30: null,
|
||||
slope90: null,
|
||||
r2_90: null,
|
||||
},
|
||||
]).mockResolvedValueOnce([
|
||||
{ type: "PULSE", value: 72, measured_at: new Date() },
|
||||
]);
|
||||
|
||||
const result = await computeSummariesSlice("user-1");
|
||||
expect(result.summaries.PULSE.slope7).toBeNull();
|
||||
expect(result.summaries.PULSE.slope30).toBeNull();
|
||||
expect(result.summaries.PULSE.slope90).toBeNull();
|
||||
});
|
||||
|
||||
// v1.4.34 IW-B — surface the per-type freshness map so the
|
||||
// dashboard tile strip can render an "Letzter Wert vor Xd" caption
|
||||
// even when the consumer read from the slim slice (the path the
|
||||
// tile-strip pre-fetch uses on cold paint).
|
||||
it("surfaces lastSeenByType from the DISTINCT ON pass's measured_at", async () => {
|
||||
const tenDaysAgo = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000);
|
||||
RAW.mockResolvedValueOnce([
|
||||
{
|
||||
type: "WEIGHT",
|
||||
count: BigInt(5),
|
||||
min_value: 80,
|
||||
max_value: 84,
|
||||
mean_value: 82,
|
||||
expect(result.summaries.WEIGHT).toEqual({
|
||||
count: 0,
|
||||
latest: null,
|
||||
min: null,
|
||||
max: null,
|
||||
mean: null,
|
||||
avg7: null,
|
||||
avg30: 82,
|
||||
avg30: null,
|
||||
slope7: null,
|
||||
r2_7: null,
|
||||
slope30: 0.005,
|
||||
r2_30: 0.2,
|
||||
slope30: null,
|
||||
slope90: null,
|
||||
r2_90: null,
|
||||
},
|
||||
]).mockResolvedValueOnce([
|
||||
{ type: "WEIGHT", value: 82.3, measured_at: tenDaysAgo },
|
||||
]);
|
||||
anomalyCount: 0,
|
||||
avg30LastMonth: null,
|
||||
avg30LastYear: null,
|
||||
});
|
||||
expect(result.bmi).toBeNull();
|
||||
expect(RAW).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
const result = await computeSummariesSlice("user-1");
|
||||
const ws = result.lastSeenByType.WEIGHT;
|
||||
expect(ws).not.toBeNull();
|
||||
expect(ws?.daysAgo).toBeGreaterThanOrEqual(9);
|
||||
expect(ws?.daysAgo).toBeLessThanOrEqual(11);
|
||||
expect(ws?.lastSeenAt).toBe(tenDaysAgo.toISOString());
|
||||
// Types the user never logged report null so call sites can
|
||||
// fall through without painting a caption.
|
||||
expect(result.lastSeenByType.PULSE).toBeNull();
|
||||
it("maps a populated heavy aggregate row into the DataSummary shape on cold path", async () => {
|
||||
// Coverage probe returns WEIGHT with no buckets → cold path.
|
||||
RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: false }])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
type: "WEIGHT",
|
||||
count: BigInt(42),
|
||||
min_value: 79.2,
|
||||
max_value: 84.1,
|
||||
mean_value: 82.05,
|
||||
avg7: 81.9,
|
||||
avg30: 82.1,
|
||||
slope7: -0.014,
|
||||
r2_7: 0.65,
|
||||
slope30: -0.005,
|
||||
r2_30: 0.42,
|
||||
slope90: 0.001,
|
||||
r2_90: 0.12,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ type: "WEIGHT", value: 81.4, measured_at: new Date() },
|
||||
]);
|
||||
|
||||
const result = await computeSummariesSlice("user-1");
|
||||
const weight = result.summaries.WEIGHT;
|
||||
|
||||
expect(weight.count).toBe(42);
|
||||
expect(weight.latest).toBe(81.4);
|
||||
expect(weight.min).toBe(79.2);
|
||||
expect(weight.max).toBe(84.1);
|
||||
expect(weight.mean).toBe(82.05);
|
||||
expect(weight.avg7).toBe(81.9);
|
||||
expect(weight.avg30).toBe(82.1);
|
||||
expect(weight.anomalyCount).toBe(0);
|
||||
expect(weight.avg30LastMonth).toBeNull();
|
||||
expect(weight.avg30LastYear).toBeNull();
|
||||
expect(weight.slope7).toEqual({
|
||||
slope: -0.014,
|
||||
direction: "down",
|
||||
confidence: 0.65,
|
||||
});
|
||||
expect(weight.slope30).toEqual({
|
||||
slope: -0.005,
|
||||
direction: "stable",
|
||||
confidence: 0.42,
|
||||
});
|
||||
expect(weight.slope90).toEqual({
|
||||
slope: 0.001,
|
||||
direction: "stable",
|
||||
confidence: 0.12,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a null slope tuple when the SQL slope is null (insufficient rows)", async () => {
|
||||
RAW.mockResolvedValueOnce([{ type: "PULSE", has_buckets: false }])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
type: "PULSE",
|
||||
count: BigInt(1),
|
||||
min_value: 72,
|
||||
max_value: 72,
|
||||
mean_value: 72,
|
||||
avg7: 72,
|
||||
avg30: 72,
|
||||
slope7: null,
|
||||
r2_7: null,
|
||||
slope30: null,
|
||||
r2_30: null,
|
||||
slope90: null,
|
||||
r2_90: null,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ type: "PULSE", value: 72, measured_at: new Date() },
|
||||
]);
|
||||
|
||||
const result = await computeSummariesSlice("user-1");
|
||||
expect(result.summaries.PULSE.slope7).toBeNull();
|
||||
expect(result.summaries.PULSE.slope30).toBeNull();
|
||||
expect(result.summaries.PULSE.slope90).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces lastSeenByType from the DISTINCT ON pass's measured_at", async () => {
|
||||
const tenDaysAgo = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000);
|
||||
RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: false }])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
type: "WEIGHT",
|
||||
count: BigInt(5),
|
||||
min_value: 80,
|
||||
max_value: 84,
|
||||
mean_value: 82,
|
||||
avg7: null,
|
||||
avg30: 82,
|
||||
slope7: null,
|
||||
r2_7: null,
|
||||
slope30: 0.005,
|
||||
r2_30: 0.2,
|
||||
slope90: null,
|
||||
r2_90: null,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ type: "WEIGHT", value: 82.3, measured_at: tenDaysAgo },
|
||||
]);
|
||||
|
||||
const result = await computeSummariesSlice("user-1");
|
||||
const ws = result.lastSeenByType.WEIGHT;
|
||||
expect(ws).not.toBeNull();
|
||||
expect(ws?.daysAgo).toBeGreaterThanOrEqual(9);
|
||||
expect(ws?.daysAgo).toBeLessThanOrEqual(11);
|
||||
expect(ws?.lastSeenAt).toBe(tenDaysAgo.toISOString());
|
||||
expect(result.lastSeenByType.PULSE).toBeNull();
|
||||
});
|
||||
|
||||
it("seeds the latest value from the DISTINCT ON pass per type", async () => {
|
||||
RAW.mockResolvedValueOnce([{ type: "PULSE", has_buckets: false }])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
type: "PULSE",
|
||||
count: BigInt(3),
|
||||
min_value: 60,
|
||||
max_value: 95,
|
||||
mean_value: 77,
|
||||
avg7: 77,
|
||||
avg30: 77,
|
||||
slope7: 0,
|
||||
r2_7: 0,
|
||||
slope30: 0,
|
||||
r2_30: 0,
|
||||
slope90: 0,
|
||||
r2_90: 0,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ type: "PULSE", value: 88, measured_at: new Date() },
|
||||
]);
|
||||
|
||||
const result = await computeSummariesSlice("user-1");
|
||||
expect(result.summaries.PULSE.latest).toBe(88);
|
||||
expect(result.summaries.PULSE.max).toBe(95);
|
||||
});
|
||||
});
|
||||
|
||||
it("seeds the latest value from the DISTINCT ON pass per type", async () => {
|
||||
RAW.mockResolvedValueOnce([
|
||||
{
|
||||
type: "PULSE",
|
||||
count: BigInt(3),
|
||||
min_value: 60,
|
||||
max_value: 95,
|
||||
mean_value: 77,
|
||||
avg7: 77,
|
||||
avg30: 77,
|
||||
slope7: 0,
|
||||
r2_7: 0,
|
||||
slope30: 0,
|
||||
r2_30: 0,
|
||||
slope90: 0,
|
||||
r2_90: 0,
|
||||
},
|
||||
]).mockResolvedValueOnce([
|
||||
{ type: "PULSE", value: 88, measured_at: new Date() },
|
||||
]);
|
||||
describe("rollup-fresh happy path", () => {
|
||||
it("composes count/min/max/mean from DAY buckets without running the heavy aggregate", async () => {
|
||||
// 1. per-type coverage probe — WEIGHT fully covered ⇒ happy path.
|
||||
// 2. narrow aggregate ($queryRaw) — windowed/regression only.
|
||||
// 3. latests ($queryRaw).
|
||||
RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
type: "WEIGHT",
|
||||
avg7: 82,
|
||||
avg30: 82.5,
|
||||
slope7: 0.02,
|
||||
r2_7: 0.5,
|
||||
slope30: 0.01,
|
||||
r2_30: 0.4,
|
||||
slope90: 0.005,
|
||||
r2_90: 0.2,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ type: "WEIGHT", value: 82.7, measured_at: new Date() },
|
||||
]);
|
||||
|
||||
const result = await computeSummariesSlice("user-1");
|
||||
// Latest is NOT max — comes from the row at MAX(measured_at).
|
||||
expect(result.summaries.PULSE.latest).toBe(88);
|
||||
expect(result.summaries.PULSE.max).toBe(95);
|
||||
ROLLUP_FIND_MANY.mockResolvedValueOnce([
|
||||
{
|
||||
type: "WEIGHT",
|
||||
bucketStart: new Date("2026-05-10T00:00:00.000Z"),
|
||||
count: 10,
|
||||
mean: 81.0,
|
||||
minValue: 79.5,
|
||||
maxValue: 82.0,
|
||||
},
|
||||
{
|
||||
type: "WEIGHT",
|
||||
bucketStart: new Date("2026-05-11T00:00:00.000Z"),
|
||||
count: 10,
|
||||
mean: 83.0,
|
||||
minValue: 81.5,
|
||||
maxValue: 84.0,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await computeSummariesSlice("user-rollup");
|
||||
const weight = result.summaries.WEIGHT;
|
||||
|
||||
// count = 10 + 10 = 20; min/max across buckets; weighted mean.
|
||||
expect(weight.count).toBe(20);
|
||||
expect(weight.min).toBe(79.5);
|
||||
expect(weight.max).toBe(84.0);
|
||||
expect(weight.mean).toBe(82);
|
||||
expect(weight.latest).toBe(82.7);
|
||||
expect(weight.avg7).toBe(82);
|
||||
expect(weight.slope7).toEqual({
|
||||
slope: 0.02,
|
||||
direction: "up",
|
||||
confidence: 0.5,
|
||||
});
|
||||
|
||||
// probe + narrow aggregate + latests — NOT the heavy aggregate.
|
||||
expect(RAW).toHaveBeenCalledTimes(3);
|
||||
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* v1.4.33 C1 — slim `summaries` slice for `/api/analytics?slice=summaries`.
|
||||
* v1.4.36 — slim `summaries` slice for `/api/analytics?slice=summaries`.
|
||||
*
|
||||
* The default `/api/analytics` path fans out 30+ `Promise.all`-wrapped
|
||||
* chunked findMany reads (one per `MeasurementType`) so it can hand
|
||||
@@ -9,28 +9,34 @@
|
||||
* three slope tuples — every other field gets `null`-coalesced before
|
||||
* it ever paints — yet the user waits on the full chunked walk.
|
||||
*
|
||||
* This helper resolves the same `DataSummary` shape (per-type) with
|
||||
* three passes (two SQL + one rollup-table read):
|
||||
* Read shape (v1.4.36)
|
||||
* --------------------
|
||||
* v1.4.35 partially read-swapped onto the persistent `measurement_rollups`
|
||||
* table but kept the full-fat `$queryRaw` running in parallel as a
|
||||
* parity check. The heavy COUNT/MIN/MAX/AVG aggregate scanned the
|
||||
* measurements table every request even on warm rollups.
|
||||
*
|
||||
* 1. **`groupBy` + windowed aggregates** — one `$queryRaw` carrying
|
||||
* `COUNT`/`MIN`/`MAX`/`AVG`, the 7-day and 30-day `AVG` slices
|
||||
* via `FILTER`, plus `regr_slope` + `regr_r2` for the slope
|
||||
* tuples at 7d / 30d / 90d windows. Postgres folds all of this
|
||||
* into one index scan over `measurements WHERE user_id = …`.
|
||||
* The `COUNT / MIN / MAX / AVG` columns stay on this pass as
|
||||
* the live-SQL fallback for the v1.4.35 rollup parity check.
|
||||
* v1.4.36 promotes the rollup table to the canonical source on the
|
||||
* happy path: a single cheap COUNT against `measurement_rollups`
|
||||
* decides whether to read the composable stats from buckets or fall
|
||||
* back to the heavy live aggregate. When the rollup is populated:
|
||||
*
|
||||
* 2. **`DISTINCT ON (type)` latest read** — one `$queryRaw`
|
||||
* returning the most-recent `(type, value)` pair per type so the
|
||||
* slim shape can populate `latest`. `MAX(value)` would not do —
|
||||
* we need the value of the row at `MAX(measured_at)`, not the
|
||||
* largest value ever recorded.
|
||||
* 1. **DAY-bucket read** — composes `count / min / max / mean` per
|
||||
* type via `aggregateBuckets`. The four are linearly composable
|
||||
* across DAY buckets so the composed value is mathematically
|
||||
* identical to the live aggregate over the same rows.
|
||||
*
|
||||
* 3. **`measurement_rollups` DAY-bucket read** (v1.4.35) — folded
|
||||
* into `count / min / max / mean` via `aggregateBuckets`. Used
|
||||
* when the composed count agrees byte-for-byte with the live
|
||||
* `COUNT(*)`; falls back to live for accounts that never ran
|
||||
* the explicit backfill or that are mid-flight on the populator.
|
||||
* 2. **Narrow `$queryRaw`** — carries only the non-composable
|
||||
* windowed columns (`avg7`, `avg30`, and the slope tuples). The
|
||||
* heavy COUNT/MIN/MAX/AVG columns are NOT projected.
|
||||
*
|
||||
* 3. **`DISTINCT ON (type)` latest read** — most-recent `(type,
|
||||
* value, measured_at)` triplet per type for the `latest` field
|
||||
* and the `lastSeenByType` freshness map.
|
||||
*
|
||||
* On the cold fallback (no rollup rows yet) the slim slice runs the
|
||||
* legacy heavy aggregate so the response shape is correct on the very
|
||||
* first request.
|
||||
*
|
||||
* Fields the slim shape intentionally omits (the dashboard never
|
||||
* reads them on first paint):
|
||||
@@ -53,8 +59,17 @@ import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { ensureUserRollupsFresh } from "@/lib/measurements/rollups";
|
||||
import { aggregateBuckets } from "@/lib/measurements/rollup-read";
|
||||
import {
|
||||
isFullyCovered,
|
||||
probeRollupCoverage,
|
||||
} from "@/lib/measurements/rollup-coverage";
|
||||
|
||||
interface AggregateRow {
|
||||
/**
|
||||
* Heavy aggregate row — used on the cold-mount fallback path where the
|
||||
* rollup table is empty and we need every per-type column out of one
|
||||
* SQL pass for the very first request.
|
||||
*/
|
||||
interface HeavyAggregateRow {
|
||||
type: string;
|
||||
count: bigint;
|
||||
min_value: number | null;
|
||||
@@ -70,6 +85,24 @@ interface AggregateRow {
|
||||
r2_90: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow aggregate row — only the columns DAY buckets can't compose
|
||||
* linearly. The heavy `COUNT / MIN / MAX / AVG` columns are dropped
|
||||
* so the rollup-fresh read path's SQL projection is half as wide as
|
||||
* the legacy aggregate's.
|
||||
*/
|
||||
interface NarrowAggregateRow {
|
||||
type: string;
|
||||
avg7: number | null;
|
||||
avg30: number | null;
|
||||
slope7: number | null;
|
||||
r2_7: number | null;
|
||||
slope30: number | null;
|
||||
r2_30: number | null;
|
||||
slope90: number | null;
|
||||
r2_90: number | null;
|
||||
}
|
||||
|
||||
interface LatestRow {
|
||||
type: string;
|
||||
value: number;
|
||||
@@ -146,26 +179,41 @@ export interface SummariesSlice {
|
||||
export async function computeSummariesSlice(
|
||||
userId: string,
|
||||
): Promise<SummariesSlice> {
|
||||
// v1.4.35 — keep the persistent rollup table current before the
|
||||
// bucket read fires. On a warm process this is a single watermark
|
||||
// query; on a cold mount it folds the trailing 90-day window into
|
||||
// the DAY rollup so the bucket read below has something to compose.
|
||||
// Keep the persistent rollup table current before the bucket read
|
||||
// fires. On a warm process this is a single watermark query; on a
|
||||
// cold mount it folds the trailing 90-day window into the DAY
|
||||
// rollup so the bucket read below has something to compose.
|
||||
await ensureUserRollupsFresh(userId);
|
||||
|
||||
// Three passes in parallel. The first two target the same
|
||||
// `measurements (user_id, type, measured_at)` index path; the third
|
||||
// hits the much smaller `measurement_rollups` table (one row per
|
||||
// bucket, not per row). On users with a complete backfill the
|
||||
// bucket scan dominates only marginally; on un-backfilled users it
|
||||
// returns a short list and the parity check falls back to live.
|
||||
const [aggregates, latests, dayBuckets] = await Promise.all([
|
||||
prisma.$queryRaw<AggregateRow[]>`
|
||||
// v1.4.36 QA C1 — per-type coverage probe replaces the legacy global
|
||||
// COUNT. The previous gate returned true as soon as ANY type had at
|
||||
// least one DAY bucket. Pathology: a user with BP fully rolled up
|
||||
// logs their first WEIGHT measurement → the sync write-hook upserts
|
||||
// one WEIGHT DAY bucket → the global probe stayed >0 → the route
|
||||
// flipped to the bucket-derived path for WEIGHT as well → all-time
|
||||
// count collapsed to whatever the trailing window covers because the
|
||||
// narrow aggregate's windowed columns + the single fresh bucket
|
||||
// can't reconstruct the prior history. We now decide per type and
|
||||
// only take the rollup path when EVERY type the user has logged is
|
||||
// covered. Partial coverage falls back to the live aggregate so the
|
||||
// brand-new-type case stays correct.
|
||||
const coverage = await probeRollupCoverage(userId);
|
||||
if (isFullyCovered(coverage)) {
|
||||
return computeFromRollups(userId);
|
||||
}
|
||||
return computeFromLiveAggregate(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Happy path — DAY buckets carry `count / min / max / mean`; a narrow
|
||||
* `$queryRaw` carries only the windowed avgs + regression columns the
|
||||
* buckets cannot reconstruct.
|
||||
*/
|
||||
async function computeFromRollups(userId: string): Promise<SummariesSlice> {
|
||||
const [narrows, latests, dayBuckets] = await Promise.all([
|
||||
prisma.$queryRaw<NarrowAggregateRow[]>`
|
||||
SELECT
|
||||
m."type"::text AS type,
|
||||
COUNT(*) AS count,
|
||||
MIN(m."value")::double precision AS min_value,
|
||||
MAX(m."value")::double precision AS max_value,
|
||||
AVG(m."value")::double precision AS mean_value,
|
||||
AVG(m."value") FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '7 days'
|
||||
)::double precision AS avg7,
|
||||
@@ -301,26 +349,165 @@ export async function computeSummariesSlice(
|
||||
bucketsByType.set(b.type, list);
|
||||
}
|
||||
|
||||
const narrowByType = new Map<string, NarrowAggregateRow>();
|
||||
for (const row of narrows) {
|
||||
narrowByType.set(row.type, row);
|
||||
}
|
||||
|
||||
let totalRows = 0;
|
||||
for (const [type, buckets] of bucketsByType.entries()) {
|
||||
const composed = aggregateBuckets(buckets);
|
||||
if (composed.count === 0) continue;
|
||||
totalRows += composed.count;
|
||||
const narrow = narrowByType.get(type);
|
||||
const latest = latestByType.get(type) ?? null;
|
||||
summaries[type] = {
|
||||
count: composed.count,
|
||||
latest,
|
||||
min: round2(composed.min),
|
||||
max: round2(composed.max),
|
||||
mean: round2(composed.mean),
|
||||
avg7: round2(narrow?.avg7 ?? null),
|
||||
avg30: round2(narrow?.avg30 ?? null),
|
||||
slope7: buildSlope(narrow?.slope7 ?? null, narrow?.r2_7 ?? null),
|
||||
slope30: buildSlope(narrow?.slope30 ?? null, narrow?.r2_30 ?? null),
|
||||
slope90: buildSlope(narrow?.slope90 ?? null, narrow?.r2_90 ?? null),
|
||||
anomalyCount: 0,
|
||||
avg30LastMonth: null,
|
||||
avg30LastYear: null,
|
||||
};
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "analytics.get.slim" },
|
||||
meta: {
|
||||
analytics: {
|
||||
slim_summaries: {
|
||||
row_count: totalRows,
|
||||
type_count: bucketsByType.size,
|
||||
path: "rollup",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { summaries, bmi: null, lastSeenByType };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cold fallback — runs the legacy heavy aggregate when the rollup
|
||||
* table is empty for this user. Subsequent reads pick up the populated
|
||||
* rollup on `computeFromRollups`.
|
||||
*/
|
||||
async function computeFromLiveAggregate(
|
||||
userId: string,
|
||||
): Promise<SummariesSlice> {
|
||||
const [aggregates, latests] = await Promise.all([
|
||||
prisma.$queryRaw<HeavyAggregateRow[]>`
|
||||
SELECT
|
||||
m."type"::text AS type,
|
||||
COUNT(*) AS count,
|
||||
MIN(m."value")::double precision AS min_value,
|
||||
MAX(m."value")::double precision AS max_value,
|
||||
AVG(m."value")::double precision AS mean_value,
|
||||
AVG(m."value") FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '7 days'
|
||||
)::double precision AS avg7,
|
||||
AVG(m."value") FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '30 days'
|
||||
)::double precision AS avg30,
|
||||
REGR_SLOPE(
|
||||
m."value",
|
||||
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
|
||||
) FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '7 days'
|
||||
)::double precision AS slope7,
|
||||
REGR_R2(
|
||||
m."value",
|
||||
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
|
||||
) FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '7 days'
|
||||
)::double precision AS r2_7,
|
||||
REGR_SLOPE(
|
||||
m."value",
|
||||
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
|
||||
) FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '30 days'
|
||||
)::double precision AS slope30,
|
||||
REGR_R2(
|
||||
m."value",
|
||||
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
|
||||
) FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '30 days'
|
||||
)::double precision AS r2_30,
|
||||
REGR_SLOPE(
|
||||
m."value",
|
||||
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
|
||||
) FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '90 days'
|
||||
)::double precision AS slope90,
|
||||
REGR_R2(
|
||||
m."value",
|
||||
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
|
||||
) FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '90 days'
|
||||
)::double precision AS r2_90
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
GROUP BY m."type"
|
||||
`,
|
||||
prisma.$queryRaw<LatestRow[]>`
|
||||
SELECT DISTINCT ON (m."type")
|
||||
m."type"::text AS type,
|
||||
m."value"::double precision AS value,
|
||||
m."measured_at" AS measured_at
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
ORDER BY m."type", m."measured_at" DESC
|
||||
`,
|
||||
]);
|
||||
|
||||
const latestByType = new Map<string, number>();
|
||||
const lastSeenAtByType = new Map<string, Date>();
|
||||
for (const row of latests) {
|
||||
latestByType.set(row.type, Number(row.value));
|
||||
if (row.measured_at) {
|
||||
lastSeenAtByType.set(row.type, new Date(row.measured_at));
|
||||
}
|
||||
}
|
||||
|
||||
const summaries: Record<string, DataSummary> = {};
|
||||
const lastSeenByType: Record<
|
||||
string,
|
||||
{ lastSeenAt: string; daysAgo: number } | null
|
||||
> = {};
|
||||
for (const type of measurementTypeEnum.options) {
|
||||
summaries[type] = emptySummary();
|
||||
lastSeenByType[type] = null;
|
||||
}
|
||||
|
||||
const nowForStaleness = Date.now();
|
||||
for (const [type, measuredAt] of lastSeenAtByType.entries()) {
|
||||
const daysAgo = Math.floor(
|
||||
(nowForStaleness - measuredAt.getTime()) / (24 * 60 * 60 * 1000),
|
||||
);
|
||||
lastSeenByType[type] = {
|
||||
lastSeenAt: measuredAt.toISOString(),
|
||||
daysAgo,
|
||||
};
|
||||
}
|
||||
|
||||
let totalRows = 0;
|
||||
for (const row of aggregates) {
|
||||
const liveCount = Number(row.count);
|
||||
// Compose count/min/max/mean from the DAY buckets when the
|
||||
// composed count agrees with the live `COUNT(*)`. The slim slice's
|
||||
// window is all-time, so a divergent count signals either an
|
||||
// un-backfilled account (rollup only covers the trailing 90d
|
||||
// `ensureUserRollupsFresh` warmed up) or a mid-flight populator —
|
||||
// both cases want the live SQL values, which we already have.
|
||||
const composed = aggregateBuckets(bucketsByType.get(row.type) ?? []);
|
||||
const useRollup = composed.count === liveCount;
|
||||
const count = useRollup ? composed.count : liveCount;
|
||||
const count = Number(row.count);
|
||||
totalRows += count;
|
||||
const latest = latestByType.get(row.type) ?? null;
|
||||
summaries[row.type] = {
|
||||
count,
|
||||
latest,
|
||||
min: useRollup ? round2(composed.min) : round2(row.min_value),
|
||||
max: useRollup ? round2(composed.max) : round2(row.max_value),
|
||||
mean: useRollup ? round2(composed.mean) : round2(row.mean_value),
|
||||
min: round2(row.min_value),
|
||||
max: round2(row.max_value),
|
||||
mean: round2(row.mean_value),
|
||||
avg7: round2(row.avg7),
|
||||
avg30: round2(row.avg30),
|
||||
slope7: buildSlope(row.slope7, row.r2_7),
|
||||
@@ -339,6 +526,7 @@ export async function computeSummariesSlice(
|
||||
slim_summaries: {
|
||||
row_count: totalRows,
|
||||
type_count: aggregates.length,
|
||||
path: "live",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Vendored
+11
@@ -31,6 +31,11 @@ export function invalidateUserMeasurements(userId: string): void {
|
||||
caches.analytics.deleteByPrefix(`${userId}|`);
|
||||
caches.achievements.deleteByPrefix(userId);
|
||||
caches.workouts.deleteByPrefix(`${userId}|`);
|
||||
// v1.4.36 W1 — measurement writes change the per-target consistency
|
||||
// strips, the in-range rates and the streak counters that
|
||||
// `/api/insights/targets` computes. Evict the user's bucket so the
|
||||
// next mount paints fresh data.
|
||||
caches.insightsTargets.deleteByPrefix(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,6 +49,9 @@ export function invalidateUserMood(userId: string): void {
|
||||
caches.moodAnalytics.deleteByPrefix(userId);
|
||||
caches.achievements.deleteByPrefix(userId);
|
||||
caches.analytics.deleteByPrefix(`${userId}|`);
|
||||
// v1.4.36 W1 — mood writes change the mood target rows on the
|
||||
// insights/targets response (MOOD_SCORE, MOOD_STABILITY).
|
||||
caches.insightsTargets.deleteByPrefix(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,6 +64,9 @@ export function invalidateUserMedications(userId: string): void {
|
||||
caches.medications.deleteByPrefix(userId);
|
||||
caches.medicationsIntake.deleteByPrefix(`${userId}|`);
|
||||
caches.achievements.deleteByPrefix(userId);
|
||||
// v1.4.36 W1 — medication writes change the MEDICATION_COMPLIANCE
|
||||
// target rollup (compliance7 / compliance30 / consistency strip).
|
||||
caches.insightsTargets.deleteByPrefix(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Vendored
+13
@@ -247,6 +247,19 @@ export const caches = {
|
||||
maxEntries: 1000,
|
||||
ttlMs: 60_000,
|
||||
}),
|
||||
/**
|
||||
* v1.4.36 W1 — `/api/insights/targets` is read on every Insights
|
||||
* mount and runs >1 s cold (it pulls 30-day measurements for every
|
||||
* type + medications + intakes + mood + glucose). 60 s TTL matches
|
||||
* the analytics cache so multiple sub-page mounts inside a minute
|
||||
* all hit a warm cache. Invalidated alongside the analytics bucket
|
||||
* on measurement / mood / medication writes.
|
||||
*/
|
||||
insightsTargets: new ServerCache<unknown>({
|
||||
name: "insightsTargets",
|
||||
maxEntries: 1000,
|
||||
ttlMs: 60_000,
|
||||
}),
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
/**
|
||||
* v1.4.35 — unit pin for `buildComprehensiveAggregate`.
|
||||
* Unit pin for `buildComprehensiveAggregate`.
|
||||
*
|
||||
* Heavier integration coverage (real Postgres, real `regr_slope`,
|
||||
* cache hit/miss assertions) lives in
|
||||
* `tests/integration/insights-comprehensive-cache.test.ts`. This file
|
||||
* mocks `$queryRaw` and Prisma's `measurement.findMany` so the shape +
|
||||
* rounding + slope-direction contracts are pinned without a container.
|
||||
* cache hit/miss assertions, byte-identical parity with live SQL)
|
||||
* lives in `tests/integration/insights-comprehensive-cache.test.ts`
|
||||
* and `tests/integration/measurement-rollups.test.ts`. This file
|
||||
* mocks `$queryRaw` and Prisma's `measurement.findMany` so the shape
|
||||
* + rounding + slope-direction + read-path-selection contracts are
|
||||
* pinned without a container.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
$queryRaw: vi.fn(),
|
||||
measurement: { findMany: vi.fn() },
|
||||
// v1.4.35 — the rollup read inside the Promise.all + the
|
||||
measurement: { findMany: vi.fn(), findFirst: vi.fn() },
|
||||
// v1.4.36 — the rollup read inside the Promise.all + the
|
||||
// freshness watermark inside `ensureUserRollupsFresh`. We mock
|
||||
// both so the unit test doesn't need a real container.
|
||||
measurementRollup: {
|
||||
@@ -33,6 +35,8 @@ import { buildComprehensiveAggregate } from "../comprehensive-aggregator";
|
||||
const RAW = prisma.$queryRaw as unknown as ReturnType<typeof vi.fn>;
|
||||
const FIND_MANY =
|
||||
prisma.measurement.findMany as unknown as ReturnType<typeof vi.fn>;
|
||||
const MEASUREMENT_FIND_FIRST =
|
||||
prisma.measurement.findFirst as unknown as ReturnType<typeof vi.fn>;
|
||||
const ROLLUP_FIND_MANY =
|
||||
prisma.measurementRollup.findMany as unknown as ReturnType<typeof vi.fn>;
|
||||
const ROLLUP_FIND_FIRST =
|
||||
@@ -41,15 +45,16 @@ const ROLLUP_FIND_FIRST =
|
||||
beforeEach(() => {
|
||||
RAW.mockReset();
|
||||
FIND_MANY.mockReset();
|
||||
MEASUREMENT_FIND_FIRST.mockReset();
|
||||
ROLLUP_FIND_MANY.mockReset();
|
||||
ROLLUP_FIND_FIRST.mockReset();
|
||||
// `ensureUserRollupsFresh` calls `prisma.measurement.findFirst`;
|
||||
// we don't have it on the mock above, so it throws and the helper
|
||||
// swallows + returns `{ recomputed: false }`. The bucket read
|
||||
// proceeds against whatever we mock on `measurementRollup.findMany`.
|
||||
// We default both to empty so individual tests opt in to data.
|
||||
// `ensureUserRollupsFresh` calls `prisma.measurement.findFirst` and
|
||||
// `prisma.measurementRollup.findFirst` in parallel. Default both to
|
||||
// null so the warm-up is a no-op; individual tests opt in to a
|
||||
// freshness mismatch by overriding the mocks.
|
||||
ROLLUP_FIND_MANY.mockResolvedValue([]);
|
||||
ROLLUP_FIND_FIRST.mockResolvedValue(null);
|
||||
MEASUREMENT_FIND_FIRST.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -57,153 +62,229 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("buildComprehensiveAggregate", () => {
|
||||
it("returns an empty bundle for a user with no measurements", async () => {
|
||||
// v1.4.35 pass order: aggregates ($queryRaw), latests ($queryRaw),
|
||||
// dayBuckets (rollup.findMany — defaulted to [] in beforeEach),
|
||||
// sysRaw (findMany), diaRaw (findMany). The `firstMeasurementAt`
|
||||
// $queryRaw is skipped when totalMeasurements === 0.
|
||||
RAW.mockResolvedValueOnce([]) // aggregates
|
||||
.mockResolvedValueOnce([]); // latests
|
||||
FIND_MANY.mockResolvedValueOnce([]) // sys
|
||||
.mockResolvedValueOnce([]); // dia
|
||||
describe("rollup-fresh happy path", () => {
|
||||
it("skips the heavy live aggregate when the rollup table is populated", async () => {
|
||||
const now = new Date();
|
||||
// 1. per-type coverage probe — WEIGHT fully covered ⇒ happy path.
|
||||
// 2. narrow aggregate ($queryRaw) — windowed/regression columns only.
|
||||
// 3. latests ($queryRaw).
|
||||
// 4. firstMeasurementAt ($queryRaw).
|
||||
RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
type: "WEIGHT",
|
||||
count: BigInt(42),
|
||||
stddev_value: 1.2,
|
||||
anomaly_count: BigInt(3),
|
||||
avg7: 81.9,
|
||||
avg30: 82.1,
|
||||
avg30_last_month: 83.0,
|
||||
slope7: -0.014,
|
||||
r2_7: 0.65,
|
||||
slope30: -0.005,
|
||||
r2_30: 0.42,
|
||||
slope90: 0.001,
|
||||
r2_90: 0.12,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ type: "WEIGHT", value: 81.4, measured_at: now },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ first_at: new Date(now.getTime() - 86400000) },
|
||||
]);
|
||||
|
||||
const result = await buildComprehensiveAggregate("user-empty");
|
||||
// DAY buckets compose to count=42, min=79.2, max=84.1, mean=82.05.
|
||||
// The summary's count/min/max/mean read from these buckets — NOT
|
||||
// from a heavy live aggregate column.
|
||||
ROLLUP_FIND_MANY.mockResolvedValueOnce([
|
||||
{
|
||||
type: "WEIGHT",
|
||||
bucketStart: new Date("2026-05-10T00:00:00.000Z"),
|
||||
count: 20,
|
||||
mean: 81.0,
|
||||
minValue: 79.2,
|
||||
maxValue: 82.5,
|
||||
},
|
||||
{
|
||||
type: "WEIGHT",
|
||||
bucketStart: new Date("2026-05-11T00:00:00.000Z"),
|
||||
count: 22,
|
||||
mean: 83.0,
|
||||
minValue: 80.0,
|
||||
maxValue: 84.1,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result.summaries).toEqual({});
|
||||
expect(result.bpRawRows.sys).toEqual([]);
|
||||
expect(result.bpRawRows.dia).toEqual([]);
|
||||
expect(result.dailyByType).toEqual({});
|
||||
expect(result.firstMeasurementAt).toBeNull();
|
||||
expect(result.totalMeasurements).toBe(0);
|
||||
// 2 $queryRaw (aggregates, latests) + 2 findMany + 1 rollup.findMany.
|
||||
// No firstMeasurementAt query when total === 0.
|
||||
expect(RAW).toHaveBeenCalledTimes(2);
|
||||
expect(FIND_MANY).toHaveBeenCalledTimes(2);
|
||||
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(1);
|
||||
FIND_MANY.mockResolvedValueOnce([]) // sys
|
||||
.mockResolvedValueOnce([]); // dia
|
||||
|
||||
const result = await buildComprehensiveAggregate("user-rollup-fresh");
|
||||
const weight = result.summaries.WEIGHT;
|
||||
|
||||
// count/min/max/mean composed from buckets — the test asserts the
|
||||
// bucket-derived values are present even though we never wired up
|
||||
// a heavy aggregate row for the parity check.
|
||||
expect(weight.count).toBe(42);
|
||||
expect(weight.min).toBe(79.2);
|
||||
expect(weight.max).toBe(84.1);
|
||||
// Weighted mean = (20 * 81 + 22 * 83) / 42 = 3446 / 42 ≈ 82.0476
|
||||
// → round2 → 82.05.
|
||||
expect(weight.mean).toBe(82.05);
|
||||
// Windowed/regression columns come from the narrow aggregate.
|
||||
expect(weight.latest).toBe(81.4);
|
||||
expect(weight.avg7).toBe(81.9);
|
||||
expect(weight.avg30).toBe(82.1);
|
||||
expect(weight.avg30LastMonth).toBe(83.0);
|
||||
expect(weight.anomalyCount).toBe(3);
|
||||
expect(weight.slope7).toEqual({
|
||||
slope: -0.014,
|
||||
direction: "down",
|
||||
confidence: 0.65,
|
||||
});
|
||||
expect(result.dailyByType.WEIGHT).toEqual([
|
||||
{ day: "2026-05-10", value: 81 },
|
||||
{ day: "2026-05-11", value: 83 },
|
||||
]);
|
||||
|
||||
// Contract pin — the heavy aggregate path is NOT exercised on the
|
||||
// rollup-fresh branch. The $queryRaw calls land on the COUNT
|
||||
// probe + narrow aggregate + DISTINCT-ON latest + first_at, NOT
|
||||
// the legacy heavy COUNT/MIN/MAX/AVG query. We assert by call
|
||||
// count + by checking that the narrow projection (no min_value /
|
||||
// max_value / mean_value cols) is what landed.
|
||||
expect(RAW).toHaveBeenCalledTimes(4);
|
||||
expect(FIND_MANY).toHaveBeenCalledTimes(2);
|
||||
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("maps a populated WEIGHT aggregate into the DataSummary shape", async () => {
|
||||
const now = new Date();
|
||||
RAW.mockResolvedValueOnce([
|
||||
{
|
||||
type: "WEIGHT",
|
||||
count: BigInt(42),
|
||||
min_value: 79.2,
|
||||
max_value: 84.1,
|
||||
mean_value: 82.05,
|
||||
stddev_value: 1.2,
|
||||
anomaly_count: BigInt(3),
|
||||
avg7: 81.9,
|
||||
avg30: 82.1,
|
||||
avg30_last_month: 83.0,
|
||||
slope7: -0.014,
|
||||
r2_7: 0.65,
|
||||
slope30: -0.005,
|
||||
r2_30: 0.42,
|
||||
slope90: 0.001,
|
||||
r2_90: 0.12,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([{ type: "WEIGHT", value: 81.4, measured_at: now }])
|
||||
.mockResolvedValueOnce([{ first_at: new Date(now.getTime() - 86400000) }]);
|
||||
// v1.4.35 — DAY buckets feed both the per-type count/min/max/mean
|
||||
// composition AND `dailyByType`. The two buckets compose to a
|
||||
// count of 42 → matches the live aggregate's BigInt(42) → the
|
||||
// parity check elects the rollup-derived values, which equal the
|
||||
// live values byte-for-byte (chosen on purpose so the assertions
|
||||
// exercise the rollup path while keeping the previous expected
|
||||
// numbers stable).
|
||||
ROLLUP_FIND_MANY.mockResolvedValueOnce([
|
||||
{
|
||||
type: "WEIGHT",
|
||||
bucketStart: new Date("2026-05-10T00:00:00.000Z"),
|
||||
count: 20,
|
||||
mean: 81.0,
|
||||
minValue: 79.2,
|
||||
maxValue: 82.5,
|
||||
},
|
||||
{
|
||||
type: "WEIGHT",
|
||||
bucketStart: new Date("2026-05-11T00:00:00.000Z"),
|
||||
count: 22,
|
||||
mean: 83.0,
|
||||
minValue: 80.0,
|
||||
maxValue: 84.1,
|
||||
},
|
||||
]);
|
||||
FIND_MANY.mockResolvedValueOnce([]) // sys
|
||||
.mockResolvedValueOnce([]); // dia
|
||||
describe("cold fallback when no rollup buckets exist", () => {
|
||||
it("returns an empty bundle for a user with no measurements and no rollups", async () => {
|
||||
// 1. per-type coverage probe — empty (no measurements) ⇒ cold path.
|
||||
// 2. heavy aggregate ($queryRaw) — empty.
|
||||
// 3. latests ($queryRaw) — empty.
|
||||
// No firstMeasurementAt query when totalMeasurements === 0.
|
||||
RAW.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([]);
|
||||
FIND_MANY.mockResolvedValueOnce([]) // sys
|
||||
.mockResolvedValueOnce([]); // dia
|
||||
|
||||
const result = await buildComprehensiveAggregate("user-pop");
|
||||
const weight = result.summaries.WEIGHT;
|
||||
const result = await buildComprehensiveAggregate("user-empty");
|
||||
|
||||
expect(weight.count).toBe(42);
|
||||
expect(weight.latest).toBe(81.4);
|
||||
// Composed from buckets: min(79.2, 80.0) = 79.2; max(82.5, 84.1) = 84.1.
|
||||
expect(weight.min).toBe(79.2);
|
||||
expect(weight.max).toBe(84.1);
|
||||
// Weighted mean = (20 * 81 + 22 * 83) / 42 = 3446 / 42 = 82.0476…,
|
||||
// round2 → 82.05. Matches the live `AVG` value of 82.05.
|
||||
expect(weight.mean).toBe(82.05);
|
||||
expect(weight.avg7).toBe(81.9);
|
||||
expect(weight.avg30).toBe(82.1);
|
||||
expect(weight.avg30LastMonth).toBe(83.0);
|
||||
// Always null in the 90-day window — preserved legacy semantics.
|
||||
expect(weight.avg30LastYear).toBeNull();
|
||||
expect(weight.anomalyCount).toBe(3);
|
||||
// Slope direction matches the JS threshold rules:
|
||||
// -0.014 → "down" (|s| >= 0.01)
|
||||
// -0.005 → "stable" (|s| < 0.01)
|
||||
// 0.001 → "stable"
|
||||
expect(weight.slope7).toEqual({
|
||||
slope: -0.014,
|
||||
direction: "down",
|
||||
confidence: 0.65,
|
||||
});
|
||||
expect(weight.slope30).toEqual({
|
||||
slope: -0.005,
|
||||
direction: "stable",
|
||||
confidence: 0.42,
|
||||
});
|
||||
expect(weight.slope90).toEqual({
|
||||
slope: 0.001,
|
||||
direction: "stable",
|
||||
confidence: 0.12,
|
||||
expect(result.summaries).toEqual({});
|
||||
expect(result.bpRawRows.sys).toEqual([]);
|
||||
expect(result.bpRawRows.dia).toEqual([]);
|
||||
expect(result.dailyByType).toEqual({});
|
||||
expect(result.firstMeasurementAt).toBeNull();
|
||||
expect(result.totalMeasurements).toBe(0);
|
||||
expect(RAW).toHaveBeenCalledTimes(3);
|
||||
expect(FIND_MANY).toHaveBeenCalledTimes(2);
|
||||
// The cold path's rollup.findMany still fires (in case some
|
||||
// buckets exist for a subset of types post-race), but returns [].
|
||||
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(result.totalMeasurements).toBe(42);
|
||||
expect(result.firstMeasurementAt).toBeInstanceOf(Date);
|
||||
// `dailyByType` is sourced from the same DAY buckets; the entries
|
||||
// are the bucket means rounded to 2 decimals, keyed on the UTC
|
||||
// bucket-start date.
|
||||
expect(result.dailyByType.WEIGHT).toEqual([
|
||||
{ day: "2026-05-10", value: 81 },
|
||||
{ day: "2026-05-11", value: 83 },
|
||||
]);
|
||||
it("runs the heavy aggregate when no rollup rows exist yet", async () => {
|
||||
const now = new Date();
|
||||
// 1. per-type coverage probe — WEIGHT measured but no buckets ⇒ cold path.
|
||||
// 2. heavy aggregate ($queryRaw) — populated.
|
||||
// 3. latests ($queryRaw) — populated.
|
||||
// 4. firstMeasurementAt ($queryRaw).
|
||||
RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: false }])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
type: "WEIGHT",
|
||||
count: BigInt(42),
|
||||
min_value: 79.2,
|
||||
max_value: 84.1,
|
||||
mean_value: 82.05,
|
||||
stddev_value: 1.2,
|
||||
anomaly_count: BigInt(3),
|
||||
avg7: 81.9,
|
||||
avg30: 82.1,
|
||||
avg30_last_month: 83.0,
|
||||
slope7: -0.014,
|
||||
r2_7: 0.65,
|
||||
slope30: -0.005,
|
||||
r2_30: 0.42,
|
||||
slope90: 0.001,
|
||||
r2_90: 0.12,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ type: "WEIGHT", value: 81.4, measured_at: now },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ first_at: new Date(now.getTime() - 86400000) },
|
||||
]);
|
||||
FIND_MANY.mockResolvedValueOnce([]) // sys
|
||||
.mockResolvedValueOnce([]); // dia
|
||||
|
||||
const result = await buildComprehensiveAggregate("user-cold");
|
||||
const weight = result.summaries.WEIGHT;
|
||||
|
||||
expect(weight.count).toBe(42);
|
||||
expect(weight.latest).toBe(81.4);
|
||||
expect(weight.min).toBe(79.2);
|
||||
expect(weight.max).toBe(84.1);
|
||||
expect(weight.mean).toBe(82.05);
|
||||
expect(weight.avg7).toBe(81.9);
|
||||
expect(weight.avg30).toBe(82.1);
|
||||
expect(weight.avg30LastMonth).toBe(83.0);
|
||||
expect(weight.avg30LastYear).toBeNull();
|
||||
expect(weight.anomalyCount).toBe(3);
|
||||
expect(weight.slope7).toEqual({
|
||||
slope: -0.014,
|
||||
direction: "down",
|
||||
confidence: 0.65,
|
||||
});
|
||||
expect(weight.slope30).toEqual({
|
||||
slope: -0.005,
|
||||
direction: "stable",
|
||||
confidence: 0.42,
|
||||
});
|
||||
expect(weight.slope90).toEqual({
|
||||
slope: 0.001,
|
||||
direction: "stable",
|
||||
confidence: 0.12,
|
||||
});
|
||||
|
||||
expect(result.totalMeasurements).toBe(42);
|
||||
expect(result.firstMeasurementAt).toBeInstanceOf(Date);
|
||||
// The cold path's `dailyByType` is sourced from the same rollup
|
||||
// findMany call (returns [] here since no buckets exist). The
|
||||
// contract is: even on cold mount, if buckets do exist for some
|
||||
// type, they feed `dailyByType` so the v1.4.35 shape is stable.
|
||||
expect(result.dailyByType.WEIGHT).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("threads sys/dia raw rows through bpRawRows so 5-min pairing survives", async () => {
|
||||
const measuredAt = new Date("2026-05-10T08:00:00Z");
|
||||
RAW.mockResolvedValueOnce([
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
count: BigInt(1),
|
||||
min_value: 120,
|
||||
max_value: 120,
|
||||
mean_value: 120,
|
||||
stddev_value: 0,
|
||||
anomaly_count: BigInt(0),
|
||||
avg7: 120,
|
||||
avg30: 120,
|
||||
avg30_last_month: null,
|
||||
slope7: null,
|
||||
r2_7: null,
|
||||
slope30: null,
|
||||
r2_30: null,
|
||||
slope90: null,
|
||||
r2_90: null,
|
||||
},
|
||||
])
|
||||
// Cold path so the heavy aggregate fires (BP type lacks coverage).
|
||||
RAW.mockResolvedValueOnce([{ type: "BLOOD_PRESSURE_SYS", has_buckets: false }])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
count: BigInt(1),
|
||||
min_value: 120,
|
||||
max_value: 120,
|
||||
mean_value: 120,
|
||||
stddev_value: 0,
|
||||
anomaly_count: BigInt(0),
|
||||
avg7: 120,
|
||||
avg30: 120,
|
||||
avg30_last_month: null,
|
||||
slope7: null,
|
||||
r2_7: null,
|
||||
slope30: null,
|
||||
r2_30: null,
|
||||
slope90: null,
|
||||
r2_90: null,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { applyInsightsExcludeFilter } from "../exclude-filter";
|
||||
import type { AggregatedFeatures } from "../features";
|
||||
|
||||
function baseFeatures(): AggregatedFeatures {
|
||||
return {
|
||||
weight: {
|
||||
latest: 82,
|
||||
avg7: 82,
|
||||
avg30: 82,
|
||||
avg90: 82,
|
||||
allTimeAvg: 82,
|
||||
allTimeMin: 80,
|
||||
allTimeMax: 84,
|
||||
slope30: 0,
|
||||
outlierCount: 0,
|
||||
bmi: 25,
|
||||
coverage: {
|
||||
count: 10,
|
||||
spanDays: 30,
|
||||
avgDaysBetween: 3,
|
||||
oldestDaysAgo: 30,
|
||||
newestDaysAgo: 1,
|
||||
},
|
||||
},
|
||||
bloodPressure: {
|
||||
avgSys30: 128,
|
||||
avgDia30: 82,
|
||||
avgSys90: 128,
|
||||
avgDia90: 82,
|
||||
allTimeAvgSys: 128,
|
||||
allTimeAvgDia: 82,
|
||||
allTimeMinSys: 110,
|
||||
allTimeMaxSys: 145,
|
||||
allTimeMinDia: 70,
|
||||
allTimeMaxDia: 90,
|
||||
slopeSys30: 0,
|
||||
slopeDia30: 0,
|
||||
sdSys30: 5,
|
||||
sdDia30: 3,
|
||||
pulsePressure30: 46,
|
||||
pctInTarget: 70,
|
||||
coverage: {
|
||||
count: 10,
|
||||
spanDays: 30,
|
||||
avgDaysBetween: 3,
|
||||
oldestDaysAgo: 30,
|
||||
newestDaysAgo: 1,
|
||||
},
|
||||
},
|
||||
medications: [
|
||||
{
|
||||
name: "Mounjaro",
|
||||
dose: "7.5mg",
|
||||
category: "GLP1",
|
||||
compliance7: 100,
|
||||
compliance30: 95,
|
||||
compliance90: 92,
|
||||
streak: 30,
|
||||
missedLast7: 0,
|
||||
},
|
||||
],
|
||||
context: {
|
||||
heightCm: 180,
|
||||
hasBpTargets: true,
|
||||
totalMeasurements: 100,
|
||||
dataSpanDays: 365,
|
||||
oldestMeasurementDaysAgo: 365,
|
||||
newestMeasurementDaysAgo: 1,
|
||||
ageYears: 45,
|
||||
gender: "MALE",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("applyInsightsExcludeFilter", () => {
|
||||
it("returns the original payload unchanged when excludeList is empty", () => {
|
||||
const f = baseFeatures();
|
||||
const out = applyInsightsExcludeFilter(f, []);
|
||||
expect(out).toBe(f);
|
||||
});
|
||||
|
||||
it("drops weight when 'weight' is excluded", () => {
|
||||
const f = baseFeatures();
|
||||
const out = applyInsightsExcludeFilter(f, ["weight"]);
|
||||
expect(out.weight).toBeUndefined();
|
||||
expect(out.bloodPressure).toBeDefined();
|
||||
});
|
||||
|
||||
it("drops blood pressure when 'bp' is excluded", () => {
|
||||
const f = baseFeatures();
|
||||
const out = applyInsightsExcludeFilter(f, ["bp"]);
|
||||
expect(out.bloodPressure).toBeUndefined();
|
||||
expect(out.weight).toBeDefined();
|
||||
});
|
||||
|
||||
it("drops medications when 'medications' or 'compliance' is excluded", () => {
|
||||
expect(
|
||||
applyInsightsExcludeFilter(baseFeatures(), ["medications"]).medications,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
applyInsightsExcludeFilter(baseFeatures(), ["compliance"]).medications,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("strips anthropometrics from context without nuking the whole block", () => {
|
||||
const out = applyInsightsExcludeFilter(baseFeatures(), ["anthropometrics"]);
|
||||
expect(out.context.heightCm).toBeNull();
|
||||
expect(out.context.ageYears).toBeNull();
|
||||
expect(out.context.gender).toBeNull();
|
||||
// The aggregate totals stay — those aren't PII.
|
||||
expect(out.context.dataSpanDays).toBe(365);
|
||||
expect(out.context.totalMeasurements).toBe(100);
|
||||
});
|
||||
|
||||
it("applies multiple exclusions in one pass", () => {
|
||||
const out = applyInsightsExcludeFilter(baseFeatures(), [
|
||||
"weight",
|
||||
"bp",
|
||||
"anthropometrics",
|
||||
]);
|
||||
expect(out.weight).toBeUndefined();
|
||||
expect(out.bloodPressure).toBeUndefined();
|
||||
expect(out.context.heightCm).toBeNull();
|
||||
});
|
||||
|
||||
it("does not mutate the input", () => {
|
||||
const f = baseFeatures();
|
||||
applyInsightsExcludeFilter(f, ["weight", "bp", "anthropometrics"]);
|
||||
expect(f.weight).toBeDefined();
|
||||
expect(f.bloodPressure).toBeDefined();
|
||||
expect(f.context.heightCm).toBe(180);
|
||||
});
|
||||
|
||||
it("is a no-op for tokens with no mapped feature keys (hrv / resting_hr)", () => {
|
||||
const out = applyInsightsExcludeFilter(baseFeatures(), [
|
||||
"hrv",
|
||||
"resting_hr",
|
||||
]);
|
||||
expect(out.weight).toBeDefined();
|
||||
expect(out.bloodPressure).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
measurement: { findMany: vi.fn() },
|
||||
measurementRollup: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
medication: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/medication-category", () => ({
|
||||
getMedicationCategories: vi.fn(async () => ({})),
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
extractFeatures,
|
||||
FeaturesPayloadTooLargeError,
|
||||
FEATURES_MAX_BYTES,
|
||||
type RawFeatures,
|
||||
} from "../features";
|
||||
|
||||
const prismaMock = prisma as unknown as {
|
||||
user: { findUnique: ReturnType<typeof vi.fn> };
|
||||
measurement: { findMany: ReturnType<typeof vi.fn> };
|
||||
measurementRollup: { findMany: ReturnType<typeof vi.fn> };
|
||||
moodEntry: { findMany: ReturnType<typeof vi.fn> };
|
||||
medication: { findMany: ReturnType<typeof vi.fn> };
|
||||
medicationIntakeEvent: { findMany: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
|
||||
function rollupRow(daysAgo: number, mean: number, count: number) {
|
||||
return {
|
||||
bucketStart: new Date(Date.now() - daysAgo * dayMs),
|
||||
count,
|
||||
mean,
|
||||
minValue: mean,
|
||||
maxValue: mean,
|
||||
sd: 0,
|
||||
slope: 0,
|
||||
r2: 0,
|
||||
computedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
heightCm: 180,
|
||||
dateOfBirth: new Date("1980-01-01"),
|
||||
gender: "MALE",
|
||||
});
|
||||
prismaMock.measurement.findMany.mockResolvedValue([]);
|
||||
prismaMock.measurementRollup.findMany.mockResolvedValue([]);
|
||||
prismaMock.moodEntry.findMany.mockResolvedValue([]);
|
||||
prismaMock.medication.findMany.mockResolvedValue([]);
|
||||
prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("extractFeatures — v1.4.36 W3 bucketed payload", () => {
|
||||
it("does not attach bucketedMeasurements when includeRaw=false", async () => {
|
||||
const features = await extractFeatures("user-1", false);
|
||||
const asRecord = features as unknown as Record<string, unknown>;
|
||||
expect(asRecord.bucketedMeasurements).toBeUndefined();
|
||||
expect(asRecord.rawMeasurements).toBeUndefined();
|
||||
});
|
||||
|
||||
it("attaches DAY / WEEK / MONTH buckets from measurement_rollups when includeRaw=true", async () => {
|
||||
// Two WEIGHT DAY buckets in the 0-90d window and one MONTH bucket
|
||||
// in the 365-1825d window. The reader is called once per
|
||||
// (type, granularity) combination — return data only for the two
|
||||
// we care about, empty for the rest.
|
||||
prismaMock.measurementRollup.findMany.mockImplementation(
|
||||
async (args: { where: { type: string; granularity: string } }) => {
|
||||
if (args.where.type === "WEIGHT" && args.where.granularity === "DAY") {
|
||||
return [rollupRow(10, 82.5, 2), rollupRow(20, 82.7, 1)];
|
||||
}
|
||||
if (
|
||||
args.where.type === "WEIGHT" &&
|
||||
args.where.granularity === "MONTH"
|
||||
) {
|
||||
return [rollupRow(400, 85.1, 28)];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
const features = (await extractFeatures("user-1", true)) as RawFeatures;
|
||||
|
||||
expect(features.bucketedMeasurements).toBeDefined();
|
||||
const weightDay = features.bucketedMeasurements.find(
|
||||
(s) => s.type === "WEIGHT" && s.granularity === "DAY",
|
||||
);
|
||||
expect(weightDay).toBeDefined();
|
||||
expect(weightDay?.buckets).toHaveLength(2);
|
||||
expect(weightDay?.buckets[0]).toMatchObject({
|
||||
mean: 82.5,
|
||||
count: 2,
|
||||
});
|
||||
expect(weightDay?.buckets[0].bucketStart).toMatch(
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/,
|
||||
);
|
||||
|
||||
const weightMonth = features.bucketedMeasurements.find(
|
||||
(s) => s.type === "WEIGHT" && s.granularity === "MONTH",
|
||||
);
|
||||
expect(weightMonth?.buckets).toHaveLength(1);
|
||||
expect(weightMonth?.buckets[0].count).toBe(28);
|
||||
});
|
||||
|
||||
it("drops empty (type, granularity) series so the payload never carries labelled-but-empty arrays", async () => {
|
||||
prismaMock.measurementRollup.findMany.mockResolvedValue([]);
|
||||
const features = (await extractFeatures("user-1", true)) as RawFeatures;
|
||||
expect(features.bucketedMeasurements).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps anthropometrics (heightCm / ageYears / gender) on context", async () => {
|
||||
const features = await extractFeatures("user-1", false);
|
||||
expect(features.context.heightCm).toBe(180);
|
||||
expect(features.context.gender).toBe("MALE");
|
||||
expect(features.context.ageYears).toBeGreaterThan(40);
|
||||
});
|
||||
|
||||
it("throws FeaturesPayloadTooLargeError when the serialised payload exceeds the 5 MB cap", async () => {
|
||||
// Fabricate an absurdly long bucket list so the JSON dump crosses
|
||||
// the ceiling. ~200 KB per series × 28 series ≈ ~5.6 MB.
|
||||
const giant = new Array(200_000).fill(null).map((_, i) => ({
|
||||
bucketStart: new Date(Date.now() - i * dayMs),
|
||||
count: i,
|
||||
mean: i,
|
||||
minValue: i,
|
||||
maxValue: i,
|
||||
sd: 0,
|
||||
slope: 0,
|
||||
r2: 0,
|
||||
computedAt: new Date(),
|
||||
}));
|
||||
prismaMock.measurementRollup.findMany.mockResolvedValue(giant);
|
||||
|
||||
await expect(extractFeatures("user-1", true)).rejects.toThrow(
|
||||
FeaturesPayloadTooLargeError,
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes the size cap as a public constant", () => {
|
||||
expect(FEATURES_MAX_BYTES).toBe(5 * 1024 * 1024);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { compactSections } from "../prompt-compact";
|
||||
|
||||
describe("compactSections", () => {
|
||||
it("drops empty arrays", () => {
|
||||
const out = compactSections({ sleep: [], weight: [{ v: 1 }] });
|
||||
expect(out).toEqual({ weight: [{ v: 1 }] });
|
||||
});
|
||||
|
||||
it("drops empty plain objects", () => {
|
||||
const out = compactSections({ medications: {}, weight: { latest: 82 } });
|
||||
expect(out).toEqual({ weight: { latest: 82 } });
|
||||
});
|
||||
|
||||
it("drops undefined values", () => {
|
||||
const out = compactSections({ weight: undefined, bp: { avg: 128 } });
|
||||
expect(out).toEqual({ bp: { avg: 128 } });
|
||||
});
|
||||
|
||||
it("preserves null (null is a meaningful 'unknown' marker, not 'empty')", () => {
|
||||
const out = compactSections({ avg30: null, avg7: 82 });
|
||||
expect(out).toEqual({ avg30: null, avg7: 82 });
|
||||
});
|
||||
|
||||
it("preserves nested objects shallow by default (count: 0 stays)", () => {
|
||||
const out = compactSections({
|
||||
coverage: { count: 0, spanDays: 0 },
|
||||
bp: { avg30: 128 },
|
||||
});
|
||||
expect(out.coverage).toEqual({ count: 0, spanDays: 0 });
|
||||
});
|
||||
|
||||
it("deep mode recurses into nested plain objects", () => {
|
||||
const out = compactSections(
|
||||
{
|
||||
bp: { avg30: 128, history: [] },
|
||||
ghost: { medications: [] },
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
expect(out).toEqual({ bp: { avg30: 128 } });
|
||||
});
|
||||
|
||||
it("preserves arrays of objects with content", () => {
|
||||
const out = compactSections({
|
||||
medications: [{ name: "Mounjaro" }],
|
||||
bp: [],
|
||||
});
|
||||
expect(out).toEqual({ medications: [{ name: "Mounjaro" }] });
|
||||
});
|
||||
|
||||
it("returns a new object — does not mutate the input", () => {
|
||||
const input = { sleep: [], weight: 82 } as Record<string, unknown>;
|
||||
const out = compactSections(input);
|
||||
expect(input.sleep).toEqual([]);
|
||||
expect(out).not.toBe(input);
|
||||
});
|
||||
|
||||
it("renders empty input as empty output", () => {
|
||||
expect(compactSections({})).toEqual({});
|
||||
});
|
||||
|
||||
it("does not drop primitive zeros, false, or empty strings", () => {
|
||||
// These are legitimate data signals — only structural emptiness
|
||||
// (empty arrays/objects/undefined) is filtered.
|
||||
const out = compactSections({
|
||||
count: 0,
|
||||
enabled: false,
|
||||
label: "",
|
||||
});
|
||||
expect(out).toEqual({ count: 0, enabled: false, label: "" });
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* v1.4.35 — SQL-driven aggregator for `/api/insights/comprehensive`,
|
||||
* with the partial read-swap onto `measurement_rollups` landed.
|
||||
* v1.4.36 — SQL-driven aggregator for `/api/insights/comprehensive`,
|
||||
* rollup-first read with live-SQL fallback on cold mounts.
|
||||
*
|
||||
* Background
|
||||
* ----------
|
||||
@@ -21,39 +21,42 @@
|
||||
* slim slice: the comprehensive endpoint also needs `anomalyCount`,
|
||||
* `avg30LastMonth`, `avg30LastYear`, and the 90-day window cap.
|
||||
*
|
||||
* Read-swap (v1.4.35)
|
||||
* -------------------
|
||||
* Two surfaces now read from the persistent `measurement_rollups`
|
||||
* table instead of recomputing per request:
|
||||
* Read-swap (v1.4.35 → v1.4.36)
|
||||
* -----------------------------
|
||||
* v1.4.35 landed a partial read-swap that ran both the live aggregate
|
||||
* AND the rollup read in parallel, then preferred the rollup-derived
|
||||
* values when the count matched. That left the regression intact: the
|
||||
* heavy `$queryRaw` over `measurements` still ran on every request,
|
||||
* still scanning the 311k-row table on a power-user account.
|
||||
*
|
||||
* - **`count / min / max / mean`** per type — composed from the DAY
|
||||
* rollup buckets via `aggregateBuckets` (sum of counts; min of
|
||||
* mins; max of maxes; count-weighted mean). These four are
|
||||
* linearly composable across DAY buckets, so the composed value
|
||||
* is mathematically identical to the live `AVG` / `MIN` / `MAX`
|
||||
* / `COUNT(*)` over the same rows. A defensive parity check
|
||||
* against the live aggregate's `COUNT(*)` falls back to live SQL
|
||||
* when divergence is detected — covers the cold-mount edge case
|
||||
* where the rollup populator is mid-flight, or a sub-second race
|
||||
* between the watermark check and the bucket read.
|
||||
* v1.4.36 promotes the rollup table to the canonical source on the
|
||||
* happy path:
|
||||
*
|
||||
* - **`dailyByType`** — read directly from the DAY rollup buckets
|
||||
* keyed on (userId, type, granularity=DAY, bucketStart). The
|
||||
* bucket's stored `mean` is the same `AVG(value)` per day the
|
||||
* legacy SQL emitted via `date_trunc('day', measured_at)`, so
|
||||
* downstream correlation pairings remain byte-shape stable.
|
||||
* - On entry we call `ensureUserRollupsFresh` (warm-on-read populator
|
||||
* for the trailing 90-day DAY window) and then probe the rollup
|
||||
* table with a single COUNT.
|
||||
*
|
||||
* Everything else stays on live SQL:
|
||||
* - `slope7 / r2_7`, `slope30 / r2_30`, `slope90 / r2_90` — slope
|
||||
* and R² do **not** compose linearly across DAY buckets.
|
||||
* - `stddev`, `anomalyCount` — same reason.
|
||||
* - `avg7 / avg30 / avg30LastMonth` — these windows aren't aligned
|
||||
* to DAY-bucket boundaries (the 7-day window slides relative to
|
||||
* `NOW()`), so the live `FILTER (WHERE measured_at >= NOW() -
|
||||
* INTERVAL '7 days')` clause is canonical.
|
||||
* - `latest / latestMeasuredAt` — the `DISTINCT ON (type)` pass
|
||||
* resolves the most recent raw row, which the bucket can't
|
||||
* surface (it stores the day's mean, not the day's last value).
|
||||
* - When the DAY rollup table has rows for this user the **heavy**
|
||||
* `$queryRaw` over `measurements` is skipped. `count / min / max /
|
||||
* mean` per type compose from the persisted DAY buckets via
|
||||
* `aggregateBuckets`; `dailyByType` reads the same buckets keyed
|
||||
* on `(type, bucketStart)`.
|
||||
*
|
||||
* - A **narrow** `$queryRaw` still fires for the non-composable
|
||||
* fields: `stddev`, `anomalyCount`, `avg7 / avg30 / avg30LastMonth`,
|
||||
* and the `slope7 / slope30 / slope90` tuples. These don't compose
|
||||
* linearly across DAY buckets so live SQL stays canonical, but the
|
||||
* query no longer carries the redundant `COUNT(*)` / `MIN` / `MAX`
|
||||
* / `AVG` columns that duplicated the bucket-derived values.
|
||||
*
|
||||
* - `latest / latestMeasuredAt` keep their dedicated `DISTINCT ON
|
||||
* (type)` pass; the bucket stores the day's mean rather than the
|
||||
* last reading.
|
||||
*
|
||||
* - Fallback path — when the COUNT probe returns zero rows
|
||||
* (post-restart cold mount or a brand-new account whose boot-time
|
||||
* backfill is mid-flight) we run the legacy heavy aggregate so the
|
||||
* response shape is correct on the very first request.
|
||||
*
|
||||
* Scope notes
|
||||
* -----------
|
||||
@@ -98,14 +101,18 @@ import { prisma } from "@/lib/db";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import { ensureUserRollupsFresh } from "@/lib/measurements/rollups";
|
||||
import { aggregateBuckets } from "@/lib/measurements/rollup-read";
|
||||
import {
|
||||
isFullyCovered,
|
||||
probeRollupCoverage,
|
||||
} from "@/lib/measurements/rollup-coverage";
|
||||
|
||||
/**
|
||||
* Raw row from the aggregate query. Keyed on `MeasurementType`, with
|
||||
* windowed AVG/SLOPE/R2 expressions plus anomaly-detection inputs
|
||||
* (window-wide mean + stddev so we can compute the |z| > 2 count
|
||||
* client-side without a second pass over the raw rows).
|
||||
* Heavy aggregate row — count/min/max/mean alongside the non-composable
|
||||
* windowed columns. Used on the cold-mount fallback path where the
|
||||
* rollup table is empty (the populator hasn't caught up yet) so the
|
||||
* response shape is correct on the very first request.
|
||||
*/
|
||||
interface AggregateRow {
|
||||
interface HeavyAggregateRow {
|
||||
type: string;
|
||||
count: bigint;
|
||||
min_value: number | null;
|
||||
@@ -124,6 +131,29 @@ interface AggregateRow {
|
||||
r2_90: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow aggregate row — only the columns the DAY rollup buckets cannot
|
||||
* compose linearly. `count` rides along so the anomaly z-score loop
|
||||
* knows the divisor for the window-wide mean / stddev; the per-type
|
||||
* count exposed on `DataSummary` still comes from the bucket
|
||||
* composition.
|
||||
*/
|
||||
interface NarrowAggregateRow {
|
||||
type: string;
|
||||
count: bigint;
|
||||
stddev_value: number | null;
|
||||
anomaly_count: bigint;
|
||||
avg7: number | null;
|
||||
avg30: number | null;
|
||||
avg30_last_month: number | null;
|
||||
slope7: number | null;
|
||||
r2_7: number | null;
|
||||
slope30: number | null;
|
||||
r2_30: number | null;
|
||||
slope90: number | null;
|
||||
r2_90: number | null;
|
||||
}
|
||||
|
||||
/** Latest raw value per type (DISTINCT ON over the 90-day window). */
|
||||
interface LatestRow {
|
||||
type: string;
|
||||
@@ -183,35 +213,61 @@ function round2(value: number | null): number | null {
|
||||
/**
|
||||
* Build the per-type `DataSummary` bundle for the comprehensive route.
|
||||
*
|
||||
* Five queries against the trailing 90 days, in parallel:
|
||||
* Two read shapes:
|
||||
*
|
||||
* 1. Per-type aggregate (count + min/max/mean/stddev + windowed
|
||||
* avgs + regr_slope/r2 for the 7/30/90-day slopes). The
|
||||
* `count / min / max / mean` columns are kept on this query as
|
||||
* the live-SQL fallback for the v1.4.35 read-swap parity check.
|
||||
* 2. `DISTINCT ON (type)` for the most-recent raw value per type.
|
||||
* 3. DAY rollup buckets from `measurement_rollups` (v1.4.35) —
|
||||
* composed into `count / min / max / mean` for the per-type
|
||||
* summary and replayed as the `dailyByType` correlation feed.
|
||||
* 4. Bounded raw-row sys query for BP target pairing (sub-day
|
||||
* timestamps, can't be replaced by a daily mean).
|
||||
* 5. Bounded raw-row dia query for BP target pairing.
|
||||
* - **Rollup-fresh path** (the happy path on every warm request):
|
||||
* `count / min / max / mean` + `dailyByType` compose from the
|
||||
* persisted DAY buckets; a narrow `$queryRaw` carries only the
|
||||
* non-composable windowed columns. The heavy COUNT/MIN/MAX/AVG
|
||||
* aggregate is **not** executed.
|
||||
*
|
||||
* - **Live fallback path** (cold mount before the populator catches
|
||||
* up, or a brand-new account whose backfill is mid-flight): the
|
||||
* legacy heavy aggregate runs so the response shape stays correct
|
||||
* on the very first request. Subsequent requests fall back to the
|
||||
* rollup-fresh path once `ensureUserRollupsFresh` has written
|
||||
* buckets.
|
||||
*/
|
||||
export async function buildComprehensiveAggregate(
|
||||
userId: string,
|
||||
): Promise<ComprehensiveAggregate> {
|
||||
const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// v1.4.35 — keep the persistent rollup table current before any
|
||||
// read fires. After this returns the DAY-granularity buckets for
|
||||
// the trailing 90 days reflect every measurement that landed before
|
||||
// this call (the per-write hook is synchronous on the DAY bucket,
|
||||
// and `ensureUserRollupsFresh` covers cold-mount / process-restart
|
||||
// cases). The bucket reads below depend on that guarantee.
|
||||
// Keep the persistent rollup table current before any read fires.
|
||||
// After this returns the DAY-granularity buckets for the trailing
|
||||
// 90 days reflect every measurement that landed before this call
|
||||
// (the per-write hook is synchronous on the DAY bucket, and
|
||||
// `ensureUserRollupsFresh` covers cold-mount / process-restart).
|
||||
await ensureUserRollupsFresh(userId);
|
||||
|
||||
const [aggregates, latests, dayBuckets, sysRaw, diaRaw] = await Promise.all([
|
||||
prisma.$queryRaw<AggregateRow[]>`
|
||||
// v1.4.36 QA C1 — per-type coverage probe. The legacy global COUNT
|
||||
// returned true as soon as any one type had a DAY bucket, which made
|
||||
// the bucket-derived path swallow a brand-new type's all-time history
|
||||
// (the per-write hook upserts a single bucket for the new type, the
|
||||
// probe flips to true, the comprehensive response then iterates only
|
||||
// bucket-bearing types and the trailing-90d narrow aggregate becomes
|
||||
// the new "all-time" total for that type). We now decide per type
|
||||
// and only take the rollup path when EVERY type the user has logged
|
||||
// is covered. Partial coverage falls back to the live aggregate so
|
||||
// a fresh-type measurement doesn't break its summary card.
|
||||
const coverage = await probeRollupCoverage(userId);
|
||||
if (isFullyCovered(coverage)) {
|
||||
return buildFromRollups(userId, ninetyDaysAgo);
|
||||
}
|
||||
return buildFromLiveAggregate(userId, ninetyDaysAgo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Happy path — DAY buckets carry the composable stats; a narrow
|
||||
* `$queryRaw` carries only the windowed / regression columns the
|
||||
* buckets cannot reconstruct.
|
||||
*/
|
||||
async function buildFromRollups(
|
||||
userId: string,
|
||||
ninetyDaysAgo: Date,
|
||||
): Promise<ComprehensiveAggregate> {
|
||||
const [narrows, latests, dayBuckets, sysRaw, diaRaw, firstRows] = await Promise.all([
|
||||
prisma.$queryRaw<NarrowAggregateRow[]>`
|
||||
WITH window_stats AS (
|
||||
SELECT
|
||||
m."type",
|
||||
@@ -225,9 +281,6 @@ export async function buildComprehensiveAggregate(
|
||||
SELECT
|
||||
m."type"::text AS type,
|
||||
COUNT(*) AS count,
|
||||
MIN(m."value")::double precision AS min_value,
|
||||
MAX(m."value")::double precision AS max_value,
|
||||
AVG(m."value")::double precision AS mean_value,
|
||||
ws.stddev_value::double precision AS stddev_value,
|
||||
-- |z| > 2 anomaly count over the 90-day window. STDDEV_POP
|
||||
-- matches the JS helper which divides by n (not n-1).
|
||||
@@ -333,12 +386,14 @@ export async function buildComprehensiveAggregate(
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
}),
|
||||
prisma.$queryRaw<Array<{ first_at: Date | null }>>`
|
||||
SELECT MIN(m."measured_at") AS first_at
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${ninetyDaysAgo}
|
||||
`,
|
||||
]);
|
||||
|
||||
// ── avg30LastYear ─────────────────────────────────────────
|
||||
// The 90-day window guarantees this is null (no rows >= 365 days
|
||||
// old will appear) — preserves legacy semantics.
|
||||
|
||||
const latestByType = new Map<string, { value: number; measuredAt: Date }>();
|
||||
for (const row of latests) {
|
||||
latestByType.set(row.type, {
|
||||
@@ -347,53 +402,214 @@ export async function buildComprehensiveAggregate(
|
||||
});
|
||||
}
|
||||
|
||||
// v1.4.35 — partition DAY buckets by type so the per-type aggregate
|
||||
// loop below can compose `count / min / max / mean` from them in O(1).
|
||||
const bucketsByType = new Map<
|
||||
string,
|
||||
Array<{
|
||||
day: Date;
|
||||
count: number;
|
||||
mean: number;
|
||||
minValue: number;
|
||||
maxValue: number;
|
||||
}>
|
||||
>();
|
||||
for (const b of dayBuckets) {
|
||||
const list = bucketsByType.get(b.type) ?? [];
|
||||
list.push({
|
||||
day: b.bucketStart,
|
||||
count: b.count,
|
||||
mean: b.mean,
|
||||
minValue: b.minValue,
|
||||
maxValue: b.maxValue,
|
||||
});
|
||||
bucketsByType.set(b.type, list);
|
||||
const bucketsByType = partitionBucketsByType(dayBuckets);
|
||||
const narrowByType = new Map<string, NarrowAggregateRow>();
|
||||
for (const row of narrows) {
|
||||
narrowByType.set(row.type, row);
|
||||
}
|
||||
|
||||
// Seed only types that actually have rows — the legacy route only
|
||||
// populated `summaries[t]` when `data.length > 0`.
|
||||
const summaries: Record<string, DataSummary> = {};
|
||||
let totalMeasurements = 0;
|
||||
|
||||
// Seed every type that has buckets — the bucket set is the source of
|
||||
// truth on this path. The narrow aggregate provides the non-composable
|
||||
// windowed columns alongside.
|
||||
for (const [type, buckets] of bucketsByType.entries()) {
|
||||
const composed = aggregateBuckets(buckets);
|
||||
if (composed.count === 0) continue;
|
||||
totalMeasurements += composed.count;
|
||||
const latest = latestByType.get(type);
|
||||
const narrow = narrowByType.get(type);
|
||||
summaries[type] = {
|
||||
count: composed.count,
|
||||
latest: latest?.value ?? null,
|
||||
min: round2(composed.min),
|
||||
max: round2(composed.max),
|
||||
mean: round2(composed.mean),
|
||||
avg7: round2(narrow?.avg7 ?? null),
|
||||
avg30: round2(narrow?.avg30 ?? null),
|
||||
slope7: buildSlope(narrow?.slope7 ?? null, narrow?.r2_7 ?? null),
|
||||
slope30: buildSlope(narrow?.slope30 ?? null, narrow?.r2_30 ?? null),
|
||||
slope90: buildSlope(narrow?.slope90 ?? null, narrow?.r2_90 ?? null),
|
||||
anomalyCount: Number(narrow?.anomaly_count ?? 0),
|
||||
avg30LastMonth: round2(narrow?.avg30_last_month ?? null),
|
||||
// Legacy semantics: the 90-day window guarantees no rows from a
|
||||
// year ago. Always null.
|
||||
avg30LastYear: null,
|
||||
};
|
||||
}
|
||||
|
||||
const firstMeasurementAt = firstRows[0]?.first_at
|
||||
? new Date(firstRows[0].first_at)
|
||||
: null;
|
||||
|
||||
return {
|
||||
summaries,
|
||||
bpRawRows: {
|
||||
sys: sysRaw.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
|
||||
dia: diaRaw.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
|
||||
},
|
||||
dailyByType: buildDailyByType(bucketsByType),
|
||||
firstMeasurementAt,
|
||||
totalMeasurements,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cold fallback — the rollup table is empty for this user (e.g. brand-
|
||||
* new account, or post-restart before the boot-time backfill landed).
|
||||
* Runs the legacy heavy aggregate so the response shape is correct on
|
||||
* the very first request; subsequent requests will see the rollup
|
||||
* populator catch up and route through `buildFromRollups`.
|
||||
*/
|
||||
async function buildFromLiveAggregate(
|
||||
userId: string,
|
||||
ninetyDaysAgo: Date,
|
||||
): Promise<ComprehensiveAggregate> {
|
||||
const [aggregates, latests, dayBuckets, sysRaw, diaRaw] = await Promise.all([
|
||||
prisma.$queryRaw<HeavyAggregateRow[]>`
|
||||
WITH window_stats AS (
|
||||
SELECT
|
||||
m."type",
|
||||
AVG(m."value") AS mean_value,
|
||||
STDDEV_POP(m."value") AS stddev_value
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${ninetyDaysAgo}
|
||||
GROUP BY m."type"
|
||||
)
|
||||
SELECT
|
||||
m."type"::text AS type,
|
||||
COUNT(*) AS count,
|
||||
MIN(m."value")::double precision AS min_value,
|
||||
MAX(m."value")::double precision AS max_value,
|
||||
AVG(m."value")::double precision AS mean_value,
|
||||
ws.stddev_value::double precision AS stddev_value,
|
||||
COUNT(*) FILTER (
|
||||
WHERE ws.stddev_value IS NOT NULL
|
||||
AND ws.stddev_value > 0
|
||||
AND ABS(
|
||||
(ROUND(((m."value" - ws.mean_value) / ws.stddev_value)::numeric, 2))
|
||||
) > 2
|
||||
) AS anomaly_count,
|
||||
AVG(m."value") FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '7 days'
|
||||
)::double precision AS avg7,
|
||||
AVG(m."value") FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '30 days'
|
||||
)::double precision AS avg30,
|
||||
AVG(m."value") FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '60 days'
|
||||
AND m."measured_at" < NOW() - INTERVAL '30 days'
|
||||
)::double precision AS avg30_last_month,
|
||||
REGR_SLOPE(
|
||||
m."value",
|
||||
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
|
||||
) FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '7 days'
|
||||
)::double precision AS slope7,
|
||||
REGR_R2(
|
||||
m."value",
|
||||
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
|
||||
) FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '7 days'
|
||||
)::double precision AS r2_7,
|
||||
REGR_SLOPE(
|
||||
m."value",
|
||||
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
|
||||
) FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '30 days'
|
||||
)::double precision AS slope30,
|
||||
REGR_R2(
|
||||
m."value",
|
||||
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
|
||||
) FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '30 days'
|
||||
)::double precision AS r2_30,
|
||||
REGR_SLOPE(
|
||||
m."value",
|
||||
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
|
||||
) FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '90 days'
|
||||
)::double precision AS slope90,
|
||||
REGR_R2(
|
||||
m."value",
|
||||
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
|
||||
) FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '90 days'
|
||||
)::double precision AS r2_90
|
||||
FROM measurements m
|
||||
JOIN window_stats ws ON ws."type" = m."type"
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${ninetyDaysAgo}
|
||||
GROUP BY m."type", ws.stddev_value
|
||||
`,
|
||||
prisma.$queryRaw<LatestRow[]>`
|
||||
SELECT DISTINCT ON (m."type")
|
||||
m."type"::text AS type,
|
||||
m."value"::double precision AS value,
|
||||
m."measured_at" AS measured_at
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${ninetyDaysAgo}
|
||||
ORDER BY m."type", m."measured_at" DESC
|
||||
`,
|
||||
// Buckets may exist for some types even when the COUNT probe came
|
||||
// back zero (race window between the probe and a sibling
|
||||
// populator); the cold-path read still honours them for the
|
||||
// `dailyByType` feed.
|
||||
prisma.measurementRollup.findMany({
|
||||
where: {
|
||||
userId,
|
||||
granularity: "DAY",
|
||||
bucketStart: { gte: ninetyDaysAgo },
|
||||
},
|
||||
orderBy: [{ type: "asc" }, { bucketStart: "asc" }],
|
||||
}),
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
measuredAt: { gte: ninetyDaysAgo },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
}),
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
measuredAt: { gte: ninetyDaysAgo },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const latestByType = new Map<string, { value: number; measuredAt: Date }>();
|
||||
for (const row of latests) {
|
||||
latestByType.set(row.type, {
|
||||
value: Number(row.value),
|
||||
measuredAt: new Date(row.measured_at),
|
||||
});
|
||||
}
|
||||
|
||||
const bucketsByType = partitionBucketsByType(dayBuckets);
|
||||
|
||||
const summaries: Record<string, DataSummary> = {};
|
||||
let totalMeasurements = 0;
|
||||
let firstMeasurementAt: Date | null = null;
|
||||
|
||||
for (const row of aggregates) {
|
||||
const liveCount = Number(row.count);
|
||||
// Compose count/min/max/mean from the DAY buckets when they agree
|
||||
// with the live `COUNT(*)`. Parity divergence (cold mount, rollup
|
||||
// populator mid-flight, watermark race) falls back to live SQL so
|
||||
// the response shape never serves a wrong value.
|
||||
const composed = aggregateBuckets(bucketsByType.get(row.type) ?? []);
|
||||
const useRollup = composed.count === liveCount;
|
||||
const count = useRollup ? composed.count : liveCount;
|
||||
const count = Number(row.count);
|
||||
totalMeasurements += count;
|
||||
const latest = latestByType.get(row.type);
|
||||
summaries[row.type] = {
|
||||
count,
|
||||
latest: latest?.value ?? null,
|
||||
min: useRollup ? round2(composed.min) : round2(row.min_value),
|
||||
max: useRollup ? round2(composed.max) : round2(row.max_value),
|
||||
mean: useRollup ? round2(composed.mean) : round2(row.mean_value),
|
||||
min: round2(row.min_value),
|
||||
max: round2(row.max_value),
|
||||
mean: round2(row.mean_value),
|
||||
avg7: round2(row.avg7),
|
||||
avg30: round2(row.avg30),
|
||||
slope7: buildSlope(row.slope7, row.r2_7),
|
||||
@@ -401,17 +617,10 @@ export async function buildComprehensiveAggregate(
|
||||
slope90: buildSlope(row.slope90, row.r2_90),
|
||||
anomalyCount: Number(row.anomaly_count),
|
||||
avg30LastMonth: round2(row.avg30_last_month),
|
||||
// Legacy semantics: the 90-day findMany window guarantees no
|
||||
// rows from a year ago. Always null.
|
||||
avg30LastYear: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── firstMeasurementAt ───────────────────────────────────
|
||||
// Take the earliest `measuredAt` across the user's 90-day rows.
|
||||
// One extra cheap aggregate query keeps the route's `dataSpanDays`
|
||||
// truthful. Run lazily: skip when no aggregate rows came back
|
||||
// (totalMeasurements === 0).
|
||||
if (totalMeasurements > 0) {
|
||||
const firstRows = await prisma.$queryRaw<Array<{ first_at: Date | null }>>`
|
||||
SELECT MIN(m."measured_at") AS first_at
|
||||
@@ -424,13 +633,84 @@ export async function buildComprehensiveAggregate(
|
||||
: null;
|
||||
}
|
||||
|
||||
// ── daily-by-type ────────────────────────────────────────
|
||||
// v1.4.35 — sourced from the DAY rollup buckets. The legacy
|
||||
// implementation ran a dedicated `date_trunc('day', m."measured_at")`
|
||||
// GROUP BY scan and only returned the three types the correlation
|
||||
// pairings consume. We filter to the same three types here so
|
||||
// downstream consumers see a byte-identical shape, but the data
|
||||
// origin is now the persisted bucket — no extra SQL pass needed.
|
||||
return {
|
||||
summaries,
|
||||
bpRawRows: {
|
||||
sys: sysRaw.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
|
||||
dia: diaRaw.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
|
||||
},
|
||||
dailyByType: buildDailyByType(bucketsByType),
|
||||
firstMeasurementAt,
|
||||
totalMeasurements,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Partition DAY buckets by type so the per-type aggregator can compose
|
||||
* `count / min / max / mean` from them in O(1) and the daily-by-type
|
||||
* builder can replay them as the correlation feed.
|
||||
*/
|
||||
function partitionBucketsByType(
|
||||
rows: ReadonlyArray<{
|
||||
type: string;
|
||||
bucketStart: Date;
|
||||
count: number;
|
||||
mean: number;
|
||||
minValue: number;
|
||||
maxValue: number;
|
||||
}>,
|
||||
): Map<
|
||||
string,
|
||||
Array<{
|
||||
day: Date;
|
||||
count: number;
|
||||
mean: number;
|
||||
minValue: number;
|
||||
maxValue: number;
|
||||
}>
|
||||
> {
|
||||
const map = new Map<
|
||||
string,
|
||||
Array<{
|
||||
day: Date;
|
||||
count: number;
|
||||
mean: number;
|
||||
minValue: number;
|
||||
maxValue: number;
|
||||
}>
|
||||
>();
|
||||
for (const b of rows) {
|
||||
const list = map.get(b.type) ?? [];
|
||||
list.push({
|
||||
day: b.bucketStart,
|
||||
count: b.count,
|
||||
mean: b.mean,
|
||||
minValue: b.minValue,
|
||||
maxValue: b.maxValue,
|
||||
});
|
||||
map.set(b.type, list);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* `dailyByType` is sourced from DAY rollup buckets, filtered to the
|
||||
* three types the correlation pairings consume so downstream consumers
|
||||
* see a byte-identical shape with the legacy
|
||||
* `date_trunc('day', m."measured_at")` GROUP BY query.
|
||||
*/
|
||||
function buildDailyByType(
|
||||
bucketsByType: Map<
|
||||
string,
|
||||
Array<{
|
||||
day: Date;
|
||||
count: number;
|
||||
mean: number;
|
||||
minValue: number;
|
||||
maxValue: number;
|
||||
}>
|
||||
>,
|
||||
): Record<string, Array<{ day: string; value: number }>> {
|
||||
const dailyByType: Record<string, Array<{ day: string; value: number }>> = {};
|
||||
const DAILY_TYPES = ["WEIGHT", "BLOOD_PRESSURE_SYS", "PULSE"] as const;
|
||||
for (const type of DAILY_TYPES) {
|
||||
@@ -447,16 +727,6 @@ export async function buildComprehensiveAggregate(
|
||||
value: round2(b.mean) ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
summaries,
|
||||
bpRawRows: {
|
||||
sys: sysRaw.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
|
||||
dia: diaRaw.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
|
||||
},
|
||||
dailyByType,
|
||||
firstMeasurementAt,
|
||||
totalMeasurements,
|
||||
};
|
||||
return dailyByType;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* v1.4.36 W3 T3 — exclude-metrics filter for the AI Insights payload.
|
||||
*
|
||||
* Mirrors the Coach snapshot's `excludeMetrics` contract so the user
|
||||
* has a single privacy contract across both surfaces: what's hidden
|
||||
* from the Coach turn stays hidden from the Insights generation. The
|
||||
* filter drops matching keys off the features object BEFORE
|
||||
* `JSON.stringify` so the LLM never sees the excluded data.
|
||||
*
|
||||
* The token vocabulary is the same `coachExcludeMetricEnum` from
|
||||
* `src/lib/validations/coach-prefs.ts` — see the comment there for
|
||||
* the canonical list. The mapping below pairs each token with the
|
||||
* features.ts field keys it gates; tokens with no mapped fields
|
||||
* (e.g. Apple Health metrics that don't have a dedicated features
|
||||
* branch yet) are no-ops on this surface but keep the contract
|
||||
* symmetric so adding a future field is one-line.
|
||||
*
|
||||
* Anthropometrics is special-cased: rather than dropping the whole
|
||||
* `context` field (which carries non-PII totals like `dataSpanDays`),
|
||||
* we strip just `heightCm` / `ageYears` / `gender` from it.
|
||||
*/
|
||||
import type { AggregatedFeatures, RawFeatures } from "./features";
|
||||
|
||||
/**
|
||||
* Map exclude-token → features.ts top-level keys to remove. `null`
|
||||
* means the token gates a substructure inside `context` and is
|
||||
* handled inline below.
|
||||
*/
|
||||
const EXCLUDE_TO_FEATURE_KEYS: Record<string, ReadonlyArray<keyof AggregatedFeatures>> = {
|
||||
bp: ["bloodPressure"],
|
||||
weight: ["weight"],
|
||||
pulse: ["pulse"],
|
||||
mood: ["mood"],
|
||||
compliance: ["medications"],
|
||||
medications: ["medications"],
|
||||
sleep: ["sleep"],
|
||||
steps: ["activity"],
|
||||
// hrv / resting_hr — no dedicated features branch yet (carried only
|
||||
// by the Coach Apple-Health blocks). Keep the entry for symmetry +
|
||||
// future-proofing.
|
||||
hrv: [],
|
||||
resting_hr: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Return a shallow copy of `features` with every key gated by an
|
||||
* active exclude-token removed. Idempotent. Original payload is not
|
||||
* mutated — same posture as the snapshot filter so call sites can
|
||||
* keep both the filtered + unfiltered shape if they need to.
|
||||
*/
|
||||
export function applyInsightsExcludeFilter<
|
||||
T extends AggregatedFeatures | RawFeatures,
|
||||
>(features: T, excludeMetrics: ReadonlyArray<string>): T {
|
||||
if (excludeMetrics.length === 0) return features;
|
||||
const next = { ...features } as Record<string, unknown>;
|
||||
for (const token of excludeMetrics) {
|
||||
const keys = EXCLUDE_TO_FEATURE_KEYS[token];
|
||||
if (keys) {
|
||||
for (const key of keys) {
|
||||
delete next[key as string];
|
||||
}
|
||||
}
|
||||
if (token === "anthropometrics" && next.context && typeof next.context === "object") {
|
||||
// Anthropometrics gates the profile sub-fields inside `context`.
|
||||
// The rest of context (totals, span days) stays — those are
|
||||
// aggregate counts the model needs for narrative coverage even
|
||||
// when the profile is hidden.
|
||||
const ctx = { ...(next.context as Record<string, unknown>) };
|
||||
ctx.heightCm = null;
|
||||
ctx.ageYears = null;
|
||||
ctx.gender = null;
|
||||
next.context = ctx;
|
||||
}
|
||||
}
|
||||
return next as T;
|
||||
}
|
||||
+159
-13
@@ -15,6 +15,11 @@ import {
|
||||
type CorrelationResult,
|
||||
} from "@/lib/analytics/correlations";
|
||||
import { getMedicationCategories } from "@/lib/medication-category";
|
||||
import { readRollupBuckets } from "@/lib/measurements/rollups";
|
||||
import type {
|
||||
MeasurementType,
|
||||
RollupGranularity,
|
||||
} from "@/generated/prisma/client";
|
||||
|
||||
interface DataCoverage {
|
||||
count: number;
|
||||
@@ -166,14 +171,100 @@ export interface AggregatedFeatures {
|
||||
};
|
||||
}
|
||||
|
||||
export interface RawFeatures extends AggregatedFeatures {
|
||||
rawMeasurements: Array<{
|
||||
type: string;
|
||||
value: number;
|
||||
dayOffset: number; // days ago (anonymized — no exact date)
|
||||
/**
|
||||
* One bucketed series. `granularity` decides the bucket width (DAY for
|
||||
* the trailing 90-day window, WEEK for 90→365 days, MONTH for the
|
||||
* 365→1825-day deep history). `bucketStart` is an ISO-8601 UTC
|
||||
* timestamp anchored to the start of the bucket — Postgres
|
||||
* `date_trunc()` semantics, same as the rollup populator.
|
||||
*
|
||||
* Replaces v1.4.35.x `rawMeasurements` (which appended every
|
||||
* measurement row and blew the 10 MB Codex prompt ceiling on power
|
||||
* users with multi-year imports — 25.9 MB observed on Marc's account
|
||||
* with 311 775 rows). Reading from `measurement_rollups` instead caps
|
||||
* the payload at O(types × buckets) regardless of underlying row
|
||||
* count.
|
||||
*/
|
||||
export interface BucketedSeries {
|
||||
type: string;
|
||||
granularity: RollupGranularity;
|
||||
buckets: Array<{
|
||||
bucketStart: string;
|
||||
mean: number;
|
||||
count: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface RawFeatures extends AggregatedFeatures {
|
||||
bucketedMeasurements: BucketedSeries[];
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.36 W3 T1 — payload size ceiling. The Codex provider rejects
|
||||
* any prompt-string above 10 MB; the features payload is roughly half
|
||||
* the eventual prompt (the JSON dump plus the system + comparison
|
||||
* blocks), so a 5 MB feature payload sits comfortably below the
|
||||
* ceiling even after locale-prefix expansion. Throwing here lets the
|
||||
* route handler downgrade to the non-raw payload + annotate so we
|
||||
* catch the regression instead of blowing the upstream call.
|
||||
*/
|
||||
export const FEATURES_MAX_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
export class FeaturesPayloadTooLargeError extends Error {
|
||||
readonly code = "FEATURES_PAYLOAD_TOO_LARGE";
|
||||
readonly sizeBytes: number;
|
||||
readonly limitBytes: number;
|
||||
|
||||
constructor(sizeBytes: number, limitBytes: number) {
|
||||
super(
|
||||
`Features payload too large: ${sizeBytes} bytes exceeds ${limitBytes} byte cap`,
|
||||
);
|
||||
this.name = "FeaturesPayloadTooLargeError";
|
||||
this.sizeBytes = sizeBytes;
|
||||
this.limitBytes = limitBytes;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.36 W3 T1 — bucket-window definitions for the
|
||||
* `bucketedMeasurements` payload. Mirrors the rollup populator's
|
||||
* granularity ladder so the read-side picks up whatever the persistent
|
||||
* table holds without a recompute round-trip.
|
||||
*
|
||||
* The 90 / 365 / 1825-day windows are non-overlapping: each row of
|
||||
* `measurement_rollups` lives at exactly one granularity, and the
|
||||
* downstream model reads the union of the three series per type.
|
||||
* Total volume per type for a Marc-scale power user (5 years of daily
|
||||
* data): 90 DAY + 39 WEEK + 50 MONTH = 179 buckets × ~40 bytes JSON
|
||||
* each ≈ 7 KB. Eight metric types lands at ~56 KB — well under the
|
||||
* 5 MB cap above, vs 25.9 MB for the v1.4.35 rawMeasurements shape.
|
||||
*/
|
||||
const BUCKET_WINDOWS: Array<{
|
||||
granularity: RollupGranularity;
|
||||
fromDays: number;
|
||||
toDays: number;
|
||||
}> = [
|
||||
{ granularity: "DAY", fromDays: 0, toDays: 90 },
|
||||
{ granularity: "WEEK", fromDays: 90, toDays: 365 },
|
||||
{ granularity: "MONTH", fromDays: 365, toDays: 1825 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Types the bucketed payload covers. Mirrors the aggregate branches
|
||||
* above so the model never sees a bucket for a metric whose aggregate
|
||||
* block was suppressed. New `MeasurementType` enum values flow in by
|
||||
* adding one row; the rollup populator already covers every type.
|
||||
*/
|
||||
const BUCKETED_TYPES: MeasurementType[] = [
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
"BODY_FAT",
|
||||
"SLEEP_DURATION",
|
||||
"ACTIVITY_STEPS",
|
||||
];
|
||||
|
||||
function stdDev(values: number[]): number | null {
|
||||
if (values.length < 2) return null;
|
||||
const mean = values.reduce((s, v) => s + v, 0) / values.length;
|
||||
@@ -764,20 +855,75 @@ export async function extractFeatures(
|
||||
});
|
||||
}
|
||||
|
||||
// Raw mode: add anonymized raw data points
|
||||
// v1.4.36 W3 T1 — "raw" mode no longer dumps every measurement row
|
||||
// into the prompt. Instead we attach DAY / WEEK / MONTH bucket means
|
||||
// from `measurement_rollups` so the model gets the same temporal
|
||||
// shape (granular near-term, coarser deep history) at O(buckets)
|
||||
// instead of O(rows). The v1.4.35 shape blew past Codex's 10 MB
|
||||
// string limit on power users (~25.9 MB observed); the bucketed
|
||||
// shape lands at ~50–500 KB per account.
|
||||
if (includeRaw) {
|
||||
const bucketedMeasurements = await readBucketedSeries(userId, now);
|
||||
const rawFeatures: RawFeatures = {
|
||||
...features,
|
||||
rawMeasurements: measurements.map((m) => ({
|
||||
type: m.type,
|
||||
value: m.value,
|
||||
dayOffset: Math.round(
|
||||
(now - m.measuredAt.getTime()) / (24 * 60 * 60 * 1000),
|
||||
),
|
||||
})),
|
||||
bucketedMeasurements,
|
||||
};
|
||||
enforceSizeGuard(rawFeatures);
|
||||
return rawFeatures;
|
||||
}
|
||||
|
||||
enforceSizeGuard(features);
|
||||
return features;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every BUCKETED_TYPES × BUCKET_WINDOWS combination from the
|
||||
* persistent rollup table and project to the wire shape. Empty
|
||||
* (type, granularity) combinations are dropped so the payload never
|
||||
* carries a labelled-but-empty series.
|
||||
*/
|
||||
async function readBucketedSeries(
|
||||
userId: string,
|
||||
now: number,
|
||||
): Promise<BucketedSeries[]> {
|
||||
const series: BucketedSeries[] = [];
|
||||
for (const type of BUCKETED_TYPES) {
|
||||
for (const window of BUCKET_WINDOWS) {
|
||||
const from = new Date(now - window.toDays * 24 * 60 * 60 * 1000);
|
||||
const to = new Date(now - window.fromDays * 24 * 60 * 60 * 1000);
|
||||
const rows = await readRollupBuckets(
|
||||
userId,
|
||||
type,
|
||||
window.granularity,
|
||||
from,
|
||||
to,
|
||||
);
|
||||
if (rows.length === 0) continue;
|
||||
series.push({
|
||||
type,
|
||||
granularity: window.granularity,
|
||||
buckets: rows.map((r) => ({
|
||||
bucketStart: r.bucketStart.toISOString(),
|
||||
mean: Math.round(r.mean * 100) / 100,
|
||||
count: r.count,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
return series;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard ceiling on the serialised payload. Throws
|
||||
* `FeaturesPayloadTooLargeError` (tagged with `code` so the route
|
||||
* handler can pattern-match) when the JSON dump crosses
|
||||
* `FEATURES_MAX_BYTES`. The route handler downgrades to the non-raw
|
||||
* shape and annotates so the regression is observable; the model
|
||||
* call still succeeds.
|
||||
*/
|
||||
function enforceSizeGuard(payload: AggregatedFeatures | RawFeatures): void {
|
||||
const sizeBytes = Buffer.byteLength(JSON.stringify(payload), "utf8");
|
||||
if (sizeBytes > FEATURES_MAX_BYTES) {
|
||||
throw new FeaturesPayloadTooLargeError(sizeBytes, FEATURES_MAX_BYTES);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* v1.4.36 W3 T4 — `compactSections` helper for the AI prompt body.
|
||||
*
|
||||
* The features payload (and the Coach snapshot, transitively) carries
|
||||
* conditionally-populated blocks: `medications`, `bucketedMeasurements`,
|
||||
* `historicalComparison`, the per-metric aggregates, etc. Empty blocks
|
||||
* have two failure modes once they hit the prompt:
|
||||
*
|
||||
* 1. The serialised JSON includes a labelled-empty key (`"sleep": []`
|
||||
* or `"medications": []`) which the model occasionally narrates as
|
||||
* "there are no medications in your data" even when the user
|
||||
* explicitly excluded them — wasted prompt budget and a misleading
|
||||
* framing.
|
||||
* 2. Some downstream prose composers render the block label without
|
||||
* checking row count, producing the v1.4.35 "Hier sind die
|
||||
* Schlafdaten: [keine]" anti-pattern Marc flagged in W3.
|
||||
*
|
||||
* `compactSections` walks the object once and drops any property whose
|
||||
* value is:
|
||||
* - an empty array
|
||||
* - an empty object (no own enumerable keys)
|
||||
* - undefined
|
||||
*
|
||||
* It runs a single shallow pass at the top level — that's enough for
|
||||
* the features + snapshot shapes today. We do NOT recurse into nested
|
||||
* objects because the legitimate empty values inside e.g.
|
||||
* `coverage: { count: 0, … }` should stay readable (count 0 IS the
|
||||
* signal). A future call site that needs deep-compaction can opt in
|
||||
* via the `deep` flag.
|
||||
*/
|
||||
|
||||
type PlainObject = Record<string, unknown>;
|
||||
|
||||
function isPlainObject(value: unknown): value is PlainObject {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
Object.getPrototypeOf(value) === Object.prototype
|
||||
);
|
||||
}
|
||||
|
||||
function isEmpty(value: unknown): boolean {
|
||||
if (value === undefined) return true;
|
||||
if (Array.isArray(value)) return value.length === 0;
|
||||
if (isPlainObject(value)) return Object.keys(value).length === 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a shallow copy of `blocks` with every empty key dropped.
|
||||
* `null` values are preserved — null carries the semantic "field
|
||||
* exists but is unknown", which is different from "block doesn't
|
||||
* exist". Set `deep: true` to recurse into nested plain objects.
|
||||
*/
|
||||
export function compactSections<T extends PlainObject>(
|
||||
blocks: T,
|
||||
options: { deep?: boolean } = {},
|
||||
): T {
|
||||
const out: PlainObject = {};
|
||||
for (const [key, value] of Object.entries(blocks)) {
|
||||
let folded = value;
|
||||
if (options.deep && isPlainObject(value)) {
|
||||
folded = compactSections(value, options);
|
||||
}
|
||||
if (isEmpty(folded)) continue;
|
||||
out[key] = folded;
|
||||
}
|
||||
return out as T;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
CUMULATIVE_DAY_SUM_TYPES,
|
||||
isCumulativeDaySumType,
|
||||
pickCumulativeDaySum,
|
||||
} from "../cumulative-day-sum";
|
||||
|
||||
/**
|
||||
* v1.4.36 W4c — pickCumulativeDaySum unit tests.
|
||||
*
|
||||
* The helper bucket-and-sums a series of cumulative samples per
|
||||
* `dayKey(measuredAt)`. Tested in pure ISO-date space so the
|
||||
* tz-aware caller (analytics route) can layer its own `userDayKey`
|
||||
* on top without dragging a TZ runtime into the helper.
|
||||
*/
|
||||
|
||||
// Use an ISO-date key so the test stays deterministic without a TZ.
|
||||
const isoDay = (d: Date) => d.toISOString().slice(0, 10);
|
||||
|
||||
describe("pickCumulativeDaySum", () => {
|
||||
it("returns an empty array when the input is empty", () => {
|
||||
expect(pickCumulativeDaySum([], isoDay)).toEqual([]);
|
||||
});
|
||||
|
||||
it("sums a single source within one day to one point", () => {
|
||||
const result = pickCumulativeDaySum(
|
||||
[
|
||||
{ measuredAt: new Date("2026-05-17T08:00:00Z"), value: 1200 },
|
||||
{ measuredAt: new Date("2026-05-17T12:30:00Z"), value: 2500 },
|
||||
{ measuredAt: new Date("2026-05-17T19:45:00Z"), value: 4300 },
|
||||
],
|
||||
isoDay,
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].value).toBe(8000);
|
||||
// The bucket's date is the latest measuredAt in that bucket.
|
||||
expect(result[0].date.toISOString()).toBe("2026-05-17T19:45:00.000Z");
|
||||
});
|
||||
|
||||
it("preserves source-priority ordering by ignoring it (caller's job)", () => {
|
||||
// The helper does not look at `source`. Two sources collapsed in
|
||||
// ONE bucket would double-count — the analytics route prevents
|
||||
// that by running `pickCanonicalSourceRows` first. The helper's
|
||||
// contract is: trust the input order, just bucket-and-sum.
|
||||
const result = pickCumulativeDaySum(
|
||||
[
|
||||
{ measuredAt: new Date("2026-05-17T08:00:00Z"), value: 1000 },
|
||||
{ measuredAt: new Date("2026-05-17T08:00:00Z"), value: 1500 },
|
||||
],
|
||||
isoDay,
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].value).toBe(2500);
|
||||
});
|
||||
|
||||
it("buckets multiple days and only sums within each day", () => {
|
||||
const result = pickCumulativeDaySum(
|
||||
[
|
||||
// Day 1 — total 5000
|
||||
{ measuredAt: new Date("2026-05-15T08:00:00Z"), value: 2000 },
|
||||
{ measuredAt: new Date("2026-05-15T20:00:00Z"), value: 3000 },
|
||||
// Day 2 — total 1200
|
||||
{ measuredAt: new Date("2026-05-16T09:00:00Z"), value: 700 },
|
||||
{ measuredAt: new Date("2026-05-16T11:00:00Z"), value: 500 },
|
||||
// Day 3 — total 9000 (today)
|
||||
{ measuredAt: new Date("2026-05-17T07:00:00Z"), value: 4000 },
|
||||
{ measuredAt: new Date("2026-05-17T13:00:00Z"), value: 5000 },
|
||||
],
|
||||
isoDay,
|
||||
);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.map((p) => p.value)).toEqual([5000, 1200, 9000]);
|
||||
// Output is sorted ascending by date.
|
||||
expect(result[0].date.toISOString()).toBe("2026-05-15T20:00:00.000Z");
|
||||
expect(result[2].date.toISOString()).toBe("2026-05-17T13:00:00.000Z");
|
||||
});
|
||||
|
||||
it("each bucket's date is the latest measuredAt in that bucket", () => {
|
||||
const result = pickCumulativeDaySum(
|
||||
[
|
||||
{ measuredAt: new Date("2026-05-17T07:00:00Z"), value: 100 },
|
||||
{ measuredAt: new Date("2026-05-17T15:00:00Z"), value: 200 },
|
||||
{ measuredAt: new Date("2026-05-17T10:00:00Z"), value: 50 },
|
||||
],
|
||||
isoDay,
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].date.toISOString()).toBe("2026-05-17T15:00:00.000Z");
|
||||
expect(result[0].value).toBe(350);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCumulativeDaySumType", () => {
|
||||
it("returns true for every metric in the canonical list", () => {
|
||||
for (const t of CUMULATIVE_DAY_SUM_TYPES) {
|
||||
expect(isCumulativeDaySumType(t)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns false for non-cumulative types", () => {
|
||||
expect(isCumulativeDaySumType("BLOOD_PRESSURE_SYSTOLIC")).toBe(false);
|
||||
expect(isCumulativeDaySumType("WEIGHT")).toBe(false);
|
||||
expect(isCumulativeDaySumType("SLEEP_DURATION")).toBe(false);
|
||||
expect(isCumulativeDaySumType("MOOD")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,15 @@ vi.mock("@/lib/jobs/boss-instance", () => ({
|
||||
getGlobalBoss: () => mocks.getGlobalBossMock(),
|
||||
}));
|
||||
|
||||
// v1.4.36 QA H3 — `ensureUserRollupsFresh` now annotates + console-
|
||||
// errors on populator failures so silent regressions show up in ops.
|
||||
// Mock the annotate boundary so the test can assert the meta payload.
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
const {
|
||||
queryRaw,
|
||||
queryRawUnsafe,
|
||||
@@ -281,6 +290,40 @@ describe("ensureUserRollupsFresh", () => {
|
||||
const result = await ensureUserRollupsFresh("user-1");
|
||||
expect(result.recomputed).toBe(false);
|
||||
});
|
||||
|
||||
it("annotates the failure when the inner recompute throws (H3)", async () => {
|
||||
// Stale watermark — rollup older than the newest measurement so
|
||||
// the inner recompute branch fires.
|
||||
const rollupAt = new Date("2026-05-10T10:00:00.000Z");
|
||||
const measurementAt = new Date("2026-05-10T12:00:00.000Z");
|
||||
findFirst.mockResolvedValueOnce({ computedAt: rollupAt });
|
||||
findFirstMeasurement.mockResolvedValueOnce({
|
||||
updatedAt: measurementAt,
|
||||
measuredAt: measurementAt,
|
||||
});
|
||||
// The recompute aggregate query throws — simulates a populator
|
||||
// regression (pool exhausted, deadlock, etc).
|
||||
queryRawUnsafe.mockRejectedValueOnce(new Error("deadlock detected"));
|
||||
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const result = await ensureUserRollupsFresh("user-1");
|
||||
|
||||
// The read path still gets a clean `{ recomputed: false }`.
|
||||
expect(result.recomputed).toBe(false);
|
||||
// The annotate event fires with the documented shape so ops can
|
||||
// spot the silent populator regression in the wide-event pipeline.
|
||||
expect(annotate).toHaveBeenCalledWith({
|
||||
meta: {
|
||||
rollup_refresh_failed: true,
|
||||
rollup_refresh_error: "deadlock detected",
|
||||
},
|
||||
});
|
||||
// And the console.error fallback fires so the worker log path
|
||||
// surfaces the failure even when there's no request context.
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("enqueueBootTimeRollupBackfill", () => {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* v1.4.36 W4c — Pure day-bucket sum picker for cumulative metrics.
|
||||
*
|
||||
* SLEEP_DURATION already runs this shape inline in
|
||||
* `src/app/api/analytics/route.ts` lines 198-225: pick a single
|
||||
* source per day (source-priority + device-type) then sum the
|
||||
* day's slices into one datapoint per day. The dashboard tile
|
||||
* surfaces the resulting `latest` as "X minutes asleep".
|
||||
*
|
||||
* Steps / active energy / walking distance / flights climbed /
|
||||
* time-in-daylight share the same physical shape — every iOS
|
||||
* HealthKit ingest writes minute-level slices that the dashboard
|
||||
* tile was rendering as the *latest slice* instead of the *day's
|
||||
* total*. Marc reported "Steps tile shows last-measurement-not-day-sum".
|
||||
*
|
||||
* This helper extracts the per-day-sum reduction so the route can
|
||||
* call it for every cumulative metric without copy-pasting the
|
||||
* SLEEP_DURATION branch. The source-priority pick stays in the
|
||||
* caller (it already imports `pickCanonicalSourceRows`); this
|
||||
* helper only handles the bucket-and-sum once that picker has
|
||||
* narrowed each day to its canonical source.
|
||||
*/
|
||||
|
||||
export interface CumulativeRow {
|
||||
measuredAt: Date;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface DaySumPoint {
|
||||
date: Date;
|
||||
value: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket `rows` by `dayKey(measuredAt)` and sum each bucket's `value`.
|
||||
* The returned points are sorted ascending by date, with each
|
||||
* bucket's `date` set to the latest `measuredAt` seen in that
|
||||
* bucket (matches the SLEEP_DURATION branch's contract).
|
||||
*
|
||||
* Empty input returns an empty array.
|
||||
*/
|
||||
export function pickCumulativeDaySum<T extends CumulativeRow>(
|
||||
rows: readonly T[],
|
||||
dayKey: (d: Date) => string,
|
||||
): DaySumPoint[] {
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const byDay = new Map<string, { total: number; date: Date }>();
|
||||
for (const row of rows) {
|
||||
const key = dayKey(row.measuredAt);
|
||||
const slot = byDay.get(key) ?? { total: 0, date: row.measuredAt };
|
||||
slot.total += row.value;
|
||||
if (row.measuredAt > slot.date) slot.date = row.measuredAt;
|
||||
byDay.set(key, slot);
|
||||
}
|
||||
|
||||
const out: DaySumPoint[] = Array.from(byDay.values()).map((s) => ({
|
||||
date: s.date,
|
||||
value: s.total,
|
||||
}));
|
||||
out.sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.36 W4c — Canonical set of cumulative metric types whose
|
||||
* dashboard tile should read the *day's sum* and not the latest
|
||||
* slice. Kept here so the analytics route and any other consumer
|
||||
* (Coach snapshot, exports) share the same source of truth.
|
||||
*
|
||||
* Note: SLEEP_DURATION is also cumulative but the route handles
|
||||
* it inline because the picker is the canonical sleep source ladder
|
||||
* ("sleep" metricKey vs "default"). Once Apple-Health passthrough
|
||||
* lands and the sleep ladder needs the same shared treatment it can
|
||||
* fold into this list.
|
||||
*/
|
||||
export const CUMULATIVE_DAY_SUM_TYPES = [
|
||||
"ACTIVITY_STEPS",
|
||||
"ACTIVE_ENERGY_BURNED",
|
||||
"WALKING_RUNNING_DISTANCE",
|
||||
"FLIGHTS_CLIMBED",
|
||||
"TIME_IN_DAYLIGHT",
|
||||
] as const;
|
||||
|
||||
export type CumulativeDaySumType = (typeof CUMULATIVE_DAY_SUM_TYPES)[number];
|
||||
|
||||
export function isCumulativeDaySumType(
|
||||
type: string,
|
||||
): type is CumulativeDaySumType {
|
||||
return (CUMULATIVE_DAY_SUM_TYPES as readonly string[]).includes(type);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* v1.4.36 — per-type rollup coverage probe.
|
||||
*
|
||||
* The v1.4.35/v1.4.36 read-swap gates on whether the DAY rollup table
|
||||
* carries buckets for a user. The first iteration checked a single
|
||||
* `COUNT(*) > 0` against `measurement_rollups`. That returns `true` as
|
||||
* soon as *any* type has buckets, which breaks the "first measurement
|
||||
* for a brand-new type" case: the per-write hook upserts one DAY bucket
|
||||
* for the new type → the global probe stays >0 → the read path picks
|
||||
* the bucket-derived branch for *every* type → the brand-new type's
|
||||
* narrow-aggregate windowed columns come back fine but the seed loop
|
||||
* only iterates bucket-bearing types, so a type that has live
|
||||
* measurements but no buckets falls out of the response entirely.
|
||||
*
|
||||
* The probe below joins `measurements`-distinct-types against
|
||||
* `measurement_rollups` so the caller learns, per type, whether the
|
||||
* bucket table covers it. Types with `hasBuckets=false` fall back to
|
||||
* the live aggregator branch; types with `hasBuckets=true` ride the
|
||||
* cheap composed branch. The result is a single round-trip indexed
|
||||
* read regardless of how many types the user has logged.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Per-type coverage map. `true` means at least one DAY rollup row
|
||||
* exists for this `(user, type)` pair; the caller can safely compose
|
||||
* `count / min / max / mean` from the bucket table for this type.
|
||||
* `false` means the bucket table is empty for this type and the caller
|
||||
* must fall back to the live aggregate.
|
||||
*
|
||||
* Types the user has never logged are absent from the map — callers
|
||||
* never need to ask about a type with zero measurements.
|
||||
*/
|
||||
export type RollupCoverageMap = Map<string, boolean>;
|
||||
|
||||
/**
|
||||
* Probe DAY-bucket coverage for every type the user has measurements
|
||||
* for. The join is anchored on the smaller `DISTINCT type FROM
|
||||
* measurements` set so the planner picks the per-type
|
||||
* `(user_id, type, measured_at)` index path; the LEFT JOIN onto the
|
||||
* rollup table uses the `(user_id, type, granularity, bucket_start)`
|
||||
* composite primary key. A `COUNT > 0` per partition keeps the result
|
||||
* shape stable even when a type has zero buckets.
|
||||
*/
|
||||
export async function probeRollupCoverage(
|
||||
userId: string,
|
||||
): Promise<RollupCoverageMap> {
|
||||
const rows = await prisma.$queryRaw<
|
||||
Array<{ type: string; has_buckets: boolean }>
|
||||
>`
|
||||
SELECT
|
||||
m."type"::text AS type,
|
||||
COUNT(r.*) > 0 AS has_buckets
|
||||
FROM (
|
||||
SELECT DISTINCT "type"
|
||||
FROM measurements
|
||||
WHERE user_id = ${userId}
|
||||
) m
|
||||
LEFT JOIN measurement_rollups r
|
||||
ON r.user_id = ${userId}
|
||||
AND r."type" = m."type"
|
||||
AND r.granularity = 'DAY'
|
||||
GROUP BY m."type"
|
||||
`;
|
||||
const coverage: RollupCoverageMap = new Map();
|
||||
for (const row of rows) {
|
||||
coverage.set(row.type, Boolean(row.has_buckets));
|
||||
}
|
||||
return coverage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the set of types the user has logged that do NOT yet have
|
||||
* DAY-rollup coverage. The bucket-fresh read paths use this to decide
|
||||
* which types still need the live aggregator branch.
|
||||
*/
|
||||
export function typesMissingCoverage(coverage: RollupCoverageMap): string[] {
|
||||
const missing: string[] = [];
|
||||
for (const [type, hasBuckets] of coverage.entries()) {
|
||||
if (!hasBuckets) missing.push(type);
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience — `true` when the user has at least one measurement and
|
||||
* every type with measurements also has DAY-bucket coverage. The read
|
||||
* path can skip the live aggregate entirely.
|
||||
*/
|
||||
export function isFullyCovered(coverage: RollupCoverageMap): boolean {
|
||||
if (coverage.size === 0) return false;
|
||||
for (const hasBuckets of coverage.values()) {
|
||||
if (!hasBuckets) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -30,6 +30,7 @@
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import type {
|
||||
MeasurementType,
|
||||
RollupGranularity,
|
||||
@@ -616,8 +617,21 @@ export async function ensureUserRollupsFresh(
|
||||
to: new Date(),
|
||||
});
|
||||
return { recomputed: true };
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// Best-effort — never block the read path on a populator failure.
|
||||
// v1.4.36 QA H3 — surface the failure so ops can spot a silent
|
||||
// populator regression. `annotate` is a no-op when there's no
|
||||
// request context (worker calls, tests); `console.error` carries
|
||||
// the same shape through the worker log pipeline so the regression
|
||||
// is visible regardless of caller surface.
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
annotate({
|
||||
meta: {
|
||||
rollup_refresh_failed: true,
|
||||
rollup_refresh_error: message,
|
||||
},
|
||||
});
|
||||
console.error("[rollups] ensureUserRollupsFresh failed:", message);
|
||||
return { recomputed: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,9 @@ describe("coachPrefsSchema", () => {
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("caps excludeMetrics at 9 entries", () => {
|
||||
it("caps excludeMetrics at 11 entries", () => {
|
||||
// v1.4.36 W3 T2 — cap raised from 9 to 11 to admit the two new
|
||||
// optional-context toggles (medications, anthropometrics).
|
||||
const result = coachPrefsSchema.safeParse({
|
||||
excludeMetrics: [
|
||||
"bp",
|
||||
@@ -68,11 +70,20 @@ describe("coachPrefsSchema", () => {
|
||||
"sleep",
|
||||
"resting_hr",
|
||||
"steps",
|
||||
"medications",
|
||||
"anthropometrics",
|
||||
"bp",
|
||||
],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts the new medications + anthropometrics tokens", () => {
|
||||
const out = coachPrefsSchema.parse({
|
||||
excludeMetrics: ["medications", "anthropometrics"],
|
||||
});
|
||||
expect(out.excludeMetrics).toEqual(["medications", "anthropometrics"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCoachPrefs", () => {
|
||||
|
||||
@@ -55,6 +55,14 @@ export const coachExcludeMetricEnum = z.enum([
|
||||
"sleep",
|
||||
"resting_hr",
|
||||
"steps",
|
||||
// v1.4.36 W3 T2 — optional context blocks the user can opt out of.
|
||||
// `medications` covers the snapshot's compliance + GLP-1 weeklyContext;
|
||||
// `anthropometrics` covers height / age / gender on `context`. Each
|
||||
// gates a single labelled block at the snapshot/feature layer, so an
|
||||
// excluded block never lands in the prompt at all (not even as a
|
||||
// labelled-empty key).
|
||||
"medications",
|
||||
"anthropometrics",
|
||||
]);
|
||||
export type CoachExcludeMetric = z.infer<typeof coachExcludeMetricEnum>;
|
||||
|
||||
@@ -83,7 +91,7 @@ export type CoachDefaultWindow = z.infer<typeof coachDefaultWindowEnum>;
|
||||
export const coachPrefsSchema = z.object({
|
||||
tone: coachToneEnum.default("warm"),
|
||||
verbosity: coachVerbosityEnum.default("default"),
|
||||
excludeMetrics: z.array(coachExcludeMetricEnum).max(9).default([]),
|
||||
excludeMetrics: z.array(coachExcludeMetricEnum).max(11).default([]),
|
||||
showEvidenceByDefault: z.boolean().default(false),
|
||||
defaultWindow: coachDefaultWindowEnum.default("allTime"),
|
||||
});
|
||||
|
||||
@@ -262,6 +262,16 @@ export const listMeasurementsSchema = z.object({
|
||||
// Omitting `aggregate` keeps the raw wire shape (iOS contract); the
|
||||
// chart-data client must opt in explicitly.
|
||||
aggregate: z.enum(["raw", "daily", "weekly", "monthly"]).optional(),
|
||||
// v1.4.36 W1 — opt-in source switch for daily-aggregate reads. When
|
||||
// `source=rollup` + `aggregate=daily`, the route reads from the
|
||||
// persistent `measurement_rollups` DAY buckets instead of running a
|
||||
// live `date_trunc` GROUP BY scan over the raw `measurements` table.
|
||||
// The chart-data client opts in for the trends-row strip + every
|
||||
// sub-page chart so the three parallel daily-aggregate requests stop
|
||||
// burning a full table scan each. The route falls back to live SQL
|
||||
// when the rollup bucket set is empty for the requested window so
|
||||
// brand-new accounts still see correct data on their first chart.
|
||||
source: z.enum(["rollup"]).optional(),
|
||||
});
|
||||
|
||||
export const createBatchMeasurementSchema = z.object({
|
||||
|
||||
@@ -630,4 +630,196 @@ describe("measurement rollups — integration", () => {
|
||||
|
||||
vi.mocked(getGlobalBoss).mockReturnValue(null as never);
|
||||
});
|
||||
|
||||
// ─── v1.4.36 rollup-fresh skip-live contract ──────────────────
|
||||
//
|
||||
// The v1.4.35 read-swap kept the heavy live aggregate running in
|
||||
// parallel with the rollup read. v1.4.36 drops that — when the
|
||||
// rollup table has DAY buckets for the user, the heavy aggregate
|
||||
// is bypassed entirely. This test pins that the rollup-derived
|
||||
// response is still byte-identical to a parallel live aggregate
|
||||
// over the same rows AND that the response shape matches the
|
||||
// pre-swap contract for every field.
|
||||
|
||||
it("rollup-fresh response matches live SQL for every DataSummary field", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "rollup-skip-live-user",
|
||||
email: "rollup-skip-live@example.test",
|
||||
role: "USER",
|
||||
},
|
||||
});
|
||||
|
||||
// 60 readings over 30 days, two types, deliberately spread so
|
||||
// anomalies + slopes + windowed avgs all have signal.
|
||||
const now = Date.now();
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
const seedRows: Array<{
|
||||
userId: string;
|
||||
type: "WEIGHT" | "PULSE";
|
||||
value: number;
|
||||
unit: string;
|
||||
source: "MANUAL";
|
||||
measuredAt: Date;
|
||||
}> = [];
|
||||
for (let day = 0; day < 30; day++) {
|
||||
seedRows.push({
|
||||
userId: user.id,
|
||||
type: "WEIGHT",
|
||||
value: 80 + day * 0.05 + ((day * 7) % 5) * 0.1,
|
||||
unit: "kg",
|
||||
source: "MANUAL",
|
||||
measuredAt: new Date(now - (29 - day) * dayMs - day * 60 * 1000),
|
||||
});
|
||||
seedRows.push({
|
||||
userId: user.id,
|
||||
type: "PULSE",
|
||||
value: 65 + ((day * 3) % 11) * 0.4,
|
||||
unit: "bpm",
|
||||
source: "MANUAL",
|
||||
measuredAt: new Date(now - (29 - day) * dayMs - day * 90 * 1000),
|
||||
});
|
||||
}
|
||||
await prisma.measurement.createMany({ data: seedRows });
|
||||
|
||||
// Pre-fold the rollup buckets so the rollup-fresh path engages.
|
||||
await recomputeUserRollups(user.id, { granularities: ["DAY"] });
|
||||
|
||||
// Parallel live aggregation over the same 90-day window using the
|
||||
// exact heavy-aggregate shape the cold-mount fallback runs.
|
||||
const ninetyDaysAgo = new Date(now - 90 * dayMs);
|
||||
const live = await prisma.$queryRaw<
|
||||
Array<{
|
||||
type: string;
|
||||
count: bigint;
|
||||
min_value: number;
|
||||
max_value: number;
|
||||
mean_value: number;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
m."type"::text AS type,
|
||||
COUNT(*) AS count,
|
||||
MIN(m."value")::double precision AS min_value,
|
||||
MAX(m."value")::double precision AS max_value,
|
||||
AVG(m."value")::double precision AS mean_value
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${user.id}
|
||||
AND m."measured_at" >= ${ninetyDaysAgo}
|
||||
GROUP BY m."type"
|
||||
`;
|
||||
|
||||
const aggregate = await buildComprehensiveAggregate(user.id);
|
||||
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
expect(live.length).toBeGreaterThan(0);
|
||||
for (const liveRow of live) {
|
||||
const summary = aggregate.summaries[liveRow.type];
|
||||
expect(summary, `summary for ${liveRow.type}`).toBeDefined();
|
||||
// count / min / max / mean compose from buckets — byte-identical
|
||||
// to live SQL over the same rows.
|
||||
expect(summary.count).toBe(Number(liveRow.count));
|
||||
expect(summary.min).toBe(round2(liveRow.min_value));
|
||||
expect(summary.max).toBe(round2(liveRow.max_value));
|
||||
expect(summary.mean).toBe(round2(liveRow.mean_value));
|
||||
// Narrow aggregate fills the non-composable windowed columns.
|
||||
// Slope tuples and avg7/avg30 are sourced from live SQL even on
|
||||
// the rollup-fresh path so the values are present (not null) as
|
||||
// long as the windowed avg has rows to chew on.
|
||||
expect(summary.avg30).not.toBeNull();
|
||||
// Latest reading comes from the DISTINCT ON pass — never null
|
||||
// when the user has rows for the type.
|
||||
expect(summary.latest).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to the live aggregate for a type without rollup coverage", async () => {
|
||||
// v1.4.36 QA C1 — pre-fix the global rollup probe flipped to true
|
||||
// as soon as ANY type had a DAY bucket, which made the comprehensive
|
||||
// response collapse a brand-new type's all-time count to whatever
|
||||
// the bucket-derived path could see (often 1 — the freshly-written
|
||||
// bucket). This guards the per-type probe so adding a first WEIGHT
|
||||
// measurement to a BP-rolled-up account doesn't truncate the WEIGHT
|
||||
// summary.
|
||||
const prisma = getPrismaClient();
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "rollup-coverage-mix",
|
||||
email: "rollup-coverage-mix@example.test",
|
||||
role: "USER",
|
||||
},
|
||||
});
|
||||
const now = Date.now();
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Seed BP — multiple days within the 90-day window so the rollup
|
||||
// path has something to compose against.
|
||||
const bpRows = [];
|
||||
for (let day = 0; day < 10; day += 1) {
|
||||
bpRows.push({
|
||||
userId: user.id,
|
||||
type: "BLOOD_PRESSURE_SYS" as const,
|
||||
value: 120 + day,
|
||||
unit: "mmHg",
|
||||
source: "MANUAL" as const,
|
||||
measuredAt: new Date(now - day * dayMs),
|
||||
});
|
||||
}
|
||||
await prisma.measurement.createMany({ data: bpRows });
|
||||
|
||||
// Pre-fold BP DAY buckets only. WEIGHT will be added below WITHOUT
|
||||
// a populator pass so its coverage probe returns false.
|
||||
await recomputeUserRollups(user.id, {
|
||||
granularities: ["DAY"],
|
||||
types: ["BLOOD_PRESSURE_SYS"],
|
||||
});
|
||||
|
||||
// Insert WEIGHT measurements directly via Prisma — no rollup
|
||||
// populator runs, no DAY bucket exists for WEIGHT.
|
||||
const weightRows = [];
|
||||
for (let day = 0; day < 5; day += 1) {
|
||||
weightRows.push({
|
||||
userId: user.id,
|
||||
type: "WEIGHT" as const,
|
||||
value: 80 + day,
|
||||
unit: "kg",
|
||||
source: "MANUAL" as const,
|
||||
measuredAt: new Date(now - day * dayMs - 1000),
|
||||
});
|
||||
}
|
||||
await prisma.measurement.createMany({ data: weightRows });
|
||||
|
||||
// Confirm setup: BP has rollups, WEIGHT does not.
|
||||
const bpRollups = await prisma.measurementRollup.count({
|
||||
where: { userId: user.id, type: "BLOOD_PRESSURE_SYS", granularity: "DAY" },
|
||||
});
|
||||
const weightRollups = await prisma.measurementRollup.count({
|
||||
where: { userId: user.id, type: "WEIGHT", granularity: "DAY" },
|
||||
});
|
||||
expect(bpRollups).toBeGreaterThan(0);
|
||||
expect(weightRollups).toBe(0);
|
||||
|
||||
const aggregate = await buildComprehensiveAggregate(user.id);
|
||||
|
||||
// Parallel live SQL — the contract is that the aggregator surfaces
|
||||
// a WEIGHT summary whose count matches the actual measurement
|
||||
// count, not zero (pre-fix) and not a bucket-derived partial.
|
||||
const liveCounts = await prisma.$queryRaw<
|
||||
Array<{ type: string; count: bigint }>
|
||||
>`
|
||||
SELECT m."type"::text AS type, COUNT(*) AS count
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${user.id}
|
||||
AND m."type" = 'WEIGHT'
|
||||
GROUP BY m."type"
|
||||
`;
|
||||
expect(liveCounts).toHaveLength(1);
|
||||
const expectedWeightCount = Number(liveCounts[0].count);
|
||||
|
||||
expect(aggregate.summaries.WEIGHT).toBeDefined();
|
||||
expect(aggregate.summaries.WEIGHT.count).toBe(expectedWeightCount);
|
||||
expect(aggregate.summaries.BLOOD_PRESSURE_SYS).toBeDefined();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user