Compare commits

...
148 Commits
Author SHA1 Message Date
Marc-André Bombeck 66286f085a Merge pull request #674 from MBombeck/release/v1.35.2
release/v1.35.2
2026-08-01 19:49:28 +02:00
Marc-André Bombeck d3d32ff8e0 Reset the today-just-in fixture through the app, not behind its back
The mobile project read the day as final on a table with no sleep in it.
The rows were gone; what was left was the dashboard snapshot the desktop
run built from them. It carries the sleep-freshness the digest reads, it
lives in the analytics SWR bucket at sixty seconds fresh and an hour of
stale-serve, and a DELETE straight into Postgres does not touch it. So
the second project asked whether last night's sleep was in, and a cached
answer from the first project said yes.

The purge now goes through the bulk-delete route. That tombstones the
rows the way the management list does, recomputes the rollup buckets the
snapshot reads through, and hard-evicts the bucket, so the next read
rebuilds from the table rather than from the run before it. The
delete-by-external-id route names the rows the way this spec does, but it
invalidates without evicting, and a marked-stale cell is served while it
revalidates - the next read would still have been the stale one. The ids
come from SQL because the measurements list collapses overlapping sources
to one row per day and can hide a row that still counts.

The purge stays where it is rather than moving into the cleanup block:
the eviction only happens when the delete removes something, so tidying
up at the end of each run would leave the next one with nothing to delete
and the same stale snapshot.

The starting state is asserted, not assumed, and it stays that way.
2026-08-01 19:33:27 +02:00
Marc-André Bombeck 008a6d46a4 docs(changelog): drop third-person narration and review-process detail
The changelog is meant to read as my own notes, but a stack of entries
narrated me in the third person ("the maintainer's complaints", "per
maintainer directive", "26 of the 27 maintainer findings landed") and
carried internal review choreography no reader can use: numbered review
rounds, finding counts, item codes, QA severity labels, a local path to
a design handoff.

Every one of those now states the change instead of who asked for it.
Where the only content was the attribution, the line is shorter or gone.
No entry's technical claim changes. "maintainer" stays wherever it means
the operator role of a self-hosted instance.
2026-08-01 18:38:13 +02:00
Marc-André Bombeck ad1554a36f Make the round-trip case actually round-trip
The flip from an authored recipe back to the defaults was written with the
retired pillar as the thing left out. With that pillar gone, both halves of the
case sent the same full list, so the first read false where it expected true and
the flip was never exercised at all.

It now takes out a pillar that still exists, and says at the assertion why the
two halves have to differ.
2026-08-01 17:56:20 +02:00
Marc-André Bombeck 96c10f07e6 Give the today-just-in spec an account that earns a hero
The spec watched the day flip from provisional to final on a hero that
only its own subject was keeping on screen: the arrival marker was the
last thing in the calm-degrade guard holding the hero up for the bare
fixture account, so removing the arrival chip took the hero with it and
left the spec nothing to observe.

Putting the arrival back into the guard would bring back a worse
problem. An arrival-only digest lands in 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 guard stays as it
is; the fixture was what was wrong.

So the spec now seeds its own precondition: one integration needing a
reconnect, which is an ordinary Today for anyone whose readings come
from a device. It is read fresh on every request rather than through the
dashboard snapshot cache, so unlike a seeded score or briefing it cannot
lose a race with a cached snapshot, and the rail item admits no module
gate and no dismiss path. The row is removed again afterwards.

While the spec was open: it now pins the state it starts from. It only
ever asserted that the pending-sleep note was absent at the end, which a
note that never rendered also satisfies. It asserts the note is up and
the day reads provisional first, so the two halves are one assertion
with the number flipped.

The component side is pinned in unit tests, since the spec needs a built
app and a database to run.
2026-08-01 13:21:04 +02:00
Marc-André Bombeck 99ceb236de chore(release): v1.35.2 — one answer, published
The cycle ring stops working out its own verdict and reads the server's. The
constant that made that possible is deleted rather than deprecated, because a
constant left in client code is a constant that gets copied again, which is how
the other client's ring froze in the first place.

The score settings page takes the shape the other settings cards have, loses a
pillar that could never score, and turns green where the app's other two scores
already did. A saved selection now comes back as it was sent.

Five gaps in the published specification are closed, including a route that
shipped this morning and was never described at all.
2026-08-01 12:16:27 +02:00
Marc-André Bombeck 368eb44439 Point the score-config contract test at the pillar list that survived
Two branches met here. One retired the fitness pillar and renamed the test's
pillar list accordingly; the other, written against the eight-pillar world,
referred to it by its old name and its old count. The merge kept the new
declaration and the old references, so nothing compiled.

The name carried the count, which is why the collision was visible at all.
There are seven now.
2026-08-01 11:59:29 +02:00
Marc-André Bombeck a830e8a9d3 Merge work/contract-touchups score-config registration 2026-08-01 11:58:17 +02:00
Marc-André Bombeck 7ddcee0445 Publish the health-score configuration route
/api/auth/me/health-score-config shipped in v1.35.0 with a settings page
in front of it and no entry in the contract at all — no path, no schemas,
nothing. A client had the surface and no way to generate against it. This
is publication only; the route does what it already did.

Both verbs are registered off what the route actually returns rather than
off a neighbour's shape. The asymmetry matters and is now written down: the
request speaks the positive selection, the pillars that should count, while
the response carries both sides resolved plus the recipe version. The column
stores the complement, which is storage and no client's business.

The response's updatedAt is documented as optional because it genuinely is
absent until a selection exists — an account that never chose has nothing to
guard a write against, and a decoder that required the field would break on
that read.

The PATCH 422 is written out rather than reusing the shared base-token
response, because this route has four of them and a client branching on
meta.errorCode needs all four: the breadth refusal with the sentence to show
the person, the validation failure, the unparseable base token, and the
non-JSON body. Naming only the token would have been a contract that omits
the refusal people actually hit.

The route test now drives the real route to produce each of those responses
and asserts the published contract names every code that comes back, so a
response the route can emit but the spec does not describe fails the suite.
That closes the base-token ledger: nine routes emit invalid_base_updated_at
and all nine now document it.
2026-08-01 11:57:31 +02:00
Marc-André Bombeck f6edff9b82 Merge work/contract-touchups 2026-08-01 11:50:33 +02:00
Marc-André Bombeck 6a69bad05e Publish the passkey list and the base-token 422
GET /api/auth/passkeys was absent from the contract entirely — no path
entry, no schema — while the client teams had been pointed at a published
schema for it since v1.32.8. It is now registered with the shape the route
actually serves: id, name, credentialDeviceType, credentialBackedUp,
createdAt, and the nullable lastUsedAt that started this. The route itself
is unchanged; only the record of it was missing. PasskeyInfo is deliberately
separate from WebauthnMfaCredentialInfo — one lists sign-in credentials, the
other second-factor security keys, and merging them would blur two surfaces
that behave differently. The route test now compares the projection the
route asks Prisma for against the published property set, so neither end can
move without the other.

`invalid_base_updated_at` existed only in route tests. It is now on all
eight published endpoints that accept a `baseUpdatedAt` body token, and the
field description says the thing implementers keep getting wrong: an
unparseable token, `null` included, is rejected with 422 rather than falling
back to the unconditional write. Omitting the key is the only way to get
that write.

/api/auth/me/health-score-config also returns this 422 but is not in the
spec at all; publishing that route is a separate piece of work.
2026-08-01 11:49:40 +02:00
Marc-André Bombeck 43c591fe19 Correct three contract claims the published spec got wrong
The `type` query parameter on GET /api/insights/derived carried the eleven
VITALS_BASELINE vitals and described itself as being for that metric only.
SAME_TIME_BASELINE takes a `type` too, and its four cumulative day metrics
were absent from the enum, so a client generated against the spec could not
send them at all. The parameter now publishes the union of both registry
sets and the description says which values belong to which metric. A guard
reads the enum back out of the assembled document and pins it to the
registry, so a type added to either engine reaches the contract with it.

The idempotency wrapper's two 409 arms sent `error` as `{ message }`. That
was the only object-shaped error the application emitted; every other route
sends the documented `{ data, error: <string> }` envelope, which is also
what the spec has always promised. The code was wrong, not the spec. Both
arms now build their response through one helper that sends the string, so
every route under withIdempotency serves the envelope its clients decode.

The hero score-ring comment still described MED_COMPLIANCE as pooled 7-day
adherence. It has been today's dose progress since v1.27.8.
2026-08-01 11:42:07 +02:00
Marc-André Bombeck f6bd8be6d5 Merge work/fhir-retire 2026-08-01 11:38:55 +02:00
Marc-André Bombeck 5767e0330b Retire allowFhirApi and resourceTypes off the share-link response
iOS green-lit removal on 2026-07-30 (their decoder treats both as
optional, confirmed at ShareLinkDTO.swift:105-106) and confirmed no
shipped build breaks. Drops RETIRED_FHIR_SCOPE_FIELDS and its spread
from the create and list responses, removes the two properties and
their required-list entries from ShareLinkCreated/ShareLinkList in
the OpenAPI source, and regenerates the spec. The request side was
already closed off by .strict() in an earlier pass; this finishes it
on the response side.

Route tests now assert the fields are absent from both responses
instead of asserting the constants they used to carry.
2026-08-01 11:38:16 +02:00
Marc-André Bombeck 4f79b9d49a Merge work/cycle-verdict 2026-08-01 11:32:07 +02:00
Marc-André Bombeck 4d200ebd43 Merge work/score-settings-polish 2026-08-01 11:32:07 +02:00
Marc-André Bombeck 9bcd5dfaf5 Turn the health score green at 70
The app carries three scores and meant two things by a green dot.
Readiness and the sleep score both call 70 green; the health score alone
wanted 75. One app cannot do that, so the outlier moved. The yellow floor
stays at 50: raising the bar for a reward and lowering the bar for a
warning are different decisions, and only the first one was on the table.

The band is still not a function of the number. The composite takes the
worse of the mean's band and the worst counted pillar's, so a composite
carrying a red pillar stays red however high the mean sits - the stored
band on a recorded day exists for exactly that reason. Only the mean-side
thresholds moved.

The method version moves with it. A day already recorded keeps the band
it was actually shown under, and the notice key carries the version, so
each account is told once that the rules moved. The week-over-week
comparison is left alone on purpose: the number itself does not change,
only the colour it is drawn in, so a subtraction across the change still
compares two numbers worked out the same way.
2026-08-01 11:09:38 +02:00
Marc-André Bombeck 4e934a191d Drop the just-in line from the Today hero
Under a number, 'Just in' did not say what had arrived - the score? a
reading? - and the clock face beside it changed nothing a person would
then do. The line is gone, and with it the only wall clock this surface
rendered.

What it was there for survives: the day still flips in place when last
night's sleep lands, and the pending-night note still says the number is
reading an incomplete day. An arrival on its own is no longer a reason
to paint a hero at all, so an account whose only news was the arrival
now gets nothing rather than a bordered empty card.

The digest still carries the arrival, which the morning line and the
wide event both read.
2026-08-01 11:09:37 +02:00
Marc-André Bombeck ee63b09e59 Rebuild the score settings card the way the other settings cards are built
Save moves out of the header and into its own row at the bottom right,
where every other settings card that saves a form puts it. The body
starts on the title's reading edge instead of the card edge, so the text
lines up with the cards above and below it.

The rules between the rows are gone; the gap between groups does the
grouping. Every row said 'Being recorded - Shown in the app' under the
pillar name, which is the modules screen's business and was the same two
words on every line - the sentence at the top of the card already says
it once. The paragraph explaining that heart, glucose and cholesterol
are three pillars in one area is gone too.

The area headings read in the content colour now rather than the muted
one, so they stop looking switched off beside the rows they head.
2026-08-01 10:53:44 +02:00
Marc-André Bombeck fb1bf710e6 Merge pull request #673 from MBombeck/release/v1.35.1
release/v1.35.1
2026-08-01 10:52:52 +02:00
Marc-André Bombeck c60689affd Take the fitness pillar out of the health score
It could never score. The schema records VO2max but cannot tell a
measured test apart from a device estimate, so every fitness row reached
the scorer unproven and was refused - the pillar has never produced a
value on any account. Listing it on the score settings page as a thing
you can choose, with a line admitting it does nothing, was chrome over a
switch that changed no number.

So it leaves the catalogue: no pillar id, no domain, no scorer, no row,
no wire field. A stored selection that still names it reads back fine -
the resolver has always filtered stored ids against the catalogue and
drops what it no longer knows, so nobody's saved recipe throws or is
discarded over it.

That also fixes the page claiming unsaved changes forever. The save
stripped the fitness pillar out of the selection on its way to the
server, so the answer that came back could never match the form that
sent it, and 'All pillars' left the save button lit no matter how often
you pressed it.
2026-08-01 10:51:01 +02:00
Marc-André Bombeck 043d2ba143 Give the mobile cycle smoke test a verdict to render
The spec hand-builds a calendar response, and the wheel now reads the
resolved verdict from it. Without the field the ring paints empty, so the
fixture derives the verdict from the same phase plan it derives the day
grid from and the two cannot drift apart.
2026-08-01 10:48:51 +02:00
Marc-André Bombeck c65f122017 Prove the verdict reaches a client and that nothing recomputes it
Three checks. The engine tests pin the overdue boundary from both sides:
day 42 of a stated 28-day cycle still counts, day 43 stops counting and
reports fifteen days later than usual.

The round-trip test calls the real GET handler, reads the response body as
text and parses it, so a field that both ends handle but the wire never
carries fails here. It runs the same instant twice for two accounts in
different timezones and gets two different answers, which is what the
account timezone is for. It also checks the ring's cycle day against the
phase engine the coach read uses, so the two cannot tell someone two
different days.

The guard reads the syntax tree of every file under components, hooks and
app, and fails on a grace-period constant, on an overdue verdict computed
rather than read, and on a cycle-day count compared against a sum. It
follows local bindings, so lifting the threshold into a variable above the
comparison does not hide it. A companion suite tries nine disguises against
the same detector and pins five innocent shapes it must ignore, including
an overdue medication dose.
2026-08-01 10:45:24 +02:00
Marc-André Bombeck dbecbbcbdb Publish the resolved cycle verdict instead of letting each client work it out
The cycle ring's day count, its phase arcs, and the judgement that a period
is late all used to be worked out in web client code, including the
fourteen-day grace window that decides when the count stops being an
observed fact. Anything else that wanted the same answer had to reproduce
that rule, and a second copy of it is how a ring ends up showing someone a
cycle day their record does not hold.

GET /api/cycle/calendar now carries a verdict: the state, the cycle day,
the ring's arcs, the cycle start, how many days until the next period, and
when the period is late, how late. It is resolved against the user's own
timezone day, which a browser in another zone cannot do. The web wheel,
the wellness-strip tile, the insights teaser and the temperature chart all
render it, the grace constant is gone from the components directory, and
the spec says the field is required.

The overdue caption now names the number of days instead of only saying
later than usual.
2026-08-01 10:37:18 +02:00
Marc-André Bombeck 7231c9f3f7 chore(release): v1.35.1 — stop asking for a weekly dose that was already taken
A medication on a rolling interval had its expected dose anchored on the moment
the person 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 dose taken late still counts, so the dose someone's own
injection created could never be marked done. It stayed open, went overdue, and
the app asked for it again.

No editing was needed to reach it. Taking a weekly dose a few days late was
enough, on any account. Push reminders read the plan rather than the entries and
were never affected, so this was on screen, not on a phone.
2026-08-01 10:28:40 +02:00
Marc-André Bombeck 6c87c96fa6 Merge pull request #672 from MBombeck/release/v1.35.0
release/v1.35.0
2026-07-31 23:46:48 +02:00
Marc-André Bombeck 5424ab0b60 Assert the settings heading that the width actually shows
The shell renders the heading pair twice under distinct ids, one column
hidden below the medium breakpoint and one hidden above it, so exactly one is
ever on screen. The spec asserted the desktop id alone: green on the wide
project, red on the narrow one against a heading that is hidden by design.

It now follows whichever of the two the viewport reveals, and asserts that
exactly one is visible, so a real duplicate would still fail.
2026-07-31 23:31:00 +02:00
Marc-André Bombeck b85a348891 Give the type check the same memory ceiling the build already has
CI died at the 4 GB default heap while checking types. The build was given its
own ceiling earlier this month so a local run, CI and the published image all
invoke it the same way; the type check was left on the default and has now grown
past it. Same treatment, same reason.

Also raises the aggregate bundle cap from 3260 to 3310 KB, and only that one.
Every per-route ceiling is untouched and every measured route sits within a
kilobyte of its previous value, so nothing a person downloads grew. The rise is
the new score-settings route, a mutually exclusive chunk that the whole-build sum
counts even though no single visit pays for it, plus its strings across six
locales. The reasoning is recorded in the budget file beside the number.
2026-07-31 22:08:05 +02:00
Marc-André Bombeck 84706196dd Let the score settings announce a save the way every other surface does
The new page wrote its own success toast. The outcome module owns that
affordance so a saved thing looks the same everywhere, and a structural check
catches anyone reaching around it. That check offers an allowlist entry as a
way out; taking it would have left two places deciding what a success looks
like, which is the thing the module exists to prevent.
2026-07-31 21:17:01 +02:00
Marc-André Bombeck 88162eda07 Say whether each coverage case is an authored recipe or the default
Two branches met here. One made `configured` a required field so a caller
cannot stay silent and publish "not authored" for an account that chose its
own composition. The other wrote these cases before that existed, so they
compiled against the old shape and stopped compiling against the new one.

Filling the field in to satisfy the compiler would have thrown away the point
of making it required, so each case now states what it actually is: the two
with a hand-picked selection are authored, and the one whose own comment says
the account counts all eight is the default.
2026-07-31 20:34:29 +02:00
Marc-André Bombeck 778725834f chore(release): v1.35.0 — the score you decide
The Health Score stops being a by-product of switches meant for something else.
It gets its own page, its own recorded history with the recipe that made each
day, and a comparison that refuses to draw a line across two different
definitions.

An account that upgrades keeps the composition it already had, so the number
does not move on its own. What changes is that the composition is now visible
and yours.
2026-07-31 20:32:25 +02:00
Marc-André Bombeck cdfe4ec114 Merge work/score-settings-ui 2026-07-31 20:31:02 +02:00
Marc-André Bombeck f42d423edd Merge work/score-coverage-contract 2026-07-31 20:31:01 +02:00
Marc-André Bombeck 2b95d7ebc4 Merge work/score-configured-flag 2026-07-31 20:31:01 +02:00
Marc-André Bombeck bf0a637bab Keep the score's own coverage honest about what it counts
The composite's coverage counts distinct areas of health, not pillars.
The learning line called them pillars, so an account recording blood
pressure, glycaemia and cholesterol read that it had one eligible pillar
while three sat in front of it. The count was right and stays; the noun
was wrong.

The meter beneath the score carried the same confusion further. Its
fraction is the three-area minimum the score requires, and a score only
exists once that floor is met, so it rendered as full coverage for every
account that has one. The arithmetic is untouched. Below the floor the
fraction is a real number and is shown as one; at or above it, the floor
is stated. The dots and the percentage were always honest, they track how
much history is behind the number, and they stay.

Both are held down by an AST guard rather than a text search, with a
counter-test for the neighbouring lines that are legitimately about
pillars and legitimately about a real fraction.
2026-07-31 19:05:20 +02:00
Marc-André Bombeck 78fe4d8eea Guard the configured flag's meaning without a container
The flag was proven end to end over the real snapshot, digest and derived
wires, which is the right place to show that it arrives and the wrong place
to be the only proof of what it means. That suite needs a container runtime,
so on a machine without one the definition stood unguarded: replacing the
default side of the comparison with the unnarrowed catalogue, the exact
mistake the docblock warns about, left the whole unit suite green.

The definition now has its own fast test, and that break takes two of its
cases red.
2026-07-31 19:03:49 +02:00
Marc-André Bombeck f8b51b7994 Keep the insights end-to-end fixture faithful to the wire
The composite fixture mirrors the real payload field for field, so it
gains the resolved flag alongside the composition it sits next to.
2026-07-31 18:51:47 +02:00
Marc-André Bombeck bd4a6d14d5 Let a person choose what counts toward their Health Score
A dedicated settings page lists every scorable pillar grouped by the area
of health it speaks to, so the fact that blood pressure, glycaemia and
lipids are three pillars of one area is visible rather than buried. Each
row shows the three things that were never separable before: whether the
pillar is being recorded, whether it is shown, and whether it counts
toward the score. Only the last is writable here, and the page says so.

A pillar whose tracking is switched off is not listed at all, because it
is not waiting for anything. A pillar this build cannot score yet is
listed, disabled, and says why. Wellbeing under safety signposting is
named as safety guidance and never as a mistake in the selection. The
refusal for a selection too narrow to produce a score comes from the
server and is shown in the reader's own language.

The score's foot disclosure gains one link into the page.
2026-07-31 18:49:29 +02:00
Marc-André Bombeck 308908c226 Stop the score from describing a recipe the person may have changed
Two sentences about the Health Score stopped being true the moment
someone could choose which pillars count toward it.

The one people read said the score averaged "every pillar that has
enough recent data". For an account that took a pillar out, that
describes ground the number does not cover, and it reads as a
reassurance rather than a description. It now says the average is over
the pillars that count, that a pillar without recent data is still left
out rather than zeroed, and that which pillars count is the reader's own
choice, so the combined number is not itself a clinical standard. All
six locales.

The one nobody reads named the WHO HEARTS technical package flat, as
though a standards body had signed off on whatever selection the account
holds. HEARTS names the risk factors the pillars are drawn from; it
prescribes no average over them. The entry now says what it is cited
for. While checking where that citation surfaces it turned out it
surfaces nowhere: ProvenanceExplainer takes the prop and renders only
the method caption, which v1.22 did on purpose when the info popover
went away. That is written down in the file now, because the map's own
header implies otherwise and the next person would trust it.

The registry entry for HEALTH_SCORE lists all eight pillars with a
minimum of three, which reads like the recipe and like a threshold. It
is neither. The published coverage and confidence are built in
computeComposite over the resolved composition, so a person counting
four pillars is not measured against a denominator of eight, and the
real floor is the breadth rule, which counts distinct domains rather
than inputs. Both facts are now stated at the entry instead of being
inferable only by following the call chain.

Tests: the published denominator is never the eight-pillar catalogue; a
pillar somebody took out of their score is never listed back as missing
coverage; a pillar they kept and have no data for still is; the registry
list stays equal to the score catalogue; the choice sentence is present
in all six locales.
2026-07-31 18:10:39 +02:00
Marc-André Bombeck 46146f389c Test the configured flag over the real snapshot and digest assembly
Five accounts, each writing its selection through the real PATCH route
and reading the flag back off the dashboard snapshot, the daily digest
and the derived-metric payload. Nothing is hand-built between the ends:
a builder that forgets to carry the field returns undefined and the
assertions fail.

The four negatives are the ways an account reaches the default
composition without having authored it, including one that kept every
pillar (hasSelection is true, the flag is not) and one that took out a
pillar its modules had already withdrawn. A sixth case walks the same
account through a save and an undo, so a value cached under a key that
does not include the recipe cannot pass.
2026-07-31 18:10:25 +02:00
Marc-André Bombeck 0276ca9121 State an authored composition in the score's method footer
One line under the method version, shown only when the server says the
composition is the person's own. It states the fact and stops there:
which pillars they took out belongs to the settings surface, and
working it out here from a configuration blob would put a second
decider behind one sentence.
2026-07-31 18:06:09 +02:00
Marc-André Bombeck c29055be1c Carry the configured flag onto the snapshot and digest wires
The dashboard snapshot copies it off the composite, the daily digest
copies it off the snapshot, and the derived-metric payload already
carries it inside the composite value. Optional on both DTOs like the
score identity fields beside it, so older cached bodies stay valid.

The digest's score object is additionalProperties:false and had never
declared deltaReason, scoreVersion or composition, all of which it has
been sending. A generated strict decoder would have rejected the real
payload, so they are declared here too rather than left standing next
to a field that documents itself correctly.
2026-07-31 18:03:53 +02:00
Marc-André Bombeck 5d7d00949c Resolve the configured flag on the score composite
One boolean on the composite: true when the account's resolved
composition differs from the composition its defaults would resolve to
today, both narrowed by the same modules. Decided once in the score
reader, from the same config and module map the composition came from,
so no client interprets a config blob to answer it.

This is not hasSelection. An account that opened the surface and kept
every pillar has written a selection and is not configured; an account
whose modules alone narrow the set has authored nothing and is not
configured either.
2026-07-31 18:02:22 +02:00
Marc-André Bombeck 0a524c542b Write down what an upgrade does to an account that already exists
The promise is that nobody's number moves the day they upgrade, and the
way it holds is easy to lose: there is no backfill and no migration
step, because a null column reads as version zero, version zero counts
every pillar, and every pillar narrowed to what the account's modules
record is exactly the set the module switches were producing before.
The inheritance is the resolver's behaviour, so the next person to touch
the resolver needs to know they are holding it.

Also records what the first save sets in motion, once each: the delta
suppressed while the comparison window still straddles the change, the
next stored day marked as a break so the line is drawn in two segments,
and the notice raised under a key carrying the recipe version.
2026-07-31 16:57:42 +02:00
Marc-André Bombeck 98eb609eb1 Remove two refusals that could not refuse anything
The recipe boundary opened with an early return for a missing date and
another for a date it could not read. Deleting either one changed no
answer any test or any caller could observe: both cases parse to NaN,
and every comparison against NaN is already false, so the refusal was
falling out of the arithmetic underneath them. An if that cannot change
an outcome is a comment wearing a keyword, and it invites the next
reader to trust a lock that is not attached to anything.

The behaviour is unchanged and now has one statement instead of three,
with the reason written where the next person will look for it.

The seam had the same shape in miniature: a day naming no recipe was
already broken away from a day that named one, by plain inequality, so
the arm only ever mattered when two consecutive days both named none.
That case is now covered, and removing the arm makes it fail.
2026-07-31 16:57:05 +02:00
Marc-André Bombeck 3406ed6946 Drop a boundary condition no input could reach
The recipe boundary also required a version of at least one before it
would suppress. That reads like a second lock and is not one: the
resolver returns a version below one only on the same path that returns
no change date, so nothing could ever reach the condition and no test
could make it fail. Removing it leaves the guard asking the one question
that has an honest answer, which is whether the recipe moved inside the
window.

The invariant that makes the removal safe is now pinned instead: a
resolved configuration never carries a version without the date beside
it. A resolver path that produced one would wave that account's recipe
change straight past the guard.

Also pins the order of the two rules that can fire together. A change
that moved the composition trips both, and the person is told they
changed what counts rather than that the included pillars changed, which
would describe the consequence and leave out the act.
2026-07-31 16:52:22 +02:00
Marc-André Bombeck 5d13f0e69c Prove the false drop cannot come back
The named failure now has a test that drives the whole chain a request
takes: fixture rows through the real reader, the real score record
write, the real dashboard snapshot, the real verdict rung, and the real
digest that gives the morning push its sentence. Only the IO around that
chain is faked. A version built on a hand-written snapshot literal would
have stayed green through a release where the assembly between the two
ends dropped the field, which has happened here before.

Every case runs twice on the same data. Once with the recipe changed
three days ago, once with it changed a month ago. The second run is what
proves the fixture really does produce an eleven-point fall, so the first
run's silence is the guard working rather than an account with nothing
to say. The decline is gradual on purpose: a cliff would raise each
pillar's own noise floor and the delta would be suppressed for a reason
that has nothing to do with the recipe.

Beside it, the boundary's edges, the notice key's two dimensions and the
seam's shape, each with a case proving the guard stays quiet on innocent
data. Against real Postgres, a row now has to come back carrying the
recipe version and change date the account's stored configuration
supplied.
2026-07-31 16:48:40 +02:00
Marc-André Bombeck 087f3f496b Stop a settings change from reading as a score drop
Changing which pillars count is an act the person performed, not
something that happened to their health. Until now the week-over-week
comparison could not tell the difference: both windows are computed in
one request under whatever recipe is in force right now, so the pillar
sets agree, the subtraction looks legitimate, and the dashboard could
announce "your score dropped 14 points" to someone who had just come
back from the settings page.

The recipe now carries its own dated identity, and the delta guard
compares against it the same way it already compares against the global
method boundary: a recipe that changed between the two windows
suppresses the delta with a new reason, config_changed, and the surfaces
that gate on an unexplained delta stay quiet. The score record stores
the recipe version and its change date on every write, resolved from the
same stored blob the composition came from, so a row can say which
recipe produced it. A new series module reads those versions back, marks
the first day after a change as a break, refuses to join the composite
line across it, and keeps the per-pillar rows running through it: a
pillar is graded against its own reference band and stays comparable
when the average of the pillars does not.

The notice key folds the person's recipe version in beside the method
version, so a change in either raises the existing dismissible notice
once and dismissing one never dismisses the other. An account that never
chose keeps its old key untouched.
2026-07-31 16:17:20 +02:00
Marc-André Bombeck 0826bd80e6 Merge the persisted score record into the composition swap 2026-07-31 15:57:37 +02:00
Marc-André Bombeck 0a877bdf4d Pin what decides the score's composition
The module-to-pillar mapping had no test of its own. It lived in
ternaries, so the only way to notice it had changed was for someone's
score to change.

The new file drives the real reader against a fake database and pins
three things. The composition for every module combination with no
stored recipe, compared against the old expression written out verbatim,
which is what makes it a before-and-after rather than a restatement. The
difference between a pillar that is selected and has no data yet, which
keeps its row, and a pillar whose module is off, which has no row at
all. And the breadth refusal at read time, whose reason has to be the
shared rule's verdict on the set the reader resolved, so a second copy
of that rule would show up as a disagreement.
2026-07-31 15:51:26 +02:00
Marc-André Bombeck 76e68f0247 Let the score's recipe decide what counts, not the module switches
Until now the set of pillars behind the Health Score was assembled from
the module switches alone, in a stack of ternaries inside the reader. A
person who turned the sleep module off lost the sleep pillar from their
number as a side effect of a setting about recording data, and there was
nowhere else to say what should count.

The reader now reads the stored recipe and narrows it to the pillars a
module is actually recording. The mapping from module to pillar moves
out of the ternaries into a named map beside the reader, where it can be
read and tested. Glycaemia stays fed by either glucose or labs, which
was the one entry a one-module-per-pillar map would have lost.

Nobody's number moves on upgrade. An account that never opened the new
surface resolves to every pillar, and every pillar narrowed by module
availability is exactly the set the ternaries built, so the inheritance
holds by construction with no backfill.

A pillar the recipe selects whose module is off produces no row at all,
rather than a "waiting for data" line. Turning a module off is a
deliberate act, and asking someone for data they chose to stop recording
would be nagging them in the voice of their own settings.
2026-07-31 15:47:12 +02:00
Marc-André Bombeck c66aafe9c8 Read the deferred configuration columns in one line in the audit report
The audit script prints the annotation verbatim, so a note wrapped across
three schema comment lines came out with the comment markers folded into
the middle of the sentence.
2026-07-31 15:45:22 +02:00
Marc-André Bombeck 54fed25859 Silence an unused-parameter warning in the record write test 2026-07-31 15:39:24 +02:00
Marc-André Bombeck 5fd5447069 Make the band a database type instead of a text check
The delete-all-data guard seeds every table straight from information_schema
so a table added next year is asserted without anyone remembering. A CHECK
constraint defeats that: the generic seeder has no way to guess a value the
constraint accepts, and the whole suite failed on the new table. A Postgres
enum carries the same closed set, the seeder reads it from pg_enum, and
Prisma now types the column instead of trusting a string.

Dropped the day-key format and non-empty-composition checks with it. The
writer builds the key through the timezone helper and refuses an empty
composition before inserting, and the restore validates both against the
wire schema before the transaction opens, so a third copy in the database
bought nothing and cost a hand-written line in a fixture whose point is not
having one. The composite range check stays: free to check, and a number
outside nought to a hundred renders as a score nobody can read.
2026-07-31 15:38:50 +02:00
Marc-André Bombeck 956746fe7d Keep one sanitiser for pillar ids
The scorer and the stored composition both had to answer what a valid
pillar list looks like, and both had their own copy of the answer. One
function next to the catalogue now, used by both.
2026-07-31 15:37:04 +02:00
Marc-André Bombeck cd7c6f44c8 Teach the export test mocks about the recorded score days
Four suites hand-roll a Prisma stand-in for the backup builder, so a new
section reaches them as an undefined delegate rather than as an empty
result. Each gains the delegate, and the admin restore round trip gains an
assertion that the day comes back with its composition and per-pillar
scores, not just its headline: a row restored without those looks restored
and answers nothing.
2026-07-31 15:31:47 +02:00
Marc-André Bombeck ab8e9a164d Carry the recorded score days through wipe, backup and restore
A new user-scoped table is outside all three by default, and the two
structural guards said so the moment the model landed: the wipe plan and
the backup verdict list both named it as unclassified. It now joins the
wipe list, rides the backup payload as its own spread section, and comes
back through the restore route.

The classification guards only prove a helper exists in a declared file, so
they would pass an unreferenced restore helper. The integration round trip
goes through the real admin restore route instead, with the table emptied
first so a restore that carries nothing cannot pass by leaving what was
already there.
2026-07-31 15:13:47 +02:00
Marc-André Bombeck 2f6f3573a2 Add the route that chooses what counts toward the score
PATCH /api/auth/me/health-score-config takes the pillars a person
wants counted and stores the rest as the deselection list. A selection
that cannot produce a score is refused with a sentence naming what is
missing, using the same rule the scorer applies at read time. The
write evicts the score caches, and so does the modules route, which
changes the same composition and never did.
2026-07-31 15:12:47 +02:00
Marc-André Bombeck d89b90da4b Hash what the score actually depends on, and nothing that cannot move
The fingerprint carried the composition twice: once as its own key and once
as the ordered pillar ids inside the per-pillar triples. No input could make
those two disagree, so the term could not be broken and the test that
claimed to cover it passed with the term removed. Dropped the duplicate and
replaced the test with one that pins what the ids are for: a different set
of pillars producing the same numbers in the same order is a different
standing, and now reads as one.
2026-07-31 15:07:35 +02:00
Marc-André Bombeck 4e649de4f7 Add the health-score composition resolver
Reads the stored deselection list into the pillar set the scorer and
the settings surface both work from. An absent list means the person
never chose and keeps counting everything; a present list is an
authored recipe and is never widened. Ids outside the catalogue are
dropped on read rather than failing the whole blob, so one unknown
string cannot discard a recipe someone wrote.
2026-07-31 15:06:55 +02:00
Marc-André Bombeck 860a1fa299 Record each local day's score on the path that computes it
Every surface that shows a score now goes through one entry point that
computes it and writes the day down, rather than three surfaces each
remembering to. The write is an insert that yields on conflict, so a second
computation of the same day leaves the first row alone, and a failure is
annotated rather than thrown: a score that renders must not disappear
because a bookkeeping insert did.

The fingerprint covers exactly what the stored composite and band depend on
- the algorithm version, the composition, and each counted pillar's score
with its scoring-mode identity. A pillar that was graded but not counted
stays out of both the fingerprint and the row, because it did not
contribute to the number being stored.
2026-07-31 15:05:51 +02:00
Marc-André Bombeck d92be0d0bd Lift the score breadth rule into one shared place
The composite needs three distinct domains and one physiological
pillar. Until now only the scorer could narrow the pillar set, so the
rule lived inside it. A settings write can narrow it too, and a second
copy of the rule would drift, so both layers now call the same
function over the same pillar-to-domain map.
2026-07-31 15:03:20 +02:00
Marc-André Bombeck cb75986eb5 Add the health-score record table
The score is computed live from today's rows, so once the readings behind
a past day change the number the person actually saw that day is gone.
Nothing wrote it down. This adds the table that does: one row per account
per local day, holding the composite, its band, the algorithm version, the
composition that produced it, every counted pillar's own score, and a
fingerprint over exactly what those depend on.

Append-only by construction. A unique index on account plus local day gives
the writer something to conflict against, and there is no updated_at,
because a later computation of the same day must leave the earlier row
exactly as it stood.
2026-07-31 15:02:01 +02:00
Marc-André Bombeck c574ef78fb Add the per-user health-score composition column
A nullable JSONB column on users carrying which pillars the person
chose to count toward the composite score. Stored as a deselection
list so a pillar shipped later counts by default and cannot be
confused with one the person removed. NULL keeps its own meaning:
never chose, which is not the same as counting nothing.
2026-07-31 15:01:23 +02:00
Marc-André Bombeck 63b54587a8 Merge pull request #671 from MBombeck/release/v1.34.5
Release v1.34.5
2026-07-31 13:02:20 +02:00
Marc-André Bombeck 842a24f1d1 Wait for a page's placeholders before measuring its height
The over-scroll guard measured a settings page while two of its cards were
still showing their loading state, and read a height that no longer existed a
moment later. The page was blamed for a second scroll surface it did not have.

The gate waited on the layout shell, which is present long before the data
under it, and its own documentation says a sentinel has to be something the
measurement depends on. It now also waits for the loading placeholders the app
marks in the markup, and gives up quietly rather than failing if one legitimately
stays, since a timeout would be a worse failure than the one being prevented.
2026-07-31 12:46:44 +02:00
Marc-André Bombeck 5ef7fa86a8 Let the geometry check borrow postcss without declaring it
The check compiles the app's stylesheet the way the app does, and reaches
postcss through the Tailwind plugin's own resolution rather than taking a
dependency on it. The dead-code gate reads the module name and asks for a
declaration; the borrow is deliberate, so the name is listed as one the gate
should leave alone.
2026-07-31 12:27:44 +02:00
Marc-André Bombeck 7b9cde7a24 chore(release): v1.34.5 — the score says where its numbers come from
Version anchors, the regenerated service-worker version and API spec, and
the changelog entry.
2026-07-31 11:59:57 +02:00
Marc-André Bombeck f8074e1b07 Merge work/hero-score-panel 2026-07-31 11:48:22 +02:00
Marc-André Bombeck 9982f87dc9 Merge work/bp-score-basis 2026-07-31 11:48:21 +02:00
Marc-André Bombeck 2d01b9846d Merge work/coach-read-locale 2026-07-31 11:48:21 +02:00
Marc-André Bombeck 2bd964973f Record the score's delta chip in the affordance ledger
The panel picked up a green +N chip beside its label when the score moved
back into the band, and the repo-wide affordance guard counts that colour
wherever it appears outside the outcome module. The chip reports a week that
went up rather than an action that succeeded, so it is recorded here at
exactly one use rather than routed through the outcome table.
2026-07-31 11:40:26 +02:00
Marc-André Bombeck b697a0a10e Measure the panel against the band, not against the page
The sideways check could not fail for the case it was written for. A panel
wider than the phone kept it green: the hero band wears overflow-hidden for
its glow, so an over-wide panel is clipped rather than scrolled and the
document never widens by a pixel. The person loses the right-hand end of
every row and nothing reports it.

It now compares the panel with the room the band actually offers, which is
the comparison the clipping makes meaningful.
2026-07-31 11:32:44 +02:00
Marc-André Bombeck cc7978a573 Measure the hero panel instead of describing it
The reserve and the row's label column were carried as comments with pixel
figures nobody could reproduce: the tool that produced them was never
committed. This adds one that is, and the figures change.

The reserve did not hold anything. Its declared minimum was 30rem while its
own blocks added up to more than that, so the height was whatever the blocks
happened to be and the constant was decoration. The blocks are back under the
floor and the floor is 28rem, which sits between the shortest and the tallest
panel a real report produces: three scored rows come to 410 pixels at the
352 pixel column, four 438, five 466, six 494, and the reserve is 448. A short
report is no longer pushed down by 74 pixels when the score lands.

The label column goes back to the 7rem the design specifies. It had been
widened to 8rem on the grounds that 7rem clipped the longest label in five of
six languages and 8rem cleared them; measured, the longest label overruns both
widths in five of six, and the only difference is English, by a margin smaller
than the error introduced by measuring in a substitute typeface. The extra
width came off the fill bar for nothing.

The new check renders the real band with the compiled stylesheet and reads the
boxes back in a browser: the reserve against the panel it stands in for, every
row in all six languages on one line with an ellipsis rather than a push, and
no sideways scroll at the 352 pixel column or at 360 pixels stacked. It states
plainly that it cannot know the shipped typeface, since the font is fetched at
build time and no font file is committed, so every assertion in it is one that
holds in any typeface.
2026-07-31 11:29:06 +02:00
Marc-André Bombeck 06fdeb0336 Pin the panel's rules with tests
Four things a person notices if they break: a pillar never shows up twice
anywhere in the panel, a failed read never lands in the not-scored list, the
panel does not push the page sideways at 360 pixels, and the whole thing is
there with the briefing switched off. Every one of them was broken on purpose
first to check the test actually goes red.

The label column is 8rem, not 7. Rendered in all six languages, 7rem clipped
the longest label in five of them, two of those by two pixels, which reads as
a bug rather than a truncation. The reserve is 480 pixels, measured against
the built panel rather than guessed: three rows come to 440 and six to 524.

The overview fixture now carries a health score, so the phone-width guard
measures a band with the panel in it instead of an empty column.
2026-07-31 11:23:05 +02:00
Marc-André Bombeck 9d82b25c6f Type the cycle route's locale probe instead of ignoring its argument
The stand-in for the discovery engine took an argument it never read, so
the lint run flagged it. Give the mock a function type instead: the
argument stays inspectable, which is the whole point of the probe, and
nothing is declared and then dropped.
2026-07-31 11:15:38 +02:00
Marc-André Bombeck 5784d5ec72 Prove the blood-pressure basis survives the assembly, not just both ends
The grader returns the score and its basis together and the pillar builder
copies both across, and each of those ends already had its own test. Neither
sees what happens between them: the envelope hand-off, the reader's narrowing,
the pillar list, the JSON that finally goes out. A field can be produced
correctly, consumed correctly, and never reach anyone, which is exactly how a
payload field shipped earlier this month and carried nothing on the wire.

These tests read the assembled response and assert the basis that arrives, so a
future hand-picked key list on that path fails here rather than in someone's
browser.
2026-07-31 11:15:27 +02:00
Marc-André Bombeck cf7bb13ee8 Explain a blood-pressure score in its own popover
Three lines now sit above the reference band: which axis set the number
and how far it was from its ceiling, what the scale means at the ceiling
and at 100, and that the pair shown is a recency-weighted average rather
than a reading anyone took. In all six languages.
2026-07-31 11:08:54 +02:00
Marc-André Bombeck 7233845b10 Close four ways around the locale guard
The guard read a parameter as required whenever it carried no question
mark and no default, so `Locale | undefined`, `Locale | null`, a locale
widened to `any`, and a default sitting on the destructuring pattern all
walked straight past it. Each of those lets a caller say nothing and the
sentence come back in English, which is the bug the guard exists to stop.

A counter-case pins the other direction: a plainly required locale still
reads clean, so the guard stays quiet on the shape the chain is meant to
have. The correlation read now also asserts the language it was asked in;
nothing else in that file would have noticed it going back to English.
2026-07-31 11:08:06 +02:00
Marc-André Bombeck 0ce40b0950 Say why the blood-pressure pillar scores what it does
The score popover now opens with three sentences before the reference
line: which axis set the number and how far it sat from its boundary,
what the scale means at that boundary, and that the pair behind it is a
recency-weighted average rather than a single reading someone took.

The lines read the basis the grader already publishes, so nothing here
recomputes anything. A pillar that arrives without a basis contributes no
lines at all instead of an empty one.
2026-07-31 11:07:39 +02:00
Marc-André Bombeck 64a92fd392 Publish which blood-pressure axis set the pillar score
The score is the worse of the systolic and diastolic sub-scores, and
until now nothing said which one it was or how far over its ceiling the
reading sat. The grader now returns that basis together with the score,
as one value, and it is carried as one value all the way to the pillar
so an explanation can never belong to a different grading run than the
number it explains.
2026-07-31 11:04:20 +02:00
Marc-André Bombeck 63c7f63f99 Put the health score back beside the greeting
The score returns to the right-hand column of the hero band, where it sat
for eleven weeks: the number at the top, then one thin line per pillar that
actually counts toward it. The card that was pinned below the greeting is
gone, so the score has one place on the page again.

Each contributor line now takes its own colour. A pillar scoring 92 used to
be painted red just because the overall score was red.

Anything not contributing is one quiet line instead of a block: a failed
read is named once for all the pillars it took down and carries the retry,
safety guidance is named in full, and everything simply not recorded yet is
counted, with the names a tap away under the score.
2026-07-31 11:02:21 +02:00
Marc-André Bombeck 271a49a822 Pin the Coach read strip's language, and keep the default out
Three things are held down here. The route hands the reader's language to
the strip builder, for a cookie and for a stored preference. The pattern
sentence comes back in German for a German reader, asserted word for
word, through the real chain rather than the engine on its own — the
engine could always write German, it was simply never asked, so an
engine-only test would have stayed green the whole time it was broken.
And a cached strip stays with the language it was written in.

The guard reads the declarations rather than grepping them, so renaming
the argument or moving the fallback into a variable does not slip past
it. Its counterpart runs the same detector against those disguises and
insists each one is caught, because a check that cannot fail reads like
proof and is not.
2026-07-31 09:07:50 +02:00
Marc-André Bombeck 4d6091ec45 Merge pull request #670 from MBombeck/release/v1.34.4
release/v1.34.4
2026-07-31 08:53:46 +02:00
Marc-André Bombeck 4ed6f7d1ce Write the Coach read strip's second line in the reader's language
On a German metric page the strip printed its own summary in German and
the pattern line under it in English. The pattern line is a finished
sentence the server writes, and the route never told the server who was
reading, so it wrote English.

The route now resolves the reader's language the same way the
correlations and analytics routes do and passes it down. The language is
a required argument the whole way through — it used to be optional with
an English fallback, which is why a caller could stay silent and still
get a sentence back. The cycle page's lagged rows had the same gap and
are fixed with it.

The strip's browser cache is keyed by language too, so switching
language no longer re-uses the sentence written for the previous one.
2026-07-31 08:52:23 +02:00
Marc-André Bombeck 001641f57e chore(release): v1.34.4 — one question, one answer
Every fix in this release is the same shape underneath. Two places in the code
were each deciding something they both had to agree on, and nothing made them
agree: two copies of the due-day rule, two clocks on one card, two server caches
behind one page, two ideas of where a nap belongs.

The version anchors, the changelog and the published specification move together
as usual.
2026-07-31 08:29:37 +02:00
Marc-André Bombeck 36ab24727f Merge work/nap-separation contrast fix 2026-07-31 08:28:10 +02:00
Marc-André Bombeck b53ab36c4b Separate the nap band from the stage beneath it with a visible edge
The nap fill and a wake bout measured 1.52:1 in the dark theme and 1.16:1
in the light one, so on any night with a wake bout the two bands read as a
single block. That defeats the point of pulling the nap out in the first
place.

No fill colour can fix this. To clear the 3:1 asked between neighbouring
shapes, a nap colour would have to beat all five stage colours and the card
behind them, and in both themes those two demands point in opposite
directions with nothing left in between. The band now carries a card-
coloured outline instead, which is the usual answer for stacked bars and
leaves the stage colours exactly where they were. The tightest pair it
leaves is 4.81:1.

The legend and the tooltip already name the nap, so the distinction never
rests on colour alone.
2026-07-31 08:27:19 +02:00
Marc-André Bombeck 1c5191d1e3 Merge work/nap-separation 2026-07-31 08:21:55 +02:00
Marc-André Bombeck ee0375142c Merge work/cache-instance-proof 2026-07-31 08:21:18 +02:00
Marc-André Bombeck 8a316f5820 Record that the cache pin was checked against the entrypoint the image runs
The reading behind the pin was taken under `next start`. The container runs
`node .next/standalone/server.js`, and a fix that is green everywhere and
inert in production is the failure this project has paid for before, so the
same probe was re-run under the standalone entrypoint with static assets and
public copied the way the Dockerfile copies them.

No difference: three module instantiations either way, one shared registry
with the pin, separate registries without it, and a measurement write made
through a route handler reaches the page render's cache only when the pin is
there. The note says which entrypoint the claim rests on so the next person
does not have to wonder.
2026-07-31 08:20:34 +02:00
Marc-André Bombeck a99e98332c Pin the nap split against the night view, and the empty case against itself
Three things are worth not regressing: a big afternoon nap must not land in
the night's stage buckets, a day without a nap must come out exactly as the
old per-wake-day path produced it, and the two surfaces must name the same
session as the night for the same rows. The last one drives the real
/api/sleep/night handler rather than re-deriving its answer.

The chart's row assembly moved into a plain function so the nap band can be
tested at all; Recharts draws nothing under server rendering.
2026-07-31 08:20:05 +02:00
Marc-André Bombeck 1f8c31d29e Keep a daytime nap out of the night in the sleep stage composition
The stage-composition chart totalled each wake day, and a nap ends on the
same wake day as that morning's night. An afternoon nap was added straight
into the night's stage buckets, so the column read longer and shallower
than the night actually was.

The composition now asks the same question the night view already asks:
which session was the night. The night's stages are the night's, and the
nap sits on its own band above them, in its own colour, with its own line
in the tooltip. Days without a nap look exactly as they did.
2026-07-31 08:13:58 +02:00
Marc-André Bombeck af70312306 Merge work/onboarding-honesty 2026-07-31 08:10:39 +02:00
Marc-André Bombeck 0fbaf6b2d3 Build the dashboard snapshot cache key from the one function that owns it
`dashboardSnapshotCacheKey` documents itself as the single source of truth
for the key, and the invalidators sweep by it, but the read spelled the same
string out by hand. The two matched, so nothing was broken today — edit one
side and the other stops matching silently, and the only symptom is a
dashboard that keeps painting the body from before the write.

The read now calls the function. The guard warms the cell through the real
read and then asserts the entry sits at the composed key exactly, so a
suffix change on either side is caught rather than forgiven by the prefix
sweep; two more cases cover a snapshot invalidation and a recorded dose.

The header on the shared read claimed both entry points ride the same cell.
That claim now holds, but only because the cache registry is pinned to the
global, so the header says so.
2026-07-31 08:07:04 +02:00
Marc-André Bombeck 80c8674ee1 Remove the Health-Score explainer switch, which switched nothing
The admin panel offered a toggle for the Health-Score explainer and
described it as the ? next to the delta line. That ? had already been
taken off the insights surface a release earlier, and the caption that
replaced it went away entirely when the health score was replaced by
the reference composite. Since then an operator could turn the switch
off, get a saved confirmation, and change absolutely nothing.

The toggle, its wording in all six languages, its place in the flag
matrix the app and the iOS client both read, and its column on
app_settings are all gone. Nothing is left to promise a control that
does not exist.

A guard goes in with it: every remaining assistant sub-flag has to be
read by some surface, and the panel and the plumbing that carry all of
them by construction do not count as proof.
2026-07-31 08:06:07 +02:00
Marc-André Bombeck 96a3743009 Share one server cache between the home page and the API routes
A production build instantiates a module imported from both a Server
Component and a route handler once per bundler layer. The cache registry
sat in plain module state, so the home page's server render held its own
set of Maps and every API route held another. Recording a dose evicted
the route handlers' copy; the home page then rendered from a copy nothing
had touched and painted the dose as still due until the entry aged out on
its own, two or three minutes later. Because that render is dehydrated
into the client query cache, it also overwrote the fresh value the browser
had already fetched.

Measured on `next build && next start`, single process: the page render
and the snapshot route reported different cache instances, and after an
interactive write the route handlers' copy dropped to zero entries while
the page's copy stayed full.

The registry now lives on globalThis under a Symbol.for slot, which is the
only scope both layers share. db.ts guards its own global assignment with
a non-production check because it is only defending against dev reloads;
this one has to hold in production, so it is unconditional.
2026-07-31 08:03:21 +02:00
Marc-André Bombeck 8b3c0369d1 Merge work/vorsorge-due-day zone fix 2026-07-31 08:01:54 +02:00
Marc-André Bombeck d0a42908fe Hold the due phrase to the profile clock and to whole calendar days
Moves the host zone under the function to prove the answer does not follow it, since the suite otherwise runs pinned to UTC and would not have noticed. Adds the two days a year that are not twenty-four hours long, where counting hours rounds the evening into tomorrow. The structural check that the shared body never reads the device clock was looking for the wrong shape and let a deliberate break through; it now looks for the word itself.
2026-07-31 08:00:28 +02:00
Marc-André Bombeck 8e6880b04c Say when the onboarding profile step could not save a field
The baseline step submitted what you filled in and ignored the answer.
The profile write is field-independent: it stores everything that
validated and reports back what it declined. Nobody read that report
here, so a value the server refused vanished between the input and the
next screen and you moved on believing it was saved.

It now says which field did not make it, using the name that sits next
to the input you typed into, and keeps going with the rest that did
save. When nothing could be saved at all, the step stays put and names
the field that blocked it instead of a shrug. The wording and the
field-naming come from the same place the account form in Settings
uses, so the same refusal reads the same wherever you hit it.
2026-07-31 07:58:45 +02:00
Marc-André Bombeck e1ca6d9da2 Work out the checkup due phrase on the same clock as the date beside it
The phrase read the device clock while the date printed under it read the profile clock. Set a laptop to another zone and the card could say a checkup was tomorrow above a date that was today. The zone is now something a caller has to state, and both screens state the one their dates are printed in.
2026-07-31 07:55:20 +02:00
Marc-André Bombeck f322736074 Merge work/vorsorge-due-day 2026-07-31 07:50:14 +02:00
Marc-André Bombeck c1b372de9c Stop a second copy of the checkup due line from appearing
Two screens once carried their own copy of the same date arithmetic and quietly disagreed for a year. This fails the moment a second one is written, whatever it is named, and says where the real one lives.
2026-07-31 07:48:57 +02:00
Marc-André Bombeck 0471c7cb9b Merge work/dose-taken-lag 2026-07-31 07:48:39 +02:00
Marc-André Bombeck d432842a73 Stop the dashboard answering a refresh with a reading it fetched earlier
The dashboard starts its snapshot request the moment the router commits to
"/", and parks the response in a slot outside the query cache until the
mounted cell asks for it. The cell only asks on its first fetch, so on every
return to the dashboard — where it already holds data and does not refetch on
mount — the parked response simply sat there unclaimed.

The next thing to call the query function was then whatever came along: the
two-minute poll, a focus refetch, or the invalidation that follows recording
something. All three exist to fetch data newer than what is on screen, and all
three were handed a response that had been requested before the entry was
written. So a value recorded within a minute of arriving on the dashboard
could keep reading as the old one until the following poll replaced it.

Only preload when no snapshot cell has produced data yet. That is the cold
first load the preload was built for, and it is the only moment the mounted
cell will claim the response promptly.
2026-07-31 07:47:00 +02:00
Marc-André Bombeck 70cab3c885 Pin the checkup due line to calendar days
The cases that used to read wrong: an evening look at this morning's checkup, a Sunday early-hours checkup seen late on Friday, a Saturday late-night one seen just after Friday midnight, and one from yesterday that only slipped past a few minutes ago. Each is a case where counting hours and counting dates give different answers.
2026-07-31 07:46:55 +02:00
Marc-André Bombeck a2497cf21d Make the checkup due line say the same thing on both screens
The dashboard summary card and the checkups page each worked out "today",
"tomorrow", "in N days" and "overdue by N days" from the same due date, and
each did it with its own copy of the arithmetic. The page compares calendar
days. The card subtracted one instant from the other and divided, so a checkup
due Sunday at 01:00 read "tomorrow" when you looked on Friday evening, one due
Saturday at 23:59 read "in 2 days" when it was in fact tomorrow, and one due at
09:00 quietly stopped saying "today" as the afternoon wore on.

Both now read from one place, so they cannot drift apart again.
2026-07-31 07:44:20 +02:00
Marc-André Bombeck 1a0d2795c3 Merge work/backup-severity 2026-07-31 07:44:09 +02:00
Marc-André Bombeck 8f10ae011d Merge work/reminder-midnight 2026-07-31 07:44:09 +02:00
Marc-André Bombeck 92da02f824 fix(backup): carry how hard a symptom hit through export and restore
A cycle symptom can be recorded with a 1-4 intensity. The log-day sheet
writes it, the day-log reads it back, and the backup never carried it:
the file held the symptom keys and nothing else. Export a day recorded
as "cramps, and they were a 4", restore it, and you get "cramps". The
symptom is there, the number is gone, and nothing fails, which is why
nobody noticed.

The file now carries `symptomSeverities` alongside `symptomKeys` — one
entry per symptom that was actually rated. `symptomKeys` still decides
which symptoms a day had, so a release that only knows that field reads
every symptom out of a new file unchanged, and the two lists can never
disagree about presence. A link nobody rated is simply not listed: no
entry means never rated, which is not the same as rated zero.

A file written before this restores exactly as it did, with every
intensity absent rather than invented. An intensity outside 1-4, from a
hand-edited file or some later scale, costs that one number and leaves
the symptom and the rest of the restore alone.
2026-07-31 07:37:48 +02:00
Marc-André Bombeck 8488092d48 test(medications): prove a dose taken before midnight really stops reminding
The overnight reminder tests handed the check every intake row the
fixture held, whatever date range it had actually asked for. That left
the suppression assertion unable to fail. Narrow the read back to the
day the check happens to run in and the 23:45 dose logged at 23:50 drops
straight out of it, yet the test stayed green. The fake now honours the
scheduledFor range it is given, so the assertion answers the question a
person really asks: does it stop pestering me about a dose I already
took.

Two cases were missing at the other end of the same boundary. A dose
genuinely missed at 23:45 still has to escalate in the small hours, and
the missed-dose row it leaves behind has to name the evening it belongs
to rather than the morning the check runs in. Both are pinned now, next
to the case where the dose was taken and no missed row may be written at
all.
2026-07-31 07:36:17 +02:00
Marc-André Bombeck 340d1db6d9 Merge pull request #669 from MBombeck/release/v1.34.3
release/v1.34.3
2026-07-31 07:12:59 +02:00
Marc-André Bombeck f7d846458a fix(nutrients): never let the empty-state explanation break the read it explains
The last-attempt lookup runs on the zero-rows path, which is exactly the path a
fresh account takes. When it threw, the whole nutrients read returned 500 — so an
account with nothing to show got an error instead of an empty page, and the
feature that exists to explain an empty surface was the thing preventing it from
rendering.

Wrapped so a failed ledger read degrades to the plain empty state and is recorded
for the operator rather than shown to the person. The route's shape assertion now
carries `lastAttempt: null` explicitly, so a field cannot join this response
without someone deciding it should.
2026-07-31 05:09:36 +02:00
Marc-André Bombeck 4c35c16d64 chore(release): v1.34.3 — say the thing the surface already knew
Thirty-four changes, most of them one shape: something the server or the screen
already knew and did not say. A closed schema that omitted three fields it sends.
A fence around sensitive data that the contract never described, so the native
client dropped an affordance rather than risk crossing it. An ingest whose only
durable trace fired on success, leaving "did the phone even post" unanswerable. A
profile save that discarded valid fields to punish an invalid neighbour. A card
titled "that day" that received the day and threw it away.
2026-07-31 01:27:29 +02:00
Marc-André Bombeck 9b69be8ab5 fix(openapi): declare shrunkR and tier on DiscoveredCorrelation
The correlation-discovery engine always sets shrunkR (the
sample-size-shrunk effect) and tier (the phrasing-confidence class)
on every surfaced pair, but the closed DiscoveredCorrelation schema
never declared either — same defect class as the dashboard snapshot:
additionalProperties: false rejecting a field the runtime actually
sends. Both are required, matching src/lib/insights/correlation-discovery.ts.
2026-07-31 01:26:21 +02:00
Marc-André Bombeck d04eab71a3 fix(openapi): declare the dashboard snapshot fields the route actually emits
GET /api/dashboard/snapshot is additionalProperties: false in the spec
but the runtime emits three blocks the schema never declared: the
band/target math (targetBands, always present), the score-card
narrative (healthScore.tension / healthScore.returnToBand, additive
optional fields), and the briefing recall/forward-look
(briefingMemory). A strictly generated client rejects the real
response as a result.

Adds all four to dashboardSnapshotResponse with the exact runtime
type, optionality, and nullability, matching DashboardSnapshot in
src/lib/dashboard/snapshot.ts and the DTOs in
src/lib/dashboard/score-narrative.ts. No route behaviour changes.
2026-07-31 01:26:21 +02:00
Marc-André Bombeck d8ddf97d9e test(step-up): wait for the audit breadcrumb instead of sleeping and hoping
Five assertions slept a fixed 100 ms and then read the audit ledger. The write is
fire-and-forget, so on a busy runner the read lands first and the assertion fails
with "expected [] to include 'consumed'" — which reads as a broken refusal when
the refusal worked and only its breadcrumb was still in flight. CI failed on
exactly that while nothing in the branch touches step-up or MFA.

Replaced with a poll that returns the moment the reason appears and, on timeout,
asserts against the real list so a genuine regression still says what WAS
written rather than reporting a bare timeout.
2026-07-31 01:12:22 +02:00
Marc-André Bombeck cbfbf359e7 test(nutrients): assert the audit fires on every ingest, not only on a landing
The existing test encoded the behaviour this release deliberately changed: the
breadcrumb used to fire only when a row landed, which made the one durable trace
of an ingest conditional on its success. It failed for the right reason.

Rewritten to assert the new intent with the reasoning kept, and it goes red again
if the condition returns.
2026-07-31 01:09:52 +02:00
Marc-André Bombeck 12d3239de0 feat(nutrients): tell the account holder when a sync arrived but nothing landed
GET /api/nutrients now looks up the most recent nutrient.batch.ingest
audit row when the window is genuinely empty, and surfaces it as
lastAttempt only when that call landed nothing (inserted === 0 &&
updated === 0). A real success elsewhere is left alone, so an empty
window for an unrelated reason never gets reframed as a failure.

The lookup runs on the zero-rows path only, never on the happy path,
and stays counts-and-reason-codes only - no user-typed content, no
per-entry health values. The reason code never reaches the page as
text: each of the four closed skip reasons maps to its own human
sentence, and the two that have no useful action for the account
holder (unit_mismatch, upsert_failed) say plainly that the entries
couldn't be stored rather than inventing advice. day_invalid is the
one case worth naming a real cause for - a clock or time zone mismatch
on the sending side - without claiming it was a phone, since any
token holder can call this route.

This depends on AuditLog retention: cleanupOldAuditLogs prunes rows
past 365 days by default, so a dormant account's last failed attempt
can age out and the explanation silently reverts to the plain empty
state. Documented at the lookup for whoever next touches that job.
2026-07-30 23:30:28 +02:00
Marc-André Bombeck 3ec3a8c6aa fix(profile): stop echoing the raw save error verbatim on Settings
The Settings > Account save handler read json.error straight off the
wire and rendered it as-is - the exact path a raw validator message
would have reached a person's screen through, and the only place the
report's symptom actually reproduces (baseline-form already goes
through the errorCode-aware localizedApiError helper).

It now resolves meta.errorCode into a localized sentence, the same
convention used everywhere else in the app, and never touches the
top-level string. A partial save (some fields landed, one didn't)
gets its own message naming the rejected field via the same label
already shown next to that input on this form, and reseeds the
form from what the server actually persisted so a field that was
silently skipped can't read as saved.

describeRejectedProfileField lives in account-section-utils so both
the warning and the failure path share one field-name-to-label
mapping instead of duplicating it.
2026-07-30 23:25:18 +02:00
Marc-André Bombeck 356888134d fix(profile): localize the email-conflict message too
The 409 email-conflict branch had a safe, specific message already,
but no errorCode - the account-section fallback would have had nothing
to translate it through once it stopped echoing json.error verbatim.
Give it profile.update.emailInUse so it renders in the active locale
like every other profile-save outcome, and propagate errorCode through
both route handlers even on the (non-issues) conflict path.

Also updates the /api/user/profile route test for the reported
scenario directly: a valid timeFormat now lands even when gender in
the same PATCH is unsupported, instead of the whole request failing.
2026-07-30 23:25:18 +02:00
Marc-André Bombeck 6f5935aeb4 i18n(profile): add strings for the partial-save and nothing-saved cases
apiErrors.profile.update.{nothingSaved,invalidBody} let the web client
resolve applyProfileUpdate's new errorCode into a localized sentence.
settings.profilePartiallySaved / profileNothingSaved carry a {field}
placeholder so the account settings screen can name the specific field
that blocked or was skipped, in the active locale, instead of a raw
validator string. All six locales.
2026-07-30 23:25:18 +02:00
Marc-André Bombeck e49bd866dd fix(profile): save every valid field even when a sibling is invalid
A profile PUT/PATCH validated as one safeParse and wrote as one
transaction, so a single rejected field took every sibling change down
with it - editing height and gender together lost both, and onboarding
lost a whole baseline step to one field. The schema has no cross-field
dependency, so applyProfileUpdate now re-validates each present field
against its own schema and writes whatever validates; only a body where
nothing validates is refused outright, naming the field that blocked it.

The top-level error string also stopped being a raw Zod message
(issues[0].message) that could read "Invalid option: expected one of
"MALE"|"FEMALE"|"OTHER"" on a person's screen. It's now a person-safe
sentence plus a stable errorCode for the web client to localize; the
full per-field detail is relocated (not deleted) into details.issues,
matching the multi-issue envelope the iOS contract already expects from
other write routes.
2026-07-30 23:25:18 +02:00
Marc-André Bombeck bed634fd67 Put the new workout blocks on the page's reading edge
SubPageShell insets its own heading to the card body edge rather than the
container edge, so a section heading or a meta line rendered flush left sat
16 px to the left of everything above it. The day-context heading, the sport
chips and the count line now take the same responsive inset.
2026-07-30 23:23:37 +02:00
Marc-André Bombeck 0784cbcbb1 Say how many workouts the list holds and let a sport narrow it
The list hook already returned the canonical total and the page threw it
away, so the surface never said how much history it was showing. Render it
as a meta line above the rows.

The route and the hook already accepted a sport, with nothing to reach it.
Add the chip row. Its vocabulary comes from the unfiltered read, which is
the same query the list itself runs when no sport is chosen, so the row
costs no extra request and choosing a sport can never empty the row it was
chosen from. A filtered view falls outside the server prefetch and pays one
client fetch; a filter that matches nothing says so instead of borrowing the
first-run empty state.
2026-07-30 23:23:37 +02:00
Marc-André Bombeck 52cccb3835 Move the workout zone range label onto the type scale
The bpm range under each effort zone sat at an arbitrary text-[10px]. It is
a micro-label on a card, not a chart axis, so it takes the text-2xs token
the standards mint for exactly this.
2026-07-30 23:23:37 +02:00
Marc-André Bombeck a61cdae437 Offer the previous session of the same sport from the workout page
"How does this compare to last time?" is the most likely next question
after reading a workout, and answering it meant going back to the list and
scanning for the one before.

Resolve the previous session server-side, through the same canonical picker
the list uses, so the link can never land on a duplicate row recorded by a
second watch, and render it beside the own-history average line. Null when
nothing older exists, and then no link is shown.

The stats card's hand-rolled heading moves to the icon-less TileHeader
while the file is open.
2026-07-30 23:23:37 +02:00
Marc-André Bombeck 76b407b6de Render the day around a workout instead of linking away from it
The "That day" card took the workout, dropped it, and offered three chips
to Pulse, Sleep and Mood. None of those pages accepts a date, so every chip
landed on an all-time trend surface with the day nowhere in sight.

Replace it with the day itself: the intraday pulse chart pinned to the
session's day, the night the person woke from into it, and that day's mood
entries. All three reads are addressed by the server-resolved dayKey, so
nothing here decides which day a workout belongs to.

The two outbound links that remain are undated and say what they lead to,
Sleep trends and Mood history. Sleep and the intraday chart mount only when
their module is on, so a switched-off module cannot paint as a failed read,
and a query that has not answered yet shows a skeleton rather than claiming
the night was empty.

The detail page also drops the description it borrowed from the list.
2026-07-30 23:23:36 +02:00
Marc-André Bombeck 0094445f07 Date a workout by the user's timezone on the detail endpoint
The detail payload carried the session's start timestamp but nothing that
said which local day it belongs to. Anything that wants the day around a
workout had to slice the ISO string, which is the browser's day, not the
person's: a 20:00 UTC session reads as the 15th in London and the 16th in
Auckland.

Resolve the day server-side from the user's timezone and ship it as
dayKey. Additive, so a native client that does not read it is untouched.
2026-07-30 23:23:36 +02:00
Marc-André Bombeck a4cffbbea5 Keep the pillar reference formatter module-private
Only `pillarDetailLines` composes it, and the card reads the lines
rather than the individual band string.
2026-07-30 23:20:02 +02:00
Marc-André Bombeck eb55c235c6 Rebuild the Insights health score as one pinned card
The score had two mounts: a compact face in the hero band and a
nine-block stack further down the overview. The stack also rode inside
the daily-briefing branch, so an instance with the briefing switched off
lost the whole breakdown.

Fold both into one progressive-disclosure card pinned between the
greeting and the customizable region. Collapsed it reads as the score
face: the ring, the band sentence, the weekly delta or the honest reason
there is none, Rest Mode, the tension and return-to-baseline lines, and
the coverage meter. Opening it adds a three-across pillar grid, the
named list of pillars that are not scored yet, the personal weight goal,
and the method footer with its cited standard.

The card is deliberately not a section in the insights layout registry:
an id a saved layout has never seen merges in as default-invisible,
which would have deleted the score for every account that has ever
customised the page.

A pillar the server gated stays visible by name with its reason instead
of being drawn as a zero, safety signposting leads that list, and a
pillar whose read failed gets an error row with a retry rather than
silence. The hero band goes back to the full-width greeting, the shape
it already had for an account without a score.
2026-07-30 23:20:02 +02:00
Marc-André Bombeck 845b89255d fix(nutrients): audit every batch ingest call, not only the ones that wrote a row
nutrient.batch.ingest only reached the durable AuditLog table when
inserted + updated > 0, so a batch where every entry was rejected
(unit mismatch, out-of-range value, invalid day, a write failure) left
the exact same empty trail as a phone that never called the endpoint
at all. That made an empty nutrient_intake_days table unanswerable
from the durable record - the question this surface exists to answer.

Record the call unconditionally, the same shape measurement.batch.ingest
and workout.batch.ingest already use (counts only, no per-entry health
values, no user-typed content), plus an aggregate skip-reason breakdown
so an operator can tell, from the row alone, that the phone posted and
why nothing landed.

HTTP status and the per-entry response shape are unchanged - a batch
endpoint returning 200 with per-entry outcomes on a partial failure is
the correct, documented contract and native clients already decode it.
2026-07-30 23:18:48 +02:00
Marc-André Bombeck a1c5d78f72 fix(nutrients): stop the /insights/nutrients overview swallowing a failed read as absence
The page's overview query shares queryKeys.nutrientIntake(30) with
MicronutrientsCard, which already renders its own QueryErrorCard on a
failed read. But the page's empty-state check ran first and treated
undefined data (the failed-read state) the same as a genuinely empty
window, returning "no nutrient data yet" and short-circuiting before
any of the three cards - including the already-correct one - ever
mounted. A self-hoster with HealthKit permissions granted and a read
that actually failed saw the same blank invitation as someone who
never logged anything.

Add the isError branch above the empty-state check so a failed read
surfaces its own QueryErrorCard with a retry, and cover it with a test
that renders the actual page (not just the card in isolation) so a
regression here can't hide behind a passing unit test again.
2026-07-30 23:11:36 +02:00
Marc-André Bombeck 37496f0083 docs(openapi): add the account-wide import job-status poll, fix a second dangling schema
GET /api/medications/intake/dose-history-import/{jobId}/status reads
through the same readMedicationIntakeImportJob projection as the
per-medication route, so both 200 responses now point at one shared
MedicationIntakeImportJobStatusEnvelope object instead of two
independently generated copies.

Audited every other route description for a schema named by id that
never resolves to a components.schemas entry. Found one more:
CoachPrefs is named in the coach-prefs PUT description, but every
actual consumer is a .extend() of it, so the base never carried
forward to a $ref. Force-registered it the same way as the
dose-history fatal-reason enum. The remaining unresolved meta ids are
request/query schemas zod-openapi inlines rather than hoists — none of
them are named by id in another schema's prose, so nothing points a
reader at a component that silently isn't there.
2026-07-30 20:12:06 +02:00
Marc-André Bombeck 8b40066f42 docs(openapi): add dose-history import and its job-status poll
Documents POST /api/medications/intake/dose-history-import and
GET /api/medications/{id}/intake/import/{jobId}/status, including the
full 16-value MedicationImportSkipReason set the v1.33.0 shape widened
to, read straight from MEDICATION_IMPORT_SKIP_REASONS so the two never
drift again.

Also force-registers MedicationDoseHistoryImportFatalReason into
components.schemas: the 422 description names it, but nothing else
$refs it, and zod-openapi drops unreferenced schemas silently.
2026-07-30 20:03:26 +02:00
Marc-André Bombeck 02c6658a35 docs(openapi): add on-demand provider sync and Google Health status routes
Documents POST /api/oura/sync, /api/polar/sync, /api/strava/sync,
/api/nightscout/sync (all four share the resolveSyncOutcome shape) and
GET /api/google-health/sync/status, matching the routes' own
sanitising/clamping logic field for field.
2026-07-30 20:03:26 +02:00
Marc-André Bombeck b7074e04b8 docs(openapi): add GET/PUT /api/auth/me/report-selection
Documents the saved doctor-report scope endpoint, reusing the real
savedReportProfileSchema so the spec's leaf cap and version literal
stay tied to the source of truth.
2026-07-30 20:03:26 +02:00
Marc-André Bombeck ec2c8a9e71 docs(openapi): publish documentOnly on share-link create and list
The field has been served on both routes since v1.28.13 but was
missing from ShareLinkCreated and ShareLinkList, which are
additionalProperties: false.
2026-07-30 20:03:26 +02:00
Marc-André Bombeck c9c824ca4d Publish share-selection group membership in capabilities
GET /api/meta/capabilities dropped group membership at the wire: it
shipped a flat group-id list and a flat leaf list with no boundary
between them, so a native client could not tell which leaves live
under which group, or which group is the fenced sensitive tier. That
made it unsafe for the client to offer a "select all" affordance for
any group, since it could not mirror the server's fence against
bundling more than one sensitive leaf behind a single control.

Add SHARE_GROUPS to the report-selection catalogue, derived from the
existing REPORT_GROUPS/MEASUREMENT_LEAF_GROUP/STRUCTURED_LEAF_GROUP
maps: each group now carries its member leaf ids, and the group whose
id equals SENSITIVE_GROUP_ID carries sensitive: true. share.groups in
the capabilities response now ships this shape instead of bare ids;
share.leaves is unchanged. Update the OpenAPI schema and regenerate
docs/api/openapi.yaml.

Add an invariant test asserting every leaf in the flat leaves set
appears in exactly one group and no group carries a leaf outside that
set, plus a check that exactly one group is flagged sensitive.
2026-07-30 19:54:49 +02:00
Marc-André Bombeck b767cff187 build: carry the heap ceiling in the build script itself
`next build` needs more than Node's 4 GB default: the TypeScript pass after
compilation runs the whole project through the checker and dies with
"Ineffective mark-compacts near heap limit" at around 4.07 GB.

CI and the production image already knew that. `e2e.yml` and the Dockerfile both
invoke the build with `--max-old-space-size=8192`. The bare `pnpm build` did not,
so the one place that ran with the default was a local gate — which then failed
for a condition that exists in neither environment that matters, and would have
kept failing every time somebody ran the documented gate.

Three places declared how the build is invoked and one of them disagreed. The
script now carries it, so a local run, CI and the image all get the same ceiling.
The existing prefixes in the workflow and the Dockerfile stay: they are harmless
now, and removing them would put the knowledge back in more than one place.
2026-07-30 18:35:38 +02:00
Marc-André Bombeck 9a5206ee5f docs(openapi): give rhythm-events its own six-value classification enum
GET /api/insights/rhythm-events was never in the spec, so a client had
nothing to generate a type from but the three-value ECG classification
next door — reusing it would drop VERY_LOW / FIRED. Register the route
with its own RhythmEventClassification carrying all six
RhythmClassification values; the ECG enum stays untouched at three.
2026-07-30 16:06:53 +02:00
Marc-André Bombeck d7badf1512 fix(coach): keep the composer one row tall until someone types
On a phone the composer stacked its controls under the textarea, so an EMPTY
input was two rows tall: a text line, a 44 px tap-target row, and padding. That
is roughly a tenth of a phone screen spent before anyone has typed anything, and
it is the first thing on the screen when the Coach opens.

The auto-grow was never the problem. The textarea already starts at one line and
grows to six; it was the layout around it that reserved the second row up front.

The stacking existed to protect keyboard focus order. That argument does not
hold: focus follows DOM order, and the DOM order is textarea, actions, Send in
both layouts. Nothing jumps. `items-end` keeps the controls beside the last line
as the textarea grows, so the send button stays where the thumb left it.

One component serves both surfaces — the conversation view and the hero, which
re-parents the same live composer — so this is one fix rather than two.

The test that asserted the stacked layout now asserts the row, with the reason
written down, and it fails when the stack comes back.
2026-07-30 15:59:36 +02:00
Marc-André Bombeck 8a2bb70dbd test(ui): gate the two card-header classes that have to agree
The header fix needs two declarations to hold together: the second grid row is
conditional on a description, and CardAction spans the full row set. The second
is the trap. `row-span-2` against a single explicit row mints an implicit second
row and brings the phantom gap straight back for every header that carries an
action but no description.

Reverting it broke nothing. Nothing in the suite asserts a `row-span` class, so
the regression is DOM geometry the unit tests cannot see, and it would have come
back silently at the next tidy-up. This is a literal-class assertion in the shape
`assessment-spine-order.test.ts` already uses, because the question has an exact
answer.

Also finishes the compact-card tier while it is open: three cards on the metric
surfaces still carried `gap-1.5` where the assessment cards carry `gap-2`. One
tier, one value, no `gap-1.5 py-3` left in the tree.
2026-07-30 15:52:21 +02:00
Marc-André Bombeck 14d0eeeb46 fix(ui): stop CardHeader drawing a phantom gap without a description
grid-rows-[auto_auto] was unconditional, so a header with a title and
no CardDescription still reserved an empty second row track and drew
the gap-2 for it — 32px instead of the intended 24px between heading
and body on desktop. Make the second row conditional on a description
being present, and widen CardAction to row-span-full so it still
tracks the header's row count when an action lands in a
description-less card.

Also correct two assessment cards that had drifted onto gap-1.5
instead of the sanctioned gap-2 compact tier.
2026-07-30 15:50:14 +02:00
Marc-André Bombeck a4bb2adcb2 fix(e2e): select the measured wrapper by data-slot, not by birth order
The navigation-geometry spec failed on a merge commit whose tree was
byte-identical to the branch head that had passed minutes earlier, so nothing had
regressed. The spec resolved the element it measures with
`main.firstElementChild`, independently in each of its two `page.evaluate` calls.
Any node mounting ahead of the wrapper between the two reads made it measure two
different elements, which is why the failure was 64 against 131.92 rather than a
two-pixel drift. A timing problem gives a small difference or a null; this was a
substitution.

The shell's content wrapper now carries a stable `data-slot`, both reads select
it by that, and the wait gates on the element rather than on scroll state alone.
The same positional pattern in the overscroll spec is fixed with it, so the class
is gone from the measurement paths rather than from one spec.

Re-running the job made it green, which is a workaround. This is the fix.
2026-07-30 14:35:08 +02:00
Marc-André Bombeck 8d86416072 fix(compose): stop the worker example drifting, and bound logs and memory
The `app` service declares 62 environment variables. The commented-out
`app-worker` example declared 14, missing every SMTP, VAPID and APNs value plus
the AI-host allowlist and the offline geo directory. The worker is the process
that sends notifications and runs the AI jobs, so anyone following the documented
split got a worker that could not email, push or send APNs, and nothing failed
loudly. It just did nothing.

Hoists the shared block to a YAML anchor both services merge, so copy-paste
cannot drift again. The resolved configuration is unchanged: 62 variables on
`app` and 3 on `db` before and after.

Also:

- The commented GeoLite2 mount pointed at a directory the default GEOLITE2_DIR
  does not read. Uncommenting the example as written did nothing. The two now
  agree.
- Bounded log rotation on both services. The default json-file driver grows
  without limit and fills the disk on exactly the small hosts this file targets.
- `cap_drop: [ALL]` on the app, and a written reason why the database does not
  get it: the Postgres entrypoint starts as root and needs CHOWN/SETUID/SETGID
  to hand off to its own user. Dropping capabilities there breaks startup, which
  was confirmed rather than assumed.
- A memory limit on the app, and a written reason why the database has none. A
  bounded app container restarts clean when the kernel takes it; a database
  killed mid-write does not.

`read_only: true` stays off for now: the app writes a runtime cache and that
needs testing before it can be turned on.
2026-07-30 14:34:55 +02:00
Marc-André Bombeck 207010e8fa docs(deploy): warn that an image pin is sticky and a stale pin is silent
The digest-pinning section explained how to set HEALTHLOG_IMAGE_REF and never
said what happens if it is left behind. It stays until someone changes it, and
every deploy in between reports success while the running instance holds the
previous release.

Adds the warning, the two post-deploy checks that would have caught it (the
version endpoint AND the running container's image, because the first alone can
be served from a cache), the note that a suspiciously fast deploy is a signal,
and the duplicate-key trap: the platform accepts two rows with the same key
without warning and does not define which wins.
2026-07-30 14:30:19 +02:00
Marc-André Bombeck a8b61abf6f feat(cycle): drop the standing disclaimer from the cycle-tracking settings card
The card carried a four-sentence non-medical notice at the top of the cycle
on-ramp. It goes, at the maintainer's decision.

The goal-specific caveat on the prediction surface is untouched: a profile set to
avoid pregnancy still gets the stronger "not a contraceptive method" copy, which
is chosen server-side by goal and shared with the native client, so nothing about
that path changes here.

Removes the key from all six locales and the now-unused icon import.
2026-07-30 14:30:18 +02:00
298 changed files with 26492 additions and 3680 deletions

No files matched your search

+328 -79
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+17
View File
@@ -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
+40
View File
@@ -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}`) {
+50 -11
View File
@@ -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() },
},
+136
View File
@@ -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);
});
});
+12 -2
View File
@@ -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
+293
View File
@@ -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
View File
@@ -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();
}
+131
View File
@@ -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.
+11
View File
@@ -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;
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+300
View File
@@ -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 },
+6 -1
View File
@@ -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([]);
});
});
+41 -8
View File
@@ -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);
});
});
+31 -43
View File
@@ -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
+7
View File
@@ -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", () => {
+6 -1
View File
@@ -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 -3
View File
@@ -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"]);
});
});
+7
View File
@@ -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,
});
});
+1 -2
View File
@@ -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",
]);
});
});
+10 -1
View File
@@ -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;
+7 -5
View File
@@ -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 () => {
+34 -10
View File
@@ -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,
},
});
+116 -1
View File
@@ -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 });
});
+11 -20
View File
@@ -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");
});
});
+1 -21
View File
@@ -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" }),
]);
});
});
+6 -1
View File
@@ -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 } : {}),
});
});
+59
View File
@@ -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,
});
},
);
+99 -1
View File
@@ -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(
+85 -10
View File
@@ -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")}
+46 -48
View File
@@ -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
+11 -4
View File
@@ -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
+93 -12
View File
@@ -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>
);
}
+2
View File
@@ -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 `&#x27;` in text nodes. */
function htmlEscaped(text: string): string {
return text.replace(/'/g, "&#x27;");
}
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.
+3 -17
View File
@@ -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();
});
});
+16 -8
View File
@@ -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
+16 -27
View File
@@ -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>
+26 -28
View File
@@ -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}
+25
View File
@@ -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
+5 -1
View File
@@ -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,
});
-259
View File
@@ -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"');
});
});
+36 -98
View File
@@ -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