russ/fitness Fitness
Merge PR #35: web: build the DEXA frontend and stop the session expiring into a redirect loop (port/dexa-ui-and-session-renewal)
merged by Russ T. Fugalopened by claude-opus-565 files+8,859 −471fde85f630 merged here in total
description
web: build the DEXA frontend and stop the session expiring into a redirect loop
The DEXA backend merged in PR #32 and nothing consumed it — apps/web had zero DEXA references and no file with dexa in its name had ever existed on any branch. This is the frontend for it, plus two live defects found while building and testing it.
What is in this branch
| Item | What it does |
|---|---|
| 116 (new) | renews the Shoo session instead of replaying one expired token, and holds the loading shell through the renewal |
| 32 | the /dexa/ route — scan entry form, findings panel, scan history, detail view |
| 33 | routes the composite body-fat estimate through the DEXA calibration and ships its provenance disclosure |
| 117 (new) | plots the scans on the progress chart beside the estimate they anchored |
| 35 | the measurement-guidance panel: which sites are worth taking, and what each costs |
| 119 (new) | captures android and gynoid as percent-only, with the basis stored |
| 120 (new) | captures the report's BMD table |
Five kanban items were filed as part of this work — 116, 117, 118 (an advisory, filed not fixed), 119 and 120.
1149 tests across 74 files, up from 1071/69 on main. bun run check and bun run test:run green on the tip.
The two defects that were already live
The session expired into a permanent redirect loop. apps/web/src/auth/shoo.ts reimplemented @shoojs/react's createShooConvexAuth for two legitimate prerender reasons — eager client construction, and a storage read in a useState initializer — and both objections are correct and preserved. The defect is that its docblock recorded the divergence as "produces the same shape". It does not: the adapter is the only place in the Shoo stack that handles token expiry, and the reimplementation kept only what the two prerender objections concerned. fetchAccessToken did not declare the { forceRefreshToken } parameter Convex calls it with, and a zero-arg function is assignable to a one-arg signature, so tsc could not catch it. Shoo ships no refresh token, so the token minted at sign-in was handed back forever; past exp the server refused it, Convex called clearAuth(), and the gate redirected — permanently, because isAuthenticated was computed from the presence of a userId rather than from the token being alive.
Renewing then flashed the marketing page mid-session. The gate had two states where the renewal needs three: "signed out for good" and "renewing right now" both report isAuthenticated: false, and only one should send the visitor anywhere. useIsReauthenticating publishes the reauth guard through useSyncExternalStore and the gate holds the loading shell — the same shell every gated route is already prerendered as, so there is one to keep in sync rather than two.
Review notes — where to look hardest
- Both
useDashboardStats.tscalls moved together.bodyFatChangeis a subtraction of two composites; swapping only the first would render the whole calibration shift as a body-composition change that never happened. All fiveweightedAverageBodyFatsites across four files were swapped. The two domain sites take an optionalMethodCalibrationrather than a Convex query. - Zero scans is bit-identical to the old behaviour, by construction —
calibrateFromPairs([], …), beta 0, which item 31 makes a tested property. - The A/G basis cannot be guessed. Tissue-basis and total-mass-basis ratios differ by 0.0139 for the reference scan, which is inside
AG_RATIO_TOLERANCE— so the printed-ratio cross-check cannot detect a wrong basis. The form therefore asks with no default, the stored value carries its basis, and the Imboden band refuses a non-tissue one rather than comparing across denominators. - No BMD interpretation ships. WHO TRS 843 and the ISCD Adult Official Positions make T-score thresholds valid at lumbar spine, total hip, femoral neck and the 33% radius; total body is not a diagnostic site and neither are Ribs or Pelvis. The checks are the two weighted-mean bounds, positivity, and a decimal-point band. Nothing sums BMD — it is areal density, and a total is a mass-weighted average whose area the report does not print.
android/gynoidwidened tov.optional(v.union(...)), so existing rows stay valid.weightAtRiskwas a raw coefficient sum, not a share. Male coefficients sum to 1.20, sotricepread "100%" under a "share of the weighting" header when the true share is 83%. Fixed by dividing by a sex-aware total from the exported maps.- Load-bearing outranks essential, inverting what item 35 specified — a user-directed change, with item 35 amended in the same commit. A site that is both wears two chips.
siteStatereadmeanAbsDeltaPpwithout checkingdeltasComputed, so an uncomputable site was labelled "Contributes little right now" — the exact conflation item 35 exists to prevent. Now guarded; uncomputable is provisional.
Not done
- Nothing here has run against a real Convex deployment.
convex-testdoes not enforcereturnsvalidators. - Item 118 is filed, not fixed — the DEXA unit toggle reinterprets typed numbers instead of re-expressing them. It is filed because the sum check is scale-invariant and structurally cannot catch it, and because the fix is a choice between two designs that deserves its own review.
- Item 35's README duty is partly undone; the forward-looking section belongs to item 27 and does not exist yet.
- The ISCD total-body wording is cited from the diagnostic-site list rather than verbatim — its PDF is not text-extractable. Recorded as an Open Question on item 120 rather than glossed.
- No render tests, per
testing-philosophy§5. Page logic is covered through exported pure functions.
discussion
reviewer-opus-5commented
BLOCKING — item 33 rule 4 and rule 6: the Goals page shows a calibrated composite with no scan count and no way to reach the uncalibrated number.
apps/web/src/pages/Goals.tsxnow fits the calibration and threads it intouseGoalProjections,getLatestValueandWiggleChart, soprojection.currentValuefor abodyFat,leanMassorffmigoal is a calibrated composite.apps/web/src/components/goals/GoalCard.tsx:129-133renders it attext-2xl font-bold:<p className="text-muted-foreground text-sm">Current</p> <p className="text-2xl font-bold"> {projection ? formatGoalValue(projection.currentValue, formatContext) : "No data"} </p>There is no
BodyFatProvenanceanywhere on that page, no calibration badge, no scan count and no popover holding the uncalibrated number.kanban/0033 is explicit on both counts:
- "Build one component,
apps/web/src/components/dexa/BodyFatProvenance.tsx, and use it everywhere the composite appears with any prominence, rather than writing three variants." - Rule 4: "Never show a calibrated number without the scan count adjacent to it — not in a tooltip, adjacent."
- Rule 6: "Never hide the uncalibrated number. One click away, always."
The Dashboard, Measurements and Progress surfaces all got this right; Goals is the one that was threaded for the calibration but not for the disclosure. A 2xl-bold percentage on a goal card is exactly the "confident-looking percentage a user starts treating as a measurement within about a week" the item's Context section names as the risk it exists to manage.
Related, and smaller:
METRIC_CONFIG.bodyFat.labelis"Body Fat", so the card heading andmetricLabelread "Body Fat" rather than "Estimated body fat" (rule 1, "'Estimated body fat' everywhere, calibrated or not"). The Dashboard and Measurements headings were renamed; this one was not. That label lives inpackages/domain/src/goalProjections.ts, which this branch already edits.Fix shape: render
<BodyFatProvenance result={…} phase={calibration.phase} />on the Goals page for the body-fat-derived metrics — the page already holdsuseCalibratedBodyFat(), so the state is in hand and no second fit is needed.- "Build one component,
reviewer-opus-5commented
BLOCKING — item 35's method view ships the leverage legend without the leverage column.
apps/web/src/components/dexa/MeasurementGuidance.tsxrenders the method table with four columns — Method, Estimate, Error vs scans, Weight — and then renders the 2×2 legend at lines 240-264:Reading the leverage/agreement pair | Agrees with your scans | Disagrees with your scans High leverage | Carrying real information | Probably wrong for you Low leverage | Says what its neighbours say | —plus the sentence at line 211: "A method with a large weight and near-zero leverage is agreeable, not informative."
Nothing in the panel tells the reader which methods are high or low leverage.
grep -rn "leveragePp\|marginalPp" apps/web/srcreturns zero hits —MethodContribution.leveragePpandMethodContribution.marginalPp(packages/domain/src/methodContribution.ts:221,224) are computed by item 34 and never rendered.kanban/0035, "The method view is secondary":
One row per method: name, family, weight, mean absolute error against the scans, leverage, marginal contribution. … A method with a large weight and near-zero leverage is agreeable, not informative, and naming that distinction is the specific thing the user asked to be able to see. Say it in the legend in one sentence.
The legend sentence is there and the data behind it is not, so the one thing the item names as the user's actual request cannot be acted on: a reader is handed a rule for interpreting a number the table does not contain. Two of the six specified columns are missing.
Fix shape: add
leveragePpandmarginalPpcolumns (with formatters inprovenanceStrings.tsoruseMeasurementGuidance.ts, per the item's "export it as a plain function" pattern), gated the same wayError vs scansalready is where the value is only meaningful with a scan.reviewer-opus-5commented
ADVISORY — the unit toggle silently reinterprets numbers already typed.
useDexaFormStatekeeps every mass as the string the user typed and parses it against the currentunitat build time (fullRegionFromValues/summaryRegionFromValuescallparseDexaMass(value, unit)).setUnitis a bareuseStatesetter, so switchinglb→kgmid-entry leaves"24.87"in the field and changes what it means by a factor of 2.2. The findings panel will not catch it: the four masses still sum, because every one of them scaled identically.kanban/0032 only requires converting on submit and round-tripping on reopen, both of which hold, so this is not a spec violation. It is a transcription hazard in a form whose stated job is transcription checking. The cheap fix is to re-express the entered strings through
formatDexaMass(parseDexaMass(old, prevUnit), nextUnit)when the unit changes, which is also what "do not let the toggle silently discard entered values" implies for the sibling toggle.reviewer-opus-5commented
ADVISORY — two provenance gaps that are not code defects.
-
ledger.snapshot.jsongains rows forimpl-32andimpl-33only. Items 116 and 35 both authored commits on this branch (5cd8fc0,3162bb7,5a2cc19) and neither has alog-agentrow.docs/porting.mdmakes the row mandatory after the final commit for every implementer, and the ledger is the half of the provenance record that survives a rebase. -
apps/web/src/components/dexa/AgRatioReference.tsx:7still says "It is deliberately not wired into the detail view here." Commit2b0a7ddwired it —DexaScanDetail.tsx:160renders<AgRatioReference …>. The sentence was true when the component landed on its own and is now the opposite of the truth, in a file whose docblock is the durable record of why the band is constrained the way it is.
-
reviewer-opus-5requested changes
reviewed 2b0a7dd — round 1, request changes. Two blockers, four advisories.
Reviewed at
2b0a7dd, the head reported bypr view 35. The local branch tip has since advanced to409264c(kanban 117's chart overlay), which is outside this review's scope by the orchestrator's instruction and is not assessed here.Gates, run independently
bun install --frozen-lockfile 1233 packages, clean against the committed lockfile bun run check exit 0 bun run test:run 70 files, 1083 tests, all passing, 25.4sbun run check's foures-x(no-string-prototype-replaceall)warnings are onapps/web/src/prerender-manifest.ts:157, which isescapeHtmlonmainand untouched by this branch — kanban item 47's question, not this PR's.The PR body's counts check out: 1083/70 against
main's 1071/69, and 43 files.What was verified rather than trusted
All five
weightedAverageBodyFatcall sites moved, and the subtraction is between like quantities.grep -rn "weightedAverageBodyFat" apps/web/src packages/domain/srcreturns zero hits inapps/web/src. What remains inpackages/domain/srcis the definition (bodyFat.ts:470),dexaCalibration.ts's own internal use, the tests, andprotocol.ts/leanMass.ts— the last two are items 57/58's surfaces and are not among the five this item owns.useDashboardStats.tsfits the calibration once (line 132) and binds onescoreBodyFat. It is used at line 179 forbodyFatResultand again at line 206 forpreviousBodyFatinside thechangesmemo, andbodyFatChangeat line 213 subtractsbodyFatResult.percent − previousBodyFat.percent. Both sides come from the same weight vector; the calibrated-minus-uncalibrated failure mode does not exist here.Measurements.tsx,chartData.tsandgoalProjections.tsare the other three, and the two domain sites take an optionalMethodCalibrationparameter rather than a hook or a query —packages/domainstays free of I/O, as item 33 requires.Both domain helpers also preserve the pre-existing decision not to pass
race(they passundefined), with a comment saying why. That is the right call: passing it would have moved every historical point for any user with a race set, which is a change to what the chart plots rather than a calibration.Zero scans is genuinely behaviour-preserving.
zeroScanCalibrationiscalibrateFromPairs([], sex, race, 0), andnowis passed as0rather than read from a clock, which keeps it pure. The identity it rests on is item 31's and is already pinned bydexaCalibration.test.ts:163-225— seven cases across both sexes, both race states and the low-arity cap cases, assertingtoBe(nottoBeCloseTo) againstweightedAverageBodyFat(...).weighted, plusbeta === 0andeffectivePairs === 0.calibratedBodyFat's beta-0 branch is documented as a floating-point accumulation order rather than a behavioural switch. The claim holds.Item 35's
weightAtRiskshare is a real share.coefficientTotal(sex)readsMALE_COEFFICIENTS/FEMALE_COEFFICIENTSfrom@anthropometry/domain/bodyFatand sums them;siteSharedivides. Nothing is hard-coded, anduseMeasurementGuidance.test.ts:139-185pins all three of the things that could go wrong: that the male coefficients do not already sum to 1, thattricep's 1.00 renders as ~83% and not 100%, and that the female total is computed from its own map rather than borrowed.meanAbsDeltaPpis never read withoutdeltasComputed.formatSiteShiftis the only display reader and it branches ondeltasComputed === 0first, returning "not computable — dropping this leaves nothing to compare" rather than a 0 that would mean the opposite finding.siteStatereadsmeanAbsDeltaPpfor the threshold comparison, but only afterfamiliesEliminatedandpresenthave been checked, so a not-computable site cannot reach "contributes little" —present: falsecatches it. Pinned atuseMeasurementGuidance.test.ts:187.The A/G band's three constraints hold and the percentile columns are unused.
agReferenceBand.ts'sBANDStable reproduces item 33's Table 4 values exactly, both sexes, all six decades. The scanner constraint is enforced first ininterpretAgRatioand returnsno-reference-for-scannerfor anything butge-lunar— the conservative reading of the two the item offered. Ages outside 20–79 fall out of thefindand returnno-reference-for-age. There is no threshold anywhere:formatAgComparisonreturns a signed distance in SDs, and the "under 1.0" consumer line appears only in the docblock as the thing not to repeat. The percentile columns are absent from the module and the reverse-coding is recorded in the docblock with the instruction to read the paper's methods before shipping one.IMBODEN_CITATIONrenders whenever a band does. Age is computed at the scan's own date —Dexa.tsxpassescalculateAgeAtDate(userProfile.birthDate, viewingScan.date). Covered byagReferenceBand.test.ts.The auth fix preserves both prerender divergences while actually renewing.
getClient()is still the lazy singleton with thederiveRedirectUri/requireBrowserreasoning intact, anduseShooAuthstill opens atuseState(true)/useState(false)with no storage read in either initializer. On top of that:readIdentityreturnsexpiresAtMsdecoded viadecodeIdentityClaimsin its owntry(a malformed token becomes "expiry unknown", not "no identity" — the right split);fetchAccessTokendeclares{ forceRefreshToken }andShooAuthStatewidens to Convex's signature, which is the defect thattscstructurally could not catch; the four branches are in the item's order; the mount effect gatessetIsAuthenticatedon!hasExpired(...)as well asuserId !== null. No newuseEffect— the callback exchange is still the only one. The docblock's "produces the same shape" is gone and replaced with an enumeration of what the adapter carries.On the deliberate deviation: the in-flight guard releasing only on failure. The reasoning in
beginReauth's docblock is sound.startSignInresolving meanswindow.location.assignhas been called, but the microtask queue keeps draining until teardown, so afinallywould reopen the guard for exactly the Convex retry it exists to absorb — and the secondstartSignInwould write a fresh PKCE bundle over the one the in-flight redirect will be matched against. That is a worse failure than the one it avoids. The residual risk is astartSignInthat resolves without navigating, which would wedge re-auth for the life of the document; nothing in@shoojs/authdoes that today. Accepting the deviation.shoo.test.tsx:485pins the one-redirect-per-N-retries property and:497pins that a failed attempt lets a later ask retry.Zero
useEffect, zero route literals, zero duplicate453.59237. The only+useEffectlines in the whole diff are three comments saying none belongs.453.59237appears inpackages/domain/src/dexa.ts(GRAMS_PER_POUND) and in two domain test files;useDexaFormState.tsimports the constant./dexaappears only asROUTE_PATTERNS.dexa,dexaPath()'s return, androutes.test.ts— every consumer callsdexaPath(). The four item-14 files each gained exactly one entry.Item 35's egress check. Ran the grep the item specifies across
packages/convex/dexaScans.ts,packages/domain/src/dexa*.ts,apps/web/src/components/dexa/**,useDexaFormState.ts,useCalibratedBodyFat.tsanduseMeasurementGuidance.tsforfetch(,XMLHttpRequest,sendBeaconandhttps://. Zero hits across the whole chain. A DEXA scan's masses reach the user's own Convex deployment and nothing else. The README claim is safe as written.Item 33's eight rules, one by one
- Never measured/actual/true/your body fat — "Estimated Body Fat" on the dashboard stat card, the dashboard breakdown card, the Measurements column header and detail label, and the Progress chart title. Partial miss on Goals, see blocker 1.
- One decimal, never two —
formatEstimatedBodyFatandformatUncalibratedBodyFataretoFixed(1), and nothing else formats the composite. Pinned atprovenanceStrings.test.ts:79. spreadPpnever a±—formatMethodDisagreementreturns "Method disagreement: N points, weighted spread across the methods".METHOD_DISAGREEMENT_CAVEATcarries item 31's three exclusions. The±assertion exists atprovenanceStrings.test.ts:138.- Scan count adjacent to a calibrated number — held on Dashboard (
captionslot under the delta), Measurements (card header above the column) and Progress (chart description). Not held on Goals — blocker 1. - Never claim to beat the scanner —
CALIBRATION_CLAIMsays so in the disclosure's first section. - Never hide the uncalibrated number — "Where this number came from" opens on the calibrated and uncalibrated numbers side by side with the shift between them. Not reachable from Goals — blocker 1.
- Never ten independent estimates —
FAMILY_INDEPENDENCE_COPYrenders in the disclosure's family section, under the dashboard breakdown grid, and above the guidance panel's method table;perFamilyis rendered with published mass beside current mass. - No silent backfill — the Progress body-fat chart's description is
formatCalibrationState(phase)+HISTORY_RECOMPUTED_COPY, which says entering a scan moves the whole line rather than the newest point.
Every warning code in
CalibrationWarningCodehas adescribeWarningarm,no-race-on-profileandscan-unpairedcarry actions, andcriterion-outside-method-rangeis filtered out of the list and rendered inline beside the number withoutsideHullResidualPpin it — exactly the treatment the item asks for.Item 35's seven rules, one by one
- Never "stop measuring this" — the copy is "Contributes little right now". Pinned at
useMeasurementGuidance.test.ts:126. - Never auto-disable, hide or pre-collapse a site — no
droppableboolean exists, every site renders a row,DexaScanFormis untouched by the guidance. measurementsAnalysedvisible — in the card description, "from your last N measurements", rendered above every recommendation.- At
measurementsAnalysed1 the panel is provisional and "contributes little" is suppressed —siteStatereturnsprovisional, and the panel-level banner renders. Pinned at:88. - Never ten independent looks —
FAMILY_INDEPENDENCE_COPYsits above the method table and the rows are grouped by family with the family share on the group header. - Always warn that dropping a site breaks comparability — first paragraph of the panel, above everything else.
- Never recommend dropping on a profile missing
race— the missing-race prompt renders above the site table with a link to settings, andsiteStatesuppresses "contributes little" for every site. Pinned at:97.
All seven hold. The blocker against this item is the method view's missing columns, not one of these.
Policies
react-useeffect-discipline: no effect was added anywhere in the diff.useCalibratedBodyFatfits once in auseMemoand hands back a memoised scorer, which is what stops the progress and wiggle charts from refitting per point.useMeasurementGuidancecallsanalyseContributionsonce in auseMemoand never inside a.map.DexaScanDialogresets the form by remountingDexaScanFormwith akey— §1.3 rather than a clearing effect. Twoconst [now] = useState(() => Date.now())captures, both with the reasoning written down and neither an effect.testing-philosophy§5: no render tests, no snapshots. Every rule that could be undone by a later edit is pinned through an exported pure function — the mass converter, the provenance formatters, the site-state classifier and its copy, the A/G band. The±and the "stop" assertions look like string tests and are the right kind of string test: they are the cheapest guard on the two requirements a well-meaning edit is most likely to undo.Verdict
Request changes on the two blockers below. Everything else in this branch is careful work — the auth fix in particular is the kind that closes a defect and the reason it was invisible in the same commit. Filed separately as
pr comment:- BLOCKING — the Goals page renders a calibrated composite at
text-2xl font-boldwith no scan count and no route to the uncalibrated number. Item 33 rules 4 and 6, and "use it everywhere the composite appears with any prominence." - BLOCKING — the guidance panel's method table ships the leverage legend without a leverage column.
leveragePpandmarginalPpare computed by item 34 and rendered nowhere; item 35 specifies both as columns and calls the leverage distinction "the specific thing the user asked to be able to see." - ADVISORY — the DEXA form's unit toggle reinterprets already-typed numbers instead of re-expressing them.
- ADVISORY —
ledger.snapshot.jsonhas nolog-agentrow for items 116 or 35, andAgRatioReference.tsx's docblock still says it is not wired into the detail view.
The advisories must not gate a second round; they belong in the wave's carry-forward.
claude-opus-5commented
Blockers fixed at 0cb3af4, plus one user-directed change and one defect found while making it.
Blocker 1 (fe170f4) — GoalCard renders BodyFatProvenance via a new
compactprop rather than a fork, for bodyFat/leanMass/ffmi. New plain modulegoalCalibratedMetrics.tssupplies the predicate, the 'Estimated body fat' label as a web-layer override (packages/** untouched), andnewestGoalBodyFat, which walks history the waycalculateProjectiondoes so the disclosure describes the number actually shown rather than a different one.Blocker 2 (7413b1f) — the method table gains Leverage and Marginal contribution columns beside Weight, with a shared
contributesToCompositeguard so a non-contributing method renders a dash rather than a misleading0.0 pp.User-directed (86126e4) — load-bearing now outranks essential, inverting what item 35 specified. A site that is both wears two chips, load-bearing first, with the description from the primary state only;
siteChipsis a separate pure function fromsiteStatebecause chips are a set and the description is singular. Item 35's spec section is amended in the same commit.Defect found while making that change, not in the review:
siteStatereadmeanAbsDeltaPpwithout checkingdeltasComputed, which item 35 forbids andformatSiteShiftguards against ten lines below. It was masked — an uncomputable site reports 0, falls under the threshold, and was labelled 'Contributes little right now', the exact conflation the item exists to prevent, and it could render that label beside a detail reading 'not computable'. The load-bearing test is now guarded and an uncomputable site is provisional.Advisories 3 and 4 are closed: the unit toggle is filed as kanban item 118 (it is scale-invariant against the sum check, so the findings panel structurally cannot catch it), and the missing ledger rows and the stale AgRatioReference docblock are fixed in 73e73b8.
Gates: check clean, 1110 tests / 72 files, up from 1083/70 at the review.
reviewer-delta-opus-5approved
reviewed e8c470ab — round 2, approve. Delta only (
409264c..e8c470ab, 10 commits, 37 files). No blocking findings; seven advisories for the carry-forward.Gates, run independently in a clean worktree at
e8c470abbun install --frozen-lockfile 1233 packages, clean against the committed lockfile bun run check exit 0 bun run test:run 74 files, 1149 tests, all passing, 26.2scheck's foures-x(no-string-prototype-replaceall)warnings are stillapps/web/src/prerender-manifest.ts:157, untouched by this branch — kanban item 47's question, as at round 1.Cross-cutting checks on the whole delta: zero
useEffectadded (the only+useEffectline isshoo.ts's import, widened foruseSyncExternalStore; no new call site); zero route string literals; zero new copies of453.59237; no new network egress (fetch(/XMLHttpRequest/sendBeacon/https:— nothing outside the ISCD citation string); nopackage.jsonorbun.lockchange. The three review-fix commits (fe170f4,7413b1f,86126e4) touchapps/webonly — thepackages/**edits in the delta all belong to items 119 and 120, which own them.Blocker 1 is closed, and the
newestGoalBodyFatclaim is truefe170f4renders<BodyFatProvenance compact>under the "Current" number on everybodyFat,leanMassandffmicard (GoalCard.tsx:163-175). The compact branch drops nothing:BodyFatProvenance.tsx:247-256lays the calibration badge,formatCalibrationState(phase)and the "Where this number came from" trigger on one wrapping row, so rule 4's scan count is adjacent and rule 6's uncalibrated number is one click away.showEstimatedefaults false andGoalCarddoes not pass it, so the 2xl number is not drawn twice. Rule 1's label moved togoalMetricLabelin the web layer rather thanMETRIC_CONFIG, sopackages/**stays untouched.The claim worth verifying was that
newestGoalBodyFatwalks history "the waycalculateProjectiondoes". It does, field by field.calculateProjectiontakeslastPointofextractDataPoints, andgetLatestValuetakes the first descending row that yields a value — both are "newest measurement that scores".newestGoalBodyFatsorts descending and returns the first row whosepercent !== null, callingcalibratedBodyFatwith the same five inputsgetBodyFatPercentuses:skinfoldsOf≡buildSkinfolds(same eight columns),{...circumferencesOf(m), height: m.height ?? profile.height}≡buildCircumferences(m, profile.height),calculateAge(profile.birthDate, m.date),raceasundefined, andcalibration ?? zeroScanCalibration(profile.sex, undefined).goalCalibratedMetrics.test.ts:91-113pins the result againstgetLatestValue(measurements, "bodyFat", PROFILE)rather than a literal, so the two cannot drift silently. Finding 1 has not returned in a new costume.One narrow gap remains and is advisory 1 below.
Blocker 2 is closed
7413b1fadds Leverage and Marginal contribution beside Weight (MeasurementGuidance.tsx:231-232,:305-306), readingentry.leveragePpandentry.marginalPpstraight offMethodContribution—marginalPpis read, not recomputed, which matters becausemethodContribution.ts:223documents it asweight × leveragePpexactly and a recomputation would be a second definition.columnCountmoved 4→6 / 3→5 with the header.contributesToCompositedashes out a method that was never in the average, which is the right call:analyseContributionsreports 0 for both figures there, and a 0 under "Leverage" reads as "says what its neighbours say" — the opposite finding. A genuinely near-zero leverage still renders0.0 pp, which is the row the legend exists for. The legend's rule is now actionable.The user-directed precedence change (
86126e4), checked as instructed rather than assessed- The spec was amended in the same commit.
kanban/0035-…md's four-state list now leads with load-bearing, says "This state outranks Essential", records that the user directed it during PR #35's review, records what was rejected with it (appending the family clause to the load-bearing description), and adds the two paragraphs on the provisional case and onsiteChipsbeing a separate function fromsiteState. Code and spec agree. - Order is asserted, not membership.
useMeasurementGuidance.test.ts:178-182istoStrictEqual(["load-bearing", "essential"]), with a comment saying why an unordered assertion would not catch a flip. The single-chip cases and the "description follows the primary state alone" case are pinned beside it. - The
deltasComputed > 0guard is correct.siteStatecomputesshiftComputedfirst and gates the load-bearing threshold on it, then falls throughessential→!present→!shiftComputed → provisionalbefore reachingcontributes-little. A present site whose deltas could not be computed is provisional, never "contributes little", which is whatformatSiteShift's ten-lines-below guard has always implied and what item 35 forbids. Pinned at:116-123and in thesiteChipscases.
Item 35's seven "must never" rules all still hold; nothing in either commit touches the copy, the auto-disable question,
measurementsAnalysed, the analysed-1 suppression, the family-independence copy, the comparability warning or the missing-race suppression.The auth gate's third state (
a5788ec)getServerReauthSnapshotis(): boolean => falseatshoo.ts:226— a module-scope constant reading no mutable state, passed as the third argument at:247. Hydration is safe twice over:beginReauthcan only fire fromfetchAccessToken, which Convex calls after mount.subscribeReauth(:217-220) returns a closure over the exact listener andSet.deletes it; the function is a module-scope declaration, so its identity is stable across renders and there is no resubscribe churn.- The failed-
startSignInpath both clears and notifies. The rejection handler callssetReauthInFlight(null)(:259), andsetReauthInFlight(:212-215) assigns and runs the listener loop. There is no clear-without-notify path — it is the only writer besides the??=at:252, which has its own notify loop at:264. A failed renewal falls back toisAuthenticatedrather than wedging the shell. This was the specific failure to look for and it is not present. - The one-shell invariant holds.
AppShell.tsx:274isif (isLoading || isReauthenticating)returning the same single JSX literal, not a second shell, soAppShell.test.tsx:164-174still sees one identical loading shell at every gated route.renderToStringtakes the server snapshot, so the prerender pass is unchanged. - No new
useEffect, no route literal (welcomePath()atAppShell.tsx:296), no dependency.
Items 119 and 120
BMD is never summed.
dexaBone.tscontains no accumulation over sites at all:presentValuesis aflatMapthat collects,boundFindingstakesMath.min/Math.maxand compares, and the per-site loop is independent.DexaBoneTable.tsxmaps sites to rows and renders no footer total;Dexa.tsx:184readsscan.bone?.sites.totaldirectly rather than deriving one.dexaScans.ts's bone block iterates for non-positives and nothing else, with a comment saying why. Requirement 5 holds in all three layers, andBMD_IS_NOT_ADDITIVEsays so on the page.No clinical interpretation ships.
grep -rniE "t-score|z-score|osteo|percentile" apps/web/srcreturns onlybmdDisclosure.ts's docblock,BMD_NO_INTERPRETATION,ISCD_CITATION, the test's forbidden-word list, andagReferenceBand.ts's existing note on the deliberately-unused Imboden percentile columns. There is no threshold, badge, colour or arrow keyed to a BMD value anywhere. The citations support what they claim:ISCD_CITATION— the user-visible string — asserts only the four sites the thresholds are valid at, which is the position's diagnostic-site list and not an inference.DexaBoneTable.tsx:88-91rendersBMD_IS_NOT_ADDITIVE,BMD_IS_NOT_BMC,BMD_NO_INTERPRETATIONandISCD_CITATIONinline under the table, andformatBoneProvenancerenders the reference database and analysis mode in the header — neither is behind a disclosure, per requirements 2 and 3. The header isBMD (g/cm²)spelled out and the composition table's column is stillBMCin grams, per requirement 6/§3.formatBoneFinding'sbmd-out-of-rangearm ends "This is a transcription check, not a finding about your bones", per requirement 4.On the honesty question: the ISCD total-body wording is recorded in
kanban/0120-…md's Open Questions as sourced from the diagnostic-site list rather than a verbatim sentence, because the 2023 PDF is not text-extractable, with the note that nothing in the UI depends on the wording since the UI ships no interpretation either way. That is an honest record. See advisory 6.The schema widening is backwards compatible.
android/gynoidbecomev.optional(v.union(dexaRegion, dexaPercentOnlyRegion))andboneisv.optional(dexaBone). Every stored row is a four-massdexaRegionand still validates against the union unchanged; nokinddiscriminator was added todexaRegion, which is the reason it is safe.dexaScanDocisdexaScans.validator.extend({…}), so thereturnsvalidators onlist,getand the by-date query widen with the table rather than drifting — the failure modeconvex-testcould not have caught. The consumer audit (grep -rn "\.android\|\.gynoid") turns up eleven live sites and every one branches:dexaScans.tsviahasMasses,dexa.tsviawithMasses/percentOnly/isPercentOnlyRegion,useDexaFormState.tsviamassesOf/percentStringOf,DexaScanDetail.tsxviaregionPercentFatOnBasis, andDexa.tsxviaformatAgRatioCell, which returns an em dash and never aNaN.dexaScans.test.ts:191pins that a percent-only scan writes and thatpercentFat: 101is refused; every other test in that file writes a full-mass scan, so the old shape is exercised throughout.The A/G basis. The subtlest claim in the delta and it is correct.
agRatioBasisimplements item 119's four cases exactly — both percent-only and differing →mixed-basiswith no division; exactly one percent-only → that region's basis, with the full region answering on it throughregionPercentFatOnBasis; both full → tissue, and the result says so.dexa.test.ts:207-218pins the whole argument: the total-mass pair gives 0.757,|0.757 − 0.743|is0.0139, and the test asserts that gap is less thanAG_RATIO_TOLERANCE(0.015) — i.e. it pins thatag-ratio-mismatchstructurally cannot detect a guessed basis. Given that, the three defences are the right ones and all three are in place: the form asks with no default (percentFatBasisinitialises to"", andbuildPercentOnlyRegionreturnsundefinedwhen the basis is"", soisRegionCompleteis false andcanSubmitblocks); the basis is stored inside the union member; andinterpretAgRatioreturnsno-reference-for-basisbefore it looks at the scanner or the age, with copy that says the two differ "by little enough to look right and enough to be wrong".checkDexaScandegrades rather than fires or crashes.crossRegionFindingsemitsandroid-containment-unavailable/gynoid-containment-unavailableas warnings for an absent or percent-only region and skips the mass comparisons entirely;withMasseskeeps percent-only regions out of the per-region mass loop;percentOnlyroutes them to the singlepercent-fat-out-of-rangeerror; andprintedValueFindingsemitsag-ratio-uncheckablerather than comparing against an unavailable ratio.dexa.test.ts:240+pins that the containment rules degrade instead of firing.The calibration is untouched.
packages/domain/src/dexaCalibration.tsis not in the delta at all;grep -n "android\|gynoid"on it returns nothing, and the criterion is stillregionPercentFat(scan.total)at:303.Bookkeeping (
73e73b8,e8c470a)Round 1's advisory 3 is filed as
kanban/0118-…md, which correctly records the reason the findings panel is structurally blind to it (every check incheckDexaScanis a ratio or an ordering, andfat + lean + BMC = totalis scale-invariant). Advisory 4 is closed both ways:AgRatioReference.tsx's docblock now saysDexaScanDetailrenders it and passes the age at the scan's date, andledger.snapshot.jsoncarries rows forimpl-35,impl-116,impl-119-120andfixer-35.Advisories — for the wave's carry-forward, not this round
-
The disclosure on a
leanMassorffmigoal card can describe a different session than the number above it.METRIC_CONFIG.leanMass.getValueand.ffmi.getValuereturn null withoutm.weight, socalculateProjection'scurrentValuefor those two is the newest row with weight and a scorable body fat.newestGoalBodyFattakes the newest row with a scorable body fat, full stop, because it does not know the metric. A user whose latest session is calipers-without-a-weight gets a lean-mass number from session B under a disclosure describing session A. Narrow, and strictly smaller than themeasurements[0]bug the commit fixed, but it is the same failure mode. The fix is a metric argument, or reusingMETRIC_CONFIG[metric].getValueas the acceptance predicate. -
A blank reference database passes the form's own gate and is hard-refused by the mutation.
buildDexaBonereturns aboneobject as soon as any site parses, even withreferenceDatabase: "";reference-database-missingis awarning, socanSubmit(useDexaFormState.ts:651) allows submit;assertPlausibleScan(dexaScans.ts) then throwsinvalidDexaScan / bone / reference-database-missing.DexaBoneFields.tsx's own docblock says "the reference database is required once any density is entered" and nothing enforces it, and item 120's Storage section assumes it ("was written by this item's form, which requires it"). Not blocking —saveFailureMessagesurfaces the specific server message, the typed data is not lost, and nothing wrong is stored — but the client and server gates disagree, and the entire scan is refused over one blank text field. Either raise the domain finding toerror, or add the condition tocanSubmit. -
normaliseReferenceDatabasedoes not unify the one pair item 120 names. The item says to normalise "so thatUSA (Combined NHANES/Lunar)andUSA (Combined NHANES / Lunar)are one database and not two". Trim + lower-case + collapse runs of whitespace leaves…nhanes/lunar…and…nhanes / lunar…distinct, so those two would reportmixed-reference-databases. The spec's stated normaliser does not achieve the spec's stated goal, anddexaBone.test.ts:135-143sidesteps it by comparing two strings that both have spaces around the slash. Zero impact today —boneComparabilityandformatBoneComparabilityhave no caller outside their tests — but it will produce a false "your scans are not comparable" the moment one is wired up. Stripping whitespace around punctuation, or removing whitespace entirely for the comparison key, closes it. -
boneComparabilityreportsmixed-analysis-modeswhen one scan simply did not record one.analysisMode ?? ""puts a missing mode in the sameSetas a present one, so a table with the mode and a table without it read as two modes. Conservative, and defensible, but "not recorded" and "different" are different findings — the same distinctionbone-partition-unavailableexists to preserve one file over. -
The scan list's A/G column does not say which basis a row is on.
Dexa.tsx:183printsresult.ratio.toFixed(2)for both bases;DexaScanDetail.tsx:186-190appends ", total-mass basis" but the list does not. Two scans on two bases sit in one column looking directly comparable, which is the thingno-reference-for-basisexists to prevent one screen away. A suffix or a marker on the cell would close it. -
percent-fat-out-of-rangeis notNaN-safe while its Convex twin is.dexa.ts's check isr.percentFat < 0 || r.percentFat > 100, both false forNaN;assertPlausibleScanguards with!Number.isFinite(...)first. Unreachable from the form (buildPercentOnlyRegionreturnsundefinedfor an unparseable string), so this is a symmetry point rather than a defect — but the domain checker is the one a future non-form caller would rely on. Same shape asbmd-non-positive, which does checkNumber.isFinite. -
Minor, no action needed unless it bothers you: the
beginReauthdocblock inshoo.ts:185-202— the load-bearing "only a failed attempt releases the guard, and here is why notfinally" argument — now sits abovereauthListenersrather than above the function it defends, because the new block was inserted between them. Andledger.snapshot.jsoncarries anorchestrator-dexa-uirow, whichdocs/porting.mdsays orchestrators do not write; it attributes a real authored commit (ce1c824), so it is more honest than the doc, but the doc and the ledger now disagree.
Verdict
Approve. Both round-1 blockers are genuinely closed rather than papered over — the goal-card fix picks the same session the domain picks and proves it against
getLatestValue, and the method table now draws the two columns its legend explains. The user-directed precedence change carries its spec amendment and an order-asserting test. The auth gate's third state has the constant server snapshot, a clean unsubscribe, and a failure path that both clears and notifies. And the largest, least-reviewed part — items 119 and 120 — gets the two things that could have gone badly wrong exactly right: BMD is never summed anywhere in three layers and ships no verdict, and the A/G basis is asked for rather than defaulted, with a test that pins why the printed-ratio check cannot save you if it is guessed. The seven advisories are backlog and must not gate a round.- The spec was amended in the same commit.
reviewer-opus-5approved
reviewed e8c470ab — round 2, approve. Both of my round-1 blockers are closed; my request-changes at
2b0a7ddis cleared.Scope: my own two findings only, per the orchestrator. Everything else on the branch since
409264c— the BMD table, percent-only android/gynoid, the auth-gate change, the precedence change — was reviewed and approved separately at this same sha byreviewer-delta-opus-5and is not re-reviewed here.Gates, re-run
bun install --frozen-lockfile no changes against the committed lockfile bun run check exit 0 bun run test:run 74 files, 1149 tests, all passingMatches the expected 1149/74.
check's foures-x(no-string-prototype-replaceall)warnings are stillescapeHtmlonmain, item 47's question.Finding 1 — closed (
fe170f4)GoalCardrenders<BodyFatProvenance … compact />directly under the "Current" value for any metricisCalibratedGoalMetricaccepts, which isbodyFat,leanMassandffmi— the right set, because lean mass isweight × (1 − bodyFat)and FFMI normalizes that, so a shift in the composite moves all three and a disclosure on the body-fat card alone would leave the other two reading as measurements. Rule 4's scan count is adjacent to the number rather than in a tooltip, and rule 6's uncalibrated number is behind the trigger on the same line.goalMetricLabeloverridesbodyFatto "Estimated body fat" in the web layer while every other metric keeps the domain label — the right layer for it, sinceMETRIC_CONFIG.bodyFat.labelis the domain package's name for the metric and item 33's rule is about what a reader sees. Rule 1 now holds on this surface too.The claim I was asked to be sceptical of — that
newestGoalBodyFatwalks history the waycalculateProjectiondoes — holds, checked in both directions:- Inputs.
newestGoalBodyFatcallscalibratedBodyFat(skinfoldsOf(m), {...circumferencesOf(m), height: m.height ?? profile.height}, calculateAge(profile.birthDate, m.date), profile.sex, undefined, calibration ?? zeroScanCalibration(profile.sex, undefined)).getBodyFatPercentingoalProjections.ts:134-148passesbuildSkinfolds(m),buildCircumferences(m, profile.height)and the identical age, sex,undefinedrace and calibration fallback.skinfoldsOf(dexaCalibration.ts:254) is field-for-fieldbuildSkinfolds;circumferencesOf(:268) isbuildCircumferencesexcept that it setsheight: m.heightrather than the profile fallback, which is exactly what the spread-and-override innewestGoalBodyFatrestores. Same six arguments. - Selection.
extractDataPointssorts ascending (goalProjections.ts:395) andcalculateProjectiontakesdataPoints[dataPoints.length - 1](:433,:439), socurrentValueis the newest measurement that yields a non-null value — not the newest measurement, which is the trap.newestGoalBodyFatsortstoSorted((a, b) => b.date - a.date)and returns the first session whosepercent !== null. For abodyFatgoal these select the same session. That matters:listreturns newest-first, so a helper that trusted the incoming order rather than sorting would have picked the opposite end of the history.
One narrow edge I checked and am not blocking on: for
leanMassandffmi,getValueadditionally requiresm.weight, so a newest session with calipers but no weight would put the card's number on an older session than the one the per-method table describes. The calibration state, the scan count and the uncalibrated number — the three things rules 4 and 6 are actually about — are identical either way, and the per-method table is the only part that would drift. Not worth a round; note it if the panel ever grows a per-session date.Finding 2 — closed (
7413b1f)MeasurementGuidance's method table now carriesLeverageandMarginal contributionbesideWeight(:230-231,:304-305), withcolumnCountmoved to 6/5 so the family group headers still span the row. Both go throughformatLeverage/formatMarginalContributioninuseMeasurementGuidance.ts, guarded bycontributesToComposite—estimate !== null && weight > 0 && weight < 1— so a method that was never in the average renders—rather than a0.0 ppthat would read as "says exactly what its neighbours say", the opposite finding. Theweight < 1half also covers the degenerate sole-contributor case, where leverage against an empty set of neighbours is undefined rather than zero. Same shape aspresenton the site rows, which is the right precedent.The legend at
:255-264is no longer dangling: a reader can now find a method's leverage and read the 2×2 against it, andmarginalPpisweight × leverageso the "large weight, near-zero marginal contribution" case — the agreeable-not-informative one item 35 calls the specific thing the user asked to see — is now visible in numbers on the same row.Advisories
3 was filed as kanban item 118 rather than fixed, which is the standing rule working as intended, and the item records the property that makes it worth filing at all: the sum check is scale-invariant, so the findings panel structurally cannot catch a unit reinterpretation. 4 is fixed in
73e73b8—impl-116andimpl-35have ledger rows andAgRatioReference's docblock no longer claims it is unwired.Verdict
Approve. Nothing from my round-1 review remains open.
- Inputs.
65 files changed
This view needs a browser with declarative shadow DOM: Chrome 111, Safari 16.4, or Firefox 123. Read the source instead.
README.md
27 unmodified lines282930313233343527 unmodified lines
That weighting is the reason this app exists instead of a spreadsheet, and it is the part of the port to get exactly right. The Evans equations also need a `race` input, which is why the profile carries one.
Recording a DEXA scan calibrates the composite toward what that scanner would read for the same body: the ten equations' weights tilt toward whichever have tracked your own scans, decaying back to the published prior as evidence ages. It never claims to beat the scanner — the most it claims is to estimate what a DXA of this type would read for you — and a measurement panel says which skinfold sites are actually carrying that estimate, so a user can see which sites are worth continuing to take.
### Progress and goals
Trend charts over any date range, goals with a direction and target date, and projections that say whether the current trajectory lands on the target. FFMI with its classification band.apps/web/src/App.tsx
32 unmodified lines3334353637383942 unmodified lines8283848586878832 unmodified linesimport { convex } from "./convexClient";import { AppShell } from "./pages/AppShell";import { Dashboard } from "./pages/Dashboard";import { Dexa } from "./pages/Dexa";import { Goals } from "./pages/Goals";import { Measurements } from "./pages/Measurements";import { NotFound } from "./pages/NotFound";42 unmodified lines /> <Route path={ROUTE_PATTERNS.progress} element={<Progress />} /> <Route path={ROUTE_PATTERNS.goals} element={<Goals />} /> <Route path={ROUTE_PATTERNS.dexa} element={<Dexa />} /> <Route path={ROUTE_PATTERNS.settings} element={<Settings />} /> <Route path={ROUTE_PATTERNS.gpxCalculator}apps/web/src/auth/shoo.test.tsx
40 unmodified lines41424344454647484950515253545 unmodified lines6061626364656667686970717273747511 unmodified lines8788899091929394959697989910010163 unmodified lines165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198165 unmodified lines3643653663203673683693703713723733743753763773783793803813823833843853863873889 unmodified lines39839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453540 unmodified lines userId: null as string | null, /** What `getIdentity()` reports as the access token. */ token: null as string | null, /** * What `decodeIdentityClaims()` reports as the token's `exp`, in ms, or null * for a token whose claims will not decode. Stubbed rather than encoded into * a real JWT because the decoding belongs to @shoojs/auth and is not this * module's to test — what is this module's is every decision made from the * number that comes out. */ expiresAtMs: null as number | null, /** * What a storage-touching call throws, or null for a browser that allows * storage. `getIdentity` and `clearIdentity` both read `localStorage`5 unmodified lines exchanges: 0, /** How many times the identity was cleared without throwing. */ cleared: 0, /** How many redirect flows were started. */ signIns: 0, /** * What `startSignIn()` does. Never settling is the default because that is * what the real one does: it ends in `window.location.assign`, so the * document is on its way out and no continuation of the promise ever runs. * A resolved stand-in would let the in-flight guard clear itself between two * awaited calls and hide exactly the duplicate this file pins. */ redirect: (): Promise<never> => new Promise(() => undefined),}));
vi.mock("@shoojs/react", () => ({11 unmodified lines if (auth.storageBlocked) throw auth.storageBlocked; auth.cleared += 1; }, startSignIn: () => { auth.signIns += 1; return auth.redirect(); }, }) as unknown as ShooAuthClient, decodeIdentityClaims: (idToken?: string) => idToken === undefined || auth.expiresAtMs === null ? null : { exp: auth.expiresAtMs / 1000 },}));
/**63 unmodified lines });}
/** * Ask the hook for a token the way Convex does, inside `act`. * * Inside `act` because `fetchAccessToken` sets `isAuthenticated` on three of * its four branches — it is where a token found dead mid-session turns the * app signed-out, not just where a string is returned. */async function fetchToken( hook: { current: ShooModule.ShooAuthState }, forceRefreshToken: boolean,): Promise<string | null> { let token: string | null = null; await act(async () => { token = await hook.current.fetchAccessToken({ forceRefreshToken }); }); return token;}
beforeEach(() => { auth.exchange = () => Promise.resolve(null); auth.redirect = () => new Promise(() => undefined); auth.userId = null; auth.token = null; auth.expiresAtMs = null; auth.storageBlocked = null; auth.exchanges = 0; auth.cleared = 0; auth.signIns = 0; backend.mutation.mockReset(); backend.mutation.mockImplementation(() => Promise.resolve(null)); backend.toastError.mockReset();165 unmodified lines const hook = renderHook(useShooAuth); await settle();
await expect(hook.current.fetchAccessToken()).resolves.toBeNull(); await expect(fetchToken(hook, false)).resolves.toBeNull(); });
it("resolves signed-out when the stored token has already expired", async () => { // The reload that used to look like "auth does not persist": the identity // survives in localStorage perfectly well and is simply dead, and answering // from `userId` alone reported a session that is not there — the gate let // the visitor in, Convex was handed the dead token, the server refused it, // and they were bounced to /welcome again. auth.userId = "user_1"; auth.token = "dead"; auth.expiresAtMs = Date.now() - 1_000; const { useShooAuth } = await freshDocument();
const hook = renderHook(useShooAuth); await settle();
expect(hook.current.isLoading).toBe(false); expect(hook.current.isAuthenticated).toBe(false); });
it("spends the code once under Strict Mode's replayed effect", async () => {9 unmodified lines });});
/** * The two predicates every expiry decision in the module is made from. * * Both take the clock as an argument rather than reading `Date.now()`, so the * boundaries are exact rather than approximately-now, and both answer `false` * for a null expiry — the case that decides what happens to a token whose * claims will not decode. */describe("token expiry predicates", () => { const now = 1_800_000_000_000;
it("treats a token with no decodable expiry as not expired", async () => { // The conservative reading of "no evidence": treating it as expired would // sign out every visitor the moment Shoo changed its token format. const { hasExpired, expiresSoon } = await freshDocument();
expect(hasExpired(null, now)).toBe(false); expect(expiresSoon(null, now)).toBe(false); });
it("counts the instant of expiry as expired", async () => { const { hasExpired } = await freshDocument();
expect(hasExpired(now - 1, now)).toBe(true); expect(hasExpired(now, now)).toBe(true); expect(hasExpired(now + 1, now)).toBe(false); });
it("puts the leeway boundary at thirty seconds", async () => { const { expiresSoon } = await freshDocument();
expect(expiresSoon(now + 30_001, now)).toBe(false); expect(expiresSoon(now + 30_000, now)).toBe(true); expect(expiresSoon(now - 1, now)).toBe(true); });});
/** * What Convex is handed, and what happens to a dead token when it asks. * * This is the half of `createShooConvexAuth` the reimplementation dropped. * Shoo issues no refresh token, so the only renewal available is re-running * the redirect flow, and the trigger for it is `exp` — which nothing read. */describe("fetchAccessToken", () => { /** Mount the hook against a stored identity and let it resolve. */ async function signedInWith(expiresAtMs: number | null) { auth.userId = "user_1"; auth.token = "stored-token"; auth.expiresAtMs = expiresAtMs; const { useShooAuth } = await freshDocument(); const hook = renderHook(useShooAuth); await settle(); return hook; }
it("hands over a live token and leaves the identity alone", async () => { const hook = await signedInWith(Date.now() + 3_600_000);
await expect(fetchToken(hook, true)).resolves.toBe("stored-token"); expect(auth.cleared).toBe(0); expect(auth.signIns).toBe(0); });
it("hands over a token whose expiry would not decode", async () => { // `expiresAtMs === null` reaching the same branch as a comfortably live // token is the whole point of the null rule: an opaque or malformed token // is passed through, not thrown away. const hook = await signedInWith(null);
await expect(fetchToken(hook, true)).resolves.toBe("stored-token"); expect(auth.cleared).toBe(0); });
it("clears an expired token instead of replaying it", async () => { // The clear is what stops the reload from repeating the whole failure: // before it, the dead token stayed in storage and nothing but the sign-out // button ever removed it. const hook = await signedInWith(Date.now() - 1_000);
await expect(fetchToken(hook, false)).resolves.toBeNull(); expect(auth.cleared).toBe(1); });
it("opens exactly one redirect however many times Convex retries", async () => { // Convex asks again when a token is refused. Two redirects would write two // PKCE bundles over each other in storage, and whichever navigation landed // last would decide which one the provider is asked to match. const hook = await signedInWith(Date.now() - 1_000);
await fetchToken(hook, true); await fetchToken(hook, true);
expect(auth.signIns).toBe(1); });
it("lets a later ask retry when the redirect could not be started", async () => { // The guard releases on failure and only on failure. A `finally` would // release it on success too, and a success has already called // `window.location.assign` — the microtask queue keeps draining while the // browser tears the document down, so the next retry would write a second // PKCE bundle over the one the pending redirect is matched against. auth.redirect = () => Promise.reject(new Error("no window to redirect")); const reported = vi .spyOn(console, "error") .mockImplementation(() => undefined); const hook = await signedInWith(Date.now() - 1_000);
await fetchToken(hook, true); await fetchToken(hook, true);
expect(auth.signIns).toBe(2); expect(reported).toHaveBeenCalledWith( expect.stringContaining("re-authentication"), expect.any(Error), ); });
it("renews a token inside the leeway only when Convex forces it", async () => { // A token with ten seconds left is still good for the request in hand, so // an unforced ask gets it. The forced ask is Convex saying it wants a token // that will outlive the next one — which this one will not. const hook = await signedInWith(Date.now() + 10_000);
await expect(fetchToken(hook, false)).resolves.toBe("stored-token"); expect(auth.signIns).toBe(0);
await expect(fetchToken(hook, true)).resolves.toBeNull(); expect(auth.signIns).toBe(1); });});
/** * `users.ensureFromAuth` is triggered by the identity resolving, not by a * component being displayed.apps/web/src/auth/shoo.ts
10 unmodified lines111213141516171819141516171819202122232425262728293031323334353637383923244041424344118 unmodified lines16316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029134 unmodified lines32632732832933033133233333433533633733833934019634134234334434534634734819934935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141220641341441541641741814 unmodified lines43343443522743643743843944044144244344444544644744844945045145245345445545645745845946046146223346346446546646746846947047147247347447547647747847948048148248348448548648748848949049149213 unmodified lines50650750825325425525625725825950951051151251351426226326426526651551651710 unmodified lines * `ensureUserRow` below. That is the only Convex call in this module, and the * only reason it imports ../convexClient. * * @shoojs/react ships a `createShooConvexAuth` that produces the same shape, * and it is deliberately not used: it calls `createShooAuth` eagerly, which * cannot survive the prerender pass (see `getClient` below), and its `useAuth` * seeds `isAuthenticated` from stored token state in a `useState` initializer, * which is a storage read during the first render — the one thing a * prerendered app cannot do without a hydration mismatch. * @shoojs/react ships a `createShooConvexAuth`, and it is deliberately not * used, for two reasons and exactly two: it calls `createShooAuth` eagerly, * which cannot survive the prerender pass (see `getClient` below), and its * `useAuth` seeds `isAuthenticated` from stored token state in a `useState` * initializer, which is a storage read during the first render — the one thing * a prerendered app cannot do without a hydration mismatch. * * WHAT THE ADAPTER CARRIES, AND SO DOES THIS FILE. Neither of those objections * touches token expiry, and expiry is the whole of the rest of the adapter. * This docblock used to say the adapter "produces the same shape", which is * how the expiry half came to be dropped without anyone noticing it existed: * Shoo issues no refresh token, so an `id_token` whose `exp` has passed can * only be renewed by re-running the redirect flow, and a module that never * reads `exp` hands Convex the same dead token forever. The parts ported from * `createShooConvexAuth` and required to stay ported are: `exp` decoded off * the stored token (`readIdentity`'s `expiresAtMs`), the `{ forceRefreshToken }` * parameter Convex actually calls `fetchAccessToken` with, the expired branch * that clears the identity rather than replaying it, the proactive re-auth * inside `REFRESH_LEEWAY_MS`, the single in-flight re-auth, and an * `isAuthenticated` gated on the token being live rather than on a `userId` * being present. Anything added to the adapter's expiry path upstream belongs * here too; anything this file omits, it omits for one of the two prerender * reasons above or not at all. */import { api } from "@anthropometry/convex/api";import type { ShooAuthClient, StartSignInOptions } from "@shoojs/react";import { createShooAuth } from "@shoojs/react";import { useCallback, useEffect, useState } from "react";import { createShooAuth, decodeIdentityClaims } from "@shoojs/react";import { useCallback, useEffect, useState, useSyncExternalStore } from "react";import { toast } from "sonner";import { convex } from "../convexClient";
118 unmodified lines toast.error("Could not finish signing you in. Reload to try again.");}
/** * How close to `exp` a token has to be before a forced refresh renews it * rather than replaying it. `createShooConvexAuth`'s `REFRESH_LEEWAY_MS`, same * value: Convex asks for a fresh token before it needs one, and a token with * twenty seconds left would otherwise be handed over and die mid-session. */const REFRESH_LEEWAY_MS = 30_000;
/** * The single in-flight re-authentication, or null. * * Module scope for the reason `callbackExchange` is: a redirect is a fact * about the document. Convex retries `fetchAccessToken` when a token is * refused, so without this a dead token produces one `startSignIn` per retry * — several PKCE bundles written over each other in storage, and whichever * `window.location.assign` lands last decides which one the provider sees. */let reauthInFlight: Promise<void> | null = null;
/** * Re-run the redirect flow, at most once per document at a time. * * Never rejects, for the same reason `exchangeCallbackOnce` does not: every * caller is a `void`-discarded call inside `fetchAccessToken`, which Convex * invokes outside any promise chain of ours, so a rejection here would surface * as a bare unhandledrejection with nothing naming the redirect it came from. * * Only a *failed* attempt releases the guard, which is the one place this * departs from the adapter — `createShooConvexAuth` clears `refreshInFlight` * in a `finally`. A resolved `startSignIn` has already called * `window.location.assign`, but navigation is not instant and the microtask * queue keeps draining until the document is torn down, so clearing on success * reopens the guard for exactly the retry it exists to absorb, and the second * `startSignIn` would write a fresh PKCE bundle over the one the redirect * already in flight is going to be matched against. A success is the last * thing this document does; there is nothing left to retry. *//** * Everything currently rendering a decision about the re-auth state. * * `reauthInFlight` is module scope rather than React state, so nothing * re-renders when it changes unless something publishes the change. This is * that publication, read through `useSyncExternalStore` below. */const reauthListeners = new Set<() => void>();
function setReauthInFlight(promise: Promise<void> | null): void { reauthInFlight = promise; for (const listener of reauthListeners) listener();}
function subscribeReauth(listener: () => void): () => void { reauthListeners.add(listener); return () => reauthListeners.delete(listener);}
const getReauthSnapshot = (): boolean => reauthInFlight !== null;
/** The prerender pass has no document and can start no redirect, so the * server snapshot is the only honest constant: never re-authenticating. */const getServerReauthSnapshot = (): boolean => false;
/** * Is a re-authentication redirect under way? * * The gate in pages/AppShell.tsx needs three states and Convex's * `useConvexAuth` only reports two. During a renewal `fetchAccessToken` * clears the dead identity and reports unauthenticated — which is true, and * which the gate would otherwise read as "definitively signed out" and answer * with a client-side redirect to the marketing page. The visitor gets a flash * of /welcome in the middle of their own session, and it is on screen for as * long as `startSignIn` takes to hand the document to the provider. * * "Renewing" is not "signed out". This is the distinction, published * separately rather than folded into `ShooAuthState` because that interface * is Convex's contract and Convex has no use for it. */export function useIsReauthenticating(): boolean { return useSyncExternalStore( subscribeReauth, getReauthSnapshot, getServerReauthSnapshot, );}
function beginReauth(client: ShooAuthClient): Promise<void> { reauthInFlight ??= client.startSignIn().then( () => undefined, (error: unknown) => { console.error("shoo: starting re-authentication failed", error); // Released, and published: a failed redirect is the one path where the // document survives, so the gate has to stop holding the shell and go // back to answering from `isAuthenticated`. setReauthInFlight(null); }, ); // Published after assignment rather than through `setReauthInFlight`, // because `??=` is what decides whether this call started anything. for (const listener of reauthListeners) listener(); return reauthInFlight;}
/** * Is this token past its `exp`? * * The clock is a parameter rather than a `Date.now()` read so the boundaries * are testable without stubbing a global, and so one `fetchAccessToken` call * asks both predicates about the same instant. * * `expiresAtMs === null` is **not** expired. That covers a token whose `exp` * could not be decoded, and the conservative reading of an undecodable token * is "no evidence it is dead" — treating it as expired would sign out every * visitor the moment Shoo changed its token format. */export function hasExpired(expiresAtMs: number | null, now: number): boolean { return expiresAtMs !== null && expiresAtMs <= now;}
/** Is this token inside `REFRESH_LEEWAY_MS` of its `exp`? Same null rule. */export function expiresSoon(expiresAtMs: number | null, now: number): boolean { return expiresAtMs !== null && expiresAtMs - now <= REFRESH_LEEWAY_MS;}
/** * Whether the "this browser blocks storage" line has already been printed for * this document. Module scope for the same reason `callbackExchange` is: it is34 unmodified lines * * "No identity" is the honest answer for a document that cannot read where an * identity would be kept, and it is a state the whole app already renders. * * `expiresAtMs` is `exp` in milliseconds, and is what every expiry decision in * this module is made from. It is null when there is no token, when the token * carries no numeric `exp`, and when decoding threw — see `readExpiry`. */function readIdentity(client: ShooAuthClient): { userId: string | null; token: string | null; expiresAtMs: number | null;} { try { const identity = client.getIdentity(); return { userId: identity.userId, token: identity.token ?? null }; const token = identity.token ?? null; return { userId: identity.userId, token, expiresAtMs: token === null ? null : readExpiry(token), }; } catch (error: unknown) { reportStorageFailure("reading the stored identity", error); return { userId: null, token: null }; return { userId: null, token: null, expiresAtMs: null }; }}
/** Whether the undecodable-token line has already been printed, as above. */let expiryFailureReported = false;
/** * `exp` off a stored token, in milliseconds, or null. * * Caught separately from the storage read rather than folded into * `readIdentity`'s `try` because the two failures deserve different answers. * `decodeIdentityClaims` reaches `atob`, which throws on a token whose payload * segment is not valid base64 — and the right response to a malformed token is * "an identity whose expiry is unknown", not "no identity at all", which is * what sharing the outer catch would have produced. The claims themselves are * a `JSON.parse` result typed as `IdentityClaims`, so `exp` is checked at * runtime however confidently the type asserts it. */function readExpiry(token: string): number | null { try { const claims = decodeIdentityClaims(token); return typeof claims?.exp === "number" ? claims.exp * 1000 : null; } catch (error: unknown) { if (!expiryFailureReported) { expiryFailureReported = true; console.error( "shoo: decoding the stored token's claims failed — this token is treated as never expiring", error, ); } return null; }}
/** * `clearIdentity()`, made total, for the same reason `readIdentity` is. * * Both callers are places a throw does real damage: inside `fetchAccessToken` * it surfaces in Convex's auth machinery with nothing of this app's on the * stack, and inside `signOut` it lands before the reload and leaves the button * doing nothing at all. A browser that blocks storage has nothing persisted to * clear anyway, so "the identity is gone" is already true there. */function clearIdentity(client: ShooAuthClient): void { try { client.clearIdentity(); } catch (error: unknown) { reportStorageFailure("clearing the stored identity", error); }}
export interface ShooAuthState { readonly isLoading: boolean; readonly isAuthenticated: boolean; /** * Convex's signature, not a convenient subset of it. * * `ConvexProviderWithAuth` calls this as * `fetchAccessToken({ forceRefreshToken })` and the parameter is the entire * renewal trigger — a zero-argument function is assignable to a one-argument * one, so declaring it without the parameter type-checks perfectly and * silently discards every request for a fresh token. */ readonly fetchAccessToken: () => Promise<string | null>; readonly fetchAccessToken: (args: { forceRefreshToken: boolean; }) => Promise<string | null>;}
export function useShooAuth(): ShooAuthState {14 unmodified lines useEffect(() => { const client = getClient(); void exchangeCallbackOnce(client).then(() => { setIsAuthenticated(readIdentity(client).userId !== null); // A stored identity outlives the token inside it. Answering from // `userId` alone reported a session that is not there: the gate let the // visitor into the app, Convex was handed the dead token, the server // refused it, and the gate bounced them to /welcome — every reload, with // the identity still in storage and nothing clearing it. const identity = readIdentity(client); setIsAuthenticated( identity.userId !== null && !hasExpired(identity.expiresAtMs, Date.now()), ); setIsLoading(false); }); }, []);
/** * The four answers Convex can get, in the order they are decided. * * There is no refresh token in Shoo 0.2.2, so "renew" means "re-run the * redirect flow" and the return trip is already built: `startSignIn` stores * `currentRoute()` as `returnTo` and `handleCallback` pops it and replaces * the location with it. * * The forced-and-expiring-soon branch returns null rather than the token it * still holds, matching the adapter: a redirect is already under way, and a * token with seconds left would be refused mid-request anyway. */ const fetchAccessToken = useCallback( () => Promise.resolve(readIdentity(getClient()).token), (args: { forceRefreshToken: boolean }): Promise<string | null> => { const client = getClient(); const identity = readIdentity(client); const now = Date.now();
if (identity.token === null || identity.userId === null) { setIsAuthenticated(false); return Promise.resolve(null); }
if (hasExpired(identity.expiresAtMs, now)) { // Cleared rather than left in place, because a dead token that stays // in storage is the thing that makes this survive a reload. clearIdentity(client); setIsAuthenticated(false); if (args.forceRefreshToken) void beginReauth(client); return Promise.resolve(null); }
if (args.forceRefreshToken && expiresSoon(identity.expiresAtMs, now)) { void beginReauth(client); return Promise.resolve(null); }
setIsAuthenticated(true); return Promise.resolve(identity.token); }, [], );
13 unmodified lines * network-first, so the reload fetches a fresh document rather than replaying * a cached one. * * The reload therefore happens whether or not the clear succeeded. * `clearIdentity` reaches `window.localStorage.removeItem` directly and throws * where the browser blocks storage, and letting that through would mean the * sign-out button does nothing at all — no reload, the identity still held in * memory, and nothing on screen acknowledging the click. There is nothing * persisted to clear in such a browser anyway, so the reload alone is the * whole of what sign-out can and must do there. * The reload therefore happens whether or not the clear succeeded — the local * `clearIdentity` above swallows the `SecurityError` a blocked browser raises, * which is what keeps the throw from landing before this line and leaving the * sign-out button doing nothing at all. */export function signOut(): void { try { getClient().clearIdentity(); } catch (error: unknown) { reportStorageFailure("clearing the stored identity", error); } clearIdentity(getClient()); window.location.reload();}apps/web/src/components/dexa/AgRatioReference.tsx
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172/** * The A/G ratio beside its own age- and sex-banded reference distribution. * * Its own module rather than part of the scan detail view (kanban item 32), * so the citation and the three constraints in `agReferenceBand.ts` can be * checked without reading a page, and so the two items could land in either * order — they were built in parallel and this one shipped first, unwired. * `DexaScanDetail` now renders it under the android/gynoid block, passing the * age at the *scan's* date rather than at reading time. * * There is no traffic light and no grade. The ratio's clinical meaning is a * continuous association with cardiometabolic risk rather than a threshold, * and the reference distribution shifts by roughly 0.3 across the adult age * range in men — a three-colour badge would be wrong for most users most of * the time. What this renders is a distance from the reader's own decade's * mean, and the two cases where no honest comparison exists. */
import type { Sex } from "@anthropometry/domain/bodyFat";import type { PercentFatBasis } from "@anthropometry/domain/dexa";import type { ScannerManufacturer } from "@anthropometry/domain/dexaCalibration";
import { IMBODEN_CITATION, formatAgComparison, formatAgRatio, interpretAgRatio,} from "./agReferenceBand";
interface AgRatioReferenceProps { /** The derived android-to-gynoid ratio — the `ratio` of what `agRatio` in * `packages/domain/src/dexa.ts` returns. */ readonly ratio: number; /** Which denominator the two percentages behind the ratio were taken over. * The band applies to the tissue basis only. */ readonly basis: PercentFatBasis; readonly sex: Sex; /** Age at the scan date, not at reading time. */ readonly age: number; readonly manufacturer: ScannerManufacturer;}
export function AgRatioReference({ ratio, basis, sex, age, manufacturer,}: AgRatioReferenceProps) { const interpretation = interpretAgRatio( ratio, sex, age, manufacturer, basis, );
return ( <div className="space-y-1"> <div className="flex flex-wrap items-baseline gap-2"> <span className="text-2xl font-bold">{formatAgRatio(ratio)}</span> <span className="text-muted-foreground text-sm"> android-to-gynoid fat ratio </span> </div> <p className="text-sm">{formatAgComparison(interpretation, sex)}</p> {interpretation.kind === "banded" && ( <p className="text-muted-foreground text-xs">{IMBODEN_CITATION}</p> )} </div> );}apps/web/src/components/dexa/BodyFatProvenance.tsx
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267/** * Where the composite body-fat estimate came from. * * One component, used everywhere the composite appears with any prominence, * rather than three variants that drift. It renders the badge and the scan * count inline — adjacent to the number, never in a tooltip — and puts the * rest behind a popover: the uncalibrated number beside the calibrated one, * the per-method table with each method's weight beside the population weight * it started from, the per-family summary, the method disagreement with its * caveat, the tilt schedule in plain language, and every warning the * calibration raised. * * `criterion-outside-method-range` is deliberately NOT in the warnings list. * It is the one case the calibration genuinely cannot help with, and a user * whose methods all sit on one side of their scan needs to know the composite * is structurally limited rather than that it has been fixed — so it is * rendered inline, beside the number, and skipped when the list is built. * * All strings come from `provenanceStrings.ts`, which is where the eight * "must never" rules are written down and where they are tested. Nothing here * formats a number itself. */
import type { CalibratedBodyFat } from "@anthropometry/domain/dexaCalibration";import { Badge } from "@anthropometry/ui/components/ui/badge";import { Button } from "@anthropometry/ui/components/ui/button";import { Popover, PopoverContent, PopoverTrigger,} from "@anthropometry/ui/components/ui/popover";import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@anthropometry/ui/components/ui/table";import { Link } from "react-router";
import { CALIBRATION_CLAIM, FAMILY_INDEPENDENCE_COPY, FAMILY_LABEL, HISTORY_RECOMPUTED_COPY, METHOD_DISAGREEMENT_CAVEAT, METHOD_LABEL, calibrationBadgeLabel, formatBracketFailure, formatCalibrationShift, formatCalibrationState, formatEstimatedBodyFat, formatMae, formatMethodDisagreement, formatMethodEstimate, formatUncalibratedBodyFat, formatTiltStrength, formatWeightShare, warningNotices, type CalibrationPhase,} from "./provenanceStrings";
interface BodyFatProvenanceProps { /** The scored session this provenance describes. Null while loading, and * for a session with nothing to score. */ readonly result: CalibratedBodyFat | null; readonly phase: CalibrationPhase; /** Render the estimate itself. False where the caller has already drawn the * number and only wants the state and the disclosure beside it. */ readonly showEstimate?: boolean; /** * Lay the state and the disclosure trigger out on one line, for a * constrained space like a goal card. * * A prop rather than a second component: rules 4 and 6 are that the scan * count is adjacent and the uncalibrated number is one click away, and a * fork of this file is how a variant ends up honouring one of them. Nothing * is dropped in this mode — the row wraps. */ readonly compact?: boolean;}
function SectionHeading({ children }: { children: React.ReactNode }) { return <h4 className="text-sm font-semibold">{children}</h4>;}
export function BodyFatProvenance({ result, phase, showEstimate = false, compact = false,}: BodyFatProvenanceProps) { const bracketFailure = result === null ? null : formatBracketFailure(result); const notices = result === null ? [] : warningNotices(result).filter( (notice) => notice.code !== "criterion-outside-method-range", );
const disclosure = result !== null && ( <Popover> <PopoverTrigger asChild> <Button variant="link" size="sm" className="h-auto px-0 text-xs"> Where this number came from </Button> </PopoverTrigger> <PopoverContent align="start" className="max-h-[70vh] w-[min(92vw,44rem)] space-y-5 overflow-y-auto" > <section className="space-y-1"> <SectionHeading>Estimated body fat</SectionHeading> <div className="flex flex-wrap items-baseline gap-4"> <span className="text-2xl font-bold"> {formatEstimatedBodyFat(result)} </span> <span className="text-muted-foreground text-sm"> published equations alone: {formatUncalibratedBodyFat(result)} </span> </div> <p className="text-muted-foreground text-xs"> {formatCalibrationShift(result)} </p> <p className="text-muted-foreground text-xs">{CALIBRATION_CLAIM}</p> </section>
<section className="space-y-1"> <SectionHeading>How far the weighting has moved</SectionHeading> <p className="text-muted-foreground text-xs"> {formatTiltStrength(result)} </p> </section>
<section className="space-y-1"> <SectionHeading>Method disagreement</SectionHeading> <p className="text-sm">{formatMethodDisagreement(result.spreadPp)}</p> <p className="text-muted-foreground text-xs"> {METHOD_DISAGREEMENT_CAVEAT} </p> </section>
{result.perFamily.length > 0 && ( <section className="space-y-1"> <SectionHeading>Method families</SectionHeading> <p className="text-muted-foreground text-xs"> {FAMILY_INDEPENDENCE_COPY} </p> <Table> <TableHeader> <TableRow> <TableHead>Family</TableHead> <TableHead>Methods</TableHead> <TableHead>Published mass</TableHead> <TableHead>Current mass</TableHead> </TableRow> </TableHeader> <TableBody> {result.perFamily.map((family) => ( <TableRow key={family.family}> <TableCell>{FAMILY_LABEL[family.family]}</TableCell> <TableCell>{family.memberCount}</TableCell> <TableCell>{formatWeightShare(family.priorMass)}</TableCell> <TableCell>{formatWeightShare(family.mass)}</TableCell> </TableRow> ))} </TableBody> </Table> </section> )}
<section className="space-y-1"> <SectionHeading>Each method</SectionHeading> <p className="text-muted-foreground text-xs"> Weight beside the published weight it started from is what makes the calibration legible: it shows what moved, and by how much. </p> <div className="overflow-x-auto"> <Table> <TableHeader> <TableRow> <TableHead>Method</TableHead> <TableHead>Family</TableHead> <TableHead>Estimate</TableHead> <TableHead>Error vs scans</TableHead> <TableHead>Weight</TableHead> <TableHead>Published weight</TableHead> </TableRow> </TableHeader> <TableBody> {result.perMethod.map((entry) => ( <TableRow key={entry.method}> <TableCell>{METHOD_LABEL[entry.method]}</TableCell> <TableCell className="text-muted-foreground text-xs"> {FAMILY_LABEL[entry.family]} </TableCell> <TableCell> {formatMethodEstimate(entry.estimate)} </TableCell> <TableCell>{formatMae(entry.mae)}</TableCell> <TableCell>{formatWeightShare(entry.weight)}</TableCell> <TableCell className="text-muted-foreground"> {formatWeightShare(entry.populationWeight)} </TableCell> </TableRow> ))} </TableBody> </Table> </div> </section>
{notices.length > 0 && ( <section className="space-y-2"> <SectionHeading>Worth knowing</SectionHeading> {notices.map((notice) => ( <div key={notice.code} className="rounded-md border p-2 text-xs"> <p className="font-medium">{notice.title}</p> <p className="text-muted-foreground">{notice.detail}</p> {notice.action && ( <Button asChild variant="link" size="sm" className="h-auto px-0" > <Link to={notice.action.to}>{notice.action.label}</Link> </Button> )} </div> ))} </section> )}
<p className="text-muted-foreground text-xs"> {HISTORY_RECOMPUTED_COPY} </p> </PopoverContent> </Popover> );
return ( <div className={compact ? "space-y-1" : "space-y-2"}> {showEstimate && ( <p className="text-2xl font-bold">{formatEstimatedBodyFat(result)}</p> )}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1"> <Badge variant={phase.phase === "calibrated" ? "default" : "outline"}> {calibrationBadgeLabel(phase)} </Badge> <span className="text-muted-foreground text-xs"> {formatCalibrationState(phase)} </span> {compact && disclosure} </div>
{bracketFailure !== null && ( <p className="rounded-md border border-amber-500/50 bg-amber-500/10 p-2 text-xs"> {bracketFailure} </p> )}
{!compact && disclosure} </div> );}apps/web/src/components/dexa/DexaBoneFields.tsx
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111/** * The densitometry section of the entry form: eight optional densities and * the two fields that say what they were compared against. * * **No unit toggle.** Every manufacturer prints BMD in g/cm², so there is * nothing to convert, and offering a unit select would only invite an error. * That is why this section does not take the form's `DexaWeightUnit`. * * The whole section is optional — a report that prints no bone table leaves * it blank and `buildDexaBone` stores nothing — but the reference database * is required once any density is entered, because a density without the * population it was compared against cannot be read against a later scan and * the string is unrecoverable once the printout is gone. */import { DEXA_BONE_SITES, type DexaBoneSiteName,} from "@anthropometry/domain/dexaBone";import { Input } from "@anthropometry/ui/components/ui/input";import { Label } from "@anthropometry/ui/components/ui/label";import { BMD_IS_NOT_BMC } from "./bmdDisclosure";
const SITE_LABELS: Record<DexaBoneSiteName, string> = { head: "Head", arms: "Arms", legs: "Legs", trunk: "Trunk", ribs: "Ribs", spine: "Spine", pelvis: "Pelvis", total: "Total",};
interface DexaBoneFieldsProps { readonly values: Readonly<Record<DexaBoneSiteName, string>>; readonly onChange: (site: DexaBoneSiteName, value: string) => void; readonly referenceDatabase: string; readonly onReferenceDatabaseChange: (value: string) => void; readonly analysisMode: string; readonly onAnalysisModeChange: (value: string) => void;}
export function DexaBoneFields({ values, onChange, referenceDatabase, onReferenceDatabaseChange, analysisMode, onAnalysisModeChange,}: DexaBoneFieldsProps) { return ( <div className="space-y-3 rounded-md border p-3"> <div> <h4 className="font-medium"> Bone mineral density (g/cm²) — optional </h4> <p className="text-muted-foreground text-xs"> The densitometry table, if your report prints one. {BMD_IS_NOT_BMC} </p> </div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="space-y-1"> <Label htmlFor="dexa-bone-database">Reference database</Label> <Input id="dexa-bone-database" placeholder="USA (Combined NHANES/Lunar)" value={referenceDatabase} onChange={(e) => { onReferenceDatabaseChange(e.target.value); }} /> </div> <div className="space-y-1"> <Label htmlFor="dexa-bone-mode">Analysis mode (optional)</Label> <Input id="dexa-bone-mode" placeholder="Enhanced Analysis" value={analysisMode} onChange={(e) => { onAnalysisModeChange(e.target.value); }} /> </div> </div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> {DEXA_BONE_SITES.map((site) => ( <div key={site} className="space-y-1"> <Label htmlFor={`dexa-bmd-${site}`}>{SITE_LABELS[site]}</Label> <Input id={`dexa-bmd-${site}`} type="number" step="any" value={values[site]} onChange={(e) => { onChange(site, e.target.value); }} /> </div> ))} </div>
<p className="text-muted-foreground text-xs"> Ribs, spine and pelvis are parts of the trunk. Nothing here is added up — an areal density is not a mass, and the total is an area-weighted average of the regions rather than their sum. </p> </div> );}apps/web/src/components/dexa/DexaBoneTable.tsx
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114/** * The densitometry table on the scan detail view: areal density per site, * its reference database and analysis mode, and the statement that nothing * here is interpreted. * * Three things this component must keep doing, all from kanban item 120: * * 1. The column header says **BMD (g/cm²)** in full, never "Bone" or * "Density". The composition table above prints BMC in grams, and a * reader who takes one for the other has been misled by the layout. * 2. The reference database renders **with** the table, not behind a * disclosure. A density without the population it was compared against * cannot be read against a later scan. * 3. There is no verdict, no colour and no threshold. `bmdDisclosure.ts` * holds the citations for why. */import { checkDexaBone, DEXA_BONE_SITES, type DexaBoneDensity, type DexaBoneSiteName,} from "@anthropometry/domain/dexaBone";import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@anthropometry/ui/components/ui/table";import { BMD_IS_NOT_ADDITIVE, BMD_IS_NOT_BMC, BMD_NO_INTERPRETATION, BONE_SITE_LABELS, formatBmd, formatBoneFinding, formatBoneProvenance, ISCD_CITATION,} from "./bmdDisclosure";
/** Ribs, spine and pelvis sit inside the trunk. Containment, and nothing * more: it licenses no sum, only the bound `checkDexaBone` already tests. */const SUBREGION_NOTE: Partial<Record<DexaBoneSiteName, string>> = { ribs: "part of trunk", spine: "part of trunk", pelvis: "part of trunk",};
export function DexaBoneTable({ bone }: { readonly bone: DexaBoneDensity }) { const findings = checkDexaBone(bone);
return ( <div className="space-y-3 rounded-md border p-3"> <div> <h4 className="font-medium">Bone mineral density</h4> <p className="text-muted-foreground text-sm"> {formatBoneProvenance(bone.referenceDatabase, bone.analysisMode)} </p> </div>
<div className="overflow-x-auto"> <Table> <TableHeader> <TableRow> <TableHead>Site</TableHead> <TableHead>BMD (g/cm²)</TableHead> </TableRow> </TableHeader> <TableBody> {DEXA_BONE_SITES.map((site) => ( <TableRow key={site}> <TableCell className="font-medium"> {BONE_SITE_LABELS[site]} {SUBREGION_NOTE[site] !== undefined && ( <span className="text-muted-foreground ml-2 text-xs font-normal"> {SUBREGION_NOTE[site]} </span> )} </TableCell> <TableCell>{formatBmd(bone.sites[site])}</TableCell> </TableRow> ))} </TableBody> </Table> </div>
<p className="text-muted-foreground text-xs">{BMD_IS_NOT_ADDITIVE}</p> <p className="text-muted-foreground text-xs">{BMD_IS_NOT_BMC}</p> <p className="text-sm">{BMD_NO_INTERPRETATION}</p> <p className="text-muted-foreground text-xs">{ISCD_CITATION}</p>
{findings.length > 0 && ( <ul className="space-y-0.5 text-xs"> {/* Findings are rebuilt from the scan on every render and carry no stable id; site + rule is not unique on its own, so the index is the only key that cannot collide within this render. */} {findings.map((finding, index) => ( <li key={index} className={ finding.severity === "error" ? "text-destructive" : "text-muted-foreground" } > {formatBoneFinding(finding)} </li> ))} </ul> )} </div> );}apps/web/src/components/dexa/DexaRegionFields.tsx
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248/** * One region's inputs — rendered six or eight times by `DexaScanForm` * (five required regions, plus arms and head when the caller switches those * on). * * The four inputs in full mode are the four masses a report prints for the * region; the three in summary mode are what the report's one-page summary * table prints instead (kanban item 32). Which set renders is `mode`, owned * by the parent form — this component never decides its own mode, only * reads it, so full and summary values can be held independently upstream * without this component caring which one is live. * * The derived-%fat line under the inputs is read-only and exists only while * the region is complete enough to compute it — it is a courtesy read-back, * not a second place a percentage is entered or stored * (`packages/domain/src/dexa.ts` derives every percentage from masses; this * component calls that derivation, never reimplements it). */import { regionPercentFat, tissuePercentFat, type DexaFinding, type DexaRegion,} from "@anthropometry/domain/dexa";import { Input } from "@anthropometry/ui/components/ui/input";import { Label } from "@anthropometry/ui/components/ui/label";import type { DexaEntryMode, DexaWeightUnit, FullRegionValues, SummaryRegionValues,} from "../../hooks/useDexaFormState";
/** A third shape, for the two regions a report may print as a bare * percentage. `"percent"` is decided per region by the parent, not by the * form-wide full/summary mode. */export type DexaRegionEntryMode = DexaEntryMode | "percent";
interface DexaRegionFieldsProps { regionId: string; label: string; mode: DexaRegionEntryMode; unit: DexaWeightUnit; fullValues: FullRegionValues; onFullChange: (field: keyof FullRegionValues, value: string) => void; summaryValues: SummaryRegionValues; onSummaryChange: (field: keyof SummaryRegionValues, value: string) => void; /** Percent-only entry: the typed percentage. Its basis is form-wide and * lives beside the mode toggle, because it comes off one table. */ percentValue?: string; onPercentChange?: (value: string) => void; computed: DexaRegion; isComplete: boolean; findings: readonly DexaFinding[];}
function findingText(finding: DexaFinding): string { switch (finding.rule) { case "masses-sum-to-total": return `Fat + lean + BMC = ${finding.actual.toFixed(0)} g, but total is entered as ${finding.expected.toFixed(0)} g.`; case "negative-mass": return `${finding.field ?? "a mass"} cannot be negative.`; case "bmc-exceeds-total": return "BMC exceeds the region's total mass."; case "fat-exceeds-total": return "Fat mass exceeds the region's total mass."; case "lean-exceeds-total": return "Lean mass exceeds the region's total mass."; case "android-total-exceeds-trunk": return "Android total mass exceeds trunk total mass — android sits inside trunk."; case "android-fat-exceeds-trunk": return "Android fat mass exceeds trunk fat mass — android sits inside trunk."; case "gynoid-exceeds-trunk-plus-legs": return "Gynoid total mass exceeds trunk + legs — gynoid cannot be larger than the area it overlaps."; case "legs-plus-trunk-exceed-total": return "Legs + trunk exceed the scan's total mass."; case "percent-fat-out-of-range": return `A percentage of ${finding.actual.toFixed(1)} is not a percentage of anything.`; case "android-containment-unavailable": return "Your report printed a percentage here and no masses, so the check that android sits inside trunk cannot run. Nothing is wrong; nothing was checked."; case "gynoid-containment-unavailable": return "Your report printed a percentage here and no masses, so the check that gynoid fits within trunk + legs cannot run."; case "partition-mismatch": return `Arms + legs + trunk + head = ${finding.actual.toFixed(0)} g, but the total region is entered as ${finding.expected.toFixed(0)} g.`; default: return finding.rule; }}
export function DexaRegionFields({ regionId, label, mode, unit, fullValues, onFullChange, summaryValues, onSummaryChange, percentValue, onPercentChange, computed, isComplete, findings,}: DexaRegionFieldsProps) { const errorFindings = findings.filter((f) => f.severity === "error"); const warningFindings = findings.filter((f) => f.severity === "warning");
return ( <div className="space-y-3 rounded-md border p-3"> <h4 className="font-medium">{label}</h4>
{mode === "percent" ? ( <div className="space-y-1 sm:max-w-xs"> <Label htmlFor={`${regionId}-percent`}>% fat</Label> <Input id={`${regionId}-percent`} type="number" step="any" value={percentValue ?? ""} onChange={(e) => { onPercentChange?.(e.target.value); }} /> </div> ) : mode === "full" ? ( <div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> <div className="space-y-1"> <Label htmlFor={`${regionId}-fat`}>Fat ({unit})</Label> <Input id={`${regionId}-fat`} type="number" step="any" value={fullValues.fatMass} onChange={(e) => { onFullChange("fatMass", e.target.value); }} /> </div> <div className="space-y-1"> <Label htmlFor={`${regionId}-lean`}>Lean ({unit})</Label> <Input id={`${regionId}-lean`} type="number" step="any" value={fullValues.leanMass} onChange={(e) => { onFullChange("leanMass", e.target.value); }} /> </div> <div className="space-y-1"> <Label htmlFor={`${regionId}-bmc`}>BMC ({unit})</Label> <Input id={`${regionId}-bmc`} type="number" step="any" value={fullValues.bmc} onChange={(e) => { onFullChange("bmc", e.target.value); }} /> </div> <div className="space-y-1"> <Label htmlFor={`${regionId}-total`}>Total ({unit})</Label> <Input id={`${regionId}-total`} type="number" step="any" value={fullValues.totalMass} onChange={(e) => { onFullChange("totalMass", e.target.value); }} /> </div> </div> ) : ( <div className="grid grid-cols-1 gap-3 sm:grid-cols-3"> <div className="space-y-1"> <Label htmlFor={`${regionId}-tissue-pct`}>Tissue % fat</Label> <Input id={`${regionId}-tissue-pct`} type="number" step="any" value={summaryValues.tissuePercentFat} onChange={(e) => { onSummaryChange("tissuePercentFat", e.target.value); }} /> </div> <div className="space-y-1"> <Label htmlFor={`${regionId}-summary-total`}>Total ({unit})</Label> <Input id={`${regionId}-summary-total`} type="number" step="any" value={summaryValues.totalMass} onChange={(e) => { onSummaryChange("totalMass", e.target.value); }} /> </div> <div className="space-y-1"> <Label htmlFor={`${regionId}-summary-bmc`}>BMC ({unit})</Label> <Input id={`${regionId}-summary-bmc`} type="number" step="any" value={summaryValues.bmc} onChange={(e) => { onSummaryChange("bmc", e.target.value); }} /> </div> </div> )}
{isComplete && mode !== "percent" && ( <p className="text-muted-foreground text-xs"> % fat (of tissue): {tissuePercentFat(computed).toFixed(1)} · % fat (of total mass): {regionPercentFat(computed).toFixed(1)} </p> )}
{errorFindings.length > 0 && ( <ul className="text-destructive space-y-0.5 text-xs"> {/* Findings carry no stable id and are rebuilt from the draft on every render; region + rule is not unique on its own (a region can fail "negative-mass" on more than one field), so the index is the only key that never collides within this render. */} {errorFindings.map((finding, index) => ( <li key={index}>{findingText(finding)}</li> ))} </ul> )}
{/* Warnings never block a save, but a check that could not run has to be visible — "not checked" is not "passed", and for a percent-only region that is the whole story of what entry costs. */} {warningFindings.length > 0 && ( <ul className="text-muted-foreground space-y-0.5 text-xs"> {warningFindings.map((finding, index) => ( <li key={index}>{findingText(finding)}</li> ))} </ul> )} </div> );}apps/web/src/components/dexa/DexaScanDetail.tsx
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229/** * One scan's regional table, in the shape a report prints it — plus the * android/gynoid block with the A/G ratio. * * Two labelling requirements from kanban item 32, both sourced from item * 29's findings and both non-negotiable: * * 1. Every percentage column names its own denominator in the header, in * words — "% fat (of total mass)" and "% fat (of tissue)" — because a * real report prints both under one ambiguous "% Fat" header and that * ambiguity is the defect this feature exists to remove. * 2. `agRatioAsPrinted` renders next to the derived ratio only when they * differ, and "differ" is answered by `checkDexaScan`'s own * `ag-ratio-mismatch` finding rather than a second tolerance re-declared * here — `packages/domain/src/dexa.ts` owns `AG_RATIO_TOLERANCE`. * * No "healthy/unhealthy" verdict on the ratio is rendered here. The reference * band is kanban item 33's decision and lives in `AgRatioReference`, which * needs the age and sex this component takes as props and holds the citation * and its three constraints. It renders a distance from the reader's own * decade's mean, never a threshold — and nothing at all when the profile is * still loading, which is why `sex` and `ageAtScan` are nullable here. */import type { Doc } from "@anthropometry/convex/dataModel";import type { Sex } from "@anthropometry/domain/bodyFat";import { agRatio, checkDexaScan, isPercentOnlyRegion, regionPercentFatOnBasis, type DexaPartialRegion, type DexaRegion, type DexaRegionName, type PercentFatBasis,} from "@anthropometry/domain/dexa";import { AgRatioReference } from "./AgRatioReference";import { DexaBoneTable } from "./DexaBoneTable";import { convertWeightForDisplay, type WeightUnit,} from "@anthropometry/domain/unitConversion";import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@anthropometry/ui/components/ui/table";
const REGION_LABELS: Record<DexaRegionName, string> = { total: "Total", legs: "Legs", trunk: "Trunk", android: "Android", gynoid: "Gynoid", arms: "Arms", head: "Head",};
/** An em dash, not a zero and not a blank: the report did not print this * number, which is a different thing from the number being small. */const NOT_PRINTED = "—";
function formatMass( region: DexaPartialRegion | undefined, field: keyof DexaRegion, weightUnit: WeightUnit,): string { if (region === undefined || isPercentOnlyRegion(region)) return NOT_PRINTED; const kg = region[field] / 1000; const displayed = convertWeightForDisplay(kg, weightUnit); return `${displayed.toFixed(weightUnit === "lbs" ? 1 : 2)} ${weightUnit}`;}
/** A percent-only region can fill the column its own basis names and no * other: nothing converts one denominator into the other without the * masses, and the masses are what it does not have. */function formatPercent( region: DexaPartialRegion | undefined, basis: PercentFatBasis,): string { if (region === undefined) return NOT_PRINTED; const percent = regionPercentFatOnBasis(region, basis); return percent === undefined ? NOT_PRINTED : `${percent.toFixed(1)}%`;}
interface DexaScanDetailProps { scan: Doc<"dexaScans">; weightUnit: WeightUnit; /** Null while the profile query is in flight. */ sex: Sex | null; /** Age at the scan date, not at reading time. Null with the profile. */ ageAtScan: number | null;}
export function DexaScanDetail({ scan, weightUnit, sex, ageAtScan,}: DexaScanDetailProps) { const rows: readonly [DexaRegionName, DexaPartialRegion | undefined][] = [ ["total", scan.total], ["legs", scan.legs], ["trunk", scan.trunk], ["android", scan.android], ["gynoid", scan.gynoid], ...(scan.arms === undefined ? [] : ([["arms", scan.arms]] as [DexaRegionName, DexaRegion][])), ...(scan.head === undefined ? [] : ([["head", scan.head]] as [DexaRegionName, DexaRegion][])), ];
const derivedAgRatio = agRatio(scan); const findings = checkDexaScan(scan); const agRatioDiffers = findings.some( (finding) => finding.rule === "ag-ratio-mismatch", ); // A region the report printed as a bare percentage has no masses to show // and can fill only the one percentage column its own basis names. Saying // that once, under the table, beats eight silent em dashes. const percentOnlyRegions = rows .filter( ([, region]) => region !== undefined && isPercentOnlyRegion(region), ) .map(([name]) => REGION_LABELS[name]);
return ( <div className="space-y-4"> <div className="overflow-x-auto"> <Table> <TableHeader> <TableRow> <TableHead>Region</TableHead> <TableHead>% fat (of total mass)</TableHead> <TableHead>% fat (of tissue)</TableHead> <TableHead>Total mass</TableHead> <TableHead>Fat</TableHead> <TableHead>Lean</TableHead> <TableHead>BMC</TableHead> </TableRow> </TableHeader> <TableBody> {rows.map(([name, region]) => ( <TableRow key={name}> <TableCell className="font-medium"> {REGION_LABELS[name]} </TableCell> <TableCell>{formatPercent(region, "total-mass")}</TableCell> <TableCell>{formatPercent(region, "tissue")}</TableCell> <TableCell>{formatMass(region, "totalMassG", weightUnit)}</TableCell> <TableCell>{formatMass(region, "fatMassG", weightUnit)}</TableCell> <TableCell>{formatMass(region, "leanMassG", weightUnit)}</TableCell> <TableCell>{formatMass(region, "bmcG", weightUnit)}</TableCell> </TableRow> ))} </TableBody> </Table> </div>
{percentOnlyRegions.length > 0 && ( <p className="text-muted-foreground text-xs"> Your report printed {percentOnlyRegions.join(" and ")} as a percentage with no masses, so those rows carry only the percentage on the basis the report used. Nothing converts one basis into the other without the masses. </p> )}
<div className="rounded-md border p-3"> <h4 className="mb-2 font-medium">Android / Gynoid</h4> <dl className="grid grid-cols-2 gap-2 text-sm sm:grid-cols-4"> <div> <dt className="text-muted-foreground">Android % fat (tissue)</dt> <dd>{formatPercent(scan.android, "tissue")}</dd> </div> <div> <dt className="text-muted-foreground">Gynoid % fat (tissue)</dt> <dd>{formatPercent(scan.gynoid, "tissue")}</dd> </div> <div> <dt className="text-muted-foreground"> A/G ratio (derived {derivedAgRatio.kind === "ratio" && derivedAgRatio.basis === "total-mass" ? ", total-mass basis" : ""} ) </dt> <dd> {derivedAgRatio.kind === "ratio" ? derivedAgRatio.ratio.toFixed(2) : NOT_PRINTED} </dd> </div> {scan.agRatioAsPrinted !== undefined && agRatioDiffers && ( <div> <dt className="text-muted-foreground">A/G ratio (as printed)</dt> <dd>{scan.agRatioAsPrinted.toFixed(2)}</dd> </div> )} </dl> {derivedAgRatio.kind === "unavailable" && ( <p className="text-muted-foreground mt-2 text-xs"> {derivedAgRatio.reason === "region-missing" ? "No A/G ratio: this scan has no android or no gynoid region." : "No A/G ratio: the two percentages were printed over different denominators, and dividing one by the other would be a ratio of nothing."} </p> )} {sex !== null && ageAtScan !== null && derivedAgRatio.kind === "ratio" && ( <div className="mt-3 border-t pt-3"> <AgRatioReference ratio={derivedAgRatio.ratio} basis={derivedAgRatio.basis} sex={sex} age={ageAtScan} manufacturer={scan.scannerManufacturer} /> </div> )} </div>
{scan.bone !== undefined && <DexaBoneTable bone={scan.bone} />} </div> );}apps/web/src/components/dexa/DexaScanDialog.tsx
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162/** * The modal `DexaScanForm` opens in — add when `scan` is undefined, edit * when it is a row. Mirrors `AddMeasurementDialog` (kanban item 17): * `open`/`onOpenChange` are owned by whoever mounts this, so there is * nothing here to keep in sync with an effect, and the form calls * `onSuccess` to close itself the same way. * * `key={scan?._id ?? "new"}` is what makes editing a second scan after the * first work correctly without an effect: without it, `DexaScanForm` (and * `useDexaFormState` inside it) would keep the first scan's state mounted * and never re-read the second scan's props. Changing the `key` is * react-useeffect-discipline §1.3's answer to "reset state when the prop * that identifies the entity changes" — React tears the old `DexaScanForm` * down and mounts a fresh one, so `useDexaFormState`'s lazy initializers * run again against the new scan. */import type { Doc } from "@anthropometry/convex/dataModel";import type { DexaWeightUnit } from "../../hooks/useDexaFormState";import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,} from "@anthropometry/ui/components/ui/dialog";import { DexaScanForm } from "./DexaScanForm";
interface DexaScanDialogProps { open: boolean; onOpenChange: (open: boolean) => void; scan?: Doc<"dexaScans">; defaultUnit: DexaWeightUnit;}
export function DexaScanDialog({ open, onOpenChange, scan, defaultUnit,}: DexaScanDialogProps) { return ( <Dialog open={open} onOpenChange={onOpenChange}> <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl"> <DialogHeader> <DialogTitle>{scan ? "Edit DEXA Scan" : "Add DEXA Scan"}</DialogTitle> <DialogDescription> Transcribe the masses from the printed report. The findings panel below checks them as you type. </DialogDescription> </DialogHeader> <DexaScanForm key={scan?._id ?? "new"} scan={scan} defaultUnit={defaultUnit} onSuccess={() => { onOpenChange(false); }} /> </DialogContent> </Dialog> );}apps/web/src/components/dexa/DexaScanForm.tsx
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563/** * The DEXA scan form: mode toggle, unit toggle, every region's fields, and * the findings panel that is this whole feature's reason to exist (kanban * item 32 — "the form's job is not data entry, it is transcription * checking"). * * `checkDexaScan` runs over the current draft on every render, through * `useMemo` inside `useDexaFormState` — never an `useEffect` * (react-useeffect-discipline §1.2). Submitting and editing share this one * component: `scan` is `undefined` for "add" and a `Doc<"dexaScans">` for * "edit", and `DexaScanDialog` remounts this component with a fresh `key` * when the target scan changes, which is the React-recommended way to reset * form state for a new entity (react-useeffect-discipline §1.3) rather than * an effect that clears state when a prop changes. */import { api } from "@anthropometry/convex/api";import type { Doc } from "@anthropometry/convex/dataModel";import { refusalOf } from "@anthropometry/convex/helpers";import type { DexaFinding } from "@anthropometry/domain/dexa";import { Button } from "@anthropometry/ui/components/ui/button";import { Card, CardContent } from "@anthropometry/ui/components/ui/card";import { Input } from "@anthropometry/ui/components/ui/input";import { Label } from "@anthropometry/ui/components/ui/label";import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@anthropometry/ui/components/ui/select";import { Tabs, TabsList, TabsTrigger,} from "@anthropometry/ui/components/ui/tabs";import { useMutation } from "convex/react";import { useState } from "react";import { toast } from "sonner";import { type ALL_REGIONS, DEXA_WEIGHT_UNITS, OPTIONAL_REGIONS, REQUIRED_REGIONS, useDexaFormState, type DexaScannerManufacturer, type DexaWeightUnit,} from "../../hooks/useDexaFormState";import { formatBoneFinding } from "./bmdDisclosure";import { DexaBoneFields } from "./DexaBoneFields";import { DexaRegionFields } from "./DexaRegionFields";
const REGION_LABELS: Record<(typeof ALL_REGIONS)[number], string> = { total: "Total", legs: "Legs", trunk: "Trunk", android: "Android", gynoid: "Gynoid", arms: "Arms", head: "Head",};
const MANUFACTURERS: readonly { value: DexaScannerManufacturer; label: string;}[] = [ { value: "hologic", label: "Hologic" }, { value: "ge-lunar", label: "GE Lunar" }, { value: "other", label: "Other" },];
/** Parse the structured `invalidDexaScan` payload `dexaScans.ts` throws for * a plausibility failure the client's own findings did not catch (a future * date is the one today's findings do not check). `refusalOf` deliberately * returns null for this code — it is not one of the four session-level * refusals — so this is the second branch a catch here needs. */function invalidDexaScanFrom( error: unknown,): { region: string; rule: string; message: string } | null { if (typeof error !== "object" || error === null || !("data" in error)) { return null; } const { data } = error; let payload: unknown = data; if (typeof data === "string") { try { payload = JSON.parse(data) as unknown; } catch { return null; } } if (typeof payload !== "object" || payload === null) return null; const candidate = payload as { code?: unknown; region?: unknown; rule?: unknown; message?: unknown; }; if ( candidate.code !== "invalidDexaScan" || typeof candidate.region !== "string" || typeof candidate.rule !== "string" || typeof candidate.message !== "string" ) { return null; } return { region: candidate.region, rule: candidate.rule, message: candidate.message, };}
function saveFailureMessage(error: unknown): string { const refusal = refusalOf(error); if (refusal) { switch (refusal.code) { case "unauthenticated": case "noUserRecord": return "Your session ended. Sign in again and retry."; case "forbidden": return "That scan is no longer yours to change. Reload the page."; default: return "Failed to save scan."; } } const invalid = invalidDexaScanFrom(error); if (invalid) return `${invalid.region}: ${invalid.message}`; return "Failed to save scan.";}
function scanLevelFindingText(finding: DexaFinding): string { switch (finding.rule) { case "partition-unavailable": return "Enter arms and head to unlock a check that compares them against the total."; case "ag-ratio-mismatch": return `The A/G ratio you entered (${finding.actual.toFixed(2)}) does not match the ratio derived from the masses (${finding.expected.toFixed(2)}).`; case "scale-weight-mismatch": { const deltaKg = ((finding.deltaG ?? 0) / 1000).toFixed(1); return `Your scale weight differs from the DXA total by ${deltaKg} kg.`; } case "ag-ratio-uncheckable": return "The A/G ratio you entered could not be checked: the android and gynoid percentages are on different bases, so there is no derived ratio to compare it against."; default: return finding.rule; }}
interface DexaScanFormProps { scan?: Doc<"dexaScans">; defaultUnit: DexaWeightUnit; onSuccess?: () => void;}
export function DexaScanForm({ scan, defaultUnit, onSuccess,}: DexaScanFormProps) { const createScan = useMutation(api.dexaScans.create); const updateScan = useMutation(api.dexaScans.update); const [isSubmitting, setIsSubmitting] = useState(false);
const state = useDexaFormState(defaultUnit, scan); const { findings } = state;
const regionFindings = (region: (typeof ALL_REGIONS)[number]) => findings.filter((f) => f.region === region); // "total" and every other region's findings render inside that region's // own DexaRegionFields via regionFindings() above; what is left here is // the three rules that are about the scan as a whole rather than any one // region (partition-unavailable, ag-ratio-mismatch, scale-weight-mismatch). const scanFindings = findings.filter((f) => f.region === "scan"); // Bone findings render with the bone table, never in a composition // region's field group: five of the eight site names collide with region // names and mean a different partition of a different quantity. const boneFindings = findings.filter((f) => f.region === "bone"); const errorCount = findings.filter((f) => f.severity === "error").length;
const isPercentOnly = (region: (typeof ALL_REGIONS)[number]) => state.androidGynoidShape === "percent" && (region === "android" || region === "gynoid");
const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!state.canSubmit) return; setIsSubmitting(true); try { const payload = state.buildPayload(); if (scan) { await updateScan({ id: scan._id, ...payload }); toast.success("Scan updated!"); } else { await createScan(payload); toast.success("Scan saved!"); } onSuccess?.(); } catch (error) { toast.error(saveFailureMessage(error)); console.error(error); } finally { setIsSubmitting(false); } };
return ( <form onSubmit={(e) => { void handleSubmit(e); }} className="space-y-6" > <div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> <div className="space-y-1"> <Label htmlFor="dexa-date">Date</Label> <Input id="dexa-date" type="date" value={state.date} onChange={(e) => { state.setDate(e.target.value); }} required /> </div>
<div className="space-y-1"> <Label htmlFor="dexa-unit">Units</Label> <Select value={state.unit} onValueChange={(value) => { state.setUnit(value as DexaWeightUnit); }} > <SelectTrigger id="dexa-unit"> <SelectValue /> </SelectTrigger> <SelectContent> {DEXA_WEIGHT_UNITS.map((unit) => ( <SelectItem key={unit} value={unit}> {unit} </SelectItem> ))} </SelectContent> </Select> </div>
<div className="space-y-1 sm:col-span-2"> <Label htmlFor="dexa-manufacturer">Scanner manufacturer</Label> <Select value={state.scannerManufacturer} onValueChange={(value) => { state.setScannerManufacturer(value as DexaScannerManufacturer); }} required > <SelectTrigger id="dexa-manufacturer"> <SelectValue placeholder="Select a manufacturer" /> </SelectTrigger> <SelectContent> {MANUFACTURERS.map((m) => ( <SelectItem key={m.value} value={m.value}> {m.label} </SelectItem> ))} </SelectContent> </Select> </div>
<div className="space-y-1"> <Label htmlFor="dexa-model">Scanner model (optional)</Label> <Input id="dexa-model" value={state.scannerModel} onChange={(e) => { state.setScannerModel(e.target.value); }} /> </div>
<div className="space-y-1"> <Label htmlFor="dexa-software">Software version (optional)</Label> <Input id="dexa-software" value={state.scannerSoftwareVersion} onChange={(e) => { state.setScannerSoftwareVersion(e.target.value); }} /> </div>
<div className="space-y-1"> <Label htmlFor="dexa-facility">Facility (optional)</Label> <Input id="dexa-facility" value={state.facility} onChange={(e) => { state.setFacility(e.target.value); }} /> </div>
<div className="space-y-1"> <Label htmlFor="dexa-ag-ratio">A/G ratio as printed (optional)</Label> <Input id="dexa-ag-ratio" type="number" step="any" value={state.agRatioAsPrinted} onChange={(e) => { state.setAgRatioAsPrinted(e.target.value); }} /> </div>
<div className="space-y-1"> <Label htmlFor="dexa-scale-weight"> Scale weight ({state.unit}, optional) </Label> <Input id="dexa-scale-weight" type="number" step="any" value={state.scaleWeight} onChange={(e) => { state.setScaleWeight(e.target.value); }} /> </div> </div>
<div className="space-y-2"> <Tabs value={state.mode} onValueChange={(value) => { state.setMode(value === "summary" ? "summary" : "full"); }} > <TabsList> <TabsTrigger value="full">Full (4 masses/region)</TabsTrigger> <TabsTrigger value="summary"> Summary (3 numbers/region) </TabsTrigger> </TabsList> </Tabs> <p className="text-muted-foreground text-xs"> {state.mode === "full" ? "Fat, lean, BMC and total mass per region — the redundancy this form uses to catch a mistyped digit." : "Tissue % fat, total mass and BMC per region. The other two masses are solved exactly from these three, so the sum always checks out by construction — the transcription check full mode gives you is gone."} </p> </div>
<div className="space-y-2"> <Tabs value={state.includeArmsAndHead ? "seven" : "five"} onValueChange={(value) => { state.setIncludeArmsAndHead(value === "seven"); }} > <TabsList> <TabsTrigger value="five">5 regions</TabsTrigger> <TabsTrigger value="seven">7 regions (arms + head)</TabsTrigger> </TabsList> </Tabs> <p className="text-muted-foreground text-xs"> Entering arms and head unlocks a check that arms + legs + trunk + head sum to the total — the strongest identity this form can run. </p> </div>
<div className="space-y-2"> <Tabs value={state.androidGynoidShape} onValueChange={(value) => { state.setAndroidGynoidShape( value === "percent" ? "percent" : "masses", ); }} > <TabsList> <TabsTrigger value="masses">Android/gynoid: masses</TabsTrigger> <TabsTrigger value="percent">Android/gynoid: % fat only</TabsTrigger> </TabsList> </Tabs> <p className="text-muted-foreground text-xs"> Some reports print android and gynoid as a percentage with no masses at all. The A/G ratio is a ratio of two percentages, so it survives intact — but the four-mass check that catches a mistyped digit does not exist for a region that printed one number. </p> {state.androidGynoidShape === "percent" && ( <div className="space-y-1 sm:max-w-md"> <Label htmlFor="dexa-percent-basis"> Which denominator does your report use? </Label> <Select value={state.percentFatBasis} onValueChange={(value) => { state.setPercentFatBasis( value === "total-mass" ? "total-mass" : "tissue", ); }} > <SelectTrigger id="dexa-percent-basis"> <SelectValue placeholder="Select a basis" /> </SelectTrigger> <SelectContent> <SelectItem value="tissue"> of tissue — fat + lean, bone excluded </SelectItem> <SelectItem value="total-mass"> of total mass — fat + lean + BMC </SelectItem> </SelectContent> </Select> <p className="text-muted-foreground text-xs"> There is no default, because the answer changes the A/G ratio and nothing afterwards can tell that it was guessed. A trending page and the headline body-fat number use the tissue basis; the one-page regional summary table uses total mass. If your report does not say, check its other pages. </p> </div> )} </div>
<Card> <CardContent className="space-y-2 py-4"> <h3 className="text-sm font-semibold">Findings</h3> {findings.length === 0 ? ( <p className="text-muted-foreground text-sm"> No issues found yet. </p> ) : ( <> {errorCount > 0 && ( <p className="text-destructive text-sm"> {errorCount} issue{errorCount === 1 ? "" : "s"} must be fixed before this scan can be saved. </p> )} <ul className="space-y-1 text-sm"> {scanFindings.map((finding, index) => ( <li key={index} className={ finding.severity === "error" ? "text-destructive" : "text-muted-foreground" } > {scanLevelFindingText(finding)} </li> ))} </ul> </> )} </CardContent> </Card>
<div className="space-y-3"> {REQUIRED_REGIONS.map((region) => ( <DexaRegionFields key={region} regionId={region} label={REGION_LABELS[region]} mode={isPercentOnly(region) ? "percent" : state.mode} percentValue={ region === "android" || region === "gynoid" ? state.percentValues[region] : undefined } onPercentChange={ region === "android" || region === "gynoid" ? (value) => { state.setPercentValue(region, value); } : undefined } unit={state.unit} fullValues={state.fullValues[region]} onFullChange={(field, value) => { state.setFullField(region, field, value); }} summaryValues={state.summaryValues[region]} onSummaryChange={(field, value) => { state.setSummaryField(region, field, value); }} computed={state.regions[region]} isComplete={state.isRegionComplete(region)} findings={regionFindings(region)} /> ))}
{state.includeArmsAndHead && OPTIONAL_REGIONS.map((region) => ( <DexaRegionFields key={region} regionId={region} label={REGION_LABELS[region]} mode={state.mode} unit={state.unit} fullValues={state.fullValues[region]} onFullChange={(field, value) => { state.setFullField(region, field, value); }} summaryValues={state.summaryValues[region]} onSummaryChange={(field, value) => { state.setSummaryField(region, field, value); }} computed={state.regions[region]} isComplete={state.isRegionComplete(region)} findings={regionFindings(region)} /> ))} </div>
<DexaBoneFields values={state.boneValues} onChange={state.setBoneValue} referenceDatabase={state.boneReferenceDatabase} onReferenceDatabaseChange={state.setBoneReferenceDatabase} analysisMode={state.boneAnalysisMode} onAnalysisModeChange={state.setBoneAnalysisMode} />
{boneFindings.length > 0 && ( <ul className="space-y-0.5 text-xs"> {boneFindings.map((finding, index) => ( <li key={index} className={ finding.severity === "error" ? "text-destructive" : "text-muted-foreground" } > {formatBoneFinding(finding)} </li> ))} </ul> )}
<div className="space-y-1"> <Label htmlFor="dexa-notes">Notes (optional)</Label> <Input id="dexa-notes" value={state.notes} onChange={(e) => { state.setNotes(e.target.value); }} /> </div>
<Button type="submit" className="w-full" disabled={isSubmitting || !state.canSubmit} > {isSubmitting ? "Saving…" : scan ? "Update scan" : "Save scan"} </Button> </form> );}apps/web/src/components/dexa/MeasurementGuidance.tsx
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310/** * "Which measurements are worth taking" — kanban item 35. Surfaces item * 34's leave-one-out analysis (`analyseContributions`, via * `useMeasurementGuidance`) as advice a user can act on: which sites carry * the estimate, which are near-duplicates of sites already taken, and * which methods are triangulating rather than agreeing. * * The site table is primary and sorted by `meanAbsDeltaPp` descending — * weight-at-risk is how much of the scheme depends on a site, the shift is * what actually changes, and they come apart precisely in the redundancy * case this panel exists to find (kanban 0035, "What was considered and * rejected"). The method table is secondary, grouped by family so it does * not imply ten independent opinions. * * Every classification and format comes from `useMeasurementGuidance.ts`. * This component only lays the numbers out — sorting the site rows and * grouping the method rows by family, both cheap enough to do during render * without a `useMemo` of their own; the expensive step * (`analyseContributions`) is already memoised once inside the hook. * * Works with zero DEXA scans: `maePp` is null on every method until one * exists, and per item 34 the site analysis needs none. When every * `maePp` is null the "Error vs scans" column is omitted entirely rather * than rendered as a column of dashes. Leverage and marginal contribution * need no scan either — they are properties of the newest session's own * spread — so they are always present, and dash out per row only for a * method that is not in the composite at all. */import type { MethodFamily } from "@anthropometry/domain/dexaCalibration";import type { MethodContribution } from "@anthropometry/domain/methodContribution";import { Badge } from "@anthropometry/ui/components/ui/badge";import { Button } from "@anthropometry/ui/components/ui/button";import { Card, CardContent, CardDescription, CardHeader, CardTitle,} from "@anthropometry/ui/components/ui/card";import { Skeleton } from "@anthropometry/ui/components/ui/skeleton";import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@anthropometry/ui/components/ui/table";import { Link } from "react-router";import { settingsPath } from "../../routes";import { formatLeverage, formatMarginalContribution, useMeasurementGuidance,} from "../../hooks/useMeasurementGuidance";import { FAMILY_INDEPENDENCE_COPY, FAMILY_LABEL, METHOD_LABEL, formatMae, formatMethodEstimate, formatWeightShare,} from "./provenanceStrings";import { SiteContributionRow } from "./SiteContributionRow";
/** `report.methods`, grouped by family in first-appearance order (navy's * `circumference` first, then `siri-2C`, then `evans-direct`) — the same * order `BODY_FAT_METHODS` produces the rows in, so the grouping never * reorders anything a reader already scanned. */function groupByFamily( methods: readonly MethodContribution[],): ReadonlyMap<MethodFamily, readonly MethodContribution[]> { const groups = new Map<MethodFamily, MethodContribution[]>(); for (const entry of methods) { const group = groups.get(entry.family); if (group) { group.push(entry); } else { groups.set(entry.family, [entry]); } } return groups;}
function familyShare(members: readonly MethodContribution[]): number { return members.reduce((sum, entry) => sum + entry.weight, 0);}
export function MeasurementGuidance() { const { isLoading, report, sex, missingRace, provisional } = useMeasurementGuidance();
return ( <Card> <CardHeader> <CardTitle>Which measurements are worth taking</CardTitle> <CardDescription> Which skinfold sites are carrying your estimate, and which methods are triangulating rather than agreeing — from your last{" "} {report ? report.measurementsAnalysed : "12"} measurement {report?.measurementsAnalysed === 1 ? "" : "s"}. </CardDescription> </CardHeader> <CardContent className="space-y-6"> {isLoading ? ( <div className="space-y-2"> {Array.from({ length: 4 }, (_, i) => ( <Skeleton key={i} className="h-10 w-full" /> ))} </div> ) : report === null ? ( <p className="text-muted-foreground text-sm"> Add your profile to see which measurements are carrying your estimate. </p> ) : report.measurementsAnalysed === 0 ? ( <p className="text-muted-foreground text-sm"> Record a measurement session to see which sites are worth continuing to take. </p> ) : sex === null ? null : ( <> <p className="rounded-md border p-2 text-xs"> Dropping a site — even one that “contributes little right now” — breaks comparability with your own history. A session taken without a site is not comparable to one taken with it, whatever the composite says. </p>
{missingRace && ( <div className="rounded-md border border-amber-500/50 bg-amber-500/10 p-3 text-xs"> <p className="font-medium"> Both Evans equations are unavailable </p> <p className="text-muted-foreground"> The Evans equations take race as an input and return nothing without one, so an entire method family is missing from your estimate — and the guidance below is suppressed until it is resolved, because it changes substantially once race is set. </p> <Button asChild variant="link" size="sm" className="h-auto px-0" > <Link to={settingsPath()}>Add it in Settings</Link> </Button> </div> )}
{provisional && ( <p className="rounded-md border p-2 text-xs"> Only one measurement analysed. The shift and the methods each site enables are shown below; the “contributes little” judgement is withheld until there is a pattern rather than one occasion. </p> )}
<div> <h3 className="mb-2 text-sm font-semibold">By site</h3> <div className="overflow-x-auto"> <Table> <TableHeader> <TableRow> <TableHead>Site</TableHead> <TableHead>Typical shift if dropped</TableHead> <TableHead>Methods it enables</TableHead> <TableHead>Share of the weighting</TableHead> <TableHead>State</TableHead> </TableRow> </TableHeader> <TableBody> {report.sites .slice() .sort((a, b) => b.meanAbsDeltaPp - a.meanAbsDeltaPp) .map((contribution) => ( <SiteContributionRow key={contribution.site} contribution={contribution} sex={sex} context={{ measurementsAnalysed: report.measurementsAnalysed, missingRace, }} /> ))} </TableBody> </Table> </div> </div>
<MethodTable methods={report.methods} /> </> )} </CardContent> </Card> );}
function MethodTable({ methods,}: { readonly methods: readonly MethodContribution[];}) { const showError = methods.some((entry) => entry.maePp !== null); const groups = groupByFamily(methods); const columnCount = showError ? 6 : 5;
return ( <div className="space-y-2"> <h3 className="text-sm font-semibold">By method</h3> <p className="text-muted-foreground text-xs"> {FAMILY_INDEPENDENCE_COPY} A method with a large weight and near-zero leverage is agreeable, not informative — it says what its neighbours already say. Leverage is how far a method sits from what the others say; marginal contribution is its weight times its leverage, which is what the composite actually moves if the method goes. </p>
<div className="overflow-x-auto"> <Table> <TableHeader> <TableRow> <TableHead>Method</TableHead> <TableHead>Estimate</TableHead> {showError && <TableHead>Error vs scans</TableHead>} <TableHead>Weight</TableHead> <TableHead>Leverage</TableHead> <TableHead>Marginal contribution</TableHead> </TableRow> </TableHeader> <TableBody> {[...groups.entries()].map(([family, members]) => ( <FamilyGroup key={family} family={family} members={members} showError={showError} columnCount={columnCount} /> ))} </TableBody> </Table> </div>
<div className="overflow-x-auto"> <p className="text-muted-foreground mb-1 text-xs font-medium"> Reading the leverage/agreement pair </p> <Table className="max-w-xl"> <TableHeader> <TableRow> <TableHead></TableHead> <TableHead>Agrees with your scans</TableHead> <TableHead>Disagrees with your scans</TableHead> </TableRow> </TableHeader> <TableBody> <TableRow> <TableCell className="font-medium">High leverage</TableCell> <TableCell>Carrying real information</TableCell> <TableCell>Probably wrong for you</TableCell> </TableRow> <TableRow> <TableCell className="font-medium">Low leverage</TableCell> <TableCell colSpan={2}>Says what its neighbours say</TableCell> </TableRow> </TableBody> </Table> </div> </div> );}
function FamilyGroup({ family, members, showError, columnCount,}: { readonly family: MethodFamily; readonly members: readonly MethodContribution[]; readonly showError: boolean; readonly columnCount: number;}) { return ( <> <TableRow className="bg-muted/50 hover:bg-muted/50"> <TableCell colSpan={columnCount} className="text-xs font-medium"> {FAMILY_LABEL[family]} — {formatWeightShare(familyShare(members))} of the composite </TableCell> </TableRow> {members.map((entry) => ( <TableRow key={entry.method}> <TableCell>{METHOD_LABEL[entry.method]}</TableCell> <TableCell>{formatMethodEstimate(entry.estimate)}</TableCell> {showError && <TableCell>{formatMae(entry.maePp)}</TableCell>} <TableCell> <Badge variant="outline">{formatWeightShare(entry.weight)}</Badge> </TableCell> <TableCell>{formatLeverage(entry)}</TableCell> <TableCell>{formatMarginalContribution(entry)}</TableCell> </TableRow> ))} </> );}apps/web/src/components/dexa/ScanChartLegend.tsx
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116/** * What the marks on the body-fat chart mean, how many of them you are being * shown, and the sentence that keeps the line honest. * * Three requirements live here rather than in the chart, because all three * are copy: * * - The denominator is named in words. Every percentage in the DEXA UI says * which mass it is a percentage of, and this one is fat over **total mass, * bone included** — the same basis the estimate is fitted against, which is * the only reason the two are comparable on one pair of axes. * - A subset is never plotted silently. When the selected range hides scans, * both counts are stated, so a reader who counts the points and then reads * the count in the provenance popover does not find a contradiction. * - Kanban item 33's rule 8: a chart whose whole shape moved the day a scan * was entered has to say so. Item 33 said it in the card's description * because the chart change was out of its scope; this is that chart change, * so the sentence moves down here, beneath the line it is about. It is * rendered for every reader, including one with no scans at all. * * An unpaired scan is a scheduling fact, not a failed scan, and the copy is * written to read that way — it names the missing measurement and the action * that fixes it, and never calls the scan bad. */import type { ReactNode } from "react";import { HISTORY_RECOMPUTED_COPY } from "./provenanceStrings";
export interface ScanChartLegendProps { /** Scans plotted, after the date-range filter. */ readonly shownCount: number; /** Scans stored, which is what the calibration used. */ readonly storedCount: number; /** Whether any of the plotted scans is unpaired, which decides whether the * hollow mark needs explaining at all. */ readonly hasUnpaired: boolean;}
/** The basis, in words. Requirement 1, and item 32's rule that every DEXA * percentage names its denominator. */const BASIS_COPY = "% fat of total mass, the same basis as the estimate.";
/** * Both counts, always — never "2 scans" when there are four. * * The plural cases are spelled out rather than assembled from fragments * because "1 of 1 scans" is the kind of sentence a fragment-assembler * produces and a reader notices. */export function formatScanCoverage(shown: number, stored: number): string { if (stored === 0) return ""; if (shown < stored) { return `${shown} of ${stored} scans in this range; the calibration uses all ${stored}.`; } return stored === 1 ? "Your one scan is in this range, and the calibration uses it." : `All ${stored} of your scans are in this range, and the calibration uses all of them.`;}
/** * One swatch and what it means. * * The colours are named as theme tokens rather than as the `--color-scanPaired` * variables the chart uses, because those are emitted by `ChartStyle` onto the * chart container and do not resolve out here. They have to agree with * `useProgressChartData`'s chart config by hand: `chart-2` is the body-fat * series' own colour, which is the point — an anchor of that line is drawn in * that line's colour, and an unpaired scan is muted and a different shape. */function ScanKey({ filled, children,}: { readonly filled: boolean; readonly children: ReactNode;}) { return ( <li className="flex items-start gap-2"> <span aria-hidden className={ filled ? "bg-chart-2 mt-0.5 size-2.5 shrink-0 rounded-full" : "border-muted-foreground mt-0.5 size-2.5 shrink-0 rotate-45 border-2" } /> <span>{children}</span> </li> );}
export function ScanChartLegend({ shownCount, storedCount, hasUnpaired,}: ScanChartLegendProps) { return ( <div className="text-muted-foreground mt-3 space-y-2 text-xs"> {storedCount > 0 && ( <ul className="space-y-1"> <ScanKey filled> DEXA scan the calibration is anchored to. {BASIS_COPY} </ScanKey> {hasUnpaired && ( <ScanKey filled={false}> DEXA scan with no measurement session within 36 hours of it, so it did not calibrate anything. Entering the measurements you took that week gives it weight. </ScanKey> )} </ul> )} {storedCount > 0 && <p>{formatScanCoverage(shownCount, storedCount)}</p>} <p>{HISTORY_RECOMPUTED_COPY}</p> </div> );}apps/web/src/components/dexa/SiteContributionRow.tsx
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112/** * One row of the site table — the table users read first, per kanban item * 35. All formatting comes from `useMeasurementGuidance.ts`, which is where * the "must never" rules are encoded and tested; nothing here computes a * number. */import { Collapsible, CollapsibleContent, CollapsibleTrigger,} from "@anthropometry/ui/components/ui/collapsible";import { Badge } from "@anthropometry/ui/components/ui/badge";import { Button } from "@anthropometry/ui/components/ui/button";import { TableCell, TableRow } from "@anthropometry/ui/components/ui/table";import type { Sex } from "@anthropometry/domain/bodyFat";import type { SiteContribution } from "@anthropometry/domain/methodContribution";import { ChevronDown } from "lucide-react";import { useState } from "react";import { formatWeightShare, METHOD_LABEL } from "./provenanceStrings";import { SITE_LABEL, formatSiteShift, siteChips, siteShare, siteState, siteStateCopy, type SiteState, type SiteStateContext,} from "../../hooks/useMeasurementGuidance";
const STATE_BADGE_VARIANT: Readonly< Record<SiteState, "default" | "secondary" | "outline">> = { essential: "default", "load-bearing": "secondary", "contributes-little": "outline", "not-taken": "outline", provisional: "outline",};
interface SiteContributionRowProps { readonly contribution: SiteContribution; readonly sex: Sex; /** The suppression context, rather than a computed state: the row needs * both the primary state and the chip list, and deriving them from one * input here is what stops the two from being able to disagree. */ readonly context: SiteStateContext;}
export function SiteContributionRow({ contribution, sex, context,}: SiteContributionRowProps) { const [methodsOpen, setMethodsOpen] = useState(false); // The description answers for the primary state alone; the chips answer // for everything true of the site, which for load-bearing-and-essential is // two things. const state = siteState(contribution, context); const chips = siteChips(contribution, context); const copy = siteStateCopy(state, contribution); const methodCount = contribution.methodsDisabled.length;
return ( <TableRow> <TableCell className="font-medium capitalize"> {SITE_LABEL[contribution.site]} </TableCell> <TableCell>{formatSiteShift(contribution)}</TableCell> <TableCell> {methodCount === 0 ? ( <span className="text-muted-foreground">none</span> ) : ( <Collapsible open={methodsOpen} onOpenChange={setMethodsOpen}> <CollapsibleTrigger asChild> <Button variant="ghost" size="sm" className="h-auto gap-1 px-1 py-0.5" > {methodCount} method{methodCount === 1 ? "" : "s"} <ChevronDown className={`h-3 w-3 transition-transform ${methodsOpen ? "rotate-180" : ""}`} /> </Button> </CollapsibleTrigger> <CollapsibleContent> <ul className="text-muted-foreground mt-1 list-disc pl-4 text-xs"> {contribution.methodsDisabled.map((method) => ( <li key={method}>{METHOD_LABEL[method]}</li> ))} </ul> </CollapsibleContent> </Collapsible> )} </TableCell> <TableCell>{formatWeightShare(siteShare(contribution, sex))}</TableCell> <TableCell> <div className="space-y-1"> <div className="flex flex-wrap items-center gap-1"> {chips.map((chip) => ( <Badge key={chip} variant={STATE_BADGE_VARIANT[chip]}> {siteStateCopy(chip, contribution).label} </Badge> ))} </div> <p className="text-muted-foreground text-xs">{copy.detail}</p> </div> </TableCell> </TableRow> );}apps/web/src/components/dexa/agReferenceBand.test.ts
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586/** * The band lookup, not the component. * * `testing-philosophy` §8 would leave a plain table lookup alone, and most of * `agReferenceBand.ts` is one. What is covered here is the part that is not: * the two refusals, which are the whole safety argument for shipping this * band at all, and the sign of the SD distance, which is the difference * between telling a user they are above the mean and telling them they are * below it. */
import { describe, expect, it } from "vitest";
import { formatAgComparison, interpretAgRatio, type AgInterpretation,} from "./agReferenceBand";
/** The worked example from kanban item 33: a man aged 40–49 at 0.61, against * a decade mean of 0.66 and an SD of 0.18. */const banded = (): AgInterpretation => interpretAgRatio(0.61, "male", 44, "ge-lunar", "tissue");
describe("interpretAgRatio", () => { it("places a GE scan in its own decade and signs the distance", () => { const result = banded();
expect(result.kind).toBe("banded"); if (result.kind !== "banded") return; expect(result.band.mean).toBe(0.66); expect(result.band.sd).toBe(0.18); // Below the mean, so negative — the sign is the claim. expect(result.sdFromMean).toBeCloseTo(-0.278, 3); });
it("refuses to band a scan from anything but a GE Lunar system", () => { // The cohort is GE-only and Hologic disagrees with GE by more than a // percentage point on body fat, so a cross-manufacturer band would // report the machines' difference as the user's. expect(interpretAgRatio(0.61, "male", 44, "hologic", "tissue").kind).toBe( "no-reference-for-scanner", ); expect(interpretAgRatio(0.61, "male", 44, "other", "tissue").kind).toBe( "no-reference-for-scanner", ); });
it("refuses to band a ratio that is not on the tissue basis", () => { // The table is a distribution of tissue-basis ratios. A ratio built from // two total-mass percentages differs by about 0.014 on the reference // report — small enough to look right, which is why it is refused rather // than converted. Nothing converts it: the masses are what a percent-only // region does not have. expect( interpretAgRatio(0.61, "male", 44, "ge-lunar", "total-mass").kind, ).toBe("no-reference-for-basis"); });
it("refuses to band an age outside the published 20–79 range", () => { expect(interpretAgRatio(0.61, "male", 19, "ge-lunar", "tissue").kind).toBe( "no-reference-for-age", ); expect(interpretAgRatio(0.61, "female", 80, "ge-lunar", "tissue").kind).toBe( "no-reference-for-age", ); expect(interpretAgRatio(0.61, "female", 79, "ge-lunar", "tissue").kind).toBe( "banded", ); });});
describe("formatAgComparison", () => { it("reads as a distance from the mean, never as a verdict", () => { const rendered = formatAgComparison(banded(), "male");
expect(rendered).toContain("0.3 SD below the mean"); expect(rendered).toContain("men aged 40–49"); expect(rendered).toContain("mean 0.66, SD 0.18"); // Not a target, not a threshold, and never the consumer-report line — // 1.0 is roughly an SD ABOVE the mean for a man aged 60–69. expect(rendered).not.toMatch(/ideal|healthy|normal|risk|good|1\.0/i); // A ± here would read as a confidence interval on this user's ratio. expect(rendered).not.toContain("±"); });});apps/web/src/components/dexa/agReferenceBand.ts
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161/** * The one reference band this feature ships: the android-to-gynoid ratio * against an age- and sex-banded population mean. * * SOURCE. Imboden MT, Welch WA, Swartz AM, Montoye AHK, Finch HW, Harber MP, * Kaminsky LA. *Reference standards for body fat measures using GE dual * energy x-ray absorptiometry in Caucasian adults.* PLOS ONE. * 2017;12(4):e0175110. doi:10.1371/journal.pone.0175110. n = 3,327, GE Lunar * Prodigy and iDXA. Table 4, mean ± SD of the A/G ratio by decade. Pooled * values are men 0.66 ± 0.22 and women 0.43 ± 0.17. * * THREE CONSTRAINTS, all of which bind: * * 1. **The cohort is 95% Caucasian and every scan was on a GE-Healthcare * system.** Hologic and GE differ by over a percentage point on whole-body * percent fat, and the Hologic reference values that exist (Kelly et al., * from NHANES) are a different table that has not been retrieved. A scan * from anything but a GE Lunar system therefore gets its ratio with **no * interpretation**, and is told why — a cross-manufacturer comparison * dressed up as a percentile is worse than no comparison. * 2. **Ages outside 20–79 are out of range.** Show the ratio, no * interpretation. There is no row to extrapolate from and the distribution * shifts by roughly 0.3 across the adult range in men. * 3. **The "ideal A/G ratio is under 1.0" line consumer reports print is not * a target and is not repeated here.** Against this table 1.0 is roughly a * standard deviation *above* the mean for a man aged 60–69 and far above * any female mean, so repeating it would tell almost every user they are * fine. This module reports distance from the mean in SDs and never a * pass/fail verdict: the ratio's clinical meaning is a continuous * association with cardiometabolic risk, not a threshold. * * ONLY THE MEANS AND SDs ARE USED. The paper also prints percentile columns, * but they run 90th through 10th with *increasing* values — for women aged * 20–29 the column headed "90th" holds 0.20 and the one headed "10th" holds * 0.47, against a mean of 0.33 — so the percentile labels are reverse-coded * relative to the raw distribution. Getting that backwards would label a * high-risk ratio as excellent. The means and SDs carry no such ambiguity. * **If percentiles are ever wanted, read the paper's methods section first * and record the convention in kanban item 33 before shipping a single * band.** */
import type { Sex } from "@anthropometry/domain/bodyFat";import type { PercentFatBasis } from "@anthropometry/domain/dexa";import type { ScannerManufacturer } from "@anthropometry/domain/dexaCalibration";
export interface AgReferenceBand { /** Inclusive decade bounds, in years. */ readonly fromAge: number; readonly toAge: number; readonly mean: number; readonly sd: number;}
/** Table 4, verbatim. Decades in ascending order; the lookup relies on the * bounds rather than the order, so a reordering cannot silently mis-band. */const BANDS: Readonly<Record<Sex, readonly AgReferenceBand[]>> = { female: [ { fromAge: 20, toAge: 29, mean: 0.33, sd: 0.11 }, { fromAge: 30, toAge: 39, mean: 0.39, sd: 0.17 }, { fromAge: 40, toAge: 49, mean: 0.44, sd: 0.17 }, { fromAge: 50, toAge: 59, mean: 0.48, sd: 0.18 }, { fromAge: 60, toAge: 69, mean: 0.5, sd: 0.15 }, { fromAge: 70, toAge: 79, mean: 0.46, sd: 0.13 }, ], male: [ { fromAge: 20, toAge: 29, mean: 0.47, sd: 0.13 }, { fromAge: 30, toAge: 39, mean: 0.57, sd: 0.15 }, { fromAge: 40, toAge: 49, mean: 0.66, sd: 0.18 }, { fromAge: 50, toAge: 59, mean: 0.73, sd: 0.21 }, { fromAge: 60, toAge: 69, mean: 0.77, sd: 0.2 }, { fromAge: 70, toAge: 79, mean: 0.76, sd: 0.19 }, ],};
/** The citation, rendered wherever a band is. A reference band without its * source is an assertion. */export const IMBODEN_CITATION = "Imboden et al., Reference standards for body fat measures using GE dual energy x-ray absorptiometry in Caucasian adults. PLOS ONE 2017;12(4):e0175110. n = 3,327, GE Lunar Prodigy and iDXA, 95% Caucasian.";
export type AgInterpretation = | { readonly kind: "banded"; readonly band: AgReferenceBand; /** Signed distance from the decade mean, in SDs. Negative is below. */ readonly sdFromMean: number; } | { readonly kind: "no-reference-for-scanner" } | { readonly kind: "no-reference-for-age" } | { readonly kind: "no-reference-for-basis" };
/** * Where a ratio sits in its own decade's distribution, or why it cannot be * placed there. Never a verdict — the caller renders a distance, not a grade. * * The scanner check comes first deliberately: a Hologic scan of a * seventy-year-old has two reasons for no interpretation and the * manufacturer is the one that would not go away if the user aged. */export function interpretAgRatio( ratio: number, sex: Sex, age: number, manufacturer: ScannerManufacturer, basis: PercentFatBasis,): AgInterpretation { // The table is a distribution of **tissue-basis** ratios. A ratio derived // from two total-mass percentages — which is what a report printing a // percent-only android and gynoid over total mass yields — is a different // quantity, and placing it in this table is the same category of error as // placing a Hologic scan in a GE one. The gap is small (about 0.014 on the // reference report) and that is exactly the danger: it would look right. if (basis !== "tissue") return { kind: "no-reference-for-basis" };
if (manufacturer !== "ge-lunar") { return { kind: "no-reference-for-scanner" }; }
const band = BANDS[sex].find( (candidate) => age >= candidate.fromAge && age <= candidate.toAge, ); if (band === undefined) return { kind: "no-reference-for-age" };
return { kind: "banded", band, sdFromMean: (ratio - band.mean) / band.sd };}
/** "0.61" — the ratio itself, always shown whether or not it can be placed. */export function formatAgRatio(ratio: number): string { return ratio.toFixed(2);}
/** * "about 0.3 SD below the mean for men aged 40–49 (mean 0.66, SD 0.18)". * * Written as a distance rather than a `±` band: the population SD is a real * SD and could honestly carry one, but a `±` beside a body-composition number * reads as a confidence interval on *this user's* value, which it is not. */export function formatAgComparison( interpretation: AgInterpretation, sex: Sex,): string { switch (interpretation.kind) { case "no-reference-for-scanner": return "No reference band. The published standards for this ratio come from GE Lunar systems, and Hologic and GE disagree by more than a percentage point on body fat, so comparing across them would invent a difference the machines already have."; case "no-reference-for-age": return "No reference band. The published standards cover ages 20 to 79, and extrapolating past them would be guessing — the distribution shifts by roughly 0.3 across the adult range."; case "no-reference-for-basis": return "No reference band. This ratio came from percentages taken over total mass, and the published standards are a distribution of tissue-basis ratios. The two differ by little enough to look right and enough to be wrong."; case "banded": { const { band, sdFromMean } = interpretation; const magnitude = Math.abs(sdFromMean).toFixed(1); const people = sex === "male" ? "men" : "women"; const place = Math.abs(sdFromMean) < 0.05 ? "right at the mean" : `about ${magnitude} SD ${sdFromMean < 0 ? "below" : "above"} the mean`; return `${place} for ${people} aged ${band.fromAge}–${band.toAge} (mean ${band.mean.toFixed(2)}, SD ${band.sd.toFixed(2)}).`; } }}apps/web/src/components/dexa/bmdDisclosure.test.ts
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758import { describe, expect, it } from "vitest";import { BMD_NO_INTERPRETATION, formatBmd, formatBoneComparability, formatBoneProvenance,} from "./bmdDisclosure";
describe("formatBmd", () => { it("prints three decimals, which is what a densitometry table prints", () => { expect(formatBmd(1.2)).toBe("1.200"); expect(formatBmd(1.2346)).toBe("1.235"); });
it("renders a missing site as an em dash rather than as a number", () => { // `restrict-template-expressions` makes the alternative a compile error, // but the visible failure this guards is "undefined" or "NaN" in a cell. expect(formatBmd(undefined)).toBe("—"); });});
describe("formatBoneProvenance", () => { it("names both fields when both are present", () => { expect( formatBoneProvenance("USA (Combined NHANES/Lunar)", "Enhanced Analysis"), ).toBe("USA (Combined NHANES/Lunar) · Enhanced Analysis"); });
it("says the reference database is missing rather than rendering an empty label", () => { // A BMD without the population it was compared against cannot be read // against a later scan, so the gap has to be visible. expect(formatBoneProvenance(" ", undefined)).toBe( "reference database not recorded", ); expect(formatBoneProvenance("NHANES", " ")).toBe("NHANES"); });});
describe("the disclosure copy", () => { it("carries no verdict word", () => { // The cheapest possible guard on the requirement this whole module // exists for: the WHO thresholds are not valid at these sites, so no // copy beside a BMD number may grade one. A later edit that softens // this into "your bone density is normal" fails here. for (const forbidden of ["osteoporotic", "normal", "healthy", "low bone"]) { expect(BMD_NO_INTERPRETATION.toLowerCase()).not.toContain(forbidden); } });
it("names both comparability warnings without grading either", () => { expect(formatBoneComparability("mixed-reference-databases")).toContain( "reference databases", ); expect(formatBoneComparability("mixed-analysis-modes")).toContain( "analysis modes", ); });});apps/web/src/components/dexa/bmdDisclosure.ts
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125/** * The copy and the citations for the densitometry table — and the record of * why this feature ships **no interpretation of a BMD number at all**. * * WHY THERE IS NO VERDICT HERE. The thresholds people have heard of — * osteopenia at a T-score of −1.0, osteoporosis at −2.5 — are defined for * specific measurement sites and are not valid for total-body BMD or for a * subregion like Ribs or Pelvis. Applying them to the numbers on this table * would tell a healthy person they have a bone disease. * * - WHO Study Group. *Assessment of fracture risk and its application to * screening for postmenopausal osteoporosis.* WHO Technical Report Series * 843. Geneva: World Health Organization; 1994. The origin of the −1.0 and * −2.5 thresholds. * - International Society for Clinical Densitometry, *Official Positions — * Adult* (2015, carried into 2023). Osteoporosis may be diagnosed in * postmenopausal women and men aged 50 and over when the T-score of the * **lumbar spine, total hip or femoral neck** is −2.5 or below; the 33% * (one-third) radius may be used in defined circumstances. Other regions * of interest — Ward's area and the greater trochanter are named * explicitly — are **not to be used for diagnosis**. Total body is not a * diagnostic site. <https://iscd.org/official-positions-2023/> * * This is the same discipline `agReferenceBand.ts` follows and it reaches * the opposite conclusion from the same rule: that module ships a band * because an age- and sex-specific reference exists with its constraints * recordable beside the table, and this one ships none because for these * sites, on this scanner, no such reference does. **Defaulting to no * interpretation is a correct and complete answer.** Do not add a T-score, a * Z-score, a percentile, a colour, an arrow, or a word like "normal" or * "low" to anything this module's copy sits beside without replacing this * docblock with the citation that justifies it. */
import type { DexaFinding } from "@anthropometry/domain/dexa";import type { DexaBoneSiteName } from "@anthropometry/domain/dexaBone";
export const BMD_NO_INTERPRETATION = "These are the densities the report printed, with no interpretation. The osteopenia and osteoporosis thresholds people have heard of are defined for the lumbar spine, the total hip and the femoral neck measured on their own — not for a whole-body scan and not for a rib or a pelvis — so this app has no reference population to place these numbers in, and will not invent one.";
/** BMD and BMC are related, different, and one line of copy away from being * conflated by any reader who sees both tables on one page. */export const BMD_IS_NOT_BMC = "BMD is bone mineral density: the bone mineral in a region divided by the area the scanner saw, in g/cm². The composition table above reports BMC — bone mineral content, in grams. Neither can be computed from the other here, because the report does not print the areas.";
/** The reason the rows must not be added up, stated where someone might. */export const BMD_IS_NOT_ADDITIVE = "These do not add up, and are not meant to: an areal density is not a mass. The total is a weighted average of the regions, weighted by area, not their sum.";
export const ISCD_CITATION = "WHO Technical Report Series 843 (1994) for the T-score thresholds; ISCD Official Positions — Adult (2015, carried into 2023) for the sites they are valid at: lumbar spine, total hip, femoral neck, and the 33% radius in defined circumstances.";
export const BONE_SITE_LABELS: Record<DexaBoneSiteName, string> = { head: "Head", arms: "Arms", legs: "Legs", trunk: "Trunk", ribs: "Ribs", spine: "Spine", pelvis: "Pelvis", total: "Total",};
/** * A densitometry finding in words. Shared by the entry form and the detail * view so the two cannot drift, and written so that the plausibility warning * reads as what it is — a decimal-point check — rather than as a statement * about the reader's bones. */export function formatBoneFinding(finding: DexaFinding): string { const site = finding.site === undefined ? "This table" : BONE_SITE_LABELS[finding.site]; switch (finding.rule) { case "bmd-non-positive": return `${site}: a density must be greater than zero.`; case "bmd-out-of-range": return `${site}: outside anything a scanner prints for a person — check the decimal point. This is a transcription check, not a finding about your bones.`; case "bmd-total-outside-parts": return "The total density sits outside every region it averages, which cannot happen: the total is an area-weighted mean of head, arms, legs and trunk, so it has to fall between the smallest and the largest of them. One of the five is mistyped."; case "bmd-trunk-outside-subregions": return "The trunk density sits outside ribs, spine and pelvis, which cannot happen for the same reason. One of the four is mistyped."; case "bone-partition-unavailable": return "Entering head, arms, legs, trunk and total together unlocks the one arithmetic check this table can run."; case "bone-subregions-unavailable": return "Entering ribs, spine and pelvis alongside trunk unlocks the second check."; case "reference-database-missing": return "No reference database recorded. Without it, this table cannot be read against a later scan."; default: return finding.rule; }}
/** Three decimals, which is what a densitometry table prints. */export function formatBmd(value: number | undefined): string { return value === undefined ? "—" : value.toFixed(3);}
/** "USA (Combined NHANES/Lunar) · Enhanced Analysis" — the two fields that * decide whether two of these tables can be read against each other. */export function formatBoneProvenance( referenceDatabase: string, analysisMode: string | undefined,): string { const database = referenceDatabase.trim() === "" ? "reference database not recorded" : referenceDatabase.trim(); return analysisMode === undefined || analysisMode.trim() === "" ? database : `${database} · ${analysisMode.trim()}`;}
/** Why two bone tables in one history may not be comparable. Same shape of * problem, and the same treatment, as a series with two scanner * manufacturers in it. */export function formatBoneComparability( warning: "mixed-reference-databases" | "mixed-analysis-modes",): string { switch (warning) { case "mixed-reference-databases": return "Your scans were compared against different reference databases. Densities from two databases are two different comparisons, so reading one against the other measures the databases as much as the bone."; case "mixed-analysis-modes": return "Your scans used different analysis modes. A change of analysis mode is a change in how the scanner drew the regions, so a difference between them is not only a difference in you."; }}apps/web/src/components/dexa/provenanceStrings.test.ts
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183/** * The formatter, not the component. `testing-philosophy` §5 rules out render * assertions and snapshots, and there would be nothing left in a render test * of `BodyFatProvenance` that TypeScript and JSX had not already checked. * * What is worth pinning is the set of rules these strings encode, because * each of them is a requirement rather than a style choice, and each is the * kind of thing a well-meaning later edit undoes without noticing. The `±` * assertion in particular looks like testing a string and is exactly that: * it is the cheapest possible guard on "never render `spreadPp` as a `±`", * whose failure mode is a user reading inter-method agreement as a * confidence interval. * * `calibrateFromPairs` is not tested here — kanban item 31 owns it. Every * `CalibratedBodyFat` below is a literal, which is also what keeps these * cases readable: the residual that matters is one field, not a fitted * outcome. */
import type { CalibratedBodyFat } from "@anthropometry/domain/dexaCalibration";import { describe, expect, it } from "vitest";
import { NO_ESTIMATE, calibrationPhase, formatBracketFailure, formatCalibrationState, formatEstimatedBodyFat, formatMethodDisagreement, formatUncalibratedBodyFat,} from "./provenanceStrings";
/** A `CalibratedBodyFat` with every field at its zero-scan value; each test * overrides only the field it is about. */function scored(over: Partial<CalibratedBodyFat> = {}): CalibratedBodyFat { return { percent: 18, uncalibratedPercent: 18, effectivePairs: 0, pairCount: 0, beta: 0, calibratedWeightFraction: null, bracketed: null, outsideHullResidualPp: null, reachableLowPp: null, reachableHighPp: null, outsideReachableResidualPp: null, spreadPp: null, unavailableFamilies: [], warnings: [], perMethod: [], perFamily: [], ...over, };}
/** One `perMethod` row. Only `weight` is read by anything under test — it is * what decides whether a method contributes at all. */function methodEntry( method: CalibratedBodyFat["perMethod"][number]["method"], weight: number,): CalibratedBodyFat["perMethod"][number] { return { method, family: "siri-2C", estimate: 18, mae: null, weight, populationWeight: 0.1, effectivePairs: 0, };}
/** March 2026, as a scan date. Mid-month so the runner's pinned * America/Denver offset cannot roll it into February. */const MARCH_2026 = Date.UTC(2026, 2, 15, 12);
describe("formatEstimatedBodyFat", () => { it("prints one decimal place and never two", () => { expect(formatEstimatedBodyFat(scored({ percent: 18 }))).toBe("18.0%"); expect(formatEstimatedBodyFat(scored({ percent: 18.049 }))).toBe("18.0%"); expect(formatEstimatedBodyFat(scored({ percent: 18.05 }))).toBe("18.1%"); expect(formatEstimatedBodyFat(scored({ percent: 18.2648 }))).toBe("18.3%"); });
it("renders the empty state for a missing estimate, not null or NaN", () => { // Both routes to "there is no number": no result at all, and a session // with no method able to resolve. `percent` is `number | null` and // reaches a template literal, which is where a "null" gets printed. for (const rendered of [ formatEstimatedBodyFat(null), formatEstimatedBodyFat(scored({ percent: null })), formatUncalibratedBodyFat(scored({ uncalibratedPercent: null })), ]) { expect(rendered).toBe(NO_ESTIMATE); expect(rendered).not.toContain("null"); expect(rendered).not.toContain("NaN"); expect(rendered).not.toContain("undefined"); } });});
describe("formatCalibrationState", () => { it("distinguishes a query in flight from a settled answer of no scans", () => { // `useQuery` answers `undefined` before it lands. Conflating that with // an empty array shows a settled "Uncalibrated" to a user whose scans // are one round trip away. const loading = calibrationPhase(undefined, 0, null); const noScans = calibrationPhase([], 0, null);
expect(loading.phase).toBe("loading"); expect(noScans.phase).toBe("uncalibrated"); expect(formatCalibrationState(loading)).not.toBe( formatCalibrationState(noScans), ); });
it("carries no scan count when uncalibrated", () => { const uncalibrated = formatCalibrationState(calibrationPhase([], 0, null));
expect(uncalibrated).toContain("Uncalibrated"); expect(uncalibrated).not.toMatch(/\d/); });
it("always carries the scan count when calibrated", () => { const one = formatCalibrationState(calibrationPhase([{}], 1, MARCH_2026)); const two = formatCalibrationState( calibrationPhase([{}, {}], 2, MARCH_2026), );
expect(one).toContain("1 DEXA scan,"); expect(two).toContain("2 DEXA scans,"); expect(two).toContain("March 2026"); });});
describe("formatMethodDisagreement", () => { it("never renders as a ± interval", () => { // The requirement most likely to be undone by a later edit, and the one // whose failure reads to every user as a confidence interval. expect(formatMethodDisagreement(1.42)).toBe( "Method disagreement: 1.4 points, weighted spread across the methods", ); expect(formatMethodDisagreement(1.42)).not.toContain("±"); expect(formatMethodDisagreement(1.42)).not.toContain("+/-"); });
it("renders the empty state rather than a number for a missing spread", () => { expect(formatMethodDisagreement(null)).toContain(NO_ESTIMATE); expect(formatMethodDisagreement(null)).not.toContain("null"); });});
describe("formatBracketFailure", () => { it("names the direction and the magnitude when the scan is out of reach", () => { // outsideHullResidualPp = criterion − hullHigh when the scan reads higher // than every method, so a positive residual means the methods read below. const below = formatBracketFailure( scored({ bracketed: false, outsideHullResidualPp: 3.24, perMethod: [ methodEntry("navy", 0.5), methodEntry("jp7", 0.5), methodEntry("jp3", 0), ], }), );
expect(below).toContain("2 contributing methods"); expect(below).toContain("3.2 points below your scan");
const above = formatBracketFailure( scored({ bracketed: false, outsideHullResidualPp: -1.5 }), ); expect(above).toContain("1.5 points above your scan"); });
it("says nothing when the scan is bracketed", () => { expect(formatBracketFailure(scored({ bracketed: true }))).toBeNull(); expect(formatBracketFailure(scored({ bracketed: null }))).toBeNull(); });});apps/web/src/components/dexa/provenanceStrings.ts
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407/** * Every string the calibrated body-fat estimate is allowed to be rendered as. * * A plain module rather than logic inside a component, for two reasons. The * rules it encodes are requirements — one decimal and never two, never a `±`, * never a calibrated number without its scan count — and a requirement that * lives in JSX cannot be tested without rendering, which * `testing-philosophy` §5 rules out. And half a dozen of these strings * interpolate a `number | null` straight off `CalibratedBodyFat`, which is * where a `"null"` or a `"NaN"` reaches a user. Every one of them branches on * the null; none coerces with `String()`. * * The eight rules this module exists to hold, from kanban item 33: * * 1. The number is an **estimate**, never measured, actual, true, or "your * body fat". * 2. One decimal place. Never two. * 3. `spreadPp` is never a `±` and never a confidence interval. * 4. A calibrated number is never shown without its scan count adjacent. * 5. It never claims to beat the scanner. The most it claims is to estimate * what a DXA of this type would read for this person. * 6. The uncalibrated number is never hidden. * 7. Ten methods are never presented as ten independent estimates. * 8. History is never backfilled silently. */
import { BETA_MAX, type BodyFatMethod, type CalibratedBodyFat, type CalibrationWarningCode, type MethodFamily,} from "@anthropometry/domain/dexaCalibration";import { measurementsPath, settingsPath } from "../../routes";
/** What a number that does not exist renders as. Not `"null"`, not `"NaN"`, * and not `"0.0"` — a missing estimate is not a zero one. */export const NO_ESTIMATE = "—";
/** The one framing sentence, from rule 5. The estimate cannot be better than * the thing it is calibrated against, and that thing is not truth. */export const CALIBRATION_CLAIM = "Calibrating cannot make this more accurate than your scanner. The most it estimates is what a DXA of this type would read for you.";
/** Rule 7, in one sentence, and the reason the family view exists. */export const FAMILY_INDEPENDENCE_COPY = "These are not ten independent opinions. Nine of the ten come from the same caliper session and seven share the same density conversion, so they rank-order almost identically and differ mainly in level. The tape measurement is the only genuinely separate look the app has.";
/** Item 31's caveat on `spreadPp`, repeated in the UI verbatim as rule 3 * requires. */export const METHOD_DISAGREEMENT_CAVEAT = "This measures how much the equations disagree with each other. It is not a confidence interval, and it understates total error: it excludes the scanner's own error, the skinfold measurement error, and any common-mode error shared across a family.";
/** Rule 8. Every historical composite is recomputed under the current * calibration — the number is derived, not stored — so a chart whose shape * moved has to say why. */export const HISTORY_RECOMPUTED_COPY = "Every point is recomputed under your current calibration, so entering a scan moves the whole line, not just the newest point.";
export const METHOD_LABEL: Readonly<Record<BodyFatMethod, string>> = { navy: "Navy (tape)", jp7: "Jackson-Pollock 7-site", jp3: "Jackson-Pollock 3-site", dw: "Durnin-Womersley", evans3: "Evans 3-site", evans7: "Evans 7-site", lohman: "Lohman", katch: "Katch", forsyth: "Forsyth", thorland: "Thorland",};
export const FAMILY_LABEL: Readonly<Record<MethodFamily, string>> = { circumference: "Tape circumference", "evans-direct": "Evans direct %fat", "siri-2C": "Skinfold via Siri 2-compartment",};
/** What a missing family costs, for the `family-unavailable` warning. */const FAMILY_COST: Readonly<Record<MethodFamily, string>> = { circumference: "the only modality that does not go through a caliper, so nothing is left to triangulate against the skinfolds", "evans-direct": "the only equations that predict percent fat without a density conversion", "siri-2C": "seven of the ten equations, and most of the published prior",};
// ---------------------------------------------------------------------------// The estimate itself.// ---------------------------------------------------------------------------
/** * The composite, to one decimal place, always. Rule 2. * * `toFixed(1)` rather than a rounding expression because it is the one form * that cannot print two decimals: `18` becomes `"18.0"`, `18.049` becomes * `"18.0"`, `18.05` becomes `"18.1"`. */export function formatEstimatedBodyFat( result: CalibratedBodyFat | null,): string { const percent = result?.percent; return percent === null || percent === undefined ? NO_ESTIMATE : `${percent.toFixed(1)}%`;}
/** The published-equations answer, which rule 6 says is never hidden. */export function formatUncalibratedBodyFat( result: CalibratedBodyFat | null,): string { const percent = result?.uncalibratedPercent; return percent === null || percent === undefined ? NO_ESTIMATE : `${percent.toFixed(1)}%`;}
/** What the calibration did, in one sentence — the only way a reader can * judge whether it did something sensible. */export function formatCalibrationShift(result: CalibratedBodyFat): string { const { percent, uncalibratedPercent } = result; if (percent === null || uncalibratedPercent === null) { return "There is not enough data in this session to compare the two."; }
const delta = percent - uncalibratedPercent; if (Math.abs(delta) < 0.05) { return "The calibration left the estimate where the published equations put it."; } return `The calibration moved the estimate ${Math.abs(delta).toFixed(1)} points ${delta > 0 ? "up" : "down"} from the published equations.`;}
// ---------------------------------------------------------------------------// Calibration state — rule 4's scan count, and the loading case.// ---------------------------------------------------------------------------
export type CalibrationPhase = | { readonly phase: "loading" } | { readonly phase: "uncalibrated" } | { readonly phase: "calibrated"; readonly scanCount: number; readonly latestScanDate: number; };
/** * Which of the three states the badge is in. * * `scans === undefined` is a Convex query in flight and is deliberately its * own phase (`testing-philosophy` §2): "we have not looked yet" and "we * looked and there are none" render very differently and are easy to * conflate, and conflating them shows a settled "Uncalibrated" to a user * whose scans are one round trip away. */export function calibrationPhase( scans: readonly unknown[] | undefined, pairedScanCount: number, latestPairedScanDate: number | null,): CalibrationPhase { if (scans === undefined) return { phase: "loading" }; if (pairedScanCount === 0 || latestPairedScanDate === null) { return { phase: "uncalibrated" }; } return { phase: "calibrated", scanCount: pairedScanCount, latestScanDate: latestPairedScanDate, };}
const monthAndYear = (timestamp: number): string => new Date(timestamp).toLocaleDateString("en-US", { month: "long", year: "numeric", });
/** * The one-word state plus, when calibrated, the scan count — which rule 4 * requires adjacent to the number rather than in a tooltip, and which is why * this returns one string rather than a badge and a separate caption. */export function formatCalibrationState(phase: CalibrationPhase): string { switch (phase.phase) { case "loading": return "Checking for DEXA scans…"; case "uncalibrated": return "Uncalibrated — published equations only"; case "calibrated": return `Calibrated to ${phase.scanCount} DEXA scan${phase.scanCount === 1 ? "" : "s"}, most recent ${monthAndYear(phase.latestScanDate)}`; }}
/** The one word, for a badge. */export function calibrationBadgeLabel(phase: CalibrationPhase): string { switch (phase.phase) { case "loading": return "Checking…"; case "uncalibrated": return "Uncalibrated"; case "calibrated": return "Calibrated"; }}
// ---------------------------------------------------------------------------// The tilt schedule, made visible rather than mysterious.// ---------------------------------------------------------------------------
export function formatTiltStrength(result: CalibratedBodyFat): string { if (result.pairCount === 0) { return "No scan has been paired yet, so the weighting is the published population weighting exactly."; }
const percentOfFull = Math.round((result.beta / BETA_MAX) * 100); if (result.pairCount === 1) { return `One scan. The weighting has moved ${percentOfFull}% of the way toward what that scan alone suggests — deliberately partway, because one scan cannot tell a real difference from a bad measurement day.`; } return `${result.pairCount} scans. The weighting has moved ${percentOfFull}% of the way toward what they suggest; more scans move it further, and each scan loses influence as it ages.`;}
// ---------------------------------------------------------------------------// Method disagreement. Rule 3 lives here.// ---------------------------------------------------------------------------
/** Never a `±`, and never called an interval. A `±` reads as a confidence * interval to everyone, and nothing available supports one. */export function formatMethodDisagreement(spreadPp: number | null): string { return spreadPp === null ? `Method disagreement: ${NO_ESTIMATE}` : `Method disagreement: ${spreadPp.toFixed(1)} points, weighted spread across the methods`;}
/** A weight as a whole-number percentage of the composite. */export function formatWeightShare(weight: number): string { return `${(weight * 100).toFixed(1)}%`;}
/** A per-method mean absolute error against the scans, or the empty state for * a method no scan could score. */export function formatMae(mae: number | null): string { return mae === null ? NO_ESTIMATE : `${mae.toFixed(1)} pp`;}
/** A per-method estimate, which is null for any method whose sites are * missing from the session. */export function formatMethodEstimate(estimate: number | null): string { return estimate === null ? NO_ESTIMATE : `${estimate.toFixed(1)}%`;}
// ---------------------------------------------------------------------------// The case the calibration cannot fix.// ---------------------------------------------------------------------------
/** How many methods actually carry weight in this composite. Not always ten: * a method whose sites are missing contributes nothing. */export function contributingMethodCount(result: CalibratedBodyFat): number { return result.perMethod.filter((entry) => entry.weight > 0).length;}
/** * `criterion-outside-method-range`, said directly rather than buried in a * warnings list. Every method sits on one side of the scan, so no weighting * can reach it — a weighted average only lands between its inputs. */export function formatBracketFailure(result: CalibratedBodyFat): string | null { if (result.bracketed !== false) return null;
const count = contributingMethodCount(result); const residual = result.outsideHullResidualPp; const gap = residual === null ? "on the same side of your scan" : `${Math.abs(residual).toFixed(1)} points ${residual > 0 ? "below" : "above"} your scan`;
return `All ${count} contributing methods read ${gap}. Reweighting them cannot close that gap — a weighted average can only land somewhere between its inputs.`;}
// ---------------------------------------------------------------------------// Warnings. Every code gets a rendering; none is swallowed.// ---------------------------------------------------------------------------
export interface WarningAction { readonly label: string; readonly to: string;}
export interface WarningNotice { readonly code: CalibrationWarningCode; readonly title: string; readonly detail: string; readonly action?: WarningAction;}
function describeWarning( code: CalibrationWarningCode, result: CalibratedBodyFat,): WarningNotice { switch (code) { case "single-pair": return { code, title: "One scan behind the calibration", detail: formatTiltStrength(result), };
case "partially-calibrated": { const fraction = result.calibratedWeightFraction; const share = fraction === null ? "Part of the composite" : `${(fraction * 100).toFixed(0)}% of the composite's weight is calibrated; the rest`; return { code, title: "Only part of the weighting is calibrated", detail: `${share} still rests on untilted published weight, because those methods could not be scored against any of your scans.`, }; }
case "no-race-on-profile": return { code, title: "Both Evans equations are unavailable", detail: "The Evans equations take race as an input and return nothing without one, so an entire method family — the only one that predicts percent fat without a density conversion — is missing from your estimate.", action: { label: "Add it in Settings", to: settingsPath() }, };
case "family-unavailable": { const missing = result.unavailableFamilies; const named = missing .map((family) => `${FAMILY_LABEL[family]} (${FAMILY_COST[family]})`) .join("; "); return { code, title: missing.length === 1 ? "A method family is missing" : "Method families are missing", detail: named === "" ? "A whole modality is missing from this session, so there is less to triangulate against." : `Missing: ${named}.`, }; }
case "criterion-outside-method-range": return { code, title: "The calibration cannot close this gap", detail: formatBracketFailure(result) ?? "Every method sits on one side of your scan, which no reweighting can fix.", };
case "criterion-outside-reachable-range": { const residual = result.outsideReachableResidualPp; const gap = residual === null ? "short of your scan" : `${Math.abs(residual).toFixed(1)} points ${residual > 0 ? "short of" : "past"} your scan`; return { code, title: "The constrained weighting cannot reach your scan", detail: `Under the per-family floors and the per-method cap that protect triangulation, the best the composite can do lands ${gap}. Those limits are deliberate: they are what stops one scan from collapsing the estimate onto a single equation.`, }; }
case "mixed-scanner-manufacturers": return { code, title: "Your scans come from different machines", detail: "GE Lunar and Hologic systems disagree on whole-body percent fat by more than a percentage point, which is about the size of the effect this calibration fits. Scans from one machine are worth more than scans from two.", };
case "stale-calibration": return { code, title: "Your newest scan is over two years old", detail: "Evidence decays with a half-life of about eighteen months, because body composition changes and these equations carry documented proportional bias. Your calibration has faded most of the way back to the published weighting.", };
case "scan-unpaired": return { code, title: "A stored scan contributed nothing", detail: "A scan pairs with a measurement session taken within 36 hours of it. One of yours had none, so it is stored and shown but carries no weight in the calibration.", action: { label: "Add the session's measurements", to: measurementsPath(), }, }; }}
/** Every warning the calibration raised, deduplicated and in a stable order. * `calibratedBodyFat` can repeat a code — the per-measurement warnings are * appended to the calibration's own — and a reader should see each once. */export function warningNotices( result: CalibratedBodyFat,): readonly WarningNotice[] { return [...new Set(result.warnings)].map((code) => describeWarning(code, result), );}apps/web/src/components/goals/GoalCard.tsx
123451 unmodified line7899101112101112137 unmodified lines21222324252627282930313247 unmodified lines80818283848586878889909192939495969798996 unmodified lines106107108109110929311111211311411511611745 unmodified lines163164165166167168169170171172173174175176177178179180181import type { Doc, Id } from "@anthropometry/convex/dataModel";import type { CalibratedBodyFat } from "@anthropometry/domain/dexaCalibration";import { formatGoalRate, formatGoalValue,1 unmodified line getGoalStatus, getTrendConfig,} from "@anthropometry/domain/formatting/goalFormatters";import { METRIC_CONFIG, type ProjectionResult,} from "@anthropometry/domain/goalProjections";import type { ProjectionResult } from "@anthropometry/domain/goalProjections";import type { LengthUnit, WeightUnit,7 unmodified lines CardTitle,} from "@anthropometry/ui/components/ui/card";import { Check, Minus, Trash2, TrendingDown, TrendingUp } from "lucide-react";import { BodyFatProvenance } from "../dexa/BodyFatProvenance";import type { CalibrationPhase } from "../dexa/provenanceStrings";import { goalMetricLabel, isCalibratedGoalMetric,} from "./goalCalibratedMetrics";import { GoalProgressBar } from "./GoalProgressBar";
// --- Extracted sub-components ---47 unmodified lines chartColor?: string; isVisibleOnChart?: boolean; onToggleChartVisibility?: () => void; /** * The scored session behind `projection.currentValue`, and the calibration * state it was scored under. * * Passed for every card and rendered only on the metrics that are the * calibrated composite — a `bodyFat`, `leanMass` or `ffmi` card shows a * number this app constructed, and kanban item 33's rules 4 and 6 say it * never appears without its scan count beside it and the uncalibrated * number one click away. Absent while the page is still loading. */ bodyFatProvenance?: { readonly result: CalibratedBodyFat | null; readonly phase: CalibrationPhase; };}
export function GoalCard({6 unmodified lines chartColor, isVisibleOnChart = true, onToggleChartVisibility, bodyFatProvenance,}: GoalCardProps) { const config = METRIC_CONFIG[goal.metric]; const metricLabel = config?.label ?? goal.metric; const metricLabel = goalMetricLabel(goal.metric); const formatContext = { metric: goal.metric, weightUnit, lengthUnit }; const showProvenance = bodyFatProvenance !== undefined && isCalibratedGoalMetric(goal.metric);
const statusConfig = getGoalStatus(goal, projection);
45 unmodified lines </div> </div>
{/* Where the "Current" number came from, for the metrics whose value is the calibrated composite. Directly under the number rather than in a tooltip: item 33's rule 4 is that the scan count is adjacent, and its rule 6 that the uncalibrated number is one click away — which is what the trigger on the same line opens. */} {showProvenance && ( <BodyFatProvenance result={bodyFatProvenance.result} phase={bodyFatProvenance.phase} compact /> )}
{/* Progress Bar */} {projection && !goal.completed && ( <GoalProgressBarapps/web/src/components/goals/WiggleChart.tsx
1234564 unmodified lines7071727374757677781 unmodified line8081828384858611 unmodified lines9899100101102103104import type { Doc } from "@anthropometry/convex/dataModel";import type { MethodCalibration } from "@anthropometry/domain/dexaCalibration";import { formatDateWithYear, formatShortDate,64 unmodified lines measurements: Doc<"measurements">[]; profile?: Doc<"userProfiles"> | null; weeks?: number; /** The user's DEXA calibration, passed down rather than fitted here so the * chart, the goal cards and the dashboard all score body fat one way. */ calibration: MethodCalibration | null;}
export function WiggleChart({1 unmodified line measurements, profile, weeks, calibration,}: WiggleChartProps) { const [selectedWeeks, setSelectedWeeks] = useState<number>(4); const activeWeeks = weeks ?? selectedWeeks;11 unmodified lines measurements, profile, weeks: activeWeeks, calibration, });
// Index map for O(1) goal lookups by ID (used in tooltip/legend formatters).apps/web/src/components/goals/goalCalibratedMetrics.test.ts
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141/** * The three rules `GoalCard` cannot state in JSX without a render test that * `testing-philosophy` §5 rules out: which goal metrics carry the calibrated * composite, what the composite is called, and that the session the * disclosure describes is the session the card's number came from. * * The last one is the assertion that matters. `calculateProjection`'s * `currentValue` is the newest measurement that yields a value, not the * newest measurement, so a disclosure keyed on `measurements[0]` would show * the right percentage over the wrong per-method table for any user whose * latest session is a bare weigh-in — which is most of them, most weeks. * `getLatestValue` is the domain's own answer to "what does the card show", * so it is what these assert against rather than a literal. * * `calibratedBodyFat` is not tested here; kanban item 31 owns it. */
import { getLatestValue } from "@anthropometry/domain/goalProjections";import type { Measurement, UserProfile } from "@anthropometry/domain/model";import { describe, expect, it } from "vitest";
import { goalMetricLabel, isCalibratedGoalMetric, newestGoalBodyFat,} from "./goalCalibratedMetrics";
const PROFILE: UserProfile = { sex: "male", birthDate: Date.UTC(1985, 0, 1), height: 180,};
/** A session with the full caliper pass, which every equation can score. */function fullSession(date: number): Measurement { return { date, weight: 82, waistCirc: 84, neckCirc: 38, height: 180, skinfoldChest: 8, skinfoldAxilla: 9, skinfoldTricep: 10, skinfoldSubscapular: 12, skinfoldAbdominal: 16, skinfoldSuprailiac: 14, skinfoldThigh: 11, skinfoldBicep: 5, };}
/** A bare weigh-in: no site, no tape, so no equation resolves. */function weightOnly(date: number): Measurement { return { date, weight: 82 };}
describe("isCalibratedGoalMetric", () => { it("covers every metric derived from the composite, not just body fat", () => { expect(isCalibratedGoalMetric("bodyFat")).toBe(true); expect(isCalibratedGoalMetric("leanMass")).toBe(true); expect(isCalibratedGoalMetric("ffmi")).toBe(true); });
it("leaves the measured metrics alone", () => { expect(isCalibratedGoalMetric("weight")).toBe(false); expect(isCalibratedGoalMetric("vo2max")).toBe(false); expect(isCalibratedGoalMetric("waistCirc")).toBe(false); expect(isCalibratedGoalMetric("somethingElse")).toBe(false); });});
describe("goalMetricLabel", () => { it("calls the composite an estimate, never a measurement", () => { const label = goalMetricLabel("bodyFat"); expect(label).toBe("Estimated body fat"); for (const forbidden of ["Measured", "Actual", "True", "Your"]) { expect(label).not.toContain(forbidden); } });
it("leaves every other metric with the domain's own label", () => { expect(goalMetricLabel("weight")).toBe("Weight"); expect(goalMetricLabel("vo2max")).toBe("VO2max"); });
it("falls back to the metric key for a metric the app does not know", () => { expect(goalMetricLabel("somethingElse")).toBe("somethingElse"); });});
describe("newestGoalBodyFat", () => { it("describes the session the card's number came from, not the newest row", () => { const measurements = [ weightOnly(Date.UTC(2026, 2, 10)), fullSession(Date.UTC(2026, 1, 3)), ];
const scored = newestGoalBodyFat(measurements, PROFILE, null);
expect(scored?.percent).not.toBeNull(); expect(scored?.percent).toBe( getLatestValue(measurements, "bodyFat", PROFILE), ); });
it("takes the most recent scorable session when several qualify", () => { const measurements = [ fullSession(Date.UTC(2026, 2, 10)), fullSession(Date.UTC(2026, 1, 3)), ];
expect(newestGoalBodyFat(measurements, PROFILE, null)?.percent).toBe( getLatestValue(measurements, "bodyFat", PROFILE), ); });
it("keeps the uncalibrated number, which rule 6 says is never hidden", () => { const scored = newestGoalBodyFat( [fullSession(Date.UTC(2026, 2, 10))], PROFILE, null, );
expect(scored?.uncalibratedPercent).not.toBeNull(); });
it("is null before the queries land and for a visitor with no profile", () => { expect(newestGoalBodyFat(undefined, PROFILE, null)).toBeNull(); expect( newestGoalBodyFat([fullSession(Date.UTC(2026, 2, 10))], null, null), ).toBeNull(); expect(newestGoalBodyFat([], PROFILE, null)).toBeNull(); });
it("is null when nothing in the history scores, rather than a zero", () => { expect( newestGoalBodyFat([weightOnly(Date.UTC(2026, 2, 10))], PROFILE, null), ).toBeNull(); });});apps/web/src/components/goals/goalCalibratedMetrics.ts
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100/** * Which goal metrics are the calibrated body-fat composite wearing another * name, what a goal card is allowed to call them, and which session the * disclosure beside them describes. * * A plain module rather than logic inside `GoalCard`, for the usual reason * (`testing-philosophy` §5): what lives here is three of kanban item 33's * rules, and a rule that lives in JSX cannot be tested without rendering. * * - Rule 1 — the number is never called measured, actual, true, or "your body * fat". `METRIC_CONFIG.bodyFat.label` is `"Body Fat"`, which is the domain * package's name for the metric; the name a reader sees is this one. * - Rule 4 — a calibrated number never appears without its scan count * adjacent. `bodyFat`, `leanMass` and `ffmi` are all computed from the * calibrated composite, so all three carry the disclosure. * - Rule 6 — the uncalibrated number is never hidden, which is what the * session returned by `newestGoalBodyFat` puts one click away. */
import { calculateAge } from "@anthropometry/domain/ageUtils";import { calibratedBodyFat, circumferencesOf, skinfoldsOf, type CalibratedBodyFat, type MethodCalibration,} from "@anthropometry/domain/dexaCalibration";import { METRIC_CONFIG } from "@anthropometry/domain/goalProjections";import type { Measurement, UserProfile } from "@anthropometry/domain/model";import { zeroScanCalibration } from "@anthropometry/domain/zeroScanCalibration";
/** * The metrics whose value is the calibrated composite, or arithmetic on it. * * `leanMass` is `weight × (1 − bodyFat)` and `ffmi` normalizes that by * height, so a shift in the composite moves all three. A disclosure on the * body-fat card alone would leave the other two looking like measurements. */const CALIBRATED_GOAL_METRICS: readonly string[] = [ "bodyFat", "leanMass", "ffmi",];
export function isCalibratedGoalMetric(metric: string): boolean { return CALIBRATED_GOAL_METRICS.includes(metric);}
/** Rule 1's wording, applied to the metric the rule is about. Every other * metric keeps the domain package's label. */const GOAL_METRIC_LABEL_OVERRIDE: Readonly<Record<string, string>> = { bodyFat: "Estimated body fat",};
export function goalMetricLabel(metric: string): string { return ( GOAL_METRIC_LABEL_OVERRIDE[metric] ?? METRIC_CONFIG[metric]?.label ?? metric );}
/** * The session a goal card's "Current" value came from, scored. * * `calculateProjection` takes the newest measurement that yields a value, not * the newest measurement, so this walks the history the same way: a session * with a weight and no calipers is not the one the number came from, and * describing it would put the wrong per-method table under the right * percentage. * * `race` is deliberately not passed, which is what `getBodyFatPercent` in * `goalProjections.ts` does and why: the two Evans equations return null * without one, so they have never contributed to a goal's value, and passing * race here would describe a composite the card does not show. */export function newestGoalBodyFat( measurements: readonly Measurement[] | undefined, profile: UserProfile | null | undefined, calibration: MethodCalibration | null,): CalibratedBodyFat | null { if (measurements === undefined || !profile) return null;
const fitted = calibration ?? zeroScanCalibration(profile.sex, undefined);
for (const measurement of measurements.toSorted((a, b) => b.date - a.date)) { const scored = calibratedBodyFat( skinfoldsOf(measurement), { ...circumferencesOf(measurement), height: measurement.height ?? profile.height, }, calculateAge(profile.birthDate, measurement.date), profile.sex, undefined, fitted, ); if (scored.percent !== null) return scored; }
return null;}apps/web/src/hooks/useCalibratedBodyFat.ts
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181/** * Fit the DEXA calibration once per user, and hand back a scorer. * * `calibrateFromPairs` fits the mixing weights over the ten body-fat * equations from every scan the user has entered; `calibratedBodyFat` applies * those weights to one measurement session. Item 31 split its API in two for * exactly this reason, and the split is the whole point of this hook: the * progress chart re-scores a hundred measurements and the wiggle chart scores * each of them a dozen times over, so fitting per scored point would be both * slow and wrong-headed — the weights are a property of the user, not of the * point. * * There is no `useEffect` here and none belongs (react-useeffect-discipline * §1.2). The calibration is derived from three `useQuery` results, which is a * `useMemo` — expensive enough to be worth memoising properly, with the query * results as dependencies. * * `const [now] = useState(() => Date.now())` follows `useDashboardStats`: the * recency weights inside `calibrateFromPairs` read the clock, and a clock read * during render would refit on every render and hand every consumer a fresh * object. A state initialiser runs once per mount and is not an effect. * * WHAT THIS IS NOT. The number it produces is an **estimate**, not a * measurement, and the most it can honestly claim is to estimate what a DXA of * this type would read for this person. Everything that renders it is required * to say so — see `components/dexa/provenanceStrings.ts`. */import { api } from "@anthropometry/convex/api";import type { CircumferenceMeasurements, SkinfoldMeasurements,} from "@anthropometry/domain/bodyFat";import { calibrateFromPairs, calibratedBodyFat, pairScansWithMeasurements, type CalibratedBodyFat, type CalibrationScan, type DexaPair, type MethodCalibration,} from "@anthropometry/domain/dexaCalibration";import { useQuery } from "convex/react";import { useMemo, useState } from "react";import { calibrationPhase, type CalibrationPhase,} from "../components/dexa/provenanceStrings";
/** * How far back the pairing looks for a session to pair a scan with. * * `measurements.list`'s own ceiling (`MAX_LIST_LIMIT` in * `packages/convex/measurements.ts`), so this asks for as much history as one * call can return. A scan older than the two-hundredth most recent measurement * pairs with nothing and raises `scan-unpaired`, which the disclosure renders * — the failure is visible rather than silent, which is the property that * matters here. */const PAIRING_MEASUREMENT_LIMIT = 200;
/** Score one measurement session under the fitted calibration. Null before * the queries land, and for a visitor with no profile — the equations need a * sex and an age and there is nothing to guess from. */export type BodyFatScorer = ( skinfolds: SkinfoldMeasurements, circumferences: Partial<CircumferenceMeasurements>, age: number,) => CalibratedBodyFat | null;
export interface CalibratedBodyFatState { /** True while any of the three reads is in flight. Distinct from "no * scans", which is a settled answer and renders very differently. */ readonly isLoading: boolean; /** The fitted calibration, for the domain helpers that take one. Null while * loading and for a visitor with no profile. */ readonly calibration: MethodCalibration | null; /** The stored scans, and the pairing this fit was built from — handed back * rather than re-queried so that a surface plotting the scans beside the * estimate (the progress chart) neither issues a second * `api.dexaScans.list` nor calls `calibrateFromPairs` again. Both are * empty until the fit has something to fit. */ readonly scans: readonly CalibrationScan[]; readonly pairs: readonly DexaPair[]; /** Scans stored, whether or not they paired. */ readonly scanCount: number; /** Scans that paired with a measurement session and so carry evidence. This * is the count the UI must show beside a calibrated number. */ readonly pairedScanCount: number; /** Stored scans that paired with nothing and contributed nothing. */ readonly unpairedScanCount: number; /** Date of the newest scan that paired, for "most recent March 2026". */ readonly latestPairedScanDate: number | null; /** Loading / uncalibrated / calibrated, derived from the raw scan query so * that "we have not looked yet" cannot be rendered as "there are none". */ readonly phase: CalibrationPhase; readonly score: BodyFatScorer;}
const NO_SCORE: BodyFatScorer = () => null;
/** Module constants rather than a fresh `[]` per render: consumers memoise on * these, and a new empty array every render would defeat that. */const NO_SCANS: readonly CalibrationScan[] = [];const NO_PAIRS: readonly DexaPair[] = [];
export function useCalibratedBodyFat(): CalibratedBodyFatState { const userProfile = useQuery(api.userProfile.get); const scans = useQuery(api.dexaScans.list); const measurements = useQuery(api.measurements.list, { limit: PAIRING_MEASUREMENT_LIMIT, });
// Stable reference time — captured once, stable across re-renders. const [now] = useState(() => Date.now());
const isLoading = userProfile === undefined || scans === undefined || measurements === undefined;
const fitted = useMemo(() => { if (!userProfile || scans === undefined || measurements === undefined) { return null; }
const pairs = pairScansWithMeasurements( scans, measurements, userProfile.birthDate, ); const paired = pairs.filter((pair) => pair.measurement !== null);
return { calibration: calibrateFromPairs( pairs, userProfile.sex, userProfile.race, now, ), sex: userProfile.sex, race: userProfile.race, scans, pairs, scanCount: scans.length, pairedScanCount: paired.length, unpairedScanCount: pairs.length - paired.length, latestPairedScanDate: paired.reduce<number | null>( (latest, pair) => latest === null || pair.scanDate > latest ? pair.scanDate : latest, null, ), }; }, [userProfile, scans, measurements, now]);
const score = useMemo<BodyFatScorer>(() => { if (fitted === null) return NO_SCORE; const { calibration, sex, race } = fitted; return (skinfolds, circumferences, age) => calibratedBodyFat(skinfolds, circumferences, age, sex, race, calibration); }, [fitted]);
const pairedScanCount = fitted?.pairedScanCount ?? 0; const latestPairedScanDate = fitted?.latestPairedScanDate ?? null;
return { isLoading, calibration: fitted?.calibration ?? null, scans: fitted?.scans ?? NO_SCANS, pairs: fitted?.pairs ?? NO_PAIRS, scanCount: fitted?.scanCount ?? 0, pairedScanCount, unpairedScanCount: fitted?.unpairedScanCount ?? 0, latestPairedScanDate, phase: calibrationPhase( isLoading ? undefined : scans, pairedScanCount, latestPairedScanDate, ), score, };}apps/web/src/hooks/useDashboardStats.ts
23 unmodified lines24252627282930313233343536376 unmodified lines4445463940414243474849505152535455565758596061625 unmodified lines68697071727374607576777879808182838485868736 unmodified lines1241251261271281291301311321331341351361377 unmodified lines14514614712214814915015115215318 unmodified lines17217317414717517617717815117918018118215515618315818418518618711 unmodified lines1992002012022032042051762062072082091801812102112121851862132142152162177 unmodified lines2252262272002282292302312322332342352362372382 unmodified lines24124224324424524623 unmodified lines * render is not one either. A state initialiser runs once per mount and never * again, which is exactly the semantics wanted, and it is not an effect. * * BOTH COMPOSITES ARE SCORED UNDER THE SAME CALIBRATION. `bodyFatChange` is * `bodyFatResult.percent − previousBodyFat.percent`, so if only one side went * through `useCalibratedBodyFat`'s scorer the difference would be calibrated * minus uncalibrated — and the whole calibration shift would render on the * dashboard as a body-composition change that never happened, on the day the * user entered their first scan. There is one scorer here for that reason and * it is used twice. * * There is no `useEffect` here and none is needed: `useQuery` handles its own * subscription and staleness, and everything downstream of it is derived * (react-useeffect-discipline §1.1, §1.2, §3.2).6 unmodified lines */import { api } from "@anthropometry/convex/api";import { calculateAge } from "@anthropometry/domain/ageUtils";import { weightedAverageBodyFat, type BodyFatResults, type CircumferenceMeasurements, type SkinfoldMeasurements,import type { CircumferenceMeasurements, SkinfoldMeasurements,} from "@anthropometry/domain/bodyFat";import type { CalibratedBodyFat } from "@anthropometry/domain/dexaCalibration";import { buildCompositeMeasurement } from "@anthropometry/domain/measurementHelpers";import type { Measurement } from "@anthropometry/domain/model";import { useQuery } from "convex/react";import { useMemo, useState } from "react";import { useCalibratedBodyFat, type CalibratedBodyFatState,} from "./useCalibratedBodyFat";
export interface UseDashboardStatsOptions { /** Number of days to look back for measurements (default: 60) */5 unmodified linesexport interface DashboardStats { currentComposite: Partial<Measurement> | null; previousComposite: Partial<Measurement> | null; /** The composite under the user's DEXA calibration, which at zero scans is * the published-equations answer exactly. Carries its own provenance — * scan count, per-method weights, warnings — because the number must never * be rendered without it. */ bodyFatResult: BodyFatResults | null; bodyFatResult: CalibratedBodyFat | null; weightChange: number | null; bodyFatChange: number | null; vo2maxChange: number | null; time5kChange: number | null; age: number; isLoading: boolean; /** Scan count, dates and the scorer, so the page can render where the * number came from without fitting the calibration a second time. */ calibration: CalibratedBodyFatState;}
/** Build skinfold data from a composite measurement */36 unmodified lines
const userProfile = useQuery(api.userProfile.get);
// Fitted once, applied to both composites below. Both of them, always: // `bodyFatChange` is the difference of the two, so scoring the current // composite under the calibration and the previous one without it would // render the entire calibration shift as a body-composition change that // never happened, on the day the user enters their first scan. const calibration = useCalibratedBodyFat(); const scoreBodyFat = calibration.score;
// Capture current time once on mount for age calculation const [now] = useState(() => Date.now());
7 unmodified lines });
const isLoading = recentMeasurements === undefined || userProfile === undefined; recentMeasurements === undefined || userProfile === undefined || calibration.isLoading;
// Calculate age from birth date const age = useMemo(18 unmodified lines }; }, [recentMeasurements, cutoffDate]);
// Calculate weighted body fat from current composite // Calculate the calibrated composite from the current composite const bodyFatResult = useMemo(() => { if (!currentComposite || !userProfile) return null;
return weightedAverageBodyFat( return scoreBodyFat( buildSkinfoldData(currentComposite), buildCircumferenceData(currentComposite, userProfile.height), age, userProfile.sex, userProfile.race, ); }, [currentComposite, userProfile, age]); }, [currentComposite, userProfile, age, scoreBodyFat]);
// Calculate changes from previous composite const changes = useMemo(() => {11 unmodified lines ? currentComposite.weight - previousComposite.weight : null;
// Scored under the same calibration as `bodyFatResult`, which is the one // thing about this hook that must not be got wrong: the subtraction below // is only a body-composition change if both sides come from the same // weight vector. const previousBodyFat = weightedAverageBodyFat( const previousBodyFat = scoreBodyFat( buildSkinfoldData(previousComposite), buildCircumferenceData(previousComposite, userProfile.height), age, userProfile.sex, userProfile.race, );
const bodyFatChange = bodyFatResult?.weighted != null && previousBodyFat.weighted != null ? bodyFatResult.weighted - previousBodyFat.weighted bodyFatResult?.percent != null && previousBodyFat?.percent != null ? bodyFatResult.percent - previousBodyFat.percent : null;
const vo2maxChange =7 unmodified lines : null;
return { weightChange, bodyFatChange, vo2maxChange, time5kChange }; }, [currentComposite, previousComposite, userProfile, age, bodyFatResult]); }, [ currentComposite, previousComposite, userProfile, age, bodyFatResult, scoreBodyFat, ]);
return { currentComposite,2 unmodified lines ...changes, age, isLoading, calibration, };}apps/web/src/hooks/useDexaFormState.test.ts
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173/** * The parse-and-build boundary of the DEXA form: the one place a string out * of an `<input>` becomes something the schema stores, and back. Everything * else in `useDexaFormState.ts` is React state, which * `infra/audit/policies/testing-philosophy/POLICY.md` §5 says not to test; * these functions are plain string-parsing code (§3) and are what the kanban * items ask this file to cover. */import { GRAMS_PER_POUND } from "@anthropometry/domain/dexa";import { describe, expect, it } from "vitest";import { buildDexaBone, buildPercentOnlyRegion, formatDexaMass, parseBmd, parseDexaMass,} from "./useDexaFormState";
describe("parseDexaMass", () => { it("converts pounds to grams using the exact conversion factor", () => { expect(parseDexaMass("24.87", "lb")).toBeCloseTo(24.87 * GRAMS_PER_POUND); });
it("converts kilograms to grams", () => { expect(parseDexaMass("11.28", "kg")).toBeCloseTo(11280); });
it("passes grams through unchanged", () => { expect(parseDexaMass("11280", "g")).toBe(11280); });
it("returns undefined for an empty string", () => { expect(parseDexaMass("", "lb")).toBeUndefined(); expect(parseDexaMass(" ", "kg")).toBeUndefined(); });
it("returns undefined for non-numeric input", () => { expect(parseDexaMass("abc", "lb")).toBeUndefined(); });
it("does not reject a negative number — checkDexaScan's negative-mass finding is what catches it", () => { expect(parseDexaMass("-1", "g")).toBe(-1); expect(parseDexaMass("-1", "lb")).toBeCloseTo(-1 * GRAMS_PER_POUND); });
it("accepts scientific notation, the same as Number.parseFloat", () => { expect(parseDexaMass("1e3", "g")).toBe(1000); });});
describe("formatDexaMass", () => { it("formats grams as pounds to 2 decimal places", () => { expect(formatDexaMass(11280, "lb")).toBe( (11280 / GRAMS_PER_POUND).toFixed(2), ); });
it("formats grams as kilograms to 3 decimal places", () => { expect(formatDexaMass(11280, "kg")).toBe("11.280"); });
it("formats grams to 0 decimal places", () => { expect(formatDexaMass(11280.4, "g")).toBe("11280"); });
it("renders an undefined mass as an empty string", () => { expect(formatDexaMass(undefined, "lb")).toBe(""); });});
describe("round-tripping a mass through parse and format", () => { it("returns the original two-decimal pounds string, not float noise", () => { // The failure this guards against: 24.87 lb converts to grams and back // to something like 24.869999999999997 without a fixed-precision // formatter. toFixed(2) is what keeps a reopened form showing the // number the user actually typed. const original = "24.87"; const grams = parseDexaMass(original, "lb"); expect(grams).toBeDefined(); expect(formatDexaMass(grams, "lb")).toBe(original); });
it("round-trips a kilogram value to 3 decimals", () => { const original = "11.280"; const grams = parseDexaMass(original, "kg"); expect(formatDexaMass(grams, "kg")).toBe(original); });
it("round-trips a gram value with no fractional part", () => { const original = "11280"; const grams = parseDexaMass(original, "g"); expect(formatDexaMass(grams, "g")).toBe(original); });});
describe("buildPercentOnlyRegion", () => { it("carries the basis, because nothing downstream can recover it", () => { expect(buildPercentOnlyRegion("8.2", "tissue")).toEqual({ percentFat: 8.2, percentFatBasis: "tissue", }); });
it("refuses to build a percentage whose denominator nobody named", () => { // The empty string is the Select's "nothing chosen yet" sentinel, not a // fourth basis. A percentage stored without one produces a wrong A/G // ratio and then a wrong position against the reference band, and the // error is undetectable afterwards — so the form must ask. expect(buildPercentOnlyRegion("8.2", "")).toBeUndefined(); });
it("returns undefined for a blank or unparseable percentage", () => { expect(buildPercentOnlyRegion("", "tissue")).toBeUndefined(); expect(buildPercentOnlyRegion(" ", "tissue")).toBeUndefined(); expect(buildPercentOnlyRegion("abc", "tissue")).toBeUndefined(); });});
describe("parseBmd", () => { it("takes the printed g/cm² with no unit conversion of any kind", () => { expect(parseBmd("1.234")).toBe(1.234); });
it("returns undefined rather than NaN for blank and unparseable input", () => { expect(parseBmd("")).toBeUndefined(); expect(parseBmd("abc")).toBeUndefined(); });
it("passes a negative through for the checker to flag", () => { // Same contract as parseDexaMass: rejecting it here would hide the // finding rather than show it. expect(parseBmd("-1")).toBe(-1); });});
describe("buildDexaBone", () => { const blank = { head: "", arms: "", legs: "", trunk: "", ribs: "", spine: "", pelvis: "", total: "", };
it("stores nothing when the section was left alone", () => { expect(buildDexaBone("", "", blank)).toBeUndefined(); });
it("keeps only the sites that were typed", () => { const built = buildDexaBone("USA (Combined NHANES/Lunar)", "", { ...blank, spine: "1.104", total: "1.203", }); expect(built).toEqual({ referenceDatabase: "USA (Combined NHANES/Lunar)", analysisMode: undefined, sites: { spine: 1.104, total: 1.203 }, }); });
it("trims the two metadata fields and drops an empty analysis mode", () => { const built = buildDexaBone(" NHANES ", " Enhanced Analysis ", { ...blank, total: "1.2", }); expect(built?.referenceDatabase).toBe("NHANES"); expect(built?.analysisMode).toBe("Enhanced Analysis"); });});apps/web/src/hooks/useDexaFormState.ts
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733/** * State for the DEXA scan entry form, and the two plain functions — mass * parsing and mass formatting — that turn a typed string into the grams the * schema stores and back again. * * Like `useMeasurementFormState`, every numeric field is held as a `string` * for the life of the form; parsing happens once, when a draft is built for * the findings panel or for submit, never on every keystroke. * * Two entry modes are held **independently**, each in its own string tree * (`fullValues`, `summaryValues`), rather than converting between them on * toggle. That is what "do not let the toggle silently discard entered * values" (kanban item 32) means in code: switching from full to summary and * back returns the exact strings the user typed, because they were never * touched. * * `parseDexaMass` and `formatDexaMass` are exported plain functions rather * than closures inside the hook, so they are unit-testable without React — * see the co-located `useDexaFormState.test.ts`, which is what * `docs/testing-philosophy` §3 (string parsing) asks for here. */import type { Doc } from "@anthropometry/convex/dataModel";import { checkDexaScan, GRAMS_PER_POUND, isPercentOnlyRegion, solveRegionFromSummary, tissuePercentFat, type DexaFinding, type DexaPartialRegion, type DexaPercentOnlyRegion, type DexaRegion, type DexaRegionName, type DexaScan, type PercentFatBasis,} from "@anthropometry/domain/dexa";import { DEXA_BONE_SITES, type DexaBoneDensity, type DexaBoneSiteName, type DexaBoneSites,} from "@anthropometry/domain/dexaBone";import { localDateStringToTimestamp, toLocalDateString,} from "@anthropometry/domain/dateUtils";import { parseNumber } from "@anthropometry/domain/formParsers";import { useMemo, useState } from "react";
// Re-derive the manufacturer union from the generated doc type rather than// hand-typing "hologic" | "ge-lunar" | "other" a second time — one source of// truth, `packages/convex/schema/dexa.ts`.export type DexaScannerManufacturer = Doc<"dexaScans">["scannerManufacturer"];
export type DexaEntryMode = "full" | "summary";
/** The unit a printed report states masses in. Distinct from the app-wide * `WeightUnit` (`kg` | `lbs`, `@anthropometry/domain/unitConversion`), * which has no `g` option — a DXA report's masses are too fine-grained for * a scale's units alone. */export type DexaWeightUnit = "lb" | "kg" | "g";
export const DEXA_WEIGHT_UNITS: readonly DexaWeightUnit[] = ["lb", "kg", "g"];
const GRAMS_PER_KG = 1000;
/** The four masses a full-mode region entry collects, each a string in the * form's current `DexaWeightUnit`. */export interface FullRegionValues { fatMass: string; leanMass: string; bmc: string; totalMass: string;}
/** The three numbers a summary-mode region entry collects. `tissuePercentFat` * is always a plain percentage string, never unit-converted. */export interface SummaryRegionValues { tissuePercentFat: string; totalMass: string; bmc: string;}
const EMPTY_FULL: FullRegionValues = { fatMass: "", leanMass: "", bmc: "", totalMass: "",};
const EMPTY_SUMMARY: SummaryRegionValues = { tissuePercentFat: "", totalMass: "", bmc: "",};
/** Every region a scan can carry, in report order. `arms` and `head` are * optional — see `packages/domain/src/dexa.ts`'s `partition-unavailable`. */export const REQUIRED_REGIONS: readonly DexaRegionName[] = [ "total", "legs", "trunk", "android", "gynoid",];
export const OPTIONAL_REGIONS: readonly DexaRegionName[] = ["arms", "head"];
export const ALL_REGIONS: readonly DexaRegionName[] = [ ...REQUIRED_REGIONS, ...OPTIONAL_REGIONS,];
/** * Parse a mass typed in `unit` to grams, the schema's storage unit. Blank or * non-finite input is `undefined` — the same contract as every other parser * in this app (`@anthropometry/domain/formParsers`), and what lets a sparse * region (a field the user has not reached yet) flow through as "missing" * rather than "zero". * * Negative numbers are not rejected here: a transcribed `-1` is exactly the * kind of typo `checkDexaScan`'s `negative-mass` finding exists to catch, so * rejecting it silently at the parser would hide the finding rather than * show it. */export function parseDexaMass( value: string, unit: DexaWeightUnit,): number | undefined { const trimmed = value.trim(); if (trimmed === "") return undefined; const parsed = Number.parseFloat(trimmed); if (!Number.isFinite(parsed)) return undefined; switch (unit) { case "lb": return parsed * GRAMS_PER_POUND; case "kg": return parsed * GRAMS_PER_KG; case "g": return parsed; }}
/** * The inverse of `parseDexaMass`, fixed to the decimal precision a report * actually prints at: 2dp for `lb`, 3dp for `kg`, 0dp for `g` (kanban item * 32). The fixed precision is what makes round-tripping clean — dividing a * gram value by `GRAMS_PER_POUND` and calling `.toString()` on the result * prints `24.869999999999997`; `.toFixed(2)` prints `24.87`. */export function formatDexaMass( grams: number | undefined, unit: DexaWeightUnit,): string { if (grams === undefined) return ""; switch (unit) { case "lb": return (grams / GRAMS_PER_POUND).toFixed(2); case "kg": return (grams / GRAMS_PER_KG).toFixed(3); case "g": return grams.toFixed(0); }}
function formatPercent(value: number): string { return value.toFixed(1);}
/** Which shape the report printed android and gynoid in. One toggle covers * both because they come off one table; storage allows them to differ. */export type AndroidGynoidShape = "masses" | "percent";
/** The two android/gynoid regions, the only ones a report is known to print * as a bare percentage. */export const PERCENT_CAPABLE_REGIONS = ["android", "gynoid"] as const;export type PercentCapableRegion = (typeof PERCENT_CAPABLE_REGIONS)[number];
/** * Build the percent-only region a report printed instead of four masses. * * Returns `undefined` when either half is missing, and **the basis is half**: * a percentage without its denominator is not storable, because `agRatio` is * defined on the tissue basis and nothing recovers the basis afterwards. The * form must ask; this function refuses to guess. */export function buildPercentOnlyRegion( percentFat: string, basis: PercentFatBasis | "",): DexaPercentOnlyRegion | undefined { const parsed = parseNumber(percentFat); if (parsed === undefined || basis === "") return undefined; return { percentFat: parsed, percentFatBasis: basis };}
/** * Parse a typed bone mineral density to g/cm². **No unit conversion**: every * manufacturer prints BMD in g/cm², so there is nothing to convert and a * unit toggle would only invite an error. */export function parseBmd(value: string): number | undefined { return parseNumber(value);}
/** * Build the densitometry block from the eight typed site strings and the two * metadata fields, or `undefined` when the section was left alone — so a * user who ignores it stores no empty `bone` object. * * The reference database is required whenever anything else is present: a * BMD without the population it was compared against cannot be read against * a later scan, and the string is unrecoverable once the printout is gone. */export function buildDexaBone( referenceDatabase: string, analysisMode: string, values: Readonly<Record<DexaBoneSiteName, string>>,): DexaBoneDensity | undefined { const sites: DexaBoneSites = Object.fromEntries( DEXA_BONE_SITES.flatMap((site) => { const parsed = parseBmd(values[site]); return parsed === undefined ? [] : [[site, parsed] as const]; }), );
const hasSite = DEXA_BONE_SITES.some( (site) => sites[site] !== undefined, ); if (!hasSite && referenceDatabase.trim() === "") return undefined;
return { referenceDatabase: referenceDatabase.trim(), analysisMode: analysisMode.trim() === "" ? undefined : analysisMode.trim(), sites, };}
const EMPTY_BONE_VALUES: Record<DexaBoneSiteName, string> = { head: "", arms: "", legs: "", trunk: "", ribs: "", spine: "", pelvis: "", total: "",};
function boneValuesFrom( bone: DexaBoneDensity | undefined,): Record<DexaBoneSiteName, string> { if (bone === undefined) return EMPTY_BONE_VALUES; return Object.fromEntries( DEXA_BONE_SITES.map((site) => { const value = bone.sites[site]; // Three decimals is what a densitometry table prints. return [site, value === undefined ? "" : value.toFixed(3)] as const; }), ) as Record<DexaBoneSiteName, string>;}
/** The masses of a stored region, or `undefined` when the report printed a * percentage instead. Every reader of `scan.android` needs this branch. */function massesOf( region: DexaPartialRegion | undefined,): DexaRegion | undefined { return region === undefined || isPercentOnlyRegion(region) ? undefined : region;}
function percentStringOf(region: DexaPartialRegion | undefined): string { return region !== undefined && isPercentOnlyRegion(region) ? String(region.percentFat) : "";}
function isFullComplete(values: FullRegionValues): boolean { return ( values.fatMass.trim() !== "" && values.leanMass.trim() !== "" && values.bmc.trim() !== "" && values.totalMass.trim() !== "" );}
function isSummaryComplete(values: SummaryRegionValues): boolean { return ( values.tissuePercentFat.trim() !== "" && values.totalMass.trim() !== "" && values.bmc.trim() !== "" );}
function fullRegionFromValues( values: FullRegionValues, unit: DexaWeightUnit,): DexaRegion { return { fatMassG: parseDexaMass(values.fatMass, unit) ?? 0, leanMassG: parseDexaMass(values.leanMass, unit) ?? 0, bmcG: parseDexaMass(values.bmc, unit) ?? 0, totalMassG: parseDexaMass(values.totalMass, unit) ?? 0, };}
function summaryRegionFromValues( values: SummaryRegionValues, unit: DexaWeightUnit,): DexaRegion { return solveRegionFromSummary({ tissuePercentFat: parseNumber(values.tissuePercentFat) ?? 0, totalMassG: parseDexaMass(values.totalMass, unit) ?? 0, bmcG: parseDexaMass(values.bmc, unit) ?? 0, });}
function fullValuesFromRegion( region: DexaRegion | undefined, unit: DexaWeightUnit,): FullRegionValues { if (region === undefined) return EMPTY_FULL; return { fatMass: formatDexaMass(region.fatMassG, unit), leanMass: formatDexaMass(region.leanMassG, unit), bmc: formatDexaMass(region.bmcG, unit), totalMass: formatDexaMass(region.totalMassG, unit), };}
function summaryValuesFromRegion( region: DexaRegion | undefined, unit: DexaWeightUnit,): SummaryRegionValues { if (region === undefined) return EMPTY_SUMMARY; return { tissuePercentFat: formatPercent(tissuePercentFat(region)), totalMass: formatDexaMass(region.totalMassG, unit), bmc: formatDexaMass(region.bmcG, unit), };}
function initialFullValues( scan: Doc<"dexaScans"> | null | undefined, unit: DexaWeightUnit,): Record<DexaRegionName, FullRegionValues> { return { total: fullValuesFromRegion(scan?.total, unit), legs: fullValuesFromRegion(scan?.legs, unit), trunk: fullValuesFromRegion(scan?.trunk, unit), android: fullValuesFromRegion(massesOf(scan?.android), unit), gynoid: fullValuesFromRegion(massesOf(scan?.gynoid), unit), arms: fullValuesFromRegion(scan?.arms, unit), head: fullValuesFromRegion(scan?.head, unit), };}
function initialSummaryValues( scan: Doc<"dexaScans"> | null | undefined, unit: DexaWeightUnit,): Record<DexaRegionName, SummaryRegionValues> { return { total: summaryValuesFromRegion(scan?.total, unit), legs: summaryValuesFromRegion(scan?.legs, unit), trunk: summaryValuesFromRegion(scan?.trunk, unit), android: summaryValuesFromRegion(massesOf(scan?.android), unit), gynoid: summaryValuesFromRegion(massesOf(scan?.gynoid), unit), arms: summaryValuesFromRegion(scan?.arms, unit), head: summaryValuesFromRegion(scan?.head, unit), };}
/** The `dexaScans.create` / `dexaScans.update` payload this hook builds, * mirroring `scanFields.fields` in `packages/convex/dexaScans.ts`. */export interface DexaScanInput { date: number; total: DexaRegion; legs: DexaRegion; trunk: DexaRegion; android?: DexaPartialRegion; gynoid?: DexaPartialRegion; arms?: DexaRegion; head?: DexaRegion; bone?: DexaBoneDensity; agRatioAsPrinted?: number; scaleWeightG?: number; scannerManufacturer: DexaScannerManufacturer; scannerModel?: string; scannerSoftwareVersion?: string; facility?: string; notes?: string;}
export interface UseDexaFormStateReturn { mode: DexaEntryMode; setMode: (mode: DexaEntryMode) => void; unit: DexaWeightUnit; setUnit: (unit: DexaWeightUnit) => void; date: string; setDate: (date: string) => void; includeArmsAndHead: boolean; setIncludeArmsAndHead: (include: boolean) => void; fullValues: Record<DexaRegionName, FullRegionValues>; setFullField: ( region: DexaRegionName, field: keyof FullRegionValues, value: string, ) => void; summaryValues: Record<DexaRegionName, SummaryRegionValues>; setSummaryField: ( region: DexaRegionName, field: keyof SummaryRegionValues, value: string, ) => void; androidGynoidShape: AndroidGynoidShape; setAndroidGynoidShape: (shape: AndroidGynoidShape) => void; /** Empty until the user says which denominator their report used. There is * deliberately no default: see `buildPercentOnlyRegion`. */ percentFatBasis: PercentFatBasis | ""; setPercentFatBasis: (basis: PercentFatBasis | "") => void; percentValues: Record<PercentCapableRegion, string>; setPercentValue: (region: PercentCapableRegion, value: string) => void; boneValues: Record<DexaBoneSiteName, string>; setBoneValue: (site: DexaBoneSiteName, value: string) => void; boneReferenceDatabase: string; setBoneReferenceDatabase: (value: string) => void; boneAnalysisMode: string; setBoneAnalysisMode: (value: string) => void; agRatioAsPrinted: string; setAgRatioAsPrinted: (value: string) => void; scaleWeight: string; setScaleWeight: (value: string) => void; scannerManufacturer: DexaScannerManufacturer | ""; setScannerManufacturer: (value: DexaScannerManufacturer | "") => void; scannerModel: string; setScannerModel: (value: string) => void; scannerSoftwareVersion: string; setScannerSoftwareVersion: (value: string) => void; facility: string; setFacility: (value: string) => void; notes: string; setNotes: (value: string) => void; /** Every region's masses, built from whichever mode is active — always all * seven, regardless of `includeArmsAndHead`, so a field group can show a * live derived %fat even before arms/head are switched on. */ regions: Record<DexaRegionName, DexaRegion>; /** `checkDexaScan` over the current draft. Recomputed on every render via * `useMemo`, never an effect (react-useeffect-discipline §1.2). */ findings: readonly DexaFinding[]; isRegionComplete: (region: DexaRegionName) => boolean; /** False while a required field is blank or an error-severity finding is * present. Warnings never block. */ canSubmit: boolean; buildPayload: () => DexaScanInput;}
export function useDexaFormState( initialUnit: DexaWeightUnit, initialScan?: Doc<"dexaScans"> | null,): UseDexaFormStateReturn { const [mode, setMode] = useState<DexaEntryMode>("full"); const [unit, setUnit] = useState<DexaWeightUnit>(initialUnit); const [date, setDate] = useState(() => initialScan === undefined || initialScan === null ? toLocalDateString(new Date()) : toLocalDateString(new Date(initialScan.date)), ); const [includeArmsAndHead, setIncludeArmsAndHead] = useState( () => initialScan?.arms !== undefined && initialScan.head !== undefined, ); const [fullValues, setFullValuesState] = useState(() => initialFullValues(initialScan, initialUnit), ); const [summaryValues, setSummaryValuesState] = useState(() => initialSummaryValues(initialScan, initialUnit), ); const [agRatioAsPrinted, setAgRatioAsPrinted] = useState(() => initialScan?.agRatioAsPrinted === undefined ? "" : String(initialScan.agRatioAsPrinted), ); const [scaleWeight, setScaleWeight] = useState(() => formatDexaMass(initialScan?.scaleWeightG, initialUnit), ); const [scannerManufacturer, setScannerManufacturer] = useState< DexaScannerManufacturer | "" >(initialScan?.scannerManufacturer ?? ""); const [scannerModel, setScannerModel] = useState( initialScan?.scannerModel ?? "", ); const [scannerSoftwareVersion, setScannerSoftwareVersion] = useState( initialScan?.scannerSoftwareVersion ?? "", ); const [facility, setFacility] = useState(initialScan?.facility ?? ""); const [notes, setNotes] = useState(initialScan?.notes ?? "");
const storedPercentRegion = initialScan?.android !== undefined && isPercentOnlyRegion(initialScan.android) ? initialScan.android : undefined;
const [androidGynoidShape, setAndroidGynoidShape] = useState<AndroidGynoidShape>( storedPercentRegion === undefined ? "masses" : "percent", ); const [percentFatBasis, setPercentFatBasis] = useState<PercentFatBasis | "">( storedPercentRegion?.percentFatBasis ?? "", ); const [percentValues, setPercentValuesState] = useState< Record<PercentCapableRegion, string> >(() => ({ android: percentStringOf(initialScan?.android), gynoid: percentStringOf(initialScan?.gynoid), }));
const [boneValues, setBoneValuesState] = useState(() => boneValuesFrom(initialScan?.bone), ); const [boneReferenceDatabase, setBoneReferenceDatabase] = useState( initialScan?.bone?.referenceDatabase ?? "", ); const [boneAnalysisMode, setBoneAnalysisMode] = useState( initialScan?.bone?.analysisMode ?? "", );
const setPercentValue = (region: PercentCapableRegion, value: string) => { setPercentValuesState((prev) => ({ ...prev, [region]: value })); };
const setBoneValue = (site: DexaBoneSiteName, value: string) => { setBoneValuesState((prev) => ({ ...prev, [site]: value })); };
const setFullField = ( region: DexaRegionName, field: keyof FullRegionValues, value: string, ) => { setFullValuesState((prev) => ({ ...prev, [region]: { ...prev[region], [field]: value }, })); };
const setSummaryField = ( region: DexaRegionName, field: keyof SummaryRegionValues, value: string, ) => { setSummaryValuesState((prev) => ({ ...prev, [region]: { ...prev[region], [field]: value }, })); };
const regions = useMemo<Record<DexaRegionName, DexaRegion>>(() => { const build = (region: DexaRegionName): DexaRegion => mode === "full" ? fullRegionFromValues(fullValues[region], unit) : summaryRegionFromValues(summaryValues[region], unit); return { total: build("total"), legs: build("legs"), trunk: build("trunk"), android: build("android"), gynoid: build("gynoid"), arms: build("arms"), head: build("head"), }; }, [mode, unit, fullValues, summaryValues]);
/** What android and gynoid are on the current draft: four masses, or the * one percentage the report printed instead. */ const androidGynoid = useMemo< Record<PercentCapableRegion, DexaPartialRegion | undefined> >( () => androidGynoidShape === "masses" ? { android: regions.android, gynoid: regions.gynoid } : { android: buildPercentOnlyRegion( percentValues.android, percentFatBasis, ), gynoid: buildPercentOnlyRegion( percentValues.gynoid, percentFatBasis, ), }, [androidGynoidShape, regions, percentValues, percentFatBasis], );
const bone = useMemo( () => buildDexaBone(boneReferenceDatabase, boneAnalysisMode, boneValues), [boneReferenceDatabase, boneAnalysisMode, boneValues], );
const draftScan = useMemo<DexaScan>(() => { const parsedDate = localDateStringToTimestamp(date); return { date: Number.isNaN(parsedDate) ? 0 : parsedDate, total: regions.total, legs: regions.legs, trunk: regions.trunk, android: androidGynoid.android, gynoid: androidGynoid.gynoid, arms: includeArmsAndHead ? regions.arms : undefined, head: includeArmsAndHead ? regions.head : undefined, bone, agRatioAsPrinted: parseNumber(agRatioAsPrinted), scaleWeightG: parseDexaMass(scaleWeight, unit), }; }, [ date, regions, androidGynoid, bone, includeArmsAndHead, agRatioAsPrinted, scaleWeight, unit, ]);
const findings = useMemo(() => checkDexaScan(draftScan), [draftScan]);
const isRegionComplete = (region: DexaRegionName): boolean => { if ( androidGynoidShape === "percent" && (region === "android" || region === "gynoid") ) { // Complete means the percentage *and* its basis: a percentage whose // denominator nobody named cannot be stored. return androidGynoid[region] !== undefined; } return mode === "full" ? isFullComplete(fullValues[region]) : isSummaryComplete(summaryValues[region]); };
const requiredComplete = REQUIRED_REGIONS.every((region) => isRegionComplete(region), ); const optionalComplete = !includeArmsAndHead || OPTIONAL_REGIONS.every((region) => isRegionComplete(region)); const hasErrorFinding = findings.some( (finding) => finding.severity === "error", );
const canSubmit = date.trim() !== "" && scannerManufacturer !== "" && requiredComplete && optionalComplete && !hasErrorFinding;
const buildPayload = (): DexaScanInput => { // A caller that reaches this without `canSubmit` true is a bug in the // form, not a state the mutation should ever see — the empty string is // the Select's "nothing chosen yet" sentinel, not a fourth manufacturer. if (scannerManufacturer === "") { throw new Error("buildPayload called before a scanner was selected"); } return { date: localDateStringToTimestamp(date), total: regions.total, legs: regions.legs, trunk: regions.trunk, android: androidGynoid.android, gynoid: androidGynoid.gynoid, arms: includeArmsAndHead ? regions.arms : undefined, head: includeArmsAndHead ? regions.head : undefined, bone, agRatioAsPrinted: parseNumber(agRatioAsPrinted), scaleWeightG: parseDexaMass(scaleWeight, unit), scannerManufacturer, scannerModel: scannerModel.trim() === "" ? undefined : scannerModel, scannerSoftwareVersion: scannerSoftwareVersion.trim() === "" ? undefined : scannerSoftwareVersion, facility: facility.trim() === "" ? undefined : facility, notes: notes.trim() === "" ? undefined : notes, }; };
return { mode, setMode, unit, setUnit, date, setDate, includeArmsAndHead, setIncludeArmsAndHead, fullValues, setFullField, summaryValues, setSummaryField, androidGynoidShape, setAndroidGynoidShape, percentFatBasis, setPercentFatBasis, percentValues, setPercentValue, boneValues, setBoneValue, boneReferenceDatabase, setBoneReferenceDatabase, boneAnalysisMode, setBoneAnalysisMode, agRatioAsPrinted, setAgRatioAsPrinted, scaleWeight, setScaleWeight, scannerManufacturer, setScannerManufacturer, scannerModel, setScannerModel, scannerSoftwareVersion, setScannerSoftwareVersion, facility, setFacility, notes, setNotes, regions, findings, isRegionComplete, canSubmit, buildPayload, };}apps/web/src/hooks/useDexaScanPoints.test.ts
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161/** * `toScanChartPoints` — the mapping the body-fat chart overlay is, and the * one place its requirements can be checked without rendering anything * (`infra/audit/policies/testing-philosophy/POLICY.md` §5). * * The first test is the one that matters. `regionPercentFat` and * `tissuePercentFat` are the same number when `bmcG` is 0, so a zero-bone * fixture would pass while proving nothing; this fixture carries 3 kg of bone * and the expectation is arithmetic written out by hand rather than a second * call into the domain, so a switch to the tissue basis fails here instead of * quietly lifting every point above the estimated line. * * Pairedness comes from `pairScansWithMeasurements`, never from a comparison * re-written here: kanban items 29 and 31 own `regionPercentFat` and the * 36-hour window, and neither is this file's to re-test. */import type { DexaRegion } from "@anthropometry/domain/dexa";import { pairScansWithMeasurements, type CalibrationScan,} from "@anthropometry/domain/dexaCalibration";import type { Measurement } from "@anthropometry/domain/model";import { describe, expect, it } from "vitest";import { toScanChartPoints, type ScanRangeFilter } from "./useDexaScanPoints";
const HOUR = 60 * 60 * 1000;const DAY = 24 * HOUR;
const SCAN_DATE = Date.UTC(2026, 2, 14);const BIRTH_DATE = Date.UTC(1985, 5, 1);
/** * The total region of the fixture scan. 20 kg fat, 55 kg lean, 3 kg bone, * 78 kg total — the four masses sum, so it is a scan the checker accepts. * * regionPercentFat = 100 × 20000 / 78000 = 25.641… → 25.6 * tissuePercentFat = 100 × 20000 / 75000 = 26.666… → 26.7 * * A full point apart, which is the whole reason the bone mass is non-zero. */const TOTAL_REGION: DexaRegion = { fatMassG: 20_000, leanMassG: 55_000, bmcG: 3_000, totalMassG: 78_000,};
/** A region the cross-region checks are indifferent to; only `total` is * plotted, so the rest are filler the type requires. */const filler: DexaRegion = { fatMassG: 2_000, leanMassG: 5_000, bmcG: 300, totalMassG: 7_300,};
const scanOn = ( date: number, total: DexaRegion = TOTAL_REGION,): CalibrationScan => ({ date, total, legs: filler, trunk: filler, android: filler, gynoid: filler,});
const measurementOn = (date: number): Measurement => ({ date, weight: 78, skinfoldChest: 10, skinfoldAbdominal: 20, skinfoldThigh: 15,});
/** No range selected — every scan is in view. */const allDates: ScanRangeFilter = (rows) => rows;
const pointsFor = ( scans: readonly CalibrationScan[], measurements: readonly Measurement[], inRange: ScanRangeFilter = allDates,) => toScanChartPoints( scans, pairScansWithMeasurements(scans, measurements, BIRTH_DATE), inRange, );
describe("toScanChartPoints", () => { it("plots percent fat of total mass, not of soft tissue", () => { const { points } = pointsFor( [scanOn(SCAN_DATE)], [measurementOn(SCAN_DATE)], );
expect(points).toHaveLength(1); // 100 × 20000 / 78000 = 25.641…, to one decimal. expect(points[0]?.scanPaired).toBe(25.6); // 100 × 20000 / 75000 = 26.666…, the tissue basis this must never use. expect(points[0]?.scanPaired).not.toBe(26.7); });
it("carries one decimal place, like every other percentage in the app", () => { const { points } = pointsFor( [scanOn(SCAN_DATE)], [measurementOn(SCAN_DATE)], ); const percent = points[0]?.scanPaired ?? 0;
expect(percent.toFixed(1)).toBe(String(percent)); });
it("marks a scan with a measurement inside the pairing window as paired", () => { const { points } = pointsFor( [scanOn(SCAN_DATE)], [measurementOn(SCAN_DATE + 12 * HOUR)], );
expect(points[0]?.scanPaired).toBe(25.6); expect(points[0]?.scanUnpaired).toBeUndefined(); });
it("marks a scan with no measurement inside the pairing window as unpaired", () => { const { points } = pointsFor( [scanOn(SCAN_DATE)], [measurementOn(SCAN_DATE + 2 * DAY)], );
expect(points[0]?.scanUnpaired).toBe(25.6); expect(points[0]?.scanPaired).toBeUndefined(); });
it("hides a scan outside the range but still counts it as stored", () => { const scans = [scanOn(SCAN_DATE - 400 * DAY), scanOn(SCAN_DATE)]; const cutoff = SCAN_DATE - 30 * DAY; const lastMonth: ScanRangeFilter = (rows) => rows.filter((row) => row.date >= cutoff);
const { points, shownCount, storedCount } = pointsFor( scans, [measurementOn(SCAN_DATE)], lastMonth, );
expect(points.map((point) => point.date)).toEqual([SCAN_DATE]); expect(shownCount).toBe(1); expect(storedCount).toBe(2); });
it("orders the points oldest first, whatever order the scans arrive in", () => { const older = SCAN_DATE - 90 * DAY; const { points } = pointsFor( [scanOn(SCAN_DATE), scanOn(older)], [measurementOn(SCAN_DATE)], );
expect(points.map((point) => point.date)).toEqual([older, SCAN_DATE]); });});apps/web/src/hooks/useDexaScanPoints.ts
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119/** * The DEXA scans, as rows the body-fat chart can plot beside the estimate. * * This module computes nothing. The y-value is `regionPercentFat(scan.total)` * — fat over **total mass, bone included** — because that is the quantity * `packages/domain/src/dexaCalibration.ts` builds each pair's `criterion` from * and therefore the quantity the estimated line was fitted against. Plotting * `tissuePercentFat` instead would be silent and catastrophic: it excludes * bone, so it runs several tenths to a full point higher on the same scan, * and it would lift every scan point above the estimated line on every user. * A correct calibration would read as a broken one. **Never call * `tissuePercentFat` here.** * * Whether a scan anchored the calibration is read off the `DexaPair`s that * `useCalibratedBodyFat` already fitted from, never re-derived: the 36-hour * window is `PAIR_WINDOW_MS` in the domain, and a second copy of the * comparison here would be a second place to change when it moves. * * No `useEffect` (react-useeffect-discipline §1.1): the points are derived * from query results already in hand, which is a `useMemo`. */import { regionPercentFat } from "@anthropometry/domain/dexa";import type { CalibrationScan, DexaPair,} from "@anthropometry/domain/dexaCalibration";import { useMemo } from "react";
/** * One scan, shaped as a row of the body-fat chart's data array. * * The two mutually exclusive keys are what lets recharts draw two visually * distinct `<Scatter>` series — filled anchors, muted unpaired marks — from a * single array, since a series is selected by `dataKey` and a row with no * value under that key renders nothing. * * The percentage is rounded to one decimal in the data rather than at render * time. The tooltip prints the datum as recharts hands it over, so this is the * only place that can hold item 33's rule 2 — one decimal, never two — and * 0.05 pp of plotted position is far below the width of the mark. */export interface ScanChartPoint { readonly date: number; /** Percent fat of total mass, for a scan that anchored the calibration. */ readonly scanPaired?: number; /** The same quantity, for a scan no measurement session paired with. */ readonly scanUnpaired?: number;}
export interface ScanChartPoints { /** The scans inside the selected date range, oldest first. */ readonly points: readonly ScanChartPoint[]; /** How many are plotted. */ readonly shownCount: number; /** How many the user has stored, which is what the calibration used. * Requirement 7: a subset is never plotted without both counts said. */ readonly storedCount: number;}
/** * The date-range predicate, as `useDateRangeFilter` hands it over — * `filterMeasurements` is generic over `{ readonly date: number }` and takes a * scan unchanged. A scan outside the range is dropped, never clamped onto the * edge of the domain. */export type ScanRangeFilter = <T extends { readonly date: number }>( rows: readonly T[],) => readonly T[];
/** One decimal, the same rounding `provenanceStrings` applies everywhere else * a body-fat percentage reaches a reader. */const toOneDecimal = (percent: number): number => Number(percent.toFixed(1));
/** * Map the stored scans onto chart rows, dropping the ones the selected range * hides and counting both totals. * * Pairedness is keyed by scan date rather than by array index so that the * range filter cannot shift the two arrays out of step with each other. */export function toScanChartPoints( scans: readonly CalibrationScan[], pairs: readonly DexaPair[], inRange: ScanRangeFilter,): ScanChartPoints { const pairedDates = new Set( pairs .filter((pair) => pair.measurement !== null) .map((pair) => pair.scanDate), );
const points = inRange(scans) .map((scan): ScanChartPoint => { const percent = toOneDecimal(regionPercentFat(scan.total)); return pairedDates.has(scan.date) ? { date: scan.date, scanPaired: percent } : { date: scan.date, scanUnpaired: percent }; }) .sort((a, b) => a.date - b.date);
return { points, shownCount: points.length, storedCount: scans.length, };}
/** Memoised on the query results and the range filter, both of which are * already stable references — `filterMeasurements` is a `useCallback`. */export function useDexaScanPoints( scans: readonly CalibrationScan[], pairs: readonly DexaPair[], inRange: ScanRangeFilter,): ScanChartPoints { return useMemo( () => toScanChartPoints(scans, pairs, inRange), [scans, pairs, inRange], );}apps/web/src/hooks/useGoalProjections.ts
1234516 unmodified lines22232425262728293031323334353637389 unmodified lines484950515253541 unmodified line5657585159606162import type { Doc, Id } from "@anthropometry/convex/dataModel";import type { MethodCalibration } from "@anthropometry/domain/dexaCalibration";import { calculateProjection, type ProjectionResult,16 unmodified lines/** * Hook for calculating goal projections. * Extracted from Goals page for reusability. * * `calibration` is the user's DEXA calibration, which the three body-fat * derived metrics — bodyFat, leanMass, ffmi — are scored under. A goal that * tracked a different composite from the dashboard's would be two answers to * one question. */export function useGoalProjections( goals: Goal[] | undefined, measurements: Measurement[] | undefined, profile: UserProfile | null | undefined, calibration: MethodCalibration | null,): UseGoalProjectionsReturn { const goalProjections = useMemo(() => { if (!goals || !measurements)9 unmodified lines goal.startValue, profile, goal.direction, calibration ?? undefined, ); if (projection) { projections.set(goal._id, projection);1 unmodified line } } return projections; }, [goals, measurements, profile]); }, [goals, measurements, profile, calibration]);
// Single pass to partition goals into active and completed const { activeGoals, completedGoals } = useMemo(() => {apps/web/src/hooks/useMeasurementGuidance.test.ts
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350/** * The pure functions, not the hook. `testing-philosophy` §5 rules out * component render tests, but `siteState` and `siteShare` are not * rendering — they are plain functions over a `SiteContribution`, and they * encode requirements from kanban item 35 that a well-meaning later edit * could undo without noticing: the essential-outranks-everything rule, the * two "contributes little" suppressions, and the fix for `weightAtRisk` not * already being a share. * * `analyseContributions` itself is not tested here — kanban item 34 owns * it. Every `SiteContribution` below is a literal. */import { describe, expect, it } from "vitest";
import type { MethodContribution, SiteContribution,} from "@anthropometry/domain/methodContribution";import { MALE_COEFFICIENTS, FEMALE_COEFFICIENTS,} from "@anthropometry/domain/bodyFat";
import { SITE_SHIFT_THRESHOLD_PP, coefficientTotal, contributesToComposite, formatLeverage, formatMarginalContribution, formatSiteShift, siteChips, siteShare, siteState, siteStateCopy, type SiteStateContext,} from "./useMeasurementGuidance";
/** A `SiteContribution` at its most ordinary value — present, redundant, * no family at risk. Each test overrides only the field it is about. */function contribution(over: Partial<SiteContribution> = {}): SiteContribution { return { site: "tricep", present: true, methodsDisabled: [], weightAtRisk: 0, calibratedWeightAtRisk: null, familiesEliminated: [], meanAbsDeltaPp: 0, maxAbsDeltaPp: 0, deltasComputed: 5, measurementsAnalysed: 5, composedOfNoMethods: false, ...over, };}
const openContext: SiteStateContext = { measurementsAnalysed: 5, missingRace: false,};
describe("siteState", () => { it("returns essential when familiesEliminated is non-empty, even at a zero shift", () => { // This is requirement 1 of the feature and the assertion that matters // most: a site can eliminate a whole family while moving the composite // by nothing today, and it must still outrank every other state. const site = contribution({ familiesEliminated: ["evans-direct"], meanAbsDeltaPp: 0, deltasComputed: 0, }); expect(siteState(site, openContext)).toBe("essential"); });
it("puts 0.49 pp below the threshold and 0.51 pp at or above it", () => { const below = contribution({ meanAbsDeltaPp: 0.49 }); const above = contribution({ meanAbsDeltaPp: 0.51 }); expect(SITE_SHIFT_THRESHOLD_PP).toBe(0.5); expect(siteState(below, openContext)).toBe("contributes-little"); expect(siteState(above, openContext)).toBe("load-bearing"); });
it("returns not-taken when present is false, never contributes-little", () => { // A site nobody measures reports the same zero delta as a perfectly // redundant one. `present` is the only field that tells them apart, and // this is the state derivation acting on it. const site = contribution({ present: false, meanAbsDeltaPp: 0, deltasComputed: 5, }); expect(siteState(site, openContext)).toBe("not-taken"); });
it("suppresses contributes-little at measurementsAnalysed 1 and returns provisional", () => { const site = contribution({ meanAbsDeltaPp: 0.1 }); const oneMeasurement: SiteStateContext = { measurementsAnalysed: 1, missingRace: false, }; expect(siteState(site, oneMeasurement)).toBe("provisional"); });
it("suppresses contributes-little for every site on a profile missing race", () => { const site = contribution({ meanAbsDeltaPp: 0.1 }); const noRace: SiteStateContext = { measurementsAnalysed: 5, missingRace: true, }; expect(siteState(site, noRace)).toBe("provisional"); });
it("returns load-bearing for a site that is both load-bearing and essential", () => { // The user's override of item 35's "essential regardless of shift": a // site that is both is described by what it moves. The family fact is // not discarded — `siteChips` still carries it, categorically. const both = contribution({ familiesEliminated: ["circumference"], meanAbsDeltaPp: 1.4, deltasComputed: 5, }); expect(siteState(both, openContext)).toBe("load-bearing"); });
it("still returns essential when the shift is below the threshold", () => { const essentialOnly = contribution({ familiesEliminated: ["circumference"], meanAbsDeltaPp: 0.2, deltasComputed: 5, }); expect(siteState(essentialOnly, openContext)).toBe("essential"); });
it("never calls a site whose shift could not be computed contributes-little", () => { // `deltasComputed` at 0 leaves `meanAbsDeltaPp` at 0, which is also what // a perfectly redundant site reports. Reading the shift without the // guard put "Contributes little right now" on a row whose own shift // column reads "not computable" — two contradictory claims in one row. const notComputable = contribution({ present: true, meanAbsDeltaPp: 0, deltasComputed: 0, }); expect(siteState(notComputable, openContext)).not.toBe( "contributes-little", ); expect(siteState(notComputable, openContext)).toBe("provisional"); });
it("still reports essential and load-bearing under a suppressed context", () => { // Suppression withholds the "safe to drop" judgement; it does not // withhold evidence that already exists. const suppressed: SiteStateContext = { measurementsAnalysed: 1, missingRace: true, }; expect( siteState( contribution({ familiesEliminated: ["circumference"] }), suppressed, ), ).toBe("essential"); expect(siteState(contribution({ meanAbsDeltaPp: 1.2 }), suppressed)).toBe( "load-bearing", ); });});
describe("siteChips", () => { it("wears both chips, load-bearing first, when both apply", () => { // Order is asserted rather than membership: the user asked for // load-bearing first, and an unordered assertion would not catch a flip. const both = contribution({ familiesEliminated: ["circumference"], meanAbsDeltaPp: 1.4, deltasComputed: 5, }); expect(siteChips(both, openContext)).toStrictEqual([ "load-bearing", "essential", ]); });
it("wears one chip for a site that is only essential", () => { const essentialOnly = contribution({ familiesEliminated: ["circumference"], meanAbsDeltaPp: 0.2, deltasComputed: 5, }); expect(siteChips(essentialOnly, openContext)).toStrictEqual(["essential"]); });
it("wears one chip for a site that is only load-bearing", () => { const loadBearingOnly = contribution({ meanAbsDeltaPp: 1.4, deltasComputed: 5, }); expect(siteChips(loadBearingOnly, openContext)).toStrictEqual([ "load-bearing", ]); });
it("carries the description from the primary state alone", () => { // The pair is the only case where the chips and the description come // apart, and this is what says which one the description follows. const both = contribution({ familiesEliminated: ["circumference"], meanAbsDeltaPp: 1.4, deltasComputed: 5, }); const primary = siteState(both, openContext); expect(siteStateCopy(primary, both).detail).toContain("1.4 pp"); });});
describe("siteStateCopy", () => { it("never says 'stop' for the contributes-little state", () => { // Requirement 1: the phrasing is "contributes little right now", never // "stop measuring this". This looks like testing a string because it // is — it is the cheapest guard on the requirement a later edit is // most likely to undo. const copy = siteStateCopy("contributes-little", contribution()); expect(copy.label.toLowerCase()).not.toContain("stop"); expect(copy.detail.toLowerCase()).not.toContain("stop"); expect(copy.label).toBe("Contributes little right now"); });});
describe("coefficientTotal and siteShare", () => { it("the male population coefficients do not already sum to 1", () => { const total = coefficientTotal("male"); expect(total).toBeCloseTo(1.2, 5); expect(total).not.toBeCloseTo(1, 5); });
it("normalizing every method's coefficient by the total sums to exactly 1", () => { // This is the identity that makes "share" a meaningful word: the raw // map does not sum to 1 (it sums to 1.20 for both sexes today), but // dividing every entry by the total recovers a real probability // distribution over the ten methods. That is the renormalization // `siteShare` applies to a site's `weightAtRisk`. for (const coefficients of [MALE_COEFFICIENTS, FEMALE_COEFFICIENTS]) { const total = Object.values(coefficients).reduce<number>( (sum, value) => sum + value, 0, ); const shareSum = Object.values(coefficients).reduce<number>( (sum, value) => sum + value / total, 0, ); expect(shareSum).toBeCloseTo(1, 10); } });
it("turns tricep's raw weightAtRisk of 1.00 into the true share of about 83%, not 100%", () => { // Item 34's own worked example: tricep's weightAtRisk is 1.00, which a // header reading "share of the weighting" renders as "100%" — but the // male coefficients sum to 1.20, so the true share is 1.00 / 1.20, // about 83%. This is the defect this hook exists to fix. const tricep = contribution({ weightAtRisk: 1.0 }); const share = siteShare(tricep, "male"); expect(share).toBeCloseTo(1.0 / 1.2, 10); expect(share).not.toBeCloseTo(1.0, 2); expect(share).toBeCloseTo(0.833, 3); });
it("does not hard-code 1.20 — the female total is computed from its own map", () => { const femaleTotal = coefficientTotal("female"); const expected = Object.values(FEMALE_COEFFICIENTS).reduce<number>( (sum, value) => sum + value, 0, ); expect(femaleTotal).toBeCloseTo(expected, 10); });});
describe("formatSiteShift", () => { it("never reads meanAbsDeltaPp without checking deltasComputed first", () => { // A 0 in meanAbsDeltaPp means either "perfectly redundant" or "not // computable" — deltasComputed is the only field that tells them apart. const notComputable = contribution({ meanAbsDeltaPp: 0, deltasComputed: 0, }); expect(formatSiteShift(notComputable)).toContain("not computable");
const redundant = contribution({ meanAbsDeltaPp: 0, deltasComputed: 5 }); expect(formatSiteShift(redundant)).toBe("0.0 pp (max 0.0 pp)"); });});
/** A `MethodContribution` for a method that is in the composite. Each test * overrides only the field it is about. */function method(over: Partial<MethodContribution> = {}): MethodContribution { return { method: "jp7", family: "siri-2C", weight: 0.25, estimate: 18.4, leveragePp: 1.2, marginalPp: 0.3, maePp: null, ...over, };}
describe("formatLeverage and formatMarginalContribution", () => { it("renders both in points of the composite, at one decimal", () => { const entry = method({ leveragePp: 1.24, marginalPp: 0.31 }); expect(formatLeverage(entry)).toBe("1.2 pp"); expect(formatMarginalContribution(entry)).toBe("0.3 pp"); });
it("shows a near-zero leverage as a number rather than hiding it", () => { // The case the legend is about: a method with a large weight and // near-zero leverage is agreeable, not informative. Dashing it out would // remove exactly the row a reader came to the table to find. const agreeable = method({ weight: 0.4, leveragePp: 0.02, marginalPp: 0.008, }); expect(formatLeverage(agreeable)).toBe("0.0 pp"); expect(formatMarginalContribution(agreeable)).toBe("0.0 pp"); });
it("does not report a zero for a method that was never in the composite", () => { // `analyseContributions` returns 0 for both when the method does not // contribute, and a 0 under "leverage" means "agrees with everyone" — // the opposite finding to "was not in the average at all". const noEstimate = method({ estimate: null, leveragePp: 0, marginalPp: 0 }); const noWeight = method({ weight: 0, leveragePp: 0, marginalPp: 0 });
expect(contributesToComposite(noEstimate)).toBe(false); expect(contributesToComposite(noWeight)).toBe(false); for (const entry of [noEstimate, noWeight]) { expect(formatLeverage(entry)).not.toContain("0.0"); expect(formatMarginalContribution(entry)).not.toContain("0.0"); } });
it("holds the marginal = weight x leverage identity the two columns sit on", () => { // Item 34 computes `marginalPp = weight × leveragePp` exactly, and this // panel renders both rather than recomputing either. The assertion pins // that the columns can be read against each other. const entry = method({ weight: 0.25, leveragePp: 1.2, marginalPp: 0.3 }); expect(entry.marginalPp).toBeCloseTo(entry.weight * entry.leveragePp, 10); expect(formatMarginalContribution(entry)).toBe("0.3 pp"); });});apps/web/src/hooks/useMeasurementGuidance.ts
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377/** * Turns item 34's leave-one-out analysis (`analyseContributions`) into the * advice `MeasurementGuidance` renders: which sites are worth continuing to * take, and what state each one is in. Computes no body-fat equation and no * contribution figure of its own — every number is read straight off * `ContributionReport`. What lives here is the window this panel analyses * (twelve measurements, item 34 deliberately takes that from its caller), * the threshold that turns a shift into a verdict, and the pure * classification and formatting functions the component and its tests both * need. * * `analyseContributions` is the most expensive derivation in the app * (`kanban/0035`), so it is called exactly once per render, in a `useMemo` * keyed on the query results and the calibration — never inside a `.map`. * There is no `useEffect` here and none belongs * (`react-useeffect-discipline` §1.2): this is derived state over three * `useQuery`/hook results, which is what `useMemo` is for. */import { api } from "@anthropometry/convex/api";import { FEMALE_COEFFICIENTS, MALE_COEFFICIENTS, type Sex,} from "@anthropometry/domain/bodyFat";import type { MethodFamily } from "@anthropometry/domain/dexaCalibration";import { analyseContributions, type ContributionReport, type MeasurementInput, type MethodContribution, type SiteContribution,} from "@anthropometry/domain/methodContribution";import { useQuery } from "convex/react";import { useMemo } from "react";import { NO_ESTIMATE } from "../components/dexa/provenanceStrings";import { useCalibratedBodyFat } from "./useCalibratedBodyFat";
/** * Window: the last twelve measurements. This constant lives here, not in * `methodContribution.ts`, which deliberately takes the window from its * caller rather than choosing one itself. */export const GUIDANCE_MEASUREMENT_WINDOW = 12;
/** * Threshold, in percentage points, at or above which a site's typical * shift is called out as load-bearing. * * Anchored on the DXA's own precision on whole-body percent fat — about a * 1% coefficient of variation, so roughly 0.25 pp at 25% body fat — * doubled: below this a shift is smaller than the noise in the thing this * whole feature is calibrated against. */export const SITE_SHIFT_THRESHOLD_PP = 0.5;
/** Plain language for a site, for the column a user reads first — "triceps", * not `tricep`. */export const SITE_LABEL: Readonly<Record<MeasurementInput, string>> = { chest: "chest", midaxillary: "mid-axillary", tricep: "triceps", subscapular: "subscapular", abdominal: "abdominal", suprailiac: "suprailiac", thigh: "thigh", bicep: "biceps", waist: "waist", neck: "neck", hip: "hip", height: "height",};
// ---------------------------------------------------------------------------// The share fix. `weightAtRisk` is a raw sum of population coefficients, not// a share of the weighting — item 34 says so on the field itself, and the// male coefficients sum to 1.20, not 1. Dividing by the coefficient total// turns the raw sum into an actual share; the total is read from the same// map `analyseContributions` reads (`MALE_COEFFICIENTS` /// `FEMALE_COEFFICIENTS` in `bodyFat.ts`), never hard-coded, because the// female column has its own total.// ---------------------------------------------------------------------------
/** Sum of the population coefficients for a sex — 1.20 for both sexes today, * but read from the map rather than assumed, because nothing here should * break silently if a future coefficient revision changes it. */export function coefficientTotal(sex: Sex): number { const coefficients = sex === "female" ? FEMALE_COEFFICIENTS : MALE_COEFFICIENTS; return Object.values(coefficients).reduce<number>( (sum, value) => sum + value, 0, );}
/** * `weightAtRisk`, renormalized into an actual share of the weighting. * * Item 34's own example: `tricep`'s `weightAtRisk` is 1.00, which a header * reading "share of the weighting" would render as "100%" — but the male * coefficients sum to 1.20, so the true share is 1.00 / 1.20 ≈ 83%, the * share `navy` — the only method that survives losing `tricep` — does not * carry. */export function siteShare(contribution: SiteContribution, sex: Sex): number { return contribution.weightAtRisk / coefficientTotal(sex);}
// ---------------------------------------------------------------------------// Site state. Four states in the item, plus the fifth the item's own// suppression rules require: a site that would otherwise be "contributes// little" but the panel has not earned the right to say so yet.// ---------------------------------------------------------------------------
export type SiteState = | "essential" | "load-bearing" | "contributes-little" | "not-taken" | "provisional";
export interface SiteStateContext { /** `ContributionReport.measurementsAnalysed`. At 1, "contributes little" * is suppressed — one occasion is not a pattern. */ readonly measurementsAnalysed: number; /** `userProfile.race === undefined`. Both Evans equations return null * without it, which changes the guidance substantially, so "contributes * little" is suppressed until it is resolved. */ readonly missingRace: boolean;}
/** * The site's primary state — what it is, in one word. It drives the * description on the row and anything that sorts or filters. What badges the * row wears is a different question, answered by `siteChips`. * * Order is the whole derivation: * * 1. A **computed** shift at or above `SITE_SHIFT_THRESHOLD_PP` is * **load-bearing**, and it outranks `essential`. Kanban item 35 specified * the opposite — essential "regardless of the shift" — and the user * directed the swap: a site that is both is described by what it moves, * while the family it protects is said categorically by the second chip * `siteChips` returns rather than in prose. * 2. `familiesEliminated` non-empty is **essential**, regardless of the * shift and outranking everything below — including whether the site is * being taken today, because a site whose absence already eliminates a * family is the strongest possible argument for adding it back, not a * reason to rank it as "not being taken". * 3. `present: false` is **not being taken** — a fact, not a verdict, and * never "contributes little": a site nobody measures reports the same * zero delta as a perfectly redundant one, and only `present` * distinguishes them. * 4. A shift that could not be computed is **provisional**. `deltasComputed` * at 0 means every removal left nothing to compare, and `meanAbsDeltaPp` * falls through to 0 — the same 0 a perfectly redundant site reports. * Reading the shift without this guard is what let a site whose * contribution is unknown render as "Contributes little right now" beside * `formatSiteShift`'s "not computable", which are contradictory claims. * "We could not compute this" is not a recommendation. * 5. Below the threshold, a suppressed context (one measurement, or a * profile missing `race`) is **provisional** rather than a recommendation. * 6. Otherwise, **contributes little** (right now). */export function siteState( contribution: SiteContribution, context: SiteStateContext,): SiteState { const shiftComputed = contribution.deltasComputed > 0;
if (shiftComputed && contribution.meanAbsDeltaPp >= SITE_SHIFT_THRESHOLD_PP) { return "load-bearing"; } if (contribution.familiesEliminated.length > 0) return "essential"; if (!contribution.present) return "not-taken"; if (!shiftComputed) return "provisional"; if (context.measurementsAnalysed <= 1 || context.missingRace) { return "provisional"; } return "contributes-little";}
/** * What badges the row wears, in render order. * * `load-bearing` and `essential` are the one pair that can co-occur — a site * can both move the composite and hold up the last member of a family — and * since `siteState` now answers "load-bearing" for it, the family fact would * otherwise be lost. Two chips say both, categorically; the description stays * singular and comes from the primary state alone. Every other state remains * mutually exclusive, so every other case is a one-element list. */export function siteChips( contribution: SiteContribution, context: SiteStateContext,): readonly SiteState[] { const primary = siteState(contribution, context); return primary === "load-bearing" && contribution.familiesEliminated.length > 0 ? ["load-bearing", "essential"] : [primary];}
/** * `meanAbsDeltaPp` must never be read without checking `deltasComputed` * first — a 0 there means either "perfectly redundant" or "not * computable", and they are opposite findings. This is the one place that * reads `meanAbsDeltaPp` for display, so it is the one place that guard has * to hold. */export function formatSiteShift(contribution: SiteContribution): string { if (contribution.deltasComputed === 0) { return "not computable — dropping this leaves nothing to compare"; } return `${contribution.meanAbsDeltaPp.toFixed(1)} pp (max ${contribution.maxAbsDeltaPp.toFixed(1)} pp)`;}
// ---------------------------------------------------------------------------// Leverage and marginal contribution — the pair the method legend explains,// and the distinction kanban item 35 calls the specific thing the user asked// to be able to see. A legend for a column the table does not draw is worse// than no legend, so these are what draw it.// ---------------------------------------------------------------------------
/** * Whether a method is in the composite this report describes at all. * * `analyseContributions` reports `leveragePp` and `marginalPp` as 0 for a * method that is not — its sites are missing from the newest session, or it * carries no weight for this sex — and a 0 under "leverage" reads as "says * exactly what its neighbours say", which is the opposite finding to "was * never in the average". Same shape as `present` on the site rows, and the * same reason item 34 put it there. */export function contributesToComposite(entry: MethodContribution): boolean { return entry.estimate !== null && entry.weight > 0 && entry.weight < 1;}
/** How far a method sits from what the rest of them say, in points of the * composite. High leverage plus low error is the method carrying real * information. */export function formatLeverage(entry: MethodContribution): string { return contributesToComposite(entry) ? `${entry.leveragePp.toFixed(1)} pp` : NO_ESTIMATE;}
/** What the composite actually moves if the method goes — `weight × * leverage`, exactly, which is why the column sits beside the weight it is * a product of. A large weight next to a near-zero marginal contribution is * the agreeable-not-informative case, said in numbers. */export function formatMarginalContribution(entry: MethodContribution): string { return contributesToComposite(entry) ? `${entry.marginalPp.toFixed(1)} pp` : NO_ESTIMATE;}
const FAMILY_ELIMINATED_COPY: Readonly<Record<MethodFamily, string>> = { circumference: "the only tape-based estimate depends on this", "evans-direct": "dropping this removes the only method family that doesn't go through a skinfold density model", "siri-2C": "dropping this removes the whole skinfold-and-Siri family — seven of the ten equations and most of the published prior",};
export interface SiteStateCopy { readonly label: string; readonly detail: string;}
/** * The copy for each state. Rule 1 of the item's seven "must never" rules * lives in the `contributes-little` case: the phrase is "contributes little * right now", never "stop measuring this" — the evidence is about this * body at this composition on these dates, not a standing verdict. */export function siteStateCopy( state: SiteState, contribution: SiteContribution,): SiteStateCopy { switch (state) { case "essential": { const family = contribution.familiesEliminated[0]; return { label: "Essential", detail: family === undefined ? "Dropping this removes a whole method family." : FAMILY_ELIMINATED_COPY[family], }; } case "load-bearing": return { label: "Load-bearing", detail: `Dropping this typically shifts the composite by ${contribution.meanAbsDeltaPp.toFixed(1)} pp.`, }; case "contributes-little": return { label: "Contributes little right now", detail: "Its neighbours already say what it says, for this body at this composition — that can change as composition changes.", }; case "not-taken": return { label: "Not being taken", detail: "Not currently measured. Not a recommendation, a fact.", }; case "provisional": return { label: "Not enough evidence yet", detail: "The shift and the methods it enables are shown; the judgement is withheld.", }; }}
// ---------------------------------------------------------------------------// The hook.// ---------------------------------------------------------------------------
export interface MeasurementGuidanceState { /** True while the profile, the measurements, or the calibration is still * in flight. */ readonly isLoading: boolean; /** Null until every input is ready. Computable with zero DEXA scans — it * only needs a profile and at least the calibration's zero-pair * identity. */ readonly report: ContributionReport | null; readonly sex: Sex | null; readonly missingRace: boolean; /** `report.measurementsAnalysed === 1`: the whole panel is provisional, * not just the sites that would otherwise read "contributes little". */ readonly provisional: boolean;}
export function useMeasurementGuidance(): MeasurementGuidanceState { const userProfile = useQuery(api.userProfile.get); const measurements = useQuery(api.measurements.list, { limit: GUIDANCE_MEASUREMENT_WINDOW, }); const { calibration, isLoading: calibrationLoading } = useCalibratedBodyFat();
const isLoading = userProfile === undefined || measurements === undefined || calibrationLoading;
const report = useMemo(() => { if ( userProfile === undefined || userProfile === null || measurements === undefined || calibration === null ) { return null; } return analyseContributions( measurements, userProfile.sex, userProfile.race, calibration, userProfile.birthDate, ); }, [userProfile, measurements, calibration]);
const missingRace = userProfile !== undefined && userProfile !== null && userProfile.race === undefined;
return { isLoading, report, sex: userProfile ? userProfile.sex : null, missingRace, provisional: report !== null && report.measurementsAnalysed === 1, };}apps/web/src/hooks/useProgressChartData.ts
1234561 unmodified line89101112131415161718192021222324252627282930313233343536373839402 unmodified lines4344454647484950515253545556575859606115 unmodified lines777879808182838485868788899091929394955253969798991001011021031041051061071081091101111121131141153 unmodified lines119120121122123124125import { useMemo } from "react";import type { ChartConfig } from "@anthropometry/ui/components/ui/chart";import type { MethodCalibration } from "@anthropometry/domain/dexaCalibration";import type { Measurement, UserProfile } from "@anthropometry/domain/model";import type { WeightUnit } from "@anthropometry/domain/unitConversion";import {1 unmodified line calculateWeightYMin, type ChartDataPoint,} from "@anthropometry/domain/chartData";import type { ScanChartPoint } from "./useDexaScanPoints";
/** * A row of the body-fat chart's own data array: a measurement's estimate, or * a DEXA scan's percent fat, never both. * * The scans share the chart's data array rather than sitting on a `<Scatter * data={…}>` of their own, and that is not cosmetic. recharts' axis tooltip * looks an entry up by the x-axis `dataKey` within the array the series was * given, and falls back to an index lookup when the label misses — so a * separate scan array would answer a hover over a measurement with whichever * scan happened to sit at that index. One array, one lookup, no wrong number. * Rows with no value under a series' `dataKey` render nothing for it and are * dropped from the tooltip by recharts' own `filterNull`. */export interface BodyFatChartRow { readonly date: number; readonly bodyFat?: number | null; readonly scanPaired?: number; readonly scanUnpaired?: number;}
export interface UseProgressChartDataReturn { chartData: ChartDataPoint[]; /** The body-fat chart's data: the same points plus the scan overlay. Only * that chart gets it; the other three keep `chartData` untouched. */ bodyFatChartData: readonly BodyFatChartRow[]; chartConfig: ChartConfig; weightYMin: number | undefined; isLoading: boolean;2 unmodified lines/** * Hook for preparing progress chart data. * Combines chart data transformation with dynamic configuration. * * `calibration` comes from the page rather than from a `useCalibratedBodyFat` * call here, because the page needs it too — to say on the body-fat chart * that entering a scan moves the whole line. `scanPoints` arrives the same * way and for the same reason: one calibration is fitted per user, and the * scans it was fitted from are handed down rather than re-queried. */export function useProgressChartData( measurements: readonly Measurement[] | undefined, profile: UserProfile | null | undefined, weightUnit: WeightUnit, calibration: MethodCalibration | null, scanPoints: readonly ScanChartPoint[],): UseProgressChartDataReturn { const isLoading = measurements === undefined || profile === undefined;
15 unmodified lines label: "VO2max", color: "var(--chart-4)", }, // The scan overlay, on the body-fat chart only. The labels are what the // tooltip prints, which is where the unpaired mark has to say why it is // hollow: a scheduling fact with an action attached, not a failed scan. scanPaired: { label: "DEXA scan, % of total mass", color: "var(--chart-2)", }, scanUnpaired: { label: "DEXA scan, no measurement within 36 hours", color: "var(--muted-foreground)", }, };
// Transform measurements to chart data const chartData = useMemo(() => { if (!measurements) return []; return transformMeasurementsToChartData(measurements, profile, weightUnit); }, [measurements, profile, weightUnit]); return transformMeasurementsToChartData( measurements, profile, weightUnit, calibration ?? undefined, ); }, [measurements, profile, weightUnit, calibration]);
// The scans, merged into the body-fat chart's rows and re-sorted so that // one array carries both series in date order. const bodyFatChartData = useMemo<readonly BodyFatChartRow[]>( () => scanPoints.length === 0 ? chartData : [...chartData, ...scanPoints].sort((a, b) => a.date - b.date), [chartData, scanPoints], );
// Calculate smart Y-axis minimum for weight chart const weightYMin = useMemo(() => {3 unmodified lines
return { chartData, bodyFatChartData, chartConfig, weightYMin, isLoading,apps/web/src/hooks/useWiggleChartData.ts
1234515 unmodified lines21222324252627282911 unmodified lines4142434445464725 unmodified lines7374757677787921 unmodified lines10110210398104105106107import type { Doc } from "@anthropometry/convex/dataModel";import type { MethodCalibration } from "@anthropometry/domain/dexaCalibration";import { getGoalColor } from "@anthropometry/domain/goalColors";import { buildWiggleChartData,15 unmodified lines measurements: Doc<"measurements">[]; profile?: Doc<"userProfiles"> | null; weeks: number; /** The user's DEXA calibration, so a body-fat goal's historical * projections are scored under the same weighting as the goal itself. */ calibration: MethodCalibration | null;}
export interface UseWiggleChartDataResult {11 unmodified lines measurements, profile, weeks, calibration,}: UseWiggleChartDataOptions): UseWiggleChartDataResult { // Stable reference time — captured once, stable across re-renders const [now] = useState(Date.now);25 unmodified lines profile, weeks, goal.direction, calibration ?? undefined, ); wiggleDataByGoal[goal._id] = wiggleData; });21 unmodified lines });
return { chartData, goalColorMap, chartConfig }; }, [activeGoals, measurements, profile, weeks]); }, [activeGoals, measurements, profile, weeks, calibration]);
// Build set of hidden goal IDs from goal data const hiddenGoalIds = useMemo(() => {apps/web/src/meta.ts
73 unmodified lines747576777879808182838485868773 unmodified lines };}
export function dexaMeta(): PageMeta { return { title: `DEXA Scans — ${SITE_NAME}`, description: "Record a DEXA scan from a printed report and review its regional body composition.", };}
export function settingsMeta(): PageMeta { return { title: `Settings — ${SITE_NAME}`,apps/web/src/pages/AppShell.tsx
55 unmodified lines565758596061623 unmodified lines6667686869707172737475767719 unmodified lines979899100101102103117 unmodified lines22122222322422522622740 unmodified lines2682692702672712722732742752762772782793 unmodified lines28328428528628728828929029129255 unmodified lines Home, LogOut, Map, Scan, Settings, Target, Timer,3 unmodified lines} from "lucide-react";import { Link, Navigate, Outlet, useLocation } from "react-router";import { toast } from "sonner";import { signOut } from "../auth/shoo";import { signOut, useIsReauthenticating } from "../auth/shoo";import { ThemeToggle } from "../components/ThemeToggle";import { UnitToggles } from "../components/UnitToggles";import { InstallOffer } from "../install/InstallOffer";import { dexaPath, goalsPath, gpxCalculatorPath, homePath,19 unmodified lines { to: measurementsPath(), label: "Measurements", icon: Activity }, { to: progressPath(), label: "Progress", icon: BarChart3 }, { to: goalsPath(), label: "Goals", icon: Target }, { to: dexaPath(), label: "DEXA Scans", icon: Scan }, { to: settingsPath(), label: "Settings", icon: Settings },];
117 unmodified lines
export function AppShell() { const { isAuthenticated, isLoading } = useConvexAuth(); const isReauthenticating = useIsReauthenticating(); const { pathname } = useLocation();
// "skip" rather than an unconditional subscription: a signed-out render is40 unmodified lines }); };
if (isLoading) { // `isReauthenticating` shares this branch with `isLoading` deliberately: it // is the same statement — we do not yet know who this is — and it renders // the same markup, which is the markup every gated route is prerendered as. // Any other treatment would be a second shell to keep in sync and a // hydration risk for nothing. if (isLoading || isReauthenticating) { return ( <div className="flex min-h-screen items-center justify-center"> <div className="text-muted-foreground animate-pulse">3 unmodified lines ); }
// Below here, `!isAuthenticated` means signed out and staying that way. The // renewal case was answered above — reaching the redirect during a renewal // is what put a flash of the marketing page in the middle of a session.
// `replace`, not push: the gated URL must not sit in history for the back // button to return to, or a signed-out visitor bounces between /welcome/ and // the page that sent them there. Declarative rather than a `router.push` fromapps/web/src/pages/Dashboard.tsx
3 unmodified lines456778910111213141516171819202126 unmodified lines4849504451525354555657585960616236 unmodified lines9910010110210310410510610710810911011111211311429 unmodified lines14414514614714814915017 unmodified lines16816917017117217317426 unmodified lines20120220318420420520620722 unmodified lines23023123221321423323423523623723823924024124224324424524624724869 unmodified lines3183193202933213223232963243252983263273283293013303313323333343353363033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943373383393403413423433443453463473483493503513523533543553563573583 unmodified lines * Four stat cards with deltas, a profile-incomplete nudge, the inline * quick-entry card, the last five measurements, and the ten-method body-fat * breakdown. Almost none of the arithmetic is here: `useDashboardStats` builds * the composite, runs the weighted body-fat average over it and returns the * the composite, scores it under the user's DEXA calibration and returns the * four deltas, and this file renders what it gets. That split is worth * keeping — it is why the page is a transposition of the source rather than a * rewrite of it. * * The body-fat number is an **estimate** and is never rendered without * `BodyFatProvenance` beside it: the calibration state, the scan count, and * the disclosure that holds the uncalibrated number, the per-method weights * and the per-family summary. A confident-looking percentage with no context * is worse than the uncalibrated number it replaced, because it carries an * implication of precision that number never had. * * Two reads, not one. `useDashboardStats` ranges over 60 days to build the * composite the stat cards need; the "Recent Measurements" list wants the last * five rows regardless of date, which is a different question and a different26 unmodified linesimport { Skeleton } from "@anthropometry/ui/components/ui/skeleton";import { useQuery } from "convex/react";import { Minus, Plus, TrendingDown, TrendingUp } from "lucide-react";import { useState } from "react";import { useState, type ReactNode } from "react";import { Link } from "react-router";import { BodyFatProvenance } from "../components/dexa/BodyFatProvenance";import { FAMILY_INDEPENDENCE_COPY, METHOD_LABEL, formatMethodEstimate, formatWeightShare,} from "../components/dexa/provenanceStrings";import { AddMeasurementDialog } from "../components/measurements/AddMeasurementDialog";import { MeasurementQuickEntry } from "../components/measurements/MeasurementQuickEntry";import { useDashboardStats } from "../hooks/useDashboardStats";36 unmodified lines unit, change, loading, caption,}: { title: string; value: number | null | undefined; unit: string; change?: number | null; loading?: boolean; /** Rendered under the delta. This is how the body-fat card carries its * calibration state adjacent to the number rather than in a tooltip. */ caption?: ReactNode;}) { if (loading) { return <StatCardSkeleton title={title} />;29 unmodified lines ) : ( <span className="text-muted-foreground text-sm">No recent data</span> )} {caption} </CardContent> </Card> );17 unmodified lines vo2maxChange, time5kChange, isLoading, calibration, } = useDashboardStats();
// Unit preference26 unmodified lines <CardHeader> <CardTitle className="text-lg">Complete Your Profile</CardTitle> <CardDescription> Set up your profile to enable accurate body fat calculations Set up your profile so the app can estimate your body fat </CardDescription> </CardHeader> <CardContent>22 unmodified lines loading={isLoading} /> <StatCard title="Body Fat" value={bodyFatResult?.weighted} title="Estimated Body Fat" value={bodyFatResult?.percent} unit="%" change={bodyFatChange} loading={isLoading} caption={ <div className="mt-2"> <BodyFatProvenance result={bodyFatResult} phase={calibration.phase} /> </div> } /> <StatCard title="VO2max"69 unmodified lines </div>
{/* Body Fat Breakdown */} {bodyFatResult && bodyFatResult.weighted !== null && ( {bodyFatResult && bodyFatResult.percent !== null && ( <Card> <CardHeader> <CardTitle>Body Fat Estimates</CardTitle> <CardTitle>Estimated Body Fat</CardTitle> <CardDescription> Weighted average and individual calculation methods The composite, what each equation said, and where the number came from </CardDescription> </CardHeader> <CardContent> <CardContent className="space-y-4"> <BodyFatProvenance result={bodyFatResult} phase={calibration.phase} showEstimate /> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> {bodyFatResult.weighted !== null && ( <div className="border-primary bg-primary/5 rounded-lg border-2 p-4"> <p className="text-primary text-sm font-medium"> Weighted Average </p> <p className="text-2xl font-bold"> {bodyFatResult.weighted.toFixed(1)}% </p> </div> )} {bodyFatResult.navy !== null && ( <div className="rounded-lg border p-4"> <p className="text-muted-foreground text-sm">Navy</p> <p className="text-2xl font-bold"> {bodyFatResult.navy.toFixed(1)}% </p> </div> )} {bodyFatResult.evans3 !== null && ( <div className="rounded-lg border p-4"> <p className="text-muted-foreground text-sm">Evans 3-site</p> <p className="text-2xl font-bold"> {bodyFatResult.evans3.toFixed(1)}% </p> </div> )} {bodyFatResult.evans7 !== null && ( <div className="rounded-lg border p-4"> <p className="text-muted-foreground text-sm">Evans 7-site</p> <p className="text-2xl font-bold"> {bodyFatResult.evans7.toFixed(1)}% </p> </div> )} {bodyFatResult.dw !== null && ( <div className="rounded-lg border p-4"> <p className="text-muted-foreground text-sm"> Durnin-Womersley </p> <p className="text-2xl font-bold"> {bodyFatResult.dw.toFixed(1)}% </p> </div> )} {bodyFatResult.lohmanResult !== null && ( <div className="rounded-lg border p-4"> <p className="text-muted-foreground text-sm">Lohman</p> <p className="text-2xl font-bold"> {bodyFatResult.lohmanResult.toFixed(1)}% </p> </div> )} {bodyFatResult.katchResult !== null && ( <div className="rounded-lg border p-4"> <p className="text-muted-foreground text-sm">Katch</p> <p className="text-2xl font-bold"> {bodyFatResult.katchResult.toFixed(1)}% </p> </div> )} {bodyFatResult.forsythResult !== null && ( <div className="rounded-lg border p-4"> <p className="text-muted-foreground text-sm">Forsyth</p> <p className="text-2xl font-bold"> {bodyFatResult.forsythResult.toFixed(1)}% </p> </div> )} {bodyFatResult.thorlandResult !== null && ( <div className="rounded-lg border p-4"> <p className="text-muted-foreground text-sm">Thorland</p> <p className="text-2xl font-bold"> {bodyFatResult.thorlandResult.toFixed(1)}% </p> </div> )} {bodyFatResult.jp3 !== null && ( <div className="rounded-lg border p-4"> <p className="text-muted-foreground text-sm">JP 3-site</p> <p className="text-2xl font-bold"> {bodyFatResult.jp3.toFixed(1)}% </p> </div> )} {bodyFatResult.jp7 !== null && ( <div className="rounded-lg border p-4"> <p className="text-muted-foreground text-sm">JP 7-site</p> <p className="text-2xl font-bold"> {bodyFatResult.jp7.toFixed(1)}% </p> </div> )} {bodyFatResult.perMethod .filter((entry) => entry.estimate !== null) .map((entry) => ( <div key={entry.method} className="rounded-lg border p-4"> <p className="text-muted-foreground text-sm"> {METHOD_LABEL[entry.method]} </p> <p className="text-2xl font-bold"> {formatMethodEstimate(entry.estimate)} </p> <p className="text-muted-foreground text-xs"> {formatWeightShare(entry.weight)} of the composite </p> </div> ))} </div> <p className="text-muted-foreground text-xs"> {FAMILY_INDEPENDENCE_COPY} </p> </CardContent> </Card> )}This view needs a browser with declarative shadow DOM: Chrome 111, Safari 16.4, or Firefox 123. Read the source instead.
39 of 65 files shown — 26 past this page's size limit. Browse or clone the repository to read the whole merge.
clone
$ git clone https://git.fugl.dev/russ/fitnessanonymous, no account$ git clone ssh://git.fugl.dev/russ/fitnessneeds the bastion ProxyCommand