Skip to content

tech-debt batch 3: shared UI primitives + lib/stats + scraper dedup (#150/#151/#152)#165

Merged
delabrcd merged 5 commits into
mainfrom
tech-debt/batch-3-primitives
Jun 22, 2026
Merged

tech-debt batch 3: shared UI primitives + lib/stats + scraper dedup (#150/#151/#152)#165
delabrcd merged 5 commits into
mainfrom
tech-debt/batch-3-primitives

Conversation

@delabrcd

Copy link
Copy Markdown
Owner

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

  • New shared pieces: 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 pure formatPeakReadout in lib/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 tests

  • New pure lib/stats.ts (median/mad/sampleStdev n-1/populationStd n) replaces ~4 duplicated median bodies + 2 stdev twins (prediction/anomaly/viz). Value-preserving (all inline bodies were byte-identical).
  • Added hand-calc tests: 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

  • Extracted one fillEmailAndPassword (login + interactive paths; selectors/timeouts byte-identical). Typed the collect.ts GraphQL any cluster with Raw* interfaces (collectRaw.ts) — zero any left in touched scraper files, parse output unchanged. Shared GQL_URL_RE, pure lib/ngrid/dates.ts (yyyymm/fmtYmd/DAY_MS) + errMessage.ts (replaces ~11 hand-rolled catch (err:any)catch (err:unknown)). New tests for dates + errMessage.

Verification (local, CI path)

  • Docker test stage: 65 files / 973 tests (921 → +52). Production builder stage (lint incl. new TS rules + typecheck + build): green. No new dependency, no suppressions.

⚠ Needs operator real-account verification post-merge

delabrcd 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).
@delabrcd delabrcd merged commit 8abdff7 into main Jun 22, 2026
6 checks passed
@delabrcd delabrcd deleted the tech-debt/batch-3-primitives branch June 22, 2026 17:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment