mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 20:32:02 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66286f085a | ||
|
|
d3d32ff8e0 | ||
|
|
008a6d46a4 | ||
|
|
ad1554a36f | ||
|
|
96c10f07e6 | ||
|
|
99ceb236de | ||
|
|
368eb44439 | ||
|
|
a830e8a9d3 | ||
|
|
7ddcee0445 | ||
|
|
f6edff9b82 | ||
|
|
6a69bad05e | ||
|
|
43c591fe19 | ||
|
|
f6bd8be6d5 | ||
|
|
5767e0330b | ||
|
|
4f79b9d49a | ||
|
|
4d200ebd43 | ||
|
|
9bcd5dfaf5 | ||
|
|
4e934a191d | ||
|
|
ee63b09e59 | ||
|
|
fb1bf710e6 | ||
|
|
c60689affd | ||
|
|
043d2ba143 | ||
|
|
c65f122017 | ||
|
|
dbecbbcbdb | ||
|
|
7231c9f3f7 | ||
|
|
6c87c96fa6 | ||
|
|
5424ab0b60 | ||
|
|
b85a348891 | ||
|
|
84706196dd | ||
|
|
88162eda07 | ||
|
|
778725834f | ||
|
|
cdfe4ec114 | ||
|
|
f42d423edd | ||
|
|
2b95d7ebc4 | ||
|
|
bf0a637bab | ||
|
|
78fe4d8eea | ||
|
|
f8b51b7994 | ||
|
|
bd4a6d14d5 | ||
|
|
308908c226 | ||
|
|
46146f389c | ||
|
|
0276ca9121 | ||
|
|
c29055be1c | ||
|
|
5d7d00949c | ||
|
|
0a524c542b | ||
|
|
98eb609eb1 | ||
|
|
3406ed6946 | ||
|
|
5d13f0e69c | ||
|
|
087f3f496b | ||
|
|
0826bd80e6 | ||
|
|
0a877bdf4d | ||
|
|
76e68f0247 | ||
|
|
c66aafe9c8 | ||
|
|
54fed25859 | ||
|
|
5fd5447069 | ||
|
|
956746fe7d | ||
|
|
cd7c6f44c8 | ||
|
|
ab8e9a164d | ||
|
|
2f6f3573a2 | ||
|
|
d89b90da4b | ||
|
|
4e649de4f7 | ||
|
|
860a1fa299 | ||
|
|
d92be0d0bd | ||
|
|
cb75986eb5 | ||
|
|
c574ef78fb | ||
|
|
63b54587a8 | ||
|
|
842a24f1d1 | ||
|
|
5ef7fa86a8 | ||
|
|
7b9cde7a24 | ||
|
|
f8074e1b07 | ||
|
|
9982f87dc9 | ||
|
|
2d01b9846d | ||
|
|
2bd964973f | ||
|
|
b697a0a10e | ||
|
|
cc7978a573 | ||
|
|
06fdeb0336 | ||
|
|
9d82b25c6f | ||
|
|
5784d5ec72 | ||
|
|
cf7bb13ee8 | ||
|
|
7233845b10 | ||
|
|
0ce40b0950 | ||
|
|
64a92fd392 | ||
|
|
63c7f63f99 | ||
|
|
271a49a822 | ||
|
|
4d6091ec45 | ||
|
|
4ed6f7d1ce | ||
|
|
001641f57e | ||
|
|
36ab24727f | ||
|
|
b53ab36c4b | ||
|
|
1c5191d1e3 | ||
|
|
ee0375142c | ||
|
|
8a316f5820 | ||
|
|
a99e98332c | ||
|
|
1f8c31d29e | ||
|
|
af70312306 | ||
|
|
0fbaf6b2d3 | ||
|
|
80c8674ee1 | ||
|
|
96a3743009 | ||
|
|
8b3c0369d1 | ||
|
|
d0a42908fe | ||
|
|
8e6880b04c | ||
|
|
e1ca6d9da2 | ||
|
|
f322736074 | ||
|
|
c1b372de9c | ||
|
|
0471c7cb9b | ||
|
|
d432842a73 | ||
|
|
70cab3c885 | ||
|
|
a2497cf21d | ||
|
|
1a0d2795c3 | ||
|
|
8f10ae011d | ||
|
|
92da02f824 | ||
|
|
8488092d48 | ||
|
|
340d1db6d9 | ||
|
|
f7d846458a | ||
|
|
4c35c16d64 | ||
|
|
9b69be8ab5 | ||
|
|
d04eab71a3 | ||
|
|
d8ddf97d9e | ||
|
|
cbfbf359e7 | ||
|
|
12d3239de0 | ||
|
|
3ec3a8c6aa | ||
|
|
356888134d | ||
|
|
6f5935aeb4 | ||
|
|
e49bd866dd | ||
|
|
bed634fd67 | ||
|
|
0784cbcbb1 | ||
|
|
52cccb3835 | ||
|
|
a61cdae437 | ||
|
|
76b407b6de | ||
|
|
0094445f07 | ||
|
|
a4cffbbea5 | ||
|
|
eb55c235c6 | ||
|
|
845b89255d | ||
|
|
a1c5d78f72 | ||
|
|
37496f0083 | ||
|
|
8b40066f42 | ||
|
|
02c6658a35 | ||
|
|
b7074e04b8 | ||
|
|
ec2c8a9e71 | ||
|
|
c9c824ca4d | ||
|
|
b767cff187 | ||
|
|
9a5206ee5f | ||
|
|
d7badf1512 | ||
|
|
8a2bb70dbd | ||
|
|
14d0eeeb46 | ||
|
|
a4bb2adcb2 | ||
|
|
8d86416072 | ||
|
|
207010e8fa | ||
|
|
a8b61abf6f |
No files matched your search
+328
-79
@@ -2,6 +2,268 @@
|
||||
|
||||
## [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
|
||||
|
||||
- A weekly injection you have already taken is no longer announced again. A
|
||||
medication dosed on a rolling interval had its expected dose anchored on the
|
||||
moment you actually injected, and was then checked off against the moment it
|
||||
was planned for, with six hours of tolerance. Those two are allowed to sit days
|
||||
apart, because a late dose still counts, so the dose your own injection created
|
||||
could never be marked done. It stayed open, went overdue, and the app asked for
|
||||
it again. Anyone on a weekly medication could hit this without ever editing
|
||||
anything: taking a dose a few days late was enough. Push reminders were never
|
||||
affected, because they read the plan rather than your entries.
|
||||
|
||||
## [1.35.0] — 2026-07-31
|
||||
|
||||
The Health Score becomes something you decide rather than something that happens
|
||||
to you.
|
||||
|
||||
### Added
|
||||
|
||||
- You choose what counts toward your Health Score. A settings page of its own
|
||||
lists the pillars, each one on or off. It starts with everything that can be
|
||||
scored, so a narrower score is a decision you make rather than a default you
|
||||
inherit.
|
||||
- Each day's score is now recorded with the recipe that produced it: the value,
|
||||
the method, which pillars took part, what each of them scored, and a
|
||||
fingerprint of the inputs it depended on. Until now nothing was kept, and every
|
||||
comparison was recomputed under today's rules.
|
||||
- The score says when its composition was authored, so a number built from a
|
||||
chosen set never looks like one built from everything available.
|
||||
|
||||
### Changed
|
||||
|
||||
- The module switches no longer decide what the score is made of. Turning off
|
||||
Sleep or Labs used to remove that pillar from the score with nothing on screen
|
||||
saying so. Those switches now govern what you see, and the score's own page
|
||||
governs what it counts. An existing account keeps exactly the composition it
|
||||
has today, so no number moves on upgrade.
|
||||
- Changing what counts no longer reads as a change in your health. The comparison
|
||||
stops at the point the recipe changed and says so, rather than drawing a line
|
||||
across two different definitions. The per-pillar figures continue, because those
|
||||
stay comparable.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A settings change can no longer produce a message telling you your score
|
||||
dropped.
|
||||
- The score's own coverage figure now describes the pillars it actually counts
|
||||
rather than every pillar that exists.
|
||||
|
||||
### Removed
|
||||
|
||||
- Two refusals that could not refuse anything, and a boundary condition no input
|
||||
could ever reach.
|
||||
|
||||
### Operations
|
||||
|
||||
- Two migrations: one for the per-account composition, one for the recorded
|
||||
scores. The recorded days travel through backup, restore and delete-all-data
|
||||
like every other table.
|
||||
|
||||
## [1.34.5] — 2026-07-31
|
||||
|
||||
A release about the score explaining itself, and about sentences arriving in the
|
||||
language you read.
|
||||
|
||||
### Added
|
||||
|
||||
- The health score sits beside the greeting again, at the top of the insights page,
|
||||
instead of on a separate card pinned underneath it. It has one place on the page.
|
||||
- Every pillar inside the score takes its own colour. A pillar doing well was painted
|
||||
in the overall score's colour, so a good result inside a weak week looked like a bad
|
||||
one.
|
||||
- A blood pressure score says how it came about. The pillar's detail now opens with
|
||||
which of the two values set the number and how far it sat from its boundary, what
|
||||
the scale means at that boundary, and that the pair it works from is a weighted
|
||||
average over the window rather than a reading anyone took.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The Coach read strip writes both of its lines in the language you use. The summary
|
||||
was translated and the line directly beneath it stayed English. The translations
|
||||
existed the whole time. The request simply never named a language, and the code
|
||||
behind it chose English whenever nobody did.
|
||||
- The cycle insights read carried the same silence and the same English line.
|
||||
- Switching language reloads those lines instead of showing the previous language out
|
||||
of the browser cache.
|
||||
- Anything in the score that is not contributing is one quiet line instead of a block
|
||||
of its own. A read that failed is named once for every pillar it took down and keeps
|
||||
its retry. Safety guidance is shown in full. Anything not recorded yet is counted,
|
||||
with the names one tap away.
|
||||
|
||||
### Internal
|
||||
|
||||
- A function that writes a sentence for a person has to be told the language, and none
|
||||
of them may fall back to English on their own. A guard reads the types rather than
|
||||
the parameter names, so renaming the parameter or moving the default onto the
|
||||
destructuring pattern does not get past it.
|
||||
- The space the score holds open while it loads is measured against the built panel
|
||||
instead of estimated. The declared minimum was smaller than the blocks inside it, so
|
||||
it never bound anything, and a short score was pushed further down the page than it
|
||||
needed to be.
|
||||
- A widening of the pillar label column was taken back out. The measurement behind it
|
||||
did not hold: the longest label in five of the six languages overruns any column
|
||||
this panel can afford, so the extra width would have come off the bar.
|
||||
|
||||
## [1.34.4] — 2026-07-31
|
||||
|
||||
A release about two places in the code answering one question differently.
|
||||
|
||||
### Added
|
||||
|
||||
- An inferred nap is now shown as its own band in the sleep stage view instead of
|
||||
being blended into that night. A nap used to be counted into the night's stages,
|
||||
so a day with a long afternoon sleep read as a longer and shallower night than
|
||||
it was. Nights without a nap look exactly as they did, with no empty band and no
|
||||
legend entry for something that is not there.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A checkup that is due tomorrow says tomorrow. The dashboard card worked its due
|
||||
line out in rolling 24-hour steps while the checkups page used calendar days, so
|
||||
the same appointment could read as due tomorrow on one screen and in two days on
|
||||
the other. Both now read one shared rule.
|
||||
- The due line and the date printed beside it are worked out on the same clock.
|
||||
One used the device's timezone and the other the profile's, which disagree by a
|
||||
day for anyone travelling or on a machine set to another zone.
|
||||
- Recording something no longer takes minutes to appear on the home page. The page
|
||||
render and the API were each holding their own copy of the server cache, so a
|
||||
write emptied one and left the other serving the state from before it. They now
|
||||
share one.
|
||||
- Returning to the dashboard no longer answers a refresh with a reading fetched
|
||||
before your last entry.
|
||||
- The onboarding profile step says when the server could not accept a field.
|
||||
Valid answers are kept, rejected ones are named, and if nothing could be saved
|
||||
the message says which field blocked it.
|
||||
- A cycle symptom's intensity survives a backup and restore. It was recorded and
|
||||
shown, and every export quietly dropped it. Older backup files still restore,
|
||||
with the intensity honestly absent rather than filled in with a zero.
|
||||
|
||||
### Removed
|
||||
|
||||
- The Health-Score explainer switch in the operator settings. It promised a control
|
||||
that was deliberately removed two releases ago, so it had been switching nothing
|
||||
for months.
|
||||
|
||||
### Internal
|
||||
|
||||
- The sleep composition and the single-night view now agree about which session is
|
||||
the night, because they ask the same function rather than each deciding.
|
||||
- A test that was meant to prove a dose taken before midnight stops reminding could
|
||||
not fail: the stand-in for the database ignored the time window it was asked for.
|
||||
It now honours it, and both directions are pinned, so a genuinely missed dose
|
||||
still escalates.
|
||||
|
||||
## [1.34.3] — 2026-07-31
|
||||
|
||||
A release about surfaces that knew something and did not say it.
|
||||
|
||||
### Added
|
||||
|
||||
- The workout page now shows the day around a session instead of linking away
|
||||
from it: the day's pulse curve, the night before, and that day's mood, on the
|
||||
page. It also offers the previous session of the same sport in one tap, says
|
||||
how many workouts the list holds, and lets a sport narrow it.
|
||||
- When a nutrient sync arrives and nothing can be stored, the nutrients page now
|
||||
says so and why, in plain sentences, instead of showing the same empty page
|
||||
someone would see who never synced at all.
|
||||
|
||||
### Changed
|
||||
|
||||
- The health score appears once. It is a single card below the greeting with the
|
||||
breakdown behind one expander, rather than a compact copy in the greeting plus
|
||||
nine full-width blocks underneath. Scores with too little input leave the grid
|
||||
but keep their name and the reason they are not scored.
|
||||
- The Coach input is one row tall until you type. It used to reserve a second row
|
||||
for its buttons before anything had been written.
|
||||
- Card headings sit closer to their content. A card with a title and no
|
||||
description reserved space for a description that was never there, which
|
||||
affected 45 of the 48 cards in the app.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A profile save no longer discards everything because one field is invalid.
|
||||
Valid fields are saved, rejected ones are named, and if nothing can be saved
|
||||
the message says which field blocked it. Validator wording no longer reaches
|
||||
the screen.
|
||||
- A failed read on the nutrients page is no longer shown as "no data yet".
|
||||
|
||||
### Contract
|
||||
|
||||
- Share-link selection now publishes which leaves belong to which group and which
|
||||
group is the sensitive one. The server always knew; a client that could not see
|
||||
it had to withhold "select all" rather than risk sending psychometric scores in
|
||||
a single tap.
|
||||
- The dashboard snapshot schema declares three fields the route has been sending
|
||||
all along. Because that schema is closed, a strictly generated client rejected
|
||||
the real response.
|
||||
- Rhythm events carry their own six-value classification, separate from the
|
||||
three-value one on ECG rows.
|
||||
- Six routes that clients consume were added to the published specification,
|
||||
including sixteen skip reasons enumerated from the source rather than
|
||||
transcribed. Three schemas were named in the specification's own prose while
|
||||
never being emitted; all three are now published.
|
||||
|
||||
### Operations
|
||||
|
||||
- The optional worker in the bundled compose file inherited 14 of the app's 62
|
||||
environment variables, missing every mail, push and notification value. It is
|
||||
the process that sends notifications, so a split deployment ran a worker that
|
||||
silently sent nothing. Both services now share one definition.
|
||||
- Bounded log rotation on both containers, dropped capabilities and a memory
|
||||
limit on the app, with the reasoning recorded, including why the database gets
|
||||
neither.
|
||||
- Every nutrient ingest call is now recorded, not only the ones that stored a
|
||||
row. The single durable trace used to be conditional on success, so a batch
|
||||
that was entirely rejected looked exactly like a batch that never arrived.
|
||||
- The build carries its own memory ceiling, so a local run, CI and the published
|
||||
image all invoke it the same way.
|
||||
- The self-hosting notes warn that a pinned image reference stays pinned, and name
|
||||
the two checks that catch a deploy which reports success while serving the
|
||||
previous release.
|
||||
|
||||
## [1.34.2] — 2026-07-30
|
||||
|
||||
Point release. One workout that arrived more than once, and several surfaces
|
||||
@@ -3156,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
|
||||
|
||||
@@ -5726,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
|
||||
|
||||
@@ -5788,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.
|
||||
@@ -5832,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
|
||||
|
||||
@@ -5866,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
|
||||
|
||||
@@ -5876,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.
|
||||
|
||||
@@ -6114,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
|
||||
|
||||
@@ -6432,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.
|
||||
@@ -6535,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
|
||||
|
||||
@@ -6708,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
|
||||
|
||||
@@ -8061,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
|
||||
@@ -8210,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
|
||||
|
||||
@@ -9198,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
|
||||
|
||||
@@ -9233,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
|
||||
@@ -9507,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
|
||||
@@ -9521,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
|
||||
@@ -9537,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
|
||||
|
||||
@@ -9663,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
|
||||
|
||||
@@ -10067,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
|
||||
@@ -10719,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
|
||||
@@ -11274,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
|
||||
|
||||
@@ -11478,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
|
||||
|
||||
@@ -11529,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
|
||||
@@ -11586,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
|
||||
|
||||
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.34.1: / 431, /insights 420, /measurements 417, /insights/mood 438, total 3238, baseline 130, lazy catalogs 792. The 108 KB aggregate rise from v1.34.0's 3130 is not one user load: the explicit assessment error/timeout/poll-exhaustion contract adds 2.7-4.4 KB to each of roughly forty mutually exclusive metric route chunks, which the whole-build sum counts independently. Catalogs account for only about 2 KB of the delta, no catalog is statically route-referenced, and Recharts remains exactly one fingerprinted chunk. A measured single next/dynamic assessment boundary was reverted because it raised the artifact further to 3243 KB while saving only about 1 KB per route. The aggregate cap therefore moves narrowly from 3140 to 3260 (22 KB headroom); per-route ceilings, the static-catalog guard, and the one-Recharts-chunk guard remain unchanged.",
|
||||
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.35.0: / 431, /insights 421, /measurements 417, /insights/mood 439, total 3286, baseline 130, lazy catalogs 804. Every per-route ceiling is unchanged and every measured route is within a kilobyte of its v1.34.1 value, so nothing a person actually downloads grew. The 48 KB aggregate rise from 3238 is the score-configuration settings route, which is a new mutually exclusive chunk the whole-build sum counts independently, plus about 12 KB of its strings across the six locale catalogs. No catalog is statically route-referenced and Recharts remains exactly one fingerprinted chunk. The aggregate cap therefore moves from 3260 to 3310 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged.",
|
||||
"routesKbGz": {
|
||||
"/page": 460,
|
||||
"/insights/page": 445,
|
||||
"/measurements/page": 445,
|
||||
"/insights/mood/page": 460
|
||||
},
|
||||
"totalClientKbGz": 3260,
|
||||
"totalClientKbGz": 3310,
|
||||
"maxRechartsChunks": 1
|
||||
}
|
||||
+253
-195
@@ -19,6 +19,199 @@
|
||||
# a backup of everything. The optional GeoLite2 databases are either baked
|
||||
# into the image at build time or mounted read-only from the host and
|
||||
# pointed at with GEOLITE2_DIR — either way they are replaceable, not state.
|
||||
|
||||
# Shared web/worker environment. `app` and the optional `app-worker` below
|
||||
# both merge this anchor with `<<: *healthlog_env` instead of each keeping
|
||||
# their own copy of the list. A hand-copied second list is exactly how the
|
||||
# app-worker example drifted before: `app` grew to 62 variables over time
|
||||
# while the worker example stayed at 14, missing every SMTP_*, VAPID_*,
|
||||
# APNS_*, INSIGHTS_MAX_TOKENS and ALLOW_LOCAL_AI_PRIVATE_HOSTS entry — so a
|
||||
# deployed worker (the process that sends notifications and runs the AI
|
||||
# jobs) silently couldn't email, push, or send APNs, and had no local-AI
|
||||
# host allowlist. One shared block makes that drift structurally
|
||||
# impossible. HEALTHLOG_PROCESS_TYPE is the one value that's meant to
|
||||
# differ per service and is overridden after the merge.
|
||||
x-healthlog-env: &healthlog_env
|
||||
# connection_limit is set explicitly: Prisma's own default is
|
||||
# cpu_count*2+1, which starves the combined web+worker process
|
||||
# (HEALTHLOG_PROCESS_TYPE=all) under background AI/rollup load. Default
|
||||
# 20 suits a small host; raise DB_CONNECTION_LIMIT on a busy one (keep it
|
||||
# below the database's max_connections, leaving headroom for pg-boss,
|
||||
# migrations, and backups). pool_timeout is the seconds a query waits for
|
||||
# a free connection before erroring.
|
||||
DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public&connection_limit=${DB_CONNECTION_LIMIT:-20}&pool_timeout=${DB_POOL_TIMEOUT:-20}"
|
||||
NODE_ENV: production
|
||||
# Encryption at rest (AES-256-GCM). Generate each secret with
|
||||
# `openssl rand -hex 32` (a 64-char hex string = 32 bytes). ENCRYPTION_KEY
|
||||
# is the single-key path and is all most self-hosts need. The map form
|
||||
# (ENCRYPTION_KEYS={"v1":"<hex>","v2":"<hex>"} + ENCRYPTION_ACTIVE_KEY_ID=v2)
|
||||
# is for key rotation — see docs/ops/encryption-key-rotation.md. Keep these
|
||||
# values stable and backed up: lose them and the encrypted columns are
|
||||
# unrecoverable.
|
||||
ENCRYPTION_KEY: "${ENCRYPTION_KEY}"
|
||||
ENCRYPTION_KEYS: "${ENCRYPTION_KEYS:-}"
|
||||
ENCRYPTION_ACTIVE_KEY_ID: "${ENCRYPTION_ACTIVE_KEY_ID:-}"
|
||||
API_TOKEN_HMAC_KEY: "${API_TOKEN_HMAC_KEY}"
|
||||
NEXT_PUBLIC_APP_URL: "${NEXT_PUBLIC_APP_URL:-http://localhost:3000}"
|
||||
APP_URL: "${APP_URL:-http://localhost:3000}"
|
||||
# Session-cookie Secure-flag opt-out. SET THIS TO false FOR LAN / plain-HTTP
|
||||
# self-hosts (http://<host>:3000 with no TLS reverse proxy) — otherwise the
|
||||
# session cookie carries the `Secure` flag, the browser drops it over HTTP,
|
||||
# and login silently fails. Unset → the flag tracks NODE_ENV=production
|
||||
# (i.e. on); set to true to force it. Forwarded explicitly so an operator
|
||||
# value in .env reaches the Node process (the `environment:` block is a
|
||||
# whitelist — vars not listed here don't propagate even though
|
||||
# docker-compose reads .env for `${VAR}` substitution at compose-up time).
|
||||
SESSION_COOKIE_SECURE: "${SESSION_COOKIE_SECURE:-}"
|
||||
NATIVE_CANVAS: "${NATIVE_CANVAS:-}"
|
||||
WITHINGS_WEBHOOK_SECRET: "${WITHINGS_WEBHOOK_SECRET:-}"
|
||||
# v1.18.11 (W3) — client-IP resolution + geo lookup. Behind Cloudflare
|
||||
# the real visitor IP is in `cf-connecting-ip`; set
|
||||
# TRUST_CF_CONNECTING_IP=1 ONLY when Cloudflare is in front (off by
|
||||
# default — a direct-exposed host must not trust a forgeable header).
|
||||
# IP_GEO_LOOKUP_URL overrides the default ipwho.is provider (HTTPS by
|
||||
# default); IP_GEO_LOOKUP_DISABLED=1 stops all third-party IP egress.
|
||||
# IP_GEO_ALLOW_INSECURE=true permits a plain-HTTP provider URL (e.g. the
|
||||
# free ip-api.com endpoint) — off by default, an explicit operator choice.
|
||||
# Listed here so operator values in .env reach the container (the
|
||||
# `environment:` block is a whitelist — vars not listed never propagate).
|
||||
TRUST_CF_CONNECTING_IP: "${TRUST_CF_CONNECTING_IP:-}"
|
||||
IP_GEO_LOOKUP_URL: "${IP_GEO_LOOKUP_URL:-}"
|
||||
IP_GEO_LOOKUP_DISABLED: "${IP_GEO_LOOKUP_DISABLED:-}"
|
||||
IP_GEO_ALLOW_INSECURE: "${IP_GEO_ALLOW_INSECURE:-}"
|
||||
# Directory the offline GeoLite2 databases are read from. Default
|
||||
# `/opt/geolite2` — the path the published image bakes its own copy
|
||||
# into, if the build had a MaxMind licence key. Point this at a
|
||||
# directory you mount yourself (see the `volumes:` block below) to
|
||||
# bring your own GeoLite2-City.mmdb + GeoLite2-ASN.mmdb, refreshed
|
||||
# with `geoipupdate` on your own schedule. Listed here so an operator
|
||||
# value in .env reaches the container (the `environment:` block is a
|
||||
# whitelist). See docs/self-hosting/geolite2.md.
|
||||
GEOLITE2_DIR: "${GEOLITE2_DIR:-/opt/geolite2}"
|
||||
# all = bundle web + worker in this container (default, single-host)
|
||||
# web = serve HTTP only; pair with a separate app-worker service
|
||||
HEALTHLOG_PROCESS_TYPE: "${HEALTHLOG_PROCESS_TYPE:-all}"
|
||||
BACKUP_ENCRYPTION_KEY: "${BACKUP_ENCRYPTION_KEY:-}"
|
||||
BACKUP_S3_ENDPOINT: "${BACKUP_S3_ENDPOINT:-}"
|
||||
BACKUP_S3_BUCKET: "${BACKUP_S3_BUCKET:-}"
|
||||
BACKUP_S3_ACCESS_KEY: "${BACKUP_S3_ACCESS_KEY:-}"
|
||||
BACKUP_S3_SECRET_KEY: "${BACKUP_S3_SECRET_KEY:-}"
|
||||
BACKUP_S3_REGION: "${BACKUP_S3_REGION:-auto}"
|
||||
BACKUP_RETENTION_DAYS: "${BACKUP_RETENTION_DAYS:-30}"
|
||||
# Per-export ceiling on MedicationAdministration rows in the FHIR
|
||||
# health-record Bundle. Bounded 1..50000, default 5000. Listed here so
|
||||
# an operator override in .env actually reaches the container (the
|
||||
# `environment:` block is a whitelist).
|
||||
FHIR_MAX_MEDICATION_ADMINISTRATIONS: "${FHIR_MAX_MEDICATION_ADMINISTRATIONS:-}"
|
||||
# WHOOP integration (optional). The app client id/secret are per-user
|
||||
# BYO-keys stored encrypted in the DB, so there is no
|
||||
# WHOOP_CLIENT_ID/SECRET env. WHOOP_WEBHOOK_SECRET is the instance-level
|
||||
# path-segment + HMAC-verification secret for the webhook;
|
||||
# WHOOP_REDIRECT_URI overrides the derived
|
||||
# `${NEXT_PUBLIC_APP_URL}/api/whoop/callback` when the public URL
|
||||
# differs. Listed here so an operator override in .env reaches the
|
||||
# container (the `environment:` block is a whitelist).
|
||||
WHOOP_WEBHOOK_SECRET: "${WHOOP_WEBHOOK_SECRET:-}"
|
||||
WHOOP_REDIRECT_URI: "${WHOOP_REDIRECT_URI:-}"
|
||||
# v1.17.0 (F4) — Polar AccessLink + Oura Cloud OAuth (shared app from
|
||||
# env). Both pairs together enable each connect button; an unset pair
|
||||
# leaves that integration disabled. The optional *_REDIRECT_URI overrides
|
||||
# the derived `${NEXT_PUBLIC_APP_URL}/api/{polar,oura}/callback`. Listed
|
||||
# here so operator values in .env reach the container (the `environment:`
|
||||
# block is a whitelist).
|
||||
POLAR_CLIENT_ID: "${POLAR_CLIENT_ID:-}"
|
||||
POLAR_CLIENT_SECRET: "${POLAR_CLIENT_SECRET:-}"
|
||||
POLAR_REDIRECT_URI: "${POLAR_REDIRECT_URI:-}"
|
||||
OURA_CLIENT_ID: "${OURA_CLIENT_ID:-}"
|
||||
OURA_CLIENT_SECRET: "${OURA_CLIENT_SECRET:-}"
|
||||
OURA_REDIRECT_URI: "${OURA_REDIRECT_URI:-}"
|
||||
# v1.28.x — Strava OAuth (shared app from env; per-user BYO keys override).
|
||||
# Both together enable the connect button; an unset pair leaves it
|
||||
# disabled. STRAVA_REDIRECT_URI overrides the derived
|
||||
# `${NEXT_PUBLIC_APP_URL}/api/strava/callback`. Listed here so operator
|
||||
# values in .env reach the container (the `environment:` block is a
|
||||
# whitelist).
|
||||
STRAVA_CLIENT_ID: "${STRAVA_CLIENT_ID:-}"
|
||||
STRAVA_CLIENT_SECRET: "${STRAVA_CLIENT_SECRET:-}"
|
||||
STRAVA_REDIRECT_URI: "${STRAVA_REDIRECT_URI:-}"
|
||||
# OIDC SSO login (self-hosted IdP: Authentik/Keycloak/Authelia/etc).
|
||||
# All three of ISSUER_URL/CLIENT_ID/CLIENT_SECRET together enable the
|
||||
# "Sign in with SSO" button; set none to leave it disabled. OIDC_ONLY
|
||||
# hides password/passkey login entirely, but only takes effect when
|
||||
# the three above are also fully set — a half-configured group never
|
||||
# locks every user out. It IS enforced server-side, and there is no
|
||||
# OIDC flow for the native iOS client yet — enabling it locks iOS
|
||||
# users out of sign-in entirely. Listed here so operator values in
|
||||
# .env reach the container (the `environment:` block is a whitelist).
|
||||
OIDC_ISSUER_URL: "${OIDC_ISSUER_URL:-}"
|
||||
OIDC_CLIENT_ID: "${OIDC_CLIENT_ID:-}"
|
||||
OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET:-}"
|
||||
OIDC_SCOPES: "${OIDC_SCOPES:-}"
|
||||
OIDC_BUTTON_LABEL: "${OIDC_BUTTON_LABEL:-}"
|
||||
OIDC_ONLY: "${OIDC_ONLY:-}"
|
||||
# Deploy-result webhook (optional). DEPLOY_WEBHOOK_SECRET authenticates
|
||||
# POST /api/internal/deploy-webhook; DEPLOY_LOGS_URL is the
|
||||
# deployment-dashboard URL (e.g. the Coolify UI) linked in
|
||||
# deploy-failure notifications. Listed here so operator values in
|
||||
# .env reach the container (the `environment:` block is a whitelist).
|
||||
DEPLOY_WEBHOOK_SECRET: "${DEPLOY_WEBHOOK_SECRET:-}"
|
||||
DEPLOY_LOGS_URL: "${DEPLOY_LOGS_URL:-}"
|
||||
# Optional replay hardening: when "true", the deploy webhook
|
||||
# requires a fresh X-Deploy-Webhook-Timestamp header (±5 min).
|
||||
# See docs/ops/deploy.md.
|
||||
DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP: "${DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP:-}"
|
||||
# TLS leaf SPKI-change alarm baseline (optional). The native client
|
||||
# pins the served TLS leaf; this is the known-good pin set the monitor
|
||||
# alarms against on a rotation. Comma-separated for the dual-pin
|
||||
# renewal window. Listed here so an operator value in .env reaches the
|
||||
# container (the `environment:` block is a whitelist). See
|
||||
# docs/ops/tls-cert-pin.md.
|
||||
TLS_LEAF_SPKI_PINS: "${TLS_LEAF_SPKI_PINS:-}"
|
||||
# Local-AI SSRF opt-in (optional). The local AI provider rejects
|
||||
# private/internal hosts by default; set this to an exact host allowlist
|
||||
# (e.g. "ollama.lan,10.0.0.5") or "true" for any private host, intended
|
||||
# for a self-hosted Ollama / LM Studio. Listed here so an operator value
|
||||
# in .env reaches the container (the `environment:` block is a whitelist).
|
||||
# See docs/integrations/ai-providers.md.
|
||||
ALLOW_LOCAL_AI_PRIVATE_HOSTS: "${ALLOW_LOCAL_AI_PRIVATE_HOSTS:-}"
|
||||
# Daily-briefing output-token ceiling (optional; default 2500, clamped
|
||||
# 500-8000). Raise when a verbose model's briefing JSON is cut off.
|
||||
# Listed here so an operator value in .env reaches the container (the
|
||||
# `environment:` block is a whitelist). See docs/integrations/ai-providers.md.
|
||||
INSIGHTS_MAX_TOKENS: "${INSIGHTS_MAX_TOKENS:-}"
|
||||
# v1.17.1 — SMTP transport for the email notification channel (optional,
|
||||
# all-or-none: HOST + PORT + FROM enable it; USER/PASS optional; SECURE
|
||||
# toggles implicit TLS). Listed here so operator values in .env reach the
|
||||
# container (the `environment:` block is a whitelist — vars not listed
|
||||
# never propagate from .env, even with `${VAR}` substitution at
|
||||
# compose-up). Per-user recipient is stored in the DB, not here.
|
||||
SMTP_HOST: "${SMTP_HOST:-}"
|
||||
SMTP_PORT: "${SMTP_PORT:-}"
|
||||
SMTP_FROM: "${SMTP_FROM:-}"
|
||||
SMTP_USER: "${SMTP_USER:-}"
|
||||
SMTP_PASS: "${SMTP_PASS:-}"
|
||||
SMTP_SECURE: "${SMTP_SECURE:-}"
|
||||
# v1.17.1 — Web Push VAPID keys (optional). The admin Settings panel stores
|
||||
# these in the DB (the supported path); these env vars are the fallback the
|
||||
# loader reads when the DB pair is unset. Listed here so the documented env
|
||||
# path reaches the container under the bundled compose. See
|
||||
# docs/self-hosting/notifications.md.
|
||||
VAPID_PUBLIC_KEY: "${VAPID_PUBLIC_KEY:-}"
|
||||
VAPID_PRIVATE_KEY: "${VAPID_PRIVATE_KEY:-}"
|
||||
VAPID_SUBJECT: "${VAPID_SUBJECT:-}"
|
||||
# APNs for a self-hosted native iOS build (optional, env-only — no admin UI
|
||||
# for the .p8). APNS_KEY_B64 is the escape-free base64 form preferred over
|
||||
# APNS_KEY/APNS_KEY_FILE; the key must be scoped "Sandbox & Production".
|
||||
# PWA users need none of this (Web Push covers them). See
|
||||
# docs/self-hosting/notifications.md.
|
||||
APNS_KEY_B64: "${APNS_KEY_B64:-}"
|
||||
APNS_KEY_ID: "${APNS_KEY_ID:-}"
|
||||
APNS_TEAM_ID: "${APNS_TEAM_ID:-}"
|
||||
APNS_BUNDLE_ID: "${APNS_BUNDLE_ID:-}"
|
||||
# Set "true" only if your own iOS build carries Apple's Critical Alerts
|
||||
# entitlement; otherwise urgent alerts use the time-sensitive level.
|
||||
APNS_CRITICAL_ENTITLEMENT: "${APNS_CRITICAL_ENTITLEMENT:-}"
|
||||
|
||||
services:
|
||||
# Single-container default (HEALTHLOG_PROCESS_TYPE=all). Existing
|
||||
# self-host deployments stay working with `docker compose up`.
|
||||
@@ -45,205 +238,52 @@ services:
|
||||
container_name: healthlog-app
|
||||
# The app runs as the unprivileged `nextjs` user and never needs a
|
||||
# setuid/setgid escalation; blocking privilege gain caps what a
|
||||
# compromised process inside the container can do.
|
||||
# compromised process inside the container can do. cap_drop removes
|
||||
# every Linux capability on top of that — the process doesn't need any
|
||||
# of them either. read_only:true is deliberately NOT set: the app
|
||||
# writes to a Next.js runtime cache, and turning the filesystem
|
||||
# read-only needs testing against that first, it's a not-yet, not an
|
||||
# oversight.
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
# connection_limit is set explicitly: Prisma's own default is
|
||||
# cpu_count*2+1, which starves the combined web+worker process
|
||||
# (HEALTHLOG_PROCESS_TYPE=all) under background AI/rollup load. Default
|
||||
# 20 suits a small host; raise DB_CONNECTION_LIMIT on a busy one (keep it
|
||||
# below the database's max_connections, leaving headroom for pg-boss,
|
||||
# migrations, and backups). pool_timeout is the seconds a query waits for
|
||||
# a free connection before erroring.
|
||||
DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public&connection_limit=${DB_CONNECTION_LIMIT:-20}&pool_timeout=${DB_POOL_TIMEOUT:-20}"
|
||||
NODE_ENV: production
|
||||
# Encryption at rest (AES-256-GCM). Generate each secret with
|
||||
# `openssl rand -hex 32` (a 64-char hex string = 32 bytes). ENCRYPTION_KEY
|
||||
# is the single-key path and is all most self-hosts need. The map form
|
||||
# (ENCRYPTION_KEYS={"v1":"<hex>","v2":"<hex>"} + ENCRYPTION_ACTIVE_KEY_ID=v2)
|
||||
# is for key rotation — see docs/ops/encryption-key-rotation.md. Keep these
|
||||
# values stable and backed up: lose them and the encrypted columns are
|
||||
# unrecoverable.
|
||||
ENCRYPTION_KEY: "${ENCRYPTION_KEY}"
|
||||
ENCRYPTION_KEYS: "${ENCRYPTION_KEYS:-}"
|
||||
ENCRYPTION_ACTIVE_KEY_ID: "${ENCRYPTION_ACTIVE_KEY_ID:-}"
|
||||
API_TOKEN_HMAC_KEY: "${API_TOKEN_HMAC_KEY}"
|
||||
NEXT_PUBLIC_APP_URL: "${NEXT_PUBLIC_APP_URL:-http://localhost:3000}"
|
||||
APP_URL: "${APP_URL:-http://localhost:3000}"
|
||||
# Session-cookie Secure-flag opt-out. SET THIS TO false FOR LAN / plain-HTTP
|
||||
# self-hosts (http://<host>:3000 with no TLS reverse proxy) — otherwise the
|
||||
# session cookie carries the `Secure` flag, the browser drops it over HTTP,
|
||||
# and login silently fails. Unset → the flag tracks NODE_ENV=production
|
||||
# (i.e. on); set to true to force it. Forwarded explicitly so an operator
|
||||
# value in .env reaches the Node process (the `environment:` block is a
|
||||
# whitelist — vars not listed here don't propagate even though
|
||||
# docker-compose reads .env for `${VAR}` substitution at compose-up time).
|
||||
SESSION_COOKIE_SECURE: "${SESSION_COOKIE_SECURE:-}"
|
||||
NATIVE_CANVAS: "${NATIVE_CANVAS:-}"
|
||||
WITHINGS_WEBHOOK_SECRET: "${WITHINGS_WEBHOOK_SECRET:-}"
|
||||
# v1.18.11 (W3) — client-IP resolution + geo lookup. Behind Cloudflare
|
||||
# the real visitor IP is in `cf-connecting-ip`; set
|
||||
# TRUST_CF_CONNECTING_IP=1 ONLY when Cloudflare is in front (off by
|
||||
# default — a direct-exposed host must not trust a forgeable header).
|
||||
# IP_GEO_LOOKUP_URL overrides the default ipwho.is provider (HTTPS by
|
||||
# default); IP_GEO_LOOKUP_DISABLED=1 stops all third-party IP egress.
|
||||
# IP_GEO_ALLOW_INSECURE=true permits a plain-HTTP provider URL (e.g. the
|
||||
# free ip-api.com endpoint) — off by default, an explicit operator choice.
|
||||
# Listed here so operator values in .env reach the container (the
|
||||
# `environment:` block is a whitelist — vars not listed never propagate).
|
||||
TRUST_CF_CONNECTING_IP: "${TRUST_CF_CONNECTING_IP:-}"
|
||||
IP_GEO_LOOKUP_URL: "${IP_GEO_LOOKUP_URL:-}"
|
||||
IP_GEO_LOOKUP_DISABLED: "${IP_GEO_LOOKUP_DISABLED:-}"
|
||||
IP_GEO_ALLOW_INSECURE: "${IP_GEO_ALLOW_INSECURE:-}"
|
||||
# Directory the offline GeoLite2 databases are read from. Default
|
||||
# `/opt/geolite2` — the path the published image bakes its own copy
|
||||
# into, if the build had a MaxMind licence key. Point this at a
|
||||
# directory you mount yourself (see the `volumes:` block below) to
|
||||
# bring your own GeoLite2-City.mmdb + GeoLite2-ASN.mmdb, refreshed
|
||||
# with `geoipupdate` on your own schedule. Listed here so an operator
|
||||
# value in .env reaches the container (the `environment:` block is a
|
||||
# whitelist). See docs/self-hosting/geolite2.md.
|
||||
GEOLITE2_DIR: "${GEOLITE2_DIR:-/opt/geolite2}"
|
||||
# all = bundle web + worker in this container (default, single-host)
|
||||
# web = serve HTTP only; pair with a separate app-worker service
|
||||
HEALTHLOG_PROCESS_TYPE: "${HEALTHLOG_PROCESS_TYPE:-all}"
|
||||
BACKUP_ENCRYPTION_KEY: "${BACKUP_ENCRYPTION_KEY:-}"
|
||||
BACKUP_S3_ENDPOINT: "${BACKUP_S3_ENDPOINT:-}"
|
||||
BACKUP_S3_BUCKET: "${BACKUP_S3_BUCKET:-}"
|
||||
BACKUP_S3_ACCESS_KEY: "${BACKUP_S3_ACCESS_KEY:-}"
|
||||
BACKUP_S3_SECRET_KEY: "${BACKUP_S3_SECRET_KEY:-}"
|
||||
BACKUP_S3_REGION: "${BACKUP_S3_REGION:-auto}"
|
||||
BACKUP_RETENTION_DAYS: "${BACKUP_RETENTION_DAYS:-30}"
|
||||
# Per-export ceiling on MedicationAdministration rows in the FHIR
|
||||
# health-record Bundle. Bounded 1..50000, default 5000. Listed here so
|
||||
# an operator override in .env actually reaches the container (the
|
||||
# `environment:` block is a whitelist).
|
||||
FHIR_MAX_MEDICATION_ADMINISTRATIONS: "${FHIR_MAX_MEDICATION_ADMINISTRATIONS:-}"
|
||||
# WHOOP integration (optional). The app client id/secret are per-user
|
||||
# BYO-keys stored encrypted in the DB, so there is no
|
||||
# WHOOP_CLIENT_ID/SECRET env. WHOOP_WEBHOOK_SECRET is the instance-level
|
||||
# path-segment + HMAC-verification secret for the webhook;
|
||||
# WHOOP_REDIRECT_URI overrides the derived
|
||||
# `${NEXT_PUBLIC_APP_URL}/api/whoop/callback` when the public URL
|
||||
# differs. Listed here so an operator override in .env reaches the
|
||||
# container (the `environment:` block is a whitelist).
|
||||
WHOOP_WEBHOOK_SECRET: "${WHOOP_WEBHOOK_SECRET:-}"
|
||||
WHOOP_REDIRECT_URI: "${WHOOP_REDIRECT_URI:-}"
|
||||
# v1.17.0 (F4) — Polar AccessLink + Oura Cloud OAuth (shared app from
|
||||
# env). Both pairs together enable each connect button; an unset pair
|
||||
# leaves that integration disabled. The optional *_REDIRECT_URI overrides
|
||||
# the derived `${NEXT_PUBLIC_APP_URL}/api/{polar,oura}/callback`. Listed
|
||||
# here so operator values in .env reach the container (the `environment:`
|
||||
# block is a whitelist).
|
||||
POLAR_CLIENT_ID: "${POLAR_CLIENT_ID:-}"
|
||||
POLAR_CLIENT_SECRET: "${POLAR_CLIENT_SECRET:-}"
|
||||
POLAR_REDIRECT_URI: "${POLAR_REDIRECT_URI:-}"
|
||||
OURA_CLIENT_ID: "${OURA_CLIENT_ID:-}"
|
||||
OURA_CLIENT_SECRET: "${OURA_CLIENT_SECRET:-}"
|
||||
OURA_REDIRECT_URI: "${OURA_REDIRECT_URI:-}"
|
||||
# v1.28.x — Strava OAuth (shared app from env; per-user BYO keys override).
|
||||
# Both together enable the connect button; an unset pair leaves it
|
||||
# disabled. STRAVA_REDIRECT_URI overrides the derived
|
||||
# `${NEXT_PUBLIC_APP_URL}/api/strava/callback`. Listed here so operator
|
||||
# values in .env reach the container (the `environment:` block is a
|
||||
# whitelist).
|
||||
STRAVA_CLIENT_ID: "${STRAVA_CLIENT_ID:-}"
|
||||
STRAVA_CLIENT_SECRET: "${STRAVA_CLIENT_SECRET:-}"
|
||||
STRAVA_REDIRECT_URI: "${STRAVA_REDIRECT_URI:-}"
|
||||
# OIDC SSO login (self-hosted IdP: Authentik/Keycloak/Authelia/etc).
|
||||
# All three of ISSUER_URL/CLIENT_ID/CLIENT_SECRET together enable the
|
||||
# "Sign in with SSO" button; set none to leave it disabled. OIDC_ONLY
|
||||
# hides password/passkey login entirely, but only takes effect when
|
||||
# the three above are also fully set — a half-configured group never
|
||||
# locks every user out. It IS enforced server-side, and there is no
|
||||
# OIDC flow for the native iOS client yet — enabling it locks iOS
|
||||
# users out of sign-in entirely. Listed here so operator values in
|
||||
# .env reach the container (the `environment:` block is a whitelist).
|
||||
OIDC_ISSUER_URL: "${OIDC_ISSUER_URL:-}"
|
||||
OIDC_CLIENT_ID: "${OIDC_CLIENT_ID:-}"
|
||||
OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET:-}"
|
||||
OIDC_SCOPES: "${OIDC_SCOPES:-}"
|
||||
OIDC_BUTTON_LABEL: "${OIDC_BUTTON_LABEL:-}"
|
||||
OIDC_ONLY: "${OIDC_ONLY:-}"
|
||||
# Deploy-result webhook (optional). DEPLOY_WEBHOOK_SECRET authenticates
|
||||
# POST /api/internal/deploy-webhook; DEPLOY_LOGS_URL is the
|
||||
# deployment-dashboard URL (e.g. the Coolify UI) linked in
|
||||
# deploy-failure notifications. Listed here so operator values in
|
||||
# .env reach the container (the `environment:` block is a whitelist).
|
||||
DEPLOY_WEBHOOK_SECRET: "${DEPLOY_WEBHOOK_SECRET:-}"
|
||||
DEPLOY_LOGS_URL: "${DEPLOY_LOGS_URL:-}"
|
||||
# Optional replay hardening: when "true", the deploy webhook
|
||||
# requires a fresh X-Deploy-Webhook-Timestamp header (±5 min).
|
||||
# See docs/ops/deploy.md.
|
||||
DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP: "${DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP:-}"
|
||||
# TLS leaf SPKI-change alarm baseline (optional). The native client
|
||||
# pins the served TLS leaf; this is the known-good pin set the monitor
|
||||
# alarms against on a rotation. Comma-separated for the dual-pin
|
||||
# renewal window. Listed here so an operator value in .env reaches the
|
||||
# container (the `environment:` block is a whitelist). See
|
||||
# docs/ops/tls-cert-pin.md.
|
||||
TLS_LEAF_SPKI_PINS: "${TLS_LEAF_SPKI_PINS:-}"
|
||||
# Local-AI SSRF opt-in (optional). The local AI provider rejects
|
||||
# private/internal hosts by default; set this to an exact host allowlist
|
||||
# (e.g. "ollama.lan,10.0.0.5") or "true" for any private host, intended
|
||||
# for a self-hosted Ollama / LM Studio. Listed here so an operator value
|
||||
# in .env reaches the container (the `environment:` block is a whitelist).
|
||||
# See docs/integrations/ai-providers.md.
|
||||
ALLOW_LOCAL_AI_PRIVATE_HOSTS: "${ALLOW_LOCAL_AI_PRIVATE_HOSTS:-}"
|
||||
# Daily-briefing output-token ceiling (optional; default 2500, clamped
|
||||
# 500-8000). Raise when a verbose model's briefing JSON is cut off.
|
||||
# Listed here so an operator value in .env reaches the container (the
|
||||
# `environment:` block is a whitelist). See docs/integrations/ai-providers.md.
|
||||
INSIGHTS_MAX_TOKENS: "${INSIGHTS_MAX_TOKENS:-}"
|
||||
# v1.17.1 — SMTP transport for the email notification channel (optional,
|
||||
# all-or-none: HOST + PORT + FROM enable it; USER/PASS optional; SECURE
|
||||
# toggles implicit TLS). Listed here so operator values in .env reach the
|
||||
# container (the `environment:` block is a whitelist — vars not listed
|
||||
# never propagate from .env, even with `${VAR}` substitution at
|
||||
# compose-up). Per-user recipient is stored in the DB, not here.
|
||||
SMTP_HOST: "${SMTP_HOST:-}"
|
||||
SMTP_PORT: "${SMTP_PORT:-}"
|
||||
SMTP_FROM: "${SMTP_FROM:-}"
|
||||
SMTP_USER: "${SMTP_USER:-}"
|
||||
SMTP_PASS: "${SMTP_PASS:-}"
|
||||
SMTP_SECURE: "${SMTP_SECURE:-}"
|
||||
# v1.17.1 — Web Push VAPID keys (optional). The admin Settings panel stores
|
||||
# these in the DB (the supported path); these env vars are the fallback the
|
||||
# loader reads when the DB pair is unset. Listed here so the documented env
|
||||
# path reaches the container under the bundled compose. See
|
||||
# docs/self-hosting/notifications.md.
|
||||
VAPID_PUBLIC_KEY: "${VAPID_PUBLIC_KEY:-}"
|
||||
VAPID_PRIVATE_KEY: "${VAPID_PRIVATE_KEY:-}"
|
||||
VAPID_SUBJECT: "${VAPID_SUBJECT:-}"
|
||||
# APNs for a self-hosted native iOS build (optional, env-only — no admin UI
|
||||
# for the .p8). APNS_KEY_B64 is the escape-free base64 form preferred over
|
||||
# APNS_KEY/APNS_KEY_FILE; the key must be scoped "Sandbox & Production".
|
||||
# PWA users need none of this (Web Push covers them). See
|
||||
# docs/self-hosting/notifications.md.
|
||||
APNS_KEY_B64: "${APNS_KEY_B64:-}"
|
||||
APNS_KEY_ID: "${APNS_KEY_ID:-}"
|
||||
APNS_TEAM_ID: "${APNS_TEAM_ID:-}"
|
||||
APNS_BUNDLE_ID: "${APNS_BUNDLE_ID:-}"
|
||||
# Set "true" only if your own iOS build carries Apple's Critical Alerts
|
||||
# entitlement; otherwise urgent alerts use the time-sensitive level.
|
||||
APNS_CRITICAL_ENTITLEMENT: "${APNS_CRITICAL_ENTITLEMENT:-}"
|
||||
<<: *healthlog_env
|
||||
# Bring your own GeoLite2 databases. The published image only carries
|
||||
# MMDBs if the build had a MaxMind licence key, so on `:latest` the
|
||||
# offline tier is usually absent and every login IP goes to the online
|
||||
# provider instead. Create a free MaxMind account, pull
|
||||
# GeoLite2-City.mmdb + GeoLite2-ASN.mmdb with `geoipupdate`, mount the
|
||||
# directory read-only, and set GEOLITE2_DIR to the container path.
|
||||
# directory read-only, and uncomment below — the container path here
|
||||
# matches GEOLITE2_DIR's default (/opt/geolite2), so this works as-is
|
||||
# once uncommented. Mounting at a different container path also works,
|
||||
# just also set GEOLITE2_DIR in .env to match it.
|
||||
# Steps: docs/self-hosting/geolite2.md.
|
||||
#
|
||||
# volumes:
|
||||
# - ./geolite2:/var/lib/geolite2:ro
|
||||
# - ./geolite2:/opt/geolite2:ro
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
# Bounded log rotation: the default json-file driver keeps every line
|
||||
# forever, which is exactly what fills the disk on the small NAS /
|
||||
# homelab hosts this file targets. 10m x 3 caps it at 30MB.
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
# Conservative memory ceiling for a small host. HEALTHLOG_PROCESS_TYPE=all
|
||||
# bundles the AI/rollup worker into this same process, and unbounded that
|
||||
# workload can grow large enough under load to starve Postgres of memory
|
||||
# on a shared host. 1g is a starting point, not a measured ceiling — raise
|
||||
# it with APP_MEMORY_LIMIT in .env if the app gets OOM-killed under real
|
||||
# load.
|
||||
mem_limit: "${APP_MEMORY_LIMIT:-1g}"
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
@@ -265,25 +305,23 @@ services:
|
||||
# context: .
|
||||
# dockerfile: Dockerfile
|
||||
# container_name: healthlog-worker
|
||||
# security_opt:
|
||||
# - no-new-privileges:true
|
||||
# cap_drop:
|
||||
# - ALL
|
||||
# environment:
|
||||
# DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public&connection_limit=${DB_CONNECTION_LIMIT:-20}&pool_timeout=${DB_POOL_TIMEOUT:-20}"
|
||||
# NODE_ENV: production
|
||||
# ENCRYPTION_KEY: "${ENCRYPTION_KEY}"
|
||||
# ENCRYPTION_KEYS: "${ENCRYPTION_KEYS:-}"
|
||||
# ENCRYPTION_ACTIVE_KEY_ID: "${ENCRYPTION_ACTIVE_KEY_ID:-}"
|
||||
# API_TOKEN_HMAC_KEY: "${API_TOKEN_HMAC_KEY}"
|
||||
# APP_URL: "${APP_URL:-http://localhost:3000}"
|
||||
# <<: *healthlog_env
|
||||
# HEALTHLOG_PROCESS_TYPE: "worker"
|
||||
# BACKUP_ENCRYPTION_KEY: "${BACKUP_ENCRYPTION_KEY:-}"
|
||||
# BACKUP_S3_ENDPOINT: "${BACKUP_S3_ENDPOINT:-}"
|
||||
# BACKUP_S3_BUCKET: "${BACKUP_S3_BUCKET:-}"
|
||||
# BACKUP_S3_ACCESS_KEY: "${BACKUP_S3_ACCESS_KEY:-}"
|
||||
# BACKUP_S3_SECRET_KEY: "${BACKUP_S3_SECRET_KEY:-}"
|
||||
# BACKUP_S3_REGION: "${BACKUP_S3_REGION:-auto}"
|
||||
# depends_on:
|
||||
# db:
|
||||
# condition: service_healthy
|
||||
# restart: unless-stopped
|
||||
# logging:
|
||||
# driver: json-file
|
||||
# options:
|
||||
# max-size: "10m"
|
||||
# max-file: "3"
|
||||
# mem_limit: "${WORKER_MEMORY_LIMIT:-1g}"
|
||||
|
||||
db:
|
||||
# Pinned to the concrete minor instead of the floating `16-alpine`
|
||||
@@ -294,6 +332,13 @@ services:
|
||||
# restore; a major-version bump (17+) is not.
|
||||
image: postgres:16.14-alpine
|
||||
container_name: healthlog-db
|
||||
# cap_drop: [ALL] is deliberately NOT applied here (unlike `app`): the
|
||||
# official Postgres entrypoint starts as root, chowns the data
|
||||
# directory, and drops privileges to the `postgres` user itself, which
|
||||
# needs CAP_CHOWN/CAP_SETUID/CAP_SETGID to do. Dropping all capabilities
|
||||
# makes that startup fail outright (verified locally — the container
|
||||
# exits with "failed switching to 'postgres': operation not permitted").
|
||||
# no-new-privileges still blocks privilege escalation beyond that.
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
environment:
|
||||
@@ -308,6 +353,19 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
# No `mem_limit` here, deliberately, and it is not an oversight. Bounding
|
||||
# Postgres is the more dangerous half of the pair: an app container the
|
||||
# kernel kills restarts clean, a database killed mid-write does not. With
|
||||
# only `app` bounded the app is what gives way first under host pressure,
|
||||
# which is the failure order we want. Raise APP_MEMORY_LIMIT before
|
||||
# reaching for a limit here.
|
||||
# Bounded log rotation, same reasoning as `app`: default json-file grows
|
||||
# without bound and fills the disk on a small NAS/homelab host.
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
+2154
-102
File diff suppressed because it is too large.
Load diff
@@ -131,6 +131,23 @@ without touching the file:
|
||||
- `HEALTHLOG_IMAGE_REF=:v1.16.5` — pin to a release tag (still mutable).
|
||||
- `HEALTHLOG_IMAGE_REF=@sha256:<digest>` — pin to the immutable digest.
|
||||
|
||||
> **A pin is sticky, and a stale pin is silent.** Once set, every later deploy
|
||||
> ships that reference until someone changes it. The deploy still reports
|
||||
> success, the tag still builds, the image still publishes, and the running
|
||||
> instance stays on the old release. If you pin, bumping the pin is part of
|
||||
> shipping, not an afterthought. If you would rather not carry that step, leave
|
||||
> the variable unset: `docker-publish.yml` rotates `:latest` on every release tag
|
||||
> without a pre-release suffix, so the git tag stays the single place a version is
|
||||
> declared.
|
||||
>
|
||||
> Two checks after any deploy, because the first alone can be served from a
|
||||
> cache: `/api/version` must report the version and commit you shipped, and the
|
||||
> running container's image must be the one you expect
|
||||
> (`docker inspect <container> --format '{{.Config.Image}}'`). A deploy that
|
||||
> finishes suspiciously fast is a signal rather than good news. Also watch for a
|
||||
> DUPLICATE `HEALTHLOG_IMAGE_REF` entry: Coolify accepts two rows with the same
|
||||
> key without warning, and which one wins is not defined.
|
||||
|
||||
### Promote a release to a pinned digest
|
||||
|
||||
`scripts/promote-digest.ts` automates the whole promotion after
|
||||
|
||||
@@ -111,6 +111,8 @@ const workoutDetail = {
|
||||
},
|
||||
aiInsight: null,
|
||||
canonicalId: A11Y_WORKOUT_ID,
|
||||
dayKey: "2026-07-20",
|
||||
previousWorkoutId: null,
|
||||
};
|
||||
|
||||
const medication = {
|
||||
@@ -363,6 +365,44 @@ async function installA11yMocks(page: Page) {
|
||||
}),
|
||||
);
|
||||
|
||||
// The workout detail page's day context reads the day's pulse shape and
|
||||
// the night the person woke from into it.
|
||||
await page.route("**/api/insights/pulse/intraday*", (route) =>
|
||||
fulfilJson(route, {
|
||||
dateKey: "2026-07-20",
|
||||
bucketMinutes: 10,
|
||||
series: [
|
||||
{ startMinute: 420, mean: 62, count: 6, min: 58, max: 70 },
|
||||
{ startMinute: 430, mean: 118, count: 6, min: 96, max: 141 },
|
||||
],
|
||||
baseline: 58,
|
||||
baselineSource: "resting",
|
||||
tension: null,
|
||||
resolution: "dense",
|
||||
}),
|
||||
);
|
||||
|
||||
await page.route("**/api/sleep/night*", (route) =>
|
||||
fulfilJson(route, {
|
||||
night: "2026-07-20",
|
||||
main: {
|
||||
night: "2026-07-20",
|
||||
source: "APPLE_HEALTH",
|
||||
start: "2026-07-19T22:10:00.000Z",
|
||||
end: "2026-07-20T05:45:00.000Z",
|
||||
asleepMinutes: 415,
|
||||
inBedMinutes: 455,
|
||||
awakeMinutes: 40,
|
||||
awakenings: 2,
|
||||
reconstructed: false,
|
||||
stages: {},
|
||||
segments: [],
|
||||
sourceDiscrepancy: null,
|
||||
},
|
||||
naps: [],
|
||||
}),
|
||||
);
|
||||
|
||||
await page.route("**/api/workouts**", (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname === `/api/workouts/${A11Y_WORKOUT_ID}`) {
|
||||
|
||||
@@ -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() },
|
||||
},
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { mockPopulatedInsights } from "./utils/mock-populated-insights";
|
||||
import {
|
||||
expectNoHorizontalOverflow,
|
||||
settleForOverflowMeasurement,
|
||||
} from "./utils/horizontal-overflow";
|
||||
|
||||
/**
|
||||
* The Health Score panel at 360 px.
|
||||
*
|
||||
* The panel is the widest-content block in the hero band: a fixed label
|
||||
* column, a fill bar, a value column and a popover trigger on one line, plus
|
||||
* a coalesced failed-read line carrying an inline button, plus a disclosure
|
||||
* whose open state adds a bordered tile and three prose paragraphs. 360 px is
|
||||
* the narrowest common Android width and the one this overview has scrolled
|
||||
* sideways at before.
|
||||
*
|
||||
* Two ways a guard like this proves nothing, both of which have bitten this
|
||||
* repo: measuring only `document.scrollWidth` when `AuthShell` scrolls inside
|
||||
* `<main>`, and measuring an empty page. The shared helper closes the first;
|
||||
* the fixture and the fixture-honesty test below close the second by asserting
|
||||
* the panel is actually on screen with rows in it before any width is read.
|
||||
*
|
||||
* Assertions are on `data-slot` attributes, never on rendered text, so a
|
||||
* locale change or a copy edit cannot turn this red for the wrong reason.
|
||||
*/
|
||||
|
||||
const PANEL = '[data-slot="health-score-card"]';
|
||||
|
||||
test.describe("the Health Score panel at phone width", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeEach(async ({}, testInfo) => {
|
||||
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
|
||||
});
|
||||
|
||||
test("is on screen with its rows before anything is measured", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockPopulatedInsights(page);
|
||||
await page.setViewportSize({ width: 360, height: 851 });
|
||||
await page.goto("/insights", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const panel = page.locator(PANEL);
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(
|
||||
panel.locator('[data-slot="health-score-card-number"]'),
|
||||
).toBeVisible();
|
||||
// Four scored pillars in the fixture; a panel with no rows would fit any
|
||||
// width and prove nothing about the row geometry.
|
||||
await expect(panel.locator("li[data-status='ok']")).toHaveCount(4);
|
||||
// The failed read and its retry are visible without opening anything.
|
||||
await expect(
|
||||
panel.locator('[data-slot="health-score-pillar-error"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
panel.locator('[data-slot="health-score-pillar-retry"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
panel.locator('[data-slot="health-score-crisis"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
panel.locator('[data-slot="health-score-not-scored-count"]'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("does not scroll the page sideways, closed or open", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockPopulatedInsights(page);
|
||||
await page.setViewportSize({ width: 360, height: 851 });
|
||||
await page.goto("/insights", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const panel = page.locator(PANEL);
|
||||
await expect(panel).toBeVisible();
|
||||
await settleForOverflowMeasurement(page);
|
||||
await expectNoHorizontalOverflow(page, "/insights score panel @360px");
|
||||
|
||||
// Opened, the disclosure adds the bordered weight-goal tile and the method
|
||||
// paragraphs — the widest content the panel can hold.
|
||||
await panel.locator('[data-slot="health-score-anatomy-toggle"]').click();
|
||||
await expect(
|
||||
panel.locator('[data-slot="health-score-weight-goal"]'),
|
||||
).toBeVisible();
|
||||
await settleForOverflowMeasurement(page);
|
||||
await expectNoHorizontalOverflow(page, "/insights score panel open @360px");
|
||||
});
|
||||
|
||||
test("keeps the panel inside the band, never wider than it", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockPopulatedInsights(page);
|
||||
await page.setViewportSize({ width: 360, height: 851 });
|
||||
await page.goto("/insights", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const panel = page.locator(PANEL);
|
||||
await expect(panel).toBeVisible();
|
||||
await panel.locator('[data-slot="health-score-anatomy-toggle"]').click();
|
||||
await expect(
|
||||
panel.locator('[data-slot="health-score-weight-goal"]'),
|
||||
).toBeVisible();
|
||||
|
||||
const measured = await panel.evaluate((el) => {
|
||||
const band = el.closest('[data-slot="insights-hero-strip"]');
|
||||
// The widest descendant that is not contained by its own scroller.
|
||||
let widest = 0;
|
||||
for (const child of Array.from(el.querySelectorAll("*"))) {
|
||||
const r = child.getBoundingClientRect();
|
||||
if (r.width > widest) widest = r.width;
|
||||
}
|
||||
return {
|
||||
panelScroll: el.scrollWidth,
|
||||
panelClient: el.clientWidth,
|
||||
panelRight: Math.round(el.getBoundingClientRect().right),
|
||||
bandRight: band ? Math.round(band.getBoundingClientRect().right) : null,
|
||||
widestChild: Math.round(widest),
|
||||
};
|
||||
});
|
||||
|
||||
expect(
|
||||
measured.panelScroll,
|
||||
`panel scrolls its own content: ${JSON.stringify(measured)}`,
|
||||
).toBeLessThanOrEqual(measured.panelClient + 1);
|
||||
expect(measured.bandRight).not.toBeNull();
|
||||
expect(
|
||||
measured.panelRight,
|
||||
`panel extends past the band: ${JSON.stringify(measured)}`,
|
||||
).toBeLessThanOrEqual(measured.bandRight! + 1);
|
||||
expect(
|
||||
measured.widestChild,
|
||||
`a child is wider than the panel: ${JSON.stringify(measured)}`,
|
||||
).toBeLessThanOrEqual(measured.panelClient + 1);
|
||||
});
|
||||
});
|
||||
@@ -68,12 +68,22 @@ test.describe("settings + admin vertical over-scroll guard", () => {
|
||||
// long when the page is ready and too short on a loaded runner, and
|
||||
// it never says what it is waiting for. Gate on the element the
|
||||
// measurement reads instead.
|
||||
await settleBeforeMeasure(page, page.locator("#main-content"));
|
||||
const wrapperLocator = page.locator(
|
||||
'#main-content [data-slot="main-content-wrapper"]',
|
||||
);
|
||||
await settleBeforeMeasure(page, wrapperLocator);
|
||||
|
||||
const dims = await page.evaluate(() => {
|
||||
const main = document.getElementById("main-content");
|
||||
if (!main) return null;
|
||||
const wrapper = main.firstElementChild as HTMLElement | null;
|
||||
// Selected by the stable `data-slot` (auth-shell.tsx), not by
|
||||
// birth order: `firstElementChild` would resolve to whatever
|
||||
// lazily-mounted sibling (portal, toast, hydrating region) landed
|
||||
// first, silently measuring the wrong subtree instead of the
|
||||
// real content wrapper.
|
||||
const wrapper = main.querySelector<HTMLElement>(
|
||||
'[data-slot="main-content-wrapper"]',
|
||||
);
|
||||
if (!wrapper) return null;
|
||||
// Lowest content edge: max CONTENT-box bottom over the wrapper's
|
||||
// visible, non-fixed descendants, in the scroll container's
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { settleBeforeMeasure } from "./utils/settle";
|
||||
|
||||
/**
|
||||
* Settings → Health Score: the surface that decides which pillars count.
|
||||
*
|
||||
* Everything asserted here goes through stable `data-slot` / `data-*`
|
||||
* attributes rather than viewport-dependent text, so the spec keeps working
|
||||
* at any width and in any locale (only the copy assertions read English, and
|
||||
* the suite pins the English locale through the storage state).
|
||||
*
|
||||
* The refusal case is driven through the REAL write. Deselecting down to
|
||||
* activity and wellbeing genuinely fails the server's breadth rule, and a
|
||||
* refused write persists nothing, so the case leaves the shared account
|
||||
* exactly as it found it. Mocking the 422 would have proved that the page
|
||||
* can render a message, not that the rule is enforced or that the page reads
|
||||
* the reason the server actually sends.
|
||||
*
|
||||
* The analytics read IS mocked, because it is the page's secondary read and
|
||||
* the states it drives — waiting for data, safety signposting — depend on
|
||||
* what the seeded account happens to hold. Fixing it makes those two rows
|
||||
* deterministic without touching the write path under test.
|
||||
*/
|
||||
|
||||
/** A slim Health Score report: only the fields the settings page reads. */
|
||||
function scoreReport() {
|
||||
const coverage = {
|
||||
requiredInputs: 3,
|
||||
presentInputs: 2,
|
||||
historyDays: 14,
|
||||
missing: [],
|
||||
};
|
||||
const provenance = {
|
||||
inputs: [],
|
||||
source: "live",
|
||||
windowDays: 90,
|
||||
computedAt: new Date().toISOString(),
|
||||
};
|
||||
const insufficient = (reason: string) => ({
|
||||
status: "insufficient",
|
||||
reason,
|
||||
coverage,
|
||||
provenance,
|
||||
});
|
||||
return {
|
||||
composite: insufficient("three_domains_required"),
|
||||
pillars: [
|
||||
{
|
||||
id: "LIPIDS",
|
||||
domain: "cardiometabolic",
|
||||
result: insufficient("not_tracked"),
|
||||
},
|
||||
{
|
||||
id: "WELLBEING",
|
||||
domain: "wellbeing",
|
||||
result: insufficient("crisis_signposting"),
|
||||
},
|
||||
{
|
||||
id: "BLOOD_PRESSURE",
|
||||
domain: "cardiometabolic",
|
||||
result: insufficient("read_failed"),
|
||||
},
|
||||
],
|
||||
delta: null,
|
||||
deltaReason: "no_current_score",
|
||||
scoreVersion: 2,
|
||||
weightGoal: insufficient("no_goal"),
|
||||
// Raised and never dismissed, which is the state every account is in:
|
||||
// nothing has ever rendered this notice.
|
||||
algorithmNotice: { itemKey: "health_score_algorithm:2", dismissed: false },
|
||||
};
|
||||
}
|
||||
|
||||
async function mockAnalytics(page: import("@playwright/test").Page) {
|
||||
await page.route(/\/api\/analytics(\?|$)/, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { summaries: {}, healthScore: scoreReport() },
|
||||
error: null,
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Settings → Health Score", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockAnalytics(page);
|
||||
});
|
||||
|
||||
test("groups the pillars by area and makes the three axes legible", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/settings/score", { waitUntil: "domcontentloaded" });
|
||||
const card = page.locator('[data-slot="score-config-card"]');
|
||||
await settleBeforeMeasure(page, card);
|
||||
|
||||
// The heading + subheading pair the settings IA requires, owned by the
|
||||
// shell and not re-declared inside the card. The shell renders the pair
|
||||
// twice under distinct ids — `…-title` inside a `hidden md:block` column
|
||||
// and `…-title-mobile` inside an `md:hidden` one — so exactly one of them
|
||||
// is on screen at any width. Asserting the desktop id alone passed on the
|
||||
// wide project and failed on the narrow one against a heading that is
|
||||
// hidden by design.
|
||||
const heading = page
|
||||
.locator(
|
||||
"#settings-section-score-title, #settings-section-score-title-mobile",
|
||||
)
|
||||
.filter({ has: page.locator(":scope:visible") });
|
||||
await expect(heading).toHaveCount(1);
|
||||
await expect(heading.first()).toBeVisible();
|
||||
await expect(
|
||||
heading.first().locator("xpath=following-sibling::p[1]"),
|
||||
).toBeVisible();
|
||||
|
||||
// One concept, one card. A second card beside it would be the
|
||||
// top/bottom split of one decision the rules forbid; the notice is a
|
||||
// different concept and is allowed its own.
|
||||
await expect(page.locator('[data-slot="score-config-card"]')).toHaveCount(
|
||||
1,
|
||||
);
|
||||
|
||||
// The cardiometabolic triple, visibly under one heading.
|
||||
const cardio = page.locator(
|
||||
'[data-slot="score-config-group"][data-domain="cardiometabolic"]',
|
||||
);
|
||||
await expect(cardio).toHaveCount(1);
|
||||
await expect(cardio.locator('[data-slot="score-pillar-row"]')).toHaveCount(
|
||||
3,
|
||||
);
|
||||
|
||||
// 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"]');
|
||||
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("offers a switch on every pillar it lists", async ({ page }) => {
|
||||
await page.goto("/settings/score", { waitUntil: "domcontentloaded" });
|
||||
const rows = page.locator('[data-slot="score-pillar-row"]');
|
||||
await settleBeforeMeasure(page, rows.first());
|
||||
|
||||
// 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(
|
||||
page.locator('[data-slot="score-pillar-row"][data-pillar="FITNESS"]'),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("tells waiting for data apart from safety guidance and a failed read", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/settings/score", { waitUntil: "domcontentloaded" });
|
||||
const card = page.locator('[data-slot="score-config-card"]');
|
||||
await settleBeforeMeasure(page, card);
|
||||
|
||||
const state = (pillar: string) =>
|
||||
page.locator(
|
||||
`[data-slot="score-pillar-row"][data-pillar="${pillar}"] [data-slot="score-pillar-state"]`,
|
||||
);
|
||||
|
||||
await expect(state("LIPIDS")).toHaveAttribute("data-state", "waiting");
|
||||
// Safety signposting is its own state and never a configuration error.
|
||||
await expect(state("WELLBEING")).toHaveAttribute("data-state", "crisis");
|
||||
// A failed read is a failure, not absence.
|
||||
await expect(state("BLOOD_PRESSURE")).toHaveAttribute(
|
||||
"data-state",
|
||||
"read_failed",
|
||||
);
|
||||
});
|
||||
|
||||
test("renders the score notice and lets it be dismissed", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/settings/score", { waitUntil: "domcontentloaded" });
|
||||
const notice = page.locator('[data-slot="score-change-notice"]');
|
||||
await settleBeforeMeasure(page, notice);
|
||||
|
||||
await expect(notice).toBeVisible();
|
||||
|
||||
let dismissed: string | null = null;
|
||||
await page.route(/\/api\/daily\/digest\/dismiss$/, async (route) => {
|
||||
dismissed = String(route.request().postDataJSON()?.itemKey ?? "");
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: { dismissed: true }, error: null }),
|
||||
});
|
||||
});
|
||||
// The refetch after the dismissal has to come back already dismissed,
|
||||
// or the notice would simply be raised again and the test would be
|
||||
// asserting a race rather than the behaviour.
|
||||
await page.route(/\/api\/analytics(\?|$)/, async (route) => {
|
||||
const report = scoreReport();
|
||||
report.algorithmNotice.dismissed = true;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { summaries: {}, healthScore: report },
|
||||
error: null,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await notice.locator('[data-slot="score-change-notice-dismiss"]').click();
|
||||
|
||||
await expect(notice).toHaveCount(0);
|
||||
expect(dismissed).toBe("health_score_algorithm:2");
|
||||
});
|
||||
|
||||
test("refuses a selection too narrow to produce a score, in plain language", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/settings/score", { waitUntil: "domcontentloaded" });
|
||||
const card = page.locator('[data-slot="score-config-card"]');
|
||||
await settleBeforeMeasure(page, card);
|
||||
|
||||
// Start from everything, then take out every pillar except activity and
|
||||
// wellbeing: two areas of health and no physical measurement, which the
|
||||
// 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"]');
|
||||
for (let index = 0; index < (await rows.count()); index += 1) {
|
||||
const row = rows.nth(index);
|
||||
const pillar = await row.getAttribute("data-pillar");
|
||||
if (!pillar || keep.has(pillar)) continue;
|
||||
if ((await row.getAttribute("data-counts")) === "false") continue;
|
||||
await row.locator('[data-slot="score-pillar-switch"]').click();
|
||||
}
|
||||
|
||||
await page.locator('[data-slot="score-config-save"]').click();
|
||||
|
||||
const refusal = page.locator('[data-slot="score-config-refusal"]');
|
||||
await expect(refusal).toBeVisible();
|
||||
await expect(refusal).toHaveAttribute("role", "alert");
|
||||
// The server's machine reason, rendered as a sentence a person can act
|
||||
// on, not as an error code and not as the server's English fallback.
|
||||
await expect(refusal).toContainText("physical measurement");
|
||||
await expect(refusal).not.toContainText("_required");
|
||||
});
|
||||
|
||||
test("keeps the content column the same width as its neighbours", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name !== "chromium-desktop",
|
||||
"viewport-driven assertion; desktop project only",
|
||||
);
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
|
||||
async function columnWidth(route: string, sentinel: string) {
|
||||
await page.goto(route, { waitUntil: "domcontentloaded" });
|
||||
await settleBeforeMeasure(page, page.locator(sentinel));
|
||||
return page.evaluate(() => {
|
||||
const column = document.querySelector("section[aria-labelledby]");
|
||||
const grid = column?.closest("div.grid") ?? null;
|
||||
const cell = grid?.lastElementChild ?? null;
|
||||
return cell ? Math.round(cell.getBoundingClientRect().width) : -1;
|
||||
});
|
||||
}
|
||||
|
||||
const modules = await columnWidth(
|
||||
"/settings/modules",
|
||||
'[data-slot="card"]',
|
||||
);
|
||||
const score = await columnWidth(
|
||||
"/settings/score",
|
||||
'[data-slot="score-config-card"]',
|
||||
);
|
||||
|
||||
expect(modules).toBeGreaterThan(0);
|
||||
expect(Math.abs(score - modules)).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
+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();
|
||||
}
|
||||
|
||||
@@ -97,6 +97,115 @@ const COINCIDENT_FIRED = {
|
||||
reason: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* A populated Health Score report for the hero band's score column.
|
||||
*
|
||||
* The panel is the widest-content block in the band: a fixed-width label
|
||||
* column, a fill bar, a value column and a popover trigger all on one 20 px
|
||||
* line, plus a coalesced failed-read line that carries an inline button. An
|
||||
* empty score leaves the column unmounted entirely, so without this the
|
||||
* phone-width guard measured a band that had no panel in it.
|
||||
*
|
||||
* Every state the panel can paint is present at once: scored rows, a failed
|
||||
* read with its retry, safety signposting, and counted absence.
|
||||
*/
|
||||
function scorePillar(id: string, domain: string, score: number) {
|
||||
return {
|
||||
id,
|
||||
domain,
|
||||
result: {
|
||||
status: "ok" as const,
|
||||
value: {
|
||||
score,
|
||||
observed: {
|
||||
value: 126,
|
||||
unit: "mmHg",
|
||||
label: "126/78 mmHg",
|
||||
asOf: NOW,
|
||||
sources: ["MANUAL"],
|
||||
},
|
||||
reference: {
|
||||
kind: "clinical-threshold" as const,
|
||||
low: 120,
|
||||
high: 129,
|
||||
label: "120 to 129/70 to 79 mmHg",
|
||||
source: "ESH 2023",
|
||||
},
|
||||
noiseFloor: 1,
|
||||
deltaEligible: true,
|
||||
deltaIdentity: id,
|
||||
},
|
||||
coverage: coverage(),
|
||||
confidence: confidence(),
|
||||
provenance: provenance([id]),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function gatedPillar(id: string, domain: string, reason: string) {
|
||||
return {
|
||||
id,
|
||||
domain,
|
||||
result: {
|
||||
status: "insufficient" as const,
|
||||
coverage: {
|
||||
requiredInputs: 4,
|
||||
presentInputs: 0,
|
||||
historyDays: 0,
|
||||
missing: [] as string[],
|
||||
},
|
||||
provenance: provenance([id]),
|
||||
reason,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const HEALTH_SCORE = {
|
||||
composite: {
|
||||
status: "ok" as const,
|
||||
value: {
|
||||
score: 74,
|
||||
band: "green" as const,
|
||||
bandSetter: "SLEEP",
|
||||
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP", "ADIPOSITY"],
|
||||
configured: false,
|
||||
noiseFloor: 3,
|
||||
scoreVersion: 2,
|
||||
},
|
||||
coverage: coverage(),
|
||||
confidence: confidence(),
|
||||
provenance: provenance(["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"]),
|
||||
},
|
||||
pillars: [
|
||||
scorePillar("BLOOD_PRESSURE", "cardiometabolic", 56),
|
||||
scorePillar("ACTIVITY", "activity", 88),
|
||||
scorePillar("SLEEP", "sleep", 41),
|
||||
scorePillar("ADIPOSITY", "adiposity", 72),
|
||||
gatedPillar("LIPIDS", "cardiometabolic", "read_failed"),
|
||||
gatedPillar("WELLBEING", "wellbeing", "crisis_signposting"),
|
||||
gatedPillar("GLYCAEMIA", "cardiometabolic", "not_tracked"),
|
||||
],
|
||||
delta: 2,
|
||||
deltaReason: null,
|
||||
scoreVersion: 2,
|
||||
weightGoal: {
|
||||
status: "ok" as const,
|
||||
value: {
|
||||
currentKg: 81,
|
||||
target: { min: 74, max: 78 },
|
||||
distanceKg: 3,
|
||||
deltaKg: 1,
|
||||
asOf: NOW,
|
||||
source: "MANUAL",
|
||||
},
|
||||
coverage: coverage(),
|
||||
confidence: confidence(),
|
||||
provenance: provenance(["WEIGHT"]),
|
||||
},
|
||||
algorithmNotice: null,
|
||||
restMode: null,
|
||||
};
|
||||
|
||||
/** A deterministic sparkline series around `center`. */
|
||||
function series(center: number, spread: number): number[] {
|
||||
return Array.from({ length: 30 }, (_, i) =>
|
||||
@@ -253,6 +362,28 @@ export async function mockPopulatedInsights(page: Page): Promise<void> {
|
||||
}),
|
||||
);
|
||||
|
||||
// The hero band's score column. The real response is fetched and the score
|
||||
// injected into it, so every OTHER field the page reads off `/api/analytics`
|
||||
// stays authentic and this fixture cannot silently blank a section it was
|
||||
// never meant to touch.
|
||||
await page.route(/\/api\/analytics(\?|$)/, async (route) => {
|
||||
const response = await route.fetch();
|
||||
let payload: { data?: Record<string, unknown> | null; error?: unknown };
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
payload = { data: {}, error: null };
|
||||
}
|
||||
await route.fulfill({
|
||||
response,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
data: { ...(payload.data ?? {}), healthScore: HEALTH_SCORE },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// The single-metric derived route. Only COINCIDENT_DEVIATION is stubbed —
|
||||
// it is the card whose heading carries the provenance sentence. Any other
|
||||
// metric falls through to the real handler.
|
||||
|
||||
@@ -32,6 +32,17 @@ export async function settleBeforeMeasure(
|
||||
} else {
|
||||
await expect(sentinel).toBeVisible();
|
||||
}
|
||||
// A shell can be visible while the data underneath it is still arriving,
|
||||
// and a placeholder standing in for that data occupies height like any
|
||||
// other content. Measuring then reports the geometry of a page that no
|
||||
// longer exists a moment later, and blames the application for it. The
|
||||
// suite marks those placeholders with a `-loading` slot, so wait for them
|
||||
// to go. Never fail on the wait: a page that legitimately keeps one would
|
||||
// otherwise turn every measurement on it into a timeout, which is a worse
|
||||
// failure than the one being prevented.
|
||||
await expect(page.locator('[data-slot$="-loading"]'))
|
||||
.toHaveCount(0, { timeout: 5_000 })
|
||||
.catch(() => {});
|
||||
// Fonts and late CSS change geometry after the element is visible, and
|
||||
// every measurement in this suite is geometric.
|
||||
await page.waitForLoadState("networkidle").catch(() => {});
|
||||
|
||||
@@ -21,25 +21,38 @@ test.describe("v1.34.1 navigation geometry", () => {
|
||||
|
||||
const heading = page.getByRole("heading", { level: 1 });
|
||||
const sidebar = page.locator("aside").first();
|
||||
// The wrapper carries a stable `data-slot` (auth-shell.tsx) so both
|
||||
// reads below measure the same DOM node instead of resolving
|
||||
// `firstElementChild` twice — a lazily-mounted sibling (portal,
|
||||
// toast, hydrating region) landing ahead of it between the two
|
||||
// reads used to substitute a different element into the second
|
||||
// measurement, producing an unrelated number rather than a
|
||||
// drifted one. Resolving the handle once removes the second
|
||||
// resolution step entirely.
|
||||
const wrapper = page.locator('[data-slot="main-content-wrapper"]');
|
||||
await settleBeforeMeasure(page, heading);
|
||||
await expect(sidebar).toBeVisible();
|
||||
await expect(wrapper).toBeVisible();
|
||||
const wrapperHandle = await wrapper.elementHandle();
|
||||
if (!wrapperHandle) {
|
||||
throw new Error("main content wrapper not found");
|
||||
}
|
||||
|
||||
const before = await page.evaluate(() => {
|
||||
const before = await page.evaluate((wrapperEl) => {
|
||||
const main = document.getElementById("main-content");
|
||||
const heading = main?.querySelector("h1");
|
||||
const aside = main?.querySelector("aside");
|
||||
const wrapper = main?.firstElementChild;
|
||||
if (!main || !heading || !aside || !wrapper) return null;
|
||||
if (!main || !heading || !aside || !wrapperEl) return null;
|
||||
return {
|
||||
headingTop: heading.getBoundingClientRect().top,
|
||||
asideTop: aside.getBoundingClientRect().top,
|
||||
wrapperLeft: wrapper.getBoundingClientRect().left,
|
||||
wrapperLeft: wrapperEl.getBoundingClientRect().left,
|
||||
mainWidth: main.clientWidth,
|
||||
documentScrollable:
|
||||
document.documentElement.scrollHeight >
|
||||
document.documentElement.clientHeight + 1,
|
||||
};
|
||||
});
|
||||
}, wrapperHandle);
|
||||
|
||||
expect(before).not.toBeNull();
|
||||
if (!before) return;
|
||||
@@ -51,26 +64,33 @@ test.describe("v1.34.1 navigation geometry", () => {
|
||||
await page.locator("#main-content").evaluate((main) => {
|
||||
main.scrollTop = Math.min(320, main.scrollHeight - main.clientHeight);
|
||||
});
|
||||
await page.waitForFunction(() => {
|
||||
// Gate on the wrapper being present and laid out (not just on
|
||||
// scroll state) — the measurement below reads it, so the wait
|
||||
// must cover everything that measurement depends on.
|
||||
await page.waitForFunction((wrapperEl) => {
|
||||
const main = document.getElementById("main-content");
|
||||
if (!main) return true;
|
||||
const wrapperReady =
|
||||
!!wrapperEl &&
|
||||
wrapperEl.isConnected &&
|
||||
wrapperEl.getBoundingClientRect().width > 0;
|
||||
return (
|
||||
!main ||
|
||||
main.scrollTop > 0 ||
|
||||
main.scrollHeight <= main.clientHeight
|
||||
wrapperReady &&
|
||||
(main.scrollTop > 0 || main.scrollHeight <= main.clientHeight)
|
||||
);
|
||||
});
|
||||
}, wrapperHandle);
|
||||
|
||||
const after = await page.evaluate(() => {
|
||||
const after = await page.evaluate((wrapperEl) => {
|
||||
const main = document.getElementById("main-content");
|
||||
const aside = main?.querySelector("aside");
|
||||
const wrapper = main?.firstElementChild;
|
||||
if (!main || !aside || !wrapper) return null;
|
||||
if (!main || !aside || !wrapperEl) return null;
|
||||
return {
|
||||
asideTop: aside.getBoundingClientRect().top,
|
||||
wrapperLeft: wrapper.getBoundingClientRect().left,
|
||||
wrapperLeft: wrapperEl.getBoundingClientRect().left,
|
||||
mainWidth: main.clientWidth,
|
||||
};
|
||||
});
|
||||
}, wrapperHandle);
|
||||
await wrapperHandle.dispose();
|
||||
|
||||
expect(after).not.toBeNull();
|
||||
if (!after) return;
|
||||
|
||||
@@ -63,7 +63,8 @@
|
||||
"testcontainers",
|
||||
"tw-animate-css",
|
||||
"@napi-rs/canvas",
|
||||
"@openai/codex-security"
|
||||
"@openai/codex-security",
|
||||
"postcss"
|
||||
],
|
||||
"ignoreBinaries": ["docker", "docker-compose", "pg_isready", "tsx", "cosign"],
|
||||
"ignoreExportsUsedInFile": true,
|
||||
|
||||
+111
-18
@@ -120,6 +120,13 @@
|
||||
"outOfOrder": "Dein Onboarding-Fortschritt hat sich geändert. Lade die Seite neu und fahre fort.",
|
||||
"concurrent": "Dein Onboarding-Fortschritt hat sich geändert. Lade die Seite neu und fahre fort."
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"update": {
|
||||
"nothingSaved": "Es wurde nichts gespeichert. Bitte das markierte Feld korrigieren und erneut versuchen.",
|
||||
"invalidBody": "Die Profilaktualisierung konnte nicht gelesen werden.",
|
||||
"emailInUse": "Diese E-Mail-Adresse wird bereits verwendet."
|
||||
}
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -2261,14 +2268,19 @@
|
||||
"yellow": "Einige Bereiche brauchen Aufmerksamkeit",
|
||||
"red": "Mindestens ein Bereich liegt außerhalb des Referenzbereichs"
|
||||
},
|
||||
"insufficient": "{count} geeignete Bereiche. Der Score benötigt mindestens drei, darunter einen gemessenen physiologischen Bereich.",
|
||||
"insufficient": "{count} von {required} Feldern der Gesundheit haben genug aktuelle Daten. Der Score braucht {required}, mindestens eines davon körperlich gemessen.",
|
||||
"anatomyToggle": "Wie dieser Score zustande kommt",
|
||||
"notScored": "Noch nicht bewertet ({count})",
|
||||
"method": "Der Score ist der gleich gewichtete Mittelwert der Säulen, die für ihn zählen, auf einer Skala von 0 bis 100. Jede Säule wird an einem veröffentlichten Referenzbereich gemessen, und die schwächste Säule kann den Gesamtbereich bestimmen. Eine Säule ohne ausreichend aktuelle Daten bleibt außen vor und zählt nicht als null. Die Referenzbereiche stammen aus veröffentlichten Quellen; welche Säulen zählen, entscheidest du selbst, deshalb ist die zusammengefasste Zahl selbst kein klinischer Standard.",
|
||||
"bandSetter": "Gesamtstufe bestimmt durch {pillar}",
|
||||
"versionAndNoise": "Methodenversion {version}. Schwelle für den Wochenvergleich: {noise} Punkte.",
|
||||
"methodVersion": "Methodenversion {version}.",
|
||||
"configured": "Du hast festgelegt, welche Bereiche in diesen Score einfließen.",
|
||||
"none": "keiner",
|
||||
"delta": "{delta} gegenüber dem vorherigen vergleichbaren Zeitraum",
|
||||
"deltaReason": {
|
||||
"algorithm_changed": "Kein Vergleich, weil sich die Score-Methode geändert hat.",
|
||||
"config_changed": "Kein Vergleich, weil du geändert hast, was in deinen Score einfließt.",
|
||||
"composition_changed": "Kein Vergleich, weil sich die einbezogenen Bereiche geändert haben.",
|
||||
"first_eligibility_window": "Kein Vergleich, weil dies der erste geeignete Zeitraum ist.",
|
||||
"below_noise_floor": "Die Veränderung liegt innerhalb der gemeinsamen Messschwankung.",
|
||||
@@ -2283,7 +2295,6 @@
|
||||
"sleep": "Schlaf",
|
||||
"adiposity": "Körperfettverteilung",
|
||||
"wellbeing": "Wohlbefinden",
|
||||
"fitness": "Gemessene Fitness",
|
||||
"lipids": "Blutfette"
|
||||
},
|
||||
"observed": "Beobachtet: {value}",
|
||||
@@ -2291,15 +2302,18 @@
|
||||
"fastingObserved": "Nüchternglukose: {value} {unit}",
|
||||
"fastingReference": "{low} bis {high} {unit} nüchtern",
|
||||
"personalReference": "Dein Ziel: {value}",
|
||||
"bpBasisAbove": "Ausschlaggebend: {axis}, etwa {offset} mmHg über der Obergrenze von {boundary} mmHg.",
|
||||
"bpBasisBelowFloor": "Ausschlaggebend: {axis}, etwa {offset} mmHg unter der Grenze für niedrigen Blutdruck von {boundary} mmHg.",
|
||||
"bpBasisInBand": "Beide Werte liegen auf oder unter ihren Referenz-Obergrenzen.",
|
||||
"bpScale": "An der Obergrenze liegt der Wert bei 85; 100 bedeutet rund 12 mmHg darunter.",
|
||||
"bpRepresentative": "Beruht auf einem Durchschnitt der letzten {days} Tage, der neuere Messungen stärker gewichtet, und kann deshalb von jeder einzelnen Messung abweichen.",
|
||||
"source": "Quelle: {source}, Stand {date}",
|
||||
"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",
|
||||
@@ -2320,7 +2334,6 @@
|
||||
"insufficientHistory": "Nicht genügend aktuelle geeignete Daten",
|
||||
"unavailable": "Dieser Bereich kann derzeit nicht bewertet werden"
|
||||
},
|
||||
"pillarInsufficient": "Kein Bereichsscore: {reason}",
|
||||
"tension": "Die Bereitschaftssignale unterscheiden sich. Unterstützend: {positive}. Belastet: {negative}.",
|
||||
"returnToBand": "{metric} liegt seit {days} Tagen wieder im üblichen Bereich.",
|
||||
"weightGoal": {
|
||||
@@ -2332,6 +2345,11 @@
|
||||
"weight_not_tracked": "Erfasse dein Gewicht, um den Fortschritt zum Ziel zu sehen.",
|
||||
"read_failed": "Gewichtsdaten konnten nicht geladen werden. Versuche es erneut.",
|
||||
"notScored": "Der Fortschritt zum Gewichtsziel ist persönlicher Kontext und beeinflusst den Gesundheitsscore nicht."
|
||||
},
|
||||
"configureLink": "Festlegen, was in deinen Score einfließt",
|
||||
"coverage": {
|
||||
"minimumMet": "Deckt die drei Felder ab, die der Score braucht",
|
||||
"areas": "{present} von {required} Feldern der Gesundheit"
|
||||
}
|
||||
},
|
||||
"suggestedPrompts": {
|
||||
@@ -2959,6 +2977,8 @@
|
||||
"asleep": "Schlafend",
|
||||
"awake": "Wach",
|
||||
"inBed": "Im Bett",
|
||||
"nap": "Nickerchen",
|
||||
"napCount": "{count} Nickerchen",
|
||||
"unavailable": "Noch keine Phasen-Daten – Schlafphasen erscheinen hier, sobald eine verbundene Quelle sie liefert."
|
||||
},
|
||||
"empty": {
|
||||
@@ -3100,9 +3120,15 @@
|
||||
"splitsTime": "Zeit",
|
||||
"splitsPace": "Tempo",
|
||||
"thatDayTitle": "An diesem Tag",
|
||||
"dayPulse": "Puls",
|
||||
"daySleep": "Schlaf",
|
||||
"dayMood": "Stimmung",
|
||||
"daySleepTitle": "Die Nacht davor",
|
||||
"daySleepNone": "Für diese Nacht ist kein Schlaf erfasst",
|
||||
"daySleepAwakeningsOne": "{count}× aufgewacht",
|
||||
"daySleepAwakeningsMany": "{count}× aufgewacht",
|
||||
"dayMoodTitle": "Stimmung an diesem Tag",
|
||||
"dayMoodNone": "An diesem Tag ist keine Stimmung erfasst",
|
||||
"sleepTrendsLink": "Schlaf-Trends",
|
||||
"moodHistoryLink": "Stimmungsverlauf",
|
||||
"previousSessionLink": "Vorherige Einheit",
|
||||
"insightTitle": "Aktivitäts-Einblick"
|
||||
},
|
||||
"sport": {
|
||||
@@ -3131,7 +3157,13 @@
|
||||
"loadError": "Deine Workouts konnten nicht geladen werden",
|
||||
"loadMore": "Weitere Workouts laden",
|
||||
"loadingMore": "Weitere Workouts werden geladen",
|
||||
"loadMoreError": "Weitere Workouts konnten nicht geladen werden"
|
||||
"loadMoreError": "Weitere Workouts konnten nicht geladen werden",
|
||||
"countOne": "{count} Workout",
|
||||
"countOther": "{count} Workouts",
|
||||
"countFew": "{count} Workouts",
|
||||
"filterLabel": "Nach Sportart filtern",
|
||||
"filterAll": "Alle Sportarten",
|
||||
"filterEmpty": "Keine Einheiten dieser Sportart in deinem Verlauf."
|
||||
},
|
||||
"bloodGlucose": {
|
||||
"title": "Blutzucker",
|
||||
@@ -3579,7 +3611,8 @@
|
||||
"summary": "{present} von {required} Eingaben · {percent}%",
|
||||
"ratioLabel": "{present}/{required}",
|
||||
"historyDays": "Basiert auf {count} Tagen Verlauf",
|
||||
"missing": "Erfasse diese, um den Wert zu schärfen: {list}"
|
||||
"missing": "Erfasse diese, um den Wert zu schärfen: {list}",
|
||||
"summaryLabelled": "{label} · {percent}%"
|
||||
},
|
||||
"scoreRing": {
|
||||
"aria": "Wert {score} von 100, {band}",
|
||||
@@ -4868,6 +4901,60 @@
|
||||
"fetchFailedOne": "Die letzte Wetteraktualisierung im Hintergrund ist fehlgeschlagen ({when}).",
|
||||
"fetchFailedFew": "Die letzten {count} Wetteraktualisierungen im Hintergrund sind fehlgeschlagen, zuletzt {when}.",
|
||||
"fetchFailedOther": "Die letzten {count} Wetteraktualisierungen im Hintergrund sind fehlgeschlagen, zuletzt {when}."
|
||||
},
|
||||
"score": {
|
||||
"title": "Gesundheitsscore",
|
||||
"subtitle": "Lege fest, welche Bereiche in deinen Score einfließen.",
|
||||
"card": {
|
||||
"title": "Was in deinen Score einfließt",
|
||||
"description": "Jeder Bereich, den du aufzeichnest, zählt mit, solange du hier nichts anderes sagst."
|
||||
},
|
||||
"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": {
|
||||
"countsFor": "{pillar} in den Score einrechnen"
|
||||
},
|
||||
"domain": {
|
||||
"cardiometabolic": "Herz und Stoffwechsel",
|
||||
"activity": "Aktivität",
|
||||
"sleep": "Schlaf",
|
||||
"adiposity": "Körperform",
|
||||
"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."
|
||||
},
|
||||
"presets": {
|
||||
"label": "Ausgangspunkt",
|
||||
"all": "Alle Bereiche",
|
||||
"current": "Deine gespeicherte Auswahl"
|
||||
},
|
||||
"save": "Speichern",
|
||||
"saved": "Gespeichert.",
|
||||
"error": "Konnte nicht gespeichert werden. Bitte versuche es erneut.",
|
||||
"loadFailed": "Deine Auswahl konnte nicht geladen werden.",
|
||||
"eligibilityFailed": "Es ließ sich nicht prüfen, für welche Bereiche gerade Daten vorliegen. Die Schalter unten funktionieren trotzdem.",
|
||||
"omitted": "{count} Bereiche stehen nicht in der Liste, weil ihre Erfassung abgeschaltet ist. Schalte ein Modul wieder ein, dann kommt sein Bereich zurück.",
|
||||
"omittedLink": "Module öffnen",
|
||||
"refusal": {
|
||||
"physiological": "Ein Health Score braucht mindestens einen körperlich gemessenen Wert. Behalte Blutdruck, Glukose, Schlaf, Körperform oder Cholesterin in deiner Auswahl, zusätzlich zu allem anderen, was du wählst.",
|
||||
"domains": "Ein Health Score braucht mindestens drei verschiedene Felder der Gesundheit. Blutdruck, Glukose und Cholesterin beschreiben dasselbe Feld, eine Auswahl nur daraus zählt also als eines."
|
||||
},
|
||||
"notice": {
|
||||
"upgrade": {
|
||||
"title": "Was zählt, entscheidest jetzt du",
|
||||
"body": "Welche Bereiche in deinen Health Score einflossen, richtete sich früher danach, welche Module du eingeschaltet hattest. Das ist jetzt eine eigene Entscheidung, und hier triffst du sie. Deine Zahl hat sich dadurch nicht bewegt: Sie zählt weiterhin genau das, was sie vorher gezählt hat."
|
||||
},
|
||||
"changed": {
|
||||
"title": "Du hast geändert, was zählt",
|
||||
"body": "Seit {date} wird dein Score aus einer anderen Zusammenstellung berechnet. Die Tage davor stammen aus der alten Zusammenstellung, beide werden deshalb nicht verglichen und die Wochenveränderung bleibt ausgesetzt, bis der Vergleichszeitraum die Änderung hinter sich hat. Der Verlauf jedes einzelnen Bereichs läuft ungebrochen durch.",
|
||||
"bodyUndated": "Dein Score wird aus einer anderen Zusammenstellung berechnet als früher. Die Tage davor stammen aus der alten Zusammenstellung, beide werden deshalb nicht verglichen und die Wochenveränderung bleibt ausgesetzt, bis der Vergleichszeitraum die Änderung hinter sich hat. Der Verlauf jedes einzelnen Bereichs läuft ungebrochen durch."
|
||||
},
|
||||
"dismiss": "Verstanden",
|
||||
"dismissError": "Konnte nicht gespeichert werden. Bitte versuche es erneut."
|
||||
}
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
@@ -4906,6 +4993,8 @@
|
||||
"timezoneHint": "Wird für Diagrammachsen, Erinnerungszeiten, Export-Zeitstempel und den Coach-Kontext verwendet. Gespeicherte Daten bleiben unverändert.",
|
||||
"timezoneInvalid": "Keine gültige IANA-Zeitzone.",
|
||||
"profileSaved": "Profil gespeichert",
|
||||
"profilePartiallySaved": "Profil gespeichert, aber {field} konnte nicht aktualisiert werden.",
|
||||
"profileNothingSaved": "Es wurde nichts gespeichert. Bitte {field} korrigieren und erneut versuchen.",
|
||||
"avatar": {
|
||||
"title": "Profilfoto",
|
||||
"description": "Lade ein Foto hoch, das auf deinem eigenen Server gespeichert wird. Es erscheint überall in der App anstelle eines externen Avatar-Dienstes.",
|
||||
@@ -5643,7 +5732,6 @@
|
||||
"cycleTracking": {
|
||||
"title": "Zyklus-Tracking",
|
||||
"description": "Verfolge deinen Menstruationszyklus, Vorhersagen, Symptome und Phasen-Insights. Standardmäßig an für als weiblich eingestellte Konten; für alle verfügbar.",
|
||||
"disclaimer": "Vorhersagen und Phasen sind beschreibende Schätzungen aus deinen erfassten Daten – keine medizinische Beratung und keine Verhütungsmethode. Sie zeigen nie an, dass ungeschützter Geschlechtsverkehr sicher ist. Die Genauigkeit steigt, je mehr Zyklen du erfasst. Wende dich für Verhütung oder medizinische Entscheidungen an eine medizinische Fachperson.",
|
||||
"enable": "Zyklus-Tracking aktivieren",
|
||||
"manage": "Zyklus-Tracking öffnen",
|
||||
"error": "Zyklus-Tracking konnte nicht aktualisiert werden."
|
||||
@@ -6154,10 +6242,6 @@
|
||||
"correlations": {
|
||||
"title": "Korrelationen",
|
||||
"description": "Erzähl-Kachel zur Korrelation auf der Insights-Übersicht."
|
||||
},
|
||||
"healthScoreExplainer": {
|
||||
"title": "Health-Score-Erklärer",
|
||||
"description": "Das ?-Symbol neben der Health-Score-Differenz. Die Zahl bleibt unabhängig davon sichtbar."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -7429,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": {
|
||||
@@ -9088,7 +9173,17 @@
|
||||
"moduleOffDescription": "Aktiviere das Nährstoffe-Modul, um Flüssigkeits- und Vitamin-/Mineralstoffaufnahme hier zu sehen. Synchronisierte Werte werden Teil der Aufzeichnung, die der Coach lesen kann.",
|
||||
"moduleOffCta": "Nährstoffe aktivieren",
|
||||
"emptyTitle": "Noch keine Nährstoffdaten",
|
||||
"emptyDescription": "Daten kommen aus der Synchronisierung der Gesundheits-App deines Telefons oder einem manuellen Wassereintrag."
|
||||
"emptyDescription": "Daten kommen aus der Synchronisierung der Gesundheits-App deines Telefons oder einem manuellen Wassereintrag.",
|
||||
"loadError": "Deine Nährstoffdaten konnten nicht geladen werden",
|
||||
"lastAttemptTitle": "Eine Synchronisierung ist angekommen, aber nichts konnte gespeichert werden",
|
||||
"lastAttemptAt": "Letzter Versuch: {date}",
|
||||
"lastAttemptReason": {
|
||||
"unitMismatch": "Die übermittelten Werte entsprachen nicht den Einheiten, die diese App für diese Nährstoffe erwartet, daher konnten sie nicht gespeichert werden.",
|
||||
"valueOutOfRange": "Die übermittelten Mengen lagen höher, als diese App plausibel speichern kann, daher konnten sie nicht gesichert werden.",
|
||||
"dayInvalid": "Die Einträge trugen ein Datum, das der Server nicht akzeptieren konnte. Das kann passieren, wenn Uhrzeit oder Zeitzone auf der sendenden Seite falsch eingestellt sind — das lohnt sich zu prüfen.",
|
||||
"upsertFailed": "Diese Einträge haben den Server erreicht, aber ein Problem auf unserer Seite hat verhindert, dass sie gespeichert wurden. Du musst nichts weiter tun.",
|
||||
"unknown": "Diese Einträge sind angekommen, konnten aber nicht gespeichert werden."
|
||||
}
|
||||
},
|
||||
"hydration": {
|
||||
"referenceMeta": "Richtwert {value} {unit}/Tag · EFSA",
|
||||
@@ -9194,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"
|
||||
|
||||
+111
-18
@@ -120,6 +120,13 @@
|
||||
"outOfOrder": "Your onboarding progress changed. Refresh and continue.",
|
||||
"concurrent": "Your onboarding progress changed. Refresh and continue."
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"update": {
|
||||
"nothingSaved": "Nothing was saved. Check the highlighted field and try again.",
|
||||
"invalidBody": "The profile update could not be read.",
|
||||
"emailInUse": "This email address is already in use."
|
||||
}
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -2261,14 +2268,19 @@
|
||||
"yellow": "Some pillars need attention",
|
||||
"red": "One or more pillars are outside the reference range"
|
||||
},
|
||||
"insufficient": "{count} eligible pillars. The score needs at least three, including a measured physiological pillar.",
|
||||
"insufficient": "{count} of {required} areas of health have enough recent data. The score needs {required}, at least one of them a physical measurement.",
|
||||
"anatomyToggle": "How this score comes about",
|
||||
"notScored": "Not scored yet ({count})",
|
||||
"method": "The score is the equal-weighted average of the pillars that count toward it, on a 0 to 100 scale. Each pillar is graded against a published reference band, and the weakest pillar can set the overall band. A pillar without enough recent data is left out rather than counted as a zero. The reference bands come from published sources; which pillars count is yours to choose, so the combined number is not itself a clinical standard.",
|
||||
"bandSetter": "Overall band set by {pillar}",
|
||||
"versionAndNoise": "Method version {version}. Weekly comparison floor: {noise} points.",
|
||||
"methodVersion": "Method version {version}.",
|
||||
"configured": "You chose which pillars count toward this score.",
|
||||
"none": "none",
|
||||
"delta": "{delta} vs the previous comparable window",
|
||||
"deltaReason": {
|
||||
"algorithm_changed": "No comparison because the score method changed.",
|
||||
"config_changed": "No comparison because you changed what counts toward your score.",
|
||||
"composition_changed": "No comparison because the included pillars changed.",
|
||||
"first_eligibility_window": "No comparison because this is the first eligible window.",
|
||||
"below_noise_floor": "Change is within the combined measurement noise.",
|
||||
@@ -2283,7 +2295,6 @@
|
||||
"sleep": "Sleep",
|
||||
"adiposity": "Adiposity",
|
||||
"wellbeing": "Wellbeing",
|
||||
"fitness": "Measured fitness",
|
||||
"lipids": "Lipids"
|
||||
},
|
||||
"observed": "Observed: {value}",
|
||||
@@ -2291,15 +2302,18 @@
|
||||
"fastingObserved": "Fasting glucose: {value} {unit}",
|
||||
"fastingReference": "{low} to {high} {unit} fasting",
|
||||
"personalReference": "Your target: {value}",
|
||||
"bpBasisAbove": "{axis} sets this score: about {offset} mmHg over its ceiling of {boundary} mmHg.",
|
||||
"bpBasisBelowFloor": "{axis} sets this score: about {offset} mmHg under the low-pressure floor of {boundary} mmHg.",
|
||||
"bpBasisInBand": "Both values are at or below their reference ceilings.",
|
||||
"bpScale": "At the ceiling the score is 85; 100 means roughly 12 mmHg below it.",
|
||||
"bpRepresentative": "Based on a recency-weighted average of the last {days} days, so it may not match any single reading.",
|
||||
"source": "Source: {source}, as of {date}",
|
||||
"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",
|
||||
@@ -2320,7 +2334,6 @@
|
||||
"insufficientHistory": "Not enough recent eligible data",
|
||||
"unavailable": "This pillar is not currently eligible"
|
||||
},
|
||||
"pillarInsufficient": "No pillar score: {reason}",
|
||||
"tension": "Readiness signals differ. Supportive: {positive}. Under strain: {negative}.",
|
||||
"returnToBand": "{metric} has been back inside your usual range for {days} days.",
|
||||
"weightGoal": {
|
||||
@@ -2332,6 +2345,11 @@
|
||||
"weight_not_tracked": "Log a weight reading to see progress toward your goal.",
|
||||
"read_failed": "Weight data could not be loaded. Try again.",
|
||||
"notScored": "Weight-goal progress is personal context and does not affect the Health Score."
|
||||
},
|
||||
"configureLink": "Choose what counts toward your score",
|
||||
"coverage": {
|
||||
"minimumMet": "Covers the three areas the score needs",
|
||||
"areas": "{present} of {required} areas of health"
|
||||
}
|
||||
},
|
||||
"suggestedPrompts": {
|
||||
@@ -2959,6 +2977,8 @@
|
||||
"asleep": "Asleep",
|
||||
"awake": "Awake",
|
||||
"inBed": "In bed",
|
||||
"nap": "Nap",
|
||||
"napCount": "{count} naps",
|
||||
"unavailable": "No stage data yet — sleep stages appear here as soon as a connected source delivers them."
|
||||
},
|
||||
"empty": {
|
||||
@@ -3100,9 +3120,15 @@
|
||||
"splitsTime": "Time",
|
||||
"splitsPace": "Pace",
|
||||
"thatDayTitle": "That day",
|
||||
"dayPulse": "Pulse",
|
||||
"daySleep": "Sleep",
|
||||
"dayMood": "Mood",
|
||||
"daySleepTitle": "The night before",
|
||||
"daySleepNone": "No sleep recorded for that night",
|
||||
"daySleepAwakeningsOne": "{count} awakening",
|
||||
"daySleepAwakeningsMany": "{count} awakenings",
|
||||
"dayMoodTitle": "Mood that day",
|
||||
"dayMoodNone": "No mood logged that day",
|
||||
"sleepTrendsLink": "Sleep trends",
|
||||
"moodHistoryLink": "Mood history",
|
||||
"previousSessionLink": "Previous session",
|
||||
"insightTitle": "Activity insight"
|
||||
},
|
||||
"sport": {
|
||||
@@ -3131,7 +3157,13 @@
|
||||
"loadError": "Your workouts could not be loaded",
|
||||
"loadMore": "Load more workouts",
|
||||
"loadingMore": "Loading more workouts",
|
||||
"loadMoreError": "More workouts could not be loaded"
|
||||
"loadMoreError": "More workouts could not be loaded",
|
||||
"countOne": "{count} workout",
|
||||
"countOther": "{count} workouts",
|
||||
"countFew": "{count} workouts",
|
||||
"filterLabel": "Filter by sport",
|
||||
"filterAll": "All sports",
|
||||
"filterEmpty": "No sessions of this sport in your history."
|
||||
},
|
||||
"bloodGlucose": {
|
||||
"title": "Blood Glucose",
|
||||
@@ -3579,7 +3611,8 @@
|
||||
"summary": "{present} of {required} inputs · {percent}%",
|
||||
"ratioLabel": "{present}/{required}",
|
||||
"historyDays": "Based on {count} days of history",
|
||||
"missing": "Track these to sharpen this: {list}"
|
||||
"missing": "Track these to sharpen this: {list}",
|
||||
"summaryLabelled": "{label} · {percent}%"
|
||||
},
|
||||
"scoreRing": {
|
||||
"aria": "Score {score} out of 100, {band}",
|
||||
@@ -4868,6 +4901,60 @@
|
||||
"fetchFailedOne": "The last background weather update failed ({when}).",
|
||||
"fetchFailedFew": "The last {count} background weather updates failed, most recently {when}.",
|
||||
"fetchFailedOther": "The last {count} background weather updates failed, most recently {when}."
|
||||
},
|
||||
"score": {
|
||||
"title": "Health Score",
|
||||
"subtitle": "Choose which pillars count toward your score.",
|
||||
"card": {
|
||||
"title": "What counts toward your score",
|
||||
"description": "Every pillar you record counts unless you say otherwise here."
|
||||
},
|
||||
"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": {
|
||||
"countsFor": "Count {pillar} toward the score"
|
||||
},
|
||||
"domain": {
|
||||
"cardiometabolic": "Heart and metabolism",
|
||||
"activity": "Activity",
|
||||
"sleep": "Sleep",
|
||||
"adiposity": "Body shape",
|
||||
"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."
|
||||
},
|
||||
"presets": {
|
||||
"label": "Start from",
|
||||
"all": "All pillars",
|
||||
"current": "Your saved selection"
|
||||
},
|
||||
"save": "Save",
|
||||
"saved": "Saved.",
|
||||
"error": "Couldn't save. Please try again.",
|
||||
"loadFailed": "Couldn't load your selection.",
|
||||
"eligibilityFailed": "Couldn't check which pillars currently have data. The switches below still work.",
|
||||
"omitted": "{count} pillars are not listed because their tracking is switched off. Turn a module back on and its pillar comes back.",
|
||||
"omittedLink": "Open modules",
|
||||
"refusal": {
|
||||
"physiological": "A health score needs at least one physical measurement. Keep one of blood pressure, glucose, sleep, body shape or cholesterol in your selection, alongside whatever else you choose.",
|
||||
"domains": "A health score needs at least three different areas of health. Blood pressure, glucose and cholesterol all describe the same area, so a selection made only of those counts as one."
|
||||
},
|
||||
"notice": {
|
||||
"upgrade": {
|
||||
"title": "What counts is now yours to choose",
|
||||
"body": "Which pillars counted toward your Health Score used to follow the modules you had switched on. It is a separate choice now, and this page is where you make it. Your number did not move when this arrived: it still counts exactly what it counted before."
|
||||
},
|
||||
"changed": {
|
||||
"title": "You changed what counts",
|
||||
"body": "Since {date} your score is worked out from a different set of pillars. Days before that were worked out from the old set, so the two are not compared and the weekly change stays paused until the comparison window is clear of the change. Each pillar's own history runs straight through it.",
|
||||
"bodyUndated": "Your score is worked out from a different set of pillars than it used to be. Days before the change were worked out from the old set, so the two are not compared and the weekly change stays paused until the comparison window is clear of the change. Each pillar's own history runs straight through it."
|
||||
},
|
||||
"dismiss": "Got it",
|
||||
"dismissError": "Couldn't save that. Please try again."
|
||||
}
|
||||
}
|
||||
},
|
||||
"profile": "Profile",
|
||||
@@ -4906,6 +4993,8 @@
|
||||
"timezoneHint": "Used for chart axis labels, reminder times, export timestamps, and Coach context. Stored data is unchanged.",
|
||||
"timezoneInvalid": "Not a valid IANA timezone.",
|
||||
"profileSaved": "Profile saved",
|
||||
"profilePartiallySaved": "Profile saved, but {field} could not be updated.",
|
||||
"profileNothingSaved": "Nothing was saved. Fix {field} and try again.",
|
||||
"avatar": {
|
||||
"title": "Profile photo",
|
||||
"description": "Upload a photo stored on your own server. It appears across the app in place of an external avatar service.",
|
||||
@@ -5643,7 +5732,6 @@
|
||||
"cycleTracking": {
|
||||
"title": "Cycle tracking",
|
||||
"description": "Track your menstrual cycle, predictions, symptoms, and phase insights. On by default for accounts set to female; available to everyone.",
|
||||
"disclaimer": "Predictions and phases are descriptive estimates from your logged data — not medical advice and not a contraceptive method. They never indicate that it is safe to have unprotected sex. Accuracy improves as you log more cycles. For contraception or any medical decision, consult a healthcare professional.",
|
||||
"enable": "Enable cycle tracking",
|
||||
"manage": "Open cycle tracking",
|
||||
"error": "Couldn't update cycle tracking."
|
||||
@@ -6154,10 +6242,6 @@
|
||||
"correlations": {
|
||||
"title": "Correlations",
|
||||
"description": "Correlation tile narration on the Insights overview."
|
||||
},
|
||||
"healthScoreExplainer": {
|
||||
"title": "Health-Score explainer",
|
||||
"description": "The ? glyph next to the Health-Score delta line. The delta digit stays visible regardless."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -7429,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": {
|
||||
@@ -9088,7 +9173,17 @@
|
||||
"moduleOffDescription": "Turn on the nutrients module to see hydration and vitamin/mineral intake here. Synced intake becomes part of the record the Coach can read.",
|
||||
"moduleOffCta": "Turn on nutrients",
|
||||
"emptyTitle": "No nutrient data yet",
|
||||
"emptyDescription": "Data arrives from your phone's health app sync or a manual water entry."
|
||||
"emptyDescription": "Data arrives from your phone's health app sync or a manual water entry.",
|
||||
"loadError": "Your nutrient data could not be loaded",
|
||||
"lastAttemptTitle": "A sync arrived, but nothing could be stored",
|
||||
"lastAttemptAt": "Last attempt: {date}",
|
||||
"lastAttemptReason": {
|
||||
"unitMismatch": "The values that arrived didn't match the units this app expects for these nutrients, so they couldn't be stored.",
|
||||
"valueOutOfRange": "The amounts that arrived were higher than this app can plausibly store, so they couldn't be saved.",
|
||||
"dayInvalid": "The entries carried a date the server couldn't accept. This can happen when the clock or time zone on the sending side is wrong — it's worth checking.",
|
||||
"upsertFailed": "These entries reached the server, but a problem on this end kept them from being saved. There's nothing you need to do.",
|
||||
"unknown": "These entries arrived but couldn't be stored."
|
||||
}
|
||||
},
|
||||
"hydration": {
|
||||
"referenceMeta": "Reference intake {value} {unit}/day · EFSA",
|
||||
@@ -9194,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"
|
||||
|
||||
+111
-18
@@ -120,6 +120,13 @@
|
||||
"outOfOrder": "Tu progreso ha cambiado. Actualiza la página y continúa.",
|
||||
"concurrent": "Tu progreso ha cambiado. Actualiza la página y continúa."
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"update": {
|
||||
"nothingSaved": "No se guardó nada. Revisa el campo marcado e inténtalo de nuevo.",
|
||||
"invalidBody": "No se pudo leer la actualización del perfil.",
|
||||
"emailInUse": "Esta dirección de correo ya está en uso."
|
||||
}
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -2261,14 +2268,19 @@
|
||||
"yellow": "Algunos pilares requieren atención",
|
||||
"red": "Uno o más pilares están fuera del intervalo de referencia"
|
||||
},
|
||||
"insufficient": "{count} pilares aptos. La puntuación necesita al menos tres, incluido un pilar fisiológico medido.",
|
||||
"insufficient": "{count} de {required} ámbitos de salud tienen suficientes datos recientes. La puntuación necesita {required}, y al menos uno debe ser una medición física.",
|
||||
"anatomyToggle": "Cómo se forma esta puntuación",
|
||||
"notScored": "Aún sin puntuar ({count})",
|
||||
"method": "La puntuación es la media con igual peso de los pilares que cuentan para ella, en una escala de 0 a 100. Cada pilar se compara con un rango de referencia publicado, y el pilar más débil puede fijar el rango general. Un pilar sin datos recientes suficientes queda fuera en lugar de contar como cero. Los rangos de referencia proceden de fuentes publicadas; qué pilares cuentan lo eliges tú, así que el número combinado no es en sí mismo un estándar clínico.",
|
||||
"bandSetter": "Categoría general determinada por {pillar}",
|
||||
"versionAndNoise": "Versión del método {version}. Umbral de comparación semanal: {noise} puntos.",
|
||||
"methodVersion": "Versión del método {version}.",
|
||||
"configured": "Tú has elegido qué pilares cuentan para esta puntuación.",
|
||||
"none": "ninguno",
|
||||
"delta": "{delta} frente al periodo comparable anterior",
|
||||
"deltaReason": {
|
||||
"algorithm_changed": "No hay comparación porque cambió el método de puntuación.",
|
||||
"config_changed": "No hay comparación porque cambiaste lo que cuenta para tu puntuación.",
|
||||
"composition_changed": "No hay comparación porque cambiaron los pilares incluidos.",
|
||||
"first_eligibility_window": "No hay comparación porque este es el primer periodo apto.",
|
||||
"below_noise_floor": "El cambio está dentro de la variación conjunta de las mediciones.",
|
||||
@@ -2283,7 +2295,6 @@
|
||||
"sleep": "Sueño",
|
||||
"adiposity": "Adiposidad",
|
||||
"wellbeing": "Bienestar",
|
||||
"fitness": "Forma física medida",
|
||||
"lipids": "Lípidos"
|
||||
},
|
||||
"observed": "Observado: {value}",
|
||||
@@ -2291,15 +2302,18 @@
|
||||
"fastingObserved": "Glucosa en ayunas: {value} {unit}",
|
||||
"fastingReference": "{low} a {high} {unit} en ayunas",
|
||||
"personalReference": "Tu objetivo: {value}",
|
||||
"bpBasisAbove": "Determinante: {axis}, unos {offset} mmHg por encima de su límite de {boundary} mmHg.",
|
||||
"bpBasisBelowFloor": "Determinante: {axis}, unos {offset} mmHg por debajo del límite de presión baja de {boundary} mmHg.",
|
||||
"bpBasisInBand": "Ambos valores están en su límite de referencia o por debajo.",
|
||||
"bpScale": "En el límite la puntuación es 85; 100 significa unos 12 mmHg por debajo.",
|
||||
"bpRepresentative": "Se basa en un promedio de los últimos {days} días que pondera más las mediciones recientes, por lo que puede no coincidir con ninguna medición concreta.",
|
||||
"source": "Fuente: {source}, a fecha de {date}",
|
||||
"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",
|
||||
@@ -2320,7 +2334,6 @@
|
||||
"insufficientHistory": "No hay suficientes datos recientes aptos",
|
||||
"unavailable": "Este pilar no reúne los requisitos actualmente"
|
||||
},
|
||||
"pillarInsufficient": "Sin puntuación del pilar: {reason}",
|
||||
"tension": "Las señales de preparación difieren. Favorables: {positive}. Con tensión: {negative}.",
|
||||
"returnToBand": "{metric} lleva {days} días de nuevo dentro de tu intervalo habitual.",
|
||||
"weightGoal": {
|
||||
@@ -2332,6 +2345,11 @@
|
||||
"weight_not_tracked": "Registra una lectura de peso para ver el progreso hacia tu objetivo.",
|
||||
"read_failed": "No se pudieron cargar los datos de peso. Inténtalo de nuevo.",
|
||||
"notScored": "El progreso del objetivo de peso es contexto personal y no afecta a la puntuación de salud."
|
||||
},
|
||||
"configureLink": "Elegir qué cuenta para tu puntuación",
|
||||
"coverage": {
|
||||
"minimumMet": "Cubre los tres ámbitos que la puntuación necesita",
|
||||
"areas": "{present} de {required} ámbitos de salud"
|
||||
}
|
||||
},
|
||||
"suggestedPrompts": {
|
||||
@@ -2959,6 +2977,8 @@
|
||||
"asleep": "Dormido",
|
||||
"awake": "Despierto",
|
||||
"inBed": "En la cama",
|
||||
"nap": "Siesta",
|
||||
"napCount": "{count} siestas",
|
||||
"unavailable": "Aún no hay datos de fases — las fases de sueño aparecerán aquí en cuanto una fuente conectada las proporcione."
|
||||
},
|
||||
"empty": {
|
||||
@@ -3100,9 +3120,15 @@
|
||||
"splitsTime": "Tiempo",
|
||||
"splitsPace": "Ritmo",
|
||||
"thatDayTitle": "Ese día",
|
||||
"dayPulse": "Pulso",
|
||||
"daySleep": "Sueño",
|
||||
"dayMood": "Ánimo",
|
||||
"daySleepTitle": "La noche anterior",
|
||||
"daySleepNone": "No hay sueño registrado esa noche",
|
||||
"daySleepAwakeningsOne": "{count} despertar",
|
||||
"daySleepAwakeningsMany": "{count} despertares",
|
||||
"dayMoodTitle": "Ánimo ese día",
|
||||
"dayMoodNone": "No hay ánimo registrado ese día",
|
||||
"sleepTrendsLink": "Tendencias de sueño",
|
||||
"moodHistoryLink": "Historial de ánimo",
|
||||
"previousSessionLink": "Sesión anterior",
|
||||
"insightTitle": "Análisis de la actividad"
|
||||
},
|
||||
"sport": {
|
||||
@@ -3131,7 +3157,13 @@
|
||||
"loadError": "No se pudieron cargar tus entrenamientos",
|
||||
"loadMore": "Cargar más entrenamientos",
|
||||
"loadingMore": "Cargando más entrenamientos",
|
||||
"loadMoreError": "No se pudieron cargar más entrenamientos"
|
||||
"loadMoreError": "No se pudieron cargar más entrenamientos",
|
||||
"countOne": "{count} entrenamiento",
|
||||
"countOther": "{count} entrenamientos",
|
||||
"countFew": "{count} entrenamientos",
|
||||
"filterLabel": "Filtrar por deporte",
|
||||
"filterAll": "Todos los deportes",
|
||||
"filterEmpty": "No hay sesiones de este deporte en tu historial."
|
||||
},
|
||||
"bloodGlucose": {
|
||||
"title": "Glucosa en sangre",
|
||||
@@ -3579,7 +3611,8 @@
|
||||
"summary": "{present} de {required} entradas · {percent}%",
|
||||
"ratioLabel": "{present}/{required}",
|
||||
"historyDays": "Basado en {count} días de historial",
|
||||
"missing": "Registra estos para afinar el valor: {list}"
|
||||
"missing": "Registra estos para afinar el valor: {list}",
|
||||
"summaryLabelled": "{label} · {percent}%"
|
||||
},
|
||||
"scoreRing": {
|
||||
"aria": "Puntuación {score} de 100, {band}",
|
||||
@@ -4868,6 +4901,60 @@
|
||||
"fetchFailedOne": "La última actualización meteorológica en segundo plano falló ({when}).",
|
||||
"fetchFailedFew": "Las últimas {count} actualizaciones meteorológicas en segundo plano fallaron, la más reciente {when}.",
|
||||
"fetchFailedOther": "Las últimas {count} actualizaciones meteorológicas en segundo plano fallaron, la más reciente {when}."
|
||||
},
|
||||
"score": {
|
||||
"title": "Puntuación de salud",
|
||||
"subtitle": "Elige qué pilares cuentan para tu puntuación.",
|
||||
"card": {
|
||||
"title": "Qué cuenta para tu puntuación",
|
||||
"description": "Cada pilar que registras cuenta, salvo que digas lo contrario aquí."
|
||||
},
|
||||
"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": {
|
||||
"countsFor": "Contar {pillar} para la puntuación"
|
||||
},
|
||||
"domain": {
|
||||
"cardiometabolic": "Corazón y metabolismo",
|
||||
"activity": "Actividad",
|
||||
"sleep": "Sueño",
|
||||
"adiposity": "Forma corporal",
|
||||
"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."
|
||||
},
|
||||
"presets": {
|
||||
"label": "Punto de partida",
|
||||
"all": "Todos los pilares",
|
||||
"current": "Tu selección guardada"
|
||||
},
|
||||
"save": "Guardar",
|
||||
"saved": "Guardado.",
|
||||
"error": "No se pudo guardar. Inténtalo de nuevo.",
|
||||
"loadFailed": "No se pudo cargar tu selección.",
|
||||
"eligibilityFailed": "No se pudo comprobar qué pilares tienen datos ahora mismo. Los interruptores de abajo siguen funcionando.",
|
||||
"omitted": "{count} pilares no aparecen porque su seguimiento está desactivado. Vuelve a activar un módulo y su pilar regresa.",
|
||||
"omittedLink": "Abrir módulos",
|
||||
"refusal": {
|
||||
"physiological": "Una puntuación de salud necesita al menos una medición física. Mantén en tu selección la presión arterial, la glucosa, el sueño, la forma corporal o el colesterol, además de lo demás que elijas.",
|
||||
"domains": "Una puntuación de salud necesita al menos tres ámbitos de salud distintos. La presión arterial, la glucosa y el colesterol describen el mismo ámbito, así que una selección hecha solo con ellos cuenta como uno."
|
||||
},
|
||||
"notice": {
|
||||
"upgrade": {
|
||||
"title": "Ahora decides tú qué cuenta",
|
||||
"body": "Qué pilares contaban para tu Health Score dependía antes de los módulos que tuvieras activados. Ahora es una decisión aparte, y aquí es donde la tomas. Tu número no se movió al llegar este cambio: sigue contando exactamente lo que contaba antes."
|
||||
},
|
||||
"changed": {
|
||||
"title": "Has cambiado qué cuenta",
|
||||
"body": "Desde el {date} tu puntuación se calcula con otro conjunto de pilares. Los días anteriores salen del conjunto antiguo, así que no se comparan entre sí y la variación semanal queda en pausa hasta que la ventana de comparación deje atrás el cambio. El historial de cada pilar sigue sin interrupción.",
|
||||
"bodyUndated": "Tu puntuación se calcula con un conjunto de pilares distinto al anterior. Los días previos salen del conjunto antiguo, así que no se comparan entre sí y la variación semanal queda en pausa hasta que la ventana de comparación deje atrás el cambio. El historial de cada pilar sigue sin interrupción."
|
||||
},
|
||||
"dismiss": "Entendido",
|
||||
"dismissError": "No se pudo guardar. Inténtalo de nuevo."
|
||||
}
|
||||
}
|
||||
},
|
||||
"profile": "Perfil",
|
||||
@@ -4906,6 +4993,8 @@
|
||||
"timezoneHint": "Se usa para las etiquetas de los ejes de los gráficos, las horas de recordatorio, las marcas de tiempo de exportación y el contexto del Coach. Los datos almacenados no cambian.",
|
||||
"timezoneInvalid": "No es una zona horaria IANA válida.",
|
||||
"profileSaved": "Perfil guardado",
|
||||
"profilePartiallySaved": "Perfil guardado, pero no se pudo actualizar {field}.",
|
||||
"profileNothingSaved": "No se guardó nada. Corrige {field} e inténtalo de nuevo.",
|
||||
"avatar": {
|
||||
"title": "Foto de perfil",
|
||||
"description": "Sube una foto que se almacena en tu propio servidor. Aparece en toda la aplicación en lugar de un servicio de avatares externo.",
|
||||
@@ -5643,7 +5732,6 @@
|
||||
"cycleTracking": {
|
||||
"title": "Seguimiento del ciclo",
|
||||
"description": "Registra tu ciclo menstrual, predicciones, síntomas e información de fases. Activado por defecto en cuentas configuradas como femeninas; disponible para todos.",
|
||||
"disclaimer": "Las predicciones y las fases son estimaciones descriptivas a partir de tus datos registrados, no son consejo médico ni un método anticonceptivo. Nunca indican que sea seguro tener relaciones sin protección. La precisión mejora a medida que registras más ciclos. Para la anticoncepción o cualquier decisión médica, consulta a un profesional sanitario.",
|
||||
"enable": "Activar el seguimiento del ciclo",
|
||||
"manage": "Abrir seguimiento del ciclo",
|
||||
"error": "No se pudo actualizar el seguimiento del ciclo."
|
||||
@@ -6154,10 +6242,6 @@
|
||||
"correlations": {
|
||||
"title": "Correlaciones",
|
||||
"description": "Narración de la tarjeta de correlación en la vista general de Insights."
|
||||
},
|
||||
"healthScoreExplainer": {
|
||||
"title": "Explicador de Health-Score",
|
||||
"description": "El icono ? junto a la línea de delta del Health-Score. El dígito del delta permanece visible siempre."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -7429,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": {
|
||||
@@ -9088,7 +9173,17 @@
|
||||
"moduleOffDescription": "Activa el módulo de nutrientes para ver aquí la hidratación y la ingesta de vitaminas/minerales. Los datos sincronizados pasarán a formar parte del registro que el Coach puede leer.",
|
||||
"moduleOffCta": "Activar nutrientes",
|
||||
"emptyTitle": "Aún no hay datos de nutrientes",
|
||||
"emptyDescription": "Los datos llegan desde la sincronización de la app de salud de tu teléfono o desde una entrada manual de agua."
|
||||
"emptyDescription": "Los datos llegan desde la sincronización de la app de salud de tu teléfono o desde una entrada manual de agua.",
|
||||
"loadError": "No se pudieron cargar tus datos de nutrientes",
|
||||
"lastAttemptTitle": "Se recibió una sincronización, pero no se pudo guardar nada",
|
||||
"lastAttemptAt": "Último intento: {date}",
|
||||
"lastAttemptReason": {
|
||||
"unitMismatch": "Los valores recibidos no coincidían con las unidades que esta app espera para estos nutrientes, así que no se pudieron guardar.",
|
||||
"valueOutOfRange": "Las cantidades recibidas eran superiores a lo que esta app puede almacenar de forma plausible, así que no se pudieron guardar.",
|
||||
"dayInvalid": "Las entradas llevaban una fecha que el servidor no pudo aceptar. Esto puede ocurrir cuando el reloj o la zona horaria del lado que envía los datos están mal configurados; merece la pena revisarlo.",
|
||||
"upsertFailed": "Estas entradas llegaron al servidor, pero un problema de nuestro lado impidió guardarlas. No tienes que hacer nada.",
|
||||
"unknown": "Estas entradas llegaron, pero no se pudieron guardar."
|
||||
}
|
||||
},
|
||||
"hydration": {
|
||||
"referenceMeta": "Ingesta de referencia {value} {unit}/día · EFSA",
|
||||
@@ -9194,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}"
|
||||
|
||||
+111
-18
@@ -120,6 +120,13 @@
|
||||
"outOfOrder": "Votre progression a changé. Actualisez la page et continuez.",
|
||||
"concurrent": "Votre progression a changé. Actualisez la page et continuez."
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"update": {
|
||||
"nothingSaved": "Rien n'a été enregistré. Corrigez le champ signalé et réessayez.",
|
||||
"invalidBody": "La mise à jour du profil n'a pas pu être lue.",
|
||||
"emailInUse": "Cette adresse e-mail est déjà utilisée."
|
||||
}
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -2261,14 +2268,19 @@
|
||||
"yellow": "Certains piliers demandent une attention",
|
||||
"red": "Un ou plusieurs piliers sont hors de la plage de référence"
|
||||
},
|
||||
"insufficient": "{count} piliers admissibles. Le score en requiert au moins trois, dont un pilier physiologique mesuré.",
|
||||
"insufficient": "{count} domaines de santé sur {required} disposent d'assez de données récentes. Le score en demande {required}, dont au moins un issu d'une mesure physique.",
|
||||
"anatomyToggle": "Comment ce score est établi",
|
||||
"notScored": "Pas encore évalué ({count})",
|
||||
"method": "Le score est la moyenne à poids égal des piliers qui comptent pour lui, sur une échelle de 0 à 100. Chaque pilier est comparé à une plage de référence publiée, et le pilier le plus faible peut déterminer la plage globale. Un pilier sans données récentes suffisantes est écarté plutôt que compté comme un zéro. Les plages de référence proviennent de sources publiées ; les piliers qui comptent sont ceux que vous choisissez, donc le nombre combiné n'est pas lui-même une norme clinique.",
|
||||
"bandSetter": "Catégorie globale fixée par {pillar}",
|
||||
"versionAndNoise": "Version de la méthode {version}. Seuil de comparaison hebdomadaire : {noise} points.",
|
||||
"methodVersion": "Version de la méthode {version}.",
|
||||
"configured": "Vous avez choisi les piliers qui comptent pour ce score.",
|
||||
"none": "aucun",
|
||||
"delta": "{delta} par rapport à la période comparable précédente",
|
||||
"deltaReason": {
|
||||
"algorithm_changed": "Aucune comparaison car la méthode de calcul a changé.",
|
||||
"config_changed": "Aucune comparaison car vous avez modifié ce qui compte dans votre score.",
|
||||
"composition_changed": "Aucune comparaison car les piliers inclus ont changé.",
|
||||
"first_eligibility_window": "Aucune comparaison car il s'agit de la première période admissible.",
|
||||
"below_noise_floor": "La variation reste dans la marge de fluctuation combinée des mesures.",
|
||||
@@ -2283,7 +2295,6 @@
|
||||
"sleep": "Sommeil",
|
||||
"adiposity": "Adiposité",
|
||||
"wellbeing": "Bien-être",
|
||||
"fitness": "Condition physique mesurée",
|
||||
"lipids": "Lipides"
|
||||
},
|
||||
"observed": "Observé : {value}",
|
||||
@@ -2291,15 +2302,18 @@
|
||||
"fastingObserved": "Glycémie à jeun : {value} {unit}",
|
||||
"fastingReference": "{low} à {high} {unit} à jeun",
|
||||
"personalReference": "Votre objectif : {value}",
|
||||
"bpBasisAbove": "Déterminant : {axis}, environ {offset} mmHg au-dessus de son plafond de {boundary} mmHg.",
|
||||
"bpBasisBelowFloor": "Déterminant : {axis}, environ {offset} mmHg sous le seuil de basse pression de {boundary} mmHg.",
|
||||
"bpBasisInBand": "Les deux valeurs sont à leur plafond de référence ou en dessous.",
|
||||
"bpScale": "Au plafond, le score est de 85 ; 100 correspond à environ 12 mmHg en dessous.",
|
||||
"bpRepresentative": "Repose sur une moyenne des {days} derniers jours qui pèse davantage les mesures récentes, et peut donc ne correspondre à aucune mesure isolée.",
|
||||
"source": "Source : {source}, au {date}",
|
||||
"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",
|
||||
@@ -2320,7 +2334,6 @@
|
||||
"insufficientHistory": "Pas assez de données récentes admissibles",
|
||||
"unavailable": "Ce pilier n'est pas admissible actuellement"
|
||||
},
|
||||
"pillarInsufficient": "Aucun score pour ce pilier : {reason}",
|
||||
"tension": "Les signaux de préparation diffèrent. Favorables : {positive}. Sous tension : {negative}.",
|
||||
"returnToBand": "{metric} est revenu dans ta plage habituelle depuis {days} jours.",
|
||||
"weightGoal": {
|
||||
@@ -2332,6 +2345,11 @@
|
||||
"weight_not_tracked": "Enregistre ton poids pour suivre ta progression vers ton objectif.",
|
||||
"read_failed": "Les données de poids n'ont pas pu être chargées. Réessaie.",
|
||||
"notScored": "La progression vers l'objectif de poids est un contexte personnel et ne modifie pas le score de santé."
|
||||
},
|
||||
"configureLink": "Choisir ce qui compte dans votre score",
|
||||
"coverage": {
|
||||
"minimumMet": "Couvre les trois domaines exigés par le score",
|
||||
"areas": "{present} domaines de santé sur {required}"
|
||||
}
|
||||
},
|
||||
"suggestedPrompts": {
|
||||
@@ -2959,6 +2977,8 @@
|
||||
"asleep": "Endormi",
|
||||
"awake": "Éveillé",
|
||||
"inBed": "Au lit",
|
||||
"nap": "Sieste",
|
||||
"napCount": "{count} siestes",
|
||||
"unavailable": "Pas encore de données de phases — les phases de sommeil apparaîtront ici dès qu’une source connectée les fournira."
|
||||
},
|
||||
"empty": {
|
||||
@@ -3100,9 +3120,15 @@
|
||||
"splitsTime": "Temps",
|
||||
"splitsPace": "Allure",
|
||||
"thatDayTitle": "Ce jour-là",
|
||||
"dayPulse": "Pouls",
|
||||
"daySleep": "Sommeil",
|
||||
"dayMood": "Humeur",
|
||||
"daySleepTitle": "La nuit précédente",
|
||||
"daySleepNone": "Aucun sommeil enregistré cette nuit-là",
|
||||
"daySleepAwakeningsOne": "{count} réveil",
|
||||
"daySleepAwakeningsMany": "{count} réveils",
|
||||
"dayMoodTitle": "Humeur ce jour-là",
|
||||
"dayMoodNone": "Aucune humeur enregistrée ce jour-là",
|
||||
"sleepTrendsLink": "Tendances du sommeil",
|
||||
"moodHistoryLink": "Historique d'humeur",
|
||||
"previousSessionLink": "Séance précédente",
|
||||
"insightTitle": "Analyse de l'activité"
|
||||
},
|
||||
"sport": {
|
||||
@@ -3131,7 +3157,13 @@
|
||||
"loadError": "Tes séances n'ont pas pu être chargées",
|
||||
"loadMore": "Charger plus de séances",
|
||||
"loadingMore": "Chargement de séances supplémentaires",
|
||||
"loadMoreError": "Les séances supplémentaires n'ont pas pu être chargées"
|
||||
"loadMoreError": "Les séances supplémentaires n'ont pas pu être chargées",
|
||||
"countOne": "{count} séance",
|
||||
"countOther": "{count} séances",
|
||||
"countFew": "{count} séances",
|
||||
"filterLabel": "Filtrer par sport",
|
||||
"filterAll": "Tous les sports",
|
||||
"filterEmpty": "Aucune séance de ce sport dans votre historique."
|
||||
},
|
||||
"bloodGlucose": {
|
||||
"title": "Glycémie",
|
||||
@@ -3579,7 +3611,8 @@
|
||||
"summary": "{present} entrées sur {required} · {percent}%",
|
||||
"ratioLabel": "{present}/{required}",
|
||||
"historyDays": "Basé sur {count} jours d'historique",
|
||||
"missing": "Enregistre ceci pour affiner la valeur : {list}"
|
||||
"missing": "Enregistre ceci pour affiner la valeur : {list}",
|
||||
"summaryLabelled": "{label} · {percent}%"
|
||||
},
|
||||
"scoreRing": {
|
||||
"aria": "Score {score} sur 100, {band}",
|
||||
@@ -4868,6 +4901,60 @@
|
||||
"fetchFailedOne": "La dernière mise à jour météo en arrière-plan a échoué ({when}).",
|
||||
"fetchFailedFew": "Les {count} dernières mises à jour météo en arrière-plan ont échoué, la plus récente {when}.",
|
||||
"fetchFailedOther": "Les {count} dernières mises à jour météo en arrière-plan ont échoué, la plus récente {when}."
|
||||
},
|
||||
"score": {
|
||||
"title": "Score de santé",
|
||||
"subtitle": "Choisissez les piliers qui comptent dans votre score.",
|
||||
"card": {
|
||||
"title": "Ce qui compte dans votre score",
|
||||
"description": "Chaque pilier que vous enregistrez compte, sauf indication contraire ici."
|
||||
},
|
||||
"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": {
|
||||
"countsFor": "Compter {pillar} dans le score"
|
||||
},
|
||||
"domain": {
|
||||
"cardiometabolic": "Cœur et métabolisme",
|
||||
"activity": "Activité",
|
||||
"sleep": "Sommeil",
|
||||
"adiposity": "Silhouette",
|
||||
"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."
|
||||
},
|
||||
"presets": {
|
||||
"label": "Point de départ",
|
||||
"all": "Tous les piliers",
|
||||
"current": "Votre sélection enregistrée"
|
||||
},
|
||||
"save": "Enregistrer",
|
||||
"saved": "Enregistré.",
|
||||
"error": "Enregistrement impossible. Veuillez réessayer.",
|
||||
"loadFailed": "Impossible de charger votre sélection.",
|
||||
"eligibilityFailed": "Impossible de vérifier quels piliers disposent de données. Les interrupteurs ci-dessous fonctionnent quand même.",
|
||||
"omitted": "{count} piliers ne sont pas listés parce que leur suivi est désactivé. Réactivez un module et son pilier revient.",
|
||||
"omittedLink": "Ouvrir les modules",
|
||||
"refusal": {
|
||||
"physiological": "Un score de santé a besoin d'au moins une mesure physique. Gardez la tension, la glycémie, le sommeil, la silhouette ou le cholestérol dans votre sélection, en plus de ce que vous choisissez par ailleurs.",
|
||||
"domains": "Un score de santé a besoin d'au moins trois domaines de santé différents. La tension, la glycémie et le cholestérol décrivent le même domaine, une sélection composée uniquement de ceux-là compte donc pour un."
|
||||
},
|
||||
"notice": {
|
||||
"upgrade": {
|
||||
"title": "Ce qui compte, c'est vous qui le décidez",
|
||||
"body": "Les piliers qui comptaient dans votre Health Score suivaient auparavant les modules que vous aviez activés. C'est désormais un choix à part, et c'est ici que vous le faites. Votre chiffre n'a pas bougé pour autant : il compte exactement ce qu'il comptait avant."
|
||||
},
|
||||
"changed": {
|
||||
"title": "Vous avez changé ce qui compte",
|
||||
"body": "Depuis le {date}, votre score est calculé à partir d'un autre ensemble de piliers. Les jours antérieurs viennent de l'ancien ensemble, les deux ne sont donc pas comparés et la variation hebdomadaire reste suspendue tant que la fenêtre de comparaison n'a pas dépassé le changement. L'historique de chaque pilier, lui, se poursuit sans rupture.",
|
||||
"bodyUndated": "Votre score est calculé à partir d'un autre ensemble de piliers qu'auparavant. Les jours antérieurs viennent de l'ancien ensemble, les deux ne sont donc pas comparés et la variation hebdomadaire reste suspendue tant que la fenêtre de comparaison n'a pas dépassé le changement. L'historique de chaque pilier, lui, se poursuit sans rupture."
|
||||
},
|
||||
"dismiss": "Compris",
|
||||
"dismissError": "Enregistrement impossible. Veuillez réessayer."
|
||||
}
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
@@ -4906,6 +4993,8 @@
|
||||
"timezoneHint": "Utilisé pour les libellés des axes des graphiques, les heures de rappel, les horodatages d’export et le contexte du Coach. Les données stockées restent inchangées.",
|
||||
"timezoneInvalid": "Fuseau horaire IANA non valide.",
|
||||
"profileSaved": "Profil enregistré",
|
||||
"profilePartiallySaved": "Profil enregistré, mais {field} n'a pas pu être mis à jour.",
|
||||
"profileNothingSaved": "Rien n'a été enregistré. Corrigez {field} et réessayez.",
|
||||
"avatar": {
|
||||
"title": "Photo de profil",
|
||||
"description": "Téléverse une photo stockée sur ton propre serveur. Elle apparaît dans toute l'application à la place d'un service d'avatar externe.",
|
||||
@@ -5643,7 +5732,6 @@
|
||||
"cycleTracking": {
|
||||
"title": "Suivi du cycle",
|
||||
"description": "Suivez votre cycle menstruel, les prédictions, les symptômes et les analyses de phases. Activé par défaut pour les comptes définis comme féminins ; disponible pour tous.",
|
||||
"disclaimer": "Les prévisions et les phases sont des estimations descriptives issues de tes données enregistrées : ce ne sont ni un avis médical ni une méthode de contraception. Elles n'indiquent jamais qu'un rapport non protégé est sans risque. La précision s'améliore à mesure que tu enregistres plus de cycles. Pour la contraception ou toute décision médicale, consulte un professionnel de santé.",
|
||||
"enable": "Activer le suivi du cycle",
|
||||
"manage": "Ouvrir le suivi du cycle",
|
||||
"error": "Impossible de mettre à jour le suivi du cycle."
|
||||
@@ -6154,10 +6242,6 @@
|
||||
"correlations": {
|
||||
"title": "Corrélations",
|
||||
"description": "Narration de la tuile de corrélation sur la vue d'ensemble Insights."
|
||||
},
|
||||
"healthScoreExplainer": {
|
||||
"title": "Explicateur de Health-Score",
|
||||
"description": "L'icône ? près de la ligne de delta du Health-Score. Le chiffre du delta reste visible quoi qu'il arrive."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -7429,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": {
|
||||
@@ -9088,7 +9173,17 @@
|
||||
"moduleOffDescription": "Activez le module nutriments pour voir ici l'hydratation et les apports en vitamines/minéraux. Les données synchronisées feront partie du dossier que le Coach peut lire.",
|
||||
"moduleOffCta": "Activer les nutriments",
|
||||
"emptyTitle": "Pas encore de données nutritionnelles",
|
||||
"emptyDescription": "Les données proviennent de la synchronisation de l'appli santé de votre téléphone ou d'une saisie manuelle d'eau."
|
||||
"emptyDescription": "Les données proviennent de la synchronisation de l'appli santé de votre téléphone ou d'une saisie manuelle d'eau.",
|
||||
"loadError": "Tes données de nutriments n'ont pas pu être chargées",
|
||||
"lastAttemptTitle": "Une synchronisation est arrivée, mais rien n'a pu être enregistré",
|
||||
"lastAttemptAt": "Dernière tentative : {date}",
|
||||
"lastAttemptReason": {
|
||||
"unitMismatch": "Les valeurs reçues ne correspondaient pas aux unités attendues par l'appli pour ces nutriments, elles n'ont donc pas pu être enregistrées.",
|
||||
"valueOutOfRange": "Les quantités reçues étaient supérieures à ce que l'appli peut raisonnablement enregistrer, elles n'ont donc pas pu être sauvegardées.",
|
||||
"dayInvalid": "Les entrées portaient une date que le serveur n'a pas pu accepter. Cela peut arriver quand l'horloge ou le fuseau horaire du côté qui envoie les données est incorrect — cela vaut la peine de vérifier.",
|
||||
"upsertFailed": "Ces entrées sont arrivées jusqu'au serveur, mais un problème de notre côté a empêché leur enregistrement. Tu n'as rien à faire.",
|
||||
"unknown": "Ces entrées sont arrivées mais n'ont pas pu être enregistrées."
|
||||
}
|
||||
},
|
||||
"hydration": {
|
||||
"referenceMeta": "Apport de référence {value} {unit}/jour · EFSA",
|
||||
@@ -9194,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}"
|
||||
|
||||
+111
-18
@@ -120,6 +120,13 @@
|
||||
"outOfOrder": "I tuoi progressi sono cambiati. Aggiorna la pagina e continua.",
|
||||
"concurrent": "I tuoi progressi sono cambiati. Aggiorna la pagina e continua."
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"update": {
|
||||
"nothingSaved": "Non è stato salvato nulla. Controlla il campo evidenziato e riprova.",
|
||||
"invalidBody": "Impossibile leggere l'aggiornamento del profilo.",
|
||||
"emailInUse": "Questo indirizzo email è già in uso."
|
||||
}
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -2261,14 +2268,19 @@
|
||||
"yellow": "Alcuni pilastri richiedono attenzione",
|
||||
"red": "Uno o più pilastri sono fuori dall'intervallo di riferimento"
|
||||
},
|
||||
"insufficient": "{count} pilastri idonei. Il punteggio ne richiede almeno tre, incluso un pilastro fisiologico misurato.",
|
||||
"insufficient": "{count} ambiti di salute su {required} hanno abbastanza dati recenti. Il punteggio ne richiede {required}, di cui almeno uno misurato fisicamente.",
|
||||
"anatomyToggle": "Come nasce questo punteggio",
|
||||
"notScored": "Non ancora valutato ({count})",
|
||||
"method": "Il punteggio è la media a peso uguale dei pilastri che contano per esso, su una scala da 0 a 100. Ogni pilastro viene confrontato con un intervallo di riferimento pubblicato e il pilastro più debole può determinare la fascia complessiva. Un pilastro senza dati recenti sufficienti resta escluso invece di contare come zero. Gli intervalli di riferimento provengono da fonti pubblicate; quali pilastri contano lo scegli tu, quindi il numero combinato non è di per sé uno standard clinico.",
|
||||
"bandSetter": "Fascia complessiva determinata da {pillar}",
|
||||
"versionAndNoise": "Versione del metodo {version}. Soglia del confronto settimanale: {noise} punti.",
|
||||
"methodVersion": "Versione del metodo {version}.",
|
||||
"configured": "Hai scelto quali pilastri contano per questo punteggio.",
|
||||
"none": "nessuno",
|
||||
"delta": "{delta} rispetto al periodo precedente confrontabile",
|
||||
"deltaReason": {
|
||||
"algorithm_changed": "Nessun confronto perché è cambiato il metodo di calcolo.",
|
||||
"config_changed": "Nessun confronto perché hai cambiato ciò che conta per il tuo punteggio.",
|
||||
"composition_changed": "Nessun confronto perché sono cambiati i pilastri inclusi.",
|
||||
"first_eligibility_window": "Nessun confronto perché questo è il primo periodo idoneo.",
|
||||
"below_noise_floor": "La variazione rientra nella fluttuazione combinata delle misurazioni.",
|
||||
@@ -2283,7 +2295,6 @@
|
||||
"sleep": "Sonno",
|
||||
"adiposity": "Adiposità",
|
||||
"wellbeing": "Benessere",
|
||||
"fitness": "Forma fisica misurata",
|
||||
"lipids": "Lipidi"
|
||||
},
|
||||
"observed": "Osservato: {value}",
|
||||
@@ -2291,15 +2302,18 @@
|
||||
"fastingObserved": "Glicemia a digiuno: {value} {unit}",
|
||||
"fastingReference": "Da {low} a {high} {unit} a digiuno",
|
||||
"personalReference": "Il tuo obiettivo: {value}",
|
||||
"bpBasisAbove": "Determinante: {axis}, circa {offset} mmHg sopra il suo limite di {boundary} mmHg.",
|
||||
"bpBasisBelowFloor": "Determinante: {axis}, circa {offset} mmHg sotto la soglia di pressione bassa di {boundary} mmHg.",
|
||||
"bpBasisInBand": "Entrambi i valori sono pari o inferiori ai loro limiti di riferimento.",
|
||||
"bpScale": "Al limite il punteggio è 85; 100 significa circa 12 mmHg più in basso.",
|
||||
"bpRepresentative": "Si basa su una media degli ultimi {days} giorni che pesa di più le misurazioni recenti, quindi può non corrispondere a nessuna singola misurazione.",
|
||||
"source": "Fonte: {source}, al {date}",
|
||||
"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",
|
||||
@@ -2320,7 +2334,6 @@
|
||||
"insufficientHistory": "Dati recenti idonei insufficienti",
|
||||
"unavailable": "Questo pilastro non è attualmente idoneo"
|
||||
},
|
||||
"pillarInsufficient": "Nessun punteggio per il pilastro: {reason}",
|
||||
"tension": "I segnali di preparazione differiscono. Favorevoli: {positive}. Sotto sforzo: {negative}.",
|
||||
"returnToBand": "{metric} è di nuovo nel tuo intervallo abituale da {days} giorni.",
|
||||
"weightGoal": {
|
||||
@@ -2332,6 +2345,11 @@
|
||||
"weight_not_tracked": "Registra il peso per vedere i progressi verso il tuo obiettivo.",
|
||||
"read_failed": "Non è stato possibile caricare i dati del peso. Riprova.",
|
||||
"notScored": "I progressi verso l'obiettivo di peso sono un contesto personale e non influenzano il punteggio di salute."
|
||||
},
|
||||
"configureLink": "Scegli cosa conta nel tuo punteggio",
|
||||
"coverage": {
|
||||
"minimumMet": "Copre i tre ambiti richiesti dal punteggio",
|
||||
"areas": "{present} ambiti di salute su {required}"
|
||||
}
|
||||
},
|
||||
"suggestedPrompts": {
|
||||
@@ -2959,6 +2977,8 @@
|
||||
"asleep": "Addormentato",
|
||||
"awake": "Sveglio",
|
||||
"inBed": "A letto",
|
||||
"nap": "Pisolino",
|
||||
"napCount": "{count} pisolini",
|
||||
"unavailable": "Ancora nessun dato sulle fasi — le fasi del sonno appariranno qui non appena una fonte collegata le fornirà."
|
||||
},
|
||||
"empty": {
|
||||
@@ -3100,9 +3120,15 @@
|
||||
"splitsTime": "Tempo",
|
||||
"splitsPace": "Ritmo",
|
||||
"thatDayTitle": "Quel giorno",
|
||||
"dayPulse": "Battito",
|
||||
"daySleep": "Sonno",
|
||||
"dayMood": "Umore",
|
||||
"daySleepTitle": "La notte prima",
|
||||
"daySleepNone": "Nessun sonno registrato quella notte",
|
||||
"daySleepAwakeningsOne": "{count} risveglio",
|
||||
"daySleepAwakeningsMany": "{count} risvegli",
|
||||
"dayMoodTitle": "Umore di quel giorno",
|
||||
"dayMoodNone": "Nessun umore registrato quel giorno",
|
||||
"sleepTrendsLink": "Andamento del sonno",
|
||||
"moodHistoryLink": "Cronologia dell'umore",
|
||||
"previousSessionLink": "Sessione precedente",
|
||||
"insightTitle": "Analisi dell'attività"
|
||||
},
|
||||
"sport": {
|
||||
@@ -3131,7 +3157,13 @@
|
||||
"loadError": "Non è stato possibile caricare i tuoi allenamenti",
|
||||
"loadMore": "Carica altri allenamenti",
|
||||
"loadingMore": "Caricamento di altri allenamenti",
|
||||
"loadMoreError": "Non è stato possibile caricare altri allenamenti"
|
||||
"loadMoreError": "Non è stato possibile caricare altri allenamenti",
|
||||
"countOne": "{count} allenamento",
|
||||
"countOther": "{count} allenamenti",
|
||||
"countFew": "{count} allenamenti",
|
||||
"filterLabel": "Filtra per sport",
|
||||
"filterAll": "Tutti gli sport",
|
||||
"filterEmpty": "Nessuna sessione di questo sport nella tua cronologia."
|
||||
},
|
||||
"bloodGlucose": {
|
||||
"title": "Glicemia",
|
||||
@@ -3579,7 +3611,8 @@
|
||||
"summary": "{present} di {required} input · {percent}%",
|
||||
"ratioLabel": "{present}/{required}",
|
||||
"historyDays": "Basato su {count} giorni di cronologia",
|
||||
"missing": "Registra questi per affinare il valore: {list}"
|
||||
"missing": "Registra questi per affinare il valore: {list}",
|
||||
"summaryLabelled": "{label} · {percent}%"
|
||||
},
|
||||
"scoreRing": {
|
||||
"aria": "Punteggio {score} su 100, {band}",
|
||||
@@ -4868,6 +4901,60 @@
|
||||
"fetchFailedOne": "L’ultimo aggiornamento meteo in background non è riuscito ({when}).",
|
||||
"fetchFailedFew": "Gli ultimi {count} aggiornamenti meteo in background non sono riusciti, il più recente {when}.",
|
||||
"fetchFailedOther": "Gli ultimi {count} aggiornamenti meteo in background non sono riusciti, il più recente {when}."
|
||||
},
|
||||
"score": {
|
||||
"title": "Punteggio di salute",
|
||||
"subtitle": "Scegli quali pilastri contano per il tuo punteggio.",
|
||||
"card": {
|
||||
"title": "Cosa conta per il tuo punteggio",
|
||||
"description": "Ogni pilastro che registri conta, a meno che tu non dica diversamente qui."
|
||||
},
|
||||
"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": {
|
||||
"countsFor": "Conta {pillar} nel punteggio"
|
||||
},
|
||||
"domain": {
|
||||
"cardiometabolic": "Cuore e metabolismo",
|
||||
"activity": "Attività",
|
||||
"sleep": "Sonno",
|
||||
"adiposity": "Forma corporea",
|
||||
"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."
|
||||
},
|
||||
"presets": {
|
||||
"label": "Punto di partenza",
|
||||
"all": "Tutti i pilastri",
|
||||
"current": "La tua selezione salvata"
|
||||
},
|
||||
"save": "Salva",
|
||||
"saved": "Salvato.",
|
||||
"error": "Non è stato possibile salvare. Riprova.",
|
||||
"loadFailed": "Non è stato possibile caricare la tua selezione.",
|
||||
"eligibilityFailed": "Non è stato possibile verificare quali pilastri hanno dati al momento. Gli interruttori qui sotto funzionano comunque.",
|
||||
"omitted": "{count} pilastri non compaiono perché il loro monitoraggio è disattivato. Riattiva un modulo e il suo pilastro torna.",
|
||||
"omittedLink": "Apri i moduli",
|
||||
"refusal": {
|
||||
"physiological": "Un punteggio di salute ha bisogno di almeno una misurazione fisica. Tieni nella selezione la pressione, la glicemia, il sonno, la forma corporea o il colesterolo, oltre a ciò che scegli d'altro.",
|
||||
"domains": "Un punteggio di salute ha bisogno di almeno tre ambiti di salute diversi. Pressione, glicemia e colesterolo descrivono lo stesso ambito, quindi una selezione fatta solo di questi conta come uno."
|
||||
},
|
||||
"notice": {
|
||||
"upgrade": {
|
||||
"title": "Ora sei tu a decidere cosa conta",
|
||||
"body": "Quali pilastri contassero nel tuo Health Score dipendeva prima dai moduli che avevi attivi. Ora è una scelta a parte, e la fai qui. Il tuo numero non si è mosso con questo cambiamento: conta ancora esattamente quello che contava prima."
|
||||
},
|
||||
"changed": {
|
||||
"title": "Hai cambiato cosa conta",
|
||||
"body": "Dal {date} il tuo punteggio è calcolato su un insieme di pilastri diverso. I giorni precedenti vengono dall'insieme vecchio, quindi i due non vengono confrontati e la variazione settimanale resta sospesa finché la finestra di confronto non ha superato il cambiamento. Lo storico di ogni singolo pilastro prosegue senza interruzioni.",
|
||||
"bodyUndated": "Il tuo punteggio è calcolato su un insieme di pilastri diverso da prima. I giorni precedenti vengono dall'insieme vecchio, quindi i due non vengono confrontati e la variazione settimanale resta sospesa finché la finestra di confronto non ha superato il cambiamento. Lo storico di ogni singolo pilastro prosegue senza interruzioni."
|
||||
},
|
||||
"dismiss": "Ho capito",
|
||||
"dismissError": "Non è stato possibile salvare. Riprova."
|
||||
}
|
||||
}
|
||||
},
|
||||
"profile": "Profilo",
|
||||
@@ -4906,6 +4993,8 @@
|
||||
"timezoneHint": "Usato per le etichette degli assi dei grafici, gli orari dei promemoria, gli orari di esportazione e il contesto del Coach. I dati memorizzati restano invariati.",
|
||||
"timezoneInvalid": "Non è un fuso orario IANA valido.",
|
||||
"profileSaved": "Profilo salvato",
|
||||
"profilePartiallySaved": "Profilo salvato, ma non è stato possibile aggiornare {field}.",
|
||||
"profileNothingSaved": "Non è stato salvato nulla. Correggi {field} e riprova.",
|
||||
"avatar": {
|
||||
"title": "Foto del profilo",
|
||||
"description": "Carica una foto archiviata sul tuo server. Appare in tutta l'app al posto di un servizio di avatar esterno.",
|
||||
@@ -5643,7 +5732,6 @@
|
||||
"cycleTracking": {
|
||||
"title": "Monitoraggio del ciclo",
|
||||
"description": "Monitora il ciclo mestruale, le previsioni, i sintomi e gli approfondimenti sulle fasi. Attivo per impostazione predefinita per gli account impostati come femminili; disponibile per tutti.",
|
||||
"disclaimer": "Le previsioni e le fasi sono stime descrittive basate sui dati che registri: non sono un parere medico né un metodo contraccettivo. Non indicano mai che sia sicuro avere rapporti non protetti. La precisione migliora man mano che registri più cicli. Per la contraccezione o qualsiasi decisione medica, rivolgiti a un professionista sanitario.",
|
||||
"enable": "Attiva il monitoraggio del ciclo",
|
||||
"manage": "Apri il monitoraggio del ciclo",
|
||||
"error": "Impossibile aggiornare il monitoraggio del ciclo."
|
||||
@@ -6154,10 +6242,6 @@
|
||||
"correlations": {
|
||||
"title": "Correlazioni",
|
||||
"description": "Narrazione del tile di correlazione sulla panoramica Insights."
|
||||
},
|
||||
"healthScoreExplainer": {
|
||||
"title": "Spiegatore Health-Score",
|
||||
"description": "L'icona ? accanto alla riga del delta dell'Health-Score. La cifra del delta resta visibile in ogni caso."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -7429,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": {
|
||||
@@ -9088,7 +9173,17 @@
|
||||
"moduleOffDescription": "Attiva il modulo nutrienti per vedere qui idratazione e apporto di vitamine/minerali. I dati sincronizzati diventano parte dei dati che il Coach può leggere.",
|
||||
"moduleOffCta": "Attiva nutrienti",
|
||||
"emptyTitle": "Ancora nessun dato sui nutrienti",
|
||||
"emptyDescription": "I dati arrivano dalla sincronizzazione dell'app salute del telefono o da una voce di acqua inserita manualmente."
|
||||
"emptyDescription": "I dati arrivano dalla sincronizzazione dell'app salute del telefono o da una voce di acqua inserita manualmente.",
|
||||
"loadError": "Non è stato possibile caricare i tuoi dati sui nutrienti",
|
||||
"lastAttemptTitle": "È arrivata una sincronizzazione, ma non è stato possibile salvare nulla",
|
||||
"lastAttemptAt": "Ultimo tentativo: {date}",
|
||||
"lastAttemptReason": {
|
||||
"unitMismatch": "I valori ricevuti non corrispondevano alle unità che questa app si aspetta per questi nutrienti, quindi non è stato possibile salvarli.",
|
||||
"valueOutOfRange": "Le quantità ricevute erano superiori a quanto questa app può plausibilmente memorizzare, quindi non è stato possibile salvarle.",
|
||||
"dayInvalid": "Le voci riportavano una data che il server non ha potuto accettare. Questo può succedere quando l'orologio o il fuso orario dal lato che invia i dati non sono corretti: vale la pena controllare.",
|
||||
"upsertFailed": "Queste voci hanno raggiunto il server, ma un problema da questa parte ne ha impedito il salvataggio. Non devi fare nulla.",
|
||||
"unknown": "Queste voci sono arrivate ma non è stato possibile salvarle."
|
||||
}
|
||||
},
|
||||
"hydration": {
|
||||
"referenceMeta": "Apporto di riferimento {value} {unit}/giorno · EFSA",
|
||||
@@ -9194,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}"
|
||||
|
||||
+111
-18
@@ -120,6 +120,13 @@
|
||||
"outOfOrder": "Twój postęp się zmienił. Odśwież stronę i kontynuuj.",
|
||||
"concurrent": "Twój postęp się zmienił. Odśwież stronę i kontynuuj."
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"update": {
|
||||
"nothingSaved": "Nic nie zostało zapisane. Sprawdź oznaczone pole i spróbuj ponownie.",
|
||||
"invalidBody": "Nie udało się odczytać aktualizacji profilu.",
|
||||
"emailInUse": "Ten adres e-mail jest już używany."
|
||||
}
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -2261,14 +2268,19 @@
|
||||
"yellow": "Niektóre filary wymagają uwagi",
|
||||
"red": "Co najmniej jeden filar jest poza zakresem referencyjnym"
|
||||
},
|
||||
"insufficient": "{count} kwalifikujące się filary. Wynik wymaga co najmniej trzech, w tym zmierzonego filaru fizjologicznego.",
|
||||
"insufficient": "{count} z {required} obszarów zdrowia ma wystarczająco świeżych danych. Wynik potrzebuje {required}, w tym co najmniej jednego pomiaru fizycznego.",
|
||||
"anatomyToggle": "Jak powstaje ten wynik",
|
||||
"notScored": "Jeszcze bez oceny ({count})",
|
||||
"method": "Wynik to średnia o równych wagach z filarów, które się do niego liczą, w skali od 0 do 100. Każdy filar jest porównywany z opublikowanym zakresem referencyjnym, a najsłabszy filar może wyznaczyć ogólny zakres. Filar bez wystarczających aktualnych danych jest pomijany, a nie liczony jako zero. Zakresy referencyjne pochodzą z opublikowanych źródeł; to ty decydujesz, które filary się liczą, więc łączna liczba sama w sobie nie jest standardem klinicznym.",
|
||||
"bandSetter": "Ogólny zakres wyznacza {pillar}",
|
||||
"versionAndNoise": "Wersja metody {version}. Próg porównania tygodniowego: {noise} pkt.",
|
||||
"methodVersion": "Wersja metody {version}.",
|
||||
"configured": "Wybrane przez Ciebie filary decydują o tym wyniku.",
|
||||
"none": "brak",
|
||||
"delta": "{delta} względem poprzedniego porównywalnego okresu",
|
||||
"deltaReason": {
|
||||
"algorithm_changed": "Brak porównania, ponieważ zmieniła się metoda obliczeń.",
|
||||
"config_changed": "Brak porównania, ponieważ zmieniono, co wlicza się do wyniku.",
|
||||
"composition_changed": "Brak porównania, ponieważ zmieniły się uwzględnione filary.",
|
||||
"first_eligibility_window": "Brak porównania, ponieważ to pierwszy kwalifikujący się okres.",
|
||||
"below_noise_floor": "Zmiana mieści się w łącznej zmienności pomiarów.",
|
||||
@@ -2283,7 +2295,6 @@
|
||||
"sleep": "Sen",
|
||||
"adiposity": "Otłuszczenie",
|
||||
"wellbeing": "Samopoczucie",
|
||||
"fitness": "Zmierzona wydolność",
|
||||
"lipids": "Lipidy"
|
||||
},
|
||||
"observed": "Zaobserwowano: {value}",
|
||||
@@ -2291,15 +2302,18 @@
|
||||
"fastingObserved": "Glukoza na czczo: {value} {unit}",
|
||||
"fastingReference": "Od {low} do {high} {unit} na czczo",
|
||||
"personalReference": "Twój cel: {value}",
|
||||
"bpBasisAbove": "Decyduje: {axis}, około {offset} mmHg powyżej swojej granicy {boundary} mmHg.",
|
||||
"bpBasisBelowFloor": "Decyduje: {axis}, około {offset} mmHg poniżej granicy niskiego ciśnienia {boundary} mmHg.",
|
||||
"bpBasisInBand": "Obie wartości są na poziomie swoich granic referencyjnych lub poniżej.",
|
||||
"bpScale": "Przy granicy wynik to 85; 100 oznacza około 12 mmHg poniżej.",
|
||||
"bpRepresentative": "Opiera się na średniej z ostatnich {days} dni, która mocniej waży nowsze pomiary, więc może nie odpowiadać żadnemu pojedynczemu pomiarowi.",
|
||||
"source": "Źródło: {source}, stan na {date}",
|
||||
"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",
|
||||
@@ -2320,7 +2334,6 @@
|
||||
"insufficientHistory": "Za mało aktualnych danych spełniających kryteria",
|
||||
"unavailable": "Ten filar obecnie nie spełnia kryteriów"
|
||||
},
|
||||
"pillarInsufficient": "Brak wyniku filaru: {reason}",
|
||||
"tension": "Sygnały gotowości są rozbieżne. Sprzyjające: {positive}. Obciążone: {negative}.",
|
||||
"returnToBand": "{metric} od {days} dni ponownie mieści się w typowym zakresie.",
|
||||
"weightGoal": {
|
||||
@@ -2332,6 +2345,11 @@
|
||||
"weight_not_tracked": "Zapisz pomiar masy ciała, aby zobaczyć postęp w kierunku celu.",
|
||||
"read_failed": "Nie udało się wczytać danych masy ciała. Spróbuj ponownie.",
|
||||
"notScored": "Postęp celu wagowego jest osobistym kontekstem i nie wpływa na wynik zdrowia."
|
||||
},
|
||||
"configureLink": "Wybierz, co liczy się do Twojego wyniku",
|
||||
"coverage": {
|
||||
"minimumMet": "Pokrywa trzy obszary wymagane przez wynik",
|
||||
"areas": "{present} z {required} obszarów zdrowia"
|
||||
}
|
||||
},
|
||||
"suggestedPrompts": {
|
||||
@@ -2959,6 +2977,8 @@
|
||||
"asleep": "Śpi",
|
||||
"awake": "Czuwa",
|
||||
"inBed": "W łóżku",
|
||||
"nap": "Drzemka",
|
||||
"napCount": "{count} drzemki/ek",
|
||||
"unavailable": "Brak danych o fazach — fazy snu pojawią się tutaj, gdy tylko dostarczy je połączone źródło."
|
||||
},
|
||||
"empty": {
|
||||
@@ -3100,9 +3120,15 @@
|
||||
"splitsTime": "Czas",
|
||||
"splitsPace": "Tempo",
|
||||
"thatDayTitle": "Tego dnia",
|
||||
"dayPulse": "Tętno",
|
||||
"daySleep": "Sen",
|
||||
"dayMood": "Nastrój",
|
||||
"daySleepTitle": "Noc wcześniej",
|
||||
"daySleepNone": "Brak zapisanego snu tej nocy",
|
||||
"daySleepAwakeningsOne": "{count} przebudzenie",
|
||||
"daySleepAwakeningsMany": "{count} przebudzeń",
|
||||
"dayMoodTitle": "Nastrój tego dnia",
|
||||
"dayMoodNone": "Brak zapisanego nastroju tego dnia",
|
||||
"sleepTrendsLink": "Trendy snu",
|
||||
"moodHistoryLink": "Historia nastroju",
|
||||
"previousSessionLink": "Poprzedni trening",
|
||||
"insightTitle": "Analiza aktywności"
|
||||
},
|
||||
"sport": {
|
||||
@@ -3131,7 +3157,13 @@
|
||||
"loadError": "Nie udało się wczytać treningów",
|
||||
"loadMore": "Wczytaj więcej treningów",
|
||||
"loadingMore": "Wczytywanie kolejnych treningów",
|
||||
"loadMoreError": "Nie udało się wczytać kolejnych treningów"
|
||||
"loadMoreError": "Nie udało się wczytać kolejnych treningów",
|
||||
"countOne": "{count} trening",
|
||||
"countOther": "{count} treningów",
|
||||
"countFew": "{count} treningi",
|
||||
"filterLabel": "Filtruj według sportu",
|
||||
"filterAll": "Wszystkie sporty",
|
||||
"filterEmpty": "Brak treningów tego sportu w Twojej historii."
|
||||
},
|
||||
"bloodGlucose": {
|
||||
"title": "Poziom glukozy",
|
||||
@@ -3579,7 +3611,8 @@
|
||||
"summary": "{present} z {required} danych · {percent}%",
|
||||
"ratioLabel": "{present}/{required}",
|
||||
"historyDays": "Na podstawie {count} dni historii",
|
||||
"missing": "Zapisuj te dane, aby uściślić wartość: {list}"
|
||||
"missing": "Zapisuj te dane, aby uściślić wartość: {list}",
|
||||
"summaryLabelled": "{label} · {percent}%"
|
||||
},
|
||||
"scoreRing": {
|
||||
"aria": "Wynik {score} na 100, {band}",
|
||||
@@ -4868,6 +4901,60 @@
|
||||
"fetchFailedOne": "Ostatnia aktualizacja pogody w tle nie powiodła się ({when}).",
|
||||
"fetchFailedFew": "Ostatnie {count} aktualizacje pogody w tle nie powiodły się, najnowsza {when}.",
|
||||
"fetchFailedOther": "Ostatnich {count} aktualizacji pogody w tle nie powiodło się, najnowsza {when}."
|
||||
},
|
||||
"score": {
|
||||
"title": "Wynik zdrowia",
|
||||
"subtitle": "Wybierz, które filary liczą się do Twojego wyniku.",
|
||||
"card": {
|
||||
"title": "Co liczy się do Twojego wyniku",
|
||||
"description": "Każdy filar, który zapisujesz, liczy się, dopóki nie powiesz tutaj inaczej."
|
||||
},
|
||||
"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": {
|
||||
"countsFor": "Licz {pillar} do wyniku"
|
||||
},
|
||||
"domain": {
|
||||
"cardiometabolic": "Serce i metabolizm",
|
||||
"activity": "Aktywność",
|
||||
"sleep": "Sen",
|
||||
"adiposity": "Sylwetka",
|
||||
"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."
|
||||
},
|
||||
"presets": {
|
||||
"label": "Punkt wyjścia",
|
||||
"all": "Wszystkie filary",
|
||||
"current": "Twój zapisany wybór"
|
||||
},
|
||||
"save": "Zapisz",
|
||||
"saved": "Zapisano.",
|
||||
"error": "Nie udało się zapisać. Spróbuj ponownie.",
|
||||
"loadFailed": "Nie udało się wczytać Twojego wyboru.",
|
||||
"eligibilityFailed": "Nie udało się sprawdzić, które filary mają teraz dane. Przełączniki poniżej i tak działają.",
|
||||
"omitted": "{count} filarów nie ma na liście, bo ich śledzenie jest wyłączone. Włącz moduł z powrotem, a jego filar wróci.",
|
||||
"omittedLink": "Otwórz moduły",
|
||||
"refusal": {
|
||||
"physiological": "Wynik zdrowia potrzebuje co najmniej jednego pomiaru fizycznego. Zostaw w wyborze ciśnienie, glukozę, sen, sylwetkę albo cholesterol, obok wszystkiego innego, co wybierzesz.",
|
||||
"domains": "Wynik zdrowia potrzebuje co najmniej trzech różnych obszarów zdrowia. Ciśnienie, glukoza i cholesterol opisują ten sam obszar, więc wybór złożony tylko z nich liczy się jako jeden."
|
||||
},
|
||||
"notice": {
|
||||
"upgrade": {
|
||||
"title": "Teraz to Ty decydujesz, co się liczy",
|
||||
"body": "To, które filary liczyły się do Twojego Health Score, szło wcześniej za modułami, które miałeś włączone. Teraz jest to osobna decyzja i podejmujesz ją tutaj. Twoja liczba się przy tym nie ruszyła: nadal liczy dokładnie to, co liczyła wcześniej."
|
||||
},
|
||||
"changed": {
|
||||
"title": "Zmieniłeś to, co się liczy",
|
||||
"body": "Od {date} Twój wynik liczony jest z innego zestawu filarów. Dni wcześniejsze pochodzą ze starego zestawu, więc obu nie porównujemy, a zmiana tygodniowa pozostaje wstrzymana, dopóki okno porównania nie minie tej zmiany. Historia każdego filaru z osobna biegnie przez nią bez przerwy.",
|
||||
"bodyUndated": "Twój wynik liczony jest z innego zestawu filarów niż wcześniej. Dni sprzed zmiany pochodzą ze starego zestawu, więc obu nie porównujemy, a zmiana tygodniowa pozostaje wstrzymana, dopóki okno porównania nie minie tej zmiany. Historia każdego filaru z osobna biegnie przez nią bez przerwy."
|
||||
},
|
||||
"dismiss": "Rozumiem",
|
||||
"dismissError": "Nie udało się zapisać. Spróbuj ponownie."
|
||||
}
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
@@ -4906,6 +4993,8 @@
|
||||
"timezoneHint": "Używane do etykiet osi wykresów, godzin przypomnień, znaczników czasu eksportu i kontekstu Coacha. Zapisane dane pozostają niezmienione.",
|
||||
"timezoneInvalid": "To nie jest prawidłowa strefa czasowa IANA.",
|
||||
"profileSaved": "Profil zapisany",
|
||||
"profilePartiallySaved": "Profil zapisany, ale nie udało się zaktualizować pola {field}.",
|
||||
"profileNothingSaved": "Nic nie zostało zapisane. Popraw {field} i spróbuj ponownie.",
|
||||
"avatar": {
|
||||
"title": "Zdjęcie profilowe",
|
||||
"description": "Prześlij zdjęcie przechowywane na własnym serwerze. Pojawia się w całej aplikacji zamiast zewnętrznej usługi awatarów.",
|
||||
@@ -5643,7 +5732,6 @@
|
||||
"cycleTracking": {
|
||||
"title": "Śledzenie cyklu",
|
||||
"description": "Śledź swój cykl menstruacyjny, prognozy, objawy i analizy faz. Domyślnie włączone dla kont ustawionych jako kobiece; dostępne dla wszystkich.",
|
||||
"disclaimer": "Prognozy i fazy to opisowe szacunki na podstawie zarejestrowanych danych — nie są poradą medyczną ani metodą antykoncepcji. Nigdy nie wskazują, że współżycie bez zabezpieczenia jest bezpieczne. Dokładność rośnie wraz z liczbą zarejestrowanych cykli. W sprawach antykoncepcji lub decyzji medycznych skonsultuj się z pracownikiem ochrony zdrowia.",
|
||||
"enable": "Włącz śledzenie cyklu",
|
||||
"manage": "Otwórz śledzenie cyklu",
|
||||
"error": "Nie udało się zaktualizować śledzenia cyklu."
|
||||
@@ -6154,10 +6242,6 @@
|
||||
"correlations": {
|
||||
"title": "Korelacje",
|
||||
"description": "Narracja kafelka korelacji w przeglądzie Insights."
|
||||
},
|
||||
"healthScoreExplainer": {
|
||||
"title": "Objaśnienie Health-Score",
|
||||
"description": "Ikona ? obok linii delty Health-Score. Cyfra delty pozostaje widoczna niezależnie."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -7429,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": {
|
||||
@@ -9088,7 +9173,17 @@
|
||||
"moduleOffDescription": "Włącz moduł składników odżywczych, aby zobaczyć tu nawodnienie i spożycie witamin/minerałów. Zsynchronizowane dane staną się częścią zapisu, który może odczytać Coach.",
|
||||
"moduleOffCta": "Włącz składniki odżywcze",
|
||||
"emptyTitle": "Brak jeszcze danych o składnikach odżywczych",
|
||||
"emptyDescription": "Dane pochodzą z synchronizacji aplikacji zdrowia telefonu lub z ręcznego wpisu wody."
|
||||
"emptyDescription": "Dane pochodzą z synchronizacji aplikacji zdrowia telefonu lub z ręcznego wpisu wody.",
|
||||
"loadError": "Nie udało się wczytać danych o składnikach odżywczych",
|
||||
"lastAttemptTitle": "Synchronizacja dotarła, ale nic nie udało się zapisać",
|
||||
"lastAttemptAt": "Ostatnia próba: {date}",
|
||||
"lastAttemptReason": {
|
||||
"unitMismatch": "Otrzymane wartości nie pasowały do jednostek, których ta aplikacja oczekuje dla tych składników odżywczych, więc nie udało się ich zapisać.",
|
||||
"valueOutOfRange": "Otrzymane ilości były wyższe, niż ta aplikacja może wiarygodnie zapisać, więc nie udało się ich zapisać.",
|
||||
"dayInvalid": "Wpisy zawierały datę, której serwer nie mógł zaakceptować. Może się to zdarzyć, gdy zegar lub strefa czasowa po stronie wysyłającej są nieprawidłowe — warto to sprawdzić.",
|
||||
"upsertFailed": "Te wpisy dotarły do serwera, ale problem po naszej stronie uniemożliwił ich zapisanie. Nie musisz nic robić.",
|
||||
"unknown": "Te wpisy dotarły, ale nie udało się ich zapisać."
|
||||
}
|
||||
},
|
||||
"hydration": {
|
||||
"referenceMeta": "Zalecane spożycie {value} {unit}/dzień · EFSA",
|
||||
@@ -9194,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}"
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.34.2",
|
||||
"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",
|
||||
@@ -28,7 +28,7 @@
|
||||
"dev": "next dev",
|
||||
"predev": "node scripts/generate-i18n-boot.mjs",
|
||||
"prebuild": "node scripts/generate-sw-version.mjs && node scripts/generate-i18n-boot.mjs",
|
||||
"build": "next build",
|
||||
"build": "NODE_OPTIONS=--max-old-space-size=8192 next build",
|
||||
"analyze": "ANALYZE=1 next build",
|
||||
"bundle-report": "node scripts/check-bundle-budget.mjs",
|
||||
"bundle-check": "node scripts/check-bundle-budget.mjs --check",
|
||||
@@ -41,7 +41,7 @@
|
||||
"db:studio": "prisma studio",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck": "NODE_OPTIONS=--max-old-space-size=8192 tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:integration": "vitest run --config vitest.integration.config.mts",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Remove the Health-Score explainer operator switch.
|
||||
--
|
||||
-- The toggle gated a caption beside the Health-Score delta line. That
|
||||
-- caption's component was deleted when the health score was replaced by
|
||||
-- the reference composite, and the trailing "?" affordance the admin
|
||||
-- description still named had already been retired a release earlier.
|
||||
-- The switch has had nothing to switch since; an operator flipping it
|
||||
-- changed nothing while being told they had.
|
||||
--
|
||||
-- Destructive by design: the setting is gone, so its stored value goes
|
||||
-- with it.
|
||||
ALTER TABLE "app_settings"
|
||||
DROP COLUMN IF EXISTS "assistant_health_score_explainer_enabled";
|
||||
@@ -0,0 +1,35 @@
|
||||
-- v1.35.0 — per-user Health Score composition.
|
||||
--
|
||||
-- A single nullable JSONB column on `users` carrying which pillars the
|
||||
-- person chose to count toward the composite Health Score. Follows the
|
||||
-- established per-user JSON-column convention (`module_preferences_json`,
|
||||
-- `dashboard_widgets_json`, `coach_prefs_json`).
|
||||
--
|
||||
-- Shape (validated by `src/lib/analytics/score/config.ts`):
|
||||
-- { "excludedPillars": ["SLEEP", "LIPIDS"],
|
||||
-- "version": 3,
|
||||
-- "changedAt": "2026-07-31T09:12:44.000Z" }
|
||||
--
|
||||
-- The stored list is a DESELECTION list, the same posture as
|
||||
-- `module_preferences_json`, and it is stored that way on purpose. A
|
||||
-- pillar shipped after the person made their choice is simply absent
|
||||
-- from the list, so it counts by default and the score keeps its
|
||||
-- "everything eligible" starting point without a backfill. Storing the
|
||||
-- positive selection instead would make a newly shipped pillar
|
||||
-- indistinguishable from one the person deliberately removed.
|
||||
--
|
||||
-- NULL = the person never chose, which is NOT the same as choosing to
|
||||
-- count nothing (an `excludedPillars` list naming every pillar). The
|
||||
-- resolver keeps those two apart because only the first one may inherit
|
||||
-- a later change of defaults.
|
||||
--
|
||||
-- `version` is the per-user recipe version: it increments on every
|
||||
-- accepted write and, with `changedAt`, is what a score record and the
|
||||
-- weekly delta key off to tell "you changed what counts" apart from a
|
||||
-- real movement in the person's health.
|
||||
--
|
||||
-- Additive + nullable: every existing account keeps NULL and the
|
||||
-- resolver treats NULL as "counts every pillar", which is exactly what
|
||||
-- those accounts get today.
|
||||
|
||||
ALTER TABLE "users" ADD COLUMN "health_score_config_json" JSONB;
|
||||
@@ -0,0 +1,63 @@
|
||||
-- v1.35.0 — the health score as it was shown, one row per account per local day.
|
||||
--
|
||||
-- Nothing can rebuild this. The live computation always answers for today from
|
||||
-- today's rows, so once the readings behind a past day have been corrected,
|
||||
-- re-synced or simply grown, the number the person saw on that day is gone
|
||||
-- unless it was written down. This table is the writing down.
|
||||
--
|
||||
-- Two properties are enforced here rather than left to the application:
|
||||
--
|
||||
-- * one row per account per local day, so a second computation on the same
|
||||
-- day cannot mint a rival record of the same day, and the writer's
|
||||
-- ON CONFLICT DO NOTHING has something to conflict against;
|
||||
-- * a closed set of bands, as a type rather than as a CHECK. The value the
|
||||
-- application speaks is lowercase, so the enum carries the same labels and
|
||||
-- needs no translation layer. A type also keeps the schema-driven wipe
|
||||
-- seeder working: it reads `pg_enum` for a value it cannot otherwise
|
||||
-- guess, where a CHECK would have forced a hand-written entry into a
|
||||
-- fixture whose whole point is not having one.
|
||||
--
|
||||
-- The composite is range-checked because it is free to check and a number
|
||||
-- outside 0-100 would render as a score nobody can read. The day key's format
|
||||
-- and the composition's non-emptiness are NOT checked here: the writer builds
|
||||
-- the key through the timezone helper and refuses an empty composition before
|
||||
-- it inserts, and the restore validates both against the wire schema before
|
||||
-- the transaction opens. A third copy of those two rules would buy nothing.
|
||||
--
|
||||
-- There is deliberately no updated_at column. The row is written once and
|
||||
-- never rewritten: a later computation under different readings, or under a
|
||||
-- different configuration, leaves the earlier day exactly as it stood.
|
||||
|
||||
CREATE TYPE "health_score_band" AS ENUM ('green', 'yellow', 'red');
|
||||
|
||||
CREATE TABLE "health_score_records" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"day_key" VARCHAR(10) NOT NULL,
|
||||
"timezone" TEXT NOT NULL,
|
||||
"composite" INTEGER NOT NULL,
|
||||
"band" "health_score_band" NOT NULL,
|
||||
"score_version" INTEGER NOT NULL,
|
||||
"composition" TEXT[] NOT NULL,
|
||||
"pillar_scores" JSONB NOT NULL,
|
||||
"input_fingerprint" VARCHAR(64) NOT NULL,
|
||||
"config_version" INTEGER,
|
||||
"config_changed_at" TIMESTAMPTZ(3),
|
||||
"computed_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "health_score_records_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "health_score_records_composite_range_check"
|
||||
CHECK ("composite" >= 0 AND "composite" <= 100)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "health_score_records_user_id_day_key_key"
|
||||
ON "health_score_records" ("user_id", "day_key");
|
||||
|
||||
CREATE INDEX "health_score_records_user_id_day_key_desc_idx"
|
||||
ON "health_score_records" ("user_id", "day_key" DESC);
|
||||
|
||||
ALTER TABLE "health_score_records"
|
||||
ADD CONSTRAINT "health_score_records_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
+123
-8
@@ -602,6 +602,30 @@ model User {
|
||||
// `GET /api/auth/me` as a `modules: { <key>: boolean }` map.
|
||||
modulePreferencesJson Json? @map("module_preferences_json")
|
||||
|
||||
// v1.35.0 — which pillars count toward the composite Health Score.
|
||||
//
|
||||
// A DESELECTION list, the same posture as `modulePreferencesJson`:
|
||||
// { "excludedPillars": ["SLEEP"], "version": 2, "changedAt": "<ISO>" }
|
||||
//
|
||||
// A pillar shipped after the person made their choice is absent from
|
||||
// the list and therefore counts, so the score keeps its "everything
|
||||
// eligible" starting point with no backfill. Storing the positive
|
||||
// selection instead would make a newly shipped pillar look exactly
|
||||
// like one the person deliberately removed.
|
||||
//
|
||||
// NULL means the person never chose and is deliberately distinct from
|
||||
// a list naming every pillar ("count nothing"): only the first may
|
||||
// inherit a later change of defaults.
|
||||
//
|
||||
// `version` increments on every accepted write and, with `changedAt`,
|
||||
// is the per-user recipe identity a score record and the weekly delta
|
||||
// read to tell an authored change apart from a health movement.
|
||||
//
|
||||
// Schema + resolver: `src/lib/analytics/score/config.ts`. Written by
|
||||
// `PATCH /api/auth/me/health-score-config` with field-by-field merge
|
||||
// (no mass assignment) under the write-time breadth rule.
|
||||
healthScoreConfigJson Json? @map("health_score_config_json")
|
||||
|
||||
// Document vault — per-user storage-quota override in bytes. NULL = the
|
||||
// instance default (`AppSettings.documentQuotaBytes`) applies. Admin-set
|
||||
// only (the admin user PUT); resolved by
|
||||
@@ -846,6 +870,10 @@ model User {
|
||||
customMetrics CustomMetric[]
|
||||
customMetricEntries CustomMetricEntry[]
|
||||
correlationPatterns CorrelationPattern[]
|
||||
// v1.35.0 — one row per local day holding the health score as it was shown
|
||||
// that day. Append-only: a later recomputation of the same day never
|
||||
// rewrites the row it finds.
|
||||
healthScoreRecords HealthScoreRecord[]
|
||||
// v1.19.0 — illness / condition journal (born-gated by the module system).
|
||||
// IllnessEpisode is the soft-deleted episode row; IllnessDayLog is the
|
||||
// per-day timeline mirroring the CycleDayLog shape.
|
||||
@@ -4364,10 +4392,10 @@ model AppSettings {
|
||||
defaultUserTimezone String? @map("default_user_timezone") @db.VarChar(64)
|
||||
|
||||
// v1.4.31 — Assistant-surface operator feature flags. The master
|
||||
// gates every LLM-driven surface app-wide; five sub-flags carve
|
||||
// gates every LLM-driven surface app-wide; four sub-flags carve
|
||||
// the visibility cut per surface (Coach drawer + chat, Daily
|
||||
// Briefing + advisor, per-metric status cards, correlation
|
||||
// narration, Health-Score delta explainer). Defaults preserve the
|
||||
// narration). Defaults preserve the
|
||||
// v1.4.30 behaviour (every surface visible) so upgrades require
|
||||
// no admin action. The runtime resolver in
|
||||
// `src/lib/feature-flags/index.ts` forces every sub-flag false
|
||||
@@ -4376,12 +4404,11 @@ model AppSettings {
|
||||
// flag matrix gates server-routed AND iOS on-device surfaces, so
|
||||
// operators can opt out of every assistant surface regardless of
|
||||
// where the model runs.
|
||||
assistantEnabled Boolean @default(true) @map("assistant_enabled")
|
||||
assistantCoachEnabled Boolean @default(true) @map("assistant_coach_enabled")
|
||||
assistantBriefingEnabled Boolean @default(true) @map("assistant_briefing_enabled")
|
||||
assistantInsightStatusEnabled Boolean @default(true) @map("assistant_insight_status_enabled")
|
||||
assistantCorrelationsEnabled Boolean @default(true) @map("assistant_correlations_enabled")
|
||||
assistantHealthScoreExplainerEnabled Boolean @default(true) @map("assistant_health_score_explainer_enabled")
|
||||
assistantEnabled Boolean @default(true) @map("assistant_enabled")
|
||||
assistantCoachEnabled Boolean @default(true) @map("assistant_coach_enabled")
|
||||
assistantBriefingEnabled Boolean @default(true) @map("assistant_briefing_enabled")
|
||||
assistantInsightStatusEnabled Boolean @default(true) @map("assistant_insight_status_enabled")
|
||||
assistantCorrelationsEnabled Boolean @default(true) @map("assistant_correlations_enabled")
|
||||
|
||||
// v1.18.0 — operator-level server-wide module availability. A DISABLED
|
||||
// allowlist mirroring the per-user `User.modulePreferencesJson`: absent /
|
||||
@@ -5051,6 +5078,94 @@ model UserAchievement {
|
||||
@@map("user_achievements")
|
||||
}
|
||||
|
||||
// ─── Health Score record ──────
|
||||
|
||||
/// The three bands a composite can land in, lowercase because that is the
|
||||
/// value every surface already speaks. Closed on purpose: the column refuses
|
||||
/// a band no renderer knows.
|
||||
enum HealthScoreBand {
|
||||
green
|
||||
yellow
|
||||
red
|
||||
|
||||
@@map("health_score_band")
|
||||
}
|
||||
|
||||
/// The health score as it was SHOWN to the account on one local day.
|
||||
///
|
||||
/// Not a cache and not a derived tier: nothing can rebuild it. The live
|
||||
/// computation always answers for today from today's rows, so once the rows
|
||||
/// behind an old day have been edited, re-synced, corrected or simply grown,
|
||||
/// the number the person actually saw that day is gone unless it was written
|
||||
/// down. That is what this table is, and it is why the write is append-only:
|
||||
/// the FIRST score computed on a local day is the one that is kept, and a
|
||||
/// later computation of the same day leaves the row exactly as it found it.
|
||||
///
|
||||
/// It never overrides the live computation either. Every surface keeps
|
||||
/// reading the live score; this table is read only where a past day is the
|
||||
/// question.
|
||||
model HealthScoreRecord {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
/// The LOCAL calendar day (`YYYY-MM-DD`) the score was computed for, cut in
|
||||
/// `timezone`. Lexicographically ordered, so a trailing-window read is a
|
||||
/// range scan.
|
||||
dayKey String @map("day_key") @db.VarChar(10)
|
||||
/// The IANA zone the day was cut in. An account that moves zones leaves
|
||||
/// behind days cut on the old clock; carrying the zone is what lets a
|
||||
/// reader see that rather than silently compare days of different lengths.
|
||||
timezone String
|
||||
|
||||
/// The composite 0-100 as shown. Integer, matching the composite's own
|
||||
/// rounding.
|
||||
composite Int
|
||||
/// The band as shown. Stored rather than re-derived from `composite`,
|
||||
/// because the worst pillar can pull the band below the one the mean alone
|
||||
/// would give, so the number does not determine the band.
|
||||
band HealthScoreBand
|
||||
/// Algorithm identity the row was computed under.
|
||||
scoreVersion Int @map("score_version")
|
||||
/// Registry-ordered pillar ids that produced the composite. Part of the
|
||||
/// number's identity: the same value under a different composition is a
|
||||
/// different measurement.
|
||||
composition String[]
|
||||
/// Each counted pillar's own 0-100 score, keyed by pillar id. The composite
|
||||
/// breaks across a composition change; these stay comparable through one,
|
||||
/// which is the whole reason they are stored beside it rather than folded
|
||||
/// away.
|
||||
pillarScores Json @map("pillar_scores")
|
||||
/// SHA-256 over exactly what the stored composite and band depend on: the
|
||||
/// score version, the composition, and every counted pillar's score with
|
||||
/// its scoring-mode identity. Two rows with the same fingerprint were
|
||||
/// computed from the same standing; two rows that differ, were not.
|
||||
inputFingerprint String @db.VarChar(64) @map("input_fingerprint")
|
||||
|
||||
/// The account's health-score configuration version in force when the row
|
||||
/// was written, and when that configuration last changed. Supplied on every
|
||||
/// write by `computeAndRecordUserHealthScore`, which resolves them from the
|
||||
/// same stored recipe the composition came from; an account that never chose
|
||||
/// records version 0 with no date, which is a real answer and not an absence.
|
||||
///
|
||||
/// Read by `scoreSeriesSeams` (`src/lib/analytics/score/series.ts`), which
|
||||
/// marks the first row after a version move as a series break, refuses to
|
||||
/// join the composite line across it, and keeps the per-pillar rows running
|
||||
/// through it. Nullable only because the columns predate the writer by one
|
||||
/// release; nothing written since supplies null.
|
||||
configVersion Int? @map("config_version")
|
||||
configChangedAt DateTime? @map("config_changed_at") @db.Timestamptz(3)
|
||||
|
||||
/// When the score was computed. Distinct from `dayKey`: the day says which
|
||||
/// day was scored, this says when the scoring ran.
|
||||
computedAt DateTime @default(now()) @map("computed_at") @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, dayKey])
|
||||
@@index([userId, dayKey(sort: Desc)])
|
||||
@@map("health_score_records")
|
||||
}
|
||||
|
||||
// ─── Host Metrics (admin system-status host-load chart) ──────
|
||||
|
||||
/// Per-minute snapshot of host-process load + memory captured by the
|
||||
|
||||
+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.34.2";
|
||||
/* @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;
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Detector for the "a narrated sentence's locale stays required" guard.
|
||||
*
|
||||
* Lives beside the guard rather than inside it so the guard's own
|
||||
* counter-tests can run this exact code against disguised sources without
|
||||
* re-running the guard itself. See `narrated-locale-required-guard.test.ts`
|
||||
* for why the rule exists and `narrated-locale-guard-defeat.test.ts` for the
|
||||
* disguises it has to see through.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import ts from "typescript";
|
||||
|
||||
const REPO_ROOT = resolve(__dirname, "../../..");
|
||||
|
||||
/**
|
||||
* The chain. Each entry is a declaration whose output is, or becomes, prose a
|
||||
* person reads. Add to it when a new one joins the chain; never remove one
|
||||
* because the check became inconvenient.
|
||||
*/
|
||||
export const CHAIN: ReadonlyArray<{ file: string; declaration: string }> = [
|
||||
{
|
||||
file: "src/lib/insights/correlation-discovery.ts",
|
||||
declaration: "discoverCorrelations",
|
||||
},
|
||||
{
|
||||
file: "src/lib/insights/correlation-discovery.ts",
|
||||
declaration: "discoverEmergingCorrelations",
|
||||
},
|
||||
{
|
||||
file: "src/lib/insights/correlation-discovery.ts",
|
||||
declaration: "discoverLabOutcomeCorrelations",
|
||||
},
|
||||
{
|
||||
file: "src/lib/ai/coach/tools/correlations-read.ts",
|
||||
declaration: "readCoachCorrelations",
|
||||
},
|
||||
{
|
||||
file: "src/lib/insights/derived/coach-read.ts",
|
||||
declaration: "buildCoachReadStrip",
|
||||
},
|
||||
{
|
||||
file: "src/lib/insights/metric-correlation-context.ts",
|
||||
declaration: "getRelevantCorrelationsForMetric",
|
||||
},
|
||||
{
|
||||
file: "src/lib/cycle/phase-crosstab.ts",
|
||||
declaration: "discoverPhaseCorrelations",
|
||||
},
|
||||
{
|
||||
file: "src/lib/insights/narrative/period-narrative.ts",
|
||||
declaration: "buildPeriodNarrativeContext",
|
||||
},
|
||||
{
|
||||
file: "src/lib/insights/narrative/period-narrative.ts",
|
||||
declaration: "assemblePeriodNarrativeContext",
|
||||
},
|
||||
{
|
||||
file: "src/lib/ai/coach/correlations-snapshot.ts",
|
||||
declaration: "buildCorrelationsSnapshotBlock",
|
||||
},
|
||||
];
|
||||
|
||||
export const WHY_NO_DEFAULT =
|
||||
"A locale on this chain must be REQUIRED, with no `?` and no default. " +
|
||||
"These functions return finished sentences that a page prints verbatim. " +
|
||||
"An optional locale lets a caller say nothing and still get a sentence — " +
|
||||
"which is exactly how the metric page's Coach-read strip came to answer a " +
|
||||
"German page in English, under a German first line, for months. A default " +
|
||||
"cannot fail loudly; a missing argument can. Pass the reader's locale " +
|
||||
"instead of restoring the fallback.";
|
||||
|
||||
export interface LocaleSite {
|
||||
/** Where it sits, for the failure message. */
|
||||
where: string;
|
||||
optional: boolean;
|
||||
hasInitializer: boolean;
|
||||
}
|
||||
|
||||
/** The local name `Locale` is bound to in this file (it may be aliased). */
|
||||
function localeTypeName(source: ts.SourceFile): string {
|
||||
let name = "Locale";
|
||||
for (const statement of source.statements) {
|
||||
if (!ts.isImportDeclaration(statement)) continue;
|
||||
const bindings = statement.importClause?.namedBindings;
|
||||
if (!bindings || !ts.isNamedImports(bindings)) continue;
|
||||
for (const element of bindings.elements) {
|
||||
if ((element.propertyName ?? element.name).text === "Locale") {
|
||||
name = element.name.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/** Does this type node denote the locale type (possibly a union with it)? */
|
||||
function isLocaleType(node: ts.TypeNode | undefined, alias: string): boolean {
|
||||
if (!node) return false;
|
||||
if (ts.isTypeReferenceNode(node)) return node.typeName.getText() === alias;
|
||||
if (ts.isUnionTypeNode(node))
|
||||
return node.types.some((t) => isLocaleType(t, alias));
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the type still let a caller say nothing? `Locale | undefined` and
|
||||
* `Locale | null` carry no `?` and no default, so a check that only looked at
|
||||
* the question mark read them as required — and they are the original bug
|
||||
* wearing a different hat: the caller passes `undefined`, the body picks a
|
||||
* language, and the sentence comes back in English again. `any` and `unknown`
|
||||
* are the same hole with the door taken off.
|
||||
*/
|
||||
function toleratesAbsence(node: ts.TypeNode | undefined): boolean {
|
||||
if (!node) return false;
|
||||
if (
|
||||
node.kind === ts.SyntaxKind.UndefinedKeyword ||
|
||||
node.kind === ts.SyntaxKind.NullKeyword ||
|
||||
node.kind === ts.SyntaxKind.AnyKeyword ||
|
||||
node.kind === ts.SyntaxKind.UnknownKeyword ||
|
||||
node.kind === ts.SyntaxKind.VoidKeyword
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (ts.isLiteralTypeNode(node)) {
|
||||
return node.literal.kind === ts.SyntaxKind.NullKeyword;
|
||||
}
|
||||
if (ts.isUnionTypeNode(node)) return node.types.some(toleratesAbsence);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Property names given a default inside an object binding pattern —
|
||||
* `function narrate({ locale = "en" }: { locale: Locale })`. The default sits
|
||||
* on the binding, not on the parameter, so `parameter.initializer` is empty
|
||||
* and the signature reads clean.
|
||||
*/
|
||||
function defaultedBindingNames(
|
||||
parameter: ts.ParameterDeclaration,
|
||||
): Set<string> {
|
||||
const names = new Set<string>();
|
||||
if (!ts.isObjectBindingPattern(parameter.name)) return names;
|
||||
for (const element of parameter.name.elements) {
|
||||
if (element.initializer === undefined) continue;
|
||||
names.add((element.propertyName ?? element.name).getText());
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/** Find a top-level function/interface declaration by name. */
|
||||
function findDeclaration(
|
||||
source: ts.SourceFile,
|
||||
name: string,
|
||||
): ts.FunctionDeclaration | ts.InterfaceDeclaration | undefined {
|
||||
for (const statement of source.statements) {
|
||||
if (
|
||||
(ts.isFunctionDeclaration(statement) ||
|
||||
ts.isInterfaceDeclaration(statement)) &&
|
||||
statement.name?.text === name
|
||||
) {
|
||||
return statement;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every place a locale is accepted by `declaration` — as a parameter in its own
|
||||
* right, as a member of an inline options object, or as a member of a
|
||||
* same-file interface used as a parameter type. Keyed on the TYPE, so renaming
|
||||
* the parameter changes nothing.
|
||||
*/
|
||||
export function localeSites(
|
||||
source: ts.SourceFile,
|
||||
declarationName: string,
|
||||
): LocaleSite[] {
|
||||
const alias = localeTypeName(source);
|
||||
const declaration = findDeclaration(source, declarationName);
|
||||
if (!declaration) {
|
||||
throw new Error(
|
||||
`${WHY_NO_DEFAULT}\n\nGuard could not find \`${declarationName}\` in ` +
|
||||
`${source.fileName}. If it was renamed or moved, update the chain ` +
|
||||
`list — do not delete the entry.`,
|
||||
);
|
||||
}
|
||||
|
||||
const sites: LocaleSite[] = [];
|
||||
|
||||
const collectFromMembers = (
|
||||
members: ts.NodeArray<ts.TypeElement>,
|
||||
owner: string,
|
||||
ownerOptional: boolean,
|
||||
ownerHasInitializer: boolean,
|
||||
defaultedNames: Set<string> = new Set(),
|
||||
) => {
|
||||
for (const member of members) {
|
||||
if (!ts.isPropertySignature(member)) continue;
|
||||
if (!isLocaleType(member.type, alias)) continue;
|
||||
const name = member.name.getText();
|
||||
sites.push({
|
||||
where: `${owner}.${name}`,
|
||||
// The property itself may be required while the object it lives on is
|
||||
// optional or defaulted — which puts the silence right back. So does a
|
||||
// type that merely admits `undefined` / `null`: no question mark, and a
|
||||
// caller can still hand over nothing.
|
||||
optional:
|
||||
member.questionToken !== undefined ||
|
||||
ownerOptional ||
|
||||
toleratesAbsence(member.type),
|
||||
hasInitializer: ownerHasInitializer || defaultedNames.has(name),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const visitParameters = (
|
||||
parameters: ts.NodeArray<ts.ParameterDeclaration>,
|
||||
) => {
|
||||
for (const parameter of parameters) {
|
||||
const owner = parameter.name.getText();
|
||||
const optional = parameter.questionToken !== undefined;
|
||||
const hasInitializer = parameter.initializer !== undefined;
|
||||
const defaultedNames = defaultedBindingNames(parameter);
|
||||
if (isLocaleType(parameter.type, alias)) {
|
||||
sites.push({
|
||||
where: owner,
|
||||
optional: optional || toleratesAbsence(parameter.type),
|
||||
hasInitializer,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const type = parameter.type;
|
||||
if (type && ts.isTypeLiteralNode(type)) {
|
||||
collectFromMembers(
|
||||
type.members,
|
||||
owner,
|
||||
optional,
|
||||
hasInitializer,
|
||||
defaultedNames,
|
||||
);
|
||||
} else if (type && ts.isTypeReferenceNode(type)) {
|
||||
const referenced = findDeclaration(source, type.typeName.getText());
|
||||
if (referenced && ts.isInterfaceDeclaration(referenced)) {
|
||||
collectFromMembers(
|
||||
referenced.members,
|
||||
type.typeName.getText(),
|
||||
optional,
|
||||
hasInitializer,
|
||||
defaultedNames,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (ts.isInterfaceDeclaration(declaration)) {
|
||||
collectFromMembers(
|
||||
declaration.members,
|
||||
declaration.name.text,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
visitParameters(declaration.parameters);
|
||||
}
|
||||
|
||||
return sites;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `??` / `||` in the file whose left side reads a locale. This is the
|
||||
* "move the default into a variable" escape: `opts.locale ?? fallback` is the
|
||||
* same bug as `opts.locale ?? defaultLocale`, and neither is allowed here.
|
||||
*/
|
||||
export function localeFallbacks(source: ts.SourceFile): string[] {
|
||||
const found: string[] = [];
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isBinaryExpression(node)) {
|
||||
const op = node.operatorToken.kind;
|
||||
if (
|
||||
op === ts.SyntaxKind.QuestionQuestionToken ||
|
||||
op === ts.SyntaxKind.BarBarToken
|
||||
) {
|
||||
const left = node.left.getText();
|
||||
if (/locale/i.test(left)) found.push(node.getText());
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
ts.forEachChild(source, visit);
|
||||
return found;
|
||||
}
|
||||
|
||||
export function parse(file: string, text?: string): ts.SourceFile {
|
||||
return ts.createSourceFile(
|
||||
file,
|
||||
text ?? readFileSync(resolve(REPO_ROOT, file), "utf8"),
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { localeSites, localeFallbacks, parse } from "./helpers/narrated-locale";
|
||||
|
||||
/**
|
||||
* The guard checked against a guard.
|
||||
*
|
||||
* A structural check that cannot fail is worse than no check: it reads as
|
||||
* proof and is not. So each case below is a disguise someone would plausibly
|
||||
* reach for while putting the English default back — renaming the parameter,
|
||||
* aliasing the type, hiding the fallback behind a variable, defaulting the
|
||||
* whole options object instead of the field — and each one is asserted to be
|
||||
* caught by the SAME detector the real guard runs.
|
||||
*
|
||||
* These sources are strings on purpose. They are the shapes that must not
|
||||
* ship, so nothing here may live in a file the app can import.
|
||||
*/
|
||||
|
||||
const FILE = "disguise.ts";
|
||||
|
||||
describe("the locale guard cannot be talked around", () => {
|
||||
it("catches the plain optional it was written for", () => {
|
||||
const [site] = localeSites(
|
||||
parse(
|
||||
FILE,
|
||||
`import type { Locale } from "@/lib/i18n/config";
|
||||
export function narrate(opts: { locale?: Locale }) { return opts; }`,
|
||||
),
|
||||
"narrate",
|
||||
);
|
||||
expect(site.optional).toBe(true);
|
||||
});
|
||||
|
||||
it("catches a renamed parameter — it reads the type, not the name", () => {
|
||||
const [site] = localeSites(
|
||||
parse(
|
||||
FILE,
|
||||
`import type { Locale } from "@/lib/i18n/config";
|
||||
export function narrate(opts: { lang?: Locale }) { return opts; }`,
|
||||
),
|
||||
"narrate",
|
||||
);
|
||||
expect(site.where).toBe("opts.lang");
|
||||
expect(site.optional).toBe(true);
|
||||
});
|
||||
|
||||
it("catches an aliased type import", () => {
|
||||
const [site] = localeSites(
|
||||
parse(
|
||||
FILE,
|
||||
`import type { Locale as L } from "@/lib/i18n/config";
|
||||
export function narrate(opts: { lang?: L }) { return opts; }`,
|
||||
),
|
||||
"narrate",
|
||||
);
|
||||
expect(site.optional).toBe(true);
|
||||
});
|
||||
|
||||
it("catches a required field on a DEFAULTED options object", () => {
|
||||
// The field says `locale: Locale`, so a field-only check reads as clean —
|
||||
// but `= {}` means the caller can still say nothing.
|
||||
const [site] = localeSites(
|
||||
parse(
|
||||
FILE,
|
||||
`import type { Locale } from "@/lib/i18n/config";
|
||||
export function narrate(opts: { locale: Locale } = {} as never) { return opts; }`,
|
||||
),
|
||||
"narrate",
|
||||
);
|
||||
expect(site.hasInitializer).toBe(true);
|
||||
});
|
||||
|
||||
it("catches a required field on an OPTIONAL options object", () => {
|
||||
const [site] = localeSites(
|
||||
parse(
|
||||
FILE,
|
||||
`import type { Locale } from "@/lib/i18n/config";
|
||||
export function narrate(opts?: { locale: Locale }) { return opts; }`,
|
||||
),
|
||||
"narrate",
|
||||
);
|
||||
expect(site.optional).toBe(true);
|
||||
});
|
||||
|
||||
it("catches an optional locale hidden in a same-file interface", () => {
|
||||
const [site] = localeSites(
|
||||
parse(
|
||||
FILE,
|
||||
`import type { Locale } from "@/lib/i18n/config";
|
||||
export interface Opts { period: string; locale?: Locale }
|
||||
export function narrate(opts: Opts) { return opts; }`,
|
||||
),
|
||||
"narrate",
|
||||
);
|
||||
expect(site.optional).toBe(true);
|
||||
});
|
||||
|
||||
it("catches a union that merely tolerates undefined", () => {
|
||||
const [site] = localeSites(
|
||||
parse(
|
||||
FILE,
|
||||
`import type { Locale } from "@/lib/i18n/config";
|
||||
export function narrate(opts: { locale?: Locale | undefined }) { return opts; }`,
|
||||
),
|
||||
"narrate",
|
||||
);
|
||||
expect(site.optional).toBe(true);
|
||||
});
|
||||
|
||||
it("catches `Locale | undefined` written WITHOUT the question mark", () => {
|
||||
// The nastiest disguise, because it reads as required at a glance: no `?`,
|
||||
// no default, and the field is spelled out in the signature. But the caller
|
||||
// can still pass `undefined`, the body picks a language, and the sentence
|
||||
// comes back in English — the original bug, one hat later.
|
||||
const [site] = localeSites(
|
||||
parse(
|
||||
FILE,
|
||||
`import type { Locale } from "@/lib/i18n/config";
|
||||
export function narrate(opts: { locale: Locale | undefined }) {
|
||||
return opts.locale === undefined ? "en" : opts.locale;
|
||||
}`,
|
||||
),
|
||||
"narrate",
|
||||
);
|
||||
expect(site.optional).toBe(true);
|
||||
});
|
||||
|
||||
it("catches `Locale | null` the same way", () => {
|
||||
const [site] = localeSites(
|
||||
parse(
|
||||
FILE,
|
||||
`import type { Locale } from "@/lib/i18n/config";
|
||||
export function narrate(opts: { locale: Locale | null }) {
|
||||
return opts.locale === null ? "en" : opts.locale;
|
||||
}`,
|
||||
),
|
||||
"narrate",
|
||||
);
|
||||
expect(site.optional).toBe(true);
|
||||
});
|
||||
|
||||
it("catches a locale widened to `any`", () => {
|
||||
const [site] = localeSites(
|
||||
parse(
|
||||
FILE,
|
||||
`import type { Locale } from "@/lib/i18n/config";
|
||||
export function narrate(opts: { locale: Locale | any }) { return opts; }`,
|
||||
),
|
||||
"narrate",
|
||||
);
|
||||
expect(site.optional).toBe(true);
|
||||
});
|
||||
|
||||
it("catches a default hidden in the parameter's own binding pattern", () => {
|
||||
// The field is required in the type and the parameter has no initializer,
|
||||
// so both of the earlier checks read clean — the default sits on the
|
||||
// destructuring instead.
|
||||
const [site] = localeSites(
|
||||
parse(
|
||||
FILE,
|
||||
`import type { Locale } from "@/lib/i18n/config";
|
||||
export function narrate({ locale = "en" }: { locale: Locale }) { return locale; }`,
|
||||
),
|
||||
"narrate",
|
||||
);
|
||||
expect(site.hasInitializer).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves a plainly required locale alone", () => {
|
||||
// The shape the whole chain is supposed to have. If this ever reads as a
|
||||
// violation the guard stops being a guard and starts being noise.
|
||||
const [site] = localeSites(
|
||||
parse(
|
||||
FILE,
|
||||
`import type { Locale } from "@/lib/i18n/config";
|
||||
export function narrate(opts: { locale: Locale }) { return opts.locale; }`,
|
||||
),
|
||||
"narrate",
|
||||
);
|
||||
expect(site.optional).toBe(false);
|
||||
expect(site.hasInitializer).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses to pass a declaration that dropped the locale entirely", () => {
|
||||
expect(() =>
|
||||
localeSites(
|
||||
parse(
|
||||
FILE,
|
||||
`export function narrate(series: string[]) { return series; }`,
|
||||
),
|
||||
"narrate",
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(
|
||||
localeSites(
|
||||
parse(
|
||||
FILE,
|
||||
`export function narrate(series: string[]) { return series; }`,
|
||||
),
|
||||
"narrate",
|
||||
),
|
||||
).toEqual([]); // the guard's own `length > 0` assertion is what fails on this
|
||||
});
|
||||
|
||||
it("throws rather than quietly passing when the declaration is gone", () => {
|
||||
expect(() =>
|
||||
localeSites(parse(FILE, `export const x = 1;`), "narrate"),
|
||||
).toThrow(/could not find/);
|
||||
});
|
||||
|
||||
it("catches the default moved into a variable", () => {
|
||||
expect(
|
||||
localeFallbacks(
|
||||
parse(
|
||||
FILE,
|
||||
`const fallback = "en";
|
||||
export function narrate(opts: { locale?: string }) {
|
||||
return opts.locale ?? fallback;
|
||||
}`,
|
||||
),
|
||||
),
|
||||
).not.toEqual([]);
|
||||
});
|
||||
|
||||
it("catches the default written with || instead of ??", () => {
|
||||
expect(
|
||||
localeFallbacks(
|
||||
parse(
|
||||
FILE,
|
||||
`export const l = (o: { locale?: string }) => o.locale || "en";`,
|
||||
),
|
||||
),
|
||||
).not.toEqual([]);
|
||||
});
|
||||
|
||||
it("catches a renamed field's fallback when it still reads like a locale", () => {
|
||||
expect(
|
||||
localeFallbacks(
|
||||
parse(
|
||||
FILE,
|
||||
`export const l = (o: { readerLocale?: string }) => o.readerLocale ?? "en";`,
|
||||
),
|
||||
),
|
||||
).not.toEqual([]);
|
||||
});
|
||||
|
||||
it("does not fire on a fallback that has nothing to do with language", () => {
|
||||
// A guard that flags everything gets silenced, so it has to stay quiet
|
||||
// where it should.
|
||||
expect(
|
||||
localeFallbacks(
|
||||
parse(
|
||||
FILE,
|
||||
`export const n = (o: { minPairs?: number }) => o.minPairs ?? 20;`,
|
||||
),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
CHAIN,
|
||||
WHY_NO_DEFAULT,
|
||||
localeFallbacks,
|
||||
localeSites,
|
||||
parse,
|
||||
} from "./helpers/narrated-locale";
|
||||
|
||||
/**
|
||||
* A function that writes a sentence for a person must be told which language.
|
||||
*
|
||||
* The metric page's "Coach read" strip printed its own summary in German and
|
||||
* the pattern line under it in English, one under the other, for months. The
|
||||
* translations existed in all six languages the whole time. What broke was the
|
||||
* shape of the call: the discovery engine took `locale?: Locale` and fell back
|
||||
* to English, and the route that knew who was reading simply never said. Nobody
|
||||
* had to write anything wrong — staying quiet was enough, and the result read
|
||||
* like a finished sentence, so nothing looked broken from the code's side.
|
||||
*
|
||||
* A default is what made silence survivable. So on this chain there is none:
|
||||
* every locale a narrated sentence depends on is a REQUIRED parameter, and a
|
||||
* caller that cannot name one does not compile. This guard is what keeps that
|
||||
* true, because "optional with a sensible default" reads like a kindness every
|
||||
* time someone adds the next caller.
|
||||
*
|
||||
* It reads types, not names. Renaming the parameter, or lifting the default
|
||||
* into a variable, does not get past it — see the disguises in
|
||||
* `narrated-locale-guard-defeat.test.ts`, which run this same detector.
|
||||
*/
|
||||
|
||||
describe("a narrated sentence's locale stays required", () => {
|
||||
for (const { file, declaration } of CHAIN) {
|
||||
it(`${declaration} (${file}) accepts a locale, and it is required`, () => {
|
||||
const sites = localeSites(parse(file), declaration);
|
||||
|
||||
// Deleting the parameter must not pass as "nothing optional found".
|
||||
expect(
|
||||
sites.length,
|
||||
`${WHY_NO_DEFAULT}\n\n\`${declaration}\` no longer accepts a locale at ` +
|
||||
`all. It writes prose; it has to be told the language.`,
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
for (const site of sites) {
|
||||
expect(
|
||||
site.optional,
|
||||
`${WHY_NO_DEFAULT}\n\nOptional locale at \`${declaration}\` → ${site.where}.`,
|
||||
).toBe(false);
|
||||
expect(
|
||||
site.hasInitializer,
|
||||
`${WHY_NO_DEFAULT}\n\nDefaulted locale at \`${declaration}\` → ${site.where}.`,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const file of [...new Set(CHAIN.map((entry) => entry.file))]) {
|
||||
it(`${file} falls back to no language of its own`, () => {
|
||||
const fallbacks = localeFallbacks(parse(file));
|
||||
expect(
|
||||
fallbacks,
|
||||
`${WHY_NO_DEFAULT}\n\nA locale fallback in ${file}: ` +
|
||||
`${fallbacks.join(", ")}. Naming the fallback something else does ` +
|
||||
`not change what it does.`,
|
||||
).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
it("keys the strip's browser cache by language", () => {
|
||||
// The server-side fix is invisible to anyone who switches language rather
|
||||
// than reloads if the cached entry is shared across languages.
|
||||
const german = queryKeys.insightsCoachRead("WEIGHT", "de");
|
||||
const english = queryKeys.insightsCoachRead("WEIGHT", "en");
|
||||
expect(german).not.toEqual(english);
|
||||
expect(german).toContain("de");
|
||||
expect(english).toContain("en");
|
||||
});
|
||||
});
|
||||
@@ -174,6 +174,11 @@ const PINNED_AFFORDANCES: Record<
|
||||
"src/components/insights/confidence-badge.ts": { "text-success": 1 },
|
||||
"src/components/insights/daily-briefing.tsx": { "text-success": 1 },
|
||||
"src/components/insights/derived/band-tokens.ts": { "text-success": 1 },
|
||||
// The green `+N` chip beside the Health Score label. It reports a week that
|
||||
// went up, not an action that succeeded, and it wears the same band token
|
||||
// the score's own number does — there is no outcome here to route through
|
||||
// the outcome module.
|
||||
"src/components/insights/health-score-card.tsx": { "text-success": 1 },
|
||||
"src/components/insights/insights-edit-mode.tsx": { "toast.success": 2 },
|
||||
"src/components/insights/insights-tab-strip.tsx": { "toast.success": 1 },
|
||||
"src/components/insights/mood/mood-better-days.tsx": { "text-success": 1 },
|
||||
|
||||
@@ -37,6 +37,12 @@ const REQUIRED_KEYS = [
|
||||
"settings.syncOutcome.partial",
|
||||
"settings.syncOutcome.failed",
|
||||
// Retained score, records, and navigation contracts.
|
||||
// `insights.healthScore.method` was listed obsolete in v1.34.1 because its
|
||||
// only reference was `METRIC_PROVENANCE.HEALTH_SCORE.methodKey`, which the
|
||||
// card then passed as `method={null}` — a dangling reference, not a dead
|
||||
// one. The score card now renders that method paragraph in its disclosure
|
||||
// footer, so the key is a live contract again.
|
||||
"insights.healthScore.method",
|
||||
"insights.healthScore.bandSetter",
|
||||
"insights.healthScore.versionAndNoise",
|
||||
"insights.healthScore.methodVersion",
|
||||
@@ -53,7 +59,6 @@ const REQUIRED_KEYS = [
|
||||
const OBSOLETE_KEYS = [
|
||||
"insights.healthScore.algorithmChanged",
|
||||
"insights.healthScore.dismissAlgorithmChanged",
|
||||
"insights.healthScore.method",
|
||||
"insights.healthScore.composition",
|
||||
"documents.detail.summary.regenerate",
|
||||
"records.profileFacts.correct",
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Structural guard on the checkup due-day bucket.
|
||||
*
|
||||
* Turning a reminder's `nextDueAt` into "today" / "tomorrow" / "in N days" /
|
||||
* "overdue by N days" is calendar arithmetic, and it is easy to get wrong in a
|
||||
* way that reads fine most of the time: subtract the two instants, divide by
|
||||
* twenty-four hours, round. That answer disagrees with the wall calendar
|
||||
* whenever the two times of day are more than twelve hours apart, which is
|
||||
* most evenings.
|
||||
*
|
||||
* It was written that way once, fixed on the checkups page, and left unfixed
|
||||
* in the hand-copied second body on the dashboard card. For a year the two
|
||||
* screens gave different answers about the same appointment. This file exists
|
||||
* so a third body cannot appear.
|
||||
*
|
||||
* These are tripwires, not proofs. Whether the bucketing is CORRECT is settled
|
||||
* by `src/lib/measurement-reminders/__tests__/due-day.test.ts`. What this file
|
||||
* settles is that there is only one of it.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync, globSync } from "node:fs";
|
||||
import { join, sep } from "node:path";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
/** The one home. Everything else imports from here. */
|
||||
const HOME = "lib/measurement-reminders/due-day.ts";
|
||||
|
||||
/** How a consumer must reach it. */
|
||||
const HOME_IMPORT = 'from "@/lib/measurement-reminders/due-day"';
|
||||
|
||||
/**
|
||||
* The two screens that render the due line. Named here so this guard can never
|
||||
* pass by having nothing left to check: if the surfaces stop importing the
|
||||
* shared body, that is the regression, not a reason to relax the test.
|
||||
*/
|
||||
const RENDERING_SURFACES = [
|
||||
"components/measurement-reminders/vorsorge-section.tsx",
|
||||
"components/measurement-reminders/vorsorge-dashboard-card.tsx",
|
||||
];
|
||||
|
||||
/**
|
||||
* The keys only the bucket itself mints. A surface may COMPARE against the
|
||||
* today / overdue keys to colour its badge, and both do — so those two are
|
||||
* deliberately not listed. Nothing but the bucket ever needs to name
|
||||
* "tomorrow", "in N days" or "none", so a file outside the home that contains
|
||||
* one is a second bucket wearing a different function name.
|
||||
*/
|
||||
const MINTED_ONLY_BY_THE_BUCKET = [
|
||||
"measurementReminders.nextDue.tomorrow",
|
||||
"measurementReminders.nextDue.inDays",
|
||||
"measurementReminders.nextDue.none",
|
||||
];
|
||||
|
||||
/** The hook that yields the zone the surrounding dates are printed in. */
|
||||
const DISPLAY_ZONE_HOOK = "useDisplayTimezone";
|
||||
|
||||
const FIX_IT = [
|
||||
`The due-day bucket lives in src/${HOME} and nowhere else.`,
|
||||
`Import { relativeDueKey } ${HOME_IMPORT} instead of writing a second one.`,
|
||||
"If the behaviour needs to change, change it there — both the checkups page",
|
||||
"and the dashboard card read that body, and they must agree.",
|
||||
].join(" ");
|
||||
|
||||
const FIX_THE_ZONE = [
|
||||
"The due phrase has to be worked out in the same zone as the date printed",
|
||||
`beside it. Read ${DISPLAY_ZONE_HOOK}() and pass it as the third argument to`,
|
||||
"relativeDueKey. Do not reach for the device clock and do not give the",
|
||||
"parameter a default: a caller that stays silent is exactly how the phrase",
|
||||
"and the date drifted a day apart in the first place.",
|
||||
].join(" ");
|
||||
|
||||
function sourceFiles(): string[] {
|
||||
return globSync("**/*.{ts,tsx}", { cwd: SRC })
|
||||
.map((p) => p.split(sep).join("/"))
|
||||
.filter((p) => !p.startsWith("generated/"))
|
||||
.filter((p) => !p.includes("__tests__/"))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function read(rel: string): string {
|
||||
return readFileSync(join(SRC, rel), "utf8");
|
||||
}
|
||||
|
||||
/** A line that declares `relativeDueKey`, ignoring comment prose. */
|
||||
function declaresTheBucket(source: string): boolean {
|
||||
return source
|
||||
.split("\n")
|
||||
.some(
|
||||
(line) =>
|
||||
/\b(function|const|let|var)\s+relativeDueKey\b/.test(line) &&
|
||||
!/^\s*(\*|\/\/|\/\*)/.test(line),
|
||||
);
|
||||
}
|
||||
|
||||
describe("the checkup due-day bucket has one home", () => {
|
||||
it("is declared in exactly one file", () => {
|
||||
const declaring = sourceFiles().filter((rel) =>
|
||||
declaresTheBucket(read(rel)),
|
||||
);
|
||||
|
||||
expect(declaring, FIX_IT).toEqual([HOME]);
|
||||
});
|
||||
|
||||
it("is reached by import everywhere it is used", () => {
|
||||
const localUsers = sourceFiles()
|
||||
.filter((rel) => rel !== HOME)
|
||||
.filter((rel) => read(rel).includes("relativeDueKey"))
|
||||
.filter((rel) => !read(rel).includes(HOME_IMPORT));
|
||||
|
||||
expect(localUsers, FIX_IT).toEqual([]);
|
||||
});
|
||||
|
||||
it("is the only place the tomorrow, in-N-days and none keys are minted", () => {
|
||||
// Catches a duplicate that was renamed on the way in: whatever it is
|
||||
// called, a second bucket has to name the same keys to say the same words.
|
||||
const minters = sourceFiles()
|
||||
.filter((rel) => rel !== HOME)
|
||||
.filter((rel) => {
|
||||
const source = read(rel);
|
||||
return MINTED_ONLY_BY_THE_BUCKET.some((key) => source.includes(key));
|
||||
});
|
||||
|
||||
expect(minters, FIX_IT).toEqual([]);
|
||||
});
|
||||
|
||||
it("is what both checkup screens read their due line from", () => {
|
||||
for (const rel of RENDERING_SURFACES) {
|
||||
expect(read(rel), `${rel} no longer reads the shared due line`).toContain(
|
||||
HOME_IMPORT,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the due-day bucket asks the same clock as the date beside it", () => {
|
||||
it("never floors a day against the device clock", () => {
|
||||
// `startOfLocalDayInTz(instant, undefined)` means "whatever zone this
|
||||
// machine is set to". That is the reading the phrase used to take while
|
||||
// the date next to it took the profile zone.
|
||||
//
|
||||
// The check is the bare word rather than a shape like
|
||||
// `startOfLocalDayInTz(…, undefined)`, because the first draft of it
|
||||
// matched only the inline form and sailed straight past a mutation that
|
||||
// put the same `undefined` in a variable one line up. There is no honest
|
||||
// use for the word in a file whose whole job is to name a zone; if one
|
||||
// ever appears, that is worth coming here and arguing for.
|
||||
const home = read(HOME);
|
||||
|
||||
expect(home, FIX_THE_ZONE).not.toContain("undefined");
|
||||
});
|
||||
|
||||
it("takes the zone as an argument with no default to fall back on", () => {
|
||||
const home = read(HOME);
|
||||
const signature = home.slice(
|
||||
home.indexOf("export function relativeDueKey"),
|
||||
home.indexOf("): RelativeDue"),
|
||||
);
|
||||
|
||||
expect(signature, FIX_THE_ZONE).toContain("timeZone: string");
|
||||
// `timeZone: string = …` or `timeZone = …` would let a caller stay silent.
|
||||
expect(signature, FIX_THE_ZONE).not.toMatch(/timeZone[^,)]*=/);
|
||||
});
|
||||
|
||||
it("is called by surfaces that read the printed date's zone", () => {
|
||||
const silent = sourceFiles()
|
||||
.filter((rel) => rel !== HOME)
|
||||
.filter((rel) => read(rel).includes("relativeDueKey("))
|
||||
.filter((rel) => !read(rel).includes(DISPLAY_ZONE_HOOK));
|
||||
|
||||
expect(silent, FIX_THE_ZONE).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -268,22 +268,55 @@ describe("v1.16.8 — insights overview cold paint", () => {
|
||||
expect(src).not.toMatch(/useDashboardDerived\([^)]*data/);
|
||||
});
|
||||
|
||||
it("reserves the health-score column while the analytics payload is pending", () => {
|
||||
it("reserves the score column's footprint while the payload is pending", () => {
|
||||
const src = load(INSIGHTS_PATH);
|
||||
// The hero receives the pending flag so the score card's slot holds
|
||||
// its footprint from first paint (skeleton in `<HeroStrip>`).
|
||||
// The reserve holds the column from first paint, so the resolved panel is
|
||||
// a swap rather than a push.
|
||||
expect(src).toMatch(
|
||||
/healthScorePending=\{analyticsQuery\.isPending && !analytics\}/,
|
||||
/scorePending=\{analyticsQuery\.isPending && !analytics\}/,
|
||||
);
|
||||
});
|
||||
|
||||
it("hero strip renders the score-card skeleton in the reserved column", () => {
|
||||
it("mounts the health score once, in the hero, outside the section registry", () => {
|
||||
const src = load(INSIGHTS_PATH);
|
||||
// An id a saved layout has never seen merges in as default-INVISIBLE, so
|
||||
// registering the score would delete it for every customised page. It is
|
||||
// also outside the briefing branch, which used to take the whole
|
||||
// breakdown down with the flag.
|
||||
// The JSX mount, not the `<HeroStrip>` mentioned in the file's header
|
||||
// comment — matching the comment would have made the slice below read a
|
||||
// block of prose and pass on nothing.
|
||||
const hero = src.search(/\n\s*<HeroStrip\n/);
|
||||
expect(hero).toBeGreaterThan(-1);
|
||||
expect(src).not.toMatch(/"health-score":/);
|
||||
|
||||
// Exactly one score surface on the page, and it is the hero's prop.
|
||||
expect(src.match(/healthScore=\{/g) ?? []).toHaveLength(1);
|
||||
expect(src).not.toContain("<HealthScoreCard");
|
||||
expect(src).not.toContain("<HealthScoreCardSkeleton");
|
||||
|
||||
const briefingBranch = src.slice(
|
||||
src.indexOf('"daily-briefing": flags.briefing'),
|
||||
src.indexOf("vitals: <VitalsDashboard"),
|
||||
);
|
||||
expect(briefingBranch).not.toContain("healthScore");
|
||||
// The band itself is not gated on the briefing flag either.
|
||||
const heroCall = src.slice(hero, src.indexOf("/>", hero));
|
||||
expect(heroCall).toContain("healthScore={analytics?.healthScore ?? null}");
|
||||
expect(heroCall).not.toContain("flags.briefing");
|
||||
});
|
||||
|
||||
it("hero strip owns the one score surface", () => {
|
||||
const heroSrc = readFileSync(
|
||||
join(ROOT, "src/components/insights/hero-strip.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
expect(heroSrc).toContain('data-slot="health-score-card-skeleton"');
|
||||
// The two-column split keys on score-or-pending, not score alone.
|
||||
expect(heroSrc).toMatch(/\(healthScore \|\| healthScorePending\) &&/);
|
||||
expect(heroSrc).toContain("<HealthScoreCard");
|
||||
expect(heroSrc).toContain("<HealthScoreCardSkeleton");
|
||||
// The split appears only when the column has something in it.
|
||||
expect(heroSrc).toContain("md:flex-row md:items-stretch");
|
||||
expect(heroSrc).toMatch(
|
||||
/\(healthScore \|\| scorePending\) &&\s*"md:flex-row/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -257,6 +257,29 @@ function sourceClient() {
|
||||
},
|
||||
]),
|
||||
},
|
||||
// The score as it was SHOWN on a local day. Not recomputable: today's
|
||||
// number always comes from today's rows, so once the readings behind this
|
||||
// day moved, nothing can reproduce what it said.
|
||||
healthScoreRecord: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "score-day-1",
|
||||
userId: OWNER,
|
||||
dayKey: "2026-07-18",
|
||||
timezone: "Europe/Berlin",
|
||||
composite: 76,
|
||||
band: "yellow",
|
||||
scoreVersion: 2,
|
||||
composition: ["BLOOD_PRESSURE", "SLEEP", "ADIPOSITY"],
|
||||
pillarScores: { BLOOD_PRESSURE: 86, SLEEP: 100, ADIPOSITY: 42 },
|
||||
inputFingerprint: "a".repeat(64),
|
||||
configVersion: null,
|
||||
configChangedAt: null,
|
||||
computedAt: new Date("2026-07-18T20:00:00.000Z"),
|
||||
createdAt: new Date("2026-07-18T20:00:00.000Z"),
|
||||
},
|
||||
]),
|
||||
},
|
||||
customMetric: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
@@ -586,6 +609,44 @@ describe("backup round trip — export, wire schema, restore", () => {
|
||||
expect(rows[0].hourlyCumulative).toEqual(DAY_CURVE);
|
||||
});
|
||||
|
||||
it("writes the recorded score day back with the number it showed", async () => {
|
||||
const { written } = await roundTrip();
|
||||
|
||||
const write = written.find(
|
||||
(w) => w.model === "healthScoreRecord" && w.op === "createMany",
|
||||
);
|
||||
expect(
|
||||
write,
|
||||
"the recorded score day reached the file and nothing wrote it back",
|
||||
).toBeDefined();
|
||||
|
||||
const rows = write!.data as unknown as Array<Record<string, unknown>>;
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
id: "score-day-1",
|
||||
userId: OWNER,
|
||||
dayKey: "2026-07-18",
|
||||
timezone: "Europe/Berlin",
|
||||
composite: 76,
|
||||
band: "yellow",
|
||||
scoreVersion: 2,
|
||||
inputFingerprint: "a".repeat(64),
|
||||
});
|
||||
// The composition and the per-pillar scores are what keep the stored day
|
||||
// readable across a later change to what counts. A row that came back
|
||||
// with only its headline would look restored and answer nothing.
|
||||
expect(rows[0].composition).toEqual([
|
||||
"BLOOD_PRESSURE",
|
||||
"SLEEP",
|
||||
"ADIPOSITY",
|
||||
]);
|
||||
expect(rows[0].pillarScores).toEqual({
|
||||
BLOOD_PRESSURE: 86,
|
||||
SLEEP: 100,
|
||||
ADIPOSITY: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it("writes the account's own cycle symptom back before resolving its links", async () => {
|
||||
const { written, res } = await roundTrip();
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
|
||||
import { restoreCycleData } from "@/lib/cycle/backup";
|
||||
import { restoreProfileData } from "@/lib/export/profile-backup";
|
||||
import { restoreIntradayProfileData } from "@/lib/export/intraday-profile-backup";
|
||||
import { restoreHealthScoreData } from "@/lib/export/health-score-backup";
|
||||
import { invalidateUserData } from "@/lib/cache/invalidate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -71,6 +72,7 @@ interface RestoreResponse {
|
||||
customMetrics: number;
|
||||
correlationPatterns: number;
|
||||
intradayProfiles: number;
|
||||
healthScoreRecords: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -391,8 +393,6 @@ const handler = apiHandler(
|
||||
settings.assistantInsightStatusEnabled,
|
||||
assistantCorrelationsEnabled:
|
||||
settings.assistantCorrelationsEnabled,
|
||||
assistantHealthScoreExplainerEnabled:
|
||||
settings.assistantHealthScoreExplainerEnabled,
|
||||
moduleAvailabilityJson: settings.moduleAvailabilityJson as never,
|
||||
documentMaxFileBytes: settings.documentMaxFileBytes,
|
||||
documentQuotaBytes: BigInt(settings.documentQuotaBytes),
|
||||
@@ -766,6 +766,14 @@ const handler = apiHandler(
|
||||
payload,
|
||||
);
|
||||
|
||||
// The score as it was shown on each local day. Both ends live in
|
||||
// `src/lib/export/health-score-backup.ts`, same as the two above.
|
||||
const healthScoreCleared = await restoreHealthScoreData(
|
||||
tx,
|
||||
ownerId,
|
||||
payload,
|
||||
);
|
||||
|
||||
const biomarkerByName = new Map<string, string>();
|
||||
const restoredBiomarkerIds = new Set<string>();
|
||||
for (const biomarker of payload.biomarkers) {
|
||||
@@ -1130,6 +1138,7 @@ const handler = apiHandler(
|
||||
customMetrics: profileCleared.customMetrics,
|
||||
correlationPatterns: profileCleared.correlationPatterns,
|
||||
intradayProfiles: intradayCleared.intradayProfiles,
|
||||
healthScoreRecords: healthScoreCleared.healthScoreRecords,
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@@ -46,7 +46,6 @@ const ALL_ON = {
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: true,
|
||||
assistantCorrelationsEnabled: true,
|
||||
assistantHealthScoreExplainerEnabled: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -140,7 +139,6 @@ describe("PUT /api/admin/settings/assistant-flags", () => {
|
||||
expect(body.data.resolved.briefing).toBe(false);
|
||||
expect(body.data.resolved.insightStatus).toBe(false);
|
||||
expect(body.data.resolved.correlations).toBe(false);
|
||||
expect(body.data.resolved.healthScoreExplainer).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an empty body", async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* `PUT /api/admin/settings/assistant-flags` — operator-side flip
|
||||
* surface for the six assistant feature flags.
|
||||
* surface for the five assistant feature flags.
|
||||
*
|
||||
* Dedicated endpoint (separate from the generic
|
||||
* `/api/admin/settings` PUT) so the admin panel can wire its
|
||||
@@ -47,7 +47,6 @@ const assistantFlagsSchema = z
|
||||
assistantBriefingEnabled: z.boolean().optional(),
|
||||
assistantInsightStatusEnabled: z.boolean().optional(),
|
||||
assistantCorrelationsEnabled: z.boolean().optional(),
|
||||
assistantHealthScoreExplainerEnabled: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -57,7 +56,6 @@ type AssistantFlagsRow = {
|
||||
assistantBriefingEnabled: boolean;
|
||||
assistantInsightStatusEnabled: boolean;
|
||||
assistantCorrelationsEnabled: boolean;
|
||||
assistantHealthScoreExplainerEnabled: boolean;
|
||||
};
|
||||
|
||||
function buildResponseShape(row: AssistantFlagsRow) {
|
||||
@@ -67,7 +65,6 @@ function buildResponseShape(row: AssistantFlagsRow) {
|
||||
briefing: row.assistantBriefingEnabled,
|
||||
insightStatus: row.assistantInsightStatusEnabled,
|
||||
correlations: row.assistantCorrelationsEnabled,
|
||||
healthScoreExplainer: row.assistantHealthScoreExplainerEnabled,
|
||||
});
|
||||
return {
|
||||
raw: {
|
||||
@@ -76,8 +73,6 @@ function buildResponseShape(row: AssistantFlagsRow) {
|
||||
assistantBriefingEnabled: row.assistantBriefingEnabled,
|
||||
assistantInsightStatusEnabled: row.assistantInsightStatusEnabled,
|
||||
assistantCorrelationsEnabled: row.assistantCorrelationsEnabled,
|
||||
assistantHealthScoreExplainerEnabled:
|
||||
row.assistantHealthScoreExplainerEnabled,
|
||||
},
|
||||
resolved,
|
||||
};
|
||||
@@ -97,7 +92,6 @@ export const GET = apiHandler(async () => {
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: true,
|
||||
assistantCorrelationsEnabled: true,
|
||||
assistantHealthScoreExplainerEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -109,8 +103,6 @@ export const GET = apiHandler(async () => {
|
||||
settings?.assistantInsightStatusEnabled ?? true,
|
||||
assistantCorrelationsEnabled:
|
||||
settings?.assistantCorrelationsEnabled ?? true,
|
||||
assistantHealthScoreExplainerEnabled:
|
||||
settings?.assistantHealthScoreExplainerEnabled ?? true,
|
||||
};
|
||||
|
||||
return apiSuccess(buildResponseShape(row));
|
||||
@@ -162,8 +154,6 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
assistantBriefingEnabled: settings.assistantBriefingEnabled,
|
||||
assistantInsightStatusEnabled: settings.assistantInsightStatusEnabled,
|
||||
assistantCorrelationsEnabled: settings.assistantCorrelationsEnabled,
|
||||
assistantHealthScoreExplainerEnabled:
|
||||
settings.assistantHealthScoreExplainerEnabled,
|
||||
};
|
||||
|
||||
return apiSuccess(buildResponseShape(row));
|
||||
|
||||
@@ -450,3 +450,183 @@ describe("GET /api/analytics", () => {
|
||||
// contract (the chunked findMany must never re-appear on the default
|
||||
// slice critical path).
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.34.5 — the blood-pressure score's basis has to ARRIVE, not merely exist.
|
||||
*
|
||||
* The grader returns `{ score, basis }` and the pillar builder copies the
|
||||
* basis onto the pillar value, and both of those ends have their own unit
|
||||
* tests. Neither of them sees the assembly in between: the route's envelope
|
||||
* hand-off, the score reader's envelope narrowing, the pillar list, the JSON
|
||||
* envelope. A field can be produced correctly, consumed correctly, and still
|
||||
* never reach a client, which is precisely how v1.33.1 shipped a payload
|
||||
* field that nothing on the wire ever carried.
|
||||
*
|
||||
* So these drive the REAL route. Nothing about the score is stubbed — the
|
||||
* fixture seeds raw blood-pressure rows on the same `$queryRawUnsafe` the
|
||||
* live BP path issues, and the route does its own pairing, its own
|
||||
* recency-weighting, its own grading and its own serialisation. The whole
|
||||
* payload is read back off `res.json()`.
|
||||
*/
|
||||
describe("GET /api/analytics — blood-pressure score basis on the wire", () => {
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* ESH 2023 band for the fixture's date of birth (age < 65).
|
||||
*/
|
||||
const CLINICAL = { sysHigh: 129, diaHigh: 79 } as const;
|
||||
|
||||
interface WirePillar {
|
||||
id: string;
|
||||
result: {
|
||||
status: string;
|
||||
value?: {
|
||||
score: number;
|
||||
scoreBasis?: {
|
||||
axis: string;
|
||||
relation: string;
|
||||
offsetMmHg: number;
|
||||
boundaryMmHg: number;
|
||||
};
|
||||
observed: { label: string };
|
||||
reference: { label: string };
|
||||
personalReference?: { label: string };
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed one blood-pressure pair per day over the trailing `pairs` days.
|
||||
* Every pair carries the same reading, so the recency-weighted
|
||||
* representative is that reading exactly and the expected score can be
|
||||
* stated as a literal instead of a tolerance.
|
||||
*/
|
||||
function seedBpSeries(input: { sys: number; dia: number; pairs: number }) {
|
||||
const seriesOf = (value: number) =>
|
||||
Array.from({ length: input.pairs }, (_, i) => ({
|
||||
measured_at: new Date(Date.now() - (i + 1) * DAY_MS),
|
||||
value,
|
||||
}));
|
||||
const sysRows = seriesOf(input.sys);
|
||||
const diaRows = seriesOf(input.dia);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(prisma.$queryRawUnsafe as any).mockImplementation(
|
||||
async (sql: unknown) => {
|
||||
if (typeof sql !== "string") return [];
|
||||
if (sql.includes(`m."type" = 'BLOOD_PRESSURE_SYS'`)) return sysRows;
|
||||
if (sql.includes(`m."type" = 'BLOOD_PRESSURE_DIA'`)) return diaRows;
|
||||
return [];
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function signInWith(thresholdsJson: unknown) {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
session: SESSION_OK.session,
|
||||
user: { ...SESSION_USER, thresholdsJson },
|
||||
} as never);
|
||||
}
|
||||
|
||||
async function readBloodPressurePillar(): Promise<WirePillar> {
|
||||
const res = await (
|
||||
GET as unknown as (...args: never[]) => Promise<Response>
|
||||
)();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { healthScore: { pillars: WirePillar[] } | null };
|
||||
};
|
||||
const pillar = body.data.healthScore?.pillars.find(
|
||||
(p) => p.id === "BLOOD_PRESSURE",
|
||||
);
|
||||
if (!pillar) throw new Error("no BLOOD_PRESSURE pillar in the payload");
|
||||
return pillar;
|
||||
}
|
||||
|
||||
it("carries the basis onto the pillar the client actually receives", async () => {
|
||||
// 137/91 against the 129/79 band: systolic sits 8 over its ceiling and
|
||||
// grades 57, diastolic sits 12 over and grades 50. The pillar takes the
|
||||
// worse of the two, so diastolic is the axis that set the number.
|
||||
seedBpSeries({ sys: 137, dia: 91, pairs: 14 });
|
||||
const pillar = await readBloodPressurePillar();
|
||||
|
||||
expect(pillar.result.status).toBe("ok");
|
||||
expect(pillar.result.value?.observed.label).toBe("137/91 mmHg");
|
||||
expect(pillar.result.value?.score).toBe(50);
|
||||
expect(pillar.result.value?.scoreBasis).toEqual({
|
||||
axis: "diastolic",
|
||||
relation: "above_ceiling",
|
||||
offsetMmHg: 12,
|
||||
boundaryMmHg: CLINICAL.diaHigh,
|
||||
});
|
||||
});
|
||||
|
||||
it("says the reading is in band when it is", async () => {
|
||||
// 126/68 clears both ceilings, so no axis is over anything and the
|
||||
// relation has to say so rather than reporting a distance from a ceiling
|
||||
// the reading never reached. Systolic is the nearer of the two to its
|
||||
// own ceiling here, so this also puts the other axis on the wire.
|
||||
seedBpSeries({ sys: 126, dia: 68, pairs: 14 });
|
||||
const pillar = await readBloodPressurePillar();
|
||||
|
||||
expect(pillar.result.value?.observed.label).toBe("126/68 mmHg");
|
||||
expect(pillar.result.value?.scoreBasis).toEqual({
|
||||
axis: "systolic",
|
||||
relation: "in_band",
|
||||
offsetMmHg: 3,
|
||||
boundaryMmHg: CLINICAL.sysHigh,
|
||||
});
|
||||
});
|
||||
|
||||
it("omits the basis entirely when the pillar has no score to explain", async () => {
|
||||
// Eleven pairs is one short of the floor, so the pillar is insufficient
|
||||
// and carries no value at all — the popover must fall back to the
|
||||
// shipped lines rather than to an explanation of a number nobody has.
|
||||
seedBpSeries({ sys: 137, dia: 91, pairs: 11 });
|
||||
const pillar = await readBloodPressurePillar();
|
||||
|
||||
expect(pillar.result.status).toBe("insufficient");
|
||||
expect(pillar.result.value).toBeUndefined();
|
||||
});
|
||||
|
||||
/**
|
||||
* The coupling the two-ended tests cannot see.
|
||||
*
|
||||
* A user with a personal target gets the whole BP helper run twice: once
|
||||
* against their own band for the in-target percentages on the dashboard,
|
||||
* once against the clinical band for the score. The score comes from the
|
||||
* second run. So does the basis, and it has to, because the two runs
|
||||
* disagree on every field of it — different binding axis, different
|
||||
* ceiling, different distance. Picking the score off one run and the
|
||||
* basis off the other reads as correct in review and produces a popover
|
||||
* that explains a number the user is not looking at.
|
||||
*
|
||||
* The fixture is chosen so the two runs share NOTHING: 137/91 against the
|
||||
* personal 135/95 band binds on systolic 2 over 135 and scores 77; against
|
||||
* the clinical 129/79 band it binds on diastolic 12 over 79 and scores 50.
|
||||
*/
|
||||
it("takes the basis from the same run as the score when a personal target differs", async () => {
|
||||
signInWith({
|
||||
BLOOD_PRESSURE_SYS: { min: 110, max: 135 },
|
||||
BLOOD_PRESSURE_DIA: { min: 65, max: 95 },
|
||||
});
|
||||
seedBpSeries({ sys: 137, dia: 91, pairs: 14 });
|
||||
const pillar = await readBloodPressurePillar();
|
||||
|
||||
// The personal band is genuinely in play — this account really does take
|
||||
// the route's second-run branch, so the assertions below are about the
|
||||
// coupling and not about a user who has no personal target at all.
|
||||
expect(pillar.result.value?.personalReference?.label).toBe(
|
||||
"110–135/65–95 mmHg",
|
||||
);
|
||||
|
||||
expect(pillar.result.value?.score).toBe(50);
|
||||
expect(pillar.result.value?.scoreBasis).toEqual({
|
||||
axis: "diastolic",
|
||||
relation: "above_ceiling",
|
||||
offsetMmHg: 12,
|
||||
boundaryMmHg: CLINICAL.diaHigh,
|
||||
});
|
||||
// Stated negatively as well: nothing from the personal run may leak in.
|
||||
expect(pillar.result.value?.scoreBasis?.boundaryMmHg).not.toBe(135);
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,10 @@ import { computeSummariesSlice } from "@/lib/analytics/summaries-slice";
|
||||
import { resolveEffectiveBpTargets } from "@/lib/analytics/effective-range";
|
||||
import { bpTargetsEqual, getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
import { reconstructSleepNights } from "@/lib/analytics/sleep-night";
|
||||
import {
|
||||
buildSleepStageComposition,
|
||||
type SleepStageComposition,
|
||||
} from "@/lib/analytics/sleep-stage-composition";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { ensureUserRollupsFresh } from "@/lib/rollups/measurement-rollups";
|
||||
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
|
||||
@@ -17,7 +20,7 @@ import {
|
||||
computeBpInTargetFastPath,
|
||||
type BpInTargetEnvelope,
|
||||
} from "@/lib/analytics/bp-in-target-fast-path";
|
||||
import { computeUserHealthScore } from "@/lib/analytics/score/reader";
|
||||
import { computeAndRecordUserHealthScore } from "@/lib/analytics/score/record";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import { resolveRestMode } from "@/lib/illness/rest-mode";
|
||||
import { deriveBpWindow90 } from "@/lib/analytics/window-confidence";
|
||||
@@ -357,6 +360,17 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
|
||||
}),
|
||||
],
|
||||
);
|
||||
// The pillar is graded against the CLINICAL band even when the user
|
||||
// keeps a personal target (that band is the number's identity; the
|
||||
// personal one is displayed beside it and scores nothing). So when the
|
||||
// two differ we re-run the whole helper against the clinical targets
|
||||
// and the score comes from that second run.
|
||||
//
|
||||
// v1.34.5 — the "why this number" basis rides the same envelope, so it
|
||||
// is the same run, the same representative pair and the same ceilings
|
||||
// by construction. Assign envelopes WHOLE here; picking `graded.score`
|
||||
// off one run and a basis off the other would let the popover explain
|
||||
// a number nobody is looking at, and it would look correct in review.
|
||||
if (bpTargetsEqual(bpTargets, clinicalBpTargets)) {
|
||||
bpEnvelope = windows;
|
||||
bpEnvelopePriorWeek = windowsPriorWeek;
|
||||
@@ -540,12 +554,11 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
|
||||
const glucoseByContextOut = glucoseEnabled ? glucoseByContext : null;
|
||||
const glucoseClinicalOut = glucoseEnabled ? glucoseClinical : null;
|
||||
const scoredAt = new Date();
|
||||
const healthScore = await computeUserHealthScore({
|
||||
const healthScore = await computeAndRecordUserHealthScore({
|
||||
userId: user.id,
|
||||
now: scoredAt,
|
||||
profile: {
|
||||
dateOfBirth: user.dateOfBirth,
|
||||
gender: user.gender,
|
||||
heightCm: user.heightCm ?? null,
|
||||
timezone: userTz,
|
||||
sourcePriorityJson: scoreSourcePriority,
|
||||
@@ -557,6 +570,7 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
|
||||
sleep: sleepEnabled,
|
||||
mentalHealth: mentalHealthEnabled,
|
||||
},
|
||||
healthScoreConfigJson: user.healthScoreConfigJson,
|
||||
bpTargets: clinicalBpTargets,
|
||||
bpEnvelope,
|
||||
bpEnvelopePriorWeek,
|
||||
@@ -620,28 +634,30 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
|
||||
* Berlin-tz day inside the window, with minutes-per-stage for the
|
||||
* stacked-bar chart. Days with zero stage-tagged rows are omitted
|
||||
* from `perNight` (the chart treats them as gaps).
|
||||
*
|
||||
* The per-night stages are the MAIN NIGHT's stages. A nap ends on the
|
||||
* same wake day as that morning's night, and the per-wake-day totals
|
||||
* folded it into the night's buckets — an afternoon nap added CORE to
|
||||
* the night and the column read longer and shallower than the night
|
||||
* was. The nap's minutes now ride alongside on `napMinutes` /
|
||||
* `napCount`, both absent on a day without one.
|
||||
*/
|
||||
async function computeSleepStageBreakdown(
|
||||
userId: string,
|
||||
userTz: string,
|
||||
): Promise<{
|
||||
windowDays: number;
|
||||
nights: number;
|
||||
totalMinutes: number;
|
||||
stages: Record<string, number>;
|
||||
perNight: Array<{ dayKey: string; stages: Record<string, number> }>;
|
||||
} | null> {
|
||||
): Promise<SleepStageComposition | null> {
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const since = new Date(Date.now() - 30 * DAY_MS);
|
||||
const WINDOW_DAYS = 30;
|
||||
const since = new Date(Date.now() - WINDOW_DAYS * DAY_MS);
|
||||
// v1.11.5 — read the FULL per-stage row set (not `sleepStage: { not: null }`)
|
||||
// and route it through the canonical `reconstructSleepNights` so the
|
||||
// and route it through the canonical session reconstruction so the
|
||||
// stacked-bar agrees with the dashboard tile + chart series. The old path
|
||||
// keyed each stage row by its OWN calendar day (splitting a midnight-
|
||||
// spanning night across two buckets) and never collapsed multi-source
|
||||
// (double-counting a WHOOP + Apple Health night). Routing through the
|
||||
// helper fixes both: it session-clusters, keys by the wake day, and picks
|
||||
// one canonical source per night. The bare-vs-granular de-dup is applied
|
||||
// per-night to the per-stage breakdown too. The `source` is selected so
|
||||
// per-session to the per-stage breakdown too. The `source` is selected so
|
||||
// the helper's source-collapse can run.
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
@@ -662,35 +678,7 @@ async function computeSleepStageBreakdown(
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const priorityJson = await loadUserSourcePriority(userId);
|
||||
const nights = reconstructSleepNights(rows, userTz, priorityJson).filter(
|
||||
(n) => Object.keys(n.stages).length > 0,
|
||||
);
|
||||
if (nights.length === 0) return null;
|
||||
|
||||
// Aggregate the per-night stage breakdowns into the window totals + the
|
||||
// per-night series the chart slices for its 7 / 14 / 30 toggle.
|
||||
const stages: Record<string, number> = {};
|
||||
let totalMinutes = 0;
|
||||
const perNight = nights
|
||||
.map((n) => {
|
||||
const nightStages: Record<string, number> = {};
|
||||
for (const [stage, mins] of Object.entries(n.stages)) {
|
||||
if (mins == null) continue;
|
||||
nightStages[stage] = mins;
|
||||
stages[stage] = (stages[stage] ?? 0) + mins;
|
||||
totalMinutes += mins;
|
||||
}
|
||||
return { dayKey: n.night, stages: nightStages };
|
||||
})
|
||||
.sort((a, b) => a.dayKey.localeCompare(b.dayKey));
|
||||
|
||||
return {
|
||||
windowDays: 30,
|
||||
nights: nights.length,
|
||||
totalMinutes,
|
||||
stages,
|
||||
perNight,
|
||||
};
|
||||
return buildSleepStageComposition(rows, userTz, priorityJson, WINDOW_DAYS);
|
||||
}
|
||||
|
||||
// v1.4.37 W2 — `computeCorrelationHypotheses` body relocated to
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,542 @@
|
||||
/**
|
||||
* v1.35.0 — GET/PATCH /api/auth/me/health-score-config.
|
||||
*
|
||||
* Covers the read projection, the positive-selection-in /
|
||||
* deselection-out inversion, the write-time breadth refusal, the
|
||||
* monotonic recipe version, and the score-cache eviction. The
|
||||
* invalidation assertion is here because a missing one is invisible:
|
||||
* the write succeeds, the response is right, and the person keeps
|
||||
* seeing the old number for an hour.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 59,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/cache/invalidate")>();
|
||||
return { ...actual, invalidateUserHealthScore: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, PATCH } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
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_PILLARS: ScorePillarId[] = [
|
||||
"BLOOD_PRESSURE",
|
||||
"GLYCAEMIA",
|
||||
"ACTIVITY",
|
||||
"SLEEP",
|
||||
"ADIPOSITY",
|
||||
"WELLBEING",
|
||||
"LIPIDS",
|
||||
];
|
||||
|
||||
interface ResolvedBody {
|
||||
pillars: ScorePillarId[];
|
||||
excludedPillars: ScorePillarId[];
|
||||
hasSelection: boolean;
|
||||
version: number;
|
||||
changedAt: string | null;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
function mkPatch(body: unknown): Request {
|
||||
return new Request("http://localhost/api/auth/me/health-score-config", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Mutable row, so a write is visible to the read that follows it. */
|
||||
function primeUser(healthScoreConfigJson: unknown = null) {
|
||||
const row: { healthScoreConfigJson: unknown; updatedAt: Date } = {
|
||||
healthScoreConfigJson,
|
||||
updatedAt: new Date("2026-07-31T09:00:00.000Z"),
|
||||
};
|
||||
vi.mocked(prisma.user.findUnique).mockImplementation((() =>
|
||||
Promise.resolve({ ...row })) as never);
|
||||
vi.mocked(prisma.user.update).mockImplementation(((args: {
|
||||
data?: { healthScoreConfigJson?: unknown };
|
||||
}) => {
|
||||
if (args.data && "healthScoreConfigJson" in args.data) {
|
||||
row.healthScoreConfigJson = args.data.healthScoreConfigJson;
|
||||
}
|
||||
return Promise.resolve({ updatedAt: row.updatedAt });
|
||||
}) as never);
|
||||
return row;
|
||||
}
|
||||
|
||||
function writtenConfig(): {
|
||||
excludedPillars: ScorePillarId[];
|
||||
version: number;
|
||||
changedAt: string;
|
||||
} {
|
||||
const call = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as unknown as {
|
||||
data: {
|
||||
healthScoreConfigJson: {
|
||||
excludedPillars: ScorePillarId[];
|
||||
version: number;
|
||||
changedAt: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
return call.data.healthScoreConfigJson;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/auth/me/health-score-config", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/health-score-config"),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("reports every pillar counting, and no selection, for a fresh account", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser(null);
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/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_PILLARS);
|
||||
expect(env.data.hasSelection).toBe(false);
|
||||
expect(env.data.version).toBe(0);
|
||||
expect(env.data.updatedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports a stored selection as the pillars it leaves counting", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser({
|
||||
excludedPillars: ["SLEEP", "LIPIDS"],
|
||||
version: 2,
|
||||
changedAt: "2026-07-30T08:00:00.000Z",
|
||||
});
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/health-score-config"),
|
||||
);
|
||||
const env = (await res.json()) as { data: ResolvedBody };
|
||||
expect(env.data.pillars).toEqual(
|
||||
ALL_PILLARS.filter((id) => id !== "SLEEP" && id !== "LIPIDS"),
|
||||
);
|
||||
expect(env.data.excludedPillars).toEqual(["SLEEP", "LIPIDS"]);
|
||||
expect(env.data.hasSelection).toBe(true);
|
||||
expect(env.data.version).toBe(2);
|
||||
expect(env.data.updatedAt).toBe("2026-07-31T09:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
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_PILLARS }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stores the complement of the selection and returns the selection", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser(null);
|
||||
const selection: ScorePillarId[] = [
|
||||
"BLOOD_PRESSURE",
|
||||
"ACTIVITY",
|
||||
"SLEEP",
|
||||
"ADIPOSITY",
|
||||
];
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ pillars: selection }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: ResolvedBody };
|
||||
expect(env.data.pillars).toEqual(selection);
|
||||
expect(env.data.hasSelection).toBe(true);
|
||||
|
||||
const stored = writtenConfig();
|
||||
expect(stored.excludedPillars).toEqual(
|
||||
ALL_PILLARS.filter((id) => !selection.includes(id)),
|
||||
);
|
||||
expect(stored.version).toBe(1);
|
||||
expect(stored.changedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.health_score_config.update",
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("increments the recipe version on every accepted write", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser({
|
||||
excludedPillars: ["LIPIDS"],
|
||||
version: 7,
|
||||
changedAt: "2026-07-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ pillars: ALL_PILLARS }),
|
||||
);
|
||||
expect(writtenConfig().version).toBe(8);
|
||||
});
|
||||
|
||||
it("keeps a kept-everything selection as an authored choice", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser(null);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ pillars: ALL_PILLARS }),
|
||||
);
|
||||
const env = (await res.json()) as { data: ResolvedBody };
|
||||
expect(writtenConfig().excludedPillars).toEqual([]);
|
||||
expect(env.data.hasSelection).toBe(true);
|
||||
});
|
||||
|
||||
it("evicts the score caches so the old number is not served on", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser(null);
|
||||
|
||||
await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ pillars: ALL_PILLARS }),
|
||||
);
|
||||
expect(invalidateUserHealthScore).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH — the breadth rule", () => {
|
||||
it("refuses activity and wellbeing alone, in words a person can act on", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser(null);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ pillars: ["ACTIVITY", "WELLBEING"] }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const env = (await res.json()) as {
|
||||
error: string;
|
||||
meta?: { errorCode?: string; reason?: string };
|
||||
};
|
||||
expect(env.meta?.errorCode).toBe("health_score_config.too_narrow");
|
||||
expect(env.meta?.reason).toBe("measured_physiological_domain_required");
|
||||
expect(env.error).toContain("at least one physical measurement");
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
expect(prisma.user.updateMany).not.toHaveBeenCalled();
|
||||
expect(invalidateUserHealthScore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses the cardiometabolic triple, which is three pillars in one area", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser(null);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ pillars: ["BLOOD_PRESSURE", "GLYCAEMIA", "LIPIDS"] }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const env = (await res.json()) as {
|
||||
error: string;
|
||||
meta?: { reason?: string };
|
||||
};
|
||||
expect(env.meta?.reason).toBe("three_domains_required");
|
||||
expect(env.error).toContain("three different areas");
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses an empty selection rather than storing a score that cannot exist", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser(null);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ pillars: [] }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts the smallest selection that clears the rule", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser(null);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ pillars: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"] }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH — envelope and transport", () => {
|
||||
it("rejects an unknown pillar id with 422 and writes nothing", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser(null);
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ pillars: ["BLOOD_PRESSURE", "MOON_PHASE", "SLEEP"] }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an unknown body key with 422 (strict stays strict)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser(null);
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ pillars: ALL_PILLARS, weights: { SLEEP: 2 } }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a userId in the body", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser(null);
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ pillars: ALL_PILLARS, userId: "user-2" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects malformed JSON with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const req = new Request(
|
||||
"http://localhost/api/auth/me/health-score-config",
|
||||
{
|
||||
method: "PATCH",
|
||||
body: "{ not valid json",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 429 when the per-user rate limit fires", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 30_000,
|
||||
});
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ pillars: ALL_PILLARS }),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("guards the write on the base token and 409s a stale one", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser(null);
|
||||
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({
|
||||
pillars: ALL_PILLARS,
|
||||
baseUpdatedAt: "2026-07-24T08:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(409);
|
||||
const env = (await res.json()) as { meta?: { errorCode?: string } };
|
||||
expect(env.meta?.errorCode).toBe("health_score_config_conflict");
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
// A write that never landed must not evict the caches either.
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* v1.35.0 — which pillars count toward the Health Score.
|
||||
*
|
||||
* GET /api/auth/me/health-score-config — the resolved composition:
|
||||
* which pillars count, which
|
||||
* the person took out, whether
|
||||
* they have chosen at all, and
|
||||
* the recipe version.
|
||||
* PATCH /api/auth/me/health-score-config — body `{ pillars: [...] }`,
|
||||
* the pillars that should
|
||||
* count. Field-by-field write
|
||||
* of the columns this route
|
||||
* owns; no mass assignment.
|
||||
*
|
||||
* The body speaks the positive selection because that is what a person
|
||||
* chooses. The column stores its complement (see
|
||||
* `src/lib/analytics/score/config.ts`), so the inversion happens once,
|
||||
* here, on the way in.
|
||||
*
|
||||
* Two things this route is responsible for beyond storing a list.
|
||||
*
|
||||
* **It refuses a selection that cannot produce a score.** Three distinct
|
||||
* areas of health and at least one physical measurement, or the
|
||||
* composite does not exist. Saving such a selection would leave the
|
||||
* person with a settings page that says one thing and a score that has
|
||||
* silently vanished, so the write is refused with a sentence they can
|
||||
* act on. The scorer applies the same rule again at read time through
|
||||
* the same function, deliberately: a config written before a rule change
|
||||
* or edited outside the app still meets an honest refusal rather than a
|
||||
* missing number.
|
||||
*
|
||||
* **It evicts the score caches.** The person just changed what the
|
||||
* number means; serving the old one for the next hour would make the
|
||||
* change look broken. `invalidateUserHealthScore` is the same call the
|
||||
* lab and assessment write paths make.
|
||||
*
|
||||
* `userId` is always narrowed from `requireAuth()`; the body never
|
||||
* carries it.
|
||||
*/
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import {
|
||||
takeBaseToken,
|
||||
guardedUserUpdate,
|
||||
invalidBaseTokenError,
|
||||
} from "@/lib/optimistic-lock";
|
||||
import { evaluateScoreBreadth } from "@/lib/analytics/score/breadth";
|
||||
import {
|
||||
healthScoreConfigFromSelection,
|
||||
resolveHealthScoreConfig,
|
||||
scorePillarIdSchema,
|
||||
} from "@/lib/analytics/score/config";
|
||||
import { SCORE_PILLAR_IDS } from "@/lib/analytics/score/types";
|
||||
import { z } from "zod";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const PATCH_RATE_LIMIT = 60;
|
||||
const PATCH_WINDOW_MS = 60_000;
|
||||
|
||||
/**
|
||||
* The request body. `.strict()` and enum-typed: a client sending an id
|
||||
* this build does not know is a mistake worth reporting, unlike a
|
||||
* stored blob, which is filtered instead so one stale string cannot
|
||||
* discard a recipe (see the resolver).
|
||||
*/
|
||||
const healthScoreConfigPatchSchema = z
|
||||
.object({
|
||||
pillars: z.array(scorePillarIdSchema).max(SCORE_PILLAR_IDS.length),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Why a selection cannot produce a score, said the way a person would
|
||||
* say it. The cardiometabolic sentence is the one that surprises people:
|
||||
* blood pressure, glucose and cholesterol are three pillars but one area.
|
||||
*/
|
||||
const BREADTH_REFUSAL = {
|
||||
measured_physiological_domain_required:
|
||||
"A health score needs at least one physical measurement. Keep one of blood pressure, glucose, sleep, body shape or cholesterol in your selection, alongside whatever else you choose.",
|
||||
three_domains_required:
|
||||
"A health score needs at least three different areas of health. Blood pressure, glucose and cholesterol all describe the same area, so a selection made only of those counts as one.",
|
||||
} as const;
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "auth.me.health-score-config.get" } });
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { healthScoreConfigJson: true, updatedAt: true },
|
||||
});
|
||||
const resolved = resolveHealthScoreConfig(row?.healthScoreConfigJson);
|
||||
// A person who never chose has nothing to guard a write against, so
|
||||
// they get no token — the first save is the unconditional write.
|
||||
if (row?.healthScoreConfigJson == null) return apiSuccess(resolved);
|
||||
return apiSuccess({ ...resolved, updatedAt: row.updatedAt?.toISOString() });
|
||||
});
|
||||
|
||||
export const PATCH = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`health-score-config:patch:${user.id}`,
|
||||
PATCH_RATE_LIMIT,
|
||||
PATCH_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many requests", 429);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
throw new HttpError(422, "health-score-config.body.invalid_json");
|
||||
}
|
||||
|
||||
// Strip the optimistic-concurrency token before the strict parse, the
|
||||
// way the modules route does: the schema must stay strict, so the
|
||||
// transport token can never reach it.
|
||||
const taken = takeBaseToken(body ?? {});
|
||||
if ("invalid" in taken) return invalidBaseTokenError();
|
||||
const base = taken.base;
|
||||
|
||||
const parsed = healthScoreConfigPatchSchema.safeParse(taken.rest ?? {});
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "auth.me.health-score-config.patch.invalid_shape" },
|
||||
meta: { issues: parsed.error.issues.length },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "health_score_config.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const breadth = evaluateScoreBreadth(parsed.data.pillars);
|
||||
if (!breadth.ok) {
|
||||
annotate({
|
||||
action: { name: "auth.me.health-score-config.patch.too_narrow" },
|
||||
meta: {
|
||||
reason: breadth.reason,
|
||||
selected: parsed.data.pillars.length,
|
||||
domains: breadth.domains.length,
|
||||
},
|
||||
});
|
||||
return apiError(BREADTH_REFUSAL[breadth.reason], 422, {
|
||||
errorCode: "health_score_config.too_narrow",
|
||||
reason: breadth.reason,
|
||||
});
|
||||
}
|
||||
|
||||
// The recipe version is monotonic per account: a score record has to
|
||||
// be able to say which recipe produced it, and a comparison between
|
||||
// two windows has to be able to see that the recipe moved between
|
||||
// them. Read the previous version, increment, write.
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { healthScoreConfigJson: true },
|
||||
});
|
||||
const previous = resolveHealthScoreConfig(existing?.healthScoreConfigJson);
|
||||
const config = healthScoreConfigFromSelection({
|
||||
selection: parsed.data.pillars,
|
||||
version: previous.version + 1,
|
||||
changedAt: new Date(),
|
||||
});
|
||||
|
||||
const guarded = await guardedUserUpdate({
|
||||
userId: user.id,
|
||||
base,
|
||||
data: { healthScoreConfigJson: config },
|
||||
conflict: {
|
||||
action: "auth.me.health-score-config.conflict",
|
||||
errorCode: "health_score_config_conflict",
|
||||
message: "The score selection changed since it was loaded",
|
||||
},
|
||||
});
|
||||
if ("conflict" in guarded) return guarded.conflict;
|
||||
|
||||
// The composite the caches hold was computed from the old selection.
|
||||
invalidateUserHealthScore(user.id);
|
||||
|
||||
const resolved = resolveHealthScoreConfig(config);
|
||||
|
||||
await auditLog("user.health_score_config.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: {
|
||||
counted: resolved.pillars,
|
||||
excluded: resolved.excludedPillars,
|
||||
version: resolved.version,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "auth.me.health-score-config.patch" },
|
||||
meta: {
|
||||
counted: resolved.pillars.length,
|
||||
excluded: resolved.excludedPillars.length,
|
||||
version: resolved.version,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ ...resolved, updatedAt: guarded.updatedAt });
|
||||
});
|
||||
@@ -51,6 +51,12 @@ vi.mock("@/lib/feature-flags", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/cache/invalidate")>();
|
||||
return { ...actual, invalidateUserHealthScore: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
@@ -66,6 +72,7 @@ import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getAssistantFlags } from "@/lib/feature-flags";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -320,6 +327,39 @@ describe("PATCH /api/auth/me/modules", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.35.0 — four of the score's pillars come and go with a module
|
||||
* toggle, so a toggle changes what the composite is made of. The score
|
||||
* caches hold the old composition and are served for up to an hour, so
|
||||
* the person who just turned a module off keeps seeing a number
|
||||
* computed from it. Nothing about that is visible in a test that does
|
||||
* not assert the eviction, which is why it went unnoticed for so long.
|
||||
*/
|
||||
describe("PATCH /api/auth/me/modules — the score caches", () => {
|
||||
it("evicts the score caches after a module toggle lands", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser({ modulePreferencesJson: null });
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ sleep: false }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(invalidateUserHealthScore).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("does not evict them when the write never landed", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser({ modulePreferencesJson: null });
|
||||
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ sleep: false, baseUpdatedAt: "2026-07-24T08:00:00.000Z" }),
|
||||
);
|
||||
expect(res.status).toBe(409);
|
||||
expect(invalidateUserHealthScore).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.32.22 (M3) — optimistic concurrency. The base token is stripped BEFORE
|
||||
* the `.strict()` schema parse, so it never trips strict validation; a stale
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import {
|
||||
takeBaseToken,
|
||||
@@ -137,6 +138,12 @@ export const PATCH = apiHandler(async (req: Request) => {
|
||||
});
|
||||
if ("conflict" in guarded) return guarded.conflict;
|
||||
|
||||
// A module toggle can add or remove a Health Score pillar, so the
|
||||
// cached composite was computed from a composition that no longer
|
||||
// holds. Without this the score stays stale for up to an hour after a
|
||||
// change the person made on purpose.
|
||||
invalidateUserHealthScore(user.id);
|
||||
|
||||
const modules = await resolveModuleMap(user.id);
|
||||
|
||||
await auditLog("user.modules.update", {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
apiValidationError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
@@ -19,7 +20,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const result = await applyProfileUpdate(user.id, body, getClientIp(request));
|
||||
if (!result.ok) {
|
||||
return apiError(result.message, result.status);
|
||||
const meta = result.errorCode ? { errorCode: result.errorCode } : undefined;
|
||||
return result.issues
|
||||
? apiValidationError(result.message, result.issues, result.status, meta)
|
||||
: apiError(result.message, result.status, meta);
|
||||
}
|
||||
|
||||
annotate({ action: { name: "auth.profile.update" } });
|
||||
@@ -37,5 +41,6 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
insurerName: result.user.insurerName,
|
||||
insurerIkNumber: result.user.insurerIkNumber,
|
||||
hasInsuranceNumber: result.user.hasInsuranceNumber,
|
||||
...(result.rejectedFields ? { rejectedFields: result.rejectedFields } : {}),
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
/**
|
||||
* The cycle insights route has to say who is reading, too.
|
||||
*
|
||||
* Each surviving lagged row carries a finished `interpretation` sentence that
|
||||
* this route serves straight to the client. The engine underneath can write it
|
||||
* in any of the six languages, and it only writes the right one if the route
|
||||
* resolves the reader's language and hands it over — the same handoff the
|
||||
* metric page's "Coach read" strip was missing when it answered a German page
|
||||
* in English.
|
||||
*
|
||||
* So what is pinned here is the handoff itself: the resolved locale arriving at
|
||||
* `discoverPhaseCorrelations`, from the cookie a language switch sets and from
|
||||
* the stored preference when there is no cookie. Without this the route could
|
||||
* go back to naming one language outright and nothing in the suite would
|
||||
* notice.
|
||||
*/
|
||||
|
||||
/** Stands in for the discovery engine so the ARGUMENTS are what is inspected. */
|
||||
const discoverPhaseCorrelations = vi.fn<
|
||||
(args: { locale?: unknown }) => {
|
||||
discovered: never[];
|
||||
pairsTested: number;
|
||||
fdrQ: number;
|
||||
minPairs: number;
|
||||
}
|
||||
>(() => ({
|
||||
discovered: [],
|
||||
pairsTested: 0,
|
||||
fdrQ: 0.1,
|
||||
minPairs: 20,
|
||||
}));
|
||||
vi.mock("@/lib/cycle/phase-crosstab", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/cycle/phase-crosstab")>()),
|
||||
discoverPhaseCorrelations: (args: { locale?: unknown }) =>
|
||||
discoverPhaseCorrelations(args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
auditLog: { create: vi.fn() },
|
||||
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
measurement: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ sourcePriorityJson: null }),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/cycle/gate", () => ({
|
||||
requireCycleEnabled: vi.fn(async () => ({
|
||||
enabled: true,
|
||||
profile: { lutealPhaseLength: 14, averageCycleLength: 28 },
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const localeCookie = vi.fn<() => string | undefined>(() => undefined);
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) =>
|
||||
name === "healthlog-locale" && localeCookie() !== undefined
|
||||
? { value: localeCookie() }
|
||||
: undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
function sessionWithLocale(locale: string | null) {
|
||||
return {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "testuser",
|
||||
role: "USER" as const,
|
||||
gender: "FEMALE" as const,
|
||||
timezone: "Europe/Berlin",
|
||||
locale,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getSession).mockResolvedValue(
|
||||
sessionWithLocale(null) as unknown as Awaited<
|
||||
ReturnType<typeof getSession>
|
||||
>,
|
||||
);
|
||||
discoverPhaseCorrelations.mockClear();
|
||||
localeCookie.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
/** The locale the route handed the discovery engine on the last call. */
|
||||
function passedLocale(): unknown {
|
||||
return discoverPhaseCorrelations.mock.calls.at(-1)?.[0]?.locale;
|
||||
}
|
||||
|
||||
describe("GET /api/cycle/insights — the reader's language", () => {
|
||||
it("passes the language the switcher's cookie names", async () => {
|
||||
localeCookie.mockReturnValue("de");
|
||||
|
||||
const res = await GET();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(passedLocale()).toBe("de");
|
||||
});
|
||||
|
||||
it("falls back to the stored preference when there is no cookie", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(
|
||||
sessionWithLocale("pl") as unknown as Awaited<
|
||||
ReturnType<typeof getSession>
|
||||
>,
|
||||
);
|
||||
|
||||
await GET();
|
||||
|
||||
expect(passedLocale()).toBe("pl");
|
||||
});
|
||||
|
||||
it("never hard-codes a language of its own", async () => {
|
||||
// Two readers, two languages, from the same handler. A route that named
|
||||
// one outright would pass this only by accident.
|
||||
localeCookie.mockReturnValue("it");
|
||||
await GET();
|
||||
const first = passedLocale();
|
||||
localeCookie.mockReturnValue("fr");
|
||||
await GET();
|
||||
|
||||
expect([first, passedLocale()]).toEqual(["it", "fr"]);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { predictCycle, type NightlyTempInput } from "@/lib/cycle";
|
||||
import { LUTEAL_DEFAULT } from "@/lib/cycle/types";
|
||||
import {
|
||||
@@ -208,10 +209,16 @@ export const GET = apiHandler(async () => {
|
||||
userPriorityJson,
|
||||
});
|
||||
const headline = selectHeadlinePhaseRow(rows);
|
||||
// The lagged rows are served with their narrated `interpretation` intact, so
|
||||
// the reader's language is resolved here the same way the correlations route
|
||||
// resolves it. This surface had the same silent-English gap the metric-page
|
||||
// strip had.
|
||||
const locale = await resolveServerLocale({ userLocale: user.locale ?? null });
|
||||
const lagged = discoverPhaseCorrelations({
|
||||
phaseByDay,
|
||||
measurements,
|
||||
userPriorityJson,
|
||||
locale,
|
||||
});
|
||||
|
||||
// Cycle-NATIVE insight: where each logged symptom clusters across the phases.
|
||||
|
||||
@@ -27,6 +27,7 @@ vi.mock("@/lib/db", () => ({
|
||||
// The hourly shape of a cumulative day, read by
|
||||
// `buildIntradayProfileBackupSection`.
|
||||
intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// v1.15.0 — cycle tables read by the full-backup helper.
|
||||
cycleProfile: { findUnique: vi.fn() },
|
||||
menstrualCycle: { findMany: vi.fn() },
|
||||
@@ -96,6 +97,7 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.intradayCumulativeProfile.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.healthScoreRecord.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -25,6 +25,7 @@ vi.mock("@/lib/db", () => ({
|
||||
// The hourly shape of a cumulative day, read by
|
||||
// `buildIntradayProfileBackupSection`.
|
||||
intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
|
||||
@@ -72,7 +72,6 @@ describe("GET /api/feature-flags", () => {
|
||||
briefing: boolean;
|
||||
insightStatus: boolean;
|
||||
correlations: boolean;
|
||||
healthScoreExplainer: boolean;
|
||||
};
|
||||
};
|
||||
error: null;
|
||||
@@ -85,7 +84,6 @@ describe("GET /api/feature-flags", () => {
|
||||
briefing: true,
|
||||
insightStatus: true,
|
||||
correlations: true,
|
||||
healthScoreExplainer: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,7 +95,6 @@ describe("GET /api/feature-flags", () => {
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: true,
|
||||
assistantCorrelationsEnabled: true,
|
||||
assistantHealthScoreExplainerEnabled: true,
|
||||
});
|
||||
|
||||
const res = await GET(req());
|
||||
@@ -113,7 +110,6 @@ describe("GET /api/feature-flags", () => {
|
||||
briefing: false,
|
||||
insightStatus: false,
|
||||
correlations: false,
|
||||
healthScoreExplainer: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,7 +121,6 @@ describe("GET /api/feature-flags", () => {
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: false,
|
||||
assistantCorrelationsEnabled: true,
|
||||
assistantHealthScoreExplainerEnabled: false,
|
||||
});
|
||||
|
||||
const res = await GET(req());
|
||||
@@ -141,7 +136,6 @@ describe("GET /api/feature-flags", () => {
|
||||
briefing: true,
|
||||
insightStatus: false,
|
||||
correlations: true,
|
||||
healthScoreExplainer: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -13,8 +13,7 @@
|
||||
* "coach": true,
|
||||
* "briefing": true,
|
||||
* "insightStatus": true,
|
||||
* "correlations": true,
|
||||
* "healthScoreExplainer": true
|
||||
* "correlations": true
|
||||
* }
|
||||
* },
|
||||
* "error": null
|
||||
|
||||
@@ -23,7 +23,7 @@ import { describe, expect, it } from "vitest";
|
||||
* `requireAssistantSurface("coach")`.
|
||||
* 2. Other-gated — file invokes `requireAssistantSurface()` with a
|
||||
* different surface (`insightStatus`,
|
||||
* `correlations`, `briefing`, `healthScoreExplainer`).
|
||||
* `correlations`, `briefing`).
|
||||
* 3. Allowlisted — non-Coach Insights routes that don't gate on
|
||||
* the matrix at all (provider chain, settings,
|
||||
* targets, glp1-timeline, feedback). Allowlist
|
||||
|
||||
@@ -129,7 +129,6 @@ describe("GET /api/insights/biomarker-assessment", () => {
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: false,
|
||||
assistantCorrelationsEnabled: true,
|
||||
assistantHealthScoreExplainerEnabled: true,
|
||||
} as never);
|
||||
const res = await callGet(makeReq("bm-1"));
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
@@ -91,7 +91,6 @@ describe("GET /api/insights/blood-pressure-status — assistant-flag gate", () =
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: false,
|
||||
assistantCorrelationsEnabled: true,
|
||||
assistantHealthScoreExplainerEnabled: true,
|
||||
} as never);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
@@ -95,7 +95,6 @@ describe("GET /api/insights/cards — assistant-flag gate", () => {
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: false,
|
||||
assistantCorrelationsEnabled: true,
|
||||
assistantHealthScoreExplainerEnabled: true,
|
||||
} as never);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(403);
|
||||
@@ -111,7 +110,6 @@ describe("GET /api/insights/cards — assistant-flag gate", () => {
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: true,
|
||||
assistantCorrelationsEnabled: true,
|
||||
assistantHealthScoreExplainerEnabled: true,
|
||||
} as never);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* The route has to say who is reading.
|
||||
*
|
||||
* The strip's second line is a finished sentence written server-side. The
|
||||
* builder can write it in any of the six languages; for months it wrote
|
||||
* English on a German page because this route never passed a language and the
|
||||
* engine underneath quietly defaulted. So what is pinned here is the handoff
|
||||
* itself: the resolved locale reaching `buildCoachReadStrip`, from the cookie a
|
||||
* language switch sets and from the stored preference when there is no cookie.
|
||||
*/
|
||||
|
||||
const buildCoachReadStrip = vi.fn();
|
||||
vi.mock("@/lib/insights/derived/coach-read", () => ({
|
||||
buildCoachReadStrip: (userId: string, metric: string, locale: string) =>
|
||||
buildCoachReadStrip(userId, metric, locale),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
auditLog: { create: vi.fn() },
|
||||
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/modules/gate")>()),
|
||||
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
|
||||
resolveModuleMap: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
vi.mock("@/lib/feature-flags", () => ({
|
||||
requireAssistantSurface: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAnalyticsReadRateLimit: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const localeCookie = vi.fn<() => string | undefined>(() => undefined);
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) =>
|
||||
name === "healthlog-locale" && localeCookie() !== undefined
|
||||
? { value: localeCookie() }
|
||||
: undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
function sessionWithLocale(locale: string | null) {
|
||||
return {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "testuser",
|
||||
role: "USER" as const,
|
||||
locale,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const REQUEST = () =>
|
||||
new NextRequest("http://localhost/api/insights/coach-read?metric=WEIGHT");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getSession).mockResolvedValue(
|
||||
sessionWithLocale(null) as unknown as Awaited<
|
||||
ReturnType<typeof getSession>
|
||||
>,
|
||||
);
|
||||
vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
} as unknown as Awaited<ReturnType<typeof checkAnalyticsReadRateLimit>>);
|
||||
buildCoachReadStrip.mockReset().mockResolvedValue({
|
||||
baseline: null,
|
||||
learning: true,
|
||||
driver: null,
|
||||
});
|
||||
localeCookie.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
describe("GET /api/insights/coach-read — the reader's language", () => {
|
||||
it("passes the language the switcher's cookie names", async () => {
|
||||
localeCookie.mockReturnValue("de");
|
||||
|
||||
const res = await GET(REQUEST());
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(buildCoachReadStrip).toHaveBeenCalledWith("user-1", "WEIGHT", "de");
|
||||
});
|
||||
|
||||
it("falls back to the stored preference when there is no cookie", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(
|
||||
sessionWithLocale("fr") as unknown as Awaited<
|
||||
ReturnType<typeof getSession>
|
||||
>,
|
||||
);
|
||||
|
||||
await GET(REQUEST());
|
||||
|
||||
expect(buildCoachReadStrip).toHaveBeenCalledWith("user-1", "WEIGHT", "fr");
|
||||
});
|
||||
|
||||
it("never hard-codes a language of its own", async () => {
|
||||
// Two readers, two languages, from the same handler. A route that pinned
|
||||
// one would pass this only by accident.
|
||||
localeCookie.mockReturnValue("es");
|
||||
await GET(REQUEST());
|
||||
localeCookie.mockReturnValue("de");
|
||||
await GET(REQUEST());
|
||||
|
||||
expect(buildCoachReadStrip.mock.calls.map((call) => call[2])).toEqual([
|
||||
"es",
|
||||
"de",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -60,12 +61,20 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
const metric = parsed.data.metric as MeasurementType;
|
||||
|
||||
const strip = await buildCoachReadStrip(user.id, metric);
|
||||
// Line 2 of the strip is a finished sentence the clients print verbatim, so
|
||||
// the reader's language is decided here — same resolution the correlations
|
||||
// and analytics routes use (cookie, then the stored preference, then the
|
||||
// Accept-Language header). Without it the strip answered a German page in
|
||||
// English, one line under the other.
|
||||
const locale = await resolveServerLocale({ userLocale: user.locale ?? null });
|
||||
|
||||
const strip = await buildCoachReadStrip(user.id, metric, locale);
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.coach-read" },
|
||||
meta: {
|
||||
metric,
|
||||
locale,
|
||||
has_baseline: strip.baseline !== null,
|
||||
learning: strip.learning,
|
||||
has_driver: strip.driver !== null,
|
||||
|
||||
@@ -167,7 +167,6 @@ describe("GET /api/insights/correlations", () => {
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: true,
|
||||
assistantCorrelationsEnabled: false,
|
||||
assistantHealthScoreExplainerEnabled: true,
|
||||
});
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(403);
|
||||
@@ -185,7 +184,6 @@ describe("GET /api/insights/correlations", () => {
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: true,
|
||||
assistantCorrelationsEnabled: true,
|
||||
assistantHealthScoreExplainerEnabled: true,
|
||||
});
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
@@ -134,7 +134,6 @@ describe("GET /api/insights/metric-status", () => {
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: false,
|
||||
assistantCorrelationsEnabled: true,
|
||||
assistantHealthScoreExplainerEnabled: true,
|
||||
} as never);
|
||||
const res = await callGet(makeReq("SLEEP_DURATION"));
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
@@ -102,7 +102,6 @@ describe("POST /api/insights/pregenerate", () => {
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: true,
|
||||
assistantCorrelationsEnabled: true,
|
||||
assistantHealthScoreExplainerEnabled: true,
|
||||
} as never);
|
||||
const res = await callPost(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
@@ -117,7 +116,6 @@ describe("POST /api/insights/pregenerate", () => {
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: false,
|
||||
assistantCorrelationsEnabled: true,
|
||||
assistantHealthScoreExplainerEnabled: true,
|
||||
} as never);
|
||||
const res = await callPost(makeReq());
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
@@ -61,6 +61,7 @@ import { SHARE_LINK_MAX_DAYS } from "@/lib/validations/clinician-share-link";
|
||||
import {
|
||||
ALL_LEAF_IDS,
|
||||
REPORT_GROUP_ORDER,
|
||||
SENSITIVE_GROUP_ID,
|
||||
} from "@/lib/report-selection/catalogue";
|
||||
|
||||
const SESSION_OK = {
|
||||
@@ -96,7 +97,7 @@ type CapabilitiesBody = {
|
||||
maxDays: number;
|
||||
reportDownload: string[];
|
||||
selectionVersion: number;
|
||||
groups: string[];
|
||||
groups: { id: string; leaves: string[]; sensitive?: boolean }[];
|
||||
leaves: string[];
|
||||
};
|
||||
};
|
||||
@@ -187,12 +188,37 @@ describe("GET /api/meta/capabilities — drift guards", () => {
|
||||
// that was never built.
|
||||
expect(body.data.share.reportDownload).toEqual(["fhir", "pdf"]);
|
||||
expect(body.data.share.selectionVersion).toBe(2);
|
||||
expect(body.data.share.groups).toEqual([...REPORT_GROUP_ORDER]);
|
||||
expect(body.data.share.groups.map((g) => g.id)).toEqual([
|
||||
...REPORT_GROUP_ORDER,
|
||||
]);
|
||||
expect([...body.data.share.leaves].sort()).toEqual(
|
||||
[...ALL_LEAF_IDS].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("share.groups carries membership: every leaf in exactly one group, fence flagged once", async () => {
|
||||
const res = await call();
|
||||
const body = (await res.json()) as CapabilitiesBody;
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const group of body.data.share.groups) {
|
||||
for (const leaf of group.leaves) {
|
||||
expect(seen.has(leaf), `${leaf} appears in more than one group`).toBe(
|
||||
false,
|
||||
);
|
||||
seen.add(leaf);
|
||||
}
|
||||
}
|
||||
// Every leaf in the flat set is in exactly one group, and no group holds
|
||||
// a leaf absent from the flat set — the invariant that makes a native
|
||||
// client's fence trustworthy.
|
||||
expect([...seen].sort()).toEqual([...body.data.share.leaves].sort());
|
||||
|
||||
const sensitiveGroups = body.data.share.groups.filter((g) => g.sensitive);
|
||||
expect(sensitiveGroups).toHaveLength(1);
|
||||
expect(sensitiveGroups[0]?.id).toBe(SENSITIVE_GROUP_ID);
|
||||
});
|
||||
|
||||
it("quantityTypes are sourced from the HealthKit ingest mapping", async () => {
|
||||
const res = await call();
|
||||
const body = (await res.json()) as CapabilitiesBody;
|
||||
|
||||
@@ -46,10 +46,7 @@ import {
|
||||
FHIR_SEARCH_PARAMS,
|
||||
} from "@/lib/fhir/rest";
|
||||
import { SHARE_LINK_MAX_DAYS } from "@/lib/validations/clinician-share-link";
|
||||
import {
|
||||
ALL_LEAF_IDS,
|
||||
REPORT_GROUP_ORDER,
|
||||
} from "@/lib/report-selection/catalogue";
|
||||
import { ALL_LEAF_IDS, SHARE_GROUPS } from "@/lib/report-selection/catalogue";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -135,7 +132,12 @@ export const GET = apiHandler(async () => {
|
||||
maxDays: SHARE_LINK_MAX_DAYS,
|
||||
reportDownload: ["fhir", "pdf"],
|
||||
selectionVersion: 2,
|
||||
groups: REPORT_GROUP_ORDER,
|
||||
// Each group carries its own member leaves, and the fenced tier carries
|
||||
// `sensitive: true` — the membership the panel, the PDF and the guard
|
||||
// tests already enforce, published so the native client can mirror the
|
||||
// fence instead of guessing which leaves it may bundle behind one
|
||||
// control. `leaves` stays the flat total set for back-compat.
|
||||
groups: SHARE_GROUPS,
|
||||
leaves: ALL_LEAF_IDS,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -96,7 +96,15 @@ describe("GET /api/nutrients", () => {
|
||||
const res = await GET(getReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as OverviewResponse;
|
||||
expect(body.data).toEqual({ windowDays: 14, nutrients: [] });
|
||||
// `lastAttempt` is null, not absent: a fresh account has no ingest in the
|
||||
// ledger, and the empty page says "nothing yet" rather than naming a failure
|
||||
// that never happened. Kept as an exact shape check so a field cannot appear
|
||||
// on this response without someone deciding it should.
|
||||
expect(body.data).toEqual({
|
||||
windowDays: 14,
|
||||
nutrients: [],
|
||||
lastAttempt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("folds rows into catalog-ordered summaries with latest day/amount and day counts", async () => {
|
||||
|
||||
@@ -387,7 +387,12 @@ describe("POST /api/nutrients/batch — write semantics", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("writes the audit-ledger breadcrumb only when at least one row landed", async () => {
|
||||
it("writes the audit-ledger breadcrumb on every call, landed rows or not", async () => {
|
||||
// This used to fire only when `inserted + updated > 0`, which made the one
|
||||
// durable trace of an ingest conditional on its success. A batch where every
|
||||
// entry was rejected then looked exactly like a batch that never arrived,
|
||||
// and on an empty table the two were indistinguishable from the record —
|
||||
// which is what made "does the client even post?" unanswerable.
|
||||
await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
@@ -395,7 +400,10 @@ describe("POST /api/nutrients/batch — write semantics", () => {
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(auditLog).not.toHaveBeenCalled();
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"nutrient.batch.ingest",
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
|
||||
await POST(
|
||||
postReq({
|
||||
@@ -404,10 +412,7 @@ describe("POST /api/nutrients/batch — write semantics", () => {
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"nutrient.batch.ingest",
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
expect(auditLog).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("a failed bulk insert marks only the insert group skipped, without touching the update group", async () => {
|
||||
|
||||
@@ -306,17 +306,40 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
// v1.34.3 — per-reason skip breakdown, durable AND ephemeral. Computed
|
||||
// once from the closed `reason` set (never raw exception text — see the
|
||||
// upsert `catch` blocks above), so this carries only counts and reason
|
||||
// codes, never a per-entry health value or anything user-typed.
|
||||
const skippedByReason: Record<string, number> = {};
|
||||
for (const s of skipped) {
|
||||
skippedByReason[s.reason] = (skippedByReason[s.reason] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// v1.34.3 — audit UNCONDITIONALLY. This used to fire only when
|
||||
// `inserted + updated > 0`, which made it the ONLY durable trace of the
|
||||
// call — so a batch where every entry was skipped (say, a systemic unit
|
||||
// mismatch) left the exact same empty AuditLog record as a phone that
|
||||
// never posted at all. An operator investigating an empty
|
||||
// `nutrient_intake_days` table could not tell "the client never called"
|
||||
// from "the client called and every entry was rejected" without that
|
||||
// record. Mirrors `measurement.batch.ingest` / `workout.batch.ingest`
|
||||
// (unconditional, counts-only, no per-entry health values) plus the
|
||||
// aggregate skip-reason breakdown so "did it post, and why did nothing
|
||||
// land" is answerable from the durable row alone, without a Wide-Event
|
||||
// trail that (unlike this table) isn't guaranteed to still exist.
|
||||
await auditLog("nutrient.batch.ingest", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
updated,
|
||||
skipped: skipped.length,
|
||||
skippedByReason,
|
||||
},
|
||||
});
|
||||
|
||||
if (inserted + updated > 0) {
|
||||
await auditLog("nutrient.batch.ingest", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
updated,
|
||||
skipped: skipped.length,
|
||||
},
|
||||
});
|
||||
// v1.30.3 (QA F9) — a landed row changes the dashboard's water/nutrient
|
||||
// tile; without this the snapshot kept serving the pre-sync figure
|
||||
// until the analytics fresh TTL lapsed (~3 min). Mirrors the manual
|
||||
@@ -333,6 +356,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
inserted,
|
||||
updated,
|
||||
skipped: skipped.length,
|
||||
skipped_by_reason: skippedByReason,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
* does the server hold". Catalog order, no pagination (≤ 26 rows).
|
||||
* Module-gated like the ingest path: 403 `module.disabled` when the
|
||||
* opt-in `nutrients` module is off. `userId` is narrowed from auth.
|
||||
*
|
||||
* `lastAttempt` (v1.34.3): non-null only when the window is empty AND the
|
||||
* most recent `nutrient.batch.ingest` audit row shows a call that landed
|
||||
* nothing, so `/insights/nutrients` can say a sync arrived and why instead
|
||||
* of a bare "no data yet". See the lookup below for the AuditLog-retention
|
||||
* coupling this introduces.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
@@ -21,6 +27,70 @@ import { DEFAULT_TIMEZONE, shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Highest-count skip reason; ties break on this fixed declaration order. */
|
||||
const SKIP_REASON_PRIORITY = [
|
||||
"unit_mismatch",
|
||||
"value_out_of_range",
|
||||
"day_invalid",
|
||||
"upsert_failed",
|
||||
] as const;
|
||||
|
||||
interface LastIngestDetails {
|
||||
inserted: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
skippedByReason: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `AuditLog.details` is a free-form JSON string; parse it back into the
|
||||
* shape `nutrient.batch.ingest` (`src/app/api/nutrients/batch/route.ts`)
|
||||
* writes, rejecting anything that doesn't match rather than trusting the
|
||||
* blob.
|
||||
*/
|
||||
function parseLastIngestDetails(raw: string | null): LastIngestDetails | null {
|
||||
if (!raw) return null;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
const { inserted, updated, skipped } = obj;
|
||||
if (
|
||||
typeof inserted !== "number" ||
|
||||
typeof updated !== "number" ||
|
||||
typeof skipped !== "number"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const skippedByReason: Record<string, number> = {};
|
||||
const rawByReason = obj.skippedByReason;
|
||||
if (rawByReason && typeof rawByReason === "object") {
|
||||
for (const [reason, count] of Object.entries(
|
||||
rawByReason as Record<string, unknown>,
|
||||
)) {
|
||||
if (typeof count === "number") skippedByReason[reason] = count;
|
||||
}
|
||||
}
|
||||
return { inserted, updated, skipped, skippedByReason };
|
||||
}
|
||||
|
||||
function pickTopSkipReason(byReason: Record<string, number>): string | null {
|
||||
let best: string | null = null;
|
||||
let bestCount = 0;
|
||||
for (const reason of SKIP_REASON_PRIORITY) {
|
||||
const count = byReason[reason] ?? 0;
|
||||
if (count > bestCount) {
|
||||
best = reason;
|
||||
bestCount = count;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
@@ -106,6 +176,51 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
};
|
||||
});
|
||||
|
||||
// The account holder's only signal that a sync ever arrived is this
|
||||
// lookup — the read path stays honest instead of a bare "no data yet"
|
||||
// when a batch actually landed and every entry was rejected. Gated on
|
||||
// the zero-rows path ONLY: an AuditLog read must never run on the happy
|
||||
// path just to serve a read endpoint. COUPLING: this depends on the row
|
||||
// still existing — `cleanupOldAuditLogs` (`src/lib/jobs/audit-log-cleanup.ts`)
|
||||
// prunes AuditLog nightly past `AUDIT_LOG_RETENTION_DAYS` (365 days by
|
||||
// default), so a dormant account whose last failed attempt ages past
|
||||
// that window silently loses the explanation and falls back to the
|
||||
// plain empty state — anyone changing that retention job should know a
|
||||
// user-facing message depends on it.
|
||||
let lastAttempt: { at: string; topReason: string } | null = null;
|
||||
if (nutrients.length === 0) {
|
||||
// Fail soft, deliberately. This lookup exists only to explain an empty
|
||||
// page; it is not the page. If it throws — a ledger read that errors, a
|
||||
// shape nobody expected — the nutrients read must still answer, with the
|
||||
// plain empty state rather than a 500. An explanation that can take down
|
||||
// the thing it explains is worse than no explanation.
|
||||
try {
|
||||
const lastIngest = await prisma.auditLog.findFirst({
|
||||
where: { userId: user.id, action: "nutrient.batch.ingest" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true, details: true },
|
||||
});
|
||||
const details = parseLastIngestDetails(lastIngest?.details ?? null);
|
||||
// Only surface a reason when the last attempt landed NOTHING: a
|
||||
// succeeded attempt whose day just falls outside this window is a
|
||||
// different, unrelated situation — showing a failure notice over it
|
||||
// would be the very false claim this feature exists to prevent.
|
||||
if (details && details.inserted === 0 && details.updated === 0) {
|
||||
const topReason = pickTopSkipReason(details.skippedByReason);
|
||||
if (topReason) {
|
||||
lastAttempt = { at: lastIngest!.createdAt.toISOString(), topReason };
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Recorded, never rendered: the operator sees it, the person sees the
|
||||
// ordinary empty state.
|
||||
annotate({
|
||||
action: { name: "nutrient.intake.last_attempt_lookup_failed" },
|
||||
meta: { reason: err instanceof Error ? err.name : "unknown" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "nutrient.intake.read" },
|
||||
meta: {
|
||||
@@ -115,5 +230,5 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ windowDays: days, nutrients });
|
||||
return apiSuccess({ windowDays: days, nutrients, lastAttempt });
|
||||
});
|
||||
@@ -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)),
|
||||
});
|
||||
});
|
||||
@@ -368,13 +368,73 @@ describe("PATCH /api/user/profile", () => {
|
||||
expect(body.data.gender).toBe("OTHER");
|
||||
});
|
||||
|
||||
it("still refuses an unsupported gender, with a message that names the field", async () => {
|
||||
it("still refuses an unsupported gender alone, with a message that names the field", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await PATCH(req({ timeFormat: "H24", gender: "diverse" }));
|
||||
const res = await PATCH(req({ gender: "diverse" }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toContain("Gender must be MALE, FEMALE or OTHER");
|
||||
const body = (await res.json()) as {
|
||||
error: string;
|
||||
details?: { issues?: Array<{ path: string; message: string }> };
|
||||
};
|
||||
// The top-level message is a sentence for a person — it names the
|
||||
// field but never repeats the validator's own enum-literal prose.
|
||||
expect(body.error).toMatch(/nothing was saved/i);
|
||||
expect(body.error).toContain("gender");
|
||||
expect(body.error).not.toContain("Invalid option");
|
||||
// The specific reason is relocated, not deleted.
|
||||
expect(body.details?.issues?.[0]).toMatchObject({ path: "gender" });
|
||||
});
|
||||
|
||||
// Reported regression: a rejected `gender` took a sibling `timeFormat`
|
||||
// edit down with it because the whole PATCH was one transaction. The
|
||||
// schema has no cross-field dependency, so the valid field must now
|
||||
// land even when `gender` is rejected in the same body.
|
||||
it("persists a valid sibling field even when gender in the same body is unsupported", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({
|
||||
id: "user-1",
|
||||
username: "testuser",
|
||||
displayName: null,
|
||||
email: null,
|
||||
role: "USER",
|
||||
heightCm: null,
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
timezone: "Europe/Berlin",
|
||||
locale: null,
|
||||
timeFormat: "H24",
|
||||
dateFormat: "AUTO",
|
||||
moodReminderEnabled: false,
|
||||
fullName: null,
|
||||
insurerName: null,
|
||||
insurerIkNumber: null,
|
||||
insuranceNumberEncrypted: null,
|
||||
} as never);
|
||||
|
||||
const res = await PATCH(req({ timeFormat: "H24", gender: "diverse" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ timeFormat: "H24" }),
|
||||
}),
|
||||
);
|
||||
const arg = vi.mocked(prisma.user.update).mock.calls[0][0] as {
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
expect("gender" in arg.data).toBe(false);
|
||||
const body = (await res.json()) as {
|
||||
error: string | null;
|
||||
data: {
|
||||
timeFormat: string;
|
||||
rejectedFields?: Array<{ path: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.error).toBeNull();
|
||||
expect(body.data.timeFormat).toBe("H24");
|
||||
expect(body.data.rejectedFields).toEqual([
|
||||
expect.objectContaining({ path: "gender" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
@@ -101,7 +102,10 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const result = await applyProfileUpdate(user.id, body, getClientIp(request));
|
||||
if (!result.ok) {
|
||||
return apiError(result.message, result.status);
|
||||
const meta = result.errorCode ? { errorCode: result.errorCode } : undefined;
|
||||
return result.issues
|
||||
? apiValidationError(result.message, result.issues, result.status, meta)
|
||||
: apiError(result.message, result.status, meta);
|
||||
}
|
||||
|
||||
annotate({ action: { name: "user.profile.update" } });
|
||||
@@ -122,5 +126,6 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
insurerName: result.user.insurerName,
|
||||
insurerIkNumber: result.user.insurerIkNumber,
|
||||
hasInsuranceNumber: result.user.hasInsuranceNumber,
|
||||
...(result.rejectedFields ? { rejectedFields: result.rejectedFields } : {}),
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,8 @@ import {
|
||||
import { computeSplits } from "@/lib/workouts/splits";
|
||||
import { buildSportContext } from "@/lib/workouts/sport-context";
|
||||
import type { RouteCoordinate } from "@/lib/workouts/route-svg";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -129,6 +131,14 @@ export const GET = apiHandler(
|
||||
where: { id: user.id },
|
||||
select: { sourcePriorityJson: true, dateOfBirth: true },
|
||||
});
|
||||
// The local calendar day this session belongs to. Resolved here, in the
|
||||
// user's timezone, so the day linkage stays server-authoritative — a
|
||||
// client that derived it from `startedAt` would fall back to the browser
|
||||
// zone and put a late-evening session on the wrong day whenever the two
|
||||
// disagree.
|
||||
const timezone = await resolveUserTimezone(user.id);
|
||||
const dayKey = userDayKey(row.startedAt, timezone);
|
||||
|
||||
const canonicalCluster = pickCanonicalWorkoutRows(
|
||||
clusterRows,
|
||||
userRow?.sourcePriorityJson ?? null,
|
||||
@@ -201,6 +211,47 @@ export const GET = apiHandler(
|
||||
row.id,
|
||||
);
|
||||
|
||||
// The session before this one in the same sport. "How does this compare
|
||||
// to last time?" is the most likely next question after reading a
|
||||
// workout, and today it costs a trip back to the list plus a scan.
|
||||
// Scoped to rows that start before this cluster's slot, so a twin of THIS
|
||||
// session can never be offered as the previous one, and run through the
|
||||
// same canonical picker so the link lands on the row the list shows.
|
||||
const previousRows = await prisma.workout.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
sportType: row.sportType,
|
||||
startedAt: { lt: slotStart },
|
||||
},
|
||||
orderBy: [{ startedAt: "desc" }, { id: "desc" }],
|
||||
// Enough to survive a run of same-session twins and re-sends without
|
||||
// reading the whole history; the picker collapses them to a handful.
|
||||
take: 20,
|
||||
select: {
|
||||
id: true,
|
||||
source: true,
|
||||
startedAt: true,
|
||||
sportType: true,
|
||||
durationSec: true,
|
||||
avgHeartRate: true,
|
||||
maxHeartRate: true,
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
// The picker takes rows in ascending order and does not promise to hand
|
||||
// them back sorted, so pick the newest survivor explicitly.
|
||||
const previousWorkoutId =
|
||||
pickCanonicalWorkoutRows(
|
||||
[...previousRows].reverse(),
|
||||
userRow?.sourcePriorityJson ?? null,
|
||||
).reduce<{ id: string; startedAt: Date } | null>(
|
||||
(newest, candidate) =>
|
||||
newest === null || candidate.startedAt > newest.startedAt
|
||||
? candidate
|
||||
: newest,
|
||||
null,
|
||||
)?.id ?? null;
|
||||
|
||||
const route = row.route
|
||||
? {
|
||||
geometry: row.route.geometry,
|
||||
@@ -250,6 +301,10 @@ export const GET = apiHandler(
|
||||
sportType: row.sportType,
|
||||
startedAt: row.startedAt,
|
||||
endedAt: row.endedAt,
|
||||
// `YYYY-MM-DD` in the user's timezone. Additive; it addresses the
|
||||
// day-scoped reads (intraday pulse, that night's sleep, the day's
|
||||
// mood) the detail surface renders around the session.
|
||||
dayKey,
|
||||
durationSec: row.durationSec,
|
||||
distanceM: row.totalDistanceM,
|
||||
activeEnergyKcal: row.totalEnergyKcal,
|
||||
@@ -281,6 +336,10 @@ export const GET = apiHandler(
|
||||
// winner. `canonicalId === id` when the requested row already
|
||||
// is the winner.
|
||||
canonicalId: canonical?.id ?? row.id,
|
||||
// The canonical previous session in this sport, or null when this is
|
||||
// the first one on record. Additive; a client that does not read it is
|
||||
// unaffected.
|
||||
previousWorkoutId,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -15,7 +15,10 @@
|
||||
* - the optional `route` field flows through GeoJSON geometry when a
|
||||
* `WorkoutRoute` row is attached, and is null when missing;
|
||||
* - `aiInsight` is a pure READ: it serves a stored paragraph when one
|
||||
* exists, null when none does, and never generates one.
|
||||
* exists, null when none does, and never generates one;
|
||||
* - `dayKey` dates the session in the user's timezone, so a session that
|
||||
* starts late in the UTC evening lands on the local day the person
|
||||
* actually trained on.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
@@ -65,6 +68,7 @@ import { GET } from "../[id]/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { isModuleEnabled, requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { invalidateUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -125,6 +129,9 @@ describe("GET /api/workouts/{id}", () => {
|
||||
enabled: true,
|
||||
} as never);
|
||||
vi.mocked(isModuleEnabled).mockResolvedValue(true);
|
||||
// The timezone resolver caches per user for 60 s in process; drop the
|
||||
// entry so a test that pins a zone is not served the previous one.
|
||||
invalidateUserTimezone(SESSION_OK.user.id);
|
||||
});
|
||||
|
||||
it("returns the workout with iOS-contract field names", async () => {
|
||||
@@ -151,6 +158,97 @@ describe("GET /api/workouts/{id}", () => {
|
||||
expect(body.data.maxHr).toBe(170);
|
||||
expect(body.data.canonicalId).toBe("w-1");
|
||||
expect(body.data.route).toBeNull();
|
||||
// Nothing older on record: absence, not a link to nowhere.
|
||||
expect(body.data.previousWorkoutId).toBeNull();
|
||||
});
|
||||
|
||||
it("points previousWorkoutId at the canonical session before this one", async () => {
|
||||
vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce(
|
||||
BASE_ROW as never,
|
||||
);
|
||||
vi.mocked(prisma.workout.findMany)
|
||||
// 1) the deep-link cluster
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: "w-1",
|
||||
source: "APPLE_HEALTH",
|
||||
startedAt: BASE_ROW.startedAt,
|
||||
sportType: "RUNNING",
|
||||
},
|
||||
] as never)
|
||||
// 2) the sport-context baseline
|
||||
.mockResolvedValueOnce([] as never)
|
||||
// 3) the older sessions, newest first
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: "w-0",
|
||||
source: "APPLE_HEALTH",
|
||||
startedAt: new Date("2026-05-13T07:00:00Z"),
|
||||
sportType: "RUNNING",
|
||||
durationSec: 1800,
|
||||
avgHeartRate: null,
|
||||
maxHeartRate: null,
|
||||
metadata: null,
|
||||
},
|
||||
// The same session as seen by the second watch. It must never win
|
||||
// the link — the list would never show it.
|
||||
{
|
||||
id: "w-0-twin",
|
||||
source: "WITHINGS",
|
||||
startedAt: new Date("2026-05-13T07:01:00Z"),
|
||||
sportType: "RUNNING",
|
||||
durationSec: 1800,
|
||||
avgHeartRate: null,
|
||||
maxHeartRate: null,
|
||||
metadata: null,
|
||||
},
|
||||
{
|
||||
id: "w-older",
|
||||
source: "APPLE_HEALTH",
|
||||
startedAt: new Date("2026-05-10T07:00:00Z"),
|
||||
sportType: "RUNNING",
|
||||
durationSec: 1800,
|
||||
avgHeartRate: null,
|
||||
maxHeartRate: null,
|
||||
metadata: null,
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await GET(makeRequest(), makeParams("w-1"));
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.data.previousWorkoutId).toBe("w-0");
|
||||
});
|
||||
|
||||
it("dates the session by the user's timezone, not by UTC", async () => {
|
||||
// 20:00 UTC is already the NEXT calendar day in Auckland. A client that
|
||||
// sliced the ISO timestamp would read 2026-05-15 and then pull the wrong
|
||||
// day's pulse, sleep and mood around the session.
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
sourcePriorityJson: null,
|
||||
dateOfBirth: null,
|
||||
timezone: "Pacific/Auckland",
|
||||
} as never);
|
||||
vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce({
|
||||
...BASE_ROW,
|
||||
startedAt: new Date("2026-05-15T20:00:00Z"),
|
||||
endedAt: new Date("2026-05-15T20:30:00Z"),
|
||||
} as never);
|
||||
vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([
|
||||
{
|
||||
id: "w-1",
|
||||
source: "APPLE_HEALTH",
|
||||
startedAt: new Date("2026-05-15T20:00:00Z"),
|
||||
sportType: "RUNNING",
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await GET(makeRequest(), makeParams("w-1"));
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.data.dayKey).toBe("2026-05-16");
|
||||
});
|
||||
|
||||
it("enriches the Apple winner with only matched WHOOP HR and zones", async () => {
|
||||
|
||||
@@ -176,11 +176,11 @@ describe("assessment presentation contract", () => {
|
||||
);
|
||||
|
||||
expect(cardSource).toContain(
|
||||
'className="animate-insight-in gap-1.5 py-3 md:py-4"',
|
||||
'className="animate-insight-in gap-2 py-3 md:py-4"',
|
||||
);
|
||||
expect(cardSource).toContain('className="text-foreground text-sm"');
|
||||
expect(moodSource).toContain(
|
||||
'className="animate-insight-in gap-1.5 py-3 md:py-4"',
|
||||
'className="animate-insight-in gap-2 py-3 md:py-4"',
|
||||
);
|
||||
expect(moodSource).toContain('data-slot="insight-assessment"');
|
||||
expect(moodBodySource).toContain(
|
||||
|
||||
@@ -6,18 +6,42 @@ import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { HydrationCard } from "@/components/insights/nutrients/hydration-card";
|
||||
import { CaffeineCard } from "@/components/insights/nutrients/caffeine-card";
|
||||
import { MicronutrientsCard } from "@/components/insights/nutrients/micronutrients-card";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { NutrientIntakeOverview } from "@/components/insights/nutrients/types";
|
||||
|
||||
const WINDOW_DAYS = 30;
|
||||
|
||||
/**
|
||||
* `nutrient.batch.ingest`'s closed skip-reason codes
|
||||
* (`src/app/api/nutrients/batch/route.ts`) mapped to a human sentence —
|
||||
* never render the raw code. A reason with no useful action for the
|
||||
* account holder says plainly that the entries couldn't be stored rather
|
||||
* than inventing advice; `dayInvalid` is the one case with something
|
||||
* genuinely worth checking (a clock/time-zone mismatch on the sending
|
||||
* side), so it says so.
|
||||
*/
|
||||
const LAST_ATTEMPT_REASON_KEYS: Record<string, string> = {
|
||||
unit_mismatch: "nutrients.page.lastAttemptReason.unitMismatch",
|
||||
value_out_of_range: "nutrients.page.lastAttemptReason.valueOutOfRange",
|
||||
day_invalid: "nutrients.page.lastAttemptReason.dayInvalid",
|
||||
upsert_failed: "nutrients.page.lastAttemptReason.upsertFailed",
|
||||
};
|
||||
|
||||
function lastAttemptReasonKey(reason: string): string {
|
||||
return (
|
||||
LAST_ATTEMPT_REASON_KEYS[reason] ??
|
||||
"nutrients.page.lastAttemptReason.unknown"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `/insights/nutrients` — hydration + micronutrients (v1.29).
|
||||
*
|
||||
@@ -33,13 +57,32 @@ const WINDOW_DAYS = 30;
|
||||
* on the device is not visible consent to a server / Coach holding
|
||||
* a supplement pattern); this page just makes the toggle
|
||||
* discoverable in context instead of buried in Settings.
|
||||
* - module on, zero rows anywhere in the window → one EmptyState
|
||||
* explaining where data comes from (phone health-app sync or
|
||||
* manual water entry) instead of three near-empty cards.
|
||||
* - module on, the overview read failed → a `QueryErrorCard` with a
|
||||
* retry. This branch has to sit ABOVE the empty-state check below:
|
||||
* `overview.data` is `undefined` on a failed read exactly like it is
|
||||
* before the first response lands, so without this the page told a
|
||||
* self-hoster who granted every HealthKit permission "no nutrient
|
||||
* data yet" when the read had actually failed — a guess dressed up
|
||||
* as an honest absence. `<MicronutrientsCard>` reads this SAME
|
||||
* `queryKeys.nutrientIntake(WINDOW_DAYS)` query and already renders
|
||||
* its own error card, but this page's old empty-state branch
|
||||
* short-circuited before that card ever mounted.
|
||||
* - module on, zero rows anywhere in the window, AND the server found a
|
||||
* recent sync attempt that landed nothing (`overview.data.lastAttempt`,
|
||||
* from `GET /api/nutrients` — see that route for the AuditLog lookup
|
||||
* and its retention coupling) → an EmptyState naming the attempt and
|
||||
* the reason, not the generic copy. A batch CAN arrive and have every
|
||||
* entry rejected (wrong unit, an implausible amount, a bad date, a
|
||||
* write failure); without this branch that read the same as nothing
|
||||
* ever syncing.
|
||||
* - module on, zero rows, no recorded attempt → the generic EmptyState
|
||||
* explaining where data comes from (a health-app sync or a manual
|
||||
* water entry) instead of three near-empty cards.
|
||||
* - otherwise → the three-card spine.
|
||||
*/
|
||||
export default function InsightsNutrientsPage() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -83,22 +126,54 @@ export default function InsightsNutrientsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const hasAnyData = (overview.data?.nutrients.length ?? 0) > 0;
|
||||
if (!overview.isLoading && !hasAnyData) {
|
||||
if (overview.isError) {
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("nutrients.page.title")}
|
||||
description={t("nutrients.page.description")}
|
||||
>
|
||||
<EmptyState
|
||||
icon={<Leaf className="size-6" />}
|
||||
title={t("nutrients.page.emptyTitle")}
|
||||
description={t("nutrients.page.emptyDescription")}
|
||||
<QueryErrorCard
|
||||
title={t("nutrients.page.loadError")}
|
||||
onRetry={() => void overview.refetch()}
|
||||
/>
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const hasAnyData = (overview.data?.nutrients.length ?? 0) > 0;
|
||||
if (!overview.isLoading && !hasAnyData) {
|
||||
const lastAttempt = overview.data?.lastAttempt ?? null;
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("nutrients.page.title")}
|
||||
description={t("nutrients.page.description")}
|
||||
>
|
||||
{lastAttempt ? (
|
||||
<EmptyState
|
||||
icon={<Leaf className="size-6" />}
|
||||
title={t("nutrients.page.lastAttemptTitle")}
|
||||
description={
|
||||
<>
|
||||
{t(lastAttemptReasonKey(lastAttempt.topReason))}
|
||||
<span className="mt-1 block text-xs">
|
||||
{t("nutrients.page.lastAttemptAt", {
|
||||
date: fmt.dateTime(lastAttempt.at),
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={<Leaf className="size-6" />}
|
||||
title={t("nutrients.page.emptyTitle")}
|
||||
description={t("nutrients.page.emptyDescription")}
|
||||
/>
|
||||
)}
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("nutrients.page.title")}
|
||||
|
||||
@@ -26,7 +26,6 @@ import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HeroStrip } from "@/components/insights/hero-strip";
|
||||
import { HealthScoreCard } from "@/components/insights/health-score-card";
|
||||
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
|
||||
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
|
||||
import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
|
||||
@@ -475,41 +474,36 @@ export default function InsightsPageClient() {
|
||||
/>
|
||||
),
|
||||
"daily-briefing": flags.briefing ? (
|
||||
<>
|
||||
<DailyBriefing
|
||||
briefing={briefingPayload}
|
||||
updatedAt={heroStripUpdatedAt}
|
||||
loading={advisor.isLoading}
|
||||
onRegenerate={advisor.regenerate}
|
||||
regenerating={advisor.isRegenerating}
|
||||
// v1.15.20 — the read path reports a 422 (no provider configured)
|
||||
// as its own outcome; swap the futile regenerate CTA for a quiet
|
||||
// Settings → AI hint instead of an eternal "preparing" loop.
|
||||
noProvider={advisor.readOutcome === "no-provider"}
|
||||
// v1.18.9 (#4) — a (stale) cached briefing is shown but no provider
|
||||
// is connected, so it can never refresh. Pair the relative-age
|
||||
// footer with a discreet connect-provider hint. Only fires when a
|
||||
// briefing is actually rendered (the empty-state path owns the
|
||||
// `noProvider` branch above).
|
||||
noProviderStale={briefingPayload !== null && !advisor.hasProvider}
|
||||
// v1.25 — the last generation attempt failed. On a held briefing this
|
||||
// adds a discreet "couldn't refresh — retry" footer hint; on an empty
|
||||
// card it swaps the generic empty state for an honest "couldn't
|
||||
// generate" one. Suppressed when no provider is configured (that hint
|
||||
// owns the surface and a retry would be futile).
|
||||
generationFailed={advisor.generationFailed && advisor.hasProvider}
|
||||
// v1.25.3 — failure class points the empty-state hint at the right
|
||||
// lever (raise the response timeout vs re-check the provider).
|
||||
generationFailureClass={advisor.generationFailureClass}
|
||||
// v1.28.28 (#470) — the grounding gate stripped the last regenerated
|
||||
// briefing; the card explains the omission instead of "no briefing
|
||||
// yet" (which made the regenerate button read as doing nothing).
|
||||
omittedReason={advisor.briefingOmittedReason}
|
||||
/>
|
||||
{analytics?.healthScore ? (
|
||||
<HealthScoreCard report={analytics.healthScore} className="mt-6" />
|
||||
) : null}
|
||||
</>
|
||||
<DailyBriefing
|
||||
briefing={briefingPayload}
|
||||
updatedAt={heroStripUpdatedAt}
|
||||
loading={advisor.isLoading}
|
||||
onRegenerate={advisor.regenerate}
|
||||
regenerating={advisor.isRegenerating}
|
||||
// v1.15.20 — the read path reports a 422 (no provider configured)
|
||||
// as its own outcome; swap the futile regenerate CTA for a quiet
|
||||
// Settings → AI hint instead of an eternal "preparing" loop.
|
||||
noProvider={advisor.readOutcome === "no-provider"}
|
||||
// v1.18.9 (#4) — a (stale) cached briefing is shown but no provider
|
||||
// is connected, so it can never refresh. Pair the relative-age
|
||||
// footer with a discreet connect-provider hint. Only fires when a
|
||||
// briefing is actually rendered (the empty-state path owns the
|
||||
// `noProvider` branch above).
|
||||
noProviderStale={briefingPayload !== null && !advisor.hasProvider}
|
||||
// v1.25 — the last generation attempt failed. On a held briefing this
|
||||
// adds a discreet "couldn't refresh — retry" footer hint; on an empty
|
||||
// card it swaps the generic empty state for an honest "couldn't
|
||||
// generate" one. Suppressed when no provider is configured (that hint
|
||||
// owns the surface and a retry would be futile).
|
||||
generationFailed={advisor.generationFailed && advisor.hasProvider}
|
||||
// v1.25.3 — failure class points the empty-state hint at the right
|
||||
// lever (raise the response timeout vs re-check the provider).
|
||||
generationFailureClass={advisor.generationFailureClass}
|
||||
// v1.28.28 (#470) — the grounding gate stripped the last regenerated
|
||||
// briefing; the card explains the omission instead of "no briefing
|
||||
// yet" (which made the regenerate button read as doing nothing).
|
||||
omittedReason={advisor.briefingOmittedReason}
|
||||
/>
|
||||
) : null,
|
||||
vitals: <VitalsDashboard batch={dashboardDerived} layout={layout} />,
|
||||
trends: (
|
||||
@@ -558,26 +552,30 @@ export default function InsightsPageClient() {
|
||||
briefing={briefingPayload}
|
||||
updatedAt={heroStripUpdatedAt}
|
||||
userName={heroGreetingName}
|
||||
healthScore={analytics?.healthScore ?? undefined}
|
||||
// v1.16.8 — reserve the score card's column while the thick
|
||||
// analytics payload is in flight. Every overview query
|
||||
// (comprehensive, advisor, analytics, derived batch, layout)
|
||||
// already fires in parallel at mount; the "greeting first,
|
||||
// score card later" stagger came from the right column not
|
||||
// existing until the slowest payload resolved. `isPending`
|
||||
// settles false on success AND error, so a no-score account
|
||||
// collapses the column exactly once.
|
||||
healthScorePending={analyticsQuery.isPending && !analytics}
|
||||
// v1.18.9 (#4) — the hero subtitle is the cached briefing paragraph;
|
||||
// when it can never refresh (no AI provider) pair the "Generated
|
||||
// <relative>" line with a discreet connect-provider hint, matching
|
||||
// the briefing card footer. Only when a briefing is actually shown.
|
||||
noProviderStale={briefingPayload !== null && !advisor.hasProvider}
|
||||
// The Health Score lives in the band's right column and NOWHERE else
|
||||
// on this page. It is deliberately not a registry section: an id a
|
||||
// saved layout has never seen merges in as default-INVISIBLE
|
||||
// (`orderedVisibleSectionIds`), so registering it would silently
|
||||
// delete the score for every account that has ever customised this
|
||||
// page. It is also outside the daily-briefing branch, so an instance
|
||||
// with the briefing switched off keeps the whole breakdown.
|
||||
healthScore={analytics?.healthScore ?? null}
|
||||
// `isPending` settles false on success AND error, so a no-score
|
||||
// account drops the reserve exactly once.
|
||||
scorePending={analyticsQuery.isPending && !analytics}
|
||||
// v1.21.2 (A5 / A6) — server-resolved Tension Verdict + return-to-
|
||||
// baseline off the dashboard snapshot. The hero localises the keys and
|
||||
// forwards them to the score card; null leaves the card quiet.
|
||||
// baseline off the dashboard snapshot. The panel localises the keys;
|
||||
// null leaves it quiet.
|
||||
tension={heroTension}
|
||||
returnToBand={heroReturnToBand}
|
||||
// A pillar whose read FAILED is a named line inside the panel, and the
|
||||
// retry there refetches the payload that carries every pillar.
|
||||
onScoreRetry={() => void analyticsQuery.refetch()}
|
||||
/>
|
||||
|
||||
{/* v1.15.18 — the inline "Anpassen" toggle was removed. Customising the
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
WorkoutDetailZones,
|
||||
WorkoutDetailRoute,
|
||||
WorkoutDetailSplits,
|
||||
WorkoutDetailDayLinks,
|
||||
WorkoutDetailDayContext,
|
||||
WorkoutInsightCard,
|
||||
} from "@/components/insights/workout-detail";
|
||||
|
||||
@@ -31,7 +31,12 @@ import {
|
||||
* Layout, top to bottom (mobile-first single column):
|
||||
* hero header → reserved Activity-Insight seam (renders nothing today)
|
||||
* → stats grid + sport-average line → HR curve → effort zones → GPS
|
||||
* route → per-km splits → "that day" cross-links → coach launch.
|
||||
* route → per-km splits → "that day" → coach launch.
|
||||
*
|
||||
* "That day" answers the last question the page is for: what the rest of
|
||||
* the day looked like around the session. It renders the day's own pulse
|
||||
* shape, that night's sleep and the day's mood inline, all addressed by
|
||||
* the server-resolved `dayKey`.
|
||||
*
|
||||
* Every data-less section returns `null` (hide, don't render empty), so
|
||||
* an aggregates-only workout (a Strava ride with no wearable, a manual
|
||||
@@ -70,7 +75,9 @@ export default function InsightsWorkoutDetailPage({
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("insights.workouts.title")}
|
||||
description={t("insights.workouts.description")}
|
||||
// No description. The one this page used to carry belongs to the LIST
|
||||
// ("Recent runs, rides, walks…, deduped…") and describes a surface
|
||||
// this is not — a detail page must not claim to be the list.
|
||||
backLink={
|
||||
<BackLink
|
||||
href="/insights/workouts"
|
||||
@@ -112,7 +119,7 @@ export default function InsightsWorkoutDetailPage({
|
||||
<WorkoutDetailZones workout={data} />
|
||||
<WorkoutDetailRoute workout={data} />
|
||||
<WorkoutDetailSplits workout={data} />
|
||||
<WorkoutDetailDayLinks workout={data} />
|
||||
<WorkoutDetailDayContext workout={data} />
|
||||
{/* 2026-07-17 UX-flows audit F6-1 — `workouts` narrows the
|
||||
snapshot the first coach turn reads. v1.31.0 — `workoutId` pins
|
||||
THIS session's own numbers as one additional snapshot section, so
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Activity, Loader2 } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useInfiniteWorkouts } from "@/hooks/use-workouts";
|
||||
import { useModulePageGuard } from "@/hooks/use-module-page-guard";
|
||||
@@ -31,12 +33,20 @@ import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
* workout-entry form today),
|
||||
* - the deduped list otherwise. Each row links to
|
||||
* `/insights/workouts/[id]` for the detail surface.
|
||||
*
|
||||
* A sport filter narrows the list server-side through the `sportType`
|
||||
* parameter the route and the hook already accept. The chips offer only
|
||||
* sports the account actually has, captured from the unfiltered read, so
|
||||
* choosing one never empties the row it was chosen from. The RSC prefetch
|
||||
* seeds the unfiltered key only, so a filtered view costs one client fetch.
|
||||
*/
|
||||
export default function InsightsWorkoutsPageClient() {
|
||||
const { t } = useTranslations();
|
||||
const { t, tCount } = useTranslations();
|
||||
const { ready } = useModulePageGuard("workouts");
|
||||
const [sportType, setSportType] = useState<string | undefined>(undefined);
|
||||
const {
|
||||
workouts,
|
||||
total,
|
||||
isLoading,
|
||||
isEmpty,
|
||||
isError,
|
||||
@@ -45,7 +55,26 @@ export default function InsightsWorkoutsPageClient() {
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
refetch,
|
||||
} = useInfiniteWorkouts({ limit: 100 });
|
||||
} = useInfiniteWorkouts({ limit: 100, sportType });
|
||||
|
||||
// The chip row's vocabulary comes from the UNFILTERED read: deriving it
|
||||
// from the filtered list would collapse the row to the one sport already
|
||||
// chosen, leaving no way back. With no filter active this resolves to the
|
||||
// very same query key as the list above, so the two share one query and
|
||||
// the row costs nothing; under a filter it keeps serving that cache.
|
||||
const { workouts: allSports } = useInfiniteWorkouts({ limit: 100 });
|
||||
const sportOptions = useMemo(
|
||||
() => Array.from(new Set(allSports.map((w) => w.sportType))).sort(),
|
||||
[allSports],
|
||||
);
|
||||
|
||||
const sportLabel = (sport: string) => {
|
||||
// A sport enum the bundle has no copy for renders as its canonical
|
||||
// string rather than a raw key — same fallback the list rows use.
|
||||
const key = `insights.workouts.sport.${sport}`;
|
||||
const label = t(key);
|
||||
return label === key ? sport : label;
|
||||
};
|
||||
|
||||
// v1.18.0 B1 — bounce a direct URL hit on a disabled-workouts account.
|
||||
if (!ready) {
|
||||
@@ -77,7 +106,7 @@ export default function InsightsWorkoutsPageClient() {
|
||||
<Skeleton className="h-14 w-full rounded-lg" />
|
||||
<Skeleton className="h-14 w-full rounded-lg" />
|
||||
</div>
|
||||
) : isEmpty ? (
|
||||
) : isEmpty && sportType === undefined ? (
|
||||
<MetricEmptyState
|
||||
icon={<Activity className="size-6" />}
|
||||
title={t("insights.workouts.emptyState.title")}
|
||||
@@ -85,15 +114,67 @@ export default function InsightsWorkoutsPageClient() {
|
||||
cta={null}
|
||||
coachPrefill="I haven't logged any workouts yet — why does tracking them matter, and what should I focus on first?"
|
||||
/>
|
||||
) : workouts.length > 0 ? (
|
||||
<WorkoutList
|
||||
workouts={workouts}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
isFetchNextPageError={isFetchNextPageError}
|
||||
onLoadMore={fetchNextPage}
|
||||
/>
|
||||
) : null}
|
||||
) : (
|
||||
<>
|
||||
{sportOptions.length > 1 ? (
|
||||
<div
|
||||
data-slot="workout-sport-filter"
|
||||
role="group"
|
||||
aria-label={t("insights.workouts.filterLabel")}
|
||||
// Borderless page-level blocks track the shell heading's own
|
||||
// card inset, so the row, the count line and the h1 above them
|
||||
// share one left edge.
|
||||
className="flex flex-wrap gap-2 px-4 md:px-6"
|
||||
>
|
||||
{[undefined, ...sportOptions].map((sport) => {
|
||||
const active = sport === sportType;
|
||||
return (
|
||||
<button
|
||||
key={sport ?? "all"}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => setSportType(sport)}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring inline-flex min-h-11 items-center rounded-full px-3 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none sm:min-h-8",
|
||||
active
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{sport === undefined
|
||||
? t("insights.workouts.filterAll")
|
||||
: sportLabel(sport)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{workouts.length > 0 ? (
|
||||
<>
|
||||
<p
|
||||
data-slot="workout-list-count"
|
||||
className="text-muted-foreground px-4 text-xs md:px-6"
|
||||
>
|
||||
{tCount("insights.workouts.count", total)}
|
||||
</p>
|
||||
<WorkoutList
|
||||
workouts={workouts}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
isFetchNextPageError={isFetchNextPageError}
|
||||
onLoadMore={fetchNextPage}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<p
|
||||
data-slot="workout-list-filter-empty"
|
||||
className="text-muted-foreground px-4 text-sm md:px-6"
|
||||
>
|
||||
{t("insights.workouts.filterEmpty")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { ModulesSection } from "@/components/settings/modules-section";
|
||||
import { MoodSection } from "@/components/settings/mood-section";
|
||||
import { NotificationsSection } from "@/components/settings/notifications-section";
|
||||
import { PrivacySection } from "@/components/settings/privacy-section";
|
||||
import { ScoreSection } from "@/components/settings/score-section";
|
||||
import { SectionPlaceholder } from "@/components/settings/section-placeholder";
|
||||
import { VorsorgeSection } from "@/components/settings/vorsorge-section";
|
||||
// v1.18.1 (D4) — `sources` is a standalone left-side entry (split out of the
|
||||
@@ -76,6 +77,7 @@ const SECTION_COMPONENTS: Record<
|
||||
illness: IllnessSection,
|
||||
environment: EnvironmentSection,
|
||||
anamnesis: AnamnesisSection,
|
||||
score: ScoreSection,
|
||||
vorsorge: VorsorgeSection,
|
||||
thresholds: ThresholdsSection,
|
||||
api: ApiSection,
|
||||
|
||||
@@ -32,8 +32,11 @@ vi.mock("@/lib/api/api-fetch", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
// `modules.nutrients: true` so the `/insights/nutrients` page tests below
|
||||
// reach their query instead of short-circuiting on the module-off branch;
|
||||
// no other surface in this file reads `user.modules`.
|
||||
useAuth: () => ({
|
||||
user: { gender: "FEMALE", modules: {} },
|
||||
user: { gender: "FEMALE", modules: { nutrients: true } },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
}),
|
||||
@@ -41,6 +44,7 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
|
||||
import { MentalWellbeing } from "@/components/mental-health/mental-wellbeing";
|
||||
import { MicronutrientsCard } from "@/components/insights/nutrients/micronutrients-card";
|
||||
import InsightsNutrientsPage from "@/app/insights/nutrients/page";
|
||||
|
||||
function makeClient() {
|
||||
return new QueryClient({
|
||||
@@ -81,6 +85,11 @@ function render(node: React.ReactNode, client: QueryClient): string {
|
||||
);
|
||||
}
|
||||
|
||||
/** React's SSR serialiser escapes `'` as `'` in text nodes. */
|
||||
function htmlEscaped(text: string): string {
|
||||
return text.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
describe("mental wellbeing — screening history", () => {
|
||||
const mh = en.mentalHealth;
|
||||
|
||||
@@ -140,6 +149,85 @@ describe("micronutrients card", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("/insights/nutrients page — overview read shares the micronutrients queryKey", () => {
|
||||
const np = en.nutrients.page;
|
||||
|
||||
// The page's overview query and `<MicronutrientsCard>` read the exact same
|
||||
// `queryKeys.nutrientIntake(30)` cache entry. Before this branch existed, a
|
||||
// failed read left `overview.data` undefined — indistinguishable from the
|
||||
// pre-response state — so the page's empty-state check fired first and
|
||||
// returned "no nutrient data yet" without ever mounting the three cards
|
||||
// below (including `<MicronutrientsCard>`'s own, already-correct error
|
||||
// card).
|
||||
it("renders the page-level error card, not the empty state, when the overview read fails", async () => {
|
||||
const client = makeClient();
|
||||
await seedFailure(client, queryKeys.nutrientIntake(30));
|
||||
const html = render(<InsightsNutrientsPage />, client);
|
||||
|
||||
expect(html).toContain('data-slot="query-error-card"');
|
||||
expect(html).toContain(np.loadError);
|
||||
expect(html).toContain(en.common.retry);
|
||||
expect(html).not.toContain(np.emptyTitle);
|
||||
// The three-card spine must not mount alongside the error notice.
|
||||
expect(html).not.toContain('data-slot="nutrients-hydration-card"');
|
||||
expect(html).not.toContain('data-slot="nutrients-micronutrients-card"');
|
||||
});
|
||||
|
||||
it("still renders the honest empty state when the window genuinely has no rows", () => {
|
||||
const client = makeClient();
|
||||
client.setQueryData(queryKeys.nutrientIntake(30), {
|
||||
nutrients: [],
|
||||
lastAttempt: null,
|
||||
});
|
||||
const html = render(<InsightsNutrientsPage />, client);
|
||||
|
||||
expect(html).not.toContain('data-slot="query-error-card"');
|
||||
expect(html).toContain(np.emptyTitle);
|
||||
expect(html).not.toContain(np.lastAttemptTitle);
|
||||
});
|
||||
|
||||
// `GET /api/nutrients` returns `lastAttempt` non-null only when the window
|
||||
// is empty AND the most recent `nutrient.batch.ingest` audit row shows a
|
||||
// call that landed nothing — a batch CAN arrive and have every entry
|
||||
// rejected, which used to read on this page exactly like nothing ever
|
||||
// synced. The reason must render as the mapped human sentence, never the
|
||||
// raw wire code.
|
||||
it("names the sync attempt and its reason instead of the generic empty state", () => {
|
||||
const client = makeClient();
|
||||
client.setQueryData(queryKeys.nutrientIntake(30), {
|
||||
nutrients: [],
|
||||
lastAttempt: {
|
||||
at: "2026-07-15T09:30:00.000Z",
|
||||
topReason: "unit_mismatch",
|
||||
},
|
||||
});
|
||||
const html = render(<InsightsNutrientsPage />, client);
|
||||
|
||||
expect(html).toContain(np.lastAttemptTitle);
|
||||
expect(html).toContain(htmlEscaped(np.lastAttemptReason.unitMismatch));
|
||||
// The date line renders around a formatted timestamp; assert the fixed
|
||||
// prefix rather than the locale-formatted date itself.
|
||||
expect(html).toContain(np.lastAttemptAt.split("{date}")[0]);
|
||||
expect(html).not.toContain(np.emptyTitle);
|
||||
expect(html).not.toContain("unit_mismatch");
|
||||
});
|
||||
|
||||
it("falls back to the generic reason sentence for an unmapped code", () => {
|
||||
const client = makeClient();
|
||||
client.setQueryData(queryKeys.nutrientIntake(30), {
|
||||
nutrients: [],
|
||||
lastAttempt: {
|
||||
at: "2026-07-15T09:30:00.000Z",
|
||||
topReason: "some_future_reason",
|
||||
},
|
||||
});
|
||||
const html = render(<InsightsNutrientsPage />, client);
|
||||
|
||||
expect(html).toContain(np.lastAttemptTitle);
|
||||
expect(html).toContain(htmlEscaped(np.lastAttemptReason.unknown));
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The remaining surfaces in the sweep are pages whose render depends on the
|
||||
* router / module gate, which the SSR-only convention cannot drive. Pin their
|
||||
@@ -155,6 +243,11 @@ describe("read-failure branch is wired on every swept surface", () => {
|
||||
"src/components/custom-metrics/custom-metric-values-list.tsx",
|
||||
"src/app/achievements/page.tsx",
|
||||
"src/components/insights/nutrients/micronutrients-card.tsx",
|
||||
// The page shares `MicronutrientsCard`'s exact queryKey and used to
|
||||
// short-circuit to the "no nutrient data yet" EmptyState on a failed
|
||||
// read, before that card's own (already-correct) error branch ever
|
||||
// got a chance to mount.
|
||||
"src/app/insights/nutrients/page.tsx",
|
||||
// A failed comprehensive read used to fall through to "you haven't added
|
||||
// any medications yet" — with a CTA inviting someone on a full daily
|
||||
// regimen to re-enter what the app already holds.
|
||||
|
||||
@@ -12,11 +12,10 @@ import { SettingsToggle } from "./_shared";
|
||||
import { apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
|
||||
/**
|
||||
* v1.4.31 — operator-side panel for the six assistant feature
|
||||
* flags. The master toggle gates the whole assistant; five
|
||||
* v1.4.31 — operator-side panel for the five assistant feature
|
||||
* flags. The master toggle gates the whole assistant; four
|
||||
* sub-toggles carve specific surfaces (Coach, Daily Briefing,
|
||||
* per-metric status cards, correlation narration, Health-Score
|
||||
* delta explainer).
|
||||
* per-metric status cards, correlation narration).
|
||||
*
|
||||
* UX:
|
||||
* - Master toggle at the top. When off, the sub-toggles are
|
||||
@@ -35,7 +34,6 @@ interface AssistantFlagsResponse {
|
||||
assistantBriefingEnabled: boolean;
|
||||
assistantInsightStatusEnabled: boolean;
|
||||
assistantCorrelationsEnabled: boolean;
|
||||
assistantHealthScoreExplainerEnabled: boolean;
|
||||
};
|
||||
resolved: {
|
||||
enabled: boolean;
|
||||
@@ -43,7 +41,6 @@ interface AssistantFlagsResponse {
|
||||
briefing: boolean;
|
||||
insightStatus: boolean;
|
||||
correlations: boolean;
|
||||
healthScoreExplainer: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -154,17 +151,6 @@ export function AssistantSection() {
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.healthScoreExplainer.title")}
|
||||
description={t("admin.assistant.healthScoreExplainer.description")}
|
||||
checked={raw?.assistantHealthScoreExplainerEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({
|
||||
assistantHealthScoreExplainerEnabled: checked,
|
||||
})
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -204,8 +204,12 @@ export function useCycleProfile() {
|
||||
* insights tab.
|
||||
*/
|
||||
export function useCycleInsights() {
|
||||
// The lagged rows come back with a narrated `interpretation` in the reader's
|
||||
// language, so the cache cell belongs to one language — otherwise a switch
|
||||
// would keep serving the sentence written for the previous one.
|
||||
const { locale } = useTranslations();
|
||||
return useQuery({
|
||||
queryKey: queryKeys.cycleInsights(),
|
||||
queryKey: queryKeys.cycleInsights(locale),
|
||||
queryFn: () => apiGet<CycleInsightsResponse>("/api/cycle/insights"),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
Loaded 100 of 298 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user