Skip to content

fix(web): show emission factors with enough precision to reproduce the reported emissions - #584

Merged
mrivas00 merged 18 commits into
mainfrom
feat/mati/emission-number-precision
Aug 26, 2026
Merged

fix(web): show emission factors with enough precision to reproduce the reported emissions#584
mrivas00 merged 18 commits into
mainfrom
feat/mati/emission-number-precision

Conversation

@mrivas00

@mrivas00 mrivas00 commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • A user reported the calculator "doesn't add up": the grid showed a factor of 0,06, they multiplied 21.600 × 0,06 = 1.296 kg ≈ 1,3 t, and the app reported 1,23 t. The app was right — the stored factor is 0,056944 and the UI rounded it to 2 decimals when displaying it. A number that contradicts its own result on screen costs the credibility of the whole inventory, for a user who has no independent way to check the maths.
  • Emission factors now display with 4 significant digits, floored at 2 decimals and capped at 6, and the value the API delivered is reachable on demand (tooltip on the factor) together with the full calculation chain (tooltip on the emissions cell). Own factors accept 10 decimals on input, matching the Decimal(28,10) column that stores them.
  • Second symptom from the same report: the equivalence card rendered 0,000057 tCO₂e/litros producidos at 4rem. The intensity now picks the mass unit that keeps the number readable, so it reads 56,94 g CO₂e/litros producidos.

Displayed numbers change in this PR — always towards more precision, never less. No stored data changes: no migration, no schema change, no API contract change. Frontend only. Worth a line in the release note, since users who knew the screen will see 0,05694 where they used to see 0,06.

Complementary tooltip

image

Correct precision on step 4

image

Correct precision on step 5

image

Linked issues

Follow-up opened from this work (not closed by this PR): #583OrganizationMainActivity needs a singular label so the equivalence can say "por litro producido" instead of "/litros producidos".

Type of change

  • fix — bug fix
  • feat — new feature
  • refactor — internal change with no behavioural impact
  • docs — documentation only
  • chore — repository or tooling change
  • infra — infrastructure-as-code change
  • security — security-relevant change
  • breaking — backward-incompatible change for country deployers

What changed

Factor precisionformatter.emissionFactor() stops delegating to the shared formatNumeric (which caps at 2 decimals for |v| ≥ 0,01, exactly the range where combustion and electricity factors live) and computes its own precision: clamp(3 − floor(log10 |v|), 2, 6).

value before after
0,056944 0,06 0,05694
0,177 0,18 0,177
2,68 2,68 2,68
1164,4894 1.164,49 1.164,49
0,000123 0,000123 0,000123

The 2-decimal floor is a no-regression guarantee — no factor may ever lose precision relative to the previous format, which is why values ≥ 100 keep more than 4 significant digits. The 6-decimal ceiling preserves the existing <0,000001 label. Precision is computed arithmetically rather than with maximumSignificantDigits + roundingPriority: that pair needs Intl NumberFormat v3, and where it is missing the browser silently degrades precision — the same bug, but intermittent per device.

formatNumeric is untouched, so emissions() and quantity() keep their exact previous output. Tests pin that boundary.

Audit affordancesDetailTooltipText (new, focusable, responds to tap) carries two things: the unrounded factor (Valor usado en el cálculo: 0,0569441234 kg/L) on the factor cell in step 3 and on the main factor of the step-4 factors table, and the full chain (21.600 L × 0,0569441234 kg/L = 1.229,99 kg = 1,23 t) on the emissions cell in step 3. This is also the answer to the known limit of display rounding: with very large quantities the rounded factor no longer reproduces the reported emissions (documented in a test), and the chain is the audit path.

Adaptive intensity — new formatter.emissionIntensity() returns { value, unit }, picking t / kg / g on the raw rate and re-checking after rounding (0,999999 t must show as 1 t, not 1.000 kg). Applied only to the equivalence card and the step-4 caption. Totals, rankings and the transparency portal stay in tCO₂e — they are compared across inventories, and a unit that varies per row destroys that. formatter.rate() had exactly these two consumers, so it was removed.

Excel export — the factor appeared in two sheets with two different defects. In Detalle emisiones the cell was already numeric but shared #,##0.00 with quantities and emissions, so it displayed as 0,06: the reported problem reproduced inside the spreadsheet. It now has its own format (#,##0.00####), leaving quantity and emissions columns untouched. In Factores utilizados the cell was a preformatted string and could not be multiplied at all; it is now a number, with the rate unit carried by the cell's number format. The unit rides in the format rather than in the column header because each row has its own (kg CO₂e/L, kg CO₂e/kWh) and one header cannot hold both — a correction to what the design document had resolved, recorded there.

Country-agnosticism checklist

  • No country-specific logic was hard-coded in the codebase.
  • Country variation is expressed through configuration, seed data or system parameters.
  • The change is backward-compatible with existing country deployments.

Precision bounds, input scale and the intensity thresholds are named constants in apps/web/src/config/constants.ts; the number formatting itself goes through APP_LOCALE.

DPG checklist

  • Documentation under docs/ was updated where relevant. — N/A, no documented behaviour changed; the change is specified under openspec/changes/improve-emission-number-precision/.
  • No new platform-specific dependency was added.
  • No new PII handling pattern was introduced.
  • New user-facing strings are in Spanish (Valor usado en el cálculo: …, t CO₂e / kg CO₂e / g CO₂e).

Security checklist

  • N/A — this PR does not touch security-sensitive code.

Mandatory local checks

  • All of the above pass locally on my machine.

pnpm format:check, pnpm lint, pnpm type-check and pnpm test:web (37 files, 763 tests) pass on the rebased tip.

Screenshots / evidence

Both columns come from the same inventory and the same stored data: the stack was brought up on origin/main, the inventory was captured, and then only the web image was rebuilt from this branch. Nothing in the database changed between the two columns, so every difference below is the display layer.

Standalone calculator, seeded DEFRA factors — electricity 0,177 kg/kWh on 10.000 kWh, solid waste 4,68568 kg/ton on 100 t, main activity 31.000 litros producidos, total 2,24 tCO₂e.

Paso 3 — the factor reproduces the emission again

This is the reported bug: 0,18 × 10.000 = 1.800 kg = 1,80 t, but the row reported 1,77 t. The number contradicted its own result on screen. With 4 significant digits the row multiplies out: 0,177 × 10.000 = 1.770 kg = 1,77 t.

Antes Después
[Pantallazo paso 3 factor Antes] [Pantallazo paso 3 factor Después]
0,18 kg/kWh1,77 tCO₂e (does not multiply out) 0,177 kg/kWh1,77 tCO₂e

Paso 3 — the exact factor, on demand

When 4 significant digits still round the stored value, the cell gets a dotted underline and reveals the unrounded number on hover, focus or tap. When they hide nothing there is no affordance, so the tooltip never repeats the cell.

Antes Después
[Pantallazo paso 3 tooltip factor Antes] [Pantallazo paso 3 tooltip factor Después]
4,69 kg/ton, no way to reach the stored value 4,686 kg/ton + Valor usado en el cálculo: 4,68568 kg/ton

Paso 3 — the full calculation chain

The emissions cell carries the whole chain, unrounded, which is also the answer to display rounding on very large quantities.

Antes Después
[Pantallazo paso 3 tooltip emisiones Antes] [Pantallazo paso 3 tooltip emisiones Después]
1,77 tCO₂e, no audit path 10.000 kWh × 0,177 kg/kWh = 1.770 kg = 1,77 t

Paso 4 — "Factores utilizados"

Same precision rule and the same exact-value affordance on the main factor of each row.

Antes Después
[Pantallazo paso 4 factores Antes] [Pantallazo paso 4 factores Después]
0,18 kg/kWh · 4,69 kg/ton 0,177 kg/kWh · 4,686 kg/ton (+ tooltip 4,68568)

Paso 4 — intensity caption of the total bar

Antes Después
[Pantallazo paso 4 equivalencia Antes] [Pantallazo paso 4 equivalencia Después]
Equivalencia: 0,000072 tCO₂e/litros producidos Equivalencia: 72,21 gCO₂e/litros producidos

The total itself stays in 2,24 tCO₂e in both columns — only the per-unit intensity adapts its mass unit.

Paso 5 — equivalence card

The hero number is 48px, which is exactly where 0,000072 reads as noise.

Antes Después
[Pantallazo paso 5 Antes] [Pantallazo paso 5 Después]
0,000072 tCO₂e/litros producidos 72,21 gCO₂e/litros producidos

Excel — the same inventory exported and opened as a spreadsheet

Detalle emisiones: the factor column had its own defect — the cell was numeric but shared #,##0.00 with quantities and emissions, so the reported problem reproduced inside the workbook. Quantity and emissions columns are untouched.

Antes Después
[Pantallazo Excel detalle Antes] [Pantallazo Excel detalle Después]
Factor 0.18 / 4.69 Factor 0.177 / 4.68568

Factores utilizados: the cell was a preformatted string and could not be multiplied at all. It is now a number, with the rate unit carried by the cell's number format (note the right alignment).

Antes Después
[Pantallazo Excel factores Antes] [Pantallazo Excel factores Después]
text 0,18 kg/kWh / 4,69 kg/ton number 0.177 kg/kWh / 4.68568 kg/ton

Other checks (no before/after pair)

  • Own factor round-trip. Typed 0,0569441234 as a "Factor Propio", saved, reloaded the screen: the input returns all 10 decimals, the grid shows 1,23 tCO₂e for 21.600 L (the reported case), and the step-4 factors table shows 0,05694 kg/L with the full value in its tooltip. This exercises the input, mapLinesToSyncRequest, the z.number() contract, Decimal(28,10) persistence, and Decimal.toNumber() on the way back.
  • Equivalence across three magnitudes. Varying the main-activity quantity on the same inventory, step 4 and step 5 agree and the hero typography does not clip: 7,63 t CO₂e/…, 7,63 kg CO₂e/…, 7,63 g CO₂e/…, with the total staying in tCO₂e throughout.
  • Keyboard reach. Tabbing through a step-3 row lands on the factor and then on the emissions cell, each opening its tooltip — the affordance is not mouse-only.

Reviewer notes

  • The floor and the ceiling take precedence over the 4-significant-digit target, deliberately. 1164,4894 → 1.164,49 is 6 significant digits and that is the intended result: dropping to 1.164 would show less precision than the format this PR replaces. There is a test pinning it, and the spec states the hierarchy, so please don't "fix" it into pure significant digits.
  • Two input scales now coexist: INPUT_DECIMAL_SCALE = 4 globally and FACTOR_INPUT_DECIMAL_SCALE = 10 on the factor cell only. Both constants carry a comment explaining why; the alternative (raising the global scale) would have changed quantities and every other numeric form for a problem that only exists for factors.
  • The per-gas breakdown in the factors table inherits the new precision but deliberately does not get the exact-value affordance — those values come from an already-converted JSON and are not the number a user tries to reconcile.
  • Commits are modular and ordered (constants + formatter + tests → step 3 → step 4 → step 5/home → Excel → cleanup); reviewing commit by commit should read cleanly.

@mrivas00 mrivas00 added priority: low Low priority type: bug reproducible defect with observable wrong behaviour area: web apps/web (React frontend) labels Aug 4, 2026
@mrivas00
mrivas00 force-pushed the feat/mati/emission-number-precision branch from f520a7b to ec7330a Compare August 4, 2026 13:51

@nivek0o0 nivek0o0 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Staff review — PR #584

1. Executive Summary

This fixes a real credibility bug with an unusually disciplined change: display-only, scoped to the two number families that were actually wrong (emission factors, equivalence intensity), with formatNumeric deliberately left untouched so emissions() and quantity() keep byte-identical output. The diff matches the description — I checked every claim I could verify from the tree, including that formatter.rate() has zero remaining consumers, that factorValue/rateUnit/equivalence.rate are all non-nullable in the contracts the new code trusts, and that the arithmetic precision formula produces every value in the PR's table. No scope creep: 19 files, 6 of them the OpenSpec change, and every code file is on the stated path.

The one substantive finding is that the calculation-chain tooltip — the affordance the spec designates as the audit path — formats its quantity term through formatter.quantity(), which rounds to 2 decimals while the quantity input on the same row accepts 4. The chain can therefore contradict the cell next to it and fail to multiply out, which is the reported bug reproduced one level down inside the fix for it. Small blast radius, easy fix, worth doing before merge.

Production readiness: Approve with Comments.

Nothing here can corrupt data, break a deployment, or weaken the security posture: no migration, no schema change, no API contract change, no new dependency, no auth surface. All 23 required checks are green. The Medium is a defect in a new tooltip, not a regression of anything that worked before, so it does not need to gate the merge — but it undercuts the PR's own thesis and should not be deferred past this change.

2. Summary of Findings

  • 🔴 Critical: 0
  • 🟠 High: 0
  • 🟡 Medium: 1
  • 🟢 Low: 5

3. Prioritized Findings

All findings are posted inline. Index:

# Sev Location Finding
1 🟡 EmissionEditorEmissionsCell.tsx:42 The audit chain rounds its own quantity term to 2 decimals while the quantity input accepts 4 — the chain can contradict the row it explains
2 🟢 DetailTooltipText.tsx:44 Hardcoded tabIndex={0} instead of forwarding GridRenderCellParams.tabIndex; adds ~2 tab stops per rendered row
3 🟢 exportCarbonInventoryToExcel.ts:226 Bypasses the existing applyNumberFormat guard; unbounded rateUnit interpolated into a number-format code (Excel's 255-char limit → repair dialog)
4 🟢 exportCarbonInventoryToExcel.ts:157 Quantity/emissions columns still show 0,00 where the app shows 0,000123 — same defect class, one column over
5 🟢 formatting.ts:36 t CO₂e vs the app's tCO₂e, now adjacent in TotalEmissionsBar; plus the zero-rate-in-grams nit
6 🟢 proposal.md:16 Proposal and tasks.md:61 still describe the unit-in-header variant that design.md decision 7 reversed; 8.3 ticked as done

One cross-cutting item that has no diff line to hang on:

🟢 Low — Testing. New code lands on the untested side of the coverage ratchet. formatting.ts is excellently covered (the four precision bands, both precedence rules, guard order, the reported case, the documented divergence, every intensity band and border). But DetailTooltipText ships with no test at all, and the chain-string construction — the thing finding 1 is about — is logic embedded in a render function. The Chatbot module already establishes the RTL pattern in this repo, so a DetailTooltipText test asserting both branches (detail empty → plain <p> with no tab stop; detail present → tooltip, focusable, aria-describedby on open) is cheap, and it is exactly the kind of addition vitest.config.ts asks for — raising coverage rather than the number. Extracting buildCalculationChain() per finding 1 would make the second half testable too.

4. Architectural Observations

The scoping decision is the best thing about this change. Four public methods shared one private formatNumeric; the fix could have been two characters in that function and would have silently moved emissions, rankings, the public transparency portal and the Excel export at once. Splitting emissionFactor() out and pinning the boundary with tests ("the precision change stops at factors") is the right instinct, and the reason the risk of this PR is as low as it is.

Formatter is accreting formatters. The class now holds eight Intl.NumberFormat instances plus a Map of five more, all built eagerly in the constructor for a module-level singleton. That's fine today — construction is cheap and one-time, and the pre-built map is the right call over per-call construction (which percentage() still does on the custom-digits path). Flagging it only as a direction: the next two or three numeric presentation rules will make this class hard to read, and the natural seam is one formatter object per value family (factor / intensity / emissions) rather than more fields on one class.

Coupled constants live in two files. INTENSITY_KG_THRESHOLD_T = 0.001 in config/constants.ts is only correct in combination with perTon: KG_PER_TON in formatting.ts. Someone tuning the threshold without touching the scale table gets a silently wrong unit. Deriving the thresholds from the scale table (or moving INTENSITY_SCALE next to them) would make the invariant unbreakable. Not worth churning this PR for.

On avoiding roundingPriority: the conclusion is right — arithmetic is deterministic and testable, and the clamp is easier to reason about than option interaction. The stated premise is aging (Intl NumberFormat v3 is broadly available now), so I would not carry "the browser might not support it" forward as a reason in future decisions; "we can test the formula" is the durable one. No action.

5. Positive Feedback

  • The OpenSpec change is a model for this repo. Discarded alternatives with reasons (4 fixed decimals, 5 significant digits, raising the global input scale, tooltip-only), the guard order documented as normative with the failure each guard prevents, and — most valuable — the known limitation stated as a scenario instead of a promise the code can't keep. Decision 7 recording a mid-implementation reversal against its own earlier decision is exactly what these documents are for.
  • The no-regression test is genuinely clever. Asserting decimalsShown(emissionFactor(v)) >= decimalsShown(quantity(v)) uses the untouched sibling method as a living oracle for the pre-change behaviour, so the floor can't be eroded later by editing an expected string. That is a better guard than a table of hardcoded values.
  • documents that very large quantities no longer reconcile by hand — encoding an accepted limitation as a passing test, with the reasoning in a comment, is how you stop a future contributor from "fixing" it into per-row precision. Same for the reviewer note asking not to correct 1.164,49 into pure significant digits.
  • The Excel test round-trips through serialize-then-parse (buildAndLoad), so the numFmt assertions verify what a consumer actually opens rather than in-memory ExcelJS state. That matters here because addTable writes rows and styles at store time; a naive assertion would have proven nothing about the per-row numFmt loop.
  • Keeping the Excel cell numeric with the unit in the number format is the better answer than either a preformatted string or a sixth column: the cell stays multipliable, the unit stays visible, and the sheet doesn't grow. The " strip shows the format-injection surface was actually thought about.
  • Deleting rate() rather than migrating it, after confirming zero consumers, keeps the class from growing a vestigial method. Small thing, right instinct.
  • Restricting the adaptive scale to the equivalence and writing down why (totals are compared across inventories; a unit that varies per row destroys that) is the kind of reasoning that keeps a DPG usable across deployments.

6. Open Questions

  1. Factor cell width. The non-editable branch renders {factor} {abbreviation} in a plain Typography inside a column with minWidth: 130 and max-h-[56px], with no noWrap/ellipsis. A small factor with a long abbreviation goes from 0 kg/cant anim (14 chars) to 0,000123 kg/cant anim (21). Did the manual pass cover a subcategory with kg/cant anim, kg/pieza arre or kg/km-ton and a sub-0,001 factor? Now that the cell carries a tooltip anyway, noWrap would make the truncation graceful instead of relying on the column never being at its minimum.
  2. EXACT_MAX_FRACTION_DIGITS = 20. For a value whose double needs more than 20 fraction digits, the method labelled "exact" would round without saying so. Unreachable from a Decimal(28,10) column (I verified 0,0569441234 and 1e-10 both round-trip through Intl intact), so this is a comment request, not a bug: was 20 chosen as "comfortably above the column's 10", or just as the old Intl maximum? Worth one line either way, since the method's name is a promise.
  3. useSubcategoryLinesColumns (step-4 lines table, not in the diff) inherits the new factor precision and shows quantity + factor + emissions side by side — the same reconcilable triple as step 3 — but no exact-value affordance, per the design's explicit scoping. Is the expectation that a user who spots a mismatch there navigates back to step 3 to audit, or is that table just "already summarized, don't audit here"? The spec says the latter; I'd only ask whether the user knows that.
  4. tasks.md:7.2 honestly discloses that HomeScreen was covered by component identity rather than visual inspection because of the onboarding gate. Recording that instead of ticking it silently is the right call — noting it here only so it's visible to whoever merges.

7. Open Source Assessment

  • OpenSSF / secure-by-default: neutral, correctly. No new dependency, no new permission, no build-graph change, no secret handling; Audit, CodeQL, Secret Scan, Trivy and Zizmor all green. The one place untrusted-ish data reaches a new sink (the Excel number-format literal) was recognized and partially handled — finding 3 closes the remaining gap.
  • Security posture: unchanged. No auth surface, no user-supplied string reaching a renderer un-escaped (React escapes the tooltip content; the chain is a plain string).
  • Maintainability: net positive. The dead rate() is gone, the precision rules are named constants carrying their rationale, and every rule has a test. The debt consciously taken on — two coexisting input scales — is documented at both definitions, which is the correct way to take it. Findings 5 and 6 are the small maintainability leaks.
  • Contributor experience: strong. The design document tells the next contributor why the floor beats the significant-digit target, which is the exact thing someone would otherwise "fix" and regress.
  • Technical debt: low and declared. Formatter growing is the only structural pressure, and it's not yet a problem.
  • DPG goals: directly supportive. A carbon inventory whose displayed factor contradicts its own total is not verifiable by the person who has to defend it, and verifiability is the product. Handling this as a precision-and-auditability change rather than a cosmetic rounding tweak is the right framing, and keeping the affordance reachable by keyboard and touch (not hover-only) matters in Latin American deployments where tablets are common.

8. Final Recommendation

Approve with comments. The change is correct, well-scoped, well-tested and unusually well-documented; the risk of merging it is close to zero and the user-facing benefit is concrete.

I'd ask for one thing before merge — finding 1, formatting the chain's quantity term without display rounding — because it's a few characters and because an audit affordance that rounds one of its own inputs is the same defect this PR exists to remove. Findings 2–6 are all safely declinable or deferrable to follow-ups; 4 in particular deserves an issue rather than a scope expansion here.

Nice work on the reviewer notes and the commit ordering — reviewing this commit by commit did read cleanly, exactly as advertised.

Comment thread apps/web/src/components/DetailTooltipText.tsx Outdated
Comment thread apps/web/src/utils/exportCarbonInventoryToExcel.ts Outdated
Comment thread apps/web/src/utils/exportCarbonInventoryToExcel.ts
Comment thread apps/web/src/utils/formatting.ts Outdated
Comment thread openspec/changes/improve-emission-number-precision/proposal.md Outdated
@mrivas00
mrivas00 force-pushed the feat/mati/emission-number-precision branch from ec7330a to d1e55ac Compare August 12, 2026 17:04
@mrivas00
mrivas00 force-pushed the feat/mati/emission-number-precision branch from d1e55ac to a27e142 Compare August 26, 2026 14:57
The grid rounded every factor to 2 decimals, so a line with 21.600 L and a
stored factor of 0,056944 displayed "0,06" — multiply that by hand and you get
1,3 t against the 1,23 t the app reports. The number contradicted its own
result on the screen where users check the arithmetic.

`emissionFactor()` now computes its own precision (4 significant digits,
floored at 2 decimals and capped at 6) instead of delegating to the shared
`formatNumeric`, which stays untouched for emissions and quantities. The floor
is a no-regression guarantee: no factor loses precision relative to today.

Adds `emissionFactorExact()` for the "value used in the calculation"
affordance, and `emissionIntensity()`, which picks the mass unit (t/kg/g) that
keeps an intensity readable — 0,000057 tCO₂e/unit reads as "57 g CO₂e".
…apture grid

The displayed factor is rounded, so with large quantities the hand-made
multiplication still drifts from the reported emissions. Both cells now offer
the audit path on demand: the factor cell reveals the value as the API
delivered it, and the emissions cell the whole chain
(21.600 L × 0,056944 kg CO₂e/L = 1.229,99 kg = 1,23 t) computed with the
unrounded factor.

`DetailTooltipText` carries the affordance: it is focusable and answers to
tap, because an audit trail reachable only with a mouse is no audit trail on a
tablet.

Own factors also accept 10 decimals now (`FACTOR_INPUT_DECIMAL_SCALE`),
matching the `Decimal(28,10)` column that stores them, so a pasted official
factor is not truncated without warning. The global input scale stays at 4.
…he summary

The factors table gains the same exact-value affordance on the main factor;
its per-gas breakdown inherits the precision but not the affordance, since
those values are not the number a user tries to reconcile.

The total bar caption drops the hardcoded "tCO₂e/" for the adaptive unit, so a
low-footprint organization reads "57 g CO₂e/litros producidos". The total
itself stays in tCO₂e — it is compared across inventories.
The card renders its number at 4rem, where "0,000057" is unreadable. It now
receives the value and unit from the adaptive intensity formatter, so the same
rate reads as "56,94" + "g CO₂e/litros producidos". Both mount points (step 5
and the home screen) get it, and the empty state is untouched.
The factor appeared in two sheets with two different defects. In "Detalle
emisiones" the cell was already numeric but shared the `#,##0.00` format with
quantities and emissions, so it *displayed* as 0,06 — the reported problem,
reproduced inside the spreadsheet. It gets its own format now, up to 6
decimals, leaving the quantity and emission columns alone.

In "Factores utilizados" the cell was a preformatted string and could not be
multiplied at all. It becomes a number, with the rate unit carried by the
cell's number format rather than by the value: each row has its own unit
(kg CO₂e/L, kg CO₂e/kWh), so a single column header could not hold it.
Its only two consumers — the equivalence card and the total-bar caption — both
moved to the adaptive intensity formatter, so migrating it would have been
dead work. Confirmed by search and by type-check that nothing else calls it.
Proposal, design, the two capability specs and the task list, with the Excel
rate-unit decision corrected to what the implementation revealed: the unit
varies per row, so it rides in the cell number format instead of the column
header.
The floor asked whether the rounded value was zero, so a rate of 0,0075 g
rounded up into a displayable 0,01 g and the card claimed a precision the rate
does not reach. It now compares the raw magnitude against the floor, which is
the guard order emissionFactor() already uses for its threshold label.

The mass unit was spelled with a space (`t CO₂e`) while the rest of the app
writes `tCO₂e` — including the inventory total that sits right above this
caption on step 4. Two spellings of one unit on one screen read as two
different units.
Two defects of the same kind: an audit affordance that does not deliver what
it promises.

The calculation chain mixed the unrounded factor with a quantity and a product
formatted by the display formatters, so a line of 0,12345 L read
"0,12 × 0,056944 = 0,00703" — a chain that does not multiply out, inside the
tooltip whose only purpose is that it does. Every number of the chain now goes
through the unrounded formatter.

The exact-value tooltip opened on every non-editable factor, even where the
display hid nothing: a factor of 0,177 opened a tooltip repeating the cell. It
is now offered only when the displayed and the unrounded value differ.

emissionFactorExact() becomes the generic exact(), since the chain needs it for
a quantity and two products, and its cap moves from Intl's 20 fraction digits
to the scale the domain columns preserve: the operands are products of doubles,
and 10.000 x 0,177 / 1000 renders as 1,7700000000000002 with 20.
The factor number format repeated the app's precision bounds as a literal, so
raising them would leave the export behind with no test failing, and its
ceiling was the grid's 6 decimals. The grid caps at 6 because it is dense and
compensates with the exact-value tooltip; a spreadsheet has no tooltip, so a
10-decimal own factor was displayed truncated — the original defect one layer
down. The format is now derived from the same constants, up to the database
scale.
Task 8.3 and the proposal still said the Excel rate unit travelled in the
column header, which the implementation had already replaced with a per-cell
number format. Beyond that, the gram floor decided on the raw value, the unit
spelling, the affordance that only appears when it has something to reveal,
the unrounded calculation chain and the derived Excel format are now normative
in their specs, with scenarios for the cases the original tests missed.
DetailTooltipText hardcoded tabIndex={0}, so every rendered row added a
fixed tab stop for its factor cell and another for its emissions cell —
~40 stops in a 20-row viewport that a keyboard user had to traverse before
Tab left the grid, bypassing MUI X's roving-tabindex model.

The trigger now accepts a tabIndex (defaulting to 0 so standalone use stays
keyboard-reachable), and the three grid renderCells that mount it — the
capture grid's factor and emissions cells and the step-4 factors table —
forward GridRenderCellParams.tabIndex so the content joins the grid's focus
model instead of competing with it.
… drift

The per-row factor format now goes through the existing applyNumberFormat
helper instead of assigning numFmt directly, so it inherits the guard that
skips any cell whose value is not a number — the "-" placeholder that display()
would produce should factorValue ever become nullable never carries a numeric
format.

factorNumFmtWithUnit already stripped the double quote that would break out of
the number format's literal, but rateUnit (RateMeasurementUnit.abbreviation,
maintainer-editable, unbounded) could still push the format past Excel's
255-character ceiling, which greets the user with a "repair the workbook"
dialog and takes down the whole export. Over the limit it now drops the unit
and keeps the plain numeric format.
… columns

The quantity and line-emission columns used a flat "#,##0.00", so a line the
app shows as 0,000123 tCO₂e or a quantity of 0,005 rendered as 0,00 and 0,01 in
the spreadsheet — the same "the export contradicts the screen" defect this
change fixes for the factor column, landing on the small-footprint lines these
SMEs routinely have. The format now carries optional decimals up to the app's
own display ceiling (MAX_DISPLAY_DECIMALS), so those lines keep their digits
while ordinary values still read with two.
The pinned acceptance case for calculator step 3 documented the flat
2-decimal factor column as expected behaviour, down to a note saying
"reproducing the arithmetic from the screen alone will not match" —
which is precisely the defect this branch removes.

Recompute the fixture against the new formatter: 11 of the 21 rows
change their Shown factor, and 3 of them now expose the unrounded value
on demand (marked with a legend). Fix the equivalence cross-check, which
renders as 59,26 gCO2e per litre and no longer as 0,000059 tCO2e, and
extend the checklist to cover the audit affordances and their keyboard
path. The fixture-refresh instructions no longer fit one Intl formatter,
so they now spell out the rule per column.

Rewrite the Display Precision section around what was actually missing:
where precision is lost end to end. The section claimed the API "never
rounds" while omitting that serialization calls Decimal.toNumber(), so
the guarantee is float64, not decimal fidelity to the stored column.
A reviewer could find no documentation for how the web app formats
numbers, nor for what "intensity" means. The rules existed only in the
OpenSpec change, which records a decision and then gets archived — not
where anyone looks for "how do I format this number".

Add docs/development/number-formatting.md: which Formatter method to
reach for, why factors need their own precision, and the two parts of
the audit-affordance pattern that are easy to get wrong — leaving the
tooltip empty when the rounding hides nothing, and forwarding the grid's
roving tabindex instead of adding a tab stop per row.

Define Emission intensity in the glossary, where the point is that one
concept answers to three names: rate in the API, "Equivalencia" in the
Spanish UI, emissionIntensity in the web code. Define Main activity
alongside it, since the quantity is snapshotted into the inventory's
organizationData while only the display name comes from the catalog.
@mrivas00
mrivas00 force-pushed the feat/mati/emission-number-precision branch from a27e142 to 6c60ace Compare August 26, 2026 16:20
@mrivas00
mrivas00 requested a review from nivek0o0 August 26, 2026 16:30
@mrivas00
mrivas00 merged commit 34f66c3 into main Aug 26, 2026
24 checks passed
@mrivas00
mrivas00 deleted the feat/mati/emission-number-precision branch August 26, 2026 17:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: web apps/web (React frontend) priority: low Low priority type: bug reproducible defect with observable wrong behaviour

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants