tech-debt batch 3: shared UI primitives + lib/stats + scraper dedup (#150/#151/#152)#165
Merged
Conversation
added 5 commits
June 22, 2026 17:32
Factor the duplicated numeric primitives into a pure, hermetic lib/stats.ts
(median, mad, sampleStdev n-1, populationStd n) and re-point the ~4 inline
median copies + the two stdev variants. Behavior-preserving: identical median
tie-break (sorted copy, even = mean of the two central) and the deliberate
n vs n-1 distinction are kept.
Re-pointed call sites:
- prediction.ts: medianIntervalDays inline-median, intervalSpreadDays lambda,
projectSeason lambda -> median(); sampleStdev def -> import.
- anomaly.ts: local median + mad -> import.
- viz/aggregate.ts: local median + populationStd -> import (call site still
passes the precomputed mean, so populationStd's optional mean is behavior-
preserving).
Added hand-calculated tests (expected values worked out by hand, not snapshots):
- test/stats.test.ts (new): median odd/even/single/negative + no-mutation,
mad, sampleStdev (n-1), populationStd (n), and a guard that sample > pop on
the same data.
- test/parse.test.ts: no-Amount-Due-line + Balance-Forward computed fallback
(amountDue = cc + bf); negative otherCharges needing the round guard (-0.13).
- test/range.test.ts: ymToLastYmd month-end math (Dec 31, Apr 30, Feb leap 29
/ non-leap 28, Jan 31, null).
- test/interval.test.ts: grain-inference edges (trailing node reuses a
non-default 900s gap; zero gap -> 3600 fallback). Source unchanged.
Docker test stage: 948 passed. Production builder: green.
Extract five behaviour/visual-preserving shared pieces and re-point all
call sites; no visual or behaviour change.
- components/widgets/Segmented.tsx: one generic amber-pill toggle. Accepts
either a plain value[] (capitalize flag) or a {label,value}[] shape, plus
disabledValues. Replaces the 4 copies in IntervalHistory/IntervalLoadShape/
IntervalHeatmap and unifies ConfigurableChart's onto it.
- lib/chartTheme.ts: TOOLTIP_STYLE / AXIS_STYLE / FUEL_COLORS / FUEL_LABEL /
FUEL_UNIT / POWER_UNIT constants, replacing the copy-pasted objects in
IntervalHistory, IntervalLoadShape, IntervalHeatmap, VizCharts and
ConfigurableChart.
- lib/hooks/useDismissable.ts: outside-click + Esc close hook with event +
capture options; preserves IntervalHistory's deliberate capture-phase
pointerdown listener and MonthRangePicker's Esc-only focus-restore.
- lib/intervalProfile.ts formatPeakReadout: pure peak-demand readout formatter
(out of the components per standards §2) with a hand-calc unit test.
- lib/hooks/useIntervalPayload.ts + components/widgets/IntervalWidgetBody.tsx:
shared self-fetch hook + loading/error/empty scaffold for IntervalLoadShape
and IntervalHeatmap; each keeps its distinct validate + empty wording.
IntervalHistory's richer SWR fetch is left untouched (#156).
Behavior-preserving tech-debt paydown on auth.ts / collect.ts and the
duplicated date/error/gql-regex helpers. No change to scraper timing,
selectors, control flow, or parse output.
- auth.ts: extract one fillEmailAndPassword(page, user, pass, log) from the
byte-identical fill sequence in login() and fillCredentials(); it returns the
submit selector so each caller keeps its own click order (login() still arms
the nav-wait between finding and clicking). Selectors + timeouts unchanged.
- collect.ts: type the GraphQL parsing `any` cluster against new Raw* interfaces
(collectRaw.ts), following interval.ts's Raw*-interface + explicit-narrow idiom.
`cap`, the discovery/response capture, and the bill/usage/weather/fuel mappers
are typed; parse output is byte-identical (compile-time casts only). Zero `any`
left in the scraper files touched.
- New shared modules:
- collectRaw.ts: exported GQL_URL_RE (was inlined in collect/intervalPull) +
Raw* gql payload types.
- dates.ts: pure DAY_MS / yyyymm / fmtYmd / fmtPortal, consolidating the copies
in collect.ts, portalFetch.ts, interval.ts.
- errMessage.ts: pure errMessage(err: unknown, max=200), replacing the ~11
hand-rolled `String(err?.message || err).slice(0, n)` sites; those catches are
now `catch (err: unknown)`. Never surfaces secret material.
- Tests: test/errMessage.test.ts + test/dates.test.ts (hand-calculated).
SKIPPED (reported, too risky for a zero-behavior-change PR): a shared
captureBillingAccount() — collect's onDiscovery and intervalPull's onResp differ
in purpose (collect buffers all payloads; intervalPull first-wins on
billingAccount), so factoring would risk altering the capture semantics. Shared
GQL_URL_RE between them instead.
NEEDS OPERATOR VERIFICATION against a real National Grid account post-merge: the
auth.ts login extraction (live login path, not CI-testable per standards §4).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Tech-debt batch 3 — shared primitives + missing tests (epic #161). All behavior-preserving refactors; no new feature, no visual change intended.
Closes #150, closes #151, closes #152.
#150 — consolidate duplicated UI primitives
components/widgets/Segmented.tsx(one generic toggle, replaces 4 copies),lib/chartTheme.ts(tooltip/axis/fuel constants, replaces 4-5 copies),lib/hooks/useDismissable.ts(outside-click+Esc, replaces 4 effects — preserves IntervalHistory's capture-phase),lib/hooks/useIntervalPayload.ts+components/widgets/IntervalWidgetBody.tsx(shared fetch/loading/error/empty for LoadShape+Heatmap), and a pureformatPeakReadoutinlib/intervalProfile.ts(§2) with a hand-calc test. Class strings + output strings byte-identical. IntervalHistory's richer SWR fetch left for Decompose IntervalHistory.tsx (1467 LOC) into fetch/overscan/navigation hooks #156.#151 — extract
lib/stats.ts+ missing hand-calc testslib/stats.ts(median/mad/sampleStdevn-1/populationStdn) replaces ~4 duplicatedmedianbodies + 2 stdev twins (prediction/anomaly/viz). Value-preserving (all inline bodies were byte-identical).stats.test.ts, parsePdf carry-over/amountDue-fallback/negative-otherCharges,ymToLastYmd(leap/non-leap/month-end), interval grain-inference edges. +52 tests.#152 — scraper dedup + typed GraphQL
fillEmailAndPassword(login + interactive paths; selectors/timeouts byte-identical). Typed thecollect.tsGraphQLanycluster withRaw*interfaces (collectRaw.ts) — zeroanyleft in touched scraper files, parse output unchanged. SharedGQL_URL_RE, purelib/ngrid/dates.ts(yyyymm/fmtYmd/DAY_MS) +errMessage.ts(replaces ~11 hand-rolledcatch (err:any)→catch (err:unknown)). New tests for dates + errMessage.Verification (local, CI path)
⚠ Needs operator real-account verification post-merge
auth.tslogin extraction is the live login path (not unit-testable) — verify a real scrape end-to-end.GET /api/verifygreen on a real account (I'll verify onember-staging's prod-seeded data before release).