mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
85 files changed
+4143
-1857
No files matched your search
+104
-79
@@ -2,6 +2,44 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.35.2] — 2026-08-01
|
||||
|
||||
### Added
|
||||
|
||||
- The cycle ring now reads its verdict from the server: which day of the cycle
|
||||
you are on, how many days until the next one is expected, and whether a period
|
||||
is late, with the number of days. Both clients read the same answer instead of
|
||||
each working one out.
|
||||
|
||||
### Changed
|
||||
|
||||
- The Health Score turns green at 70, the same threshold the readiness and sleep
|
||||
scores already used. A weak pillar still holds the band down no matter how good
|
||||
the average is, so a single poor area cannot be averaged out of sight.
|
||||
- The score settings page was rebuilt to match the other settings cards: one
|
||||
reading edge, no separator lines, the save button where the other cards keep it.
|
||||
- The Fitness pillar is gone. It could never produce a value, because the record
|
||||
cannot tell a measured fitness test from a device estimate, so it was offering a
|
||||
choice that did nothing.
|
||||
- The arrival line under the greeting is gone. It never said what had arrived.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A saved score selection now comes back exactly as it was sent. One pillar was
|
||||
quietly stripped on the way to the server, so the page could never agree with
|
||||
what it had saved and the save button stayed lit for good.
|
||||
- The cycle wheel follows the timezone on your profile rather than the one your
|
||||
browser happens to be in.
|
||||
|
||||
### Contract
|
||||
|
||||
- Two share-link fields that were agreed for retirement are no longer served.
|
||||
- A conflict on a repeated request now returns its error the way every other
|
||||
route does. One place in the whole codebase answered with a different shape.
|
||||
- Five gaps in the published specification are closed, including a route that
|
||||
shipped with the score settings and was never described at all, and one that
|
||||
had been promised for months.
|
||||
|
||||
## [1.35.1] — 2026-08-01
|
||||
|
||||
### Fixed
|
||||
@@ -3380,12 +3418,11 @@ one coherent whole.
|
||||
|
||||
### Correctness, resilience and polish
|
||||
|
||||
The same line carried a full audit sweep: DST-safe medication windows and
|
||||
The same line carried a broad correctness sweep: DST-safe medication windows and
|
||||
user-timezone compliance; graceful degradation (worker timeouts, per-metric sync
|
||||
freshness, offline-write safety, per-source isolation, honest "unreadable"
|
||||
markers); no more dashboard dead-ends and honest error/empty states; accessibility
|
||||
contrast and semantics; and cold-start + performance fixes — down to the low-priority
|
||||
findings.
|
||||
contrast and semantics; and cold-start + performance fixes.
|
||||
|
||||
## [1.27.33] — 2026-07-09 — Chat about a document
|
||||
|
||||
@@ -5950,7 +5987,7 @@ A broad polishing release across the medication, dashboard, export, and HealthKi
|
||||
|
||||
### Tests
|
||||
|
||||
- 5628 → 5878 unit; 1 skipped. New coverage spans the recurrence-engine PRN/cyclic cases, cadence-canonical compliance parity per cadence type, the FHIR bundle shape and LOINC/UCUM mapping, the KVNR validator, the export route, the dashboard snapshot envelope and pre-generation cron, the measurement delta feed and soft-delete tombstones, the daily-mean consolidation drain, and the display-unit transforms. A six-reviewer audit (correctness, security, architecture, design/a11y, simplification, i18n) ran before release; every High and Medium finding was resolved.
|
||||
- 5628 → 5878 unit; 1 skipped. New coverage spans the recurrence-engine PRN/cyclic cases, cadence-canonical compliance parity per cadence type, the FHIR bundle shape and LOINC/UCUM mapping, the KVNR validator, the export route, the dashboard snapshot envelope and pre-generation cron, the measurement delta feed and soft-delete tombstones, the daily-mean consolidation drain, and the display-unit transforms.
|
||||
|
||||
## [1.6.0] — 2026-05-30 — Medication editor overhaul, route of administration, today-tile read-flip
|
||||
|
||||
@@ -6012,12 +6049,12 @@ v1.5.5 gave every retired medication feature a home on the detail page, but the
|
||||
|
||||
## [1.5.5] — 2026-05-29 — Medication detail page, iOS coordination, outbound-fetch hardening
|
||||
|
||||
The v1.5.4 modal wizard landed the create + edit plan flow, but it took 16 features down with the retired flat form — Einnahmen bearbeiten / löschen, Medikament pausieren / beenden / löschen, per-Med API tokens, Phasen-Konfiguration, CSV-Import — and the trends-row on Insights had a 34 px overflow that pushed annotation copy on top of the charts. This release lands the medication detail page that gives every retired feature a coherent home, polishes the wizard against the live walk-through feedback, closes the iOS audit follow-ups in one go, and hardens every outbound fetch the server makes.
|
||||
The v1.5.4 modal wizard landed the create + edit plan flow, but it took 16 features down with the retired flat form — Einnahmen bearbeiten / löschen, Medikament pausieren / beenden / löschen, per-Med API tokens, Phasen-Konfiguration, CSV-Import — and the trends-row on Insights had a 34 px overflow that pushed annotation copy on top of the charts. This release lands the medication detail page that gives every retired feature a coherent home, polishes the wizard, closes the iOS audit follow-ups in one go, and hardens every outbound fetch the server makes.
|
||||
|
||||
### Added
|
||||
|
||||
- **`/medications/[id]/page.tsx`** — a single Server-Component detail page composed of eight sections (Header band → Today's-dose → Cadence summary → Phasen (GLP-1) → Intake history preview → Notifications → Settings → Verwaltung & Gefahrenzone). One-shot medications walk a five-section variant; paused medications keep the structure but the status pill flips to `Pausiert` and the dose-card surface mutes. Restores every one of the 16 features the v1.5.4 flat-form retirement displaced.
|
||||
- **Per-row + bulk intake history actions** — the preview now ships a per-row kebab (`Bearbeiten` / `Löschen`) and a multi-select toolbar that fires the new `POST /api/medications/{id}/intake/bulk-delete` endpoint. The maintainer's „Einnahmen löschen" + „Einnahmen bearbeiten" complaints from the v1.5.4 walk-through close cleanly.
|
||||
- **Per-row + bulk intake history actions** — the preview now ships a per-row kebab (`Bearbeiten` / `Löschen`) and a multi-select toolbar that fires the new `POST /api/medications/{id}/intake/bulk-delete` endpoint.
|
||||
- **`safeFetch` wrapper** at `src/lib/safe-fetch.ts` — every outbound fetch the server makes for user-supplied hosts now inherits `redirect: "manual"` + `AbortSignal.timeout(15_000)` defaults. Closes #218.
|
||||
- **DNS-rebinding pinned `undici.Agent`** at `src/lib/safe-fetch-dispatcher.ts` — when `requirePublicHost: true`, the dispatcher resolves the hostname literally inside the connect hook, refuses any address `isPublicIp` would reject, and pins the connection to the first valid public address. The five user-host paths (MoodLog sync + push + test, local-AI client, ntfy) route through it. Closes #217.
|
||||
- **Self-hosted avatar storage** at `POST/GET/DELETE /api/user/avatar` — multipart-uploaded JPEG/PNG/WebP, 2 MiB / 2048×2048 max, hand-rolled magic-byte sniff + dimension probe so no native dependency lands. Stored on the User row as BYTEA; rides `pg_dump` alongside the rest of the row. The profile response carries an `avatarUrl` with a cache-busting timestamp. `src/lib/gravatar.ts` retires — Automattic no longer sees the email-hash on every authenticated page load.
|
||||
@@ -6056,7 +6093,7 @@ The v1.5.4 modal wizard landed the create + edit plan flow, but it took 16 featu
|
||||
- `safeFetch` + DNS-rebinding pinned dispatcher close issues #217 + #218 architecturally — the input-time `isPublicUrl` guard now pairs with a connect-time IP pin so DNS rebinding cannot flip the resolved host between accept and dispatch.
|
||||
- Avatar route enforces size before parse (Content-Length pre-flight + post-parse `file.size` check), magic-byte sniff over the declared content-type, dimension probe, owner-scoped on every method. No new XSS vector — the served content-type is whitelisted to the three image MIMEs.
|
||||
- `assertMedicationOwnership` sweep closes every detail-page route's ownership narrowing — the route layer is now the single ownership predicate across `src/app/api/medications/[id]/**`.
|
||||
- Pre-tag senior-dev + security architect audit produced two docs (internal working notes). The four senior-dev Criticals + five Highs landed in code; F-1 H-5 (`safeFetch` migration to constant-host call sites like Withings + Codex + the GitHub bug-reporter) + F-2 M-1..M-3 (operator-host `requirePublicHost`, avatar chunked-body pre-flight, raw-fetch lint rule) deferred to v1.5.5.1.
|
||||
- Deferred to v1.5.5.1: the `safeFetch` migration for the constant-host call sites (Withings, Codex, the GitHub bug-reporter), `requirePublicHost` on operator-supplied hosts, the avatar chunked-body pre-flight, and the raw-fetch lint rule.
|
||||
|
||||
### iOS coord
|
||||
|
||||
@@ -6090,7 +6127,7 @@ The v1.5.3 creation wizard shipped as a single-page card with seven inline steps
|
||||
### Fixed
|
||||
|
||||
- **Multi-schedule data-loss risk** — the v1.5.3 wizard collapsed a multi-schedule medication to its first schedule on save and silently dropped the rest. Compose-mode is the proper fix; the encoder now emits every schedule, the hydrator reads every schedule, the per-schedule `id` round-trips so the PUT preserves identity.
|
||||
- **The edit form being wider than its container** — a specific user complaint from the v1.5.3 hand-walk. The container is now a constrained Dialog at `sm:max-w-md` on desktop and a sticky-footer Sheet at `max-h-[90dvh]` on mobile; the form-as-page surface is gone.
|
||||
- **The edit form being wider than its container** — the container is now a constrained Dialog at `sm:max-w-md` on desktop and a sticky-footer Sheet at `max-h-[90dvh]` on mobile; the form-as-page surface is gone.
|
||||
|
||||
### Tests
|
||||
|
||||
@@ -6100,7 +6137,7 @@ The v1.5.3 creation wizard shipped as a single-page card with seven inline steps
|
||||
|
||||
### Notes
|
||||
|
||||
- **No API or wire-format change.** The wizard writes the same `createMedicationSchema` payload the v1.5.3 wizard wrote; iOS clients reading and writing the existing schedule shape are unaffected. The native `treatmentClass` enum already shipped `GLP1`; the wizard's Step 2 maps the maintainer-confirmed labels onto the existing enum + the two new `MedicationCategory` values.
|
||||
- **No API or wire-format change.** The wizard writes the same `createMedicationSchema` payload the v1.5.3 wizard wrote; iOS clients reading and writing the existing schedule shape are unaffected. The native `treatmentClass` enum already shipped `GLP1`; the wizard's Step 2 maps its treatment-class labels onto the existing enum + the two new `MedicationCategory` values.
|
||||
- **Cadence-engine read-flip is still v1.5.x deferred** — the today-projector, the cadence chart, and the medication card continue to read the legacy `daysOfWeek` / `intervalWeeks` columns. A wizard-minted rolling or one-shot medication will render a daily-looking next-due chip on the dashboard until the read-flip ships. The reminder worker and the engine work with the new fields correctly.
|
||||
- **Test totals.** 5500 unit + 1 skipped, 261 integration + 3 skipped, 14 Playwright wizard instances. Typecheck, lint, `openapi:check`, i18n call-site coverage, locale-integrity all green at HEAD.
|
||||
|
||||
@@ -6338,7 +6375,7 @@ Post-deploy production logs from `/api/analytics` (default + slim, cold cache) c
|
||||
|
||||
## [1.4.49] — 2026-05-23 — Server-side reminder suppression + diagnostic endpoint backed + backlog closure
|
||||
|
||||
v1.4.47 closed the high-priority punch list. v1.4.49 bundles the remaining v1.4.47 follow-ups together with the items deferred out of v1.4.48: server-side suppression of `MEDICATION_REMINDER` APNs for iOS clients that manage their own local reminders, a `push_attempts` table backing the diagnostic endpoint, the `/api/admin/notifications/diagnostic` OpenAPI entry, and a sweep of v1.4.48 forward-findings (Withings reason-tagging, observability PII hardening, MoodReminderCard auto-clear parity, simplifier dead-code, sub-locale copy gaps). The workout-batch integration suite that had been red on `main` for three releases turns green; the cold-mount analytics fallback path picks up the same 90-day outer cap v1.4.47.1 shipped for the rollup-fresh path; iOS validation-failure audit rows carry the rejected payload shape so iOS serialiser drift can be chased from a single log line.
|
||||
v1.4.47 closed the high-priority punch list. v1.4.49 bundles the remaining v1.4.47 follow-ups together with the items deferred out of v1.4.48: server-side suppression of `MEDICATION_REMINDER` APNs for iOS clients that manage their own local reminders, a `push_attempts` table backing the diagnostic endpoint, the `/api/admin/notifications/diagnostic` OpenAPI entry, and a sweep of the v1.4.48 carry-overs (Withings reason-tagging, observability PII hardening, MoodReminderCard auto-clear parity, simplifier dead-code, sub-locale copy gaps). The workout-batch integration suite that had been red on `main` for three releases turns green; the cold-mount analytics fallback path picks up the same 90-day outer cap v1.4.47.1 shipped for the rollup-fresh path; iOS validation-failure audit rows carry the rejected payload shape so iOS serialiser drift can be chased from a single log line.
|
||||
|
||||
### Added
|
||||
|
||||
@@ -6656,14 +6693,14 @@ Fourteen independent sub-tasks landed on `develop` before this release commit.
|
||||
- **`phase-config-dialog.tsx` Input + Button bump to 44 px on mobile** — drops `text-sm` from the Input so the iOS-zoom-on-focus is closed.
|
||||
- **`correlation-card.tsx` skeleton mirrors the loaded scatter chart's aspect ratio** — `aspect-square sm:aspect-[3/2] min-h-[180px] sm:h-auto`. Closes the ~60 px CLS on the insights cold mount.
|
||||
- **`insights-tab-strip.tsx` group-popover items bumped to 44 px** — outer tab pills already met the floor; inner popover items lagged.
|
||||
- **`chart-overlay-controls.tsx` trigger adopts the responsive `min-h-11 sm:min-h-9` pattern** — consistent with the v1.4.33 maintainer-item-7 settlement.
|
||||
- **`chart-overlay-controls.tsx` trigger adopts the responsive `min-h-11 sm:min-h-9` pattern** — consistent with the responsive tap-target floor v1.4.33 converged on.
|
||||
- **`RecentWorkoutsTile` + `DrugLevelChart` reserve loaded-card height during loading** — no card-pop on first paint.
|
||||
- **`dedupeWorkoutBatch` honours `User.sourcePriorityJson`** — write-time picker threads the same priority ladder the read-time picker uses. The v1.4.42 docstring warning ("does NOT consult sourcePriorityJson") is now obsolete; the batch route does a single `prisma.user.findUnique` lookup before the dedup pass.
|
||||
- **`/api/dashboard/widgets` 422 audit-ledger row dedupes per `(userId, action)` over 60 s** — a misconfigured iOS client retrying every 100 ms no longer writes 10 audit rows per second. The 422 response is still per-call; only the audit-ledger row dedupes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`auth.login.failed` audit row no longer persists the raw typed identifier** — replaced with `identifier_hash: hashToken(identifier)`. Closes the PII gap surfaced by the v1.4.20 retroactive directive: an admin scrolling `/api/admin/audit-log` no longer sees every wrong-email a user typo'd at the login screen, and a future audit-table compromise no longer hands an attacker a list of probed identifiers.
|
||||
- **`auth.login.failed` audit row no longer persists the raw typed identifier** — replaced with `identifier_hash: hashToken(identifier)`. Closes a PII gap: an admin scrolling `/api/admin/audit-log` no longer sees every wrong-email a user typo'd at the login screen, and a future audit-table compromise no longer hands an attacker a list of probed identifiers.
|
||||
- **`WithingsApiError.message` capped at 1024 chars in the constructor** — the v1.4.42 classifier persisted the upstream `json.error` string verbatim into `AuditLog.details`. Withings is the trusted upstream but the operator-readable audit row should match the encrypted-error column's 1024-char cap.
|
||||
- **Chart empty-state copy split on raw measurement count** — `<HealthChart>`, `<MoodChart>`, `<MedicationComplianceChart>` now distinguish "fewer than 3 entries" (empty-state title) from "many entries but only on 1-2 distinct days" (new `needMoreDistinctDays` title). The user-reported "Erfasse 3 Einträge" on a populated account is closed.
|
||||
- **Chart `aria-busy` empty branch no longer silently returns null** — replaced with a `<ChartEmptyState>` rendering the `noDataInRange` copy.
|
||||
@@ -6759,7 +6796,7 @@ v1.4.41 closed the iOS BP/Weight 14 s perf paper-cut and the soft-delete reader-
|
||||
|
||||
## [1.4.41] — 2026-05-21 — iOS perf hotfix, soft-delete completeness, tree hygiene
|
||||
|
||||
v1.4.40 closed the architecture-level critical and high-priority findings and shipped the iOS PB30 backend prerequisites. v1.4.41 is the follow-up: one user-visible perf hotfix on the iOS-facing insights endpoints (consolidated into a shared timeout-stub helper that now backs all seven status routes), three remaining soft-delete reader-tier gaps closed (invariant pinned by integration tests), type-consolidation across analytics + backups, a four-branch iOS-onboarding discovery endpoint with per-IP rate-limiting, and a code-hygiene pass that retires the v1.4.39 legacy-NULL UNION discovery arm, extracts the today-intake projection helper, and trims a batch of dead exports.
|
||||
v1.4.40 closed the architecture-level critical and high-priority issues and shipped the iOS PB30 backend prerequisites. v1.4.41 is the follow-up: one user-visible perf hotfix on the iOS-facing insights endpoints (consolidated into a shared timeout-stub helper that now backs all seven status routes), three remaining soft-delete reader-tier gaps closed (invariant pinned by integration tests), type-consolidation across analytics + backups, a four-branch iOS-onboarding discovery endpoint with per-IP rate-limiting, and a code-hygiene pass that retires the v1.4.39 legacy-NULL UNION discovery arm, extracts the today-intake projection helper, and trims a batch of dead exports.
|
||||
|
||||
### Added
|
||||
|
||||
@@ -6932,11 +6969,11 @@ Withings sync, `/api/import`, and admin-restore paths. New writes from
|
||||
those surfaces now fold the persistent rollup tier on touch, and the
|
||||
boot-time backfill discovery surfaces every per-(user, type, day) gap
|
||||
so a worker restart converges any stranded accounts. v1.4.39.2 closes
|
||||
two follow-ups the maintainer surfaced from the post-deploy trace: the dashboard
|
||||
chart still painted its `< 3 daily points` empty-state on the 30-point
|
||||
range for accounts whose historic rollup partition had not yet been
|
||||
folded, and the cold-mount UX waterfall left every per-type tile
|
||||
waiting on the heavy `/api/analytics` envelope.
|
||||
two gaps the post-deploy trace surfaced: the dashboard chart still
|
||||
painted its `< 3 daily points` empty-state on the 30-point range for
|
||||
accounts whose historic rollup partition had not yet been folded, and
|
||||
the cold-mount UX waterfall left every per-type tile waiting on the
|
||||
heavy `/api/analytics` envelope.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -8285,7 +8322,7 @@ viewport-zoom fix surfaced during the mobile-deep pass.
|
||||
|
||||
## [1.4.34.4] — 2026-05-17 — Hotfix bundle: security, UX, code-quality, docs
|
||||
|
||||
A consolidated hotfix landing findings across mobile security, UX + accessibility, code quality + docs,
|
||||
A consolidated hotfix landing fixes across mobile security, UX + accessibility, code quality + docs,
|
||||
README + discoverability, web performance, and mobile-deep responsive
|
||||
behaviour. None of the changes break the public API or the existing
|
||||
data shape; every fix is additive and SAFE under the v1.4.34.x web
|
||||
@@ -8434,14 +8471,13 @@ focus-visible:outline-none focus-visible:ring-offset-2`. Keyboard
|
||||
|
||||
## [1.4.34.3] — 2026-05-17 — Remove the dashboard Coach CTA
|
||||
|
||||
Per maintainer directive: the dashboard hero CTA that v1.4.34 added
|
||||
("Frag den Coach" sparkles button next to "Hinzufügen") is removed
|
||||
from the dashboard. The Coach surface remains reachable from the
|
||||
`/insights` tree where it originally lived; the auth-shell-level
|
||||
`<CoachLaunchProvider>` + drawer mount stay intact so other pages
|
||||
that want to surface a Coach CTA can opt in cleanly. The button was
|
||||
visually loud on the dashboard hero and not the right placement for
|
||||
the surface.
|
||||
The dashboard hero CTA that v1.4.34 added ("Frag den Coach" sparkles
|
||||
button next to "Hinzufügen") is removed from the dashboard. The Coach
|
||||
surface remains reachable from the `/insights` tree where it
|
||||
originally lived; the auth-shell-level `<CoachLaunchProvider>` +
|
||||
drawer mount stay intact so other pages that want to surface a Coach
|
||||
CTA can opt in cleanly. The button was visually loud on the dashboard
|
||||
hero and not the right placement for the surface.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -9422,15 +9458,15 @@ mount, GLP-1 detail-page intake history + inventory, the
|
||||
weekly-report route), four broken edges close (workout-edit duplicate-
|
||||
timestamp 409, BD-Zielbereich tile rebuilt on the shared
|
||||
`<TrendCard>` primitive, `/insights/puls` chart timeout fallback,
|
||||
sticky tab-strip scroll lock), and one consistency contract lands per
|
||||
maintainer directive: every medication-list row, every medication-
|
||||
detail section header, every trend tile, every Coach launch glyph,
|
||||
every chart-height contract reads on one shape. The HealthScore card
|
||||
grows to fill its column, gains an accessible `?` tooltip explaining
|
||||
the delta, and drops the placeholder "Wochenbericht erstellen"
|
||||
button. iOS contracts are intact; the only `/api/*` evolution is
|
||||
additive (the `aggregate=monthly` grain on `/api/measurements` and an
|
||||
internal-only `/api/internal/web-vitals` beacon route).
|
||||
sticky tab-strip scroll lock), and one consistency contract lands:
|
||||
every medication-list row, every medication-detail section header,
|
||||
every trend tile, every Coach launch glyph, every chart-height
|
||||
contract reads on one shape. The HealthScore card grows to fill its
|
||||
column, gains an accessible `?` tooltip explaining the delta, and
|
||||
drops the placeholder "Wochenbericht erstellen" button. iOS contracts
|
||||
are intact; the only `/api/*` evolution is additive (the
|
||||
`aggregate=monthly` grain on `/api/measurements` and an internal-only
|
||||
`/api/internal/web-vitals` beacon route).
|
||||
|
||||
### Removed
|
||||
|
||||
@@ -9457,10 +9493,8 @@ internal-only `/api/internal/web-vitals` beacon route).
|
||||
- **GLP-1 detail-page intake history + inventory.** The "Dosis-
|
||||
Historie" disclosure and "Bestand" section retire from the GLP-1
|
||||
medication detail page. The page now collapses to header,
|
||||
schedule, dose-titration ladder, and side-effects — every section
|
||||
the maintainer flagged as wanted; every section flagged as
|
||||
unwanted is gone. Inventory tracking remains opt-in for a future
|
||||
release.
|
||||
schedule, dose-titration ladder, and side-effects. Inventory
|
||||
tracking remains opt-in for a future release.
|
||||
- **Hero "Wochenbericht erstellen" button.** The placeholder
|
||||
affordance retires from the `/insights` action row. The row now
|
||||
carries a single "Coach fragen" button so the HealthScore card on
|
||||
@@ -9731,13 +9765,10 @@ tracking-tight`). DrugLevelChart's standalone header migrates to
|
||||
`from` is a four-line edit deferred to v1.4.29 — the bucketed
|
||||
rows carry a divergent shape and the chart adapter needs a small
|
||||
helper to merge bucketed vs raw inputs.
|
||||
- **Medium-tier findings across surfaces.** The simplifier, design, UI-conformity,
|
||||
i18n, and senior-dev passes each surfaced a medium-tier
|
||||
backlog. Closed only the high-impact items in this release;
|
||||
medium-tier items (8 design, 4 UI-conformity, 5 i18n, 7 senior-
|
||||
dev, plus the `<ResponsiveSheet>` footer-slot wiring and the 5
|
||||
`<Dialog>` consumers still to migrate) defer to v1.4.29 per the
|
||||
scope-discipline directive ("less scope, more depth").
|
||||
- **Medium-priority polish across surfaces.** Design, UI-conformity,
|
||||
i18n, and code-quality items of medium priority defer to v1.4.29,
|
||||
along with the `<ResponsiveSheet>` footer-slot wiring and the 5
|
||||
`<Dialog>` consumers still to migrate.
|
||||
- **Five admin / monitoring orphan endpoints.** Unchanged from
|
||||
v1.4.27. The wire-or-remove decision still pends — each is
|
||||
documented in README but has no runtime consumer. Carry forward
|
||||
@@ -9745,7 +9776,7 @@ tracking-tight`). DrugLevelChart's standalone header migrates to
|
||||
|
||||
## [1.4.27] — 2026-05-15
|
||||
|
||||
Mobile capability + maintainer-finding cleanup. The headline is a new
|
||||
Mobile capability + accessibility cleanup. The headline is a new
|
||||
`<ResponsiveSheet>` primitive that switches between a bottom-sheet on
|
||||
narrow viewports and a centred dialog on desktop; every primary form
|
||||
entry mounts through it with sticky-pinned Save / Cancel above the soft
|
||||
@@ -9761,9 +9792,8 @@ availability with empty-state CTAs that route into a self-opening
|
||||
measurement form. Login overview surfaces an offline-resolved carrier
|
||||
chip via bundled MaxMind GeoLite2-City + GeoLite2-ASN MMDBs. A new
|
||||
`<NativeSelect>` primitive replaces five raw selects across settings
|
||||
and admin. 26 of the 27 maintainer findings landed; the weekly-report
|
||||
dead-click ask defers to v1.4.28 pending a screenshot. Migration 0061
|
||||
is additive, `IF NOT EXISTS`-guarded, and forward-only.
|
||||
and admin. The weekly-report dead-click defers to v1.4.28. Migration
|
||||
0061 is additive, `IF NOT EXISTS`-guarded, and forward-only.
|
||||
|
||||
### Added
|
||||
|
||||
@@ -9887,8 +9917,7 @@ is additive, `IF NOT EXISTS`-guarded, and forward-only.
|
||||
- **iOS handoff addendum.** A new coordination note
|
||||
documents the standalone-then-pair pattern for the iOS native
|
||||
client. Sibling of the existing `22-offline-first-architecture.md`;
|
||||
same research input, neutral framing per the v1.4.27 convention
|
||||
directive.
|
||||
same research input, neutral framing.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -10291,7 +10320,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
|
||||
enum with eight site values. A new body-map picker proposes the
|
||||
next rotation site based on the last fourteen days of injections.
|
||||
Medication-card grows a `glp1` variant (text-rich, no inline
|
||||
chart per directive — chart lives on the dashboard tile and
|
||||
chart; the chart lives on the dashboard tile and
|
||||
`/insights/medikamente` therapy timeline). Dashboard tile shows
|
||||
next-injection schedule + week-over-week weight delta. Weight
|
||||
chart gains vertical injection markers. Therapy timeline on the
|
||||
@@ -10943,8 +10972,8 @@ estimates and source citations, is tracked internally. Headline items:
|
||||
client-side only; the dashboard-widgets seed feature reads it in
|
||||
v1.4.26).
|
||||
- `advance()` hook extraction for the three onboarding step
|
||||
components — pattern surfaced in the simplifier review, deferred
|
||||
to avoid collision with v1.4.25 touch-target work.
|
||||
components — deferred to avoid collision with v1.4.25 touch-target
|
||||
work.
|
||||
- `glp1-pk.ts` unused-export decision (internalise vs wire
|
||||
dashboard chip).
|
||||
- Seven orphan endpoint go / no-go decisions
|
||||
@@ -11498,16 +11527,15 @@ NOT NULL`) is the defence-in-depth backstop.
|
||||
live in the internal ops notes. Future
|
||||
releases should drop the host-side retag fallback the moment
|
||||
the toggle is on.
|
||||
- **191 maintainer-name references in `src/` source comments
|
||||
swept** (FX carry-over from v1.4.20). Test fixtures kept as
|
||||
- **191 personal-name references in `src/` source comments
|
||||
swept** (carried over from v1.4.20). Test fixtures kept as
|
||||
opaque test data.
|
||||
- **DE+EN bilingual CHANGELOG entries (v1.4.14 + v1.4.15)
|
||||
normalised to English-only.** Per the English-only voice
|
||||
rule.
|
||||
- **`CLAUDE.md` filename retired (FX carry-over)** so the
|
||||
filename is no longer AI-vendor-specific; `CONTRIBUTING.md`
|
||||
reference updated. `AGENTS.md` stays for multi-agent
|
||||
compatibility.
|
||||
- **`CLAUDE.md` filename retired** so the filename is no longer
|
||||
AI-vendor-specific; `CONTRIBUTING.md` reference updated.
|
||||
`AGENTS.md` stays for multi-agent compatibility.
|
||||
|
||||
### Deferred to v1.4.23
|
||||
|
||||
@@ -11702,12 +11730,10 @@ Coach extension for the new measurement types
|
||||
|
||||
### Deferred to v1.4.21
|
||||
|
||||
- 22 medium + 16 low + 4 simplify-apply-maybe items from the multi-
|
||||
pass review. Highlights include: senior-dev call to consolidate the duplicated
|
||||
Pearson / linear-regression maths layer; refactor the
|
||||
`<CoachDrawer key={prefill}>` state-reset shortcut into a
|
||||
controlled prefill prop; transactional `recordSpend()`; refusal
|
||||
accounting + lexicon expansion.
|
||||
- Consolidate the duplicated Pearson / linear-regression maths
|
||||
layer; refactor the `<CoachDrawer key={prefill}>` state-reset
|
||||
shortcut into a controlled prefill prop; transactional
|
||||
`recordSpend()`; refusal accounting + lexicon expansion.
|
||||
|
||||
### Deferred to v1.5
|
||||
|
||||
@@ -11753,11 +11779,11 @@ Coach extension for the new measurement types
|
||||
No more dead "Einklappen" affordance when the route only exposes
|
||||
one section.
|
||||
- **Mobile Sys/Dia badge enum mismatch on blood-pressure rows.**
|
||||
CRITICAL from QA — the badge enum on mobile measurement rows
|
||||
decoded the wrong key. Fixed before tag with a TDD guard.
|
||||
- **6 CRITICAL + 21 HIGH copy / consistency / a11y findings from the
|
||||
quality-of-life audit.** Time-window range strings now respect the
|
||||
active locale, login overview filters out non-auth events, the
|
||||
The badge enum on mobile measurement rows decoded the wrong key;
|
||||
a regression guard pins the mapping.
|
||||
- **Copy, consistency and accessibility fixes across the app.**
|
||||
Time-window range strings now respect the active locale, login
|
||||
overview filters out non-auth events, the
|
||||
date / datetime input pair forwards `lang` so the native picker
|
||||
uses the user's locale, raw enum badges are humanised, audit-action
|
||||
labels are localised and link back to the row, achievement titles
|
||||
@@ -11810,12 +11836,11 @@ Coach extension for the new measurement types
|
||||
|
||||
### Deferred to v1.4.20
|
||||
|
||||
- 3 HIGH from QA — `/insights` `data?.` narrowing refactor,
|
||||
`/admin/api-tokens` touch-tooltip (needs Popover swap), and
|
||||
`/insights` hero density (folded into the v1.4.20 redesign).
|
||||
- 31 MED + 16 LOW from the quality-of-life audit. Short-list tracked internally.
|
||||
- `/insights` redesign with AI Coach — separate roadmap, design
|
||||
handoff at `~/Downloads/design_handoff_insights_redesign`.
|
||||
- `/insights` `data?.` narrowing refactor, `/admin/api-tokens`
|
||||
touch-tooltip (needs Popover swap), and `/insights` hero density
|
||||
(folded into the v1.4.20 redesign).
|
||||
- A longer copy and consistency backlog, tracked internally.
|
||||
- `/insights` redesign with AI Coach — separate roadmap.
|
||||
|
||||
## [1.4.18] — 2026-05-10
|
||||
|
||||
|
||||
+477
-100
File diff suppressed because it is too large.
Load diff
@@ -37,20 +37,29 @@ function ymd(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** The labelled run the fixture paints: 5 menstrual, 6 follicular, 1 ovulatory,
|
||||
* then luteal through today and one day past it. */
|
||||
const RUN_START_OFFSET = -12;
|
||||
const PHASE_PLAN: string[] = [
|
||||
...Array<string>(5).fill("MENSTRUAL"),
|
||||
...Array<string>(6).fill("FOLLICULAR"),
|
||||
"OVULATORY",
|
||||
"LUTEAL",
|
||||
"LUTEAL",
|
||||
];
|
||||
|
||||
function runStart(): Date {
|
||||
const start = new Date();
|
||||
start.setDate(start.getDate() + RUN_START_OFFSET);
|
||||
return start;
|
||||
}
|
||||
|
||||
/** A 90-day-back → 180-day-forward calendar with a recent labelled cycle run
|
||||
* so the wheel resolves a current phase + day and the calendar paints cells. */
|
||||
* so the wheel has a phase + day to show and the calendar paints cells. */
|
||||
function buildCalendarDays(): Array<Record<string, unknown>> {
|
||||
const days: Array<Record<string, unknown>> = [];
|
||||
const start = new Date();
|
||||
start.setDate(start.getDate() - 12);
|
||||
// 5 menstrual, 6 follicular, 1 ovulatory, then luteal through today.
|
||||
const plan: string[] = [
|
||||
...Array<string>(5).fill("MENSTRUAL"),
|
||||
...Array<string>(6).fill("FOLLICULAR"),
|
||||
"OVULATORY",
|
||||
"LUTEAL",
|
||||
"LUTEAL",
|
||||
];
|
||||
const start = runStart();
|
||||
const plan = PHASE_PLAN;
|
||||
for (let i = 0; i < plan.length; i++) {
|
||||
const d = new Date(start);
|
||||
d.setDate(start.getDate() + i);
|
||||
@@ -72,6 +81,34 @@ function buildCalendarDays(): Array<Record<string, unknown>> {
|
||||
return days;
|
||||
}
|
||||
|
||||
/**
|
||||
* The verdict the server resolves for that grid. The real route publishes this
|
||||
* and the wheel renders it, so a fixture that omitted it would paint an empty
|
||||
* ring — deriving it here from the SAME plan keeps the two from drifting.
|
||||
*/
|
||||
function buildVerdict(): Record<string, unknown> {
|
||||
const plan = PHASE_PLAN;
|
||||
const counts = plan.reduce<Record<string, number>>((acc, phase) => {
|
||||
acc[phase] = (acc[phase] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
const order = ["MENSTRUAL", "FOLLICULAR", "OVULATORY", "LUTEAL"];
|
||||
return {
|
||||
state: "IN_CYCLE",
|
||||
// The run starts 12 days back, so today is its 13th day.
|
||||
dayOfCycle: 1 - RUN_START_OFFSET,
|
||||
cycleLength: plan.length,
|
||||
phase: plan[-RUN_START_OFFSET],
|
||||
spans: order
|
||||
.filter((phase) => (counts[phase] ?? 0) > 0)
|
||||
.map((phase) => ({ phase, fraction: counts[phase] / plan.length })),
|
||||
cycleStartDate: ymd(runStart()),
|
||||
overdueDays: null,
|
||||
daysUntilNext: null,
|
||||
fertileWindow: { start: null, end: null, active: false },
|
||||
};
|
||||
}
|
||||
|
||||
const CALENDAR_BODY = {
|
||||
data: {
|
||||
profile: {
|
||||
@@ -81,6 +118,8 @@ const CALENDAR_BODY = {
|
||||
cyclesObserved: 1,
|
||||
},
|
||||
prediction: null,
|
||||
verdict: buildVerdict(),
|
||||
stillLearning: false,
|
||||
days: buildCalendarDays(),
|
||||
meta: { generatedAt: new Date().toISOString() },
|
||||
},
|
||||
|
||||
+19
-30
@@ -133,44 +133,35 @@ test.describe("Settings → Health Score", () => {
|
||||
await expect(cardio.locator('[data-slot="score-pillar-row"]')).toHaveCount(
|
||||
3,
|
||||
);
|
||||
await expect(
|
||||
cardio.locator('[data-slot="score-config-group-note"]'),
|
||||
).toBeVisible();
|
||||
|
||||
// Every visible row names recording and showing beside its switch, and
|
||||
// the card says in words that this page leaves those two alone.
|
||||
// The rows say nothing about recording or showing — the card says it
|
||||
// once, above them, and the modules screen owns those two questions.
|
||||
const rows = page.locator('[data-slot="score-pillar-row"]');
|
||||
const rowCount = await rows.count();
|
||||
expect(rowCount).toBeGreaterThan(0);
|
||||
await expect(
|
||||
page.locator('[data-slot="score-pillar-axis-recorded"]'),
|
||||
).toHaveCount(rowCount);
|
||||
await expect(
|
||||
page.locator('[data-slot="score-pillar-axis-shown"]'),
|
||||
).toHaveCount(rowCount);
|
||||
expect(await rows.count()).toBeGreaterThan(0);
|
||||
await expect(page.locator('[data-slot="score-pillar-axes"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(
|
||||
page.locator('[data-slot="score-config-three-axes"]'),
|
||||
).toContainText("keeps being recorded");
|
||||
});
|
||||
|
||||
test("shows the pillar it cannot score as present, disabled and honest", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("offers a switch on every pillar it lists", async ({ page }) => {
|
||||
await page.goto("/settings/score", { waitUntil: "domcontentloaded" });
|
||||
const fitness = page.locator(
|
||||
'[data-slot="score-pillar-row"][data-pillar="FITNESS"]',
|
||||
);
|
||||
await settleBeforeMeasure(page, fitness);
|
||||
const rows = page.locator('[data-slot="score-pillar-row"]');
|
||||
await settleBeforeMeasure(page, rows.first());
|
||||
|
||||
await expect(fitness).toHaveAttribute("data-selectable", "false");
|
||||
await expect(fitness).toHaveAttribute("data-eligibility", "unavailable");
|
||||
// No switch at all, rather than one that no-ops behind `disabled`.
|
||||
// Nothing on this page is a choice the person cannot make. The pillar
|
||||
// that could never score was removed from the catalogue rather than
|
||||
// listed as an inert row.
|
||||
const rowCount = await rows.count();
|
||||
expect(rowCount).toBeGreaterThan(0);
|
||||
await expect(page.locator('[data-slot="score-pillar-switch"]')).toHaveCount(
|
||||
rowCount,
|
||||
);
|
||||
await expect(
|
||||
fitness.locator('[data-slot="score-pillar-switch"]'),
|
||||
page.locator('[data-slot="score-pillar-row"][data-pillar="FITNESS"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
fitness.locator('[data-slot="score-pillar-unavailable"]'),
|
||||
).toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
test("tells waiting for data apart from safety guidance and a failed read", async ({
|
||||
@@ -247,9 +238,7 @@ test.describe("Settings → Health Score", () => {
|
||||
// server refuses.
|
||||
await page.locator('[data-slot="score-config-preset-all"]').click();
|
||||
const keep = new Set(["ACTIVITY", "WELLBEING"]);
|
||||
const rows = page.locator(
|
||||
'[data-slot="score-pillar-row"][data-selectable="true"]',
|
||||
);
|
||||
const rows = page.locator('[data-slot="score-pillar-row"]');
|
||||
for (let index = 0; index < (await rows.count()); index += 1) {
|
||||
const row = rows.nth(index);
|
||||
const pillar = await row.getAttribute("data-pillar");
|
||||
|
||||
+118
-18
@@ -12,15 +12,34 @@ import { E2E_USER, STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
*
|
||||
* The spec drives that end-to-end against the real read path:
|
||||
*
|
||||
* 1. load the dashboard with nothing fresh — no chip, day is provisional;
|
||||
* 2. land last night's sleep through the REAL batch endpoint, and the
|
||||
* arrival marker the spine's worker would have written;
|
||||
* 0. give the account a reason to paint a hero at all — one integration
|
||||
* needing a reconnect, which is an ordinary Today for someone whose
|
||||
* readings sync from devices;
|
||||
* 1. load the dashboard with last night's sleep still missing, and prove
|
||||
* it reads provisional with the pending note up;
|
||||
* 2. land it through the REAL batch endpoint, and the arrival marker the
|
||||
* spine's worker would have written;
|
||||
* 3. surface the tab's real visibility-change signal — the browser event
|
||||
* TanStack Query's focus manager consumes for
|
||||
* `refetchOnWindowFocus: "always"` (alongside the 120 s foreground poll);
|
||||
* using that existing trigger keeps the test inside the real cadence
|
||||
* while staying well under the poll interval;
|
||||
* 4. assert the chip appeared and the phase flipped — in place, no reload.
|
||||
* 4. assert the phase flipped and the pending note cleared — in place, no
|
||||
* reload.
|
||||
*
|
||||
* The hero used to carry a "just in" chip here; it is gone, because under a
|
||||
* number "just in" never said what had arrived. What the milestone was
|
||||
* always about survives it: the open page reads differently on its own
|
||||
* cadence.
|
||||
*
|
||||
* Step 0 is what the chip's removal exposed rather than caused. The arrival
|
||||
* was the only thing keeping the hero on screen for this bare fixture
|
||||
* account, so the spec was watching the day flip on a hero its own subject
|
||||
* was propping up — and on whatever data a sibling spec happened to leave
|
||||
* behind. It now establishes its own precondition, so the hero is there for
|
||||
* a reason a reader would recognise and the run order cannot decide the
|
||||
* outcome. `today-hero-just-in.test.tsx` pins the same composition at the
|
||||
* component, since this file cannot be run without a built app.
|
||||
*
|
||||
* The marker is seeded directly rather than waited for from pg-boss: whether
|
||||
* the queue drains inside the e2e web server is the SPINE's contract and has
|
||||
@@ -43,9 +62,7 @@ function localDayKey(at: Date, timeZone: string): string {
|
||||
}).format(at);
|
||||
}
|
||||
|
||||
test("a fresh arrival surfaces the just-in chip and flips the day in place", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("a fresh arrival flips the day in place", async ({ page }) => {
|
||||
const dbUrl = process.env.DATABASE_URL;
|
||||
test.skip(!dbUrl, "DATABASE_URL is required to seed the arrival marker");
|
||||
|
||||
@@ -83,17 +100,94 @@ test("a fresh arrival surfaces the just-in chip and flips the day in place", asy
|
||||
"UPDATE users SET morning_digest_refreshed_on = NULL WHERE id = $1",
|
||||
[userId],
|
||||
);
|
||||
// And no sleep sample from an earlier run of THIS spec — the desktop
|
||||
// project runs first and leaves one behind, and the day reads `final`
|
||||
// for as long as last night's sleep is in the record, which would make
|
||||
// the provisional assertion below unreachable on every run after the
|
||||
// first. Scoped to the external ids this file writes: it is the only
|
||||
// e2e spec that records sleep for the fixture account (the two v1427
|
||||
// specs mock the route in the browser and write nothing), so nothing
|
||||
// else can be depending on these rows.
|
||||
//
|
||||
// Deleted through the APP rather than with a `DELETE FROM measurements`,
|
||||
// and that difference is the whole reason this scenario used to fail on
|
||||
// the second project: a fixture that mutates rows behind the app's back
|
||||
// inherits the app's caching. `sleepLastSeenDaysAgo` is read off the
|
||||
// dashboard snapshot, which is an SWR cell in the analytics bucket
|
||||
// (60 s fresh, one HOUR of stale-serve), so the desktop run's
|
||||
// "sleep is in" snapshot outlived the rows it was built from and the
|
||||
// mobile run read `final` from cache against an empty table.
|
||||
//
|
||||
// `bulk-delete` specifically: it hard-evicts that bucket
|
||||
// (`invalidateUserMeasurements(userId, { evict: true })`, pinned by its
|
||||
// own route test), tombstones the rows the way the management list
|
||||
// does, and recomputes the rollup buckets the snapshot reads through.
|
||||
// The delete-by-external-id route would have been the closer fit for
|
||||
// how this spec names its rows, but it invalidates WITHOUT `evict` —
|
||||
// a marked-stale cell is served immediately while it revalidates, so
|
||||
// the next read would still have been the stale `final` one.
|
||||
//
|
||||
// Ids come from SQL rather than from `GET /api/measurements`: that
|
||||
// list collapses overlapping sources to one canonical row per day, so
|
||||
// it can hide a row that is still in the table and still counts.
|
||||
//
|
||||
// Deliberately NOT moved into the `finally` block. The eviction only
|
||||
// happens when the delete actually removes something, so cleaning up
|
||||
// at the end of each run would leave the next one with nothing to
|
||||
// delete, no eviction, and the same stale snapshot.
|
||||
const stale = await client.query<{ id: string }>(
|
||||
`SELECT id FROM measurements
|
||||
WHERE user_id = $1
|
||||
AND external_id LIKE 'e2e-just-in-%'
|
||||
AND deleted_at IS NULL
|
||||
LIMIT 200`,
|
||||
[userId],
|
||||
);
|
||||
if (stale.rows.length > 0) {
|
||||
const purge = await page.request.post("/api/measurements/bulk-delete", {
|
||||
data: { ids: stale.rows.map((row) => row.id) },
|
||||
});
|
||||
expect(
|
||||
purge.ok(),
|
||||
`purging ${stale.rows.length} stale sleep rows must be accepted (got ${purge.status()})`,
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
// The account needs a reason to paint a hero that is not the arrival
|
||||
// itself. A connection needing a reconnect is that reason: the digest
|
||||
// reads `integration_statuses` fresh on every request rather than
|
||||
// through the dashboard snapshot's SWR cell, so unlike a seeded score
|
||||
// or a seeded briefing (both of which ride that cell) it
|
||||
// cannot lose a race with a cached snapshot, and `buildSyncIssueItems`
|
||||
// admits it unconditionally — no module gate, no window, and
|
||||
// `sync_issue` carries no dismiss path that a previous run could have
|
||||
// silenced. Removed again in the `finally` below.
|
||||
await client.query(
|
||||
`INSERT INTO integration_statuses (id, user_id, integration, state, updated_at)
|
||||
VALUES ($1, $2, 'withings', 'error_reauth', NOW())
|
||||
ON CONFLICT (user_id, integration) DO UPDATE SET state = 'error_reauth'`,
|
||||
[`c${Date.now()}syncissue000`, userId],
|
||||
);
|
||||
|
||||
// ── BEFORE ────────────────────────────────────────────────────────────
|
||||
// Wait until the digest settles, then prove there is no stale arrival
|
||||
// reaction. Other browser specs deliberately reuse this account and may
|
||||
// have recorded older health data already, so an ordinary briefing Hero
|
||||
// is a valid baseline; the invariant here is that it has no just-in chip.
|
||||
// Wait until the digest settles, then pin the starting state. The two
|
||||
// assertions that matter here are the ones that make the AFTER block
|
||||
// mean something: a note that was never rendered is also absent at the
|
||||
// end, and a day that was already final also reads final at the end.
|
||||
await page.goto("/");
|
||||
await expect(page.locator('[data-slot="today-hero-skeleton"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
const hero = page.locator('[data-slot="today-hero"]');
|
||||
// Counts rather than visibility, matching the AFTER block: the two are
|
||||
// then the same assertion with the expected number flipped, and neither
|
||||
// can pass because a responsive class happened to hide the element.
|
||||
await expect(hero).toHaveCount(1, { timeout: 20_000 });
|
||||
await expect(hero).toHaveAttribute("data-phase", "provisional");
|
||||
await expect(
|
||||
hero.locator('[data-slot="today-hero-sleep-pending"]'),
|
||||
).toHaveCount(1);
|
||||
// Nothing on it may name an arrival: that chip was removed.
|
||||
await expect(page.locator('[data-slot="today-hero-just-in"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
@@ -149,9 +243,6 @@ test("a fresh arrival surfaces the just-in chip and flips the day in place", asy
|
||||
window.dispatchEvent(new Event("visibilitychange")),
|
||||
);
|
||||
|
||||
await expect(hero.locator('[data-slot="today-hero-just-in"]')).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(hero).toHaveAttribute("data-phase", "final", {
|
||||
timeout: 20_000,
|
||||
});
|
||||
@@ -159,11 +250,20 @@ test("a fresh arrival surfaces the just-in chip and flips the day in place", asy
|
||||
await expect(
|
||||
hero.locator('[data-slot="today-hero-sleep-pending"]'),
|
||||
).toHaveCount(0);
|
||||
// The chip names the arrival kind — the stable attribute, not the copy.
|
||||
await expect(
|
||||
hero.locator('[data-slot="today-hero-just-in"]'),
|
||||
).toHaveAttribute("data-just-in-kind", "sleep_night");
|
||||
// And the arrival still raises no chip of its own, on either pass.
|
||||
await expect(page.locator('[data-slot="today-hero-just-in"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
await client
|
||||
.query(
|
||||
"DELETE FROM integration_statuses WHERE user_id = (SELECT id FROM users WHERE username = $1) AND integration = 'withings'",
|
||||
[E2E_USER.username],
|
||||
)
|
||||
// The account is shared, so the seeded row must go even when an
|
||||
// assertion above threw — but a cleanup that throws would replace a
|
||||
// real failure with a confusing one.
|
||||
.catch(() => {});
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
@@ -181,7 +181,6 @@ const HEALTH_SCORE = {
|
||||
scorePillar("ACTIVITY", "activity", 88),
|
||||
scorePillar("SLEEP", "sleep", 41),
|
||||
scorePillar("ADIPOSITY", "adiposity", 72),
|
||||
gatedPillar("FITNESS", "fitness", "read_failed"),
|
||||
gatedPillar("LIPIDS", "cardiometabolic", "read_failed"),
|
||||
gatedPillar("WELLBEING", "wellbeing", "crisis_signposting"),
|
||||
gatedPillar("GLYCAEMIA", "cardiometabolic", "not_tracked"),
|
||||
|
||||
+3
-15
@@ -2295,7 +2295,6 @@
|
||||
"sleep": "Schlaf",
|
||||
"adiposity": "Körperfettverteilung",
|
||||
"wellbeing": "Wohlbefinden",
|
||||
"fitness": "Gemessene Fitness",
|
||||
"lipids": "Blutfette"
|
||||
},
|
||||
"observed": "Beobachtet: {value}",
|
||||
@@ -2312,11 +2311,9 @@
|
||||
"observedActivity": "{value} Schritte pro Tag",
|
||||
"observedSleep": "{value} Minuten pro Nacht",
|
||||
"observedAdiposity": "Taille-zu-Körpergröße-Verhältnis {value}",
|
||||
"observedFitness": "{value} {unit}, gemessen",
|
||||
"referenceActivity": "Der Nutzen flacht bei {high} Schritten pro Tag ab",
|
||||
"referenceSleep": "Mindestens {low} Minuten pro Nacht; längerer Schlaf wird nicht abgewertet",
|
||||
"referenceAdiposity": "Taille-zu-Körpergröße-Verhältnis unter {high}",
|
||||
"referenceFitness": "{low} bis {high} {unit}, Referenzbereich nach Alter und Geschlecht",
|
||||
"referenceHba1c": "HbA1c {low} bis {high} %",
|
||||
"referenceDetail": "{label} · {source} · {kind} · {low} bis {high}",
|
||||
"openBound": "offen",
|
||||
@@ -4914,8 +4911,6 @@
|
||||
},
|
||||
"threeAxes": "Aufzeichnen, Anzeigen und Mitzählen sind drei verschiedene Dinge. Diese Seite ändert nur das Letzte. Ein Bereich, den du hier abschaltest, wird weiter aufgezeichnet und weiter überall in HealthLog angezeigt, er bewegt nur die Zahl nicht mehr.",
|
||||
"axis": {
|
||||
"recorded": "Wird aufgezeichnet",
|
||||
"shown": "Wird in der App angezeigt",
|
||||
"countsFor": "{pillar} in den Score einrechnen"
|
||||
},
|
||||
"domain": {
|
||||
@@ -4923,19 +4918,13 @@
|
||||
"activity": "Aktivität",
|
||||
"sleep": "Schlaf",
|
||||
"adiposity": "Körperform",
|
||||
"wellbeing": "Wohlbefinden",
|
||||
"fitness": "Gemessene Fitness"
|
||||
},
|
||||
"domainNote": {
|
||||
"cardiometabolic": "Drei Bereiche, ein Feld der Gesundheit. Jeder Bereich zählt im Score gleich viel, dieses Feld wiegt also dreimal so schwer wie eines mit nur einem Bereich."
|
||||
"wellbeing": "Wohlbefinden"
|
||||
},
|
||||
"state": {
|
||||
"counting": "Zählt in deinen Score",
|
||||
"waiting": "Ausgewählt, wartet auf Daten",
|
||||
"crisis": "Statt eines Werts wird ein Sicherheitshinweis gezeigt. An deiner Auswahl liegt es nicht.",
|
||||
"readFailed": "Dieser Bereich konnte beim letzten Durchlauf nicht gelesen werden.",
|
||||
"unavailable": "Noch nicht verfügbar",
|
||||
"unavailableDetail": "HealthLog kann einen gemessenen Fitnesstest bisher nicht von einer Geräteschätzung unterscheiden, deshalb hat dieser Bereich noch nie einen Wert ergeben. Er bleibt in der Liste, damit du siehst, dass er nicht mitzählt."
|
||||
"readFailed": "Dieser Bereich konnte beim letzten Durchlauf nicht gelesen werden."
|
||||
},
|
||||
"presets": {
|
||||
"label": "Ausgangspunkt",
|
||||
@@ -7524,6 +7513,7 @@
|
||||
"caption": "deines Zyklus",
|
||||
"firstPeriodCta": "Erste Periode erfassen",
|
||||
"periodOverdue": "Deine Periode ist später als üblich – die Tageszählung pausiert, bis die nächste Periode erfasst ist.",
|
||||
"periodOverdueDays": "Deine Periode ist {days} Tage später als üblich – die Tageszählung pausiert, bis die nächste Periode erfasst ist.",
|
||||
"logPeriodCta": "Periode erfassen"
|
||||
},
|
||||
"calendar": {
|
||||
@@ -9299,8 +9289,6 @@
|
||||
"deltaVsBaseline": "{delta} ggü. deinem Ausgangswert",
|
||||
"deltaFlat": "Im Bereich deines Ausgangswerts",
|
||||
"sleepPending": "Der Schlaf von letzter Nacht fehlt noch – der heutige Wert ist vorläufig.",
|
||||
"justIn": "Gerade eingetroffen",
|
||||
"justInAt": "Gerade eingetroffen · {time}",
|
||||
"worthALook": "Einen Blick wert",
|
||||
"allClear": "Heute braucht nichts deine Aufmerksamkeit.",
|
||||
"ringLink": "{metric}-Details öffnen"
|
||||
|
||||
+3
-15
@@ -2295,7 +2295,6 @@
|
||||
"sleep": "Sleep",
|
||||
"adiposity": "Adiposity",
|
||||
"wellbeing": "Wellbeing",
|
||||
"fitness": "Measured fitness",
|
||||
"lipids": "Lipids"
|
||||
},
|
||||
"observed": "Observed: {value}",
|
||||
@@ -2312,11 +2311,9 @@
|
||||
"observedActivity": "{value} steps per day",
|
||||
"observedSleep": "{value} minutes per night",
|
||||
"observedAdiposity": "Waist-to-height ratio {value}",
|
||||
"observedFitness": "{value} {unit}, measured",
|
||||
"referenceActivity": "Benefit plateaus by {high} steps per day",
|
||||
"referenceSleep": "At least {low} minutes per night; longer sleep is not penalised",
|
||||
"referenceAdiposity": "Waist-to-height ratio below {high}",
|
||||
"referenceFitness": "{low} to {high} {unit}, age and sex reference band",
|
||||
"referenceHba1c": "HbA1c {low} to {high}%",
|
||||
"referenceDetail": "{label} · {source} · {kind} · {low} to {high}",
|
||||
"openBound": "open",
|
||||
@@ -4914,8 +4911,6 @@
|
||||
},
|
||||
"threeAxes": "Recording, showing and counting are three separate things. This page changes only the last one. A pillar you switch off here keeps being recorded and keeps appearing everywhere else in HealthLog, it just stops moving the number.",
|
||||
"axis": {
|
||||
"recorded": "Being recorded",
|
||||
"shown": "Shown in the app",
|
||||
"countsFor": "Count {pillar} toward the score"
|
||||
},
|
||||
"domain": {
|
||||
@@ -4923,19 +4918,13 @@
|
||||
"activity": "Activity",
|
||||
"sleep": "Sleep",
|
||||
"adiposity": "Body shape",
|
||||
"wellbeing": "Wellbeing",
|
||||
"fitness": "Measured fitness"
|
||||
},
|
||||
"domainNote": {
|
||||
"cardiometabolic": "Three pillars, one area of health. Each pillar carries the same weight in the score, so this area weighs three times as much as an area with a single pillar."
|
||||
"wellbeing": "Wellbeing"
|
||||
},
|
||||
"state": {
|
||||
"counting": "Counting toward your score",
|
||||
"waiting": "Selected, waiting for data",
|
||||
"crisis": "Safety guidance is shown instead of a score. Your selection is fine.",
|
||||
"readFailed": "This pillar could not be read on the last run.",
|
||||
"unavailable": "Not available yet",
|
||||
"unavailableDetail": "HealthLog cannot yet tell a measured fitness test apart from a device estimate, so this pillar has never produced a score. It stays listed so you can see it is not counted."
|
||||
"readFailed": "This pillar could not be read on the last run."
|
||||
},
|
||||
"presets": {
|
||||
"label": "Start from",
|
||||
@@ -7524,6 +7513,7 @@
|
||||
"caption": "of your cycle",
|
||||
"firstPeriodCta": "Log my first period",
|
||||
"periodOverdue": "Your period is later than usual — the day count pauses until the next period is logged.",
|
||||
"periodOverdueDays": "Your period is {days} days later than usual — the day count pauses until the next period is logged.",
|
||||
"logPeriodCta": "Log period"
|
||||
},
|
||||
"calendar": {
|
||||
@@ -9299,8 +9289,6 @@
|
||||
"deltaVsBaseline": "{delta} vs your baseline",
|
||||
"deltaFlat": "In line with your baseline",
|
||||
"sleepPending": "Last night's sleep isn't in yet — today's read is provisional.",
|
||||
"justIn": "Just in",
|
||||
"justInAt": "Just in · {time}",
|
||||
"worthALook": "Worth a look",
|
||||
"allClear": "Nothing needs your attention today.",
|
||||
"ringLink": "Open {metric} details"
|
||||
|
||||
+3
-15
@@ -2295,7 +2295,6 @@
|
||||
"sleep": "Sueño",
|
||||
"adiposity": "Adiposidad",
|
||||
"wellbeing": "Bienestar",
|
||||
"fitness": "Forma física medida",
|
||||
"lipids": "Lípidos"
|
||||
},
|
||||
"observed": "Observado: {value}",
|
||||
@@ -2312,11 +2311,9 @@
|
||||
"observedActivity": "{value} pasos al día",
|
||||
"observedSleep": "{value} minutos por noche",
|
||||
"observedAdiposity": "Relación cintura-altura {value}",
|
||||
"observedFitness": "{value} {unit}, medido",
|
||||
"referenceActivity": "El beneficio se estabiliza en {high} pasos al día",
|
||||
"referenceSleep": "Al menos {low} minutos por noche; el sueño más largo no se penaliza",
|
||||
"referenceAdiposity": "Relación cintura-altura inferior a {high}",
|
||||
"referenceFitness": "{low} a {high} {unit}, intervalo de referencia por edad y sexo",
|
||||
"referenceHba1c": "HbA1c de {low} a {high} %",
|
||||
"referenceDetail": "{label} · {source} · {kind} · {low} a {high}",
|
||||
"openBound": "abierto",
|
||||
@@ -4914,8 +4911,6 @@
|
||||
},
|
||||
"threeAxes": "Registrar, mostrar y contar son tres cosas distintas. Esta página cambia solo la última. Un pilar que desactives aquí se sigue registrando y se sigue viendo en el resto de HealthLog, simplemente deja de mover el número.",
|
||||
"axis": {
|
||||
"recorded": "Se registra",
|
||||
"shown": "Se muestra en la app",
|
||||
"countsFor": "Contar {pillar} para la puntuación"
|
||||
},
|
||||
"domain": {
|
||||
@@ -4923,19 +4918,13 @@
|
||||
"activity": "Actividad",
|
||||
"sleep": "Sueño",
|
||||
"adiposity": "Forma corporal",
|
||||
"wellbeing": "Bienestar",
|
||||
"fitness": "Condición física medida"
|
||||
},
|
||||
"domainNote": {
|
||||
"cardiometabolic": "Tres pilares, un mismo ámbito de salud. Cada pilar pesa lo mismo en la puntuación, así que este ámbito pesa el triple que uno con un solo pilar."
|
||||
"wellbeing": "Bienestar"
|
||||
},
|
||||
"state": {
|
||||
"counting": "Cuenta para tu puntuación",
|
||||
"waiting": "Seleccionado, esperando datos",
|
||||
"crisis": "En lugar de una puntuación se muestra orientación de seguridad. Tu selección está bien.",
|
||||
"readFailed": "Este pilar no se pudo leer en el último cálculo.",
|
||||
"unavailable": "Todavía no disponible",
|
||||
"unavailableDetail": "HealthLog aún no distingue una prueba de condición física medida de una estimación del dispositivo, así que este pilar nunca ha dado una puntuación. Sigue en la lista para que veas que no cuenta."
|
||||
"readFailed": "Este pilar no se pudo leer en el último cálculo."
|
||||
},
|
||||
"presets": {
|
||||
"label": "Punto de partida",
|
||||
@@ -7524,6 +7513,7 @@
|
||||
"caption": "de tu ciclo",
|
||||
"firstPeriodCta": "Registrar mi primer periodo",
|
||||
"periodOverdue": "Tu período llega más tarde de lo habitual — el conteo de días se pausa hasta que registres el próximo período.",
|
||||
"periodOverdueDays": "Tu período llega {days} días más tarde de lo habitual — el conteo de días se pausa hasta que registres el próximo período.",
|
||||
"logPeriodCta": "Registrar período"
|
||||
},
|
||||
"calendar": {
|
||||
@@ -9299,8 +9289,6 @@
|
||||
"deltaVsBaseline": "{delta} frente a tu valor de referencia",
|
||||
"deltaFlat": "En línea con tu valor de referencia",
|
||||
"sleepPending": "El sueño de anoche aún no ha llegado; la lectura de hoy es provisional.",
|
||||
"justIn": "Recién llegado",
|
||||
"justInAt": "Recién llegado · {time}",
|
||||
"worthALook": "Merece un vistazo",
|
||||
"allClear": "Hoy nada necesita tu atención.",
|
||||
"ringLink": "Abrir detalles de {metric}"
|
||||
|
||||
+3
-15
@@ -2295,7 +2295,6 @@
|
||||
"sleep": "Sommeil",
|
||||
"adiposity": "Adiposité",
|
||||
"wellbeing": "Bien-être",
|
||||
"fitness": "Condition physique mesurée",
|
||||
"lipids": "Lipides"
|
||||
},
|
||||
"observed": "Observé : {value}",
|
||||
@@ -2312,11 +2311,9 @@
|
||||
"observedActivity": "{value} pas par jour",
|
||||
"observedSleep": "{value} minutes par nuit",
|
||||
"observedAdiposity": "Rapport taille-hauteur {value}",
|
||||
"observedFitness": "{value} {unit}, mesuré",
|
||||
"referenceActivity": "Le bénéfice se stabilise à {high} pas par jour",
|
||||
"referenceSleep": "Au moins {low} minutes par nuit ; un sommeil plus long n'est pas pénalisé",
|
||||
"referenceAdiposity": "Rapport taille-hauteur inférieur à {high}",
|
||||
"referenceFitness": "{low} à {high} {unit}, plage de référence selon l'âge et le sexe",
|
||||
"referenceHba1c": "HbA1c de {low} à {high} %",
|
||||
"referenceDetail": "{label} · {source} · {kind} · {low} à {high}",
|
||||
"openBound": "ouvert",
|
||||
@@ -4914,8 +4911,6 @@
|
||||
},
|
||||
"threeAxes": "Enregistrer, afficher et compter sont trois choses distinctes. Cette page ne change que la dernière. Un pilier désactivé ici continue d'être enregistré et reste visible partout ailleurs dans HealthLog, il cesse simplement de faire bouger le chiffre.",
|
||||
"axis": {
|
||||
"recorded": "Enregistré",
|
||||
"shown": "Affiché dans l'application",
|
||||
"countsFor": "Compter {pillar} dans le score"
|
||||
},
|
||||
"domain": {
|
||||
@@ -4923,19 +4918,13 @@
|
||||
"activity": "Activité",
|
||||
"sleep": "Sommeil",
|
||||
"adiposity": "Silhouette",
|
||||
"wellbeing": "Bien-être",
|
||||
"fitness": "Condition physique mesurée"
|
||||
},
|
||||
"domainNote": {
|
||||
"cardiometabolic": "Trois piliers, un seul domaine de santé. Chaque pilier pèse autant dans le score, ce domaine pèse donc trois fois plus qu'un domaine composé d'un seul pilier."
|
||||
"wellbeing": "Bien-être"
|
||||
},
|
||||
"state": {
|
||||
"counting": "Compte dans votre score",
|
||||
"waiting": "Sélectionné, en attente de données",
|
||||
"crisis": "Un message de sécurité s'affiche à la place d'un score. Votre sélection n'est pas en cause.",
|
||||
"readFailed": "Ce pilier n'a pas pu être lu lors du dernier calcul.",
|
||||
"unavailable": "Pas encore disponible",
|
||||
"unavailableDetail": "HealthLog ne sait pas encore distinguer un test de condition physique mesuré d'une estimation d'appareil, ce pilier n'a donc jamais produit de score. Il reste listé pour que vous voyiez qu'il ne compte pas."
|
||||
"readFailed": "Ce pilier n'a pas pu être lu lors du dernier calcul."
|
||||
},
|
||||
"presets": {
|
||||
"label": "Point de départ",
|
||||
@@ -7524,6 +7513,7 @@
|
||||
"caption": "de ton cycle",
|
||||
"firstPeriodCta": "Enregistrer mes premières règles",
|
||||
"periodOverdue": "Tes règles arrivent plus tard que d’habitude — le compteur de jours est en pause jusqu’à ce que les prochaines règles soient enregistrées.",
|
||||
"periodOverdueDays": "Tes règles arrivent {days} jours plus tard que d’habitude — le compteur de jours est en pause jusqu’à ce que les prochaines règles soient enregistrées.",
|
||||
"logPeriodCta": "Enregistrer les règles"
|
||||
},
|
||||
"calendar": {
|
||||
@@ -9299,8 +9289,6 @@
|
||||
"deltaVsBaseline": "{delta} par rapport à ta référence",
|
||||
"deltaFlat": "Conforme à ta référence",
|
||||
"sleepPending": "Le sommeil de la nuit dernière n'est pas encore arrivé — la lecture du jour est provisoire.",
|
||||
"justIn": "Vient d'arriver",
|
||||
"justInAt": "Vient d'arriver · {time}",
|
||||
"worthALook": "À regarder",
|
||||
"allClear": "Rien ne requiert ton attention aujourd'hui.",
|
||||
"ringLink": "Ouvrir les détails de {metric}"
|
||||
|
||||
+3
-15
@@ -2295,7 +2295,6 @@
|
||||
"sleep": "Sonno",
|
||||
"adiposity": "Adiposità",
|
||||
"wellbeing": "Benessere",
|
||||
"fitness": "Forma fisica misurata",
|
||||
"lipids": "Lipidi"
|
||||
},
|
||||
"observed": "Osservato: {value}",
|
||||
@@ -2312,11 +2311,9 @@
|
||||
"observedActivity": "{value} passi al giorno",
|
||||
"observedSleep": "{value} minuti per notte",
|
||||
"observedAdiposity": "Rapporto vita-altezza {value}",
|
||||
"observedFitness": "{value} {unit}, misurato",
|
||||
"referenceActivity": "Il beneficio si stabilizza a {high} passi al giorno",
|
||||
"referenceSleep": "Almeno {low} minuti per notte; il sonno più lungo non viene penalizzato",
|
||||
"referenceAdiposity": "Rapporto vita-altezza inferiore a {high}",
|
||||
"referenceFitness": "Da {low} a {high} {unit}, intervallo di riferimento per età e sesso",
|
||||
"referenceHba1c": "HbA1c da {low} a {high}%",
|
||||
"referenceDetail": "{label} · {source} · {kind} · da {low} a {high}",
|
||||
"openBound": "aperto",
|
||||
@@ -4914,8 +4911,6 @@
|
||||
},
|
||||
"threeAxes": "Registrare, mostrare e contare sono tre cose diverse. Questa pagina cambia solo l'ultima. Un pilastro che disattivi qui continua a essere registrato e resta visibile ovunque in HealthLog, smette solo di muovere il numero.",
|
||||
"axis": {
|
||||
"recorded": "Viene registrato",
|
||||
"shown": "Mostrato nell'app",
|
||||
"countsFor": "Conta {pillar} nel punteggio"
|
||||
},
|
||||
"domain": {
|
||||
@@ -4923,19 +4918,13 @@
|
||||
"activity": "Attività",
|
||||
"sleep": "Sonno",
|
||||
"adiposity": "Forma corporea",
|
||||
"wellbeing": "Benessere",
|
||||
"fitness": "Forma fisica misurata"
|
||||
},
|
||||
"domainNote": {
|
||||
"cardiometabolic": "Tre pilastri, un solo ambito di salute. Ogni pilastro pesa allo stesso modo nel punteggio, quindi questo ambito pesa il triplo di uno con un solo pilastro."
|
||||
"wellbeing": "Benessere"
|
||||
},
|
||||
"state": {
|
||||
"counting": "Conta nel tuo punteggio",
|
||||
"waiting": "Selezionato, in attesa di dati",
|
||||
"crisis": "Al posto di un punteggio viene mostrata un'indicazione di sicurezza. La tua selezione va bene.",
|
||||
"readFailed": "Questo pilastro non è stato letto nell'ultimo calcolo.",
|
||||
"unavailable": "Non ancora disponibile",
|
||||
"unavailableDetail": "HealthLog non sa ancora distinguere un test di forma fisica misurato da una stima del dispositivo, perciò questo pilastro non ha mai prodotto un punteggio. Resta in elenco perché tu veda che non conta."
|
||||
"readFailed": "Questo pilastro non è stato letto nell'ultimo calcolo."
|
||||
},
|
||||
"presets": {
|
||||
"label": "Punto di partenza",
|
||||
@@ -7524,6 +7513,7 @@
|
||||
"caption": "del tuo ciclo",
|
||||
"firstPeriodCta": "Registra il mio primo ciclo",
|
||||
"periodOverdue": "Il ciclo è in ritardo rispetto al solito — il conteggio dei giorni resta in pausa finché non registri la prossima mestruazione.",
|
||||
"periodOverdueDays": "Il ciclo è in ritardo di {days} giorni rispetto al solito — il conteggio dei giorni resta in pausa finché non registri la prossima mestruazione.",
|
||||
"logPeriodCta": "Registra mestruazione"
|
||||
},
|
||||
"calendar": {
|
||||
@@ -9299,8 +9289,6 @@
|
||||
"deltaVsBaseline": "{delta} rispetto al tuo valore di riferimento",
|
||||
"deltaFlat": "In linea con il tuo valore di riferimento",
|
||||
"sleepPending": "Il sonno di stanotte non è ancora arrivato: la lettura di oggi è provvisoria.",
|
||||
"justIn": "Appena arrivato",
|
||||
"justInAt": "Appena arrivato · {time}",
|
||||
"worthALook": "Da tenere d'occhio",
|
||||
"allClear": "Oggi nulla richiede la tua attenzione.",
|
||||
"ringLink": "Apri i dettagli di {metric}"
|
||||
|
||||
+3
-15
@@ -2295,7 +2295,6 @@
|
||||
"sleep": "Sen",
|
||||
"adiposity": "Otłuszczenie",
|
||||
"wellbeing": "Samopoczucie",
|
||||
"fitness": "Zmierzona wydolność",
|
||||
"lipids": "Lipidy"
|
||||
},
|
||||
"observed": "Zaobserwowano: {value}",
|
||||
@@ -2312,11 +2311,9 @@
|
||||
"observedActivity": "{value} kroków dziennie",
|
||||
"observedSleep": "{value} minut na noc",
|
||||
"observedAdiposity": "Stosunek obwodu talii do wzrostu {value}",
|
||||
"observedFitness": "{value} {unit}, zmierzone",
|
||||
"referenceActivity": "Korzyść stabilizuje się przy {high} krokach dziennie",
|
||||
"referenceSleep": "Co najmniej {low} minut na noc; dłuższy sen nie obniża wyniku",
|
||||
"referenceAdiposity": "Stosunek obwodu talii do wzrostu poniżej {high}",
|
||||
"referenceFitness": "Od {low} do {high} {unit}, zakres odniesienia według wieku i płci",
|
||||
"referenceHba1c": "HbA1c od {low} do {high}%",
|
||||
"referenceDetail": "{label} · {source} · {kind} · od {low} do {high}",
|
||||
"openBound": "otwarty",
|
||||
@@ -4914,8 +4911,6 @@
|
||||
},
|
||||
"threeAxes": "Zapisywanie, pokazywanie i liczenie to trzy różne rzeczy. Ta strona zmienia tylko ostatnią. Filar wyłączony tutaj nadal jest zapisywany i nadal widoczny w całym HealthLogu, przestaje jedynie ruszać liczbą.",
|
||||
"axis": {
|
||||
"recorded": "Jest zapisywany",
|
||||
"shown": "Pokazywany w aplikacji",
|
||||
"countsFor": "Licz {pillar} do wyniku"
|
||||
},
|
||||
"domain": {
|
||||
@@ -4923,19 +4918,13 @@
|
||||
"activity": "Aktywność",
|
||||
"sleep": "Sen",
|
||||
"adiposity": "Sylwetka",
|
||||
"wellbeing": "Samopoczucie",
|
||||
"fitness": "Zmierzona wydolność"
|
||||
},
|
||||
"domainNote": {
|
||||
"cardiometabolic": "Trzy filary, jeden obszar zdrowia. Każdy filar waży w wyniku tyle samo, więc ten obszar waży trzy razy tyle co obszar z jednym filarem."
|
||||
"wellbeing": "Samopoczucie"
|
||||
},
|
||||
"state": {
|
||||
"counting": "Liczy się do Twojego wyniku",
|
||||
"waiting": "Wybrany, czeka na dane",
|
||||
"crisis": "Zamiast wyniku pokazywana jest wskazówka bezpieczeństwa. Z Twoim wyborem wszystko w porządku.",
|
||||
"readFailed": "Tego filaru nie udało się odczytać przy ostatnim liczeniu.",
|
||||
"unavailable": "Jeszcze niedostępny",
|
||||
"unavailableDetail": "HealthLog nie potrafi jeszcze odróżnić zmierzonego testu wydolności od szacunku z urządzenia, więc ten filar nigdy nie dał wyniku. Zostaje na liście, żebyś widział, że się nie liczy."
|
||||
"readFailed": "Tego filaru nie udało się odczytać przy ostatnim liczeniu."
|
||||
},
|
||||
"presets": {
|
||||
"label": "Punkt wyjścia",
|
||||
@@ -7524,6 +7513,7 @@
|
||||
"caption": "twojego cyklu",
|
||||
"firstPeriodCta": "Zapisz pierwszą miesiączkę",
|
||||
"periodOverdue": "Okres jest późniejszy niż zwykle — licznik dni jest wstrzymany, dopóki nie zapiszesz następnego okresu.",
|
||||
"periodOverdueDays": "Okres jest {days} dni późniejszy niż zwykle — licznik dni jest wstrzymany, dopóki nie zapiszesz następnego okresu.",
|
||||
"logPeriodCta": "Zapisz okres"
|
||||
},
|
||||
"calendar": {
|
||||
@@ -9299,8 +9289,6 @@
|
||||
"deltaVsBaseline": "{delta} wzgl. Twojej wartości bazowej",
|
||||
"deltaFlat": "Zgodnie z Twoją wartością bazową",
|
||||
"sleepPending": "Sen z ostatniej nocy jeszcze nie dotarł — dzisiejszy odczyt jest wstępny.",
|
||||
"justIn": "Właśnie dotarło",
|
||||
"justInAt": "Właśnie dotarło · {time}",
|
||||
"worthALook": "Warto sprawdzić",
|
||||
"allClear": "Dziś nic nie wymaga Twojej uwagi.",
|
||||
"ringLink": "Otwórz szczegóły: {metric}"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.35.1",
|
||||
"version": "1.35.2",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.35.1";
|
||||
/* @sw-version-fallback */ "v1.35.2";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* The cycle-verdict guard, checked against itself.
|
||||
*
|
||||
* A structural check that cannot fail reads as proof and is not. So each case
|
||||
* below is a disguise someone would plausibly reach for while putting the
|
||||
* client-side cycle derivation back — renaming the constant, inlining the
|
||||
* number, lifting it into a variable one line above the comparison (the move
|
||||
* that defeated a guard in this repo before), hiding it in an object, in a
|
||||
* default parameter, behind a helper — and each is asserted to be caught by
|
||||
* the SAME detector the real guard runs.
|
||||
*
|
||||
* The second half matters just as much: a detector that flags everything is
|
||||
* a detector nobody keeps. So the innocent shapes are pinned too, including
|
||||
* the one the client is SUPPOSED to write.
|
||||
*
|
||||
* These sources are strings on purpose. They are the shapes that must not
|
||||
* ship, so none of them may live in a file the app can import.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
derivationFindings,
|
||||
parse,
|
||||
type FindingKind,
|
||||
} from "./helpers/cycle-verdict-derivation";
|
||||
|
||||
const FILE = "disguise.tsx";
|
||||
|
||||
/** Every cycle-context source needs the vocabulary a real derivation would use. */
|
||||
const PREAMBLE = `import type { CalendarDay, CyclePhase } from "./types";\n`;
|
||||
|
||||
function kinds(source: string): FindingKind[] {
|
||||
return derivationFindings(parse(FILE, PREAMBLE + source)).map((f) => f.kind);
|
||||
}
|
||||
|
||||
describe("the guard catches the derivation it was written for", () => {
|
||||
it("catches the exact constant that was copied", () => {
|
||||
expect(
|
||||
kinds(`const OVERDUE_GRACE_DAYS = 14;
|
||||
export function wheel(days: CalendarDay[]) { return days; }`),
|
||||
).toContain("grace-constant");
|
||||
});
|
||||
|
||||
it("catches it renamed — it reads the shape, not the old name", () => {
|
||||
expect(kinds(`const graceWindow = 14;`)).toContain("grace-constant");
|
||||
expect(kinds(`const overdueAfterDays = 21;`)).toContain("grace-constant");
|
||||
});
|
||||
|
||||
it("catches it hidden in an object literal", () => {
|
||||
expect(
|
||||
kinds(`const tuning = { overdueGraceDays: 14, cycleLength: 28 };`),
|
||||
).toContain("grace-constant");
|
||||
});
|
||||
|
||||
it("catches it hidden in a default parameter", () => {
|
||||
expect(
|
||||
kinds(
|
||||
`export function wheel(days: CalendarDay[], graceDays = 14) { return days.length > graceDays; }`,
|
||||
),
|
||||
).toContain("grace-constant");
|
||||
});
|
||||
|
||||
it("catches the verdict being computed rather than read", () => {
|
||||
expect(
|
||||
kinds(
|
||||
`const dayOfCycle = 43;
|
||||
const typical = 28;
|
||||
const periodOverdue = dayOfCycle > typical + 14;`,
|
||||
),
|
||||
).toContain("client-derived-verdict");
|
||||
});
|
||||
|
||||
it("catches the threshold LIFTED INTO A VARIABLE one line up", () => {
|
||||
// The move that defeated a guard here before: no literal and no sum sit
|
||||
// next to the comparison any more, and nothing is named suspiciously.
|
||||
// The detector follows the local binding instead of reading the line.
|
||||
expect(
|
||||
kinds(
|
||||
`const limit = 28;
|
||||
const slack = 14;
|
||||
const ceiling = limit + slack;
|
||||
const dayOfCycle = 43;
|
||||
const stale = dayOfCycle > ceiling;`,
|
||||
),
|
||||
).toContain("day-count-threshold");
|
||||
});
|
||||
|
||||
it("catches it lifted twice", () => {
|
||||
expect(
|
||||
kinds(
|
||||
`const limit = 28;
|
||||
const slack = 14;
|
||||
const ceiling = limit + slack;
|
||||
const bound = ceiling;
|
||||
const dayOfCycle = 43;
|
||||
const stale = dayOfCycle > bound;`,
|
||||
),
|
||||
).toContain("day-count-threshold");
|
||||
});
|
||||
|
||||
it("catches the sum written inline", () => {
|
||||
expect(
|
||||
kinds(
|
||||
`const limit = 28;
|
||||
const slack = 14;
|
||||
const dayOfCycle = 43;
|
||||
const stale = dayOfCycle > limit + slack;`,
|
||||
),
|
||||
).toContain("day-count-threshold");
|
||||
});
|
||||
|
||||
it("catches the shape with no suspicious name anywhere", () => {
|
||||
// Nothing here is called grace, overdue, or late. The count compared
|
||||
// against a sum is the whole tell.
|
||||
expect(
|
||||
kinds(
|
||||
`function ring(dayOfCycle: number, a: number, b: number) {
|
||||
return dayOfCycle >= a + b ? null : dayOfCycle;
|
||||
}`,
|
||||
),
|
||||
).toContain("day-count-threshold");
|
||||
});
|
||||
|
||||
it("catches it with the operands swapped", () => {
|
||||
expect(
|
||||
kinds(
|
||||
`function ring(dayOfCycle: number, a: number, b: number) {
|
||||
return a + b < dayOfCycle;
|
||||
}`,
|
||||
),
|
||||
).toContain("day-count-threshold");
|
||||
});
|
||||
|
||||
it("catches a class field carrying the window", () => {
|
||||
expect(kinds(`class Wheel { private overdueGrace = 14; }`)).toContain(
|
||||
"grace-constant",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the guard stays quiet on innocent code", () => {
|
||||
it("says nothing about reading the published state — the correct shape", () => {
|
||||
expect(
|
||||
kinds(
|
||||
`const verdict = calendar.data?.verdict;
|
||||
const periodOverdue = verdict?.state === "OVERDUE";
|
||||
const dayOfCycle = verdict?.dayOfCycle ?? null;
|
||||
const overdueDays = verdict?.overdueDays ?? null;`,
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("says nothing about drawing the ring from the published numbers", () => {
|
||||
expect(
|
||||
kinds(
|
||||
`function ring(dayOfCycle: number | null, cycleLength: number | null) {
|
||||
if (dayOfCycle == null || cycleLength == null || cycleLength <= 0) return 0;
|
||||
return Math.min((dayOfCycle - 1) / cycleLength, 1);
|
||||
}`,
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("says nothing about a cycle-day count printed in copy", () => {
|
||||
expect(
|
||||
kinds(
|
||||
`const label = dayOfCycle != null ? t("cycle.ring.dayOfCycle", { day: dayOfCycle }) : null;`,
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("says nothing about an overdue medication dose", () => {
|
||||
// The same English word, a different domain, no cycle vocabulary — the
|
||||
// medication card decides its own overdue state and always has.
|
||||
expect(
|
||||
derivationFindings(
|
||||
parse(
|
||||
"medication-card.tsx",
|
||||
`const overdue = dueAt != null && dueAt + graceMinutes < now;
|
||||
const OVERDUE_GRACE_MINUTES = 30;`,
|
||||
),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("says nothing about an unrelated grace constant outside the cycle", () => {
|
||||
expect(
|
||||
derivationFindings(
|
||||
parse("tour-launcher.tsx", `const POST_WIZARD_GRACE_MS = 4000;`),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* One decider for the cycle verdict.
|
||||
*
|
||||
* The server resolves the cycle day, the ring's arcs, the fertile-window state
|
||||
* and the overdue judgement with its day count, and publishes them on
|
||||
* `GET /api/cycle/calendar`. Client code renders that. It does not recompute
|
||||
* any part of it, and it does not keep its own grace-period constant — while
|
||||
* such a constant exists in client code it can be copied into another client,
|
||||
* which is exactly how a cycle ring came to show a person a day their record
|
||||
* did not hold.
|
||||
*
|
||||
* Two halves, because either one alone is satisfiable by broken code:
|
||||
* nothing in the client tree may derive the verdict, AND the surfaces that
|
||||
* show it must still be reading the published field.
|
||||
*/
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join, relative as relativeTo } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
accessPaths,
|
||||
derivationFindings,
|
||||
parse,
|
||||
} from "./helpers/cycle-verdict-derivation";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
|
||||
/** Client-side code: the component tree, the hooks, and the app's pages. */
|
||||
const CLIENT_ROOTS = ["src/components", "src/hooks", "src/app"];
|
||||
const SKIP_DIRS = new Set(["__tests__", "node_modules", ".next", "generated"]);
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (SKIP_DIRS.has(name)) continue;
|
||||
const p = join(dir, name);
|
||||
if (statSync(p).isDirectory()) {
|
||||
walk(p, out);
|
||||
} else if (
|
||||
(name.endsWith(".ts") || name.endsWith(".tsx")) &&
|
||||
!name.endsWith(".test.ts") &&
|
||||
!name.endsWith(".test.tsx") &&
|
||||
// Route handlers under `src/app/api` are the server; the verdict is
|
||||
// resolved there on purpose.
|
||||
!p.includes(`${join("src", "app", "api")}`)
|
||||
) {
|
||||
out.push(relativeTo(ROOT, p));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function clientFiles(): string[] {
|
||||
return CLIENT_ROOTS.flatMap((root) => walk(join(ROOT, root)));
|
||||
}
|
||||
|
||||
function parseFile(relative: string) {
|
||||
return parse(relative, readFileSync(join(ROOT, relative), "utf8"));
|
||||
}
|
||||
|
||||
describe("the cycle verdict is decided in one place", () => {
|
||||
it("finds no client file that re-derives it", () => {
|
||||
const files = clientFiles();
|
||||
// A guard over an empty file list proves nothing.
|
||||
expect(files.length).toBeGreaterThan(100);
|
||||
|
||||
const findings = files.flatMap((relative) =>
|
||||
derivationFindings(parseFile(relative)),
|
||||
);
|
||||
|
||||
expect(
|
||||
findings.map((f) => `${f.file}:${f.line} ${f.kind} — ${f.where}`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the grace window out of client code entirely", () => {
|
||||
// The specific constant that was copied. Named here so its return is loud
|
||||
// rather than merely structural.
|
||||
const offenders = clientFiles().filter((relative) =>
|
||||
readFileSync(join(ROOT, relative), "utf8").includes("OVERDUE_GRACE_DAYS"),
|
||||
);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the surfaces that show the verdict actually read it", () => {
|
||||
const CONSUMERS = [
|
||||
"src/components/cycle/cycle-view.tsx",
|
||||
"src/components/cycle/cycle-ring-tile.tsx",
|
||||
"src/components/cycle/cycle-insight-summary-card.tsx",
|
||||
];
|
||||
|
||||
it.each(CONSUMERS)("%s reads the published verdict", (relative) => {
|
||||
const paths = accessPaths(parseFile(relative));
|
||||
const readsVerdict = [...paths].some((p) => p.endsWith(".verdict"));
|
||||
expect(readsVerdict).toBe(true);
|
||||
});
|
||||
|
||||
it("the temperature chart takes the cycle start from the verdict", () => {
|
||||
// The BBT chart used to walk the calendar itself to find the cycle start,
|
||||
// which was a second copy of the same walk. It now takes the resolved
|
||||
// value as a prop, and the cycle page feeds it from the verdict.
|
||||
const chart = readFileSync(
|
||||
join(ROOT, "src/components/cycle/bbt-chart.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
expect(chart).toContain("cycleStartDate");
|
||||
|
||||
const paths = accessPaths(parseFile("src/components/cycle/cycle-view.tsx"));
|
||||
expect([...paths]).toContain("verdict.cycleStartDate");
|
||||
});
|
||||
|
||||
it("the calendar response type declares the verdict as always present", () => {
|
||||
// An optional verdict is a plausible default that lets a client fall back
|
||||
// to deriving its own.
|
||||
const types = readFileSync(
|
||||
join(ROOT, "src/components/cycle/types.ts"),
|
||||
"utf8",
|
||||
);
|
||||
expect(types).toContain("verdict: CycleVerdict;");
|
||||
expect(types).not.toContain("verdict?:");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* The detector behind the cycle-verdict single-decider guard.
|
||||
*
|
||||
* The cycle ring's day count, its phase arcs and the judgement that a period is
|
||||
* late are resolved once, on the server, and published on
|
||||
* `GET /api/cycle/calendar`. A second copy of that reasoning in client code is
|
||||
* not a duplicate detail; it is a second answer about a person's body, and the
|
||||
* last time it existed it froze someone's ring on a day their record did not
|
||||
* hold.
|
||||
*
|
||||
* A text search would not hold this line. The grace window can be renamed,
|
||||
* inlined as a bare `14`, or lifted into a variable one statement above the
|
||||
* comparison — a defeat this repo has already watched happen to a different
|
||||
* guard. So this reads the syntax tree and looks for three things:
|
||||
*
|
||||
* 1. a numeric constant named like a grace / overdue window;
|
||||
* 2. a binding named like an overdue verdict whose value is COMPUTED from a
|
||||
* comparison rather than read from the published `state`;
|
||||
* 3. the threshold shape itself — a cycle-day count compared against a sum —
|
||||
* whatever the pieces happen to be called.
|
||||
*
|
||||
* Rule 3 is what survives the rename: the constant can go anywhere, but the
|
||||
* comparison has to exist somewhere for the client to reach a verdict, and it
|
||||
* has the same shape wherever it hides.
|
||||
*/
|
||||
import ts from "typescript";
|
||||
|
||||
export type FindingKind =
|
||||
"grace-constant" | "client-derived-verdict" | "day-count-threshold";
|
||||
|
||||
export interface Finding {
|
||||
kind: FindingKind;
|
||||
/** The binding or expression text that tripped the rule. */
|
||||
where: string;
|
||||
file: string;
|
||||
line: number;
|
||||
}
|
||||
|
||||
/** Names that read as a grace / overdue window. */
|
||||
const GRACE_NAME = /grace|overdue/i;
|
||||
/** Names that read as "is the period late?". */
|
||||
const VERDICT_NAME = /overdue|periodlate|islate|isoverdue/i;
|
||||
/** Names that read as a cycle-day count. */
|
||||
const DAY_COUNT_NAME = /day.?of.?cycle|cycleday|cyclerun|runlength/i;
|
||||
|
||||
/**
|
||||
* Markers that put a file in cycle territory. "Overdue" is a word other parts
|
||||
* of the product use honestly — a medication dose is overdue, a screening is
|
||||
* overdue — so the rules below only apply where the cycle's own vocabulary is
|
||||
* present. A file cannot re-derive the cycle verdict without touching the
|
||||
* calendar's types, its phase names, or the verdict's own fields, so nothing
|
||||
* that could actually commit the defect escapes by renaming one identifier.
|
||||
*/
|
||||
const CYCLE_MARKERS = new Set([
|
||||
"CalendarDay",
|
||||
"CalendarResponse",
|
||||
"CyclePhase",
|
||||
"CycleVerdict",
|
||||
"CycleVerdictState",
|
||||
"useCycleCalendar",
|
||||
"dayOfCycle",
|
||||
"cycleLength",
|
||||
"cycleStartDate",
|
||||
"typicalCycleLength",
|
||||
"lutealPhaseLength",
|
||||
"periodOverdue",
|
||||
"MENSTRUAL",
|
||||
"FOLLICULAR",
|
||||
"OVULATORY",
|
||||
"LUTEAL",
|
||||
]);
|
||||
|
||||
/** The published states. A comparison against one of these is a READ. */
|
||||
const PUBLISHED_STATES = new Set(["IN_CYCLE", "OVERDUE", "INSUFFICIENT_DATA"]);
|
||||
|
||||
const COMPARISON_OPERATORS = new Set<ts.SyntaxKind>([
|
||||
ts.SyntaxKind.GreaterThanToken,
|
||||
ts.SyntaxKind.GreaterThanEqualsToken,
|
||||
ts.SyntaxKind.LessThanToken,
|
||||
ts.SyntaxKind.LessThanEqualsToken,
|
||||
ts.SyntaxKind.EqualsEqualsToken,
|
||||
ts.SyntaxKind.EqualsEqualsEqualsToken,
|
||||
ts.SyntaxKind.ExclamationEqualsToken,
|
||||
ts.SyntaxKind.ExclamationEqualsEqualsToken,
|
||||
]);
|
||||
|
||||
const ARITHMETIC_OPERATORS = new Set<ts.SyntaxKind>([
|
||||
ts.SyntaxKind.PlusToken,
|
||||
ts.SyntaxKind.MinusToken,
|
||||
]);
|
||||
|
||||
export function parse(fileName: string, source: string): ts.SourceFile {
|
||||
return ts.createSourceFile(
|
||||
fileName,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
fileName.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
||||
);
|
||||
}
|
||||
|
||||
/** Strip parentheses, `as`, `satisfies`, `!` so the shape underneath shows. */
|
||||
function unwrap(node: ts.Expression): ts.Expression {
|
||||
let cur = node;
|
||||
for (;;) {
|
||||
if (
|
||||
ts.isParenthesizedExpression(cur) ||
|
||||
ts.isAsExpression(cur) ||
|
||||
ts.isSatisfiesExpression(cur) ||
|
||||
ts.isNonNullExpression(cur)
|
||||
) {
|
||||
cur = cur.expression;
|
||||
continue;
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
}
|
||||
|
||||
/** A plain number literal, with or without a leading minus. */
|
||||
function isNumericConstant(node: ts.Expression | undefined): boolean {
|
||||
if (!node) return false;
|
||||
const inner = unwrap(node);
|
||||
if (ts.isNumericLiteral(inner)) return true;
|
||||
return (
|
||||
ts.isPrefixUnaryExpression(inner) &&
|
||||
inner.operator === ts.SyntaxKind.MinusToken &&
|
||||
ts.isNumericLiteral(inner.operand)
|
||||
);
|
||||
}
|
||||
|
||||
/** Every identifier-ish name appearing anywhere under `node`. */
|
||||
function namesUnder(node: ts.Node): string[] {
|
||||
const out: string[] = [];
|
||||
function visit(n: ts.Node): void {
|
||||
if (ts.isIdentifier(n)) out.push(n.text);
|
||||
ts.forEachChild(n, visit);
|
||||
}
|
||||
visit(node);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether the expression compares against the PUBLISHED verdict state. */
|
||||
function readsPublishedState(node: ts.Node): boolean {
|
||||
let found = false;
|
||||
function visit(n: ts.Node): void {
|
||||
if (found) return;
|
||||
if (ts.isStringLiteral(n) && PUBLISHED_STATES.has(n.text)) {
|
||||
found = true;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(ts.isPropertyAccessExpression(n) || ts.isElementAccessExpression(n)) &&
|
||||
ts.isPropertyAccessExpression(n) &&
|
||||
n.name.text === "state"
|
||||
) {
|
||||
found = true;
|
||||
return;
|
||||
}
|
||||
ts.forEachChild(n, visit);
|
||||
}
|
||||
visit(node);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Whether a comparison lives anywhere under `node`. */
|
||||
function containsComparison(node: ts.Node): boolean {
|
||||
let found = false;
|
||||
function visit(n: ts.Node): void {
|
||||
if (found) return;
|
||||
if (
|
||||
ts.isBinaryExpression(n) &&
|
||||
COMPARISON_OPERATORS.has(n.operatorToken.kind)
|
||||
) {
|
||||
found = true;
|
||||
return;
|
||||
}
|
||||
ts.forEachChild(n, visit);
|
||||
}
|
||||
visit(node);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** The name of a declaration, when it has a plain one. */
|
||||
function declaredName(
|
||||
name: ts.Node | undefined,
|
||||
file: ts.SourceFile,
|
||||
): string | null {
|
||||
if (!name) return null;
|
||||
if (ts.isIdentifier(name)) return name.text;
|
||||
if (ts.isStringLiteral(name)) return name.text;
|
||||
if (ts.isComputedPropertyName(name)) return null;
|
||||
if (ts.isPrivateIdentifier(name)) return name.text;
|
||||
return name.getText(file);
|
||||
}
|
||||
|
||||
/** Every `const NAME = <expr>` in the file, so one line up is not a hiding place. */
|
||||
function localBindings(file: ts.SourceFile): Map<string, ts.Expression> {
|
||||
const bindings = new Map<string, ts.Expression>();
|
||||
function visit(node: ts.Node): void {
|
||||
if (
|
||||
ts.isVariableDeclaration(node) &&
|
||||
ts.isIdentifier(node.name) &&
|
||||
node.initializer
|
||||
) {
|
||||
bindings.set(node.name.text, node.initializer);
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
visit(file);
|
||||
return bindings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the expression is a sum, following local `const` bindings.
|
||||
*
|
||||
* `dayOfCycle > typical + grace` and
|
||||
* `const ceiling = typical + grace; dayOfCycle > ceiling` are the same
|
||||
* derivation written two ways, and lifting the sum into a variable one line
|
||||
* above the comparison is precisely how a structural guard was talked around
|
||||
* here before. Two hops is enough for any readable version of the move.
|
||||
*/
|
||||
function isSumLike(
|
||||
node: ts.Expression,
|
||||
bindings: Map<string, ts.Expression>,
|
||||
seen: Set<string> = new Set(),
|
||||
depth = 0,
|
||||
): boolean {
|
||||
const inner = unwrap(node);
|
||||
if (
|
||||
ts.isBinaryExpression(inner) &&
|
||||
ARITHMETIC_OPERATORS.has(inner.operatorToken.kind)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (depth >= 3) return false;
|
||||
if (ts.isIdentifier(inner) && !seen.has(inner.text)) {
|
||||
const bound = bindings.get(inner.text);
|
||||
if (bound) {
|
||||
return isSumLike(
|
||||
bound,
|
||||
bindings,
|
||||
new Set(seen).add(inner.text),
|
||||
depth + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Whether the file speaks the cycle's vocabulary at all. */
|
||||
export function isCycleContext(file: ts.SourceFile): boolean {
|
||||
let found = false;
|
||||
function visit(n: ts.Node): void {
|
||||
if (found) return;
|
||||
if (ts.isIdentifier(n) && CYCLE_MARKERS.has(n.text)) {
|
||||
found = true;
|
||||
return;
|
||||
}
|
||||
if (ts.isStringLiteral(n) && CYCLE_MARKERS.has(n.text)) {
|
||||
found = true;
|
||||
return;
|
||||
}
|
||||
ts.forEachChild(n, visit);
|
||||
}
|
||||
visit(file);
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every way the cycle verdict could be decided again in the file.
|
||||
* An empty array is the only acceptable result for client code.
|
||||
*/
|
||||
export function derivationFindings(file: ts.SourceFile): Finding[] {
|
||||
const out: Finding[] = [];
|
||||
if (!isCycleContext(file)) return out;
|
||||
const bindings = localBindings(file);
|
||||
|
||||
function record(kind: FindingKind, node: ts.Node, where: string): void {
|
||||
const { line } = file.getLineAndCharacterOfPosition(node.getStart(file));
|
||||
out.push({ kind, where, file: file.fileName, line: line + 1 });
|
||||
}
|
||||
|
||||
function checkBinding(
|
||||
node: ts.Node,
|
||||
nameNode: ts.Node | undefined,
|
||||
initializer: ts.Expression | undefined,
|
||||
): void {
|
||||
const name = declaredName(nameNode, file);
|
||||
if (name === null || initializer === undefined) return;
|
||||
|
||||
// 1. A grace / overdue window pinned to a number.
|
||||
if (GRACE_NAME.test(name) && isNumericConstant(initializer)) {
|
||||
record("grace-constant", node, name);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. An overdue verdict COMPUTED here. Reading the published `state` is
|
||||
// exactly what the client is supposed to do, so that shape is allowed.
|
||||
if (
|
||||
VERDICT_NAME.test(name) &&
|
||||
containsComparison(initializer) &&
|
||||
!readsPublishedState(initializer)
|
||||
) {
|
||||
record("client-derived-verdict", node, name);
|
||||
}
|
||||
}
|
||||
|
||||
function visit(node: ts.Node): void {
|
||||
if (ts.isVariableDeclaration(node)) {
|
||||
checkBinding(node, node.name, node.initializer);
|
||||
} else if (ts.isPropertyAssignment(node)) {
|
||||
checkBinding(node, node.name, node.initializer);
|
||||
} else if (ts.isPropertyDeclaration(node)) {
|
||||
checkBinding(node, node.name, node.initializer);
|
||||
} else if (ts.isParameter(node)) {
|
||||
checkBinding(node, node.name, node.initializer);
|
||||
} else if (ts.isEnumMember(node)) {
|
||||
checkBinding(node, node.name, node.initializer);
|
||||
}
|
||||
|
||||
// 3. The threshold shape: a cycle-day count compared against a sum. The
|
||||
// constant can be renamed, inlined, or lifted into a variable one line
|
||||
// up; this comparison still has to exist for a client to decide.
|
||||
if (
|
||||
ts.isBinaryExpression(node) &&
|
||||
COMPARISON_OPERATORS.has(node.operatorToken.kind)
|
||||
) {
|
||||
const hasSum = [node.left, node.right].some((side) =>
|
||||
isSumLike(side, bindings),
|
||||
);
|
||||
const mentionsDayCount = namesUnder(node).some((n) =>
|
||||
DAY_COUNT_NAME.test(n),
|
||||
);
|
||||
if (hasSum && mentionsDayCount) {
|
||||
record("day-count-threshold", node, node.getText(file).slice(0, 120));
|
||||
}
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
|
||||
visit(file);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every property-access path in the file, e.g. `calendar.data.verdict`. */
|
||||
export function accessPaths(file: ts.SourceFile): Set<string> {
|
||||
const paths = new Set<string>();
|
||||
function pathOf(expression: ts.Expression): string | null {
|
||||
const inner = unwrap(expression);
|
||||
if (ts.isIdentifier(inner)) return inner.text;
|
||||
if (ts.isPropertyAccessExpression(inner)) {
|
||||
const base = pathOf(inner.expression);
|
||||
return base === null ? null : `${base}.${inner.name.text}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function visit(node: ts.Node): void {
|
||||
if (ts.isPropertyAccessExpression(node)) {
|
||||
const p = pathOf(node);
|
||||
if (p !== null) paths.add(p);
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
visit(file);
|
||||
return paths;
|
||||
}
|
||||
@@ -559,7 +559,6 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
|
||||
now: scoredAt,
|
||||
profile: {
|
||||
dateOfBirth: user.dateOfBirth,
|
||||
gender: user.gender,
|
||||
heightCm: user.heightCm ?? null,
|
||||
timezone: userTz,
|
||||
sourcePriorityJson: scoreSourcePriority,
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* The bytes a client actually receives from an idempotency 409.
|
||||
*
|
||||
* `POST /api/anamnesis/facts` is the route the drift was reported against
|
||||
* (iOS #75), but nothing here is specific to it — the 409 comes from
|
||||
* `withIdempotency`, so every route under that wrapper serves the same
|
||||
* envelope. Until v1.35.1 both in-flight arms sent `error` as
|
||||
* `{ message: … }`, the only object-shaped error the app emitted and not the
|
||||
* `{ data, error: <string> }` envelope the published contract promises.
|
||||
*
|
||||
* This runs the REAL chain — the real route module, the real `apiHandler`,
|
||||
* the real `withIdempotency` — with only the data layer and the session
|
||||
* mocked, and asserts on the parsed response body. A schema test would not
|
||||
* have caught this: the schema was already right.
|
||||
*
|
||||
* Both 409 arms are covered:
|
||||
* - a `PENDING_STATUS` row already in the table (the in-flight lookup);
|
||||
* - a lost claim race (the row appears between the lookup and the insert).
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.stubEnv("ENCRYPTION_KEYS", "");
|
||||
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
|
||||
vi.stubEnv("ENCRYPTION_KEY", "0".repeat(64));
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
createFact: vi.fn(),
|
||||
factFindFirst: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
idempotencyKey: {
|
||||
findUnique: mocks.findUnique,
|
||||
create: mocks.create,
|
||||
deleteMany: mocks.deleteMany,
|
||||
updateMany: mocks.updateMany,
|
||||
delete: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
apiToken: { findUnique: vi.fn() },
|
||||
healthProfileFactRevision: { findFirst: mocks.factFindFirst },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: mocks.getSession }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/profile/health-facts", () => ({
|
||||
readHealthProfileFacts: vi.fn(),
|
||||
createHealthProfileFact: mocks.createFact,
|
||||
correctHealthProfileFact: vi.fn(),
|
||||
removeHealthProfileFact: vi.fn(),
|
||||
}));
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
import { POST } from "../route";
|
||||
|
||||
/** The pending sentinel `findCached` recognises as "another call is running". */
|
||||
const PENDING_STATUS = 0;
|
||||
|
||||
function request(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/anamnesis/facts", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"idempotency-key": "key-in-flight",
|
||||
},
|
||||
body: JSON.stringify({ kind: "SMOKING_STATUS", value: "FORMER" }),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getSession.mockResolvedValue({
|
||||
user: { id: "user-1", role: "USER" },
|
||||
});
|
||||
mocks.findUnique.mockResolvedValue(null);
|
||||
mocks.create.mockResolvedValue({});
|
||||
mocks.deleteMany.mockResolvedValue({ count: 0 });
|
||||
mocks.updateMany.mockResolvedValue({ count: 1 });
|
||||
mocks.factFindFirst.mockResolvedValue(null);
|
||||
mocks.createFact.mockResolvedValue({ id: "fact-1" });
|
||||
});
|
||||
|
||||
describe("idempotency 409 envelope, over the real route", () => {
|
||||
it("serves `error` as a string when a request is already in flight", async () => {
|
||||
mocks.findUnique.mockResolvedValue({
|
||||
id: "idem-1",
|
||||
responseStatus: PENDING_STATUS,
|
||||
responseBody: "",
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
});
|
||||
|
||||
const response = await POST(request());
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(body.data).toBeNull();
|
||||
expect(typeof body.error).toBe("string");
|
||||
expect(body.error).toBe(
|
||||
"A request with this Idempotency-Key is already in progress",
|
||||
);
|
||||
// The shape the drift produced, named explicitly so a revert cannot
|
||||
// pass by satisfying only the `typeof` check above.
|
||||
expect(body.error).not.toHaveProperty("message");
|
||||
expect(response.headers.get("X-Idempotent-Replay")).toBe("false");
|
||||
// The side effect must not have run on a refused call.
|
||||
expect(mocks.createFact).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serves `error` as a string when the claim race is lost", async () => {
|
||||
// No row on lookup, but the claiming insert collides with the racing
|
||||
// request that got there first.
|
||||
mocks.findUnique.mockResolvedValue(null);
|
||||
mocks.create.mockRejectedValue(
|
||||
Object.assign(new Error("Unique constraint failed"), { code: "P2002" }),
|
||||
);
|
||||
|
||||
const response = await POST(request());
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(body.data).toBeNull();
|
||||
expect(typeof body.error).toBe("string");
|
||||
expect(body.error).toBe(
|
||||
"A request with this Idempotency-Key is already in progress",
|
||||
);
|
||||
expect(body.error).not.toHaveProperty("message");
|
||||
expect(response.headers.get("X-Idempotent-Replay")).toBe("false");
|
||||
expect(mocks.createFact).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -63,20 +63,20 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
import type { ScorePillarId } from "@/lib/analytics/score/types";
|
||||
import { buildOpenApiDocument } from "@/lib/openapi/registry";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
const ALL_EIGHT: ScorePillarId[] = [
|
||||
const ALL_PILLARS: ScorePillarId[] = [
|
||||
"BLOOD_PRESSURE",
|
||||
"GLYCAEMIA",
|
||||
"ACTIVITY",
|
||||
"SLEEP",
|
||||
"ADIPOSITY",
|
||||
"WELLBEING",
|
||||
"FITNESS",
|
||||
"LIPIDS",
|
||||
];
|
||||
|
||||
@@ -154,7 +154,7 @@ describe("GET /api/auth/me/health-score-config", () => {
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: ResolvedBody };
|
||||
expect(env.data.pillars).toEqual(ALL_EIGHT);
|
||||
expect(env.data.pillars).toEqual(ALL_PILLARS);
|
||||
expect(env.data.hasSelection).toBe(false);
|
||||
expect(env.data.version).toBe(0);
|
||||
expect(env.data.updatedAt).toBeUndefined();
|
||||
@@ -172,7 +172,7 @@ describe("GET /api/auth/me/health-score-config", () => {
|
||||
);
|
||||
const env = (await res.json()) as { data: ResolvedBody };
|
||||
expect(env.data.pillars).toEqual(
|
||||
ALL_EIGHT.filter((id) => id !== "SLEEP" && id !== "LIPIDS"),
|
||||
ALL_PILLARS.filter((id) => id !== "SLEEP" && id !== "LIPIDS"),
|
||||
);
|
||||
expect(env.data.excludedPillars).toEqual(["SLEEP", "LIPIDS"]);
|
||||
expect(env.data.hasSelection).toBe(true);
|
||||
@@ -185,7 +185,7 @@ describe("PATCH /api/auth/me/health-score-config", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ pillars: ALL_EIGHT }),
|
||||
mkPatch({ pillars: ALL_PILLARS }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
@@ -211,7 +211,7 @@ describe("PATCH /api/auth/me/health-score-config", () => {
|
||||
|
||||
const stored = writtenConfig();
|
||||
expect(stored.excludedPillars).toEqual(
|
||||
ALL_EIGHT.filter((id) => !selection.includes(id)),
|
||||
ALL_PILLARS.filter((id) => !selection.includes(id)),
|
||||
);
|
||||
expect(stored.version).toBe(1);
|
||||
expect(stored.changedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
@@ -230,7 +230,7 @@ describe("PATCH /api/auth/me/health-score-config", () => {
|
||||
});
|
||||
|
||||
await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ pillars: ALL_EIGHT }),
|
||||
mkPatch({ pillars: ALL_PILLARS }),
|
||||
);
|
||||
expect(writtenConfig().version).toBe(8);
|
||||
});
|
||||
@@ -240,7 +240,7 @@ describe("PATCH /api/auth/me/health-score-config", () => {
|
||||
primeUser(null);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ pillars: ALL_EIGHT }),
|
||||
mkPatch({ pillars: ALL_PILLARS }),
|
||||
);
|
||||
const env = (await res.json()) as { data: ResolvedBody };
|
||||
expect(writtenConfig().excludedPillars).toEqual([]);
|
||||
@@ -252,7 +252,7 @@ describe("PATCH /api/auth/me/health-score-config", () => {
|
||||
primeUser(null);
|
||||
|
||||
await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ pillars: ALL_EIGHT }),
|
||||
mkPatch({ pillars: ALL_PILLARS }),
|
||||
);
|
||||
expect(invalidateUserHealthScore).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
@@ -334,7 +334,7 @@ describe("PATCH — envelope and transport", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser(null);
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ pillars: ALL_EIGHT, weights: { SLEEP: 2 } }),
|
||||
mkPatch({ pillars: ALL_PILLARS, weights: { SLEEP: 2 } }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
@@ -344,7 +344,7 @@ describe("PATCH — envelope and transport", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser(null);
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ pillars: ALL_EIGHT, userId: "user-2" }),
|
||||
mkPatch({ pillars: ALL_PILLARS, userId: "user-2" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
@@ -373,7 +373,7 @@ describe("PATCH — envelope and transport", () => {
|
||||
resetAt: Date.now() + 30_000,
|
||||
});
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ pillars: ALL_EIGHT }),
|
||||
mkPatch({ pillars: ALL_PILLARS }),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
@@ -386,7 +386,7 @@ describe("PATCH — envelope and transport", () => {
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({
|
||||
pillars: ALL_EIGHT,
|
||||
pillars: ALL_PILLARS,
|
||||
baseUpdatedAt: "2026-07-24T08:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
@@ -398,3 +398,145 @@ describe("PATCH — envelope and transport", () => {
|
||||
expect(invalidateUserHealthScore).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.35.1 — the route shipped in v1.35.0 with a settings surface in front
|
||||
* of it and no published contract at all: `grep -c health-score-config
|
||||
* docs/api/openapi.yaml` returned 0. This block is the second end.
|
||||
*
|
||||
* It does not compare the spec against a restatement of the spec. It drives
|
||||
* the REAL route to produce each response, reads the codes and keys off the
|
||||
* bytes it actually returns, and asserts the published contract names them.
|
||||
* A response the route can emit but the contract does not describe fails
|
||||
* here, which is the failure the release itself did not catch.
|
||||
*/
|
||||
describe("the published contract matches what the route serves", () => {
|
||||
const PATH = "/api/auth/me/health-score-config";
|
||||
|
||||
function doc() {
|
||||
return buildOpenApiDocument() as {
|
||||
paths?: Record<
|
||||
string,
|
||||
Record<string, { responses?: Record<string, { description?: string }> }>
|
||||
>;
|
||||
components?: {
|
||||
schemas?: Record<
|
||||
string,
|
||||
{ properties?: Record<string, unknown>; required?: string[] }
|
||||
>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/** Every `meta.errorCode` the PATCH route can actually produce. */
|
||||
async function emittedErrorCodes(): Promise<Map<number, Set<string>>> {
|
||||
const byStatus = new Map<number, Set<string>>();
|
||||
const record = async (body: unknown, conflict = false) => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser(null);
|
||||
vi.mocked(prisma.user.updateMany).mockResolvedValue({
|
||||
count: conflict ? 0 : 1,
|
||||
} as never);
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch(body),
|
||||
);
|
||||
const env = (await res.json()) as { meta?: { errorCode?: string } };
|
||||
if (env.meta?.errorCode) {
|
||||
const set = byStatus.get(res.status) ?? new Set<string>();
|
||||
set.add(env.meta.errorCode);
|
||||
byStatus.set(res.status, set);
|
||||
}
|
||||
};
|
||||
|
||||
// Too narrow, both reasons.
|
||||
await record({ pillars: ["ACTIVITY"] });
|
||||
await record({ pillars: ["BLOOD_PRESSURE", "GLYCAEMIA", "LIPIDS"] });
|
||||
// Body that fails validation.
|
||||
await record({ pillars: ["NOT_A_PILLAR"] });
|
||||
// Present-but-unparseable base token.
|
||||
await record({ pillars: ALL_PILLARS, baseUpdatedAt: null });
|
||||
// Stale base token.
|
||||
await record(
|
||||
{ pillars: ALL_PILLARS, baseUpdatedAt: "2026-07-24T08:00:00.000Z" },
|
||||
true,
|
||||
);
|
||||
return byStatus;
|
||||
}
|
||||
|
||||
it("registers both verbs", () => {
|
||||
const path = doc().paths?.[PATH];
|
||||
expect(
|
||||
path,
|
||||
`${PATH} is not registered in the OpenAPI document`,
|
||||
).toBeDefined();
|
||||
expect(Object.keys(path ?? {}).sort()).toEqual(["get", "patch"]);
|
||||
});
|
||||
|
||||
it("names every errorCode the route can emit, on the right status", async () => {
|
||||
const emitted = await emittedErrorCodes();
|
||||
const responses = doc().paths?.[PATH]?.patch?.responses ?? {};
|
||||
|
||||
// Sanity: the driver really did produce the codes, so a contract that
|
||||
// named none of them could not pass by the loop never running.
|
||||
expect([...(emitted.get(422) ?? [])].sort()).toEqual([
|
||||
"health_score_config.invalid",
|
||||
"health_score_config.too_narrow",
|
||||
"invalid_base_updated_at",
|
||||
]);
|
||||
expect([...(emitted.get(409) ?? [])]).toEqual([
|
||||
"health_score_config_conflict",
|
||||
]);
|
||||
|
||||
for (const [status, codes] of emitted) {
|
||||
const described = responses[String(status)]?.description ?? "";
|
||||
for (const code of codes) {
|
||||
expect(
|
||||
described,
|
||||
`${status} description does not mention \`${code}\``,
|
||||
).toContain(code);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("publishes exactly the fields a real response carries", async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser(null);
|
||||
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ pillars: ALL_PILLARS }),
|
||||
);
|
||||
const env = (await res.json()) as { data: ResolvedBody };
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const schema = doc().components?.schemas?.["HealthScoreConfigResponse"];
|
||||
expect(schema, "HealthScoreConfigResponse is not emitted").toBeDefined();
|
||||
|
||||
expect(Object.keys(schema?.properties ?? {}).sort()).toEqual(
|
||||
Object.keys(env.data).sort(),
|
||||
);
|
||||
// `updatedAt` is absent on a never-configured GET, so it must not be
|
||||
// required — a generated decoder would reject that read.
|
||||
expect(schema?.required ?? []).not.toContain("updatedAt");
|
||||
});
|
||||
|
||||
it("leaves updatedAt off the read that genuinely has none", async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser(null);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request(`http://localhost${PATH}`),
|
||||
);
|
||||
const env = (await res.json()) as { data: ResolvedBody };
|
||||
const published = Object.keys(
|
||||
doc().components?.schemas?.["HealthScoreConfigResponse"]?.properties ??
|
||||
{},
|
||||
);
|
||||
|
||||
expect(Object.keys(env.data)).not.toContain("updatedAt");
|
||||
for (const key of Object.keys(env.data)) expect(published).toContain(key);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,14 @@
|
||||
*
|
||||
* The write half (the stamp on a verified assertion) is pinned in
|
||||
* `src/lib/auth/__tests__/passkey.test.ts`.
|
||||
*
|
||||
* v1.35.1 adds the published half. The route was absent from
|
||||
* `docs/api/openapi.yaml` entirely — no path entry, no schema — while iOS
|
||||
* had been told to generate against one (iOS #60). The last case below
|
||||
* compares the projection the route actually asks Prisma for against the
|
||||
* property set the contract publishes, so the two cannot drift: a column
|
||||
* added to the select without a schema entry fails, and so does a schema
|
||||
* property the route never serves.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
@@ -29,6 +37,7 @@ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
import { GET } from "../route";
|
||||
import { requireAuth } from "@/lib/api-handler";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { buildOpenApiDocument } from "@/lib/openapi/registry";
|
||||
|
||||
const USER = { id: "user-1", username: "u", email: "u@example.com" };
|
||||
|
||||
@@ -81,6 +90,29 @@ describe("GET /api/auth/passkeys", () => {
|
||||
expect(Object.keys(body.data[0])).toContain("lastUsedAt");
|
||||
expect(body.data[0].lastUsedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("publishes exactly the columns it selects", async () => {
|
||||
await (GET as unknown as () => Promise<Response>)();
|
||||
const args = vi.mocked(prisma.passkey.findMany).mock.calls[0][0];
|
||||
const selected = Object.keys(args?.select ?? {}).sort();
|
||||
|
||||
const doc = buildOpenApiDocument() as {
|
||||
paths?: Record<string, { get?: { responses?: Record<string, unknown> } }>;
|
||||
components?: {
|
||||
schemas?: Record<string, { properties?: Record<string, unknown> }>;
|
||||
};
|
||||
};
|
||||
|
||||
expect(
|
||||
doc.paths?.["/api/auth/passkeys"]?.get,
|
||||
"GET /api/auth/passkeys is not registered in the OpenAPI document",
|
||||
).toBeDefined();
|
||||
|
||||
const published = doc.components?.schemas?.["PasskeyInfo"]?.properties;
|
||||
expect(published, "PasskeyInfo schema is not emitted").toBeDefined();
|
||||
|
||||
expect(Object.keys(published ?? {}).sort()).toEqual(selected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("primary-passkey enrollment route guards", () => {
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* `GET /api/cycle/calendar` — the resolved verdict actually reaches a client.
|
||||
*
|
||||
* Both ends of a payload field can be written correctly and the wire can still
|
||||
* carry nothing; this repo has shipped exactly that. So nothing here is
|
||||
* hand-built downstream of the route: the test seeds rows, calls the REAL
|
||||
* exported `GET`, reads the response body as text, parses it, and asserts on
|
||||
* what came back. The engine, the calendar builder, the verdict resolver, the
|
||||
* DTO mapping and the envelope all run for real.
|
||||
*
|
||||
* The scenario is the one that froze a cycle ring: several long observed
|
||||
* cycles push the predicted next start weeks into the future, so every day of
|
||||
* the gap carries a phase label and the raw count walks past the profile's
|
||||
* typical length. The verdict must stop counting and say how late the period
|
||||
* is — resolved against the USER's timezone day, not the process's.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
menstrualCycle: { findMany: vi.fn() },
|
||||
cycleDayLog: { findMany: vi.fn() },
|
||||
measurement: { findMany: vi.fn() },
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("@/lib/cycle/gate", () => ({ requireCycleEnabled: vi.fn() }));
|
||||
// The cache persist is fire-and-forget and would reach the database.
|
||||
vi.mock("@/lib/cycle/prediction-cache", () => ({
|
||||
persistPredictionCache: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import { phaseForDate } from "@/lib/cycle/engine-adapter";
|
||||
import { resolveLuteal } from "@/lib/cycle/prediction";
|
||||
import { addDays } from "@/lib/cycle/day-math";
|
||||
|
||||
/** A user in UTC+14, so their calendar day runs ahead of the process's. */
|
||||
const AHEAD_TZ = "Pacific/Kiritimati";
|
||||
|
||||
function session(timezone: string) {
|
||||
return {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "tester",
|
||||
role: "USER" as const,
|
||||
gender: "FEMALE",
|
||||
timezone,
|
||||
locale: "en",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function profile(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
userId: "user-1",
|
||||
goal: "GENERAL_HEALTH",
|
||||
// What the person says their cycle usually is. The observed cycles below
|
||||
// are much longer, which is what drives the prediction past today.
|
||||
typicalCycleLength: 28,
|
||||
typicalPeriodLength: 5,
|
||||
lutealPhaseLength: 14,
|
||||
predictionEnabled: true,
|
||||
rawChartMode: false,
|
||||
secondarySymptom: "MUCUS",
|
||||
cycleTrackingEnabled: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Four period starts 60 days apart; the last one is still open. */
|
||||
const LONG_CYCLE_STARTS = [
|
||||
"2025-11-01",
|
||||
"2025-12-31",
|
||||
"2026-03-01",
|
||||
"2026-04-30",
|
||||
];
|
||||
|
||||
function cycleRows(starts: readonly string[]) {
|
||||
return starts.map((startDate, i) => ({
|
||||
id: `cyc-${i}`,
|
||||
userId: "user-1",
|
||||
startDate,
|
||||
endDate: null,
|
||||
periodEndDate: null,
|
||||
ovulationDate: null,
|
||||
ovulationConfirmed: false,
|
||||
isPredicted: false,
|
||||
}));
|
||||
}
|
||||
|
||||
async function callCalendar(): Promise<{
|
||||
status: number;
|
||||
body: Record<string, unknown>;
|
||||
}> {
|
||||
const res = await GET(new NextRequest("http://localhost/api/cycle/calendar"));
|
||||
// Read the WIRE, not the handler's return value.
|
||||
const text = await res.text();
|
||||
return { status: res.status, body: JSON.parse(text) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(session(AHEAD_TZ) as never);
|
||||
vi.mocked(requireCycleEnabled).mockResolvedValue({
|
||||
enabled: true,
|
||||
profile: profile(),
|
||||
} as never);
|
||||
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue(
|
||||
cycleRows(LONG_CYCLE_STARTS) as never,
|
||||
);
|
||||
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("GET /api/cycle/calendar carries the resolved verdict", () => {
|
||||
it("puts the whole verdict on the wire", async () => {
|
||||
// 2026-06-10 12:00 UTC is already 2026-06-11 for a user at UTC+14.
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-10T12:00:00Z"));
|
||||
|
||||
const { status, body } = await callCalendar();
|
||||
expect(status).toBe(200);
|
||||
|
||||
const data = body.data as Record<string, unknown>;
|
||||
expect(data).toHaveProperty("verdict");
|
||||
const verdict = data.verdict as Record<string, unknown>;
|
||||
|
||||
// Every field the contract promises, present on the response itself.
|
||||
expect(Object.keys(verdict).sort()).toEqual([
|
||||
"cycleLength",
|
||||
"cycleStartDate",
|
||||
"dayOfCycle",
|
||||
"daysUntilNext",
|
||||
"fertileWindow",
|
||||
"overdueDays",
|
||||
"phase",
|
||||
"spans",
|
||||
"state",
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves the overdue judgement in the USER's timezone, with the day count", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-10T12:00:00Z"));
|
||||
|
||||
const { body } = await callCalendar();
|
||||
const verdict = (body.data as Record<string, unknown>).verdict as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
// The user's day is 2026-06-11: 43 days into a cycle that opened on
|
||||
// 2026-04-30, past their stated 28-day typical plus the grace window. The
|
||||
// count stops and the delay is stated: 43 − 28 = 15.
|
||||
expect(verdict.state).toBe("OVERDUE");
|
||||
expect(verdict.dayOfCycle).toBeNull();
|
||||
expect(verdict.phase).toBeNull();
|
||||
expect(verdict.overdueDays).toBe(15);
|
||||
// The last logged period start survives the ceiling — the BBT chart scopes
|
||||
// to it and the ring still draws four arcs.
|
||||
expect(verdict.cycleStartDate).toBe("2026-04-30");
|
||||
expect(verdict.spans).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("reads one day earlier for the same instant in UTC — the zone decides", async () => {
|
||||
// Same wall-clock instant, a user whose day has not rolled over yet. If
|
||||
// the route resolved `today` from the process instead of the account, this
|
||||
// case and the one above could not differ.
|
||||
vi.mocked(getSession).mockResolvedValue(session("UTC") as never);
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-10T12:00:00Z"));
|
||||
|
||||
const { body } = await callCalendar();
|
||||
const verdict = (body.data as Record<string, unknown>).verdict as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
expect(verdict.state).toBe("IN_CYCLE");
|
||||
expect(verdict.dayOfCycle).toBe(42);
|
||||
expect(verdict.overdueDays).toBeNull();
|
||||
});
|
||||
|
||||
it("agrees with the phase engine the coach read uses", async () => {
|
||||
// Two independent server derivations of one number: the ring counts the
|
||||
// labelled run, the coach/MCP block asks the phase engine directly. They
|
||||
// must not be able to tell a person two different cycle days.
|
||||
vi.mocked(getSession).mockResolvedValue(session("UTC") as never);
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-20T12:00:00Z"));
|
||||
|
||||
const { body } = await callCalendar();
|
||||
const data = body.data as Record<string, unknown>;
|
||||
const verdict = data.verdict as Record<string, unknown>;
|
||||
const prediction = data.prediction as { nextPeriodStart: string } | null;
|
||||
|
||||
expect(verdict.state).toBe("IN_CYCLE");
|
||||
|
||||
const fromEngine = phaseForDate(
|
||||
"2026-05-20",
|
||||
cycleRows(LONG_CYCLE_STARTS) as never,
|
||||
prediction?.nextPeriodStart ?? null,
|
||||
resolveLuteal(profile() as never),
|
||||
);
|
||||
expect(verdict.dayOfCycle).toBe(fromEngine.dayOfCycle);
|
||||
expect(verdict.phase).toBe(fromEngine.phase);
|
||||
});
|
||||
|
||||
it("counts the days to the next predicted period", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(session("UTC") as never);
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-20T12:00:00Z"));
|
||||
|
||||
const { body } = await callCalendar();
|
||||
const data = body.data as Record<string, unknown>;
|
||||
const verdict = data.verdict as Record<string, unknown>;
|
||||
const prediction = data.prediction as { nextPeriodStart: string } | null;
|
||||
|
||||
expect(prediction).not.toBeNull();
|
||||
const expected = Math.round(
|
||||
(Date.parse(`${prediction!.nextPeriodStart}T12:00:00Z`) -
|
||||
Date.parse("2026-05-20T12:00:00Z")) /
|
||||
86_400_000,
|
||||
);
|
||||
expect(verdict.daysUntilNext).toBe(expected);
|
||||
expect(verdict.daysUntilNext).toBeGreaterThan(0);
|
||||
// Sanity: the predicted start really is ahead of today.
|
||||
expect(prediction!.nextPeriodStart).toBe(addDays("2026-05-20", expected));
|
||||
});
|
||||
|
||||
it("says INSUFFICIENT_DATA when nothing has been logged", async () => {
|
||||
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(getSession).mockResolvedValue(session("UTC") as never);
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-20T12:00:00Z"));
|
||||
|
||||
const { body } = await callCalendar();
|
||||
const verdict = (body.data as Record<string, unknown>).verdict as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
expect(verdict.state).toBe("INSUFFICIENT_DATA");
|
||||
expect(verdict.dayOfCycle).toBeNull();
|
||||
expect(verdict.cycleStartDate).toBeNull();
|
||||
expect(verdict.spans).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
goalAllowsFertileWindow,
|
||||
cycleDisclaimerKey,
|
||||
} from "@/lib/cycle/dto";
|
||||
import { resolveCycleVerdict } from "@/lib/cycle/verdict";
|
||||
import { persistPredictionCache } from "@/lib/cycle/prediction-cache";
|
||||
import { addDays, dayDiff } from "@/lib/cycle/day-math";
|
||||
import { BBT_WINDOW } from "@/lib/cycle/types";
|
||||
@@ -173,12 +174,36 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// "not a contraceptive method" caveat (QA H-1).
|
||||
const disclaimer = t.t(cycleDisclaimerKey(profile.goal));
|
||||
|
||||
const predictionDto = prediction
|
||||
? toCyclePredictionDTO(prediction, goalAllowsFertile, disclaimer)
|
||||
: null;
|
||||
|
||||
// The resolved verdict. Built from the SAME grid the response carries and
|
||||
// from the ALREADY-GATED prediction DTO, never the raw engine result — a
|
||||
// fertile window suppressed for the profile's goal or by the cold-start gate
|
||||
// cannot reappear here. `today` is the user's timezone day, which is the
|
||||
// other half of why this cannot be a client-side derivation: a browser in a
|
||||
// different zone resolves a different "today" and reads a different cycle day.
|
||||
const verdict = resolveCycleVerdict({
|
||||
days,
|
||||
today,
|
||||
profile: {
|
||||
typicalCycleLength: profile.typicalCycleLength,
|
||||
typicalPeriodLength: profile.typicalPeriodLength,
|
||||
lutealPhaseLength: profile.lutealPhaseLength,
|
||||
},
|
||||
nextPeriodStart: predictionDto?.nextPeriodStart ?? null,
|
||||
fertileWindowStart: predictionDto?.fertileWindowStart ?? null,
|
||||
fertileWindowEnd: predictionDto?.fertileWindowEnd ?? null,
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "cycle.calendar.read" },
|
||||
meta: {
|
||||
days: days.length,
|
||||
cycles_observed: prediction?.cyclesObserved ?? 0,
|
||||
has_prediction: prediction !== null,
|
||||
verdict_state: verdict.state,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -194,9 +219,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// so a consumer never sees two divergent "cycles observed" values.
|
||||
cyclesObserved: prediction?.cyclesObserved ?? cycles.length,
|
||||
},
|
||||
prediction: prediction
|
||||
? toCyclePredictionDTO(prediction, goalAllowsFertile, disclaimer)
|
||||
: null,
|
||||
prediction: predictionDto,
|
||||
// The one resolved answer about today: cycle day, the ring's arcs, how
|
||||
// many days until the next period, and whether the period is late and by
|
||||
// how much. Every client renders this; none of them recomputes it.
|
||||
verdict,
|
||||
// Top-level cold-start flag (mirrors `prediction.stillLearning`): the
|
||||
// calendar grid has suppressed fertile/ovulation/phase output, so the
|
||||
// client renders a calm "learning your cycle" banner over the grid. Additive
|
||||
|
||||
@@ -374,8 +374,6 @@ describe("DELETE /api/share-links/[id] — revoke", () => {
|
||||
* Mutation checks:
|
||||
* - drop the `SHARE_LINK_FORBIDDEN_LEAVES` check in the route → "refuses the
|
||||
* insurance leaf by name" goes red.
|
||||
* - drop `RETIRED_FHIR_SCOPE_FIELDS` from the create response → "keeps the
|
||||
* retired scope fields on the response" goes red.
|
||||
* - make `needsReselection` always false → "flags a link whose frozen scope
|
||||
* predates the selection model" goes red.
|
||||
*/
|
||||
@@ -433,21 +431,17 @@ describe("POST /api/share-links — the frozen scope", () => {
|
||||
expect(createArg.data.sectionsJson).toEqual({ v: 2, leaves: [] });
|
||||
});
|
||||
|
||||
it("keeps the retired scope fields on the response", async () => {
|
||||
// Response-only, serving constants. The request refuses them and the
|
||||
// columns are gone; they stay here until the native client confirms it
|
||||
// does not decode them.
|
||||
it("the retired FHIR scope fields are gone from the create response", async () => {
|
||||
// #70 — the native client confirmed removal breaks no shipped decoder.
|
||||
vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue(
|
||||
storedRow() as never,
|
||||
);
|
||||
const res = await POST(
|
||||
postReq(validBody({ selection: { v: 2, leaves: ["WEIGHT"] } })),
|
||||
);
|
||||
const body = (await res.json()) as {
|
||||
data: { allowFhirApi: boolean; resourceTypes: string[] };
|
||||
};
|
||||
expect(body.data.allowFhirApi).toBe(false);
|
||||
expect(body.data.resourceTypes).toEqual([]);
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
expect(body.data).not.toHaveProperty("allowFhirApi");
|
||||
expect(body.data).not.toHaveProperty("resourceTypes");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -465,12 +459,9 @@ describe("GET /api/share-links — the pick-again state", () => {
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
shareLinks: Array<{
|
||||
id: string;
|
||||
needsReselection: boolean;
|
||||
allowFhirApi: boolean;
|
||||
resourceTypes: string[];
|
||||
}>;
|
||||
shareLinks: Array<
|
||||
{ id: string; needsReselection: boolean } & Record<string, unknown>
|
||||
>;
|
||||
};
|
||||
};
|
||||
const byId = new Map(body.data.shareLinks.map((l) => [l.id, l]));
|
||||
@@ -479,8 +470,8 @@ describe("GET /api/share-links — the pick-again state", () => {
|
||||
// A documents-only link carried no report scope to begin with, so it was
|
||||
// never revoked and is not asked to pick again.
|
||||
expect(byId.get("docs")!.needsReselection).toBe(false);
|
||||
// The retired fields ride the list response too.
|
||||
expect(byId.get("current")!.allowFhirApi).toBe(false);
|
||||
expect(byId.get("current")!.resourceTypes).toEqual([]);
|
||||
// The retired FHIR scope fields are gone from the list response too.
|
||||
expect(byId.get("current")).not.toHaveProperty("allowFhirApi");
|
||||
expect(byId.get("current")).not.toHaveProperty("resourceTypes");
|
||||
});
|
||||
});
|
||||
@@ -87,22 +87,6 @@ const shareLinkSummarySelect = {
|
||||
_count: { select: { documents: true } },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* RETIRED, kept on the response only.
|
||||
*
|
||||
* These two fields described a scoped FHIR face for a share link that was
|
||||
* never built. The request schema refuses them — sending either is a 422 —
|
||||
* their columns are dropped, and nothing reads or honours them. They stay in
|
||||
* the create and list responses, serving the constant `false` and `[]`,
|
||||
* because the native client was given this response shape and a missing
|
||||
* non-optional key fails a whole Swift decode. They come out once #70 in the
|
||||
* native repository confirms the client does not decode them.
|
||||
*/
|
||||
const RETIRED_FHIR_SCOPE_FIELDS = {
|
||||
allowFhirApi: false,
|
||||
resourceTypes: [] as string[],
|
||||
} as const;
|
||||
|
||||
/** Project a stored row to the safe owner-facing shape (never the token). */
|
||||
function toSummary(row: {
|
||||
id: string;
|
||||
@@ -279,7 +263,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiSuccess(
|
||||
{
|
||||
...toSummary(created),
|
||||
...RETIRED_FHIR_SCOPE_FIELDS,
|
||||
token: rawToken,
|
||||
passphrase: rawPassphrase,
|
||||
shareUrl,
|
||||
@@ -300,9 +283,6 @@ export const GET = apiHandler(async () => {
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
shareLinks: rows.map((row) => ({
|
||||
...toSummary(row),
|
||||
...RETIRED_FHIR_SCOPE_FIELDS,
|
||||
})),
|
||||
shareLinks: rows.map((row) => toSummary(row)),
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,7 @@ describe("<BbtChart>", () => {
|
||||
<BbtChart
|
||||
days={days}
|
||||
today={today}
|
||||
cycleStartDate="2026-06-01"
|
||||
predictedOvulation={null}
|
||||
rawChartMode={false}
|
||||
/>,
|
||||
@@ -60,6 +61,7 @@ describe("<BbtChart>", () => {
|
||||
<BbtChart
|
||||
days={days}
|
||||
today={today}
|
||||
cycleStartDate="2026-06-01"
|
||||
predictedOvulation="2026-06-08"
|
||||
rawChartMode={false}
|
||||
/>,
|
||||
|
||||
@@ -25,7 +25,7 @@ vi.mock("@/hooks/use-unit-display", async () => {
|
||||
});
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { CalendarResponse } from "../types";
|
||||
import type { CalendarResponse, CycleVerdict } from "../types";
|
||||
import type { CycleInsightsResponse } from "../use-cycle";
|
||||
import type { CyclePhaseCrosstabRow } from "../cycle-phase-crosstab";
|
||||
|
||||
@@ -80,8 +80,6 @@ vi.mock("../use-cycle", async () => {
|
||||
...actual,
|
||||
useCycleCalendar: () => calendarState.current,
|
||||
useCycleInsights: () => insightsState.current,
|
||||
// The card now reads the profile lengths for the low-data idealized ring.
|
||||
useCycleProfile: () => ({ data: undefined }),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -93,9 +91,26 @@ function render(node: React.ReactNode) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Build a calendar read where `today` sits on a LUTEAL day at day-of-cycle 3
|
||||
* (a short MENSTRUAL run → LUTEAL today, so `deriveWheelState` resolves a
|
||||
* phase + day). */
|
||||
/** The verdict the server would resolve for that grid: LUTEAL, cycle day 3. */
|
||||
const LUTEAL_VERDICT: CycleVerdict = {
|
||||
state: "IN_CYCLE",
|
||||
dayOfCycle: 3,
|
||||
cycleLength: 28,
|
||||
phase: "LUTEAL",
|
||||
spans: [
|
||||
{ phase: "MENSTRUAL", fraction: 5 / 28 },
|
||||
{ phase: "FOLLICULAR", fraction: 7 / 28 },
|
||||
{ phase: "OVULATORY", fraction: 2 / 28 },
|
||||
{ phase: "LUTEAL", fraction: 14 / 28 },
|
||||
],
|
||||
cycleStartDate: null,
|
||||
overdueDays: null,
|
||||
daysUntilNext: null,
|
||||
fertileWindow: { start: null, end: null, active: false },
|
||||
};
|
||||
|
||||
/** A calendar read whose SERVER-RESOLVED verdict puts today on LUTEAL, day 3.
|
||||
* The card renders the verdict; it no longer derives anything from `days`. */
|
||||
function lutealCalendar(today: string): CalendarResponse {
|
||||
// Three consecutive days ending today: MENSTRUAL, FOLLICULAR, LUTEAL.
|
||||
const days = [
|
||||
@@ -107,6 +122,7 @@ function lutealCalendar(today: string): CalendarResponse {
|
||||
days: days as unknown as CalendarResponse["days"],
|
||||
profile: {} as CalendarResponse["profile"],
|
||||
prediction: null,
|
||||
verdict: LUTEAL_VERDICT,
|
||||
} as CalendarResponse;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { CalendarResponse } from "../types";
|
||||
import type { CalendarResponse, CycleVerdict } from "../types";
|
||||
|
||||
/**
|
||||
* v1.15.3 — the compact cycle ring as a wellness-strip tile.
|
||||
@@ -10,7 +10,7 @@ import type { CalendarResponse } from "../types";
|
||||
* The tile is mounted ONLY when `user.cycleTrackingEnabled` is true (a
|
||||
* page-level gate the Insights page owns, mirroring the summary card); these
|
||||
* tests cover the tile's own behaviour given that gate: it sources the phase +
|
||||
* cycle day from the SAME calendar read + `deriveWheelState` the wheel uses,
|
||||
* cycle day from the SAME server-resolved `verdict` the wheel reads,
|
||||
* renders the cycle ring labelled, deep-links to the cycle page, and stays
|
||||
* silent while resolving / on a hard error / when there is no active cycle so
|
||||
* the wellness strip never shows a half-painted dial (no gap, no placeholder).
|
||||
@@ -49,9 +49,6 @@ vi.mock("../use-cycle", async () => {
|
||||
return {
|
||||
...actual,
|
||||
useCycleCalendar: () => calendarState.current,
|
||||
// The tile now reads the profile lengths for the low-data idealized ring.
|
||||
// Mock it at the hook boundary too so the SSR snapshot needs no QueryClient.
|
||||
useCycleProfile: () => ({ data: undefined }),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -63,7 +60,38 @@ function render(node: React.ReactNode) {
|
||||
);
|
||||
}
|
||||
|
||||
/** A calendar read where `today` sits on a LUTEAL day at day-of-cycle 3. */
|
||||
/** The verdict the server would resolve for that grid: LUTEAL, cycle day 3. */
|
||||
const LUTEAL_VERDICT: CycleVerdict = {
|
||||
state: "IN_CYCLE",
|
||||
dayOfCycle: 3,
|
||||
cycleLength: 28,
|
||||
phase: "LUTEAL",
|
||||
spans: [
|
||||
{ phase: "MENSTRUAL", fraction: 5 / 28 },
|
||||
{ phase: "FOLLICULAR", fraction: 7 / 28 },
|
||||
{ phase: "OVULATORY", fraction: 2 / 28 },
|
||||
{ phase: "LUTEAL", fraction: 14 / 28 },
|
||||
],
|
||||
cycleStartDate: null,
|
||||
overdueDays: null,
|
||||
daysUntilNext: null,
|
||||
fertileWindow: { start: null, end: null, active: false },
|
||||
};
|
||||
|
||||
/** The verdict for an account with nothing to say about today. */
|
||||
const NO_CYCLE_VERDICT: CycleVerdict = {
|
||||
state: "INSUFFICIENT_DATA",
|
||||
dayOfCycle: null,
|
||||
cycleLength: null,
|
||||
phase: null,
|
||||
spans: [],
|
||||
cycleStartDate: null,
|
||||
overdueDays: null,
|
||||
daysUntilNext: null,
|
||||
fertileWindow: { start: null, end: null, active: false },
|
||||
};
|
||||
|
||||
/** A calendar read whose SERVER-RESOLVED verdict puts today on LUTEAL, day 3. */
|
||||
function lutealCalendar(today: string): CalendarResponse {
|
||||
const days = [
|
||||
{ date: shift(today, -2), phase: "MENSTRUAL" as const },
|
||||
@@ -74,6 +102,7 @@ function lutealCalendar(today: string): CalendarResponse {
|
||||
days: days as unknown as CalendarResponse["days"],
|
||||
profile: {} as CalendarResponse["profile"],
|
||||
prediction: null,
|
||||
verdict: LUTEAL_VERDICT,
|
||||
} as CalendarResponse;
|
||||
}
|
||||
|
||||
@@ -138,6 +167,7 @@ describe("<CycleRingTile>", () => {
|
||||
days: [] as unknown as CalendarResponse["days"],
|
||||
profile: {} as CalendarResponse["profile"],
|
||||
prediction: null,
|
||||
verdict: NO_CYCLE_VERDICT,
|
||||
} as CalendarResponse;
|
||||
calendarState.current = { data: empty, isLoading: false, isError: false };
|
||||
expect(render(<CycleRingTile />)).toBe("");
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { deriveWheelState, currentCycleStartDate } from "../wheel-state";
|
||||
import type { CalendarDay } from "../types";
|
||||
|
||||
function day(date: string, phase: CalendarDay["phase"]): CalendarDay {
|
||||
return {
|
||||
date,
|
||||
phase,
|
||||
isPredictedPeriod: false,
|
||||
isFertileWindow: false,
|
||||
isPredictedOvulation: false,
|
||||
isPeriodLogged: phase === "MENSTRUAL",
|
||||
flow: null,
|
||||
hasSymptoms: false,
|
||||
confidence: 1,
|
||||
basalBodyTempC: null,
|
||||
ovulationTest: null,
|
||||
cervicalMucus: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("deriveWheelState", () => {
|
||||
it("returns a null state when today carries no phase", () => {
|
||||
const days = [day("2026-06-01", null), day("2026-06-02", null)];
|
||||
const w = deriveWheelState(days, "2026-06-02");
|
||||
expect(w.dayOfCycle).toBeNull();
|
||||
expect(w.phase).toBeNull();
|
||||
expect(w.spans).toEqual([]);
|
||||
});
|
||||
|
||||
it("counts the day-of-cycle from the most recent menstrual start", () => {
|
||||
// A run starting 2026-06-01 (menstrual) → today 2026-06-05 is cycle day 5.
|
||||
const days = [
|
||||
day("2026-05-30", "LUTEAL"),
|
||||
day("2026-06-01", "MENSTRUAL"),
|
||||
day("2026-06-02", "MENSTRUAL"),
|
||||
day("2026-06-03", "FOLLICULAR"),
|
||||
day("2026-06-04", "FOLLICULAR"),
|
||||
day("2026-06-05", "FOLLICULAR"),
|
||||
];
|
||||
const w = deriveWheelState(days, "2026-06-05");
|
||||
expect(w.dayOfCycle).toBe(5);
|
||||
expect(w.phase).toBe("FOLLICULAR");
|
||||
});
|
||||
|
||||
it("builds proportional phase spans summing to ~1", () => {
|
||||
// A well-populated run (>= 7 labelled days spanning all four phases) keeps
|
||||
// the OBSERVED-share spans. Eight forward-labelled days exercise the
|
||||
// observed path (above the sparse threshold).
|
||||
const days = [
|
||||
day("2026-06-01", "MENSTRUAL"),
|
||||
day("2026-06-02", "MENSTRUAL"),
|
||||
day("2026-06-03", "MENSTRUAL"),
|
||||
day("2026-06-04", "FOLLICULAR"),
|
||||
day("2026-06-05", "FOLLICULAR"),
|
||||
day("2026-06-06", "OVULATORY"),
|
||||
day("2026-06-07", "LUTEAL"),
|
||||
day("2026-06-08", "LUTEAL"),
|
||||
];
|
||||
const w = deriveWheelState(days, "2026-06-05");
|
||||
const total = w.spans.reduce((s, x) => s + x.fraction, 0);
|
||||
expect(total).toBeCloseTo(1, 5);
|
||||
// All four observed phases are emitted, and the observed cycle length
|
||||
// matches the labelled run (8 days) — NOT the idealized fallback length.
|
||||
expect(w.spans.map((s) => s.phase).sort()).toEqual([
|
||||
"FOLLICULAR",
|
||||
"LUTEAL",
|
||||
"MENSTRUAL",
|
||||
"OVULATORY",
|
||||
]);
|
||||
expect(w.cycleLength).toBe(8);
|
||||
});
|
||||
|
||||
it("falls back to canonical four-phase spans for a low-data tracker", () => {
|
||||
// Only the first few days of a first-ever cycle are logged — a 2-day
|
||||
// menstrual run. The observed-share path would normalise this so MENSTRUAL
|
||||
// filled (nearly) the whole ring. The low-data fallback instead draws the
|
||||
// canonical four phases from the profile lengths.
|
||||
const days = [
|
||||
day("2026-06-01", "MENSTRUAL"),
|
||||
day("2026-06-02", "MENSTRUAL"),
|
||||
];
|
||||
const w = deriveWheelState(days, "2026-06-02", {
|
||||
typicalCycleLength: 28,
|
||||
typicalPeriodLength: 5,
|
||||
lutealPhaseLength: 14,
|
||||
});
|
||||
|
||||
// The marker read stays driven by the observed labels.
|
||||
expect(w.dayOfCycle).toBe(2);
|
||||
expect(w.phase).toBe("MENSTRUAL");
|
||||
|
||||
// FOUR sensible arcs, not one dominant sliver. The idealized cycle length
|
||||
// (5 + follicular + 2 + 14 = 28) is represented, and no single phase
|
||||
// swallows the ring.
|
||||
expect(w.spans).toHaveLength(4);
|
||||
expect(w.spans.map((s) => s.phase)).toEqual([
|
||||
"MENSTRUAL",
|
||||
"FOLLICULAR",
|
||||
"OVULATORY",
|
||||
"LUTEAL",
|
||||
]);
|
||||
const total = w.spans.reduce((s, x) => s + x.fraction, 0);
|
||||
expect(total).toBeCloseTo(1, 5);
|
||||
for (const span of w.spans) {
|
||||
expect(span.fraction).toBeGreaterThan(0);
|
||||
expect(span.fraction).toBeLessThan(0.95);
|
||||
}
|
||||
expect(w.cycleLength).toBe(28);
|
||||
});
|
||||
|
||||
it("uses textbook defaults for the low-data ring when no profile is given", () => {
|
||||
const days = [day("2026-06-01", "MENSTRUAL")];
|
||||
const w = deriveWheelState(days, "2026-06-01");
|
||||
expect(w.spans).toHaveLength(4);
|
||||
// Defaults: 28-day cycle.
|
||||
expect(w.cycleLength).toBe(28);
|
||||
expect(w.phase).toBe("MENSTRUAL");
|
||||
expect(w.dayOfCycle).toBe(1);
|
||||
expect(w.periodOverdue).toBe(false);
|
||||
});
|
||||
|
||||
// v1.27.5 — a months-old open cycle whose predicted next start moved into
|
||||
// the future labels every gap day LUTEAL; the wheel must not read that back
|
||||
// as "day 90 of your cycle".
|
||||
describe("overdue ceiling", () => {
|
||||
/** A run of 5 MENSTRUAL days + (n−5) LUTEAL days ending at `today`. */
|
||||
function longRun(n: number): { days: CalendarDay[]; today: string } {
|
||||
const start = Date.UTC(2026, 0, 1);
|
||||
const days: CalendarDay[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const date = new Date(start + i * 86_400_000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
days.push(day(date, i < 5 ? "MENSTRUAL" : "LUTEAL"));
|
||||
}
|
||||
return { days, today: days[n - 1].date };
|
||||
}
|
||||
|
||||
it("stops asserting a day count beyond typical length + grace", () => {
|
||||
const { days, today } = longRun(90);
|
||||
const w = deriveWheelState(days, today, { typicalCycleLength: 28 });
|
||||
expect(w.periodOverdue).toBe(true);
|
||||
expect(w.dayOfCycle).toBeNull();
|
||||
expect(w.phase).toBeNull();
|
||||
// The ring still draws the canonical four-phase dial.
|
||||
expect(w.spans).toHaveLength(4);
|
||||
expect(w.cycleLength).toBe(28);
|
||||
});
|
||||
|
||||
it("keeps counting inside the grace window", () => {
|
||||
// Typical 28 + 14 grace = 42; day 42 is still an honest count.
|
||||
const { days, today } = longRun(42);
|
||||
const w = deriveWheelState(days, today, { typicalCycleLength: 28 });
|
||||
expect(w.periodOverdue).toBe(false);
|
||||
expect(w.dayOfCycle).toBe(42);
|
||||
});
|
||||
|
||||
it("respects a long typical cycle length from the profile", () => {
|
||||
// A 60-day typical cycle keeps day 49 honest.
|
||||
const { days, today } = longRun(49);
|
||||
const w = deriveWheelState(days, today, { typicalCycleLength: 60 });
|
||||
expect(w.periodOverdue).toBe(false);
|
||||
expect(w.dayOfCycle).toBe(49);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("currentCycleStartDate", () => {
|
||||
it("returns the most recent menstrual run-start at/before today", () => {
|
||||
const days = [
|
||||
day("2026-05-30", "LUTEAL"),
|
||||
day("2026-06-01", "MENSTRUAL"),
|
||||
day("2026-06-02", "MENSTRUAL"),
|
||||
day("2026-06-03", "FOLLICULAR"),
|
||||
day("2026-06-04", "FOLLICULAR"),
|
||||
];
|
||||
expect(currentCycleStartDate(days, "2026-06-04")).toBe("2026-06-01");
|
||||
});
|
||||
|
||||
it("returns null when today carries no phase", () => {
|
||||
const days = [day("2026-06-01", null), day("2026-06-02", null)];
|
||||
expect(currentCycleStartDate(days, "2026-06-02")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when today is absent from the calendar", () => {
|
||||
const days = [day("2026-06-01", "MENSTRUAL")];
|
||||
expect(currentCycleStartDate(days, "2026-06-09")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,10 @@
|
||||
/**
|
||||
* v1.15.0 — the basal-body-temperature (BBT) chart.
|
||||
*
|
||||
* Plots the user's logged BBT across the CURRENT cycle (the same window the
|
||||
* wheel shows, via `currentCycleStartDate`) as a line with phase-tinted dots,
|
||||
* Plots the user's logged BBT across the CURRENT cycle — the same window the
|
||||
* wheel shows, because both take the cycle start from the server's resolved
|
||||
* verdict rather than each walking the calendar themselves — as a line with
|
||||
* phase-tinted dots,
|
||||
* the symptothermal signal a fertility-aware reader expects. Fertility signs
|
||||
* already logged for a day — egg-white/watery mucus, a positive LH test —
|
||||
* surface in the tooltip so the temperature shift reads in context. When the
|
||||
@@ -42,7 +44,6 @@ import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarDay, CervicalMucus, OvulationTest } from "./types";
|
||||
import { PHASE_HUE, OVULATION_HUE } from "./phase-tokens";
|
||||
import { currentCycleStartDate } from "./wheel-state";
|
||||
|
||||
/** Trailing fallback window (days) when no MENSTRUAL-anchored cycle is active. */
|
||||
const FALLBACK_WINDOW_DAYS = 35;
|
||||
@@ -60,6 +61,12 @@ interface BbtPoint {
|
||||
export interface BbtChartProps {
|
||||
days: CalendarDay[];
|
||||
today: string;
|
||||
/**
|
||||
* First day of the current cycle, from the calendar read's resolved
|
||||
* `verdict.cycleStartDate`. Null when no cycle is active, and the chart
|
||||
* falls back to a trailing window so recent temperatures still show.
|
||||
*/
|
||||
cycleStartDate: string | null;
|
||||
/** Estimated ovulation day (`YYYY-MM-DD`), or null when goal-gated off. */
|
||||
predictedOvulation: string | null;
|
||||
/** Whether the ovulation day was confirmed by a signal layer (solid marker). */
|
||||
@@ -76,6 +83,7 @@ function ymdToMs(d: string): number {
|
||||
export function BbtChart({
|
||||
days,
|
||||
today,
|
||||
cycleStartDate,
|
||||
predictedOvulation,
|
||||
ovulationConfirmed = false,
|
||||
rawChartMode,
|
||||
@@ -84,15 +92,15 @@ export function BbtChart({
|
||||
const fmt = useFormatters();
|
||||
const animationsEnabled = !prefersReducedMotion();
|
||||
|
||||
// Scope to the current cycle (wheel window); fall back to a trailing window
|
||||
// when no cycle is active so a user still sees their recent temperatures.
|
||||
// Scope to the current cycle using the server's resolved start; fall back to
|
||||
// a trailing window when no cycle is active so a user still sees their
|
||||
// recent temperatures.
|
||||
const fromDate = useMemo(() => {
|
||||
const start = currentCycleStartDate(days, today);
|
||||
if (start) return start;
|
||||
if (cycleStartDate) return cycleStartDate;
|
||||
const fallback = new Date(ymdToMs(today));
|
||||
fallback.setUTCDate(fallback.getUTCDate() - FALLBACK_WINDOW_DAYS);
|
||||
return fallback.toISOString().slice(0, 10);
|
||||
}, [days, today]);
|
||||
}, [cycleStartDate, today]);
|
||||
|
||||
const points = useMemo<BbtPoint[]>(() => {
|
||||
const fromMs = ymdToMs(fromDate);
|
||||
|
||||
@@ -8,22 +8,16 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { CyclePhaseHeadline } from "./cycle-phase-crosstab";
|
||||
import { PHASE_HUE } from "./phase-tokens";
|
||||
import { deriveWheelState } from "./wheel-state";
|
||||
import {
|
||||
localYmd,
|
||||
useCycleCalendar,
|
||||
useCycleInsights,
|
||||
useCycleProfile,
|
||||
} from "./use-cycle";
|
||||
import { localYmd, useCycleCalendar, useCycleInsights } from "./use-cycle";
|
||||
|
||||
/**
|
||||
* v1.15.2 — the gated cycle-insights summary card for the main Insights page.
|
||||
*
|
||||
* A compact TEASER + entry point, never a duplicate of the full cycle insights
|
||||
* tab. It surfaces three things and links into the source of truth:
|
||||
* 1. the current phase (name + hue dot) and cycle day, derived from the
|
||||
* SAME calendar read + `deriveWheelState` the cycle wheel uses, so the
|
||||
* two surfaces can never disagree on the day-of-cycle or phase;
|
||||
* 1. the current phase (name + hue dot) and cycle day, read from the SAME
|
||||
* server-resolved `verdict` on the calendar read the cycle wheel uses, so
|
||||
* the two surfaces can never disagree on the day-of-cycle or phase;
|
||||
* 2. the one headline phase finding, rendered through the SHARED
|
||||
* `<CyclePhaseHeadline>` (which already owns the FDR-gated copy + the
|
||||
* honest "not enough cycles yet" empty line) — no re-implementation;
|
||||
@@ -54,25 +48,13 @@ export function CycleInsightSummaryCard() {
|
||||
|
||||
// Mirror the cycle-view window so the cache key is shared (no extra request
|
||||
// when the user later opens the cycle page in the same session).
|
||||
const today = useMemo(() => shiftToday(0), []);
|
||||
const from = useMemo(() => shiftToday(-90), []);
|
||||
const to = useMemo(() => shiftToday(180), []);
|
||||
|
||||
const calendar = useCycleCalendar(from, to);
|
||||
const insights = useCycleInsights();
|
||||
// Shared 5-min-cached profile read so a low-data tracker still gets the
|
||||
// canonical four-phase ring (no extra request — shared cache key).
|
||||
const profile = useCycleProfile();
|
||||
|
||||
const wheel = useMemo(
|
||||
() =>
|
||||
deriveWheelState(calendar.data?.days ?? [], today, {
|
||||
typicalCycleLength: profile.data?.typicalCycleLength,
|
||||
typicalPeriodLength: profile.data?.typicalPeriodLength,
|
||||
lutealPhaseLength: profile.data?.lutealPhaseLength,
|
||||
}),
|
||||
[calendar.data, today, profile.data],
|
||||
);
|
||||
// The server's resolved verdict — the teaser reports it, never recomputes it.
|
||||
const verdict = calendar.data?.verdict;
|
||||
|
||||
// Stay silent until the calendar read resolves, and on a hard error: the
|
||||
// overview must never show a half-painted or error-framed cycle teaser. The
|
||||
@@ -80,10 +62,10 @@ export function CycleInsightSummaryCard() {
|
||||
if (calendar.isLoading && !calendar.data) return null;
|
||||
if (calendar.isError && !calendar.data) return null;
|
||||
|
||||
const phase = wheel.phase;
|
||||
const phase = verdict?.phase ?? null;
|
||||
const hue = phase ? PHASE_HUE[phase] : PHASE_HUE.LUTEAL;
|
||||
const phaseName = phase ? t(`cycle.phase.${phase}`) : t("cycle.phase.none");
|
||||
const dayOfCycle = wheel.dayOfCycle;
|
||||
const dayOfCycle = verdict?.dayOfCycle ?? null;
|
||||
|
||||
const ariaLabel =
|
||||
phase && dayOfCycle != null
|
||||
|
||||
@@ -8,8 +8,7 @@ import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { CycleRing } from "./cycle-ring";
|
||||
import { PHASE_HUE } from "./phase-tokens";
|
||||
import { deriveWheelState } from "./wheel-state";
|
||||
import { localYmd, useCycleCalendar, useCycleProfile } from "./use-cycle";
|
||||
import { localYmd, useCycleCalendar } from "./use-cycle";
|
||||
|
||||
/**
|
||||
* v1.15.3 — the compact cycle ring as a WELLNESS-STRIP element.
|
||||
@@ -24,8 +23,9 @@ import { localYmd, useCycleCalendar, useCycleProfile } from "./use-cycle";
|
||||
*
|
||||
* It is NOT a second `<CycleInsightSummaryCard>` — that teaser (phase finding +
|
||||
* deep-link) already lives further down the overview. This is the RING only,
|
||||
* as a wellness dial. Both surfaces derive day-of-cycle + phase from the SAME
|
||||
* `useCycleCalendar` read + `deriveWheelState`, so they can never disagree.
|
||||
* as a wellness dial. Both surfaces read day-of-cycle + phase from the SAME
|
||||
* server-resolved `verdict` on the calendar read, so they can never disagree —
|
||||
* and neither of them computes it.
|
||||
*
|
||||
* GATING IS THE CALLER'S JOB. The page mounts this only when
|
||||
* `user.cycleTrackingEnabled` is true (the same `/api/auth/me` signal the
|
||||
@@ -48,25 +48,14 @@ export function CycleRingTile({ className }: { className?: string }) {
|
||||
|
||||
// Mirror the cycle-view + summary-card window so the cache key is shared —
|
||||
// no extra request when the user opens the cycle page in the same session.
|
||||
const today = useMemo(() => shiftToday(0), []);
|
||||
const from = useMemo(() => shiftToday(-90), []);
|
||||
const to = useMemo(() => shiftToday(180), []);
|
||||
|
||||
const calendar = useCycleCalendar(from, to);
|
||||
// Shared 5-min-cached profile read (no extra request when the cycle page or
|
||||
// summary card already loaded it): its typical lengths let a low-data tracker
|
||||
// draw the canonical four-phase ring instead of one dominant arc.
|
||||
const profile = useCycleProfile();
|
||||
|
||||
const wheel = useMemo(
|
||||
() =>
|
||||
deriveWheelState(calendar.data?.days ?? [], today, {
|
||||
typicalCycleLength: profile.data?.typicalCycleLength,
|
||||
typicalPeriodLength: profile.data?.typicalPeriodLength,
|
||||
lutealPhaseLength: profile.data?.lutealPhaseLength,
|
||||
}),
|
||||
[calendar.data, today, profile.data],
|
||||
);
|
||||
// The server already resolved the ring: cycle day, phase, and the arc
|
||||
// proportions (including the profile-derived idealized ring a low-data
|
||||
// tracker gets). The tile renders it.
|
||||
const verdict = calendar.data?.verdict;
|
||||
|
||||
// The signature reveal plays ONCE per browser session, gated on its own key
|
||||
// so a background calendar refetch never re-triggers the moment. The
|
||||
@@ -87,9 +76,9 @@ export function CycleRingTile({ className }: { className?: string }) {
|
||||
// empty-framed cycle dial. The full cycle page owns the empty-state CTA.
|
||||
if (calendar.isLoading && !calendar.data) return null;
|
||||
if (calendar.isError && !calendar.data) return null;
|
||||
if (wheel.phase == null || wheel.dayOfCycle == null) return null;
|
||||
if (verdict?.phase == null || verdict.dayOfCycle == null) return null;
|
||||
|
||||
const hue = PHASE_HUE[wheel.phase];
|
||||
const hue = PHASE_HUE[verdict.phase];
|
||||
|
||||
return (
|
||||
// `data-revealed` must sit on an ANCESTOR of `.wellness-tile-rise` — the
|
||||
@@ -102,7 +91,7 @@ export function CycleRingTile({ className }: { className?: string }) {
|
||||
href="/cycle"
|
||||
data-slot="wellness-cycle-tile"
|
||||
data-metric="CYCLE"
|
||||
data-phase={wheel.phase}
|
||||
data-phase={verdict.phase}
|
||||
// Match the score `RingTile` chrome: the phase's `--tile-hue`
|
||||
// low-opacity mix over `--card` + the faint film grain (the
|
||||
// `.wellness-tile` family), with a calm once-per-session rise. The
|
||||
@@ -122,10 +111,10 @@ export function CycleRingTile({ className }: { className?: string }) {
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<CycleRing
|
||||
dayOfCycle={wheel.dayOfCycle}
|
||||
cycleLength={wheel.cycleLength}
|
||||
phase={wheel.phase}
|
||||
spans={wheel.spans}
|
||||
dayOfCycle={verdict.dayOfCycle}
|
||||
cycleLength={verdict.cycleLength}
|
||||
phase={verdict.phase}
|
||||
spans={verdict.spans}
|
||||
animate={play}
|
||||
size={120}
|
||||
/>
|
||||
@@ -133,7 +122,7 @@ export function CycleRingTile({ className }: { className?: string }) {
|
||||
data-slot="wellness-cycle-band-word"
|
||||
className="text-muted-foreground text-center text-xs"
|
||||
>
|
||||
{t(`cycle.phase.${wheel.phase}`)}
|
||||
{t(`cycle.phase.${verdict.phase}`)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -27,7 +27,6 @@ import { PredictionsPanel } from "./predictions-panel";
|
||||
import { CyclePhaseHeadline, CyclePhaseCrosstab } from "./cycle-phase-crosstab";
|
||||
import { CycleSymptomPatterns } from "./cycle-symptom-patterns";
|
||||
import { CycleSettings } from "./cycle-settings";
|
||||
import { deriveWheelState } from "./wheel-state";
|
||||
import { PHASE_HUE } from "./phase-tokens";
|
||||
import {
|
||||
localYmd,
|
||||
@@ -139,17 +138,9 @@ export function CycleView() {
|
||||
}
|
||||
});
|
||||
|
||||
// Pass the profile's typical lengths so a low-data tracker (few logged
|
||||
// cycles) draws the canonical four-phase ring instead of one dominant arc.
|
||||
const wheel = useMemo(
|
||||
() =>
|
||||
deriveWheelState(calendar.data?.days ?? [], today, {
|
||||
typicalCycleLength: profileQuery.data?.typicalCycleLength,
|
||||
typicalPeriodLength: profileQuery.data?.typicalPeriodLength,
|
||||
lutealPhaseLength: profileQuery.data?.lutealPhaseLength,
|
||||
}),
|
||||
[calendar.data, today, profileQuery.data],
|
||||
);
|
||||
// The server's resolved verdict — cycle day, the ring's arcs, the overdue
|
||||
// judgement and its day count. Nothing on this page recomputes any of it.
|
||||
const verdict = calendar.data?.verdict;
|
||||
|
||||
function openSheet(date: string) {
|
||||
setSelectedDate(date);
|
||||
@@ -158,7 +149,7 @@ export function CycleView() {
|
||||
|
||||
const loading = calendar.isLoading && !calendar.data;
|
||||
const calendarError = calendar.isError && !calendar.data;
|
||||
const tileHue = wheel.phase ? PHASE_HUE[wheel.phase] : undefined;
|
||||
const tileHue = verdict?.phase ? PHASE_HUE[verdict.phase] : undefined;
|
||||
const goal = calendar.data?.profile.goal;
|
||||
// Honesty-gate inputs for the phase-education card (Clue precedent): the
|
||||
// predictive phase framing only shows when prediction is on, not in
|
||||
@@ -244,32 +235,38 @@ export function CycleView() {
|
||||
</div>
|
||||
) : (
|
||||
<CycleRing
|
||||
dayOfCycle={wheel.dayOfCycle}
|
||||
cycleLength={wheel.cycleLength}
|
||||
phase={wheel.phase}
|
||||
spans={wheel.spans}
|
||||
dayOfCycle={verdict?.dayOfCycle ?? null}
|
||||
cycleLength={verdict?.cycleLength ?? null}
|
||||
phase={verdict?.phase ?? null}
|
||||
spans={verdict?.spans ?? []}
|
||||
animate={play}
|
||||
/>
|
||||
)}
|
||||
{!calendarError ? (
|
||||
<p className="text-muted-foreground max-w-56 text-center text-xs">
|
||||
{/* v1.27.5 — beyond typical length + grace the wheel stops
|
||||
asserting a day count; this caption says why instead of
|
||||
the "of your cycle" tagline under an empty ring. */}
|
||||
{wheel.periodOverdue
|
||||
? t("cycle.ring.periodOverdue")
|
||||
{/* Beyond the typical length plus the server's grace window
|
||||
the count stops being an observed fact, so the server
|
||||
resolves OVERDUE and says how late; this caption reports
|
||||
that instead of the "of your cycle" tagline under an
|
||||
empty ring. */}
|
||||
{verdict?.state === "OVERDUE"
|
||||
? verdict.overdueDays != null
|
||||
? t("cycle.ring.periodOverdueDays", {
|
||||
days: verdict.overdueDays,
|
||||
})
|
||||
: t("cycle.ring.periodOverdue")
|
||||
: t("cycle.ring.caption")}
|
||||
</p>
|
||||
) : null}
|
||||
{/* Log CTA — first period when no cycle exists yet, next period
|
||||
when the count paused on an overdue cycle. */}
|
||||
{!loading && !calendarError && wheel.dayOfCycle == null ? (
|
||||
{!loading && !calendarError && verdict?.dayOfCycle == null ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-1 w-full"
|
||||
onClick={() => openSheet(today)}
|
||||
>
|
||||
{wheel.periodOverdue
|
||||
{verdict?.state === "OVERDUE"
|
||||
? t("cycle.ring.logPeriodCta")
|
||||
: t("cycle.ring.firstPeriodCta")}
|
||||
</Button>
|
||||
@@ -283,7 +280,7 @@ export function CycleView() {
|
||||
{!loading && !calendarError ? (
|
||||
<PhaseEducationCard
|
||||
animate={play}
|
||||
phase={wheel.phase}
|
||||
phase={verdict?.phase ?? null}
|
||||
symptomPatterns={insights.data?.symptomPatterns ?? []}
|
||||
predictionEnabled={calProfile?.predictionEnabled ?? false}
|
||||
rawChartMode={calProfile?.rawChartMode ?? false}
|
||||
@@ -388,6 +385,7 @@ export function CycleView() {
|
||||
<BbtChart
|
||||
days={calendar.data?.days ?? []}
|
||||
today={today}
|
||||
cycleStartDate={verdict?.cycleStartDate ?? null}
|
||||
predictedOvulation={
|
||||
calendar.data?.prediction?.predictedOvulation ?? null
|
||||
}
|
||||
@@ -446,9 +444,9 @@ export function CycleView() {
|
||||
onOpenChange={setSheetOpen}
|
||||
date={selectedDate}
|
||||
today={today}
|
||||
activePeriod={wheel.phase === "MENSTRUAL"}
|
||||
phase={wheel.phase}
|
||||
dayOfCycle={wheel.dayOfCycle}
|
||||
activePeriod={verdict?.phase === "MENSTRUAL"}
|
||||
phase={verdict?.phase ?? null}
|
||||
dayOfCycle={verdict?.dayOfCycle ?? null}
|
||||
goal={goal}
|
||||
predictionEnabled={calProfile?.predictionEnabled ?? false}
|
||||
rawChartMode={calProfile?.rawChartMode ?? false}
|
||||
|
||||
@@ -53,6 +53,29 @@ export interface CyclePrediction {
|
||||
disclaimer: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's resolved answer about today (`src/lib/cycle/verdict.ts`).
|
||||
*
|
||||
* Render it. Do not recompute any part of it here: the grace window that
|
||||
* decides `OVERDUE`, the day count, the ring's arcs and the fertile-window
|
||||
* state are all resolved server-side against the user's own timezone day, and
|
||||
* a second copy of that logic in client code is how a cycle ring ends up
|
||||
* showing a person a day their record does not hold.
|
||||
*/
|
||||
export type CycleVerdictState = "IN_CYCLE" | "OVERDUE" | "INSUFFICIENT_DATA";
|
||||
|
||||
export interface CycleVerdict {
|
||||
state: CycleVerdictState;
|
||||
dayOfCycle: number | null;
|
||||
cycleLength: number | null;
|
||||
phase: CyclePhase | null;
|
||||
spans: { phase: CyclePhase; fraction: number }[];
|
||||
cycleStartDate: string | null;
|
||||
overdueDays: number | null;
|
||||
daysUntilNext: number | null;
|
||||
fertileWindow: { start: string | null; end: string | null; active: boolean };
|
||||
}
|
||||
|
||||
export interface CalendarResponse {
|
||||
profile: {
|
||||
goal: CycleGoal;
|
||||
@@ -61,6 +84,8 @@ export interface CalendarResponse {
|
||||
cyclesObserved: number;
|
||||
};
|
||||
prediction: CyclePrediction | null;
|
||||
/** The resolved verdict — always present, so nothing here needs to derive one. */
|
||||
verdict: CycleVerdict;
|
||||
/**
|
||||
* Cold-start gate (mirrors `prediction.stillLearning`): true while < 3 cycles
|
||||
* are observed. When set, `days` carries no fertile window, ovulation dot, or
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
/**
|
||||
* v1.15.0 — derive the cycle-wheel state (day-of-cycle, current phase,
|
||||
* proportional phase spans) from the calendar read.
|
||||
*
|
||||
* The calendar read already labels each day with its `phase`; we walk
|
||||
* backward from today to the start of the current run of consecutive
|
||||
* non-null phase days to find the cycle's start, count the day-of-cycle, and
|
||||
* tally each phase's share of that run for the proportional ring segments.
|
||||
* Pure + deterministic so it matches the server engine's labels 1:1.
|
||||
*/
|
||||
import type { CalendarDay, CyclePhase } from "./types";
|
||||
|
||||
const PHASE_ORDER: CyclePhase[] = [
|
||||
"MENSTRUAL",
|
||||
"FOLLICULAR",
|
||||
"OVULATORY",
|
||||
"LUTEAL",
|
||||
];
|
||||
|
||||
export interface WheelState {
|
||||
dayOfCycle: number | null;
|
||||
cycleLength: number | null;
|
||||
phase: CyclePhase | null;
|
||||
spans: { phase: CyclePhase; fraction: number }[];
|
||||
/**
|
||||
* v1.27.5 — true when the current run overshoots the profile's typical
|
||||
* cycle length by more than the overdue grace window. The wheel then stops
|
||||
* asserting a day-of-cycle / phase (both null) and the view renders the
|
||||
* honest "period overdue" caption instead: a day count like "90" inside
|
||||
* the ring is a population-frame artefact of the open cycle window, not an
|
||||
* observed fact (see `OVERDUE_GRACE_DAYS`).
|
||||
*/
|
||||
periodOverdue: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The profile-derived canonical phase lengths used to draw an idealized ring
|
||||
* for a low-data tracker (see `idealizedSpans`). All optional — every field
|
||||
* falls back to the textbook default when the profile has not set it.
|
||||
*/
|
||||
export interface CycleProfileLengths {
|
||||
/** Typical full cycle length in days (default 28). */
|
||||
typicalCycleLength?: number | null;
|
||||
/** Typical period (menstrual) length in days (default 5). */
|
||||
typicalPeriodLength?: number | null;
|
||||
/** Typical luteal-phase length in days (default 14). */
|
||||
lutealPhaseLength?: number | null;
|
||||
}
|
||||
|
||||
/** Below this many labelled days in the current cycle run, the ring would
|
||||
* normalise a 1-3 day partial run so a single phase filled the whole circle.
|
||||
* For such low-data trackers we draw the canonical four-phase proportions from
|
||||
* the profile instead, so the dial always reads as a real cycle wheel. A run
|
||||
* of 7+ labelled days already spans more than the menstrual phase, so the
|
||||
* observed-share path takes over from there. */
|
||||
const SPARSE_RUN_THRESHOLD = 7;
|
||||
|
||||
/**
|
||||
* Days PAST the profile's typical cycle length before the wheel stops
|
||||
* counting up. The engine keeps the open cycle's phase window alive until
|
||||
* the predicted next start, and a confirmed/anchored ovulation signal (BBT
|
||||
* trend, symptothermal double-check, LH surge) or a long estimated length
|
||||
* can push that prediction into the future while the last logged period is
|
||||
* months old — every day of the gap then carries a (population-framed)
|
||||
* LUTEAL label and the day-of-cycle walks up without bound ("day 90").
|
||||
* Beyond typical + grace the count is no longer an observed fact, so the
|
||||
* wheel degrades to the honest overdue state instead. Two weeks of grace
|
||||
* covers ordinary cycle-to-cycle variation without hiding a real delay.
|
||||
*/
|
||||
const OVERDUE_GRACE_DAYS = 14;
|
||||
|
||||
/**
|
||||
* The canonical four-phase span set for a low-data tracker, derived from the
|
||||
* profile's typical lengths (textbook defaults when unset): a 28-day cycle
|
||||
* with a 5-day period, a 2-day ovulatory window, a 14-day luteal phase, and
|
||||
* the follicular phase filling the remainder. Proportions, not day counts, so
|
||||
* the ring renders the familiar MENSTRUAL/FOLLICULAR/OVULATORY/LUTEAL arcs
|
||||
* instead of one dominant sliver.
|
||||
*/
|
||||
function idealizedSpans(profile?: CycleProfileLengths): {
|
||||
spans: { phase: CyclePhase; fraction: number }[];
|
||||
cycleLength: number;
|
||||
} {
|
||||
const cycle = Math.max(
|
||||
Math.round(profile?.typicalCycleLength ?? 28) || 28,
|
||||
7,
|
||||
);
|
||||
const period = Math.min(
|
||||
Math.max(Math.round(profile?.typicalPeriodLength ?? 5) || 5, 1),
|
||||
cycle - 3,
|
||||
);
|
||||
const luteal = Math.min(
|
||||
Math.max(Math.round(profile?.lutealPhaseLength ?? 14) || 14, 1),
|
||||
cycle - period - 1,
|
||||
);
|
||||
// A short ovulatory window sits at the fertile peak; the follicular phase
|
||||
// fills whatever remains between the period and the ovulatory window.
|
||||
const ovulatory = Math.min(2, Math.max(cycle - period - luteal - 1, 1));
|
||||
const follicular = Math.max(cycle - period - ovulatory - luteal, 1);
|
||||
|
||||
const days: Record<CyclePhase, number> = {
|
||||
MENSTRUAL: period,
|
||||
FOLLICULAR: follicular,
|
||||
OVULATORY: ovulatory,
|
||||
LUTEAL: luteal,
|
||||
};
|
||||
const total = days.MENSTRUAL + days.FOLLICULAR + days.OVULATORY + days.LUTEAL;
|
||||
const spans = PHASE_ORDER.map((phase) => ({
|
||||
phase,
|
||||
fraction: days[phase] / total,
|
||||
}));
|
||||
return { spans, cycleLength: total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk back from `todayIdx` to the start of the current MENSTRUAL-anchored run:
|
||||
* the first day of the most recent MENSTRUAL block at/before today with no phase
|
||||
* gap. The ONE source of truth both `currentCycleStartDate` and
|
||||
* `deriveWheelState` use, so the BBT chart, the day-of-cycle count, and the ring
|
||||
* spans can never disagree on where the current cycle begins. Assumes
|
||||
* `sorted[todayIdx].phase != null` (the callers check first).
|
||||
*/
|
||||
function findCycleStartIndex(sorted: CalendarDay[], todayIdx: number): number {
|
||||
let startIdx = todayIdx;
|
||||
for (let i = todayIdx; i >= 0; i--) {
|
||||
const d = sorted[i];
|
||||
if (d.phase == null) break;
|
||||
startIdx = i;
|
||||
if (
|
||||
d.phase === "MENSTRUAL" &&
|
||||
(i === 0 || sorted[i - 1].phase !== "MENSTRUAL")
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return startIdx;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first day (`YYYY-MM-DD`) of the current cycle, or null when today has no
|
||||
* phase (no active cycle). Scopes the BBT chart to the same cycle the wheel
|
||||
* shows.
|
||||
*/
|
||||
export function currentCycleStartDate(
|
||||
days: CalendarDay[],
|
||||
today: string,
|
||||
): string | null {
|
||||
const sorted = [...days].sort((a, b) => a.date.localeCompare(b.date));
|
||||
const todayIdx = sorted.findIndex((d) => d.date === today);
|
||||
if (todayIdx < 0 || sorted[todayIdx].phase == null) return null;
|
||||
return sorted[findCycleStartIndex(sorted, todayIdx)].date;
|
||||
}
|
||||
|
||||
export function deriveWheelState(
|
||||
days: CalendarDay[],
|
||||
today: string,
|
||||
profile?: CycleProfileLengths,
|
||||
): WheelState {
|
||||
const byDate = new Map(days.map((d) => [d.date, d]));
|
||||
const todayDay = byDate.get(today);
|
||||
if (!todayDay || todayDay.phase == null) {
|
||||
return {
|
||||
dayOfCycle: null,
|
||||
cycleLength: null,
|
||||
phase: null,
|
||||
spans: [],
|
||||
periodOverdue: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Sort dates ascending and find today's index.
|
||||
const sorted = [...days].sort((a, b) => a.date.localeCompare(b.date));
|
||||
const todayIdx = sorted.findIndex((d) => d.date === today);
|
||||
if (todayIdx < 0) {
|
||||
return {
|
||||
dayOfCycle: null,
|
||||
cycleLength: null,
|
||||
phase: null,
|
||||
spans: [],
|
||||
periodOverdue: false,
|
||||
};
|
||||
}
|
||||
|
||||
const startIdx = findCycleStartIndex(sorted, todayIdx);
|
||||
const run = sorted.slice(startIdx, todayIdx + 1);
|
||||
const dayOfCycle = run.length;
|
||||
|
||||
// Honest ceiling: once the run overshoots the typical length + grace, the
|
||||
// count reflects the engine's open-ended cycle window, not an observed
|
||||
// cycle day. Degrade to the idealized ring with no day / phase claim; the
|
||||
// view pairs it with the "period overdue" caption + log CTA.
|
||||
const typicalLength = Math.max(
|
||||
Math.round(profile?.typicalCycleLength ?? 28) || 28,
|
||||
7,
|
||||
);
|
||||
if (dayOfCycle > typicalLength + OVERDUE_GRACE_DAYS) {
|
||||
const ideal = idealizedSpans(profile);
|
||||
return {
|
||||
dayOfCycle: null,
|
||||
cycleLength: ideal.cycleLength,
|
||||
phase: null,
|
||||
spans: ideal.spans,
|
||||
periodOverdue: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Tally each phase's day-count across the run + the forward predicted run
|
||||
// (so the ring shows the whole cycle, not just elapsed days).
|
||||
const counts: Record<CyclePhase, number> = {
|
||||
MENSTRUAL: 0,
|
||||
FOLLICULAR: 0,
|
||||
OVULATORY: 0,
|
||||
LUTEAL: 0,
|
||||
};
|
||||
// Include forward days until the next MENSTRUAL block to bound a full cycle.
|
||||
let end = todayIdx;
|
||||
for (let i = todayIdx + 1; i < sorted.length; i++) {
|
||||
const d = sorted[i];
|
||||
if (d.phase == null) break;
|
||||
if (d.phase === "MENSTRUAL") break;
|
||||
end = i;
|
||||
}
|
||||
const fullRun = sorted.slice(startIdx, end + 1);
|
||||
for (const d of fullRun) {
|
||||
if (d.phase) counts[d.phase] += 1;
|
||||
}
|
||||
|
||||
// Low-data tracker: too few labelled days to span a real cycle. The
|
||||
// observed-share math would normalise a 1-3 day partial run so a single
|
||||
// phase filled the whole ring (the "one dominant arc" bug). Fall back to the
|
||||
// canonical four-phase proportions derived from the profile's typical
|
||||
// lengths so the dial reads as a real cycle wheel. The current phase + the
|
||||
// day-of-cycle marker stay driven by the observed labels; only the arc
|
||||
// proportions and the ring's represented length come from the ideal.
|
||||
if (fullRun.length < SPARSE_RUN_THRESHOLD) {
|
||||
const ideal = idealizedSpans(profile);
|
||||
return {
|
||||
dayOfCycle,
|
||||
cycleLength: ideal.cycleLength,
|
||||
phase: todayDay.phase,
|
||||
spans: ideal.spans,
|
||||
periodOverdue: false,
|
||||
};
|
||||
}
|
||||
|
||||
const total = fullRun.length || 1;
|
||||
const spans = PHASE_ORDER.filter((p) => counts[p] > 0).map((phase) => ({
|
||||
phase,
|
||||
fraction: counts[phase] / total,
|
||||
}));
|
||||
|
||||
return {
|
||||
dayOfCycle,
|
||||
cycleLength: fullRun.length,
|
||||
phase: todayDay.phase,
|
||||
spans,
|
||||
periodOverdue: false,
|
||||
};
|
||||
}
|
||||
@@ -1,22 +1,17 @@
|
||||
/**
|
||||
* The Today hero's "just in" chip and the reaction line's lead replacement.
|
||||
* The Today hero's arrival handling and the reaction line's lead
|
||||
* replacement.
|
||||
*
|
||||
* Two invariants, both of which have a specific failure this project has
|
||||
* already paid for:
|
||||
* The hero used to carry a "just in" chip under the score, with the
|
||||
* arrival's local time on it. It is gone: under a number, "just in" did
|
||||
* not say what had arrived — the score? a reading? — and the clock face
|
||||
* changed nothing a person would then do. What is left is the invariant
|
||||
* that an arrival alone is not a reason to paint anything, and the one
|
||||
* the reaction line has always had.
|
||||
*
|
||||
* 1. HYDRATION. The hero is deliberately NOT mount-gated — it paints from the
|
||||
* server-dehydrated digest on the SSR pass because it is the LCP element
|
||||
* (v1.30.9). So anything inside it that differs between the server and the
|
||||
* browser is a React #418 mismatch. A wall-clock time is exactly that: the
|
||||
* server's locale and timezone are not the reader's. The chip therefore
|
||||
* renders its SLOT on both passes and fills the time in only after mount.
|
||||
* These tests render through `renderToStaticMarkup`, which takes the SERVER
|
||||
* snapshot of `useSyncExternalStore` — i.e. precisely the pass that must
|
||||
* carry no formatted time.
|
||||
*
|
||||
* 2. NO LAYOUT SHIFT. The reaction line REPLACES the lead; it is never a second
|
||||
* paragraph. The hero stays one lead line tall, so nothing below it moves
|
||||
* when the line arrives on a poll.
|
||||
* NO LAYOUT SHIFT. The reaction line REPLACES the lead; it is never a
|
||||
* second paragraph. The hero stays one lead line tall, so nothing below
|
||||
* it moves when the line arrives on a poll.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
@@ -53,30 +48,56 @@ function digest(over: Partial<DailyDigest> = {}): DailyDigest {
|
||||
};
|
||||
}
|
||||
|
||||
/** Any hh:mm clock face — the thing the server must never emit. */
|
||||
/** Any hh:mm clock face — the thing the hero must never print here. */
|
||||
const CLOCK_FACE = /\d{1,2}:\d{2}/;
|
||||
|
||||
describe("TodayHero — the just-in chip", () => {
|
||||
it("renders no chip when nothing landed", () => {
|
||||
const html = render(<TodayHero digest={digest()} />);
|
||||
expect(html).not.toContain('data-slot="today-hero-just-in"');
|
||||
});
|
||||
|
||||
it("renders the chip slot on the server pass, carrying the kind", () => {
|
||||
describe("TodayHero — an arrival is not a chip", () => {
|
||||
it("paints no arrival marker when something has landed", () => {
|
||||
const html = render(
|
||||
<TodayHero
|
||||
digest={digest({ justIn: { kind: "sleep_night", at: ARRIVED_AT } })}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The slot is present on the SSR pass, so the hydration render — which
|
||||
// takes the same server snapshot — produces identical HTML.
|
||||
expect(html).toContain('data-slot="today-hero-just-in"');
|
||||
expect(html).toContain('data-just-in-kind="sleep_night"');
|
||||
expect(html).toContain("Just in");
|
||||
expect(html).not.toContain('data-slot="today-hero-just-in"');
|
||||
expect(html).not.toContain("Just in");
|
||||
expect(html).not.toContain(ARRIVED_AT);
|
||||
});
|
||||
|
||||
it("renders for a bare account when the arrival has no generated line", () => {
|
||||
it("prints no clock face beside the score", () => {
|
||||
// The removed chip was the only wall clock on this surface, and a
|
||||
// wall clock rendered on the server pass is a hydration mismatch as
|
||||
// well as noise. Neither is possible once nothing prints one.
|
||||
const html = render(
|
||||
<TodayHero
|
||||
digest={digest({ justIn: { kind: "weight", at: ARRIVED_AT } })}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).not.toMatch(CLOCK_FACE);
|
||||
});
|
||||
|
||||
it("still shows a pending night, which is a different sentence", () => {
|
||||
// The freshness note says the day is not complete yet, which changes
|
||||
// how the number should be read. That one stays.
|
||||
const html = render(
|
||||
<TodayHero
|
||||
digest={digest({
|
||||
phase: "provisional",
|
||||
sleepPending: true,
|
||||
justIn: { kind: "weight", at: ARRIVED_AT },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-slot="today-hero-sleep-pending"');
|
||||
expect(html).not.toContain('data-slot="today-hero-just-in"');
|
||||
});
|
||||
|
||||
it("renders nothing at all for an account whose only news was the arrival", () => {
|
||||
// With no chip there is nothing left for such a digest to say, and a
|
||||
// bordered empty hero above the tile strip would be worse than
|
||||
// absence.
|
||||
const html = render(
|
||||
<TodayHero
|
||||
digest={digest({
|
||||
@@ -89,68 +110,85 @@ describe("TodayHero — the just-in chip", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).not.toContain('data-slot="today-hero"');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* What the `today-just-in` e2e spec stands on.
|
||||
*
|
||||
* That spec watches an open dashboard flip from provisional to final when
|
||||
* last night's sleep lands. It can only watch it on a hero that exists,
|
||||
* and until the chip was removed the ARRIVAL was what kept the hero alive
|
||||
* on the bare fixture account — so the spec was quietly testing the day
|
||||
* flip on a hero that only its own subject was propping up. It now seeds
|
||||
* a rail item instead, which is the same shape as a real account with a
|
||||
* connection needing attention.
|
||||
*
|
||||
* These cases are the local half of that: the browser proves the flip on
|
||||
* a real page, and this proves the composition the fixture relies on is
|
||||
* the one the component actually renders. Without them the e2e change
|
||||
* would be a guess about a file it never touches.
|
||||
*/
|
||||
describe("TodayHero — what earns the hero without a score", () => {
|
||||
const SYNC_ISSUE: DailyDigest["worthALook"][number] = {
|
||||
kind: "sync_issue",
|
||||
title: "A connection needs your attention",
|
||||
body: "One of your integrations stopped syncing.",
|
||||
status: "warning",
|
||||
actions: [
|
||||
{
|
||||
labelKey: "daily.action.reconnect",
|
||||
intent: "sync.reconnect",
|
||||
href: "/settings/integrations",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** No score, no briefing, no reaction line: one rail item and a night. */
|
||||
function itemOnly(over: Partial<DailyDigest> = {}): DailyDigest {
|
||||
return digest({
|
||||
score: null,
|
||||
briefingLead: null,
|
||||
reactionLine: null,
|
||||
phase: "provisional",
|
||||
sleepPending: true,
|
||||
worthALook: [SYNC_ISSUE],
|
||||
justIn: { kind: "sleep_night", at: ARRIVED_AT },
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
it("renders on a rail item alone, and names the day it is reading", () => {
|
||||
const html = render(<TodayHero digest={itemOnly()} />);
|
||||
|
||||
expect(html).toContain('data-slot="today-hero"');
|
||||
expect(html).toContain('data-slot="today-hero-just-in"');
|
||||
});
|
||||
|
||||
it("emits NO formatted time and no raw ISO instant on the server pass", () => {
|
||||
const html = render(
|
||||
<TodayHero
|
||||
digest={digest({ justIn: { kind: "weight", at: ARRIVED_AT } })}
|
||||
/>,
|
||||
);
|
||||
|
||||
const chip = html.slice(html.indexOf('data-slot="today-hero-just-in"'));
|
||||
// The mismatch itself: a wall clock the browser would render differently.
|
||||
expect(chip).not.toMatch(CLOCK_FACE);
|
||||
// And the raw instant must not leak either — it is machine data, not copy.
|
||||
expect(html).not.toContain(ARRIVED_AT);
|
||||
});
|
||||
|
||||
it("keeps the chip muted meta — never an accent, never an opacity modifier", () => {
|
||||
const html = render(
|
||||
<TodayHero
|
||||
digest={digest({ justIn: { kind: "workout", at: ARRIVED_AT } })}
|
||||
/>,
|
||||
);
|
||||
|
||||
const row = html.slice(
|
||||
0,
|
||||
html.indexOf('data-slot="today-hero-just-in"') + 400,
|
||||
);
|
||||
expect(row).toContain("text-muted-foreground");
|
||||
expect(row).toContain("text-xs");
|
||||
// UI-STANDARDS §text: alpha on muted text drops below AA contrast.
|
||||
expect(html).not.toContain("text-muted-foreground/");
|
||||
expect(html).not.toContain("text-primary");
|
||||
});
|
||||
|
||||
it("shows the chip alongside a still-pending night", () => {
|
||||
// Legitimately co-occurring: a weight landed this morning while last
|
||||
// night's sleep has not arrived yet.
|
||||
const html = render(
|
||||
<TodayHero
|
||||
digest={digest({
|
||||
phase: "provisional",
|
||||
sleepPending: true,
|
||||
justIn: { kind: "weight", at: ARRIVED_AT },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-phase="provisional"');
|
||||
expect(html).toContain('data-slot="today-hero-rail"');
|
||||
// The note the flip is going to clear. Asserting it is HERE first is
|
||||
// what stops the "it is gone afterwards" assertion from passing on a
|
||||
// note that was never rendered at all.
|
||||
expect(html).toContain('data-slot="today-hero-sleep-pending"');
|
||||
expect(html).toContain('data-slot="today-hero-just-in"');
|
||||
expect(html).not.toContain('data-slot="today-hero-just-in"');
|
||||
});
|
||||
|
||||
it("localises the chip", () => {
|
||||
it("clears the pending note and reads final once the night is in", () => {
|
||||
const html = render(
|
||||
<TodayHero
|
||||
digest={digest({ justIn: { kind: "sleep_night", at: ARRIVED_AT } })}
|
||||
/>,
|
||||
"de",
|
||||
<TodayHero digest={itemOnly({ phase: "final", sleepPending: false })} />,
|
||||
);
|
||||
expect(html).toContain("Gerade eingetroffen");
|
||||
expect(html).not.toContain("Just in");
|
||||
|
||||
expect(html).toContain('data-phase="final"');
|
||||
expect(html).not.toContain('data-slot="today-hero-sleep-pending"');
|
||||
expect(html).not.toContain('data-slot="today-hero-just-in"');
|
||||
});
|
||||
|
||||
it("has nothing to render once the item goes too", () => {
|
||||
// The mirror, and the reason the e2e fixture has to seed something:
|
||||
// strip the item and the same digest paints nothing, arrival and
|
||||
// pending night included.
|
||||
const html = render(<TodayHero digest={itemOnly({ worthALook: [] })} />);
|
||||
|
||||
expect(html).not.toContain('data-slot="today-hero"');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -37,8 +37,6 @@
|
||||
import Link from "next/link";
|
||||
import { Moon } from "lucide-react";
|
||||
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import { ScoreRing } from "@/components/insights/derived/score-ring";
|
||||
import type { ScoreBand } from "@/components/insights/derived/band-tokens";
|
||||
import { ProseBlocks } from "@/components/insights/prose-blocks";
|
||||
@@ -86,32 +84,6 @@ function withoutRepeatedScore(
|
||||
return result.length > 0 ? result : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "just in" chip's clock face.
|
||||
*
|
||||
* Formatted CLIENT-side, and only after mount. `justIn.at` crosses the wire as
|
||||
* an ISO instant precisely so this stays a client concern: the server has no
|
||||
* business rendering a local wall-clock time it would format under its OWN
|
||||
* locale and timezone, and any such string would differ from the browser's on
|
||||
* the hydration render — React #418, a class of bug this project has already
|
||||
* paid for once.
|
||||
*
|
||||
* Goes through the shared `formatTime`, which reads the profile's timezone and
|
||||
* clock preference. The bare `toLocaleTimeString(undefined, …)` this replaces
|
||||
* asked the BROWSER instead, which is a different question: someone whose
|
||||
* profile says Europe/Berlin while their laptop sits in Manila saw a Manila
|
||||
* time here and a Berlin time on every other surface, for the same instant.
|
||||
*/
|
||||
function formatJustInTime(iso: string): string | null {
|
||||
const at = new Date(iso);
|
||||
if (Number.isNaN(at.getTime())) return null;
|
||||
try {
|
||||
return formatTime(at);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function TodayHero({
|
||||
digest,
|
||||
renderFilteredAllClear = false,
|
||||
@@ -120,12 +92,11 @@ export function TodayHero({
|
||||
renderFilteredAllClear?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
// Gates ONLY the chip's time string — never the chip's presence. The hero
|
||||
// itself is deliberately NOT mount-gated (v1.30.9: it is the LCP element and
|
||||
// paints from the server-dehydrated digest on both the SSR and the hydration
|
||||
// render). So the slot renders on both passes and the formatted time fills in
|
||||
// on the first client re-render, keeping those two passes byte-identical.
|
||||
const mounted = useMounted();
|
||||
// The hero is deliberately NOT mount-gated (v1.30.9: it is the LCP element
|
||||
// and paints from the server-dehydrated digest on both the SSR and the
|
||||
// hydration render), so nothing in it may differ between those two passes.
|
||||
// The one thing that did — the removed arrival chip's wall-clock time — was
|
||||
// the reason a mount gate lived here at all.
|
||||
const { keep, letGo } = useCoachCheckinAction();
|
||||
const dismissItem = usePriorityItemDismiss();
|
||||
|
||||
@@ -166,7 +137,6 @@ export function TodayHero({
|
||||
const signalFallbackLead =
|
||||
hasScore && !lead ? topSignal?.headline?.trim() || null : null;
|
||||
const displayLead = lead ?? signalFallbackLead;
|
||||
const justInTime = digest.justIn ? formatJustInTime(digest.justIn.at) : null;
|
||||
// A score-only all-clear digest has no narrative content for the leading
|
||||
// column. Keeping the full md ring in that two-column shell left a blank
|
||||
// column beside a 168 px dial and pushed the actual all-clear read below it.
|
||||
@@ -177,13 +147,20 @@ export function TodayHero({
|
||||
const compactAllClear = !lead && !topSignal && !hasItems;
|
||||
|
||||
// Calm degrade (plan §3): a genuinely empty account — no score, no rail
|
||||
// items, no cached briefing lead, and no fresh arrival — surfaces nothing
|
||||
// items, no cached briefing lead and no reaction line — surfaces nothing
|
||||
// here. The tile strip below carries its own "add your first reading" empty
|
||||
// state, so a second alarming empty card on the hero would be noise. The
|
||||
// all-clear state (score present, nothing needs attention) is handled inline
|
||||
// below. A fresh marker counts as content even when line generation was
|
||||
// unavailable: the deterministic digest line still gives the hero an honest
|
||||
// lead, and hiding the marker would lose the arrival moment entirely.
|
||||
// below.
|
||||
//
|
||||
// A fresh arrival used to count as content on its own, back when the
|
||||
// "just in" chip was the thing it kept on screen. With the chip gone it
|
||||
// no longer earns a hero: an arrival-only digest reaches the compact
|
||||
// all-clear composition, which would tell someone with no readings at
|
||||
// all that everything is clear — a claim about their health made from
|
||||
// nothing, and made only on the days something happened to sync. The
|
||||
// arrival still rides the DTO and still feeds the morning line.
|
||||
//
|
||||
// A filtered layout is the one explicit exception: its empty digest means
|
||||
// "the hidden canonical candidates are quiet here", so retain all-clear.
|
||||
if (
|
||||
@@ -191,8 +168,7 @@ export function TodayHero({
|
||||
!hasScore &&
|
||||
!hasItems &&
|
||||
!digest.briefingLead &&
|
||||
!digest.reactionLine &&
|
||||
!digest.justIn
|
||||
!digest.reactionLine
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -267,28 +243,15 @@ export function TodayHero({
|
||||
{/* In the compact fallback the quiet freshness tier belongs next
|
||||
to the smaller ring. That uses the otherwise-empty leading
|
||||
space and avoids adding a second row below the dial. */}
|
||||
{compactAllClear && (digest.sleepPending || digest.justIn) ? (
|
||||
{compactAllClear && digest.sleepPending ? (
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
{digest.sleepPending ? (
|
||||
<p
|
||||
data-slot="today-hero-sleep-pending"
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<Moon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
{t("daily.today.sleepPending")}
|
||||
</p>
|
||||
) : null}
|
||||
{digest.justIn ? (
|
||||
<p
|
||||
data-slot="today-hero-just-in"
|
||||
data-just-in-kind={digest.justIn.kind}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
{mounted && justInTime
|
||||
? t("daily.today.justInAt", { time: justInTime })
|
||||
: t("daily.today.justIn")}
|
||||
</p>
|
||||
) : null}
|
||||
<p
|
||||
data-slot="today-hero-sleep-pending"
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<Moon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
{t("daily.today.sleepPending")}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -334,43 +297,18 @@ export function TodayHero({
|
||||
|
||||
{/* Meta row — the hero's quiet tier (UI-STANDARDS §text: `text-xs
|
||||
text-muted-foreground` is the meta floor; never an accent, never an
|
||||
opacity modifier). Holds the freshness note and the "just in" chip;
|
||||
they can legitimately co-occur (a weight landed while last night's
|
||||
sleep is still pending), so they share one wrapping row rather than
|
||||
competing for the same slot. */}
|
||||
{!compactAllClear && (digest.sleepPending || digest.justIn) ? (
|
||||
opacity modifier). Freshness note (plan §2.4) — provisional day,
|
||||
last night's sleep not yet folded in. Muted, non-blocking,
|
||||
refreshes in place when the morning job lands. */}
|
||||
{!compactAllClear && digest.sleepPending ? (
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
{/* Freshness note (plan §2.4) — provisional day, last night's
|
||||
sleep not yet folded in. Muted, non-blocking, refreshes in
|
||||
place when the morning job lands. */}
|
||||
{digest.sleepPending ? (
|
||||
<p
|
||||
data-slot="today-hero-sleep-pending"
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<Moon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
{t("daily.today.sleepPending")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* The "just in" chip — a state change, not a notification. No
|
||||
badge count, no accent, no animation: the record simply reads
|
||||
differently on the next refresh, and this names why. It is
|
||||
present for as long as the arrival is news (the server applies
|
||||
the window, so the chip expires on a poll rather than on a
|
||||
client timer) and carries the arrival's local time once the
|
||||
client has mounted and can format it honestly. */}
|
||||
{digest.justIn ? (
|
||||
<p
|
||||
data-slot="today-hero-just-in"
|
||||
data-just-in-kind={digest.justIn.kind}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
{mounted && justInTime
|
||||
? t("daily.today.justInAt", { time: justInTime })
|
||||
: t("daily.today.justIn")}
|
||||
</p>
|
||||
) : null}
|
||||
<p
|
||||
data-slot="today-hero-sleep-pending"
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<Moon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
{t("daily.today.sleepPending")}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
HealthScoreReport,
|
||||
ScorePillarResult,
|
||||
} from "@/lib/analytics/score/types";
|
||||
import { SCORE_VERSION } from "@/lib/analytics/score/types";
|
||||
import { HeroStrip } from "../hero-strip";
|
||||
|
||||
/**
|
||||
@@ -134,7 +135,6 @@ const ALL_PILLARS = [
|
||||
"SLEEP",
|
||||
"ADIPOSITY",
|
||||
"WELLBEING",
|
||||
"FITNESS",
|
||||
"LIPIDS",
|
||||
] as const;
|
||||
|
||||
@@ -149,7 +149,7 @@ function reportWith(pillars: ScorePillarResult[]): HealthScoreReport {
|
||||
composition: pillars.map((p) => p.id),
|
||||
configured: false,
|
||||
noiseFloor: 3,
|
||||
scoreVersion: 2,
|
||||
scoreVersion: SCORE_VERSION,
|
||||
},
|
||||
coverage,
|
||||
confidence: { score: 100, band: "high" },
|
||||
@@ -158,7 +158,7 @@ function reportWith(pillars: ScorePillarResult[]): HealthScoreReport {
|
||||
pillars,
|
||||
delta: 2,
|
||||
deltaReason: null,
|
||||
scoreVersion: 2,
|
||||
scoreVersion: SCORE_VERSION,
|
||||
weightGoal: {
|
||||
status: "insufficient",
|
||||
coverage: {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
HealthScoreReport,
|
||||
ScorePillarResult,
|
||||
} from "@/lib/analytics/score/types";
|
||||
import { SCORE_VERSION } from "@/lib/analytics/score/types";
|
||||
import {
|
||||
HealthScoreCard,
|
||||
HealthScoreCardSkeleton,
|
||||
@@ -211,7 +212,7 @@ function report(overrides: Partial<HealthScoreReport> = {}): HealthScoreReport {
|
||||
pillars: [BLOOD_PRESSURE, ACTIVITY_BELOW_FLOOR],
|
||||
delta: null,
|
||||
deltaReason: "no_current_score",
|
||||
scoreVersion: 2,
|
||||
scoreVersion: SCORE_VERSION,
|
||||
weightGoal: {
|
||||
status: "insufficient",
|
||||
coverage: {
|
||||
@@ -241,7 +242,7 @@ function scoredReport(
|
||||
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
|
||||
configured: false,
|
||||
noiseFloor: 3,
|
||||
scoreVersion: 2,
|
||||
scoreVersion: SCORE_VERSION,
|
||||
},
|
||||
coverage: {
|
||||
requiredInputs: 3,
|
||||
@@ -371,7 +372,7 @@ describe("<HealthScoreCard> pillars", () => {
|
||||
composition: ["BLOOD_PRESSURE", "GLYCAEMIA"],
|
||||
configured: false,
|
||||
noiseFloor: 3,
|
||||
scoreVersion: 2,
|
||||
scoreVersion: SCORE_VERSION,
|
||||
},
|
||||
coverage: {
|
||||
requiredInputs: 3,
|
||||
@@ -505,7 +506,7 @@ describe("<HealthScoreCard> pillars", () => {
|
||||
});
|
||||
|
||||
describe("<HealthScoreCard> one pillar, one home", () => {
|
||||
/** Four scored, one failed read, one crisis, two plain absences. */
|
||||
/** Four scored, one failed read, one crisis, one plain absence. */
|
||||
function mixedReport() {
|
||||
return scoredReport({
|
||||
pillars: [
|
||||
@@ -513,10 +514,9 @@ describe("<HealthScoreCard> one pillar, one home", () => {
|
||||
scoredAt("GLYCAEMIA", 74),
|
||||
scoredAt("ACTIVITY", 61),
|
||||
scoredAt("SLEEP", 45),
|
||||
gatedPillar("FITNESS", "read_failed"),
|
||||
gatedPillar("LIPIDS", "read_failed"),
|
||||
gatedPillar("WELLBEING", "crisis_signposting"),
|
||||
gatedPillar("ADIPOSITY", "missing_height"),
|
||||
gatedPillar("LIPIDS", "not_tracked"),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -538,14 +538,14 @@ describe("<HealthScoreCard> one pillar, one home", () => {
|
||||
"ACTIVITY",
|
||||
"SLEEP",
|
||||
]);
|
||||
expect(failedIds).toEqual(["FITNESS"]);
|
||||
expect(failedIds).toEqual(["LIPIDS"]);
|
||||
expect(crisisIds).toEqual(["WELLBEING"]);
|
||||
expect(counted).toBe(2);
|
||||
expect(counted).toBe(1);
|
||||
|
||||
// The four homes partition the eight pillars: disjoint, and complete.
|
||||
// The four homes partition the seven pillars: disjoint, and complete.
|
||||
const named = [...rowIds, ...failedIds, ...crisisIds];
|
||||
expect(new Set(named).size).toBe(named.length);
|
||||
expect(named.length + counted).toBe(8);
|
||||
expect(named.length + counted).toBe(7);
|
||||
});
|
||||
|
||||
it("never renders one pillar id twice anywhere in the panel", () => {
|
||||
@@ -556,14 +556,7 @@ describe("<HealthScoreCard> one pillar, one home", () => {
|
||||
const ids = pillarAttrs(html);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(ids.sort()).toEqual(
|
||||
[
|
||||
"ACTIVITY",
|
||||
"ADIPOSITY",
|
||||
"BLOOD_PRESSURE",
|
||||
"GLYCAEMIA",
|
||||
"LIPIDS",
|
||||
"SLEEP",
|
||||
].sort(),
|
||||
["ACTIVITY", "ADIPOSITY", "BLOOD_PRESSURE", "GLYCAEMIA", "SLEEP"].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -581,7 +574,6 @@ describe("<HealthScoreCard> one pillar, one home", () => {
|
||||
"Sleep",
|
||||
"Adiposity",
|
||||
"Wellbeing",
|
||||
"Measured fitness",
|
||||
"Lipids",
|
||||
]) {
|
||||
const hits = listed.split(label).length - 1;
|
||||
@@ -609,7 +601,7 @@ describe("<HealthScoreCard> composite states", () => {
|
||||
expect(html).not.toContain('data-slot="health-score-card-progress"');
|
||||
expect(html).toContain("—");
|
||||
expect(html).not.toContain("/ 100");
|
||||
expect(html).toContain("Method version 2");
|
||||
expect(html).toContain(`Method version ${SCORE_VERSION}`);
|
||||
});
|
||||
|
||||
it("explains a missing comparison where the trend would be", () => {
|
||||
@@ -674,7 +666,9 @@ describe("<HealthScoreCard> footer", () => {
|
||||
const region = anatomyRegion(html);
|
||||
expect(region).toContain('data-slot="health-score-method"');
|
||||
expect(region).toContain("Overall band set by none");
|
||||
expect(region).toContain("Method version 2. Weekly comparison floor");
|
||||
expect(region).toContain(
|
||||
`Method version ${SCORE_VERSION}. Weekly comparison floor`,
|
||||
);
|
||||
expect(region).toContain('data-slot="provenance-explainer-method"');
|
||||
expect(region).toContain("equal-weighted average");
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
ScorePillarId,
|
||||
ScorePillarResult,
|
||||
} from "@/lib/analytics/score/types";
|
||||
import { SCORE_VERSION } from "@/lib/analytics/score/types";
|
||||
|
||||
// The "generated" freshness line reads the profile timezone via `useAuth`; stub
|
||||
// it so the SSR render does not reach for a QueryClient the test omits.
|
||||
@@ -116,7 +117,7 @@ function scoreReport(pillars: ScorePillarResult[]): HealthScoreReport {
|
||||
composition: ["BLOOD_PRESSURE"],
|
||||
configured: false,
|
||||
noiseFloor: 3,
|
||||
scoreVersion: 2,
|
||||
scoreVersion: SCORE_VERSION,
|
||||
},
|
||||
coverage: {
|
||||
requiredInputs: 3,
|
||||
@@ -130,7 +131,7 @@ function scoreReport(pillars: ScorePillarResult[]): HealthScoreReport {
|
||||
pillars,
|
||||
delta: 2,
|
||||
deltaReason: null,
|
||||
scoreVersion: 2,
|
||||
scoreVersion: SCORE_VERSION,
|
||||
weightGoal: {
|
||||
status: "insufficient",
|
||||
coverage: {
|
||||
|
||||
@@ -82,12 +82,6 @@ export function pillarObservedText(
|
||||
value: observed.value,
|
||||
});
|
||||
}
|
||||
if (pillar.id === "FITNESS") {
|
||||
return t("insights.healthScore.observedFitness", {
|
||||
value: observed.value,
|
||||
unit: observed.unit,
|
||||
});
|
||||
}
|
||||
return observed.label;
|
||||
}
|
||||
|
||||
@@ -129,18 +123,12 @@ function pillarReferenceText(
|
||||
? t("insights.healthScore.referenceAdiposity", {
|
||||
high: reference.high ?? "",
|
||||
})
|
||||
: pillar.id === "FITNESS"
|
||||
? t("insights.healthScore.referenceFitness", {
|
||||
: pillar.id === "GLYCAEMIA"
|
||||
? t("insights.healthScore.referenceHba1c", {
|
||||
low: reference.low ?? "",
|
||||
high: reference.high ?? "",
|
||||
unit: value.observed.unit,
|
||||
})
|
||||
: pillar.id === "GLYCAEMIA"
|
||||
? t("insights.healthScore.referenceHba1c", {
|
||||
low: reference.low ?? "",
|
||||
high: reference.high ?? "",
|
||||
})
|
||||
: reference.label;
|
||||
: reference.label;
|
||||
return formatReference(
|
||||
reference,
|
||||
label,
|
||||
|
||||
@@ -25,27 +25,28 @@ const base: ScoreConfigRow = {
|
||||
id: "SLEEP",
|
||||
domain: "sleep",
|
||||
counts: true,
|
||||
selectable: true,
|
||||
eligibility: "counting",
|
||||
};
|
||||
|
||||
describe("the three axes", () => {
|
||||
it("names recording and showing beside the score switch", () => {
|
||||
const html = render(base);
|
||||
|
||||
expect(html).toContain('data-slot="score-pillar-axis-recorded"');
|
||||
expect(html).toContain('data-slot="score-pillar-axis-shown"');
|
||||
expect(html).toContain('data-slot="score-pillar-switch"');
|
||||
expect(html).toContain("Being recorded");
|
||||
expect(html).toContain("Shown in the app");
|
||||
});
|
||||
|
||||
describe("the row", () => {
|
||||
it("offers exactly one switch, and it is the score one", () => {
|
||||
const html = render(base);
|
||||
|
||||
expect(html).toContain('data-slot="score-pillar-switch"');
|
||||
expect(html.match(/role="switch"/g) ?? []).toHaveLength(1);
|
||||
expect(html).toContain("Count Sleep toward the score");
|
||||
});
|
||||
|
||||
it("says nothing about recording or showing", () => {
|
||||
// Those two belong to the modules screen. Restating them under every
|
||||
// pillar put the same two words on every row of a page about a third
|
||||
// question, and the sentence above the rows already says so once.
|
||||
const html = render(base);
|
||||
|
||||
expect(html).not.toContain('data-slot="score-pillar-axes"');
|
||||
expect(html).not.toContain("Being recorded");
|
||||
expect(html).not.toContain("Shown in the app");
|
||||
});
|
||||
});
|
||||
|
||||
describe("row attributes", () => {
|
||||
@@ -106,38 +107,6 @@ describe("a failed read", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("a pillar this build cannot score", () => {
|
||||
it("is disabled, honest, and offers no switch at all", () => {
|
||||
const html = render({
|
||||
...base,
|
||||
id: "FITNESS",
|
||||
domain: "fitness",
|
||||
selectable: false,
|
||||
eligibility: "unavailable",
|
||||
});
|
||||
|
||||
expect(html).toContain('data-selectable="false"');
|
||||
expect(html).toContain('data-slot="score-pillar-unavailable"');
|
||||
expect(html).toContain('aria-disabled="true"');
|
||||
expect(html).toContain("Not available yet");
|
||||
// Not a switch that no-ops behind a disabled attribute: no switch.
|
||||
expect(html).not.toContain('data-slot="score-pillar-switch"');
|
||||
expect(html).not.toContain('role="switch"');
|
||||
});
|
||||
|
||||
it("says why, rather than leaving the row a dead end", () => {
|
||||
const html = render({
|
||||
...base,
|
||||
id: "FITNESS",
|
||||
domain: "fitness",
|
||||
selectable: false,
|
||||
eligibility: "unavailable",
|
||||
});
|
||||
|
||||
expect(html).toContain("has never produced a score");
|
||||
});
|
||||
});
|
||||
|
||||
describe("localisation", () => {
|
||||
// Asserting the absence of a key path would prove nothing: `t()` falls
|
||||
// back to the English bundle, so a missing locale key renders English
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* What the score settings page does with a selection that is already
|
||||
* stored, and what it puts on the wire when you press Save.
|
||||
*
|
||||
* The v1.35.0 defect this pins the fix for: the page filtered the
|
||||
* selection on its way to the server, so the answer that came back could
|
||||
* never match the form that sent it. The switches then re-seeded from the
|
||||
* server's narrower list — a pillar the person had never touched read as
|
||||
* switched off — and the draft could never equal the server copy again,
|
||||
* which left Save lit and the card claiming unsaved changes forever.
|
||||
*
|
||||
* Two ends and the pipe between them:
|
||||
*
|
||||
* - the rows a stored selection renders (`data-counts` per pillar),
|
||||
* - the state of the Save button, which is the whole "did this settle"
|
||||
* question in one attribute,
|
||||
* - the body the mutation actually hands to `apiPatch`, driven through
|
||||
* the component's own `mutationFn` rather than restated here.
|
||||
*
|
||||
* SSR markup, matching the rest of the settings suite. The draft re-seed
|
||||
* happens in render, so a static render is enough to see it.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import {
|
||||
SCORE_PILLAR_IDS,
|
||||
type ScorePillarId,
|
||||
} from "@/lib/analytics/score/types";
|
||||
|
||||
const ALL: ScorePillarId[] = [...SCORE_PILLAR_IDS];
|
||||
|
||||
/** The resolved config the page's primary read returns. */
|
||||
const configState: { pillars: ScorePillarId[] } = { pillars: [...ALL] };
|
||||
|
||||
/** Every `mutationFn` the component registered, newest last. */
|
||||
const mutationFns: Array<(input: never) => unknown> = [];
|
||||
|
||||
const apiPatch = vi.fn(async () => ({ pillars: configState.pillars }));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: ({ queryKey }: { queryKey: readonly unknown[] }) => {
|
||||
// The factory's key, not a guess: `queryKeys.healthScoreConfig()`.
|
||||
if (
|
||||
Array.isArray(queryKey) &&
|
||||
queryKey[0] === "settings" &&
|
||||
queryKey[1] === "health-score-config"
|
||||
) {
|
||||
return {
|
||||
data: {
|
||||
pillars: configState.pillars,
|
||||
excludedPillars: ALL.filter(
|
||||
(id) => !configState.pillars.includes(id),
|
||||
),
|
||||
hasSelection: true,
|
||||
version: 1,
|
||||
changedAt: null,
|
||||
updatedAt: "2026-08-01T00:00:00.000Z",
|
||||
},
|
||||
isSuccess: true,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
};
|
||||
}
|
||||
return {
|
||||
data: undefined,
|
||||
isSuccess: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
};
|
||||
},
|
||||
useMutation: (options: { mutationFn?: (input: never) => unknown }) => {
|
||||
if (options.mutationFn) mutationFns.push(options.mutationFn);
|
||||
return { mutate: vi.fn(), isPending: false };
|
||||
},
|
||||
useQueryClient: () => ({
|
||||
setQueryData: vi.fn(),
|
||||
getQueryData: vi.fn(),
|
||||
invalidateQueries: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { message: vi.fn(), error: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/outcome/outcome-toast", () => ({
|
||||
toastWrittenOutcome: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
apiGet: vi.fn(),
|
||||
apiPost: vi.fn(),
|
||||
apiPatch: (...args: unknown[]) =>
|
||||
(apiPatch as unknown as (...a: unknown[]) => unknown)(...args),
|
||||
ApiError: class ApiError extends Error {},
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ isAuthenticated: true, isLoading: false, user: {} }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/queries/use-analytics-query", () => ({
|
||||
useAnalyticsQuery: () => ({
|
||||
data: undefined,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { ScoreSection } from "../score-section";
|
||||
|
||||
function render(): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<ScoreSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** `data-counts` per pillar, read off the rendered rows. */
|
||||
function countsByPillar(html: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const match of html.matchAll(
|
||||
/data-pillar="([A-Z_]+)"[^>]*data-counts="(true|false)"/g,
|
||||
)) {
|
||||
out[match[1]] = match[2];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function saveIsDisabled(html: string): boolean {
|
||||
const at = html.indexOf('data-slot="score-config-save"');
|
||||
expect(at, "save button is rendered").toBeGreaterThan(-1);
|
||||
const open = html.lastIndexOf("<button", at);
|
||||
return html.slice(open, html.indexOf(">", at)).includes("disabled=");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
configState.pillars = [...ALL];
|
||||
mutationFns.length = 0;
|
||||
apiPatch.mockClear();
|
||||
});
|
||||
|
||||
describe("a selection that is already stored", () => {
|
||||
it("shows every pillar the person kept switched on, and only those", () => {
|
||||
configState.pillars = ALL.filter(
|
||||
(id) => id !== "SLEEP" && id !== "WELLBEING",
|
||||
);
|
||||
|
||||
const counts = countsByPillar(render());
|
||||
|
||||
expect(Object.keys(counts).sort()).toEqual([...ALL].sort());
|
||||
for (const id of ALL) {
|
||||
expect(counts[id], `${id} switch`).toBe(
|
||||
configState.pillars.includes(id) ? "true" : "false",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("has nothing to save the moment it loads", () => {
|
||||
configState.pillars = ALL.filter((id) => id !== "SLEEP");
|
||||
|
||||
expect(saveIsDisabled(render())).toBe(true);
|
||||
});
|
||||
|
||||
it("settles on the every-pillar starting point too", () => {
|
||||
// "All pillars" has to be a selection the page can hold: if the save
|
||||
// silently narrowed it, the form would come back different from the
|
||||
// list it sent and never read as saved again.
|
||||
configState.pillars = [...ALL];
|
||||
const html = render();
|
||||
|
||||
expect(Object.values(countsByPillar(html))).toEqual(ALL.map(() => "true"));
|
||||
expect(saveIsDisabled(html)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the save puts on the wire", () => {
|
||||
it("sends the selection unchanged, pillar for pillar", async () => {
|
||||
render();
|
||||
const save = mutationFns[0];
|
||||
expect(save, "the save mutation registered a mutationFn").toBeTypeOf(
|
||||
"function",
|
||||
);
|
||||
|
||||
await (save as (input: ScorePillarId[]) => unknown)([...ALL]);
|
||||
|
||||
expect(apiPatch).toHaveBeenCalledTimes(1);
|
||||
const [path, body] = apiPatch.mock.calls[0] as unknown as [
|
||||
string,
|
||||
{ pillars: ScorePillarId[] },
|
||||
];
|
||||
expect(path).toBe("/api/auth/me/health-score-config");
|
||||
expect(body.pillars).toEqual(ALL);
|
||||
});
|
||||
|
||||
it("puts the selection in registry order whatever order it arrives in", async () => {
|
||||
render();
|
||||
const save = mutationFns[0] as (input: ScorePillarId[]) => unknown;
|
||||
|
||||
await save(["LIPIDS", "ACTIVITY", "BLOOD_PRESSURE"]);
|
||||
|
||||
const [, body] = apiPatch.mock.calls[0] as unknown as [
|
||||
string,
|
||||
{ pillars: ScorePillarId[] },
|
||||
];
|
||||
expect(body.pillars).toEqual(["BLOOD_PRESSURE", "ACTIVITY", "LIPIDS"]);
|
||||
});
|
||||
});
|
||||
@@ -3,15 +3,11 @@
|
||||
/**
|
||||
* One pillar on the "what counts toward your Health Score" surface.
|
||||
*
|
||||
* The row's job is to make three things legible that nowhere else in the
|
||||
* app shows together: whether the pillar is being recorded, whether it is
|
||||
* shown, and whether it counts toward the score. Only the last is
|
||||
* writable here, and the card above the rows says so in words.
|
||||
*
|
||||
* The first two are not switches on this surface and are not dressed to
|
||||
* look like ones. They are read-outs, because the page that owns them is
|
||||
* the modules screen and a second switch for the same fact is how two
|
||||
* places end up disagreeing about one thing.
|
||||
* The row carries the pillar's name, one line about where it currently
|
||||
* stands with the server, and the switch that decides whether it counts.
|
||||
* Nothing else: recording and showing are the modules screen's business,
|
||||
* and repeating them on every row was noise on a page about a third
|
||||
* question.
|
||||
*
|
||||
* Presentation only. The parent owns the draft, the mutation and the
|
||||
* cache, so this stays a leaf a test can drive directly.
|
||||
@@ -35,7 +31,6 @@ const ELIGIBILITY_LINE_KEYS: Partial<
|
||||
counting: "settings.sections.score.state.counting",
|
||||
crisis: "settings.sections.score.state.crisis",
|
||||
read_failed: "settings.sections.score.state.readFailed",
|
||||
unavailable: "settings.sections.score.state.unavailableDetail",
|
||||
};
|
||||
|
||||
export interface ScorePillarRowProps {
|
||||
@@ -65,32 +60,17 @@ export function ScorePillarRow({
|
||||
data-domain={row.domain}
|
||||
data-counts={row.counts ? "true" : "false"}
|
||||
data-eligibility={row.eligibility}
|
||||
data-selectable={row.selectable ? "true" : "false"}
|
||||
className="flex items-start justify-between gap-4 py-3"
|
||||
className="flex min-h-11 items-center justify-between gap-4"
|
||||
>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<Label htmlFor={inputId} className="text-sm font-medium">
|
||||
{t(SCORE_PILLAR_LABEL_KEYS[row.id])}
|
||||
</Label>
|
||||
{/* The three axes, side by side. Recording and showing read as
|
||||
settled facts; the third is the switch to the right. */}
|
||||
<p
|
||||
data-slot="score-pillar-axes"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
<span data-slot="score-pillar-axis-recorded">
|
||||
{t("settings.sections.score.axis.recorded")}
|
||||
</span>
|
||||
{" · "}
|
||||
<span data-slot="score-pillar-axis-shown">
|
||||
{t("settings.sections.score.axis.shown")}
|
||||
</span>
|
||||
</p>
|
||||
{stateKey ? (
|
||||
<p
|
||||
data-slot="score-pillar-state"
|
||||
data-state={showsWaitingForData(row) ? "waiting" : row.eligibility}
|
||||
className="text-foreground text-xs"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t(stateKey)}
|
||||
</p>
|
||||
@@ -104,30 +84,16 @@ export function ScorePillarRow({
|
||||
className="text-muted-foreground size-3.5 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
) : null}
|
||||
{row.selectable ? (
|
||||
<Switch
|
||||
id={inputId}
|
||||
data-slot="score-pillar-switch"
|
||||
checked={row.counts}
|
||||
disabled={pending}
|
||||
onCheckedChange={(next) => onToggle(next)}
|
||||
aria-label={t("settings.sections.score.axis.countsFor", {
|
||||
pillar: t(SCORE_PILLAR_LABEL_KEYS[row.id]),
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
// Not a disabled switch pretending to be a choice: a pillar this
|
||||
// build can never score has no state to show, so it says so.
|
||||
// `aria-disabled` + the opacity container is the standard
|
||||
// disabled look (UI-STANDARDS §3).
|
||||
<span
|
||||
data-slot="score-pillar-unavailable"
|
||||
aria-disabled="true"
|
||||
className="text-muted-foreground text-xs opacity-50"
|
||||
>
|
||||
{t("settings.sections.score.state.unavailable")}
|
||||
</span>
|
||||
)}
|
||||
<Switch
|
||||
id={inputId}
|
||||
data-slot="score-pillar-switch"
|
||||
checked={row.counts}
|
||||
disabled={pending}
|
||||
onCheckedChange={(next) => onToggle(next)}
|
||||
aria-label={t("settings.sections.score.axis.countsFor", {
|
||||
pillar: t(SCORE_PILLAR_LABEL_KEYS[row.id]),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,13 +14,18 @@
|
||||
* - **One concept, one card.** The rows, the starting points and the
|
||||
* save all sit in the same card. A top half and a bottom half of the
|
||||
* same decision is the split the settings IA rules forbid.
|
||||
* - **Grouped by domain, and the grouping is visible.** Blood pressure,
|
||||
* glycaemia and lipids sit under one heading with a sentence saying
|
||||
* they are one area of health carrying three pillars. Equal weight per
|
||||
* pillar is not equal weight per area, and until this page nothing in
|
||||
* the app said so.
|
||||
* - **Only the score toggle is writable.** Recording and showing are
|
||||
* read-outs with a line saying this page leaves them alone.
|
||||
* - **The settings card anatomy, unmodified.** Header through
|
||||
* `SettingsCardHeader`, body at `mt-4 pl-7` so every line starts on
|
||||
* the title's reading edge, save bottom-right in its own row. The
|
||||
* v1.35.0 cut hung the save in the header's status slot and let the
|
||||
* body start at the card edge, which read as a different kind of card
|
||||
* from the ones above and below it.
|
||||
* - **Grouped by domain, and the grouping is spacing.** Blood pressure,
|
||||
* glycaemia and lipids sit under one heading; the gap between groups
|
||||
* does the separating. Rules between every row made a short list look
|
||||
* like a table of unrelated things.
|
||||
* - **Only the score toggle is writable.** Recording and showing belong
|
||||
* to the modules screen and are not restated per row.
|
||||
* - **A pillar whose module is off is not shown.** Turning a module off
|
||||
* closes the data path deliberately; a pillar behind a closed path is
|
||||
* not waiting for anything, and saying it waits nags a person in the
|
||||
@@ -66,16 +71,13 @@ import {
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
|
||||
import {
|
||||
SCORE_DOMAIN_LABEL_KEYS,
|
||||
SCORE_DOMAIN_NOTE_KEYS,
|
||||
} from "@/lib/score-config/labels";
|
||||
import { SCORE_DOMAIN_LABEL_KEYS } from "@/lib/score-config/labels";
|
||||
import {
|
||||
buildScoreConfigRows,
|
||||
selectionToSave,
|
||||
type ScorePillarVerdict,
|
||||
} from "@/lib/score-config/rows";
|
||||
import {
|
||||
orderedUniquePillars,
|
||||
SCORE_PILLAR_IDS,
|
||||
type ScorePillarId,
|
||||
} from "@/lib/analytics/score/types";
|
||||
@@ -191,7 +193,13 @@ export function ScoreSection() {
|
||||
apiPatch<HealthScoreConfigPayload>(
|
||||
"/api/auth/me/health-score-config",
|
||||
withBaseToken(
|
||||
{ pillars: selectionToSave(selection) },
|
||||
// Registry order, no duplicates. What the person sees is what
|
||||
// the server stores: every listed pillar is a real choice, so
|
||||
// nothing is filtered out of the selection on the way to the
|
||||
// write. A selection that came back different from the one that
|
||||
// was sent is what left the page permanently claiming unsaved
|
||||
// changes in v1.35.0.
|
||||
{ pillars: orderedUniquePillars(selection) },
|
||||
readUpdatedAtToken(queryClient, queryKeys.healthScoreConfig()),
|
||||
),
|
||||
),
|
||||
@@ -295,157 +303,167 @@ export function ScoreSection() {
|
||||
as="section"
|
||||
data-slot="score-config-card"
|
||||
aria-labelledby="score-config-title"
|
||||
className="space-y-4"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={Gauge}
|
||||
titleId="score-config-title"
|
||||
title={t("settings.sections.score.card.title")}
|
||||
description={t("settings.sections.score.card.description")}
|
||||
status={
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => saveMutation.mutate(draft)}
|
||||
disabled={busy || !dirty || configQuery.isLoading}
|
||||
data-slot="score-config-save"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className="size-3.5 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
) : null}
|
||||
{t("settings.sections.score.save")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* The sentence the whole feature exists for. Foreground, in the
|
||||
body, because it is content and not a meta description. */}
|
||||
<p data-slot="score-config-three-axes" className="text-sm">
|
||||
{t("settings.sections.score.threeAxes")}
|
||||
</p>
|
||||
{/* The body starts on the title's reading edge — icon box plus
|
||||
gap is 28 px, which is the `pl-7` every other settings card
|
||||
uses. */}
|
||||
<div className="mt-4 space-y-6 pl-7">
|
||||
{/* The sentence the whole feature exists for. Foreground, in
|
||||
the body, because it is content and not a meta
|
||||
description. */}
|
||||
<p data-slot="score-config-three-axes" className="text-sm">
|
||||
{t("settings.sections.score.threeAxes")}
|
||||
</p>
|
||||
|
||||
{/* The starting points. Both name data; neither names a person.
|
||||
A template that supplies a rationale for dropping a pillar is
|
||||
the one shape the evidence rules out, so there is none. */}
|
||||
<div
|
||||
data-slot="score-config-presets"
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.score.presets.label")}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
data-slot="score-config-preset-all"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => {
|
||||
setRefusal(null);
|
||||
setDraft([...SCORE_PILLAR_IDS]);
|
||||
}}
|
||||
{/* The starting points. Both name data; neither names a
|
||||
person. A template that supplies a rationale for dropping a
|
||||
pillar is the one shape the evidence rules out, so there is
|
||||
none. */}
|
||||
<div
|
||||
data-slot="score-config-presets"
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
>
|
||||
{t("settings.sections.score.presets.all")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy || !dirty}
|
||||
data-slot="score-config-preset-current"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => {
|
||||
setRefusal(null);
|
||||
setDraft([...serverSelection]);
|
||||
}}
|
||||
>
|
||||
{t("settings.sections.score.presets.current")}
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.score.presets.label")}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
data-slot="score-config-preset-all"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => {
|
||||
setRefusal(null);
|
||||
setDraft([...SCORE_PILLAR_IDS]);
|
||||
}}
|
||||
>
|
||||
{t("settings.sections.score.presets.all")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy || !dirty}
|
||||
data-slot="score-config-preset-current"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => {
|
||||
setRefusal(null);
|
||||
setDraft([...serverSelection]);
|
||||
}}
|
||||
>
|
||||
{t("settings.sections.score.presets.current")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{refusal ? (
|
||||
<p
|
||||
data-slot="score-config-refusal"
|
||||
role="alert"
|
||||
className="text-destructive text-sm"
|
||||
>
|
||||
{t(refusal)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{analyticsQuery.isError ? (
|
||||
<QueryErrorRow
|
||||
slot="score-config-eligibility-error"
|
||||
message={t("settings.sections.score.eligibilityFailed")}
|
||||
onRetry={() => void analyticsQuery.refetch()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{configQuery.isLoading ? (
|
||||
<div data-slot="score-config-loading" className="space-y-3">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-11 w-full" />
|
||||
<Skeleton className="h-11 w-full" />
|
||||
<Skeleton className="h-11 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{groups.map((group) => (
|
||||
<div
|
||||
key={group.domain}
|
||||
data-slot="score-config-group"
|
||||
data-domain={group.domain}
|
||||
className="space-y-2"
|
||||
>
|
||||
{/* Names an area of health, so it sits in the content
|
||||
tier. The muted section-label treatment it shipped
|
||||
with read as switched-off beside the rows it
|
||||
heads. */}
|
||||
<h3
|
||||
data-slot="score-config-group-heading"
|
||||
className="text-foreground text-xs font-medium tracking-wide uppercase"
|
||||
>
|
||||
{t(SCORE_DOMAIN_LABEL_KEYS[group.domain])}
|
||||
</h3>
|
||||
{/* No rules between the rows: the gap between groups
|
||||
is the grouping. */}
|
||||
<div className="space-y-3">
|
||||
{group.rows.map((row) => (
|
||||
<ScorePillarRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
pending={busy}
|
||||
onToggle={(next) => toggle(row.id, next)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{omitted.length > 0 ? (
|
||||
<p
|
||||
data-slot="score-config-omitted"
|
||||
data-count={omitted.length}
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("settings.sections.score.omitted", {
|
||||
count: omitted.length,
|
||||
})}{" "}
|
||||
<Link
|
||||
href="/settings/modules"
|
||||
data-slot="score-config-omitted-link"
|
||||
className="text-foreground underline underline-offset-2"
|
||||
>
|
||||
{t("settings.sections.score.omittedLink")}
|
||||
</Link>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{refusal ? (
|
||||
<p
|
||||
data-slot="score-config-refusal"
|
||||
role="alert"
|
||||
className="text-destructive text-sm"
|
||||
{/* Bottom-right, in its own row, like every other settings card
|
||||
that saves a form. */}
|
||||
<div className="mt-4 flex justify-end pl-7">
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => saveMutation.mutate(draft)}
|
||||
disabled={busy || !dirty || configQuery.isLoading}
|
||||
data-slot="score-config-save"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
{t(refusal)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{analyticsQuery.isError ? (
|
||||
<QueryErrorRow
|
||||
slot="score-config-eligibility-error"
|
||||
message={t("settings.sections.score.eligibilityFailed")}
|
||||
onRetry={() => void analyticsQuery.refetch()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{configQuery.isLoading ? (
|
||||
<div data-slot="score-config-loading" className="space-y-3">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-11 w-full" />
|
||||
<Skeleton className="h-11 w-full" />
|
||||
<Skeleton className="h-11 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{groups.map((group) => (
|
||||
<div
|
||||
key={group.domain}
|
||||
data-slot="score-config-group"
|
||||
data-domain={group.domain}
|
||||
>
|
||||
<h3
|
||||
data-slot="score-config-group-heading"
|
||||
className="text-muted-foreground text-xs font-medium tracking-wide uppercase"
|
||||
>
|
||||
{t(SCORE_DOMAIN_LABEL_KEYS[group.domain])}
|
||||
</h3>
|
||||
{SCORE_DOMAIN_NOTE_KEYS[group.domain] ? (
|
||||
<p
|
||||
data-slot="score-config-group-note"
|
||||
className="text-muted-foreground mt-1 text-xs"
|
||||
>
|
||||
{t(SCORE_DOMAIN_NOTE_KEYS[group.domain] as string)}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="divide-border/60 mt-1 divide-y">
|
||||
{group.rows.map((row) => (
|
||||
<ScorePillarRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
pending={busy}
|
||||
onToggle={(next) => toggle(row.id, next)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{omitted.length > 0 ? (
|
||||
<p
|
||||
data-slot="score-config-omitted"
|
||||
data-count={omitted.length}
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("settings.sections.score.omitted", { count: omitted.length })}{" "}
|
||||
<Link
|
||||
href="/settings/modules"
|
||||
data-slot="score-config-omitted-link"
|
||||
className="text-foreground underline underline-offset-2"
|
||||
>
|
||||
{t("settings.sections.score.omittedLink")}
|
||||
</Link>
|
||||
</p>
|
||||
) : null}
|
||||
{busy ? (
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className="size-3.5 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
) : null}
|
||||
{t("settings.sections.score.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -83,14 +83,6 @@ export interface ShareLinkSummary {
|
||||
* hashes). `qrUrl` carries the passphrase in the URL fragment (`#k=`).
|
||||
*/
|
||||
export interface ShareLinkCreated extends ShareLinkSummary {
|
||||
/**
|
||||
* RETIRED, response-only. They configured a FHIR face that was never built;
|
||||
* the request refuses them and the columns are gone. They stay here because
|
||||
* the native client was given this shape and a missing non-optional key
|
||||
* fails a whole Swift decode — see #70 in the native repository.
|
||||
*/
|
||||
allowFhirApi: boolean;
|
||||
resourceTypes: string[];
|
||||
token: string;
|
||||
passphrase: string;
|
||||
shareUrl: string;
|
||||
|
||||
@@ -352,7 +352,6 @@ describe("i18n locale file integrity", () => {
|
||||
["insights.healthScore.pillar.activity", "Aktivität"],
|
||||
["insights.healthScore.pillar.sleep", "Schlaf"],
|
||||
["insights.healthScore.pillar.wellbeing", "Wohlbefinden"],
|
||||
["insights.healthScore.pillar.fitness", "Gemessene Fitness"],
|
||||
["insights.healthScore.pillar.lipids", "Blutfette"],
|
||||
];
|
||||
for (const [key, expectedDe] of expected) {
|
||||
|
||||
@@ -8,7 +8,13 @@ import {
|
||||
SCORE_ALGORITHM_CHANGED_AT,
|
||||
} from "../composite";
|
||||
import { UNCONFIGURED_SCORE_BOUNDARY } from "../config";
|
||||
import type { PillarValue, ScorePillarId, ScorePillarResult } from "../types";
|
||||
import { SCORE_GREEN_FLOOR, scoreBand } from "../shared";
|
||||
import {
|
||||
SCORE_VERSION,
|
||||
type PillarValue,
|
||||
type ScorePillarId,
|
||||
type ScorePillarResult,
|
||||
} from "../types";
|
||||
|
||||
const NOW = new Date("2026-08-20T12:00:00.000Z");
|
||||
|
||||
@@ -19,7 +25,6 @@ const DOMAIN_BY_PILLAR: Record<ScorePillarId, ScorePillarResult["domain"]> = {
|
||||
SLEEP: "sleep",
|
||||
ADIPOSITY: "adiposity",
|
||||
WELLBEING: "wellbeing",
|
||||
FITNESS: "fitness",
|
||||
LIPIDS: "cardiometabolic",
|
||||
};
|
||||
|
||||
@@ -87,6 +92,111 @@ function evaluation(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the band changes, and what the band is NOT.
|
||||
*
|
||||
* v1.35.1 moved green from 75 to 70, so the Health Score says the same
|
||||
* thing by a green dot that Readiness and the sleep score already did.
|
||||
* Boundaries are pinned on both sides because an off-by-one here is
|
||||
* invisible: a `>` where a `>=` belongs looks perfectly healthy at 80.
|
||||
*
|
||||
* The last case is the one that must survive every future threshold
|
||||
* edit. The band is not a function of the number: `computeComposite`
|
||||
* takes the worse of the mean's band and the worst counted pillar's, and
|
||||
* `HealthScoreRecord.band` is stored rather than re-derived precisely
|
||||
* because of it. A mean above the green floor with a red pillar under it
|
||||
* is still red.
|
||||
*/
|
||||
describe("the band the mean alone gives", () => {
|
||||
const CASES: Array<[number, string]> = [
|
||||
[69, "yellow"],
|
||||
[70, "green"],
|
||||
[74, "green"],
|
||||
[75, "green"],
|
||||
[49, "red"],
|
||||
[50, "yellow"],
|
||||
];
|
||||
|
||||
for (const [score, expected] of CASES) {
|
||||
it(`calls ${score} ${expected}`, () => {
|
||||
expect(scoreBand(score)).toBe(expected);
|
||||
});
|
||||
}
|
||||
|
||||
it("says the same thing the app's other two scores say", () => {
|
||||
// Readiness and the sleep score both call 70 green. Three scores in
|
||||
// one app cannot mean three things by one colour, and the Health
|
||||
// Score was the outlier.
|
||||
expect(scoreBand(SCORE_GREEN_FLOOR)).toBe("green");
|
||||
expect(scoreBand(SCORE_GREEN_FLOOR - 1)).toBe("yellow");
|
||||
expect(SCORE_GREEN_FLOOR).toBe(70);
|
||||
});
|
||||
|
||||
it("moved the method version with it", () => {
|
||||
// A stored day keeps the band it was shown under, and the notice key
|
||||
// carries this number, so each account hears once that the rules
|
||||
// moved. A threshold edit without a version bump is a silent one.
|
||||
expect(SCORE_VERSION).toBeGreaterThan(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the worst pillar still holds the band down", () => {
|
||||
it("keeps a composite red when a counted pillar is red, above the green floor", () => {
|
||||
// Mean 73: green on the number alone, and green under the OLD floor
|
||||
// it would have been yellow — so this case can only pass because the
|
||||
// worst-pillar rule survived the threshold change.
|
||||
const result = evaluation(
|
||||
[
|
||||
pillar("BLOOD_PRESSURE", 20),
|
||||
pillar("ACTIVITY", 100),
|
||||
pillar("SLEEP", 100),
|
||||
],
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
if (result.status !== "ok") return;
|
||||
expect(result.value.score).toBe(73);
|
||||
expect(scoreBand(result.value.score)).toBe("green");
|
||||
expect(result.value.band).toBe("red");
|
||||
expect(result.value.bandSetter).toBe("BLOOD_PRESSURE");
|
||||
});
|
||||
|
||||
it("keeps a composite yellow when its worst pillar is yellow", () => {
|
||||
// Mean 84, every pillar above the red floor, one of them yellow.
|
||||
const result = evaluation(
|
||||
[
|
||||
pillar("BLOOD_PRESSURE", 52),
|
||||
pillar("ACTIVITY", 100),
|
||||
pillar("SLEEP", 100),
|
||||
],
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
if (result.status !== "ok") return;
|
||||
expect(scoreBand(result.value.score)).toBe("green");
|
||||
expect(result.value.band).toBe("yellow");
|
||||
expect(result.value.bandSetter).toBe("BLOOD_PRESSURE");
|
||||
});
|
||||
|
||||
it("names no setter when every pillar clears the mean's band", () => {
|
||||
const result = evaluation(
|
||||
[
|
||||
pillar("BLOOD_PRESSURE", 71),
|
||||
pillar("ACTIVITY", 90),
|
||||
pillar("SLEEP", 90),
|
||||
],
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
if (result.status !== "ok") return;
|
||||
expect(result.value.band).toBe("green");
|
||||
expect(result.value.bandSetter).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reference-score composite", () => {
|
||||
it("requires three domains and one measured physiological domain", () => {
|
||||
const tooNarrow = evaluation(
|
||||
|
||||
@@ -57,15 +57,27 @@ import { SCORE_PILLAR_IDS, type ScorePillarId } from "../types";
|
||||
const NOW = new Date("2026-08-20T12:00:00.000Z");
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
/**
|
||||
* Pillars the catalogue has retired since the swap.
|
||||
*
|
||||
* FITNESS went in v1.35.1. It had never produced a value on any account —
|
||||
* the schema cannot prove a VO₂max reading came from a measured test, so
|
||||
* every FITNESS row reached the scorer unproven and was refused — which
|
||||
* is why retiring it moves no number. It is named here rather than edited
|
||||
* out of the frozen expression below, so the "before" side stays a
|
||||
* verbatim copy and the retirement stays a visible, deliberate subtraction
|
||||
* instead of a silent one.
|
||||
*/
|
||||
const RETIRED_SINCE_THE_SWAP: readonly string[] = ["FITNESS"];
|
||||
|
||||
/**
|
||||
* The `availablePillars` expression exactly as it stood before the swap,
|
||||
* kept here as the "before" side of the inheritance proof. It must never
|
||||
* be edited to follow the implementation: the day it disagrees with the
|
||||
* reader is the day somebody's number moved.
|
||||
* reader is the day somebody's number moved. The one sanctioned
|
||||
* subtraction is `RETIRED_SINCE_THE_SWAP`, applied below.
|
||||
*/
|
||||
function compositionBeforeTheSwap(
|
||||
modules: ScoreReaderModules,
|
||||
): ScorePillarId[] {
|
||||
function compositionBeforeTheSwap(modules: ScoreReaderModules): string[] {
|
||||
return [
|
||||
"BLOOD_PRESSURE",
|
||||
...(modules.glucose || modules.labs ? (["GLYCAEMIA"] as const) : []),
|
||||
@@ -78,6 +90,13 @@ function compositionBeforeTheSwap(
|
||||
];
|
||||
}
|
||||
|
||||
/** The pre-swap composition minus the pillars the catalogue has retired. */
|
||||
function compositionInheritedToday(modules: ScoreReaderModules): string[] {
|
||||
return compositionBeforeTheSwap(modules).filter(
|
||||
(id) => !RETIRED_SINCE_THE_SWAP.includes(id),
|
||||
);
|
||||
}
|
||||
|
||||
/** All sixteen on/off states of the four modules the reader is handed. */
|
||||
const MODULE_COMBINATIONS: ScoreReaderModules[] = [0, 1].flatMap((glucose) =>
|
||||
[0, 1].flatMap((labs) =>
|
||||
@@ -145,8 +164,8 @@ function measurement(
|
||||
|
||||
/**
|
||||
* An account with enough data for four pillars to score: steps, sleep,
|
||||
* a waist-to-height ratio and a WHO-5. Blood pressure, glycaemia, lipids
|
||||
* and fitness stay honestly absent, which is what lets the same fixture
|
||||
* a waist-to-height ratio and a WHO-5. Blood pressure, glycaemia and
|
||||
* lipids stay honestly absent, which is what lets the same fixture
|
||||
* show a selected-but-dataless pillar beside a module-withdrawn one.
|
||||
*/
|
||||
const MEASUREMENTS: MeasurementRow[] = [
|
||||
@@ -213,7 +232,6 @@ function score(args: {
|
||||
now: NOW,
|
||||
profile: {
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
timezone: "UTC",
|
||||
sourcePriorityJson: null,
|
||||
@@ -243,7 +261,7 @@ describe("the module-to-pillar map", () => {
|
||||
expect(
|
||||
pillarsWithModuleData(modules),
|
||||
`availability for ${describeModules(modules)}`,
|
||||
).toEqual(compositionBeforeTheSwap(modules));
|
||||
).toEqual(compositionInheritedToday(modules));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -267,20 +285,15 @@ describe("the module-to-pillar map", () => {
|
||||
).toContain("LIPIDS");
|
||||
});
|
||||
|
||||
it("leaves the four core-domain pillars ungated", () => {
|
||||
// These read weight, blood pressure, steps and waist — domains with
|
||||
// no module switch at all, so no module can withdraw their data.
|
||||
// Pinned rather than derived so an addition to the map that started
|
||||
// gating one of them has to be a deliberate edit here too.
|
||||
it("leaves the three core-domain pillars ungated", () => {
|
||||
// These read blood pressure, steps and waist — domains with no module
|
||||
// switch at all, so no module can withdraw their data. Pinned rather
|
||||
// than derived so an addition to the map that started gating one of
|
||||
// them has to be a deliberate edit here too.
|
||||
const ungated = SCORE_PILLAR_IDS.filter(
|
||||
(id) => !MODULE_GATED_PILLARS.has(id),
|
||||
);
|
||||
expect(ungated).toEqual([
|
||||
"BLOOD_PRESSURE",
|
||||
"ACTIVITY",
|
||||
"ADIPOSITY",
|
||||
"FITNESS",
|
||||
]);
|
||||
expect(ungated).toEqual(["BLOOD_PRESSURE", "ACTIVITY", "ADIPOSITY"]);
|
||||
const allOff: ScoreReaderModules = {
|
||||
glucose: false,
|
||||
labs: false,
|
||||
@@ -290,6 +303,19 @@ describe("the module-to-pillar map", () => {
|
||||
expect(pillarsWithModuleData(allOff)).toEqual(ungated);
|
||||
});
|
||||
|
||||
it("holds no retired pillar the catalogue still knows", () => {
|
||||
// A retirement list that named a live pillar would quietly subtract
|
||||
// it from the "before" side and hide a real composition change.
|
||||
for (const id of RETIRED_SINCE_THE_SWAP) {
|
||||
expect(SCORE_PILLAR_IDS as readonly string[]).not.toContain(id);
|
||||
}
|
||||
// And the list is not empty-by-accident: the subtraction it performs
|
||||
// has to be visible in at least one module combination.
|
||||
expect(compositionBeforeTheSwap(ALL_MODULES_ON)).not.toEqual(
|
||||
compositionInheritedToday(ALL_MODULES_ON),
|
||||
);
|
||||
});
|
||||
|
||||
it("names only real modules and real pillars", () => {
|
||||
for (const key of Object.keys(MODULE_SCORE_PILLARS) as ScoreModuleKey[]) {
|
||||
expect(MODULE_KEYS as readonly string[]).toContain(key);
|
||||
@@ -307,7 +333,7 @@ describe("inheritance: a never-written recipe keeps the number it had", () => {
|
||||
expect(
|
||||
report.pillars.map((pillar) => pillar.id),
|
||||
`composition for ${describeModules(modules)}`,
|
||||
).toEqual(compositionBeforeTheSwap(modules));
|
||||
).toEqual(compositionInheritedToday(modules));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -321,7 +347,6 @@ describe("inheritance: a never-written recipe keeps the number it had", () => {
|
||||
"ACTIVITY",
|
||||
"ADIPOSITY",
|
||||
"WELLBEING",
|
||||
"FITNESS",
|
||||
"LIPIDS",
|
||||
]);
|
||||
expect(report.composite.status).toBe("ok");
|
||||
|
||||
@@ -251,7 +251,7 @@ function recipe(changedAt: Date): unknown {
|
||||
// rest. The composition this resolves to is the same either way in
|
||||
// this fixture, which is exactly the case a composition comparison
|
||||
// cannot see.
|
||||
selection: SCORE_PILLAR_IDS.filter((id) => id !== "FITNESS"),
|
||||
selection: SCORE_PILLAR_IDS.filter((id) => id !== "LIPIDS"),
|
||||
version: 3,
|
||||
changedAt,
|
||||
});
|
||||
@@ -512,16 +512,16 @@ describe("a settings change is never narrated as a health event", () => {
|
||||
const unconfigured = await computeUserHealthScore(scoreInput(null));
|
||||
|
||||
expect(configured.algorithmNotice!.itemKey).toBe(
|
||||
"health_score_algorithm:2:config:3",
|
||||
"health_score_algorithm:3:config:3",
|
||||
);
|
||||
expect(configured.algorithmNotice!.dismissed).toBe(false);
|
||||
// An account that never chose keeps the key its acknowledgement is
|
||||
// already filed under.
|
||||
// An account that never chose keeps the bare method key, with no
|
||||
// recipe suffix on it.
|
||||
expect(unconfigured.algorithmNotice!.itemKey).toBe(
|
||||
"health_score_algorithm:2",
|
||||
"health_score_algorithm:3",
|
||||
);
|
||||
|
||||
dismissedKeys.add("health_score_algorithm:2:config:3");
|
||||
dismissedKeys.add("health_score_algorithm:3:config:3");
|
||||
expect(
|
||||
(await computeUserHealthScore(scoreInput(JUST_CHANGED))).algorithmNotice!
|
||||
.dismissed,
|
||||
@@ -532,14 +532,14 @@ describe("a settings change is never narrated as a health event", () => {
|
||||
const next = await computeUserHealthScore(
|
||||
scoreInput(
|
||||
healthScoreConfigFromSelection({
|
||||
selection: SCORE_PILLAR_IDS.filter((id) => id !== "FITNESS"),
|
||||
selection: SCORE_PILLAR_IDS.filter((id) => id !== "LIPIDS"),
|
||||
version: 4,
|
||||
changedAt: new Date(NOW.getTime() - DAY_MS),
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(next.algorithmNotice!.itemKey).toBe(
|
||||
"health_score_algorithm:2:config:4",
|
||||
"health_score_algorithm:3:config:4",
|
||||
);
|
||||
expect(next.algorithmNotice!.dismissed).toBe(false);
|
||||
});
|
||||
|
||||
@@ -39,6 +39,7 @@ import type {
|
||||
ScorePillarId,
|
||||
ScorePillarResult,
|
||||
} from "../types";
|
||||
import { SCORE_VERSION } from "../types";
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
const NOW = new Date("2026-08-20T12:00:00.000Z");
|
||||
@@ -50,7 +51,6 @@ const DOMAIN_BY_PILLAR: Record<ScorePillarId, ScorePillarResult["domain"]> = {
|
||||
SLEEP: "sleep",
|
||||
ADIPOSITY: "adiposity",
|
||||
WELLBEING: "wellbeing",
|
||||
FITNESS: "fitness",
|
||||
LIPIDS: "cardiometabolic",
|
||||
};
|
||||
|
||||
@@ -116,7 +116,7 @@ function composite(score: number, asOf: Date): Derived<CompositeValue> {
|
||||
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
|
||||
configured: false,
|
||||
noiseFloor: 1,
|
||||
scoreVersion: 2,
|
||||
scoreVersion: SCORE_VERSION,
|
||||
},
|
||||
coverage,
|
||||
confidence,
|
||||
@@ -191,7 +191,7 @@ describe("the recipe boundary", () => {
|
||||
composition: ["BLOOD_PRESSURE", "ACTIVITY"],
|
||||
configured: false,
|
||||
noiseFloor: 1,
|
||||
scoreVersion: 2,
|
||||
scoreVersion: SCORE_VERSION,
|
||||
},
|
||||
coverage: {
|
||||
requiredInputs: 3,
|
||||
|
||||
@@ -37,10 +37,10 @@ describe("resolveScoreConfigured", () => {
|
||||
});
|
||||
|
||||
it("reads true once the person's own recipe narrows the composition", () => {
|
||||
const withoutFitness = ALL.filter((id) => id !== "FITNESS");
|
||||
const narrowed = ALL.filter((id) => id !== "LIPIDS");
|
||||
expect(
|
||||
resolveScoreConfigured({
|
||||
config: { pillars: withoutFitness },
|
||||
config: { pillars: narrowed },
|
||||
recordedPillars: RECORDS_EVERYTHING,
|
||||
}),
|
||||
).toBe(true);
|
||||
@@ -87,10 +87,10 @@ describe("resolveScoreConfigured", () => {
|
||||
});
|
||||
|
||||
it("reads false again when the recipe is undone", () => {
|
||||
const withoutFitness = ALL.filter((id) => id !== "FITNESS");
|
||||
const narrowed = ALL.filter((id) => id !== "LIPIDS");
|
||||
expect(
|
||||
resolveScoreConfigured({
|
||||
config: { pillars: withoutFitness },
|
||||
config: { pillars: narrowed },
|
||||
recordedPillars: RECORDS_EVERYTHING,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
@@ -4,7 +4,6 @@ import { gradeBpScore } from "@/lib/analytics/bp-grade";
|
||||
import { computeActivityPillar } from "../activity";
|
||||
import { computeAdiposityPillar } from "../adiposity";
|
||||
import { computeBloodPressurePillar } from "../blood-pressure";
|
||||
import { computeFitnessPillar } from "../fitness";
|
||||
import { computeGlycaemiaPillar } from "../glycaemia";
|
||||
import { computeLipidsPillar } from "../lipids";
|
||||
import { computeSleepPillar } from "../sleep";
|
||||
@@ -390,43 +389,6 @@ describe("reference-score pillars", () => {
|
||||
if (who.status === "ok") expect(who.value.score).toBe(52);
|
||||
});
|
||||
|
||||
it("admits fitness only when the source proves a measured test", () => {
|
||||
const unverified = computeFitnessPillar({
|
||||
...live,
|
||||
asOf: NOW,
|
||||
ageYears: 40,
|
||||
sex: "MALE",
|
||||
rows: [
|
||||
{
|
||||
value: 42,
|
||||
at: new Date("2026-07-20T08:00:00Z"),
|
||||
source: "APPLE_HEALTH",
|
||||
measured: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(unverified.status).toBe("insufficient");
|
||||
|
||||
const measured = computeFitnessPillar({
|
||||
...live,
|
||||
asOf: NOW,
|
||||
ageYears: 40,
|
||||
sex: "MALE",
|
||||
rows: [
|
||||
{
|
||||
value: 42,
|
||||
at: new Date("2026-07-20T08:00:00Z"),
|
||||
source: "MANUAL",
|
||||
measured: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(measured.status).toBe("ok");
|
||||
if (measured.status === "ok") {
|
||||
expect(measured.value.reference.source).toBe("FRIEND (Kaminsky 2015)");
|
||||
}
|
||||
});
|
||||
|
||||
it("requires a fresh lipid panel with lab-reported reference bounds", () => {
|
||||
const result = computeLipidsPillar({
|
||||
...live,
|
||||
@@ -495,15 +457,6 @@ describe("reference-score pillars", () => {
|
||||
fastingGlucose: [],
|
||||
}).status,
|
||||
).toBe("insufficient");
|
||||
expect(
|
||||
computeFitnessPillar({
|
||||
...live,
|
||||
asOf: NOW,
|
||||
ageYears: 40,
|
||||
sex: "MALE",
|
||||
rows: [{ value: 42, at: staleAt, source: "MANUAL", measured: true }],
|
||||
}).status,
|
||||
).toBe("insufficient");
|
||||
expect(
|
||||
computeLipidsPillar({
|
||||
...live,
|
||||
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
ScorePillarId,
|
||||
ScorePillarResult,
|
||||
} from "../types";
|
||||
import { SCORE_VERSION } from "../types";
|
||||
|
||||
const NOW = new Date("2026-08-20T12:00:00.000Z");
|
||||
|
||||
@@ -38,7 +39,6 @@ const DOMAIN_BY_PILLAR: Record<ScorePillarId, ScorePillarResult["domain"]> = {
|
||||
SLEEP: "sleep",
|
||||
ADIPOSITY: "adiposity",
|
||||
WELLBEING: "wellbeing",
|
||||
FITNESS: "fitness",
|
||||
LIPIDS: "cardiometabolic",
|
||||
};
|
||||
|
||||
@@ -104,7 +104,7 @@ function report(
|
||||
pillars,
|
||||
delta: null,
|
||||
deltaReason: "no_previous_window",
|
||||
scoreVersion: 2,
|
||||
scoreVersion: SCORE_VERSION,
|
||||
weightGoal: { status: "insufficient" } as HealthScoreReport["weightGoal"],
|
||||
algorithmNotice: null,
|
||||
};
|
||||
@@ -145,7 +145,7 @@ describe("stored bands", () => {
|
||||
|
||||
describe("input fingerprint", () => {
|
||||
const base = {
|
||||
scoreVersion: 2,
|
||||
scoreVersion: 1,
|
||||
composition: ["BLOOD_PRESSURE", "SLEEP", "ADIPOSITY"] as ScorePillarId[],
|
||||
pillars: HEALTHY,
|
||||
};
|
||||
@@ -157,7 +157,7 @@ describe("input fingerprint", () => {
|
||||
});
|
||||
|
||||
it("moves when the algorithm version moves", () => {
|
||||
expect(healthScoreInputFingerprint({ ...base, scoreVersion: 3 })).not.toBe(
|
||||
expect(healthScoreInputFingerprint({ ...base, scoreVersion: 2 })).not.toBe(
|
||||
healthScoreInputFingerprint(base),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -28,7 +28,6 @@ export const PHYSIOLOGICAL_PILLARS: ReadonlySet<ScorePillarId> = new Set([
|
||||
"GLYCAEMIA",
|
||||
"SLEEP",
|
||||
"ADIPOSITY",
|
||||
"FITNESS",
|
||||
"LIPIDS",
|
||||
]);
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ export async function computeHealthScoreDerived(
|
||||
where: { id: userId },
|
||||
select: {
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
heightCm: true,
|
||||
timezone: true,
|
||||
thresholdsJson: true,
|
||||
@@ -56,7 +55,6 @@ export async function computeHealthScoreDerived(
|
||||
now,
|
||||
profile: {
|
||||
dateOfBirth: user.dateOfBirth,
|
||||
gender: user.gender,
|
||||
heightCm: user.heightCm,
|
||||
timezone,
|
||||
sourcePriorityJson,
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import { lookupNormalRange } from "@/lib/insights/derived/norms";
|
||||
import type { Derived } from "@/lib/insights/derived/types";
|
||||
|
||||
import type { FitnessPillarInput, PillarValue } from "./types";
|
||||
import {
|
||||
coverage,
|
||||
failedPillar,
|
||||
insufficientPillar,
|
||||
okPillar,
|
||||
scoreReferenceBand,
|
||||
withinWindow,
|
||||
} from "./shared";
|
||||
|
||||
export const FITNESS_WINDOW_DAYS = 365;
|
||||
|
||||
export function computeFitnessPillar(
|
||||
input: FitnessPillarInput,
|
||||
): Derived<PillarValue> {
|
||||
if (input.readFailed) {
|
||||
return failedPillar({
|
||||
id: "FITNESS",
|
||||
asOf: input.asOf,
|
||||
windowDays: FITNESS_WINDOW_DAYS,
|
||||
});
|
||||
}
|
||||
|
||||
const norm = lookupNormalRange("VO2_MAX", input.ageYears, input.sex);
|
||||
if (!norm) {
|
||||
return insufficientPillar({
|
||||
id: "FITNESS",
|
||||
source: input.source,
|
||||
asOf: input.asOf,
|
||||
windowDays: FITNESS_WINDOW_DAYS,
|
||||
requiredInputs: 1,
|
||||
presentInputs: 0,
|
||||
historyDays: 0,
|
||||
missing: ["age and binary reference sex"],
|
||||
reason: "missing_reference_profile",
|
||||
});
|
||||
}
|
||||
|
||||
const measured = input.rows
|
||||
.filter(
|
||||
(row) =>
|
||||
row.measured && withinWindow(row.at, input.asOf, FITNESS_WINDOW_DAYS),
|
||||
)
|
||||
.sort((a, b) => b.at.getTime() - a.at.getTime());
|
||||
const latest = measured[0];
|
||||
if (!latest) {
|
||||
return insufficientPillar({
|
||||
id: "FITNESS",
|
||||
source: input.source,
|
||||
asOf: input.asOf,
|
||||
windowDays: FITNESS_WINDOW_DAYS,
|
||||
requiredInputs: 1,
|
||||
presentInputs: 0,
|
||||
historyDays: 0,
|
||||
missing: ["measured VO₂max test"],
|
||||
reason: input.rows.length > 0 ? "unverified_or_stale" : "not_tracked",
|
||||
});
|
||||
}
|
||||
|
||||
return okPillar({
|
||||
id: "FITNESS",
|
||||
source: input.source,
|
||||
asOf: input.asOf,
|
||||
windowDays: FITNESS_WINDOW_DAYS,
|
||||
coverage: coverage({
|
||||
requiredInputs: 1,
|
||||
presentInputs: 1,
|
||||
historyDays: 1,
|
||||
}),
|
||||
value: {
|
||||
score: scoreReferenceBand(latest.value, norm.low, null),
|
||||
observed: {
|
||||
value: latest.value,
|
||||
unit: "mL/(kg·min)",
|
||||
label: `${latest.value} mL/(kg·min) measured`,
|
||||
asOf: latest.at.toISOString(),
|
||||
sources: [latest.source],
|
||||
},
|
||||
reference: {
|
||||
kind: "population-percentile",
|
||||
low: norm.low,
|
||||
high: norm.high,
|
||||
label: `${norm.low}–${norm.high} mL/(kg·min) age/sex band`,
|
||||
source: "FRIEND (Kaminsky 2015)",
|
||||
},
|
||||
noiseFloor: 0,
|
||||
deltaEligible: false,
|
||||
deltaIdentity: "measured_vo2max",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { computeActivityPillar } from "./activity";
|
||||
import { computeAdiposityPillar } from "./adiposity";
|
||||
import { computeBloodPressurePillar } from "./blood-pressure";
|
||||
import { computeComposite } from "./composite";
|
||||
import { computeFitnessPillar } from "./fitness";
|
||||
import { computeGlycaemiaPillar } from "./glycaemia";
|
||||
import { computeLipidsPillar } from "./lipids";
|
||||
import { computeSleepPillar } from "./sleep";
|
||||
@@ -70,11 +69,6 @@ export function computeHealthScore(
|
||||
domain: SCORE_PILLAR_DOMAINS.WELLBEING,
|
||||
result: computeWellbeingPillar(input.pillars.WELLBEING),
|
||||
},
|
||||
FITNESS: {
|
||||
id: "FITNESS",
|
||||
domain: SCORE_PILLAR_DOMAINS.FITNESS,
|
||||
result: computeFitnessPillar(input.pillars.FITNESS),
|
||||
},
|
||||
LIPIDS: {
|
||||
id: "LIPIDS",
|
||||
domain: SCORE_PILLAR_DOMAINS.LIPIDS,
|
||||
|
||||
@@ -20,12 +20,12 @@
|
||||
* was the `glucose || labs` ternary, and it is the entry that would be
|
||||
* lost by assuming one module per pillar.
|
||||
*
|
||||
* `BLOOD_PRESSURE`, `ACTIVITY`, `ADIPOSITY` and `FITNESS` are absent
|
||||
* from the map, which is the same thing the reader has always done:
|
||||
* they read core measurement domains that carry no module switch, so no
|
||||
* module can withdraw their data. Absent means ungated, not forgotten —
|
||||
* the test beside this file pins the four so a silent addition to the
|
||||
* map cannot quietly start gating one of them.
|
||||
* `BLOOD_PRESSURE`, `ACTIVITY` and `ADIPOSITY` are absent from the map,
|
||||
* which is the same thing the reader has always done: they read core
|
||||
* measurement domains that carry no module switch, so no module can
|
||||
* withdraw their data. Absent means ungated, not forgotten — the test
|
||||
* beside this file pins the three so a silent addition to the map cannot
|
||||
* quietly start gating one of them.
|
||||
*/
|
||||
import { SCORE_PILLAR_IDS, type ScorePillarId } from "./types";
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ import { reconstructNights } from "@/lib/insights/derived/sleep-score";
|
||||
import { pickCumulativeDaySum } from "@/lib/measurements/cumulative-day-sum";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
|
||||
import { toProfileSex } from "@/lib/profile/sex";
|
||||
import { healthScoreNoticeItemKey } from "@/lib/daily/priority-item-key";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
|
||||
@@ -44,7 +43,6 @@ const SCORE_READ_DAYS = 365 + HISTORY_OFFSET_DAYS;
|
||||
|
||||
export interface ScoreReaderProfile {
|
||||
dateOfBirth: Date | null;
|
||||
gender: string | null;
|
||||
heightCm: number | null;
|
||||
timezone: string;
|
||||
sourcePriorityJson: unknown;
|
||||
@@ -424,7 +422,6 @@ function scoreInputsFor(args: {
|
||||
activity: Settled<ActivityRead>;
|
||||
sleep: Settled<MeasurementRow[]>;
|
||||
adiposity: Settled<MeasurementRow[]>;
|
||||
fitness: Settled<MeasurementRow[]>;
|
||||
assessments: Settled<
|
||||
Array<{
|
||||
instrument: "PHQ9" | "GAD7" | "WHO5" | "SCI";
|
||||
@@ -463,7 +460,6 @@ function scoreInputsFor(args: {
|
||||
new Date(asOf.getTime() - (SLEEP_WINDOW_DAYS - 1) * DAY_MS),
|
||||
input.profile.timezone,
|
||||
);
|
||||
const sex = toProfileSex(input.profile.gender);
|
||||
const fastingCanonical = pickStableSourceRows(
|
||||
args.glucose.value.filter((row) => row.glucoseContext === "FASTING"),
|
||||
(row) => userDayKey(row.measuredAt, input.profile.timezone),
|
||||
@@ -492,12 +488,6 @@ function scoreInputsFor(args: {
|
||||
(row) =>
|
||||
`${row.type}|${userDayKey(row.measuredAt, input.profile.timezone)}`,
|
||||
);
|
||||
const fitnessCanonical = pickCanonicalSourceRows(
|
||||
args.fitness.value,
|
||||
"vo2Max",
|
||||
input.profile.sourcePriorityJson,
|
||||
(date) => userDayKey(date, input.profile.timezone),
|
||||
).canonicalRows;
|
||||
|
||||
return {
|
||||
BLOOD_PRESSURE: {
|
||||
@@ -599,21 +589,6 @@ function scoreInputsFor(args: {
|
||||
at: row.takenAt,
|
||||
})),
|
||||
},
|
||||
FITNESS: {
|
||||
source: "live",
|
||||
readFailed: !args.fitness.ok,
|
||||
asOf,
|
||||
ageYears,
|
||||
sex,
|
||||
// The schema records VO₂max but does not distinguish CPET measurements
|
||||
// from device estimates. No current row can therefore prove `measured`.
|
||||
rows: fitnessCanonical.map((row) => ({
|
||||
value: row.value,
|
||||
at: row.measuredAt,
|
||||
source: row.source,
|
||||
measured: false,
|
||||
})),
|
||||
},
|
||||
LIPIDS: {
|
||||
source: "live",
|
||||
readFailed: !args.labs.ok,
|
||||
@@ -653,7 +628,6 @@ export async function computeUserHealthScore(
|
||||
activity,
|
||||
sleep,
|
||||
adiposity,
|
||||
fitness,
|
||||
assessments,
|
||||
labs,
|
||||
weights,
|
||||
@@ -695,11 +669,6 @@ export async function computeUserHealthScore(
|
||||
[],
|
||||
"ADIPOSITY",
|
||||
),
|
||||
capture(
|
||||
readMeasurements(db, input.userId, ["VO2_MAX"], since),
|
||||
[],
|
||||
"FITNESS",
|
||||
),
|
||||
input.modules.mentalHealth
|
||||
? capture(
|
||||
db.mentalHealthAssessment.findMany({
|
||||
@@ -827,7 +796,6 @@ export async function computeUserHealthScore(
|
||||
activity,
|
||||
sleep,
|
||||
adiposity,
|
||||
fitness,
|
||||
assessments,
|
||||
labs,
|
||||
};
|
||||
|
||||
@@ -19,9 +19,37 @@ export function clampScore(value: number): number {
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
|
||||
/** Green from here up. Yellow from {@link SCORE_YELLOW_FLOOR} up. */
|
||||
export const SCORE_GREEN_FLOOR = 70;
|
||||
|
||||
/** Below this is red. */
|
||||
export const SCORE_YELLOW_FLOOR = 50;
|
||||
|
||||
/**
|
||||
* The band a 0-100 score falls in, from the number alone.
|
||||
*
|
||||
* v1.35.1 — green starts at 70, not 75. The app carries three scores and
|
||||
* had two conventions: Readiness and the sleep score both call 70 green
|
||||
* (`bandForScore` in `src/lib/insights/derived/readiness.ts` and
|
||||
* `src/lib/insights/derived/sleep-score.ts`), and the Health Score alone
|
||||
* sat at 75. One app cannot mean two things by a green dot, so the
|
||||
* outlier moved. The yellow floor stays at 50 deliberately: raising the
|
||||
* bar for a reward and lowering the bar for a warning are different
|
||||
* decisions, and only the first one was asked for.
|
||||
*
|
||||
* This is a METHOD change, so `SCORE_VERSION` moved with it. Stored days
|
||||
* keep the band they were actually shown under, and the notice key
|
||||
* carries the version, so each account is told once that the method
|
||||
* moved.
|
||||
*
|
||||
* NOT the whole band rule. `computeComposite` takes the worse of this
|
||||
* band and the worst counted pillar's band, so a composite carrying a red
|
||||
* pillar stays red however high the mean sits. That rule is untouched;
|
||||
* only the mean-side thresholds moved.
|
||||
*/
|
||||
export function scoreBand(score: number): ScoreBand {
|
||||
if (score >= 75) return "green";
|
||||
if (score >= 50) return "yellow";
|
||||
if (score >= SCORE_GREEN_FLOOR) return "green";
|
||||
if (score >= SCORE_YELLOW_FLOOR) return "yellow";
|
||||
return "red";
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,33 @@ import type {
|
||||
Derived,
|
||||
DerivedProvenanceSource,
|
||||
} from "@/lib/insights/derived/types";
|
||||
import type { ProfileSex } from "@/lib/profile/sex";
|
||||
|
||||
export const SCORE_VERSION = 2 as const;
|
||||
/**
|
||||
* The scoring method's identity.
|
||||
*
|
||||
* Moves whenever the method changes, which is what lets a stored day say
|
||||
* which rules produced it and lets each account be told once that the
|
||||
* rules moved (`healthScoreNoticeItemKey`). It is NOT the schema version
|
||||
* of the report.
|
||||
*
|
||||
* 3 — v1.35.1: green starts at 70 rather than 75, matching the two other
|
||||
* scores in the app, and the fitness pillar left the catalogue.
|
||||
* 2 — the composite the previous line shipped.
|
||||
*/
|
||||
export const SCORE_VERSION = 3 as const;
|
||||
|
||||
/**
|
||||
* The pillars a score can be built from.
|
||||
*
|
||||
* v1.35.2 — FITNESS is gone. The schema records VO₂max but cannot tell a
|
||||
* measured test apart from a device estimate, so every FITNESS row
|
||||
* reached the scorer unproven and was refused: the pillar had never once
|
||||
* produced a value on any account. A pillar that cannot score is not a
|
||||
* choice, and listing it as one put chrome in front of a thing that
|
||||
* recomputes nothing. Reading it back is still safe — a stored recipe
|
||||
* naming it passes through `orderedUniquePillars`, which keeps only ids
|
||||
* this catalogue knows.
|
||||
*/
|
||||
export const SCORE_PILLAR_IDS = [
|
||||
"BLOOD_PRESSURE",
|
||||
"GLYCAEMIA",
|
||||
@@ -15,7 +38,6 @@ export const SCORE_PILLAR_IDS = [
|
||||
"SLEEP",
|
||||
"ADIPOSITY",
|
||||
"WELLBEING",
|
||||
"FITNESS",
|
||||
"LIPIDS",
|
||||
] as const;
|
||||
|
||||
@@ -34,18 +56,13 @@ export function orderedUniquePillars(ids: readonly unknown[]): ScorePillarId[] {
|
||||
}
|
||||
export type ScoreBand = "green" | "yellow" | "red";
|
||||
export type ScoreDomain =
|
||||
| "cardiometabolic"
|
||||
| "activity"
|
||||
| "sleep"
|
||||
| "adiposity"
|
||||
| "wellbeing"
|
||||
| "fitness";
|
||||
"cardiometabolic" | "activity" | "sleep" | "adiposity" | "wellbeing";
|
||||
|
||||
/**
|
||||
* Which domain each pillar speaks to. The composite needs three
|
||||
* distinct domains, so this map is part of the breadth rule and not
|
||||
* decoration: three of the eight pillars share the cardiometabolic
|
||||
* domain, which is why a selection of five pillars can still fail.
|
||||
* decoration: three of the seven pillars share the cardiometabolic
|
||||
* domain, which is why a selection of four pillars can still fail.
|
||||
*
|
||||
* One map, read by the scorer when it builds a pillar result and by the
|
||||
* breadth rule when it judges a selection, so the two can never drift
|
||||
@@ -58,7 +75,6 @@ export const SCORE_PILLAR_DOMAINS: Record<ScorePillarId, ScoreDomain> = {
|
||||
SLEEP: "sleep",
|
||||
ADIPOSITY: "adiposity",
|
||||
WELLBEING: "wellbeing",
|
||||
FITNESS: "fitness",
|
||||
LIPIDS: "cardiometabolic",
|
||||
};
|
||||
|
||||
@@ -272,21 +288,6 @@ export interface WellbeingPillarInput extends DomainReadState {
|
||||
assessments: WellbeingAssessment[];
|
||||
}
|
||||
|
||||
export interface FitnessReading {
|
||||
value: number;
|
||||
at: Date;
|
||||
source: string;
|
||||
/** True only when persisted provenance proves a measured CPET/test. */
|
||||
measured: boolean;
|
||||
}
|
||||
|
||||
export interface FitnessPillarInput extends DomainReadState {
|
||||
asOf: Date;
|
||||
ageYears: number | null;
|
||||
sex: ProfileSex;
|
||||
rows: FitnessReading[];
|
||||
}
|
||||
|
||||
export interface LipidReading {
|
||||
marker: string;
|
||||
value: number;
|
||||
@@ -310,6 +311,5 @@ export interface PillarInputs {
|
||||
SLEEP: SleepPillarInput;
|
||||
ADIPOSITY: AdiposityPillarInput;
|
||||
WELLBEING: WellbeingPillarInput;
|
||||
FITNESS: FitnessPillarInput;
|
||||
LIPIDS: LipidsPillarInput;
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* The resolved cycle verdict.
|
||||
*
|
||||
* The load-bearing case is the overdue boundary. Beyond the profile's typical
|
||||
* length plus the server's grace window the day count stops being an observed
|
||||
* fact and the verdict must say so, with the number of days. One day either
|
||||
* side of that line is the difference between a person reading "day 43" and
|
||||
* reading "15 days later than usual", so both sides are pinned here, together
|
||||
* with the day counts each side produces.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { resolveCycleVerdict, type VerdictCalendarDay } from "../verdict";
|
||||
import type { CyclePhase } from "../types";
|
||||
|
||||
function day(
|
||||
date: string,
|
||||
phase: CyclePhase | null,
|
||||
isFertileWindow = false,
|
||||
): VerdictCalendarDay {
|
||||
return { date, phase, isFertileWindow };
|
||||
}
|
||||
|
||||
/** A run of 5 MENSTRUAL days then LUTEAL days, `n` long, ending at `today`. */
|
||||
function longRun(
|
||||
n: number,
|
||||
from = "2026-01-01",
|
||||
): {
|
||||
days: VerdictCalendarDay[];
|
||||
today: string;
|
||||
} {
|
||||
const start = Date.parse(`${from}T00:00:00Z`);
|
||||
const days: VerdictCalendarDay[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const date = new Date(start + i * 86_400_000).toISOString().slice(0, 10);
|
||||
days.push(day(date, i < 5 ? "MENSTRUAL" : "LUTEAL"));
|
||||
}
|
||||
return { days, today: days[n - 1].date };
|
||||
}
|
||||
|
||||
describe("resolveCycleVerdict — the overdue boundary", () => {
|
||||
it("still counts on the last honest day", () => {
|
||||
// Typical 28 + a 14-day grace window = 42. Day 42 is an observed fact.
|
||||
const { days, today } = longRun(42);
|
||||
const v = resolveCycleVerdict({
|
||||
days,
|
||||
today,
|
||||
profile: { typicalCycleLength: 28 },
|
||||
});
|
||||
expect(v.state).toBe("IN_CYCLE");
|
||||
expect(v.dayOfCycle).toBe(42);
|
||||
expect(v.overdueDays).toBeNull();
|
||||
});
|
||||
|
||||
it("stops counting one day later, and says how late", () => {
|
||||
const { days, today } = longRun(43);
|
||||
const v = resolveCycleVerdict({
|
||||
days,
|
||||
today,
|
||||
profile: { typicalCycleLength: 28 },
|
||||
});
|
||||
expect(v.state).toBe("OVERDUE");
|
||||
expect(v.dayOfCycle).toBeNull();
|
||||
expect(v.phase).toBeNull();
|
||||
// Measured against the typical length the person actually has, not against
|
||||
// typical + grace: 43 days into a 28-day cycle is 15 days later than usual.
|
||||
expect(v.overdueDays).toBe(15);
|
||||
});
|
||||
|
||||
it("keeps growing the overdue count as the delay grows", () => {
|
||||
const { days, today } = longRun(90);
|
||||
const v = resolveCycleVerdict({
|
||||
days,
|
||||
today,
|
||||
profile: { typicalCycleLength: 28 },
|
||||
});
|
||||
expect(v.state).toBe("OVERDUE");
|
||||
expect(v.overdueDays).toBe(62);
|
||||
// The ring still draws the canonical four-phase dial rather than nothing.
|
||||
expect(v.spans).toHaveLength(4);
|
||||
expect(v.cycleLength).toBe(28);
|
||||
});
|
||||
|
||||
it("moves the boundary with the profile's own typical length", () => {
|
||||
// A 60-day typical cycle keeps day 49 honest where a 28-day one would not.
|
||||
const { days, today } = longRun(49);
|
||||
const v = resolveCycleVerdict({
|
||||
days,
|
||||
today,
|
||||
profile: { typicalCycleLength: 60 },
|
||||
});
|
||||
expect(v.state).toBe("IN_CYCLE");
|
||||
expect(v.dayOfCycle).toBe(49);
|
||||
expect(v.overdueDays).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the cycle start even once the count stops", () => {
|
||||
// The last logged period start is still a fact; the BBT chart scopes to it.
|
||||
const { days, today } = longRun(60);
|
||||
const v = resolveCycleVerdict({
|
||||
days,
|
||||
today,
|
||||
profile: { typicalCycleLength: 28 },
|
||||
});
|
||||
expect(v.state).toBe("OVERDUE");
|
||||
expect(v.cycleStartDate).toBe("2026-01-01");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCycleVerdict — what the record supports", () => {
|
||||
it("says INSUFFICIENT_DATA when today carries no phase", () => {
|
||||
const days = [day("2026-06-01", null), day("2026-06-02", null)];
|
||||
const v = resolveCycleVerdict({ days, today: "2026-06-02" });
|
||||
expect(v.state).toBe("INSUFFICIENT_DATA");
|
||||
expect(v.dayOfCycle).toBeNull();
|
||||
expect(v.phase).toBeNull();
|
||||
expect(v.cycleStartDate).toBeNull();
|
||||
expect(v.spans).toEqual([]);
|
||||
});
|
||||
|
||||
it("says INSUFFICIENT_DATA when today is absent from the grid", () => {
|
||||
const days = [day("2026-06-01", "MENSTRUAL")];
|
||||
const v = resolveCycleVerdict({ days, today: "2026-06-09" });
|
||||
expect(v.state).toBe("INSUFFICIENT_DATA");
|
||||
expect(v.dayOfCycle).toBeNull();
|
||||
});
|
||||
|
||||
it("counts the cycle day from the most recent menstrual start", () => {
|
||||
const days = [
|
||||
day("2026-05-30", "LUTEAL"),
|
||||
day("2026-06-01", "MENSTRUAL"),
|
||||
day("2026-06-02", "MENSTRUAL"),
|
||||
day("2026-06-03", "FOLLICULAR"),
|
||||
day("2026-06-04", "FOLLICULAR"),
|
||||
day("2026-06-05", "FOLLICULAR"),
|
||||
];
|
||||
const v = resolveCycleVerdict({ days, today: "2026-06-05" });
|
||||
expect(v.state).toBe("IN_CYCLE");
|
||||
expect(v.dayOfCycle).toBe(5);
|
||||
expect(v.phase).toBe("FOLLICULAR");
|
||||
expect(v.cycleStartDate).toBe("2026-06-01");
|
||||
});
|
||||
|
||||
it("builds observed phase arcs that sum to the whole ring", () => {
|
||||
const days = [
|
||||
day("2026-06-01", "MENSTRUAL"),
|
||||
day("2026-06-02", "MENSTRUAL"),
|
||||
day("2026-06-03", "MENSTRUAL"),
|
||||
day("2026-06-04", "FOLLICULAR"),
|
||||
day("2026-06-05", "FOLLICULAR"),
|
||||
day("2026-06-06", "OVULATORY"),
|
||||
day("2026-06-07", "LUTEAL"),
|
||||
day("2026-06-08", "LUTEAL"),
|
||||
];
|
||||
const v = resolveCycleVerdict({ days, today: "2026-06-05" });
|
||||
expect(v.spans.reduce((s, x) => s + x.fraction, 0)).toBeCloseTo(1, 5);
|
||||
expect(v.spans.map((s) => s.phase).sort()).toEqual([
|
||||
"FOLLICULAR",
|
||||
"LUTEAL",
|
||||
"MENSTRUAL",
|
||||
"OVULATORY",
|
||||
]);
|
||||
expect(v.cycleLength).toBe(8);
|
||||
});
|
||||
|
||||
it("draws the canonical four arcs for a low-data tracker", () => {
|
||||
// A two-day first-ever run: the observed-share math would let MENSTRUAL
|
||||
// fill nearly the whole circle, so the ring falls back to the profile's
|
||||
// canonical proportions while the day count stays observed.
|
||||
const days = [
|
||||
day("2026-06-01", "MENSTRUAL"),
|
||||
day("2026-06-02", "MENSTRUAL"),
|
||||
];
|
||||
const v = resolveCycleVerdict({
|
||||
days,
|
||||
today: "2026-06-02",
|
||||
profile: {
|
||||
typicalCycleLength: 28,
|
||||
typicalPeriodLength: 5,
|
||||
lutealPhaseLength: 14,
|
||||
},
|
||||
});
|
||||
expect(v.state).toBe("IN_CYCLE");
|
||||
expect(v.dayOfCycle).toBe(2);
|
||||
expect(v.spans).toHaveLength(4);
|
||||
expect(v.cycleLength).toBe(28);
|
||||
for (const span of v.spans) {
|
||||
expect(span.fraction).toBeGreaterThan(0);
|
||||
expect(span.fraction).toBeLessThan(0.95);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCycleVerdict — days until the next period", () => {
|
||||
const days = [
|
||||
day("2026-06-01", "MENSTRUAL"),
|
||||
day("2026-06-02", "MENSTRUAL"),
|
||||
day("2026-06-03", "FOLLICULAR"),
|
||||
];
|
||||
|
||||
it("counts forward to the predicted start", () => {
|
||||
const v = resolveCycleVerdict({
|
||||
days,
|
||||
today: "2026-06-03",
|
||||
nextPeriodStart: "2026-06-29",
|
||||
});
|
||||
expect(v.daysUntilNext).toBe(26);
|
||||
});
|
||||
|
||||
it("is null when no prediction ran", () => {
|
||||
expect(
|
||||
resolveCycleVerdict({ days, today: "2026-06-03" }).daysUntilNext,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("is null once the predicted start is already past", () => {
|
||||
// There is no "until" any more, and inventing a negative number would only
|
||||
// invite a client to render it.
|
||||
const v = resolveCycleVerdict({
|
||||
days,
|
||||
today: "2026-06-03",
|
||||
nextPeriodStart: "2026-06-01",
|
||||
});
|
||||
expect(v.daysUntilNext).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCycleVerdict — the fertile window", () => {
|
||||
it("reports the window it is given and whether today falls in it", () => {
|
||||
const days = [
|
||||
day("2026-06-10", "FOLLICULAR"),
|
||||
day("2026-06-11", "OVULATORY", true),
|
||||
];
|
||||
const v = resolveCycleVerdict({
|
||||
days,
|
||||
today: "2026-06-11",
|
||||
fertileWindowStart: "2026-06-08",
|
||||
fertileWindowEnd: "2026-06-13",
|
||||
});
|
||||
expect(v.fertileWindow).toEqual({
|
||||
start: "2026-06-08",
|
||||
end: "2026-06-13",
|
||||
active: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("stays empty when the caller suppressed the window", () => {
|
||||
// The route passes the ALREADY goal- and cold-start-gated values, so a
|
||||
// suppressed window arrives here as nulls and cannot reappear.
|
||||
const days = [day("2026-06-11", "OVULATORY", false)];
|
||||
const v = resolveCycleVerdict({ days, today: "2026-06-11" });
|
||||
expect(v.fertileWindow).toEqual({
|
||||
start: null,
|
||||
end: null,
|
||||
active: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -24,3 +24,11 @@ export {
|
||||
clampLuteal,
|
||||
} from "./prediction";
|
||||
export { phaseForDay, phaseSeries, type PhaseCycle } from "./phase";
|
||||
export {
|
||||
resolveCycleVerdict,
|
||||
type CycleVerdict,
|
||||
type CycleVerdictState,
|
||||
type CyclePhaseSpan,
|
||||
type CycleProfileLengths,
|
||||
type VerdictCalendarDay,
|
||||
} from "./verdict";
|
||||
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* The resolved cycle verdict — day-of-cycle, the ring's phase segments, the
|
||||
* overdue judgement and its day count, and the days until the next predicted
|
||||
* period. Computed here, on the server, once.
|
||||
*
|
||||
* This used to live in web client code (`src/components/cycle/wheel-state.ts`)
|
||||
* and every client that wanted the same answer had to reproduce it, including
|
||||
* the fourteen-day grace window that decides when the day count stops being
|
||||
* an observed fact. Two copies of that rule means two answers about one
|
||||
* person's body, and the second copy is how a cycle ring ends up frozen. The
|
||||
* rule now exists once and the answer travels on the wire.
|
||||
*
|
||||
* Pure and deterministic: it reads the calendar grid the same request already
|
||||
* built, plus the profile's typical lengths and the (already goal-gated)
|
||||
* prediction fields. No database, no clock — `today` is resolved by the caller
|
||||
* from the user's own timezone, which is also why a client cannot compute this
|
||||
* correctly on its own.
|
||||
*/
|
||||
import { dayDiff } from "./day-math";
|
||||
import type { CyclePhase } from "./types";
|
||||
|
||||
const PHASE_ORDER: CyclePhase[] = [
|
||||
"MENSTRUAL",
|
||||
"FOLLICULAR",
|
||||
"OVULATORY",
|
||||
"LUTEAL",
|
||||
];
|
||||
|
||||
/** One arc of the ring: a phase and its share of the represented cycle. */
|
||||
export interface CyclePhaseSpan {
|
||||
phase: CyclePhase;
|
||||
fraction: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the record supports saying about today.
|
||||
*
|
||||
* - `IN_CYCLE` — today sits inside a cycle whose day count is still an
|
||||
* observed fact. `dayOfCycle`, `phase` and `cycleStartDate` are all set.
|
||||
* - `OVERDUE` — the open cycle has run past the profile's typical length by
|
||||
* more than the grace window. `dayOfCycle` and `phase` are null on purpose
|
||||
* (see the ceiling below); `overdueDays` carries how late it is.
|
||||
* - `INSUFFICIENT_DATA` — today carries no phase at all: no cycle has been
|
||||
* logged, or today falls outside every known cycle window.
|
||||
*/
|
||||
export type CycleVerdictState = "IN_CYCLE" | "OVERDUE" | "INSUFFICIENT_DATA";
|
||||
|
||||
export interface CycleVerdict {
|
||||
state: CycleVerdictState;
|
||||
/** 1-based day of the current cycle. Null in OVERDUE and INSUFFICIENT_DATA. */
|
||||
dayOfCycle: number | null;
|
||||
/** Days the ring represents (observed run, or the idealized cycle). */
|
||||
cycleLength: number | null;
|
||||
/** Today's phase. Null in OVERDUE and INSUFFICIENT_DATA. */
|
||||
phase: CyclePhase | null;
|
||||
/** The ring's arcs, as fractions summing to ~1. Empty when there is nothing to draw. */
|
||||
spans: CyclePhaseSpan[];
|
||||
/**
|
||||
* First day of the current cycle (`YYYY-MM-DD`), or null when no cycle is
|
||||
* active. Still set while OVERDUE — the last logged period start remains a
|
||||
* fact even once the count stops. Scopes the BBT chart to the same cycle the
|
||||
* ring shows.
|
||||
*/
|
||||
cycleStartDate: string | null;
|
||||
/**
|
||||
* How many days past the profile's typical cycle length the open cycle has
|
||||
* run. Set only in OVERDUE. Measured against the typical length, not against
|
||||
* typical + grace: a person asking how late they are means "later than my
|
||||
* usual cycle", and the grace window is our tolerance, not their calendar.
|
||||
*/
|
||||
overdueDays: number | null;
|
||||
/**
|
||||
* Days from today to the predicted next period start. Null when there is no
|
||||
* prediction (raw-chart mode / prediction disabled) and null once that
|
||||
* predicted start is in the past — at that point there is no "until", and
|
||||
* `state` + `overdueDays` carry what is true instead.
|
||||
*/
|
||||
daysUntilNext: number | null;
|
||||
/** The fertile window as already goal-gated upstream, plus whether today falls in it. */
|
||||
fertileWindow: {
|
||||
start: string | null;
|
||||
end: string | null;
|
||||
active: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The profile-derived canonical phase lengths used to draw an idealized ring
|
||||
* for a low-data tracker (see `idealizedSpans`). All optional — every field
|
||||
* falls back to the textbook default when the profile has not set it.
|
||||
*/
|
||||
export interface CycleProfileLengths {
|
||||
/** Typical full cycle length in days (default 28). */
|
||||
typicalCycleLength?: number | null;
|
||||
/** Typical period (menstrual) length in days (default 5). */
|
||||
typicalPeriodLength?: number | null;
|
||||
/** Typical luteal-phase length in days (default 14). */
|
||||
lutealPhaseLength?: number | null;
|
||||
}
|
||||
|
||||
/** The calendar-grid fields the verdict reads. `CalendarDayDTO` satisfies it. */
|
||||
export interface VerdictCalendarDay {
|
||||
date: string;
|
||||
phase: CyclePhase | null;
|
||||
isFertileWindow: boolean;
|
||||
}
|
||||
|
||||
export interface ResolveCycleVerdictInput {
|
||||
/** The calendar grid for the rendered span, in any order. */
|
||||
days: readonly VerdictCalendarDay[];
|
||||
/** Today's `YYYY-MM-DD` in the USER's timezone, not the process's. */
|
||||
today: string;
|
||||
profile?: CycleProfileLengths;
|
||||
/** `prediction.nextPeriodStart`, or null when no prediction ran. */
|
||||
nextPeriodStart?: string | null;
|
||||
/** Already goal- and cold-start-gated fertile window bounds. */
|
||||
fertileWindowStart?: string | null;
|
||||
fertileWindowEnd?: string | null;
|
||||
}
|
||||
|
||||
/** Below this many labelled days in the current cycle run, the ring would
|
||||
* normalise a 1-3 day partial run so a single phase filled the whole circle.
|
||||
* For such low-data trackers we draw the canonical four-phase proportions from
|
||||
* the profile instead, so the dial always reads as a real cycle wheel. A run
|
||||
* of 7+ labelled days already spans more than the menstrual phase, so the
|
||||
* observed-share path takes over from there. */
|
||||
const SPARSE_RUN_THRESHOLD = 7;
|
||||
|
||||
/**
|
||||
* Days PAST the profile's typical cycle length before the count stops.
|
||||
* The engine keeps the open cycle's phase window alive until the predicted
|
||||
* next start, and a confirmed/anchored ovulation signal (BBT trend,
|
||||
* symptothermal double-check, LH surge) or a long estimated length can push
|
||||
* that prediction into the future while the last logged period is months old —
|
||||
* every day of the gap then carries a (population-framed) LUTEAL label and the
|
||||
* day-of-cycle walks up without bound ("day 90"). Beyond typical + grace the
|
||||
* count is no longer an observed fact, so the verdict degrades to the honest
|
||||
* overdue state instead. Two weeks of grace covers ordinary cycle-to-cycle
|
||||
* variation without hiding a real delay.
|
||||
*
|
||||
* This constant is deliberately NOT published on the wire. A client that knows
|
||||
* the threshold will eventually apply it itself, and then there are two
|
||||
* deciders again. Clients read `state` and `overdueDays`.
|
||||
*/
|
||||
const OVERDUE_GRACE_DAYS = 14;
|
||||
|
||||
/** The profile's typical cycle length, floored at a week. */
|
||||
function typicalCycleLength(profile?: CycleProfileLengths): number {
|
||||
return Math.max(Math.round(profile?.typicalCycleLength ?? 28) || 28, 7);
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical four-phase span set for a low-data tracker, derived from the
|
||||
* profile's typical lengths (textbook defaults when unset): a 28-day cycle
|
||||
* with a 5-day period, a 2-day ovulatory window, a 14-day luteal phase, and
|
||||
* the follicular phase filling the remainder. Proportions, not day counts, so
|
||||
* the ring renders the familiar MENSTRUAL/FOLLICULAR/OVULATORY/LUTEAL arcs
|
||||
* instead of one dominant sliver.
|
||||
*/
|
||||
function idealizedSpans(profile?: CycleProfileLengths): {
|
||||
spans: CyclePhaseSpan[];
|
||||
cycleLength: number;
|
||||
} {
|
||||
const cycle = typicalCycleLength(profile);
|
||||
const period = Math.min(
|
||||
Math.max(Math.round(profile?.typicalPeriodLength ?? 5) || 5, 1),
|
||||
cycle - 3,
|
||||
);
|
||||
const luteal = Math.min(
|
||||
Math.max(Math.round(profile?.lutealPhaseLength ?? 14) || 14, 1),
|
||||
cycle - period - 1,
|
||||
);
|
||||
// A short ovulatory window sits at the fertile peak; the follicular phase
|
||||
// fills whatever remains between the period and the ovulatory window.
|
||||
const ovulatory = Math.min(2, Math.max(cycle - period - luteal - 1, 1));
|
||||
const follicular = Math.max(cycle - period - ovulatory - luteal, 1);
|
||||
|
||||
const days: Record<CyclePhase, number> = {
|
||||
MENSTRUAL: period,
|
||||
FOLLICULAR: follicular,
|
||||
OVULATORY: ovulatory,
|
||||
LUTEAL: luteal,
|
||||
};
|
||||
const total = days.MENSTRUAL + days.FOLLICULAR + days.OVULATORY + days.LUTEAL;
|
||||
const spans = PHASE_ORDER.map((phase) => ({
|
||||
phase,
|
||||
fraction: days[phase] / total,
|
||||
}));
|
||||
return { spans, cycleLength: total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk back from `todayIdx` to the start of the current MENSTRUAL-anchored run:
|
||||
* the first day of the most recent MENSTRUAL block at/before today with no phase
|
||||
* gap. Assumes `sorted[todayIdx].phase != null` (the caller checks first).
|
||||
*/
|
||||
function findCycleStartIndex(
|
||||
sorted: readonly VerdictCalendarDay[],
|
||||
todayIdx: number,
|
||||
): number {
|
||||
let startIdx = todayIdx;
|
||||
for (let i = todayIdx; i >= 0; i--) {
|
||||
const d = sorted[i];
|
||||
if (d.phase == null) break;
|
||||
startIdx = i;
|
||||
if (
|
||||
d.phase === "MENSTRUAL" &&
|
||||
(i === 0 || sorted[i - 1].phase !== "MENSTRUAL")
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return startIdx;
|
||||
}
|
||||
|
||||
/** Days from today to the next predicted start, or null once it is past. */
|
||||
function resolveDaysUntilNext(
|
||||
today: string,
|
||||
nextPeriodStart: string | null | undefined,
|
||||
): number | null {
|
||||
if (!nextPeriodStart) return null;
|
||||
const delta = dayDiff(nextPeriodStart, today);
|
||||
return delta >= 0 ? delta : null;
|
||||
}
|
||||
|
||||
function resolveFertileWindow(
|
||||
input: ResolveCycleVerdictInput,
|
||||
todayDay: VerdictCalendarDay | undefined,
|
||||
): CycleVerdict["fertileWindow"] {
|
||||
return {
|
||||
start: input.fertileWindowStart ?? null,
|
||||
end: input.fertileWindowEnd ?? null,
|
||||
active: todayDay?.isFertileWindow ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve everything a client needs to render the cycle ring and its caption.
|
||||
* The only place in the product that decides any of it.
|
||||
*/
|
||||
export function resolveCycleVerdict(
|
||||
input: ResolveCycleVerdictInput,
|
||||
): CycleVerdict {
|
||||
const { days, today, profile } = input;
|
||||
const byDate = new Map(days.map((d) => [d.date, d]));
|
||||
const todayDay = byDate.get(today);
|
||||
const fertileWindow = resolveFertileWindow(input, todayDay);
|
||||
const daysUntilNext = resolveDaysUntilNext(today, input.nextPeriodStart);
|
||||
|
||||
const nothingToSay: CycleVerdict = {
|
||||
state: "INSUFFICIENT_DATA",
|
||||
dayOfCycle: null,
|
||||
cycleLength: null,
|
||||
phase: null,
|
||||
spans: [],
|
||||
cycleStartDate: null,
|
||||
overdueDays: null,
|
||||
daysUntilNext,
|
||||
fertileWindow,
|
||||
};
|
||||
|
||||
if (!todayDay || todayDay.phase == null) return nothingToSay;
|
||||
|
||||
// Sort dates ascending and find today's index.
|
||||
const sorted = [...days].sort((a, b) => a.date.localeCompare(b.date));
|
||||
const todayIdx = sorted.findIndex((d) => d.date === today);
|
||||
if (todayIdx < 0) return nothingToSay;
|
||||
|
||||
const startIdx = findCycleStartIndex(sorted, todayIdx);
|
||||
const cycleStartDate = sorted[startIdx].date;
|
||||
const run = sorted.slice(startIdx, todayIdx + 1);
|
||||
const dayOfCycle = run.length;
|
||||
|
||||
// Honest ceiling: once the run overshoots the typical length + grace, the
|
||||
// count reflects the engine's open-ended cycle window, not an observed
|
||||
// cycle day. Degrade to the idealized ring with no day / phase claim; the
|
||||
// caption says the period is late and by how much.
|
||||
const typicalLength = typicalCycleLength(profile);
|
||||
if (dayOfCycle > typicalLength + OVERDUE_GRACE_DAYS) {
|
||||
const ideal = idealizedSpans(profile);
|
||||
return {
|
||||
state: "OVERDUE",
|
||||
dayOfCycle: null,
|
||||
cycleLength: ideal.cycleLength,
|
||||
phase: null,
|
||||
spans: ideal.spans,
|
||||
cycleStartDate,
|
||||
overdueDays: dayOfCycle - typicalLength,
|
||||
daysUntilNext,
|
||||
fertileWindow,
|
||||
};
|
||||
}
|
||||
|
||||
// Tally each phase's day-count across the run + the forward predicted run
|
||||
// (so the ring shows the whole cycle, not just elapsed days).
|
||||
const counts: Record<CyclePhase, number> = {
|
||||
MENSTRUAL: 0,
|
||||
FOLLICULAR: 0,
|
||||
OVULATORY: 0,
|
||||
LUTEAL: 0,
|
||||
};
|
||||
// Include forward days until the next MENSTRUAL block to bound a full cycle.
|
||||
let end = todayIdx;
|
||||
for (let i = todayIdx + 1; i < sorted.length; i++) {
|
||||
const d = sorted[i];
|
||||
if (d.phase == null) break;
|
||||
if (d.phase === "MENSTRUAL") break;
|
||||
end = i;
|
||||
}
|
||||
const fullRun = sorted.slice(startIdx, end + 1);
|
||||
for (const d of fullRun) {
|
||||
if (d.phase) counts[d.phase] += 1;
|
||||
}
|
||||
|
||||
// Low-data tracker: too few labelled days to span a real cycle. The
|
||||
// observed-share math would normalise a 1-3 day partial run so a single
|
||||
// phase filled the whole ring (the "one dominant arc" bug). Fall back to the
|
||||
// canonical four-phase proportions derived from the profile's typical
|
||||
// lengths so the dial reads as a real cycle wheel. The current phase + the
|
||||
// day-of-cycle marker stay driven by the observed labels; only the arc
|
||||
// proportions and the ring's represented length come from the ideal.
|
||||
if (fullRun.length < SPARSE_RUN_THRESHOLD) {
|
||||
const ideal = idealizedSpans(profile);
|
||||
return {
|
||||
state: "IN_CYCLE",
|
||||
dayOfCycle,
|
||||
cycleLength: ideal.cycleLength,
|
||||
phase: todayDay.phase,
|
||||
spans: ideal.spans,
|
||||
cycleStartDate,
|
||||
overdueDays: null,
|
||||
daysUntilNext,
|
||||
fertileWindow,
|
||||
};
|
||||
}
|
||||
|
||||
const total = fullRun.length || 1;
|
||||
const spans = PHASE_ORDER.filter((p) => counts[p] > 0).map((phase) => ({
|
||||
phase,
|
||||
fraction: counts[phase] / total,
|
||||
}));
|
||||
|
||||
return {
|
||||
state: "IN_CYCLE",
|
||||
dayOfCycle,
|
||||
cycleLength: fullRun.length,
|
||||
phase: todayDay.phase,
|
||||
spans,
|
||||
cycleStartDate,
|
||||
overdueDays: null,
|
||||
daysUntilNext,
|
||||
fertileWindow,
|
||||
};
|
||||
}
|
||||
@@ -406,9 +406,12 @@ function coerceChartOverlayPrefsMap(value: unknown): ChartOverlayPrefsMap {
|
||||
*
|
||||
* - `READINESS` / `RECOVERY_SCORE` / `SLEEP_SCORE` resolve through the
|
||||
* derived registry's engines (module-gated like the derived routes);
|
||||
* - `MED_COMPLIANCE` is the pooled 7-day medication adherence from the
|
||||
* canonical compliance engine — the ring that absorbs the retired
|
||||
* hero dose row's information role.
|
||||
* - `MED_COMPLIANCE` is TODAY's dose progress (taken of scheduled) from
|
||||
* the snapshot's own `medsToday` block — the ring that absorbs the
|
||||
* retired hero dose row's information role. The v1.27.7 first cut put
|
||||
* the pooled 7-day adherence percentage here; it has been today's
|
||||
* progress since v1.27.8. Resolution lives in
|
||||
* `@/lib/dashboard/score-rings`.
|
||||
*
|
||||
* The preference piggy-backs on the layout blob like `chartOverlayPrefs`
|
||||
* (a UI affordance, no Prisma migration). The resolver drops unknown ids,
|
||||
|
||||
@@ -759,7 +759,6 @@ async function buildExtras(
|
||||
now,
|
||||
profile: {
|
||||
dateOfBirth: user.dateOfBirth,
|
||||
gender: user.gender,
|
||||
heightCm: user.heightCm,
|
||||
timezone: userTz,
|
||||
sourcePriorityJson: scoreSourcePriority,
|
||||
|
||||
+27
-20
@@ -329,6 +329,31 @@ export async function defaultUserIdResolver(): Promise<string | null> {
|
||||
*
|
||||
* No-op when the header is missing or the value is malformed.
|
||||
*/
|
||||
/**
|
||||
* The 409 both in-flight arms return.
|
||||
*
|
||||
* `error` is a STRING, matching the envelope `@/lib/api-response` defines
|
||||
* for every other route (`{ data: null, error: <prose>, meta? }`). Until
|
||||
* v1.35.1 these two literals sent `error` as `{ message }` — the only
|
||||
* object-shaped error the app ever emitted, inherited by every route under
|
||||
* `withIdempotency`, and never what the published contract promised. A
|
||||
* client decoding the documented envelope now gets what it was generated
|
||||
* against.
|
||||
*
|
||||
* Built once rather than twice so the two arms cannot drift apart again.
|
||||
* The response is hand-built rather than routed through `apiError` because
|
||||
* the 409 carries the `X-Idempotent-Replay` header.
|
||||
*/
|
||||
function inflightConflictResponse(): Response {
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: null,
|
||||
error: "A request with this Idempotency-Key is already in progress",
|
||||
},
|
||||
{ status: 409, headers: { "X-Idempotent-Replay": "false" } },
|
||||
);
|
||||
}
|
||||
|
||||
export function withIdempotency<
|
||||
Args extends [Request | NextRequest, ...unknown[]],
|
||||
>(
|
||||
@@ -368,16 +393,7 @@ export function withIdempotency<
|
||||
action: { name: "idempotency.inflight_conflict" },
|
||||
meta: { method: ctx.method, path: ctx.path },
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: null,
|
||||
error: {
|
||||
message:
|
||||
"A request with this Idempotency-Key is already in progress",
|
||||
},
|
||||
},
|
||||
{ status: 409, headers: { "X-Idempotent-Replay": "false" } },
|
||||
);
|
||||
return inflightConflictResponse();
|
||||
}
|
||||
|
||||
// Claim the key before running the handler. If a racing request beats
|
||||
@@ -389,16 +405,7 @@ export function withIdempotency<
|
||||
action: { name: "idempotency.inflight_conflict" },
|
||||
meta: { method: ctx.method, path: ctx.path },
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: null,
|
||||
error: {
|
||||
message:
|
||||
"A request with this Idempotency-Key is already in progress",
|
||||
},
|
||||
},
|
||||
{ status: 409, headers: { "X-Idempotent-Replay": "false" } },
|
||||
);
|
||||
return inflightConflictResponse();
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
|
||||
@@ -129,7 +129,7 @@ function compositionFor(selection: readonly ScorePillarId[]): ScorePillarId[] {
|
||||
}
|
||||
|
||||
describe("Health Score coverage contract", () => {
|
||||
it("does not measure a four-pillar account against the eight-pillar catalogue", () => {
|
||||
it("does not measure a four-pillar account against the whole catalogue", () => {
|
||||
const selection: ScorePillarId[] = [
|
||||
"BLOOD_PRESSURE",
|
||||
"ACTIVITY",
|
||||
@@ -148,8 +148,9 @@ describe("Health Score coverage contract", () => {
|
||||
});
|
||||
|
||||
expect(composite.status).toBe("ok");
|
||||
// The catalogue is eight wide and must not be the yardstick.
|
||||
expect(SCORE_PILLAR_IDS.length).toBe(8);
|
||||
// The catalogue is wider than the selection and must not be the
|
||||
// yardstick.
|
||||
expect(SCORE_PILLAR_IDS.length).toBeGreaterThan(selection.length);
|
||||
expect(composite.coverage.requiredInputs).not.toBe(SCORE_PILLAR_IDS.length);
|
||||
// The denominator is the breadth rule applied to the person's own set,
|
||||
// so it can never exceed what they chose to count.
|
||||
@@ -162,7 +163,7 @@ describe("Health Score coverage contract", () => {
|
||||
);
|
||||
if (composite.status !== "ok") throw new Error("unreachable");
|
||||
// Fully covered means fully covered: four of four counted pillars have
|
||||
// data, so nothing about the four they left out may pull this down.
|
||||
// data, so nothing about the ones they left out may pull this down.
|
||||
expect(composite.confidence.score).toBe(100);
|
||||
expect(composite.value.composition).toEqual(selection);
|
||||
});
|
||||
@@ -187,7 +188,8 @@ describe("Health Score coverage contract", () => {
|
||||
|
||||
it("still asks for data from a pillar the person kept", () => {
|
||||
// The same four pillars have data; the difference is that this account
|
||||
// counts all eight, so the four blanks are genuinely missing coverage.
|
||||
// counts the whole catalogue, so the blanks are genuinely missing
|
||||
// coverage.
|
||||
const withData: ScorePillarId[] = [
|
||||
"BLOOD_PRESSURE",
|
||||
"ACTIVITY",
|
||||
@@ -202,8 +204,12 @@ describe("Health Score coverage contract", () => {
|
||||
asOf: NOW,
|
||||
});
|
||||
|
||||
expect(composite.coverage.missing).toContain("wellbeing");
|
||||
expect(composite.coverage.missing).toContain("fitness");
|
||||
// Named by DOMAIN, not by pillar: glycaemia and lipids are blank too,
|
||||
// but blood pressure already covers the area they sit in, so wellbeing
|
||||
// is the one area with nothing behind it. The previous case's `missing`
|
||||
// was empty, which is the contrast that makes this assertion mean
|
||||
// something.
|
||||
expect(composite.coverage.missing).toEqual(["wellbeing"]);
|
||||
});
|
||||
|
||||
it("keeps the registry's HEALTH_SCORE inputs equal to the score catalogue", () => {
|
||||
|
||||
@@ -342,7 +342,7 @@ const REGISTRY: Record<DerivedMetricId, DerivedMetricMeta> = {
|
||||
id: "HEALTH_SCORE",
|
||||
displayName: "Cardiometabolic reference score",
|
||||
archetype: "composite",
|
||||
// The eight ids below are the CATALOGUE of scorable pillars, and since
|
||||
// The seven ids below are the CATALOGUE of scorable pillars, and since
|
||||
// v1.35.0 they are not the recipe. What counts toward an account's
|
||||
// composite is the resolved per-user composition
|
||||
// (`resolveHealthScoreConfig`) intersected with the modules that record
|
||||
@@ -356,7 +356,6 @@ const REGISTRY: Record<DerivedMetricId, DerivedMetricMeta> = {
|
||||
"SLEEP",
|
||||
"ADIPOSITY",
|
||||
"WELLBEING",
|
||||
"FITNESS",
|
||||
"LIPIDS",
|
||||
],
|
||||
minHistoryDays: 1,
|
||||
@@ -364,10 +363,10 @@ const REGISTRY: Record<DerivedMetricId, DerivedMetricMeta> = {
|
||||
// confidence this metric publishes are built in `computeComposite`
|
||||
// (`src/lib/analytics/score/composite.ts`) over the resolved
|
||||
// composition, never from this entry, which is why a person who counts
|
||||
// four pillars is not measured against a denominator of eight. The real
|
||||
// four pillars is not measured against a denominator of seven. The real
|
||||
// floor is the breadth rule in `src/lib/analytics/score/breadth.ts`: at
|
||||
// least three distinct DOMAINS including one physiological pillar. Three
|
||||
// of the eight ids above share the cardiometabolic domain, so "three
|
||||
// of the seven ids above share the cardiometabolic domain, so "three
|
||||
// inputs" and "enough breadth for a score" are different questions and
|
||||
// this number answers neither. The field stays because every registry
|
||||
// entry carries it; reading it as a threshold would be wrong.
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* The `type` query parameter on `GET /api/insights/derived` sub-targets the
|
||||
* single measurement type a baseline metric works over. Two engines define
|
||||
* one: `VITALS_BASELINE` over a vital, `SAME_TIME_BASELINE` over a cumulative
|
||||
* day metric. For most of a year the published enum carried the vitals alone,
|
||||
* so a spec-conformant client could not send the four same-time types at all
|
||||
* (iOS #75).
|
||||
*
|
||||
* This guard asserts the published parameter enumerates exactly the union of
|
||||
* the two registry sets, read out of the assembled OpenAPI document rather
|
||||
* than out of the schema module — the same object the generator writes to
|
||||
* `docs/api/openapi.yaml`, so a value that reaches the registry but not the
|
||||
* contract fails here.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildOpenApiDocument } from "../registry";
|
||||
import {
|
||||
VITALS_BASELINE_TYPES,
|
||||
SAME_TIME_BASELINE_TYPES,
|
||||
} from "@/lib/insights/derived/registry";
|
||||
|
||||
/** Pull the `type` query parameter's enum out of the assembled document. */
|
||||
function publishedTypeEnum(): string[] {
|
||||
const doc = buildOpenApiDocument() as {
|
||||
paths?: Record<
|
||||
string,
|
||||
{ get?: { parameters?: unknown[]; requestParams?: unknown } }
|
||||
>;
|
||||
};
|
||||
const operation = doc.paths?.["/api/insights/derived"]?.get;
|
||||
expect(
|
||||
operation,
|
||||
"GET /api/insights/derived is missing from the document",
|
||||
).toBeDefined();
|
||||
|
||||
const parameters = (operation?.parameters ?? []) as Array<{
|
||||
name?: string;
|
||||
in?: string;
|
||||
schema?: { enum?: string[] };
|
||||
}>;
|
||||
const typeParam = parameters.find(
|
||||
(p) => p.name === "type" && p.in === "query",
|
||||
);
|
||||
expect(typeParam, "the `type` query parameter is missing").toBeDefined();
|
||||
|
||||
const values = typeParam?.schema?.enum;
|
||||
expect(Array.isArray(values), "`type` is not published as an enum").toBe(
|
||||
true,
|
||||
);
|
||||
return values as string[];
|
||||
}
|
||||
|
||||
describe("derived `type` parameter stays in sync with the registry", () => {
|
||||
it("publishes exactly the union of the two baseline type sets", () => {
|
||||
const expected = [
|
||||
...VITALS_BASELINE_TYPES,
|
||||
...SAME_TIME_BASELINE_TYPES,
|
||||
].map(String);
|
||||
|
||||
expect([...publishedTypeEnum()].sort()).toEqual([...expected].sort());
|
||||
});
|
||||
|
||||
it("publishes every same-time baseline type", () => {
|
||||
// Named separately: this is the half that was missing, and a regression
|
||||
// here is the one a client feels immediately (a 422-shaped dead end on
|
||||
// a value the server accepts).
|
||||
const published = publishedTypeEnum();
|
||||
for (const type of SAME_TIME_BASELINE_TYPES) {
|
||||
expect(published).toContain(String(type));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -230,6 +230,40 @@ const webauthnCredentialInfo = z
|
||||
})
|
||||
.meta({ id: "WebauthnMfaCredentialInfo" });
|
||||
|
||||
// ── Passkey list (primary sign-in credentials) ───────────────────────
|
||||
// A DIFFERENT surface from `WebauthnMfaCredentialInfo` above: these are
|
||||
// the credentials a passkey LOGIN asserts against, not the security keys
|
||||
// registered as a second factor. The two lists are served by different
|
||||
// routes and are never merged.
|
||||
|
||||
const passkeyInfo = z
|
||||
.object({
|
||||
id: z.string().describe("Row id; the handle for DELETE."),
|
||||
name: z.string().describe("User-supplied label, defaulting to “Passkey”."),
|
||||
credentialDeviceType: z
|
||||
.string()
|
||||
.describe(
|
||||
"Authenticator device type as reported at registration: `singleDevice` for a credential bound to one authenticator, `multiDevice` for a syncable one.",
|
||||
),
|
||||
credentialBackedUp: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"Whether the authenticator reported the credential as backed up (synced to the platform keychain) at registration time. Not re-read afterwards.",
|
||||
),
|
||||
createdAt: z.iso.datetime({ offset: true }).describe("Registered at."),
|
||||
lastUsedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.nullable()
|
||||
.describe(
|
||||
"When this passkey last completed a verified assertion; null when it has never been used to sign in. Stamped on every successful login, so a client can render a real 'last used' rather than assuming never.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "PasskeyInfo" });
|
||||
|
||||
const passkeyListResponse = z
|
||||
.array(passkeyInfo)
|
||||
.meta({ id: "PasskeyListResponse" });
|
||||
|
||||
const mfaStatusResponse = z
|
||||
.object({
|
||||
totp: z.object({ enabled: z.boolean() }),
|
||||
@@ -922,6 +956,25 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/auth/passkeys": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
summary: "List the caller's registered passkeys",
|
||||
description:
|
||||
"The passkeys registered for primary sign-in, newest first. Distinct from the second-factor security keys in `GET /api/auth/2fa/status` (`webauthn`) — a passkey replaces the password, a security key is asserted after one. `lastUsedAt` is stamped on every verified assertion and is null only for a passkey that has never signed in.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The caller's passkeys, newest first.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(passkeyListResponse, "PasskeyListEnvelope"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/auth/me/sessions": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
stdResponses,
|
||||
baseUpdatedAtField,
|
||||
conflictResponse409,
|
||||
invalidBaseTokenResponse,
|
||||
} from "./shared";
|
||||
|
||||
// ── Coach cadence suggestions (v1.18.1) ──────────────────────────────
|
||||
@@ -1011,8 +1012,10 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
...conflictResponse409("Insights layout", "insights_layout_conflict"),
|
||||
// 422 (multi-issue validation envelope) comes from stdResponses.
|
||||
// 422 (multi-issue validation envelope) comes from stdResponses;
|
||||
// the base-token variant of it narrows that description afterwards.
|
||||
...stdResponses,
|
||||
...invalidBaseTokenResponse,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
@@ -1233,6 +1236,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
...conflictResponse409("Self-context", "about_me_conflict"),
|
||||
...stdResponses,
|
||||
...invalidBaseTokenResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -191,6 +191,45 @@ const cycleCalendarDayDto = z.object({
|
||||
cervixOpening: cervixOpeningEnum.nullable(),
|
||||
});
|
||||
|
||||
const cycleVerdictDto = z
|
||||
.object({
|
||||
state: z.enum(["IN_CYCLE", "OVERDUE", "INSUFFICIENT_DATA"]),
|
||||
dayOfCycle: z.number().int().nullable().meta({
|
||||
description:
|
||||
"1-based day of the current cycle. Null in OVERDUE and INSUFFICIENT_DATA — beyond the typical length plus the server's grace window the count is no longer an observed fact.",
|
||||
}),
|
||||
cycleLength: z.number().int().nullable().meta({
|
||||
description:
|
||||
"Days the ring represents: the observed labelled run, or the profile-derived idealized cycle for a low-data tracker.",
|
||||
}),
|
||||
phase: cyclePhaseEnumOpenapi.nullable(),
|
||||
spans: z.array(
|
||||
z.object({ phase: cyclePhaseEnumOpenapi, fraction: z.number() }),
|
||||
),
|
||||
cycleStartDate: z.string().nullable().meta({
|
||||
description:
|
||||
"First day of the current cycle (YYYY-MM-DD). Still set while OVERDUE — the last logged period start remains a fact once the count stops.",
|
||||
}),
|
||||
overdueDays: z.number().int().nullable().meta({
|
||||
description:
|
||||
"How many days past the profile's typical cycle length the open cycle has run. Set only in OVERDUE.",
|
||||
}),
|
||||
daysUntilNext: z.number().int().nullable().meta({
|
||||
description:
|
||||
"Days from today to the predicted next period start. Null when no prediction ran and null once that start is in the past.",
|
||||
}),
|
||||
fertileWindow: z.object({
|
||||
start: z.string().nullable(),
|
||||
end: z.string().nullable(),
|
||||
active: z.boolean(),
|
||||
}),
|
||||
})
|
||||
.meta({
|
||||
id: "CycleVerdictDTO",
|
||||
description:
|
||||
"The resolved cycle verdict for the user's own timezone day. Render these values; do not recompute them. The grace window that decides OVERDUE is deliberately not published — read `state` and `overdueDays`.",
|
||||
});
|
||||
|
||||
const cycleProfileDto = z
|
||||
.object({
|
||||
goal: cycleTrackingGoalEnum,
|
||||
@@ -218,6 +257,10 @@ const cycleCalendarResponse = z.object({
|
||||
cyclesObserved: z.number().int(),
|
||||
}),
|
||||
prediction: cyclePredictionDto.nullable(),
|
||||
// The resolved verdict — required, never optional. An optional verdict is a
|
||||
// plausible default that invites a client to fall back to deriving its own,
|
||||
// which is the defect this field exists to remove.
|
||||
verdict: cycleVerdictDto,
|
||||
// Cold-start gate (mirrors `prediction.stillLearning`): true while < 3 cycles
|
||||
// are observed. When set, the `days` grid carries no fertile window, no
|
||||
// predicted-ovulation dot, and no phase band (those would rest on a
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
stdResponses,
|
||||
baseUpdatedAtField,
|
||||
conflictResponse409,
|
||||
invalidBaseTokenResponse,
|
||||
} from "./shared";
|
||||
|
||||
const dashboardWidgetConfig = z.object({
|
||||
@@ -143,6 +144,7 @@ export const dashboardWidgetPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
...conflictResponse409("Dashboard layout", "dashboard_layout_conflict"),
|
||||
...stdResponses,
|
||||
...invalidBaseTokenResponse,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
|
||||
@@ -36,12 +36,6 @@ const shareLinkSummary = z.object({
|
||||
// link was revoked on upgrade; the sharing list surfaces this so the owner
|
||||
// knows to re-mint rather than wondering why a working link stopped.
|
||||
needsReselection: z.boolean(),
|
||||
// RETIRED, response-only, serving the constants `false` and `[]`. They
|
||||
// configured a FHIR face that was never built; the request refuses them and
|
||||
// their columns are gone. They stay on the response until the native client
|
||||
// confirms it does not decode them.
|
||||
allowFhirApi: z.boolean(),
|
||||
resourceTypes: z.array(z.string()),
|
||||
expiresAt: z.string(),
|
||||
createdAt: z.string(),
|
||||
revokedAt: z.string().nullable(),
|
||||
|
||||
@@ -14,6 +14,7 @@ import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry";
|
||||
import {
|
||||
DERIVED_METRIC_IDS,
|
||||
VITALS_BASELINE_TYPES,
|
||||
SAME_TIME_BASELINE_TYPES,
|
||||
} from "@/lib/insights/derived/registry";
|
||||
import { ANALYTICS_RANGES } from "@/lib/analytics/range-delta";
|
||||
|
||||
@@ -108,8 +109,26 @@ export const metricStatusResponse = z
|
||||
|
||||
// v1.10.0 — generic derived-wellness-metric route. The query enum is
|
||||
// derived from the same registry the route validates against, so spec +
|
||||
// route + cache scope cannot drift. `type` sub-targets the single vital
|
||||
// a baseline metric (VITALS_BASELINE) bands over.
|
||||
// route + cache scope cannot drift. `type` sub-targets the single
|
||||
// measurement type a baseline metric works over.
|
||||
|
||||
/**
|
||||
* Every value the `type` query parameter accepts, assembled from the two
|
||||
* registry sets that define one: `VITALS_BASELINE` bands a single vital,
|
||||
* `SAME_TIME_BASELINE` compares one cumulative day metric against the same
|
||||
* hour of a typical day. Published as one enum because `type` is a single
|
||||
* parameter shared by both metrics — a client picks the subset that belongs
|
||||
* to the `metric` it names.
|
||||
*
|
||||
* Built from the registry rather than restated here, so a type added to
|
||||
* either engine reaches the published contract in the same commit.
|
||||
* `derived-type-enum-sync.test.ts` pins the equality.
|
||||
*/
|
||||
export const DERIVED_TYPE_PARAM_VALUES: string[] = [
|
||||
...VITALS_BASELINE_TYPES,
|
||||
...SAME_TIME_BASELINE_TYPES,
|
||||
];
|
||||
|
||||
export const derivedMetricQuery = z
|
||||
.object({
|
||||
metric: z
|
||||
@@ -118,10 +137,10 @@ export const derivedMetricQuery = z
|
||||
"Derived-metric id to compute (e.g. VITALS_BASELINE, FITNESS_AGE, VASCULAR_AGE_DELTA, HRV_BALANCE, BMI, READINESS). Closed enum: an unknown id 422s. Metrics whose compute has not yet landed return an `insufficient` value with reason `not_implemented`.",
|
||||
),
|
||||
type: z
|
||||
.enum(VITALS_BASELINE_TYPES as [string, ...string[]])
|
||||
.enum(DERIVED_TYPE_PARAM_VALUES as [string, ...string[]])
|
||||
.optional()
|
||||
.describe(
|
||||
"For VITALS_BASELINE only — the single vital to band (defaults to RESTING_HEART_RATE). Ignored by composites. An unsupported value yields an `insufficient` value rather than a 422 so iOS metric combinations stay forgiving.",
|
||||
"The single measurement type a baseline metric works over. `VITALS_BASELINE` takes one of the eleven vitals (`RESTING_HEART_RATE` through `WEIGHT`; defaults to `RESTING_HEART_RATE`). `SAME_TIME_BASELINE` takes one of the four cumulative day metrics — `ACTIVITY_STEPS`, `ACTIVE_ENERGY_BURNED`, `WALKING_RUNNING_DISTANCE`, `FLIGHTS_CLIMBED`. Ignored by the composite metrics. A type the named metric does not support yields an `insufficient` value rather than a 422, so client metric combinations stay forgiving.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "DerivedMetricQuery" });
|
||||
@@ -253,7 +272,7 @@ export const derivedBatchQuery = z
|
||||
.min(1)
|
||||
.max(1024)
|
||||
.describe(
|
||||
"Comma-separated derived-metric tokens. Each is a `<DERIVED_METRIC_ID>` or `<DERIVED_METRIC_ID>:<MeasurementType>` (the colon sub-targets a VITALS_BASELINE vital). An unknown id 422s; a `type` outside the MeasurementType enum 422s; at most 24 tokens; duplicates collapse.",
|
||||
"Comma-separated derived-metric tokens. Each is a `<DERIVED_METRIC_ID>` or `<DERIVED_METRIC_ID>:<MeasurementType>` (the colon sub-targets the single measurement type a baseline metric works over — a vital for `VITALS_BASELINE`, a cumulative day metric for `SAME_TIME_BASELINE`; see the `type` parameter on `GET /api/insights/derived`). An unknown id 422s; a `type` outside the MeasurementType enum 422s; at most 24 tokens; duplicates collapse.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "DerivedBatchQuery" });
|
||||
@@ -1104,7 +1123,6 @@ export const dashboardSnapshotResponse = z
|
||||
"SLEEP",
|
||||
"ADIPOSITY",
|
||||
"WELLBEING",
|
||||
"FITNESS",
|
||||
"LIPIDS",
|
||||
]),
|
||||
)
|
||||
@@ -1136,7 +1154,6 @@ export const dashboardSnapshotResponse = z
|
||||
"SLEEP",
|
||||
"ADIPOSITY",
|
||||
"WELLBEING",
|
||||
"FITNESS",
|
||||
"LIPIDS",
|
||||
])
|
||||
.nullable()
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
errorEnvelope,
|
||||
stdResponses,
|
||||
conflictResponse409,
|
||||
invalidBaseTokenResponse,
|
||||
} from "../shared";
|
||||
|
||||
efficacyTargetOverrideSchema.meta({
|
||||
@@ -289,6 +290,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"medication_layout_conflict",
|
||||
),
|
||||
...stdResponses,
|
||||
...invalidBaseTokenResponse,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
baseUpdatedAtField,
|
||||
updatedAtTokenField,
|
||||
conflictResponse409,
|
||||
invalidBaseTokenResponse,
|
||||
} from "./shared";
|
||||
|
||||
// ── Request schemas — annotated for spec emission ────────────────────
|
||||
@@ -566,6 +567,7 @@ export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
...conflictResponse409("Mood-tag layout", "mood_tag_layout_conflict"),
|
||||
...stdResponses,
|
||||
...invalidBaseTokenResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ import { notificationPrefsSchema } from "@/lib/validations/notification-prefs";
|
||||
import { sourcePrioritySchema } from "@/lib/validations/source-priority";
|
||||
import { modulePrefsPatchSchema } from "@/lib/validations/modules";
|
||||
import { MODULE_KEYS } from "@/lib/modules/registry";
|
||||
import { SCORE_PILLAR_IDS } from "@/lib/analytics/score/types";
|
||||
import { savedReportProfileSchema } from "@/lib/report-selection/profile-shape";
|
||||
import {
|
||||
dataEnvelope,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
baseUpdatedAtField,
|
||||
updatedAtTokenField,
|
||||
conflictResponse409,
|
||||
invalidBaseTokenResponse,
|
||||
} from "./shared";
|
||||
|
||||
// v1.18.0 — module enable/disable. The PATCH request is the REAL runtime
|
||||
@@ -200,6 +202,72 @@ const coachPrefsPutRequest = coachPrefsSchema
|
||||
"Coach prompt-tuning preferences to persist, plus the optional optimistic-concurrency `baseUpdatedAt` token. The guarded write covers both `coachPrefsJson` and the mirrored `insightsExcludeMetrics` column atomically. Omit the token for the legacy unconditional write.",
|
||||
});
|
||||
|
||||
// v1.35.0 — which pillars count toward the Health Score. Shipped with a
|
||||
// settings surface in front of it and no published contract at all; the
|
||||
// entry below is that contract, read off the route rather than inferred
|
||||
// from a neighbour.
|
||||
//
|
||||
// The asymmetry is the thing to get right: the REQUEST speaks the positive
|
||||
// selection (the pillars that should count, which is what a person picks),
|
||||
// while the RESPONSE carries both sides resolved. The column itself stores
|
||||
// the complement, but that is storage and no client ever sees it.
|
||||
|
||||
const scorePillarId = z
|
||||
.enum(SCORE_PILLAR_IDS)
|
||||
.meta({ id: "ScorePillarId", description: "A Health Score pillar id." });
|
||||
|
||||
const healthScoreConfigResponse = z
|
||||
.object({
|
||||
pillars: z
|
||||
.array(scorePillarId)
|
||||
.describe(
|
||||
"Registry-ordered pillars that count toward the score. Data availability narrows this further at score time, and that narrowing is not a choice — so this is the composition, not a promise that every pillar has a number today.",
|
||||
),
|
||||
excludedPillars: z
|
||||
.array(scorePillarId)
|
||||
.describe("Registry-ordered pillars the person took out."),
|
||||
hasSelection: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"True once a selection has been written. It says the person chose, not that their choice differs from the default: an account that opened the surface and kept every pillar has a selection. False means nobody ever chose, and such an account still inherits a later change of defaults.",
|
||||
),
|
||||
version: z
|
||||
.number()
|
||||
.int()
|
||||
.describe(
|
||||
"Per-user recipe version, monotonic and incremented on every accepted write. 0 while no selection has been written. A score record names the version that produced it, so a comparison can see the recipe move.",
|
||||
),
|
||||
changedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.nullable()
|
||||
.describe("When the recipe last changed; null while it never has."),
|
||||
updatedAt: updatedAtTokenField,
|
||||
})
|
||||
.meta({
|
||||
id: "HealthScoreConfigResponse",
|
||||
description:
|
||||
"The resolved Health Score composition. `updatedAt` is ABSENT while the account has never written a selection — there is nothing to guard a write against, so the first save is the unconditional write.",
|
||||
});
|
||||
|
||||
const healthScoreConfigPatchRequest = z
|
||||
.object({
|
||||
pillars: z
|
||||
.array(scorePillarId)
|
||||
.max(SCORE_PILLAR_IDS.length)
|
||||
.describe(
|
||||
"The pillars that should count — the positive selection, not the exclusions. Replaces the stored recipe whole; it never merges. An id outside the catalogue is a 422 rather than being dropped, because a client has no business sending one.",
|
||||
),
|
||||
baseUpdatedAt: baseUpdatedAtField,
|
||||
})
|
||||
// Mirrors the runtime `.strict()`, so a generated client is told about
|
||||
// the rejection rather than only being told in prose.
|
||||
.strict()
|
||||
.meta({
|
||||
id: "PatchHealthScoreConfigRequest",
|
||||
description:
|
||||
"The pillars that should count toward the score. Strict: any key other than `pillars` and `baseUpdatedAt` is rejected.",
|
||||
});
|
||||
|
||||
// v1.28-era — the owner's saved doctor-report selection (leaf list plus the
|
||||
// render choices the export panel restores). `v` is the report-selection
|
||||
// schema version (currently locked at 2); `leaves` is a closed catalogue of
|
||||
@@ -499,6 +567,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
...conflictResponse409("Coach preferences", "coach_prefs_conflict"),
|
||||
...stdResponses,
|
||||
...invalidBaseTokenResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -587,6 +656,70 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/auth/me/health-score-config": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
summary: "Read which pillars count toward the Health Score",
|
||||
description:
|
||||
"v1.35.0 — the resolved composition: which pillars count, which the person took out, whether they have chosen at all, and the recipe version. An account that never chose reads every pillar with `hasSelection: false` and `version: 0`, which is exactly what its number was already built from — there is no backfill because there is nothing to back-fill.",
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"The resolved composition. `updatedAt` is absent until a selection has been stored.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
healthScoreConfigResponse,
|
||||
"HealthScoreConfigEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
patch: {
|
||||
tags: ["Auth"],
|
||||
summary: "Choose which pillars count toward the Health Score",
|
||||
description:
|
||||
"Replaces the recipe with the given positive selection and bumps the per-user recipe version by one. The write is refused when the selection cannot produce a score: at least one physical measurement, and at least three distinct areas of health. Blood pressure, glucose and cholesterol are three pillars but one area, which is the refusal that surprises people. Accepting the write also evicts the account's score caches, so the next read reflects the new composition rather than the previous hour's number. Optimistic concurrency: send `baseUpdatedAt` from a prior read and the write 409s if the recipe moved since; omit it for the unconditional write. Rate-limited to 60 writes per minute per user.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: healthScoreConfigPatchRequest },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"The stored composition, echoed back with the incremented `version` and the advanced `updatedAt` token.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
healthScoreConfigResponse,
|
||||
"PatchHealthScoreConfigEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...conflictResponse409(
|
||||
"Score selection",
|
||||
"health_score_config_conflict",
|
||||
),
|
||||
...stdResponses,
|
||||
// Four distinct 422s on one route, so this one is written out
|
||||
// rather than reusing the shared base-token response: a client
|
||||
// branching on `meta.errorCode` needs to see all of them, and a
|
||||
// description that named only the token would be a contract that
|
||||
// quietly omits the refusal a person actually hits.
|
||||
"422": {
|
||||
description:
|
||||
"The write was refused and nothing was stored. `meta.errorCode` distinguishes the cases: `health_score_config.too_narrow` — the selection cannot produce a score (`meta.reason` is `measured_physiological_domain_required` or `three_domains_required`, and the `error` string is the sentence to show the person); `health_score_config.invalid` — the body failed validation, with the per-issue list under `details.issues`; `invalid_base_updated_at` — `baseUpdatedAt` was present but unparseable, `null` included, which is rejected rather than falling back to the unconditional write. A body that is not JSON at all returns the bare message `health-score-config.body.invalid_json` with no errorCode.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/auth/me/modules": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
@@ -634,6 +767,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
...conflictResponse409("Module preferences", "modules_conflict"),
|
||||
...stdResponses,
|
||||
...invalidBaseTokenResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -875,6 +1009,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"notification_prefs_conflict",
|
||||
),
|
||||
...stdResponses,
|
||||
...invalidBaseTokenResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -109,7 +109,7 @@ export const baseUpdatedAtField = z.iso
|
||||
.datetime({ offset: true })
|
||||
.optional()
|
||||
.describe(
|
||||
"Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored row still carrying this exact value — a stale token fails with 409 and changes nothing. Treat as opaque: only ever echo a server-returned value, never parse or synthesise it.",
|
||||
"Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored row still carrying this exact value — a stale token fails with 409 and changes nothing. A present-but-unparseable value fails with 422 and `meta.errorCode` = `invalid_base_updated_at` — note that this is NOT the unconditional write: sending `null` or a malformed string is rejected rather than treated as an omitted token. Treat as opaque: only ever echo a server-returned value, never parse or synthesise it.",
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -138,6 +138,28 @@ export function conflictResponse409(resource: string, errorCode: string) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The 422 a malformed `baseUpdatedAt` earns, for the write endpoints that
|
||||
* accept the token in their body.
|
||||
*
|
||||
* Spread AFTER `...stdResponses` — the generic 422 there would otherwise
|
||||
* overwrite this one and the errorCode would vanish from the contract.
|
||||
*
|
||||
* Worth stating outright because the shape surprises implementers: an
|
||||
* unparseable token is NOT silently downgraded to the unconditional write.
|
||||
* `null`, an empty string and a non-ISO string all 422. The unconditional
|
||||
* write is reached by OMITTING the key, nothing else. `invalid_base_updated_at`
|
||||
* had lived only in route tests since v1.32.21, so a client had no way to
|
||||
* learn this from the published spec.
|
||||
*/
|
||||
export const invalidBaseTokenResponse = {
|
||||
"422": {
|
||||
description:
|
||||
"Request validation failed. When the body carried a `baseUpdatedAt` that could not be parsed as an ISO-8601 timestamp — including an explicit `null` — `meta.errorCode` = `invalid_base_updated_at` and nothing was written. Omit the key entirely for the unconditional write; do not send `null` for it.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
};
|
||||
|
||||
// ── Standard 401 / 422 / 429 responses ───────────────────────────────
|
||||
|
||||
export const stdResponses = {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* What the "what counts toward your score" surface shows, and what it
|
||||
* refuses to show.
|
||||
*
|
||||
* Four decisions are pinned here because each of them is a sentence a
|
||||
* Three decisions are pinned here because each of them is a sentence a
|
||||
* person reads about their own health, and each has a plausible wrong
|
||||
* version that would look fine in a screenshot:
|
||||
*
|
||||
@@ -16,17 +16,12 @@
|
||||
* one the server never mentioned.
|
||||
* 3. Wellbeing under crisis signposting is its own state and never a
|
||||
* configuration problem.
|
||||
* 4. A pillar this build can never score is never sent as part of a
|
||||
* selection, because the write-time breadth rule would otherwise
|
||||
* accept a recipe on its behalf that the scorer then refuses.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildScoreConfigRows,
|
||||
selectionToSave,
|
||||
showsWaitingForData,
|
||||
UNSCORABLE_PILLARS,
|
||||
type ScoreConfigRow,
|
||||
} from "@/lib/score-config/rows";
|
||||
import {
|
||||
@@ -81,9 +76,9 @@ describe("grouping", () => {
|
||||
if (group.domain === "cardiometabolic") continue;
|
||||
expect(group.rows).toHaveLength(1);
|
||||
}
|
||||
// Six domains across eight pillars is the whole reason the surface
|
||||
// Five domains across seven pillars is the whole reason the surface
|
||||
// groups: equal weight per pillar is not equal weight per domain.
|
||||
expect(built.groups).toHaveLength(6);
|
||||
expect(built.groups).toHaveLength(5);
|
||||
expect(rowsOf(built)).toHaveLength(SCORE_PILLAR_IDS.length);
|
||||
});
|
||||
|
||||
@@ -158,7 +153,6 @@ describe("a module that is off", () => {
|
||||
"BLOOD_PRESSURE",
|
||||
"ACTIVITY",
|
||||
"ADIPOSITY",
|
||||
"FITNESS",
|
||||
] as ScorePillarId[]) {
|
||||
expect(row(built, id)).toBeDefined();
|
||||
}
|
||||
@@ -246,54 +240,3 @@ describe("states that are not absence", () => {
|
||||
expect(showsWaitingForData(bp!)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a pillar this build cannot score", () => {
|
||||
it("is present, honest and never selectable", () => {
|
||||
const built = buildScoreConfigRows({
|
||||
selection: ALL,
|
||||
modules: ALL_MODULES_ON,
|
||||
// Even a verdict claiming it scored cannot make it selectable.
|
||||
verdicts: { FITNESS: { status: "ok" } },
|
||||
});
|
||||
|
||||
const fitness = row(built, "FITNESS");
|
||||
expect(fitness).toBeDefined();
|
||||
expect(fitness?.selectable).toBe(false);
|
||||
expect(fitness?.eligibility).toBe("unavailable");
|
||||
});
|
||||
|
||||
it("leaves every other pillar selectable", () => {
|
||||
const built = buildScoreConfigRows({
|
||||
selection: ALL,
|
||||
modules: ALL_MODULES_ON,
|
||||
});
|
||||
|
||||
for (const candidate of rowsOf(built)) {
|
||||
expect(candidate.selectable).toBe(!UNSCORABLE_PILLARS.has(candidate.id));
|
||||
}
|
||||
});
|
||||
|
||||
it("is never sent as part of a saved selection", () => {
|
||||
expect(selectionToSave(ALL)).not.toContain("FITNESS");
|
||||
expect(selectionToSave(["FITNESS"])).toEqual([]);
|
||||
});
|
||||
|
||||
it("cannot carry a selection over the breadth floor on its own", () => {
|
||||
// The failure this guards: FITNESS is a domain of its own AND counts
|
||||
// as physiological, so echoing it back would let the write-time rule
|
||||
// accept `{ACTIVITY, WELLBEING, FITNESS}` — three domains, one of
|
||||
// them physiological — for a score the reader then refuses, because
|
||||
// FITNESS never reaches the composition.
|
||||
expect(selectionToSave(["ACTIVITY", "WELLBEING", "FITNESS"])).toEqual([
|
||||
"ACTIVITY",
|
||||
"WELLBEING",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps registry order and drops duplicates", () => {
|
||||
expect(selectionToSave(["LIPIDS", "ACTIVITY", "ACTIVITY"])).toEqual([
|
||||
"ACTIVITY",
|
||||
"LIPIDS",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -15,30 +15,14 @@ export const SCORE_PILLAR_LABEL_KEYS: Record<ScorePillarId, string> = {
|
||||
SLEEP: "insights.healthScore.pillar.sleep",
|
||||
ADIPOSITY: "insights.healthScore.pillar.adiposity",
|
||||
WELLBEING: "insights.healthScore.pillar.wellbeing",
|
||||
FITNESS: "insights.healthScore.pillar.fitness",
|
||||
LIPIDS: "insights.healthScore.pillar.lipids",
|
||||
};
|
||||
|
||||
/**
|
||||
* Domain headings. The cardiometabolic one carries three pillars, which
|
||||
* is the whole reason the surface groups at all: equal weight per pillar
|
||||
* is not equal weight per area, and nothing in the app said so before.
|
||||
*/
|
||||
/** Domain headings, one per distinct area of health. */
|
||||
export const SCORE_DOMAIN_LABEL_KEYS: Record<ScoreDomain, string> = {
|
||||
cardiometabolic: "settings.sections.score.domain.cardiometabolic",
|
||||
activity: "settings.sections.score.domain.activity",
|
||||
sleep: "settings.sections.score.domain.sleep",
|
||||
adiposity: "settings.sections.score.domain.adiposity",
|
||||
wellbeing: "settings.sections.score.domain.wellbeing",
|
||||
fitness: "settings.sections.score.domain.fitness",
|
||||
};
|
||||
|
||||
/**
|
||||
* Domains that need a sentence under the heading. Only the one that
|
||||
* carries more than one pillar does, and it is not decoration: without it
|
||||
* the triple count stays invisible and deselecting one of the three
|
||||
* reshapes the balance with no cue.
|
||||
*/
|
||||
export const SCORE_DOMAIN_NOTE_KEYS: Partial<Record<ScoreDomain, string>> = {
|
||||
cardiometabolic: "settings.sections.score.domainNote.cardiometabolic",
|
||||
};
|
||||
@@ -36,21 +36,6 @@ import {
|
||||
type ScoreReaderModules,
|
||||
} from "@/lib/analytics/score/modules";
|
||||
|
||||
/**
|
||||
* Pillars this build can never score, whatever anyone selects.
|
||||
*
|
||||
* FITNESS is the only entry and it is not a placeholder for a future
|
||||
* list. The schema records VO₂max but cannot distinguish a measured test
|
||||
* from a device estimate, so every FITNESS row reaches the scorer with
|
||||
* `measured: false` and is refused (`src/lib/analytics/score/reader.ts`,
|
||||
* the FITNESS input block). Offering it as a switch would be chrome over
|
||||
* a thing that recomputes nothing — the exact defect this release exists
|
||||
* to remove — so the row is present, honest, and inert.
|
||||
*/
|
||||
export const UNSCORABLE_PILLARS: ReadonlySet<ScorePillarId> = new Set([
|
||||
"FITNESS",
|
||||
]);
|
||||
|
||||
/**
|
||||
* What the server's last scoring run said about one pillar. Every value
|
||||
* comes from a `ScorePillarResult`; nothing here is computed from data.
|
||||
@@ -64,8 +49,6 @@ export type ScoreRowEligibility =
|
||||
| "crisis"
|
||||
/** The read broke. Absence and failure are different states. */
|
||||
| "read_failed"
|
||||
/** This build cannot score it at all. */
|
||||
| "unavailable"
|
||||
/** The server said nothing about it. No claim is made either way. */
|
||||
| "unknown";
|
||||
|
||||
@@ -74,8 +57,6 @@ export interface ScoreConfigRow {
|
||||
domain: ScoreDomain;
|
||||
/** True when the draft counts this pillar toward the score. */
|
||||
counts: boolean;
|
||||
/** False when the switch is inert — today only the unscorable pillars. */
|
||||
selectable: boolean;
|
||||
eligibility: ScoreRowEligibility;
|
||||
}
|
||||
|
||||
@@ -118,10 +99,8 @@ function resolveModules(
|
||||
}
|
||||
|
||||
function eligibilityOf(
|
||||
id: ScorePillarId,
|
||||
verdict: ScorePillarVerdict | undefined,
|
||||
): ScoreRowEligibility {
|
||||
if (UNSCORABLE_PILLARS.has(id)) return "unavailable";
|
||||
if (!verdict) return "unknown";
|
||||
if (verdict.status === "ok") return "counting";
|
||||
if (verdict.reason === "crisis_signposting") return "crisis";
|
||||
@@ -169,8 +148,7 @@ export function buildScoreConfigRows(
|
||||
id,
|
||||
domain,
|
||||
counts: selected.has(id),
|
||||
selectable: !UNSCORABLE_PILLARS.has(id),
|
||||
eligibility: eligibilityOf(id, input.verdicts?.[id]),
|
||||
eligibility: eligibilityOf(input.verdicts?.[id]),
|
||||
};
|
||||
const existing = byDomain.get(domain);
|
||||
if (existing) {
|
||||
@@ -199,23 +177,3 @@ export function buildScoreConfigRows(
|
||||
export function showsWaitingForData(row: ScoreConfigRow): boolean {
|
||||
return row.counts && row.eligibility === "waiting";
|
||||
}
|
||||
|
||||
/**
|
||||
* The selection a save should send.
|
||||
*
|
||||
* Every pillar the person can actually choose, and nothing else. A pillar
|
||||
* this build cannot score is left out rather than echoed back: echoing it
|
||||
* would let it satisfy the write-time breadth rule (it is a domain of its
|
||||
* own AND a physiological pillar) on behalf of a score it can never
|
||||
* reach, so the write would be accepted and the score would still
|
||||
* disappear at read time. The refusal is only worth having if it judges
|
||||
* the recipe that will actually be scored.
|
||||
*/
|
||||
export function selectionToSave(
|
||||
selection: readonly ScorePillarId[],
|
||||
): ScorePillarId[] {
|
||||
const selected = new Set(selection);
|
||||
return SCORE_PILLAR_IDS.filter(
|
||||
(id) => selected.has(id) && !UNSCORABLE_PILLARS.has(id),
|
||||
);
|
||||
}
|
||||
@@ -277,7 +277,6 @@ describe("the resolved configured flag, on every wire that carries it", () => {
|
||||
"SLEEP",
|
||||
"ADIPOSITY",
|
||||
"WELLBEING",
|
||||
"FITNESS",
|
||||
"LIPIDS",
|
||||
]);
|
||||
|
||||
@@ -324,7 +323,6 @@ describe("the resolved configured flag, on every wire that carries it", () => {
|
||||
"ACTIVITY",
|
||||
"SLEEP",
|
||||
"ADIPOSITY",
|
||||
"FITNESS",
|
||||
"LIPIDS",
|
||||
]);
|
||||
|
||||
@@ -341,7 +339,6 @@ describe("the resolved configured flag, on every wire that carries it", () => {
|
||||
"SLEEP",
|
||||
"ADIPOSITY",
|
||||
"WELLBEING",
|
||||
"LIPIDS",
|
||||
]);
|
||||
|
||||
const stored = await getPrismaClient().user.findUniqueOrThrow({
|
||||
@@ -349,7 +346,7 @@ describe("the resolved configured flag, on every wire that carries it", () => {
|
||||
select: { healthScoreConfigJson: true },
|
||||
});
|
||||
expect(stored.healthScoreConfigJson).toMatchObject({
|
||||
excludedPillars: ["FITNESS"],
|
||||
excludedPillars: ["LIPIDS"],
|
||||
});
|
||||
|
||||
expectEveryWire(await readWires(), true);
|
||||
@@ -364,6 +361,9 @@ describe("the resolved configured flag, on every wire that carries it", () => {
|
||||
|
||||
expectEveryWire(await readWires(), false);
|
||||
|
||||
// Narrowed: one pillar taken out. If both halves of this case send the
|
||||
// same list, the round trip proves nothing — it would read false at both
|
||||
// ends and the flip would never be exercised.
|
||||
await saveSelection([
|
||||
"BLOOD_PRESSURE",
|
||||
"GLYCAEMIA",
|
||||
@@ -371,7 +371,6 @@ describe("the resolved configured flag, on every wire that carries it", () => {
|
||||
"SLEEP",
|
||||
"ADIPOSITY",
|
||||
"WELLBEING",
|
||||
"LIPIDS",
|
||||
]);
|
||||
expectEveryWire(await readWires(), true);
|
||||
|
||||
@@ -382,7 +381,6 @@ describe("the resolved configured flag, on every wire that carries it", () => {
|
||||
"SLEEP",
|
||||
"ADIPOSITY",
|
||||
"WELLBEING",
|
||||
"FITNESS",
|
||||
"LIPIDS",
|
||||
]);
|
||||
expectEveryWire(await readWires(), false);
|
||||
|
||||
Reference in new issue
Block a user