fix(web): show emission factors with enough precision to reproduce the reported emissions - #584
Conversation
f520a7b to
ec7330a
Compare
nivek0o0
left a comment
There was a problem hiding this comment.
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 correct1.164,49into pure significant digits.- The Excel test round-trips through serialize-then-parse (
buildAndLoad), so thenumFmtassertions verify what a consumer actually opens rather than in-memory ExcelJS state. That matters here becauseaddTablewrites rows and styles at store time; a naive assertion would have proven nothing about the per-rownumFmtloop. - 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
- Factor cell width. The non-editable branch renders
{factor} {abbreviation}in a plainTypographyinside a column withminWidth: 130andmax-h-[56px], with nonoWrap/ellipsis. A small factor with a long abbreviation goes from0 kg/cant anim(14 chars) to0,000123 kg/cant anim(21). Did the manual pass cover a subcategory withkg/cant anim,kg/pieza arreorkg/km-tonand a sub-0,001factor? Now that the cell carries a tooltip anyway,noWrapwould make the truncation graceful instead of relying on the column never being at its minimum. 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 aDecimal(28,10)column (I verified0,0569441234and1e-10both round-trip throughIntlintact), so this is a comment request, not a bug: was 20 chosen as "comfortably above the column's 10", or just as the oldIntlmaximum? Worth one line either way, since the method's name is a promise.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.tasks.md:7.2honestly discloses thatHomeScreenwas 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.
Formattergrowing 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.
ec7330a to
d1e55ac
Compare
d1e55ac to
a27e142
Compare
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.
a27e142 to
6c60ace
Compare
Summary
0,06, they multiplied21.600 × 0,06 = 1.296 kg ≈ 1,3 t, and the app reported1,23 t. The app was right — the stored factor is0,056944and 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.Decimal(28,10)column that stores them.0,000057 tCO₂e/litros producidosat4rem. The intensity now picks the mass unit that keeps the number readable, so it reads56,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,05694where they used to see0,06.Complementary tooltip
Correct precision on step 4
Correct precision on step 5
Linked issues
Follow-up opened from this work (not closed by this PR): #583 —
OrganizationMainActivityneeds a singular label so the equivalence can say "por litro producido" instead of "/litros producidos".Type of change
What changed
Factor precision —
formatter.emissionFactor()stops delegating to the sharedformatNumeric(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).0,0569440,060,056940,1770,180,1772,682,682,681164,48941.164,491.164,490,0001230,0001230,000123The 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,000001label. Precision is computed arithmetically rather than withmaximumSignificantDigits+roundingPriority: that pair needs Intl NumberFormat v3, and where it is missing the browser silently degrades precision — the same bug, but intermittent per device.formatNumericis untouched, soemissions()andquantity()keep their exact previous output. Tests pin that boundary.Audit affordances —
DetailTooltipText(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 }, pickingt/kg/gon the raw rate and re-checking after rounding (0,999999 tmust show as1 t, not1.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 emisionesthe cell was already numeric but shared#,##0.00with quantities and emissions, so it displayed as0,06: the reported problem reproduced inside the spreadsheet. It now has its own format (#,##0.00####), leaving quantity and emissions columns untouched. InFactores utilizadosthe 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
Precision bounds, input scale and the intensity thresholds are named constants in
apps/web/src/config/constants.ts; the number formatting itself goes throughAPP_LOCALE.DPG checklist
docs/was updated where relevant. — N/A, no documented behaviour changed; the change is specified underopenspec/changes/improve-emission-number-precision/.Valor usado en el cálculo: …,t CO₂e/kg CO₂e/g CO₂e).Security checklist
Mandatory local checks
pnpm format:check,pnpm lint,pnpm type-checkandpnpm 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/kWhon 10.000 kWh, solid waste4,68568 kg/tonon 100 t, main activity31.000 litros producidos, total2,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 reported1,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.0,18 kg/kWh→1,77 tCO₂e(does not multiply out)0,177 kg/kWh→1,77 tCO₂ePaso 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.
4,69 kg/ton, no way to reach the stored value4,686 kg/ton+Valor usado en el cálculo: 4,68568 kg/tonPaso 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.
1,77 tCO₂e, no audit path10.000 kWh × 0,177 kg/kWh = 1.770 kg = 1,77 tPaso 4 — "Factores utilizados"
Same precision rule and the same exact-value affordance on the main factor of each row.
0,18 kg/kWh·4,69 kg/ton0,177 kg/kWh·4,686 kg/ton(+ tooltip4,68568)Paso 4 — intensity caption of the total bar
Equivalencia: 0,000072 tCO₂e/litros producidosEquivalencia: 72,21 gCO₂e/litros producidosThe total itself stays in
2,24 tCO₂ein 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,000072reads as noise.0,000072tCO₂e/litros producidos72,21gCO₂e/litros producidosExcel — 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.00with quantities and emissions, so the reported problem reproduced inside the workbook. Quantity and emissions columns are untouched.0.18/4.690.177/4.68568Factores 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).0,18 kg/kWh/4,69 kg/ton0.177 kg/kWh/4.68568 kg/tonOther checks (no before/after pair)
0,0569441234as a "Factor Propio", saved, reloaded the screen: the input returns all 10 decimals, the grid shows1,23 tCO₂efor 21.600 L (the reported case), and the step-4 factors table shows0,05694 kg/Lwith the full value in its tooltip. This exercises the input,mapLinesToSyncRequest, thez.number()contract,Decimal(28,10)persistence, andDecimal.toNumber()on the way back.7,63 t CO₂e/…,7,63 kg CO₂e/…,7,63 g CO₂e/…, with the total staying in tCO₂e throughout.Reviewer notes
1164,4894 → 1.164,49is 6 significant digits and that is the intended result: dropping to1.164would 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.INPUT_DECIMAL_SCALE = 4globally andFACTOR_INPUT_DECIMAL_SCALE = 10on 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.