release/v1.35.0 - #672
Merged
Merged
Conversation
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
The composite fixture mirrors the real payload field for field, so it gains the resolved flag alongside the composition it sits next to.
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.
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.
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.
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.
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.
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The Health Score stops being a by-product of switches meant for something else.
What changes for a person
Verification worth naming
configureda required field precisely so a caller cannot stay silent, and another branch's tests predated it. Each call site now states what it actually is rather than being filled in to satisfy the compiler.Contract
healthScoregainscompositionandconfigured, both additive. A native client that shows the number alone needs to know it can come from an authored recipe; a coordination ticket is filed.Honest gaps
Nothing here was rendered in a browser as a running application. The deselection swing is bounded arithmetic (
|mean − pillar| / (n−1)), so sparse accounts move much further than rich ones; that shape is understood but has not been measured across a real population.Full gate green on the assembled branch: typecheck, lint, format, 19474 tests, build.