diff --git a/.fallowrc.json b/.fallowrc.json new file mode 100644 index 0000000..41898fd --- /dev/null +++ b/.fallowrc.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json", + "minimumVersion": "3.32.0", + // Registered by URL string in components/ServiceWorker.tsx, invisible to the import graph. + "dynamicallyLoaded": ["public/sw.js"], + "duplicates": { + // Any clone fails: 0.01% is under one 5-line block of this repo. + "threshold": 0.01 + }, + "health": { + // Industry defaults (SonarQube's cognitive ceiling is 15). The big React components + // already exceed them; `fallow audit` reports only what a change makes worse. + "maxCyclomatic": 20, + "maxCognitive": 15, + // Pairs with "complexity-crap": "off". Off alone still titles cognitive findings + // "High CRAP score" in annotations (fallow 3.32); out of reach, CRAP never matches. + "maxCrap": 1000000 + }, + "rules": { + // Complexity advises; dead code and duplication block (their default severity). + "complexity-cyclomatic": "warn", + "complexity-cognitive": "warn", + // CRAP needs coverage data; this repo has none, so every branchy helper would read as 0% covered. + "complexity-crap": "off" + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 212e5b4..d3301c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,9 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + # Full history: the fallow step diffs this branch against origin/main. + fetch-depth: 0 - name: Setup Node uses: actions/setup-node@v4 @@ -43,6 +46,11 @@ jobs: - name: Self-checks run: npm run check + # Fails on dead code or duplication this branch introduces; complexity is only + # annotated (rules in .fallowrc.json). Findings show inline on the PR diff. + - name: Code quality (fallow) + run: npx fallow audit --base origin/main --format github-annotations + - name: Build static export env: # Build under the production subpath for parity with deploy.yml. diff --git a/README.md b/README.md index e439b0f..e2d1c11 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,8 @@ Type in a reference value and a tolerance, then touch parts one after another. E part that settles gets a PASS or FAIL against the band, lands in a table, and can optionally beep. The pass and fail tones are far apart in pitch and length so you can tell them apart without looking at the screen, which is the point when you are sorting -a tray of resistors. Verdicts export to their own CSV, separate from the data log. +a tray of resistors. Verdicts export to their own CSV, separate from the data log but in the same units the +meter showed (`3.000,MOM`), so the two files line up. Some honest limits. It covers resistance, diode, and capacitance only: voltage and current have no overload reading on lifted probes, so there is no reliable way to tell diff --git a/app/page.tsx b/app/page.tsx index ee0cb2e..acd6644 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { MoreVertical } from 'lucide-react'; import { clsx } from 'clsx'; import { DigitalDisplay } from '@/components/DigitalDisplay'; -import { RealtimeChart, type TimeRange } from '@/components/RealtimeChart'; +import { RealtimeChart } from '@/components/RealtimeChart'; import { Controls } from '@/components/Controls'; import { Sidebar, NAV_IDS, type NavId } from '@/components/Sidebar'; import { StatisticsPanel } from '@/components/StatisticsPanel'; @@ -14,19 +14,21 @@ import { NoDataWarning } from '@/components/NoDataWarning'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { PassFail } from '@/components/PassFail'; import { - SCALE, displayDecimals, displayUnit, normalizeReading, readingResolution, - withinStableBand, type Mode, type Reading, + SCALE, displayUnit, normalizeReading, readingResolution, type Reading, } from '@/lib/parser'; -import { RETENTION_MS, SampleStore } from '@/lib/samples'; +import { SampleStore } from '@/lib/samples'; +import { CaptureEngine, type SessionStats } from '@/lib/capture'; import { - ENTRY_UNITS, entryToBase, formatEntryValue, isSupportedMode, judge, parseSiValue, - resolveAbsoluteTolerance, resolveBand, + VERDICT_CSV_HEADER, isSupportedMode, parseEntry, verdictCsvLine, type ToleranceMode, type VerdictRow, } from '@/lib/passfail'; import { useSerial, type SerialStatus } from '@/lib/useSerial'; -import { DEFAULT_SETTINGS, loadSettings, saveSettings } from '@/lib/settings'; +import { + DEFAULT_SETTINGS, loadSettings, saveSettings, type Settings as SettingValues, +} from '@/lib/settings'; import { createBeeper, type Beeper } from '@/lib/beep'; import { csvBlob, csvEsc } from '@/lib/csv'; +import { createIsoFormatter } from '@/lib/search'; // App version — single source of truth is package.json "version". Bump it on every // change (see AGENTS.md "Versioning") so the footer reflects what's deployed. import { version as APP_VERSION } from '@/package.json'; @@ -35,10 +37,6 @@ import { version as APP_VERSION } from '@/package.json'; // for this long, the no-data warning condition is active (the ZT703s streams // continuously, so 3 s of silence is clearly abnormal — meter off / lead broken). const NO_DATA_MS = 3000; -// Stable-only logging: once a settled value is logged, the next is logged only if it -// differs by >= this fraction — ignores ±1 LSD drift. NOTE: relative, so very sensitive -// near zero (0.0001 -> 0.0002 is +100%); add an absolute floor if that proves noisy. -const MAJOR_CHANGE_RATIO = 0.5; const downloadCsv = (blob: Blob, name: string) => { const url = URL.createObjectURL(blob); const a = document.createElement('a'); @@ -47,12 +45,6 @@ const downloadCsv = (blob: Blob, name: string) => { a.click(); URL.revokeObjectURL(url); }; - -// Shared by both capture sites. The `v !== last` clause only matters when last === 0, -// which is reachable on the Data Log path (a genuine logged 0) but not on the Pass/Fail -// one (zero is intercepted as "no part") — keep it, it is load-bearing for one caller. -const isMajorChange = (v: number, last: number | null): boolean => - last === null || (v !== last && Math.abs(v - last) >= MAJOR_CHANGE_RATIO * Math.abs(last)); // Pass/Fail verdict tones. Far apart in pitch AND length so they're told apart by ear // alone — the operator is looking at the parts, not the screen. const PASS_TONE = { hz: 1180, ms: 90 }; @@ -81,14 +73,58 @@ const STATUS_DOT: Record = { unsupported: 'bg-muted', }; +function AppHeader({ status, baud }: { status: SerialStatus; baud: number }) { + return ( +
+ {/* Logo */} +
+ + + + Multimeter Visualizer +
+ + {/* Connection status */} +
+ + + {STATUS_LABEL[status]} + + {status === 'connected' && ( + + UART  ·  {baud} bps + + )} +
+ + {/* Actions */} +
+ +
+
+ ); +} + export default function Home() { - const [baud, setBaud] = useState(DEFAULT_SETTINGS.baud); + // Persisted settings. Start at defaults so the static-export HTML matches the first + // client render; a mount effect then hydrates from storage. + const [settings, setSettings] = useState(DEFAULT_SETTINGS); + const updateSettings = useCallback( + (patch: Partial) => setSettings((s) => ({ ...s, ...patch })), + [], + ); + const { + baud, rangeMin, rangeMax, autoScale, timeRange, stableOnly, stabilityCount, hysteresisPct, + preserveOnModeChange, noDataWarning, noDataAudio, capNoPartFloor, verdictAudio, + } = settings; // The active view. Stays 'dashboard' for the first render so it matches the statically // exported HTML; the URL hash takes over in the mount effect below. const [view, setView] = useState('dashboard'); const [current, setCurrent] = useState(null); - // Whether `current` has been confirmed stable. The read loop owns the run counter in - // a ref; this is its render-visible mirror, committed once per batch. Pass/Fail needs + // Whether `current` has been confirmed stable. The capture engine owns the run counter; + // this is its render-visible mirror, committed once per batch. Pass/Fail needs // it because a resistance reading sweeps through intermediate values as the probe is // lifted, and comparing those produces a spurious FAIL. const [currentStable, setCurrentStable] = useState(false); @@ -102,7 +138,6 @@ export default function Home() { // not draw (the `settings` preserve-log-on-mode-change toggle). State, not just a ref: the // chart's update effect keys off its dependency array and a ref never changes identity. const [chartFromSeq, setChartFromSeq] = useState(0); - const chartFromSeqRef = useRef(0); // Bumped whenever the chart's incremental tier is invalidated — a watermark advance, or a // retention trim dropping a chunk out from under its oldest buckets. const [chartEpoch, setChartEpoch] = useState(0); @@ -118,22 +153,8 @@ export default function Home() { // chart's y-axis need this, not the display string, so each value/tick can pick its own // SI prefix and apply the capacitance nF->F correction themselves (lib/si.ts). const [chartBaseUnit, setChartBaseUnit] = useState(''); - const [rangeMin, setRangeMin] = useState(DEFAULT_SETTINGS.rangeMin); - const [rangeMax, setRangeMax] = useState(DEFAULT_SETTINGS.rangeMax); - const [autoScale, setAutoScale] = useState(DEFAULT_SETTINGS.autoScale); - const [timeRange, setTimeRange] = useState(DEFAULT_SETTINGS.timeRange); const [triggerArmed, setTriggerArmed] = useState(false); const [triggerThreshold, setTriggerThreshold] = useState(''); - const [stableOnly, setStableOnly] = useState(DEFAULT_SETTINGS.stableOnly); - // Persisted settings. Start at defaults so the static-export HTML matches the first - // client render; a mount effect then hydrates from storage. - const [stabilityCount, setStabilityCount] = useState(DEFAULT_SETTINGS.stabilityCount); - const [hysteresisPct, setHysteresisPct] = useState(DEFAULT_SETTINGS.hysteresisPct); - const [preserveOnModeChange, setPreserveOnModeChange] = useState(DEFAULT_SETTINGS.preserveOnModeChange); - const [noDataWarning, setNoDataWarning] = useState(DEFAULT_SETTINGS.noDataWarning); - const [noDataAudio, setNoDataAudio] = useState(DEFAULT_SETTINGS.noDataAudio); - const [capNoPartFloor, setCapNoPartFloor] = useState(DEFAULT_SETTINGS.capNoPartFloor); - const [verdictAudio, setVerdictAudio] = useState(DEFAULT_SETTINGS.verdictAudio); // Pass/Fail entry, held as the operator's raw strings so SI forms survive typing // (`4.5k` must not be mangled on its way through `4.`). Parsed on demand. const [pfReference, setPfReference] = useState(''); @@ -144,18 +165,15 @@ export default function Home() { // never judged. Cleared synchronously on a mode change. const [pfReferenceSettled, setPfReferenceSettled] = useState(''); const [pfToleranceSettled, setPfToleranceSettled] = useState(''); - // Captured verdicts for the current batch. Separate from the sample store: the two - // have different lifecycles and clear independently. + // Render mirror of `engine.passFailRows`, committed once per batch. const [passFailRows, setPassFailRows] = useState([]); - const passFailRowsRef = useRef([]); - const pfRowIdRef = useRef(0); // No-data warning: `noData` = connected-but-silent; `noDataDismissed` = OK'd for the // current outage (re-armed when it clears). `lastDataAtRef` is the detector's clock. const [noData, setNoData] = useState(false); const [noDataDismissed, setNoDataDismissed] = useState(false); const lastDataAtRef = useRef(0); - // Mirror of recordedResolutionRef for render (histogram bin width + stat decimals); - // the ref is the loop-side source, this state is its render-safe copy per batch. + // Mirror of the engine's recorded resolution for render (histogram bin width + stat + // decimals); the engine is the loop-side source, this state is its render-safe copy per batch. const [recordedResolution, setRecordedResolution] = useState(null); // Value the meter spent the most readings at — the histogram window center, so a // brief outlier (short/disconnect) doesn't pull the window off the main reading. @@ -170,82 +188,30 @@ export default function Home() { // It is mutated in place by the read loop; `sampleVersion` is what tells React so. const [store] = useState(() => new SampleStore()); - type SessionStats = { count: number; mean: number; m2: number; min: number; max: number }; - const statsRef = useRef({ count: 0, mean: 0, m2: 0, min: Infinity, max: -Infinity }); + // Everything the read loop reads and writes synchronously lives here, out of React; see + // lib/capture.ts. Same lazy-initializer reasoning as the store above. + const [engine] = useState(() => new CaptureEngine(store)); const [sessionStats, setSessionStats] = useState(null); - const recordingRef = useRef(recording); - const autoScaleRef = useRef(autoScale); - const modeRef = useRef(null); - const unitRef = useRef(''); - const triggerArmedRef = useRef(triggerArmed); - const triggerThresholdRef = useRef(null); - // Mirror of timeRange read synchronously in the async read loop (skip cap in 'all'). - const timeRangeRef = useRef(timeRange); - // Whether the current session was auto-started by the trigger (scopes auto-stop). - const triggerStartedRef = useRef(false); - const stableOnlyRef = useRef(stableOnly); - // Settings mirrors read synchronously inside the async read loop (hot path). - const stabilityCountRef = useRef(stabilityCount); - const hysteresisPctRef = useRef(hysteresisPct); - const preserveOnModeChangeRef = useRef(preserveOnModeChange); - // Capacitance-only mirrors, read synchronously in the read loop. - const capNoPartFloorRef = useRef(capNoPartFloor); - // Pass/Fail config mirrors, in ENTRY units (converted per-sample with the reading's - // own mode, so a mid-batch mode change can't apply the wrong factor). - const pfRefEntryRef = useRef(null); - const pfBandEntryRef = useRef(null); - const pfToleranceModeRef = useRef(pfToleranceMode); - const pfToleranceValueRef = useRef(null); - // "Already captured this part" latch for the Pass/Fail store. Separate from - // `lastLoggedValueRef` because the two stores clear independently; both are cleared - // by the same discontinuity (OL / capacitance no-part). - const pfLastCapturedRef = useRef(null); const verdictAudioRef = useRef(verdictAudio); // One beeper for the component's life; created client-side, disposed on unmount. // Declared here rather than beside its effect because handleReadings (defined below) // sounds verdict tones through it. const beeperRef = useRef(null); - // Base-unit value anchoring the current run. A reading joins the run while it stays - // within STABLE_LSD_TOLERANCE of this; anything further starts a new run anchored at - // itself. Anchored rather than compared to the immediately previous reading, so a - // slow ramp cannot creep along one LSD at a time and look settled forever. - const runAnchorRef = useRef(null); - const stableRunRef = useRef(0); - const lastLoggedValueRef = useRef(null); - // Coarsest LSD among LOGGED readings — the basis for histogram bin width AND stat - // decimals, so both reflect stored data, not the live (possibly auto-ranged) reading. - // Frozen while logging is stopped; reset in flushSession. - const recordedResolutionRef = useRef(null); - // Reading count per LSD-snapped value -> the dominant (most-held) value, which centers - // the histogram window so a brief outlier can't pull it off. - // Readings per LSD-snapped value. Held in state with a lazy initializer, not a ref, so it - // is one stable identity that render can pass as a prop — and CLEARED in place rather than - // replaced, which is what keeps that identity stable across a session reset. - const [rawCounts] = useState>(() => new Map()); - const dominantValueRef = useRef(null); - const dominantCountRef = useRef(0); - const setRec = useCallback((v: boolean) => { - recordingRef.current = v; - // A stop (manual or disconnect) ends any trigger-started session. - if (!v) triggerStartedRef.current = false; - // A fresh start must not inherit a stale predecessor/run from before it began. - if (v) { runAnchorRef.current = null; stableRunRef.current = 0; lastLoggedValueRef.current = null; } + const setRec = useCallback((v: boolean, manual = false) => { + engine.setRecording(v, manual); setRecording(v); - }, []); + }, [engine]); - useEffect(() => { autoScaleRef.current = autoScale; }, [autoScale]); - useEffect(() => { timeRangeRef.current = timeRange; }, [timeRange]); - useEffect(() => { triggerArmedRef.current = triggerArmed; }, [triggerArmed]); - useEffect(() => { triggerThresholdRef.current = toFinite(triggerThreshold); }, [triggerThreshold]); - useEffect(() => { stableOnlyRef.current = stableOnly; }, [stableOnly]); - useEffect(() => { stabilityCountRef.current = stabilityCount; }, [stabilityCount]); - useEffect(() => { hysteresisPctRef.current = hysteresisPct; }, [hysteresisPct]); - useEffect(() => { preserveOnModeChangeRef.current = preserveOnModeChange; }, [preserveOnModeChange]); - useEffect(() => { capNoPartFloorRef.current = capNoPartFloor; }, [capNoPartFloor]); + useEffect(() => { engine.setTriggerArmed(triggerArmed); }, [engine, triggerArmed]); + useEffect(() => { engine.setTriggerThreshold(toFinite(triggerThreshold)); }, [engine, triggerThreshold]); + useEffect(() => { + engine.configure({ stabilityCount, hysteresisPct, stableOnly, preserveOnModeChange, capNoPartFloor }); + }, [engine, stabilityCount, hysteresisPct, stableOnly, preserveOnModeChange, capNoPartFloor]); useEffect(() => { verdictAudioRef.current = verdictAudio; }, [verdictAudio]); - useEffect(() => { pfToleranceModeRef.current = pfToleranceMode; }, [pfToleranceMode]); + // Pass/Fail captures, counts and sounds only on its own view; see setPassFailActive. + useEffect(() => { engine.setPassFailActive(view === 'pass-fail'); }, [engine, view]); // Debounce each field independently. The cleanup cancels the pending commit on every // keystroke, so the value lands only once typing pauses. useEffect(() => { @@ -258,40 +224,17 @@ export default function Home() { }, [pfTolerance]); useEffect(() => { - // Reads the SETTLED values, never the raw inputs. An absolute tolerance is read in - // the reference's own SI range (see resolveAbsoluteTolerance); percent is unitless. - const ref = parseSiValue(pfReferenceSettled); - const tol = - pfToleranceMode === 'absolute' - ? resolveAbsoluteTolerance(pfToleranceSettled, ref) - : parseSiValue(pfToleranceSettled); - pfRefEntryRef.current = ref; - pfToleranceValueRef.current = tol; - pfBandEntryRef.current = - ref !== null && tol !== null ? resolveBand(ref, tol, pfToleranceMode) : null; - }, [pfReferenceSettled, pfToleranceSettled, pfToleranceMode]); + // Reads the SETTLED values, never the raw inputs. + const { ref, tol, band } = parseEntry(pfReferenceSettled, pfToleranceSettled, pfToleranceMode); + engine.setPassFailConfig(ref, tol, band, pfToleranceMode); + }, [engine, pfReferenceSettled, pfToleranceSettled, pfToleranceMode]); // One-shot hydration from localStorage. setState-in-effect is intentional: the first - // render must use defaults to match the static-export HTML. + // render must use defaults to match the static-export HTML. Restoring is deliberately + // inert: no setting starts logging, arms the trigger, or opens a port. useEffect(() => { - const s = loadSettings(); - /* eslint-disable react-hooks/set-state-in-effect -- intentional one-shot hydration */ - setStabilityCount(s.stabilityCount); - setHysteresisPct(s.hysteresisPct); - setPreserveOnModeChange(s.preserveOnModeChange); - setNoDataWarning(s.noDataWarning); - setNoDataAudio(s.noDataAudio); - setCapNoPartFloor(s.capNoPartFloor); - setVerdictAudio(s.verdictAudio); - // Presentation + connection preferences. Restoring these is deliberately inert: none - // of them starts logging, arms the trigger, or opens a port. - setBaud(s.baud); - setTimeRange(s.timeRange); - setAutoScale(s.autoScale); - setRangeMin(s.rangeMin); - setRangeMax(s.rangeMax); - setStableOnly(s.stableOnly); - /* eslint-enable react-hooks/set-state-in-effect */ + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional one-shot hydration + setSettings(loadSettings()); }, []); // Persist on change. Skip the first invocation (mount, still defaults) so we don't @@ -299,16 +242,8 @@ export default function Home() { const persistReadyRef = useRef(false); useEffect(() => { if (!persistReadyRef.current) { persistReadyRef.current = true; return; } - saveSettings({ - stabilityCount, hysteresisPct, preserveOnModeChange, noDataWarning, noDataAudio, - capNoPartFloor, verdictAudio, - baud, timeRange, autoScale, rangeMin, rangeMax, stableOnly, - }); - }, [ - stabilityCount, hysteresisPct, preserveOnModeChange, noDataWarning, noDataAudio, - capNoPartFloor, verdictAudio, - baud, timeRange, autoScale, rangeMin, rangeMax, stableOnly, - ]); + saveSettings(settings); + }, [settings]); // The URL hash is the source of truth for the active view: it survives a reload, gives // browser Back/Forward between views for free, and makes a view deep-linkable. Read only @@ -363,41 +298,23 @@ export default function Home() { } }, []); - // Reset the single-unit projections (chart, stats, histogram derivation, stable run) - // while KEEPING the canonical samples — the "keep log on mode change" path. Those views - // can't mix units; the per-row table/CSV can (each row carries its own mode/unit). - // - // This is the ONE thing two parallel buffers were genuinely buying, and it costs one - // integer: the store is untouched and the chart simply starts reading later. Clearing the - // store here would take the operator's log with it. - const resetSessionDerived = useCallback(() => { - statsRef.current = { count: 0, mean: 0, m2: 0, min: Infinity, max: -Infinity }; - runAnchorRef.current = null; - stableRunRef.current = 0; - lastLoggedValueRef.current = null; - recordedResolutionRef.current = null; - rawCounts.clear(); - dominantValueRef.current = null; - dominantCountRef.current = 0; - // Next unused seq: everything already stored belongs to the previous unit. - const next = store.firstSeq + store.count; - chartFromSeqRef.current = next; - setChartFromSeq(next); + // The React half of the engine's derived reset: the watermark it moved, the chart's + // invalidation, and every render mirror of what it zeroed. + const commitReset = useCallback(() => { + setChartFromSeq(engine.chartFromSeq); setChartEpoch((e) => e + 1); setSessionStats(null); setSessionStart(null); setRecordedResolution(null); setDominantValue(null); - }, [store, rawCounts]); + }, [engine]); // Full session flush: clear the canonical store AND every derived projection. const flushSession = useCallback(() => { - // `clear()` keeps seq monotonic, so the watermark below lands past every seq ever used - // and a stale watermark can never sit ahead of new samples. - store.clear(); + engine.flush(); setSampleVersion((v) => v + 1); - resetSessionDerived(); - }, [resetSessionDerived, store]); + commitReset(); + }, [engine, commitReset]); // Clear Data / Clear Log only ask; the flush happens on confirm. const [confirmClear, setConfirmClear] = useState(false); @@ -414,328 +331,58 @@ export default function Home() { lastDataAtRef.current = Date.now(); setCurrent(readings[readings.length - 1]); - let armed = triggerArmedRef.current; - let threshold = triggerThresholdRef.current; - let release = threshold !== null ? threshold * (1 - hysteresisPctRef.current / 100) : null; - - let recordingChanged = false; - // ONE staging array, appended to the store once at the end of the batch. Not a - // performance choice — a batch is three to ten samples — but a control-flow one: the - // loop discards already-collected samples on a mode change and on a trigger edge, and - // a store appended per sample has no un-append. - // - // Collapsing the old `newPoints`/`newRows` pair into one also repairs a live bug: the - // trigger branch below used to clear only `newPoints`, so a re-trigger mid-batch kept - // rows in the log whose chart points had been dropped — two consumers disagreeing, in - // the function whose comment claims their membership is identical by construction. - const staged: { ts: number; value: number; mode: Mode; unit: string; decimals: number }[] = []; - // Whether this batch changed anything the UI reads: a staged sample, or a retention - // trim that dropped one. Everything the UI reads now lives behind the same gate. - let touched = false; - const newVerdicts: VerdictRow[] = []; - - // Append what is staged so far. Called at a mode change that preserves the log — - // those samples belong to the OLD unit, so they must reach the store before the - // watermark moves past them — and once at the end of the batch. - // - // It deliberately computes nothing about `sessionStart`. A candidate captured here - // would be captured against whatever the watermark was AT THE TIME, and the preserve - // path advances the watermark immediately afterwards — so the candidate would be an - // old-unit sample that the advance then excludes from the chart. The anchor is derived - // once, at the end of the batch, from the FINAL watermark. - // - // Samples appended SINCE THE LAST RESET in this batch. Zeroed at every reset site, - // because statsRef is zeroed there too: counting across a reset would commit a - // freshly-zeroed stats object over the `null` the reset queued. - let appended = 0; - const flushStaged = () => { - for (const st of staged) store.append(st.ts, st.value, st.mode, st.unit, st.decimals); - appended += staged.length; - staged.length = 0; - }; - for (const r of readings) { - const { baseValue, baseUnit } = normalizeReading(r); - - if (baseUnit !== '' && (modeRef.current !== r.mode || unitRef.current !== baseUnit)) { - const wasInitialized = modeRef.current !== null; - modeRef.current = r.mode; - unitRef.current = baseUnit; - // Display-only conversion: chartUnit feeds the trigger label; chartBaseUnit feeds - // the chart axis and statistics, which each derive their own display/SI prefix - // from the raw token. Neither touches the Reading's own raw unit. - setChartUnit(displayUnit(baseUnit)); - setChartBaseUnit(baseUnit); - // Keep the recorded log across a real mode change if opted in (chart/stats are - // single-unit and always reset). The first detection has no prior data, so a - // full flush is equivalent. Old-unit rows stay valid in their original unit. - if (wasInitialized && preserveOnModeChangeRef.current) { - // Samples staged before the change are old-unit rows the operator asked to keep. - // They must reach the store BEFORE the watermark advances, or the watermark - // lands behind them and the chart draws the previous unit. - flushStaged(); - resetSessionDerived(); - appended = 0; - } else { - staged.length = 0; - flushSession(); - appended = 0; - } - // A real mode/unit change makes the in-progress data and threshold - // meaningless in the new unit — stop logging, then reset the trigger - // (clear threshold + disarm). (Skip on the first reading, which is - // initial detection, not a change, so a pre-typed threshold survives.) - if (wasInitialized) { - if (recordingRef.current) { - recordingRef.current = false; - recordingChanged = true; - } - triggerThresholdRef.current = null; - triggerArmedRef.current = false; - triggerStartedRef.current = false; - armed = false; - threshold = null; - release = null; - setTriggerThreshold(''); - setTriggerArmed(false); - // The reference and every captured verdict belong to the old mode's unit — - // neither is meaningful in the new one. Cleared for the same reason the - // trigger threshold is. - pfRefEntryRef.current = null; - pfBandEntryRef.current = null; - pfToleranceValueRef.current = null; - pfLastCapturedRef.current = null; - passFailRowsRef.current = []; - pfRowIdRef.current = 0; - newVerdicts.length = 0; - setPfReference(''); - setPfTolerance(''); - setPfReferenceSettled(''); - setPfToleranceSettled(''); - setPassFailRows([]); - } - } - - // Trigger edges (evaluated before recording so the crossing sample is captured). - const mag = baseValue !== null ? Math.abs(baseValue) : null; - if (armed && threshold !== null && !recordingRef.current && mag !== null && mag > threshold) { - // Clears EVERYTHING staged, chart and log alike. Previously only the chart half was - // dropped here; see the `staged` comment above. - staged.length = 0; - flushSession(); - appended = 0; - recordingRef.current = true; - triggerStartedRef.current = true; - recordingChanged = true; - } else if (triggerStartedRef.current && recordingRef.current && release !== null && mag !== null && mag < release) { - recordingRef.current = false; - triggerStartedRef.current = false; - recordingChanged = true; - } - - // Capacitance has no OL on lifted probes — it reads lead stray capacitance (pF), - // which is numeric, so without this it appends a junk near-zero row on every probe - // lift and never clears the last-logged reference. A floor of 0 disables it. - const noPart = - baseValue !== null && - r.mode === 'CAPACITANCE' && - Math.abs(baseValue) < capNoPartFloorRef.current; - - // Stable-run tracking runs regardless of the recording session, because Pass/Fail - // capture has no session of its own (its batch boundary is Clear). Behavior- - // preserving for the Data Log: every path that STARTS recording resets these refs - // first, so anything accumulated while idle is wiped when recording begins. - if (baseValue === null || noPart) { - // OL (and a capacitance no-part reading) are excluded from the filtered - // dataset entirely — table, CSV, chart, and stats. Both are still a - // discontinuity — reset the run and BOTH stores' last-captured references. - runAnchorRef.current = null; - stableRunRef.current = 0; - lastLoggedValueRef.current = null; - pfLastCapturedRef.current = null; - } else { - // Maintain the run of consecutive readings belonging to the same measurement. - // Membership is a band around the run's anchor, not equality - // (see STABLE_LSD_TOLERANCE in lib/parser.ts). - const lsd = readingResolution(r); - const anchor = runAnchorRef.current; - if (anchor !== null && withinStableBand(baseValue, anchor, lsd)) { - stableRunRef.current += 1; - } else { - stableRunRef.current = 1; - runAnchorRef.current = baseValue; - } - // Capacitance needs no extra count: the meter sends "-.--" (isMeasuring) until - // it has a number — confirmed on a 3.3 mF electrolytic — so its first digit - // value is already settled. - const stable = stableRunRef.current >= stabilityCountRef.current; - - // ---- Pass/Fail capture (independent of the recording session) ---------- - // An exact zero means nothing is connected, not a part measuring zero: with the - // probes floating the meter settles on 0 in every supported mode, and a real - // component never reads a clean 0 (a 0R link still shows lead resistance). - // Scoped to Pass/Fail — the Data Log records a genuine 0 as a real measurement. - const pfRefEntry = pfRefEntryRef.current; - const pfBandEntry = pfBandEntryRef.current; - if (baseValue === 0) { - pfLastCapturedRef.current = null; - } else if ( - stable && - pfRefEntry !== null && - pfBandEntry !== null && - isSupportedMode(r.mode) && - // Guards a stale reference being judged in the wrong unit: the mode-change - // reset is gated on `baseUnit !== ''`, so an unrecognized unit string - // (documented as possible above nF) skips it and leaves the old reference - // live while r.mode has already changed. - baseUnit === ENTRY_UNITS[r.mode].baseUnit - ) { - const last = pfLastCapturedRef.current; - // Gated on the stable run, same flag the live verdict uses (via currentStable), - // so display and table never disagree. Resistance and diode need it: with no - // "measuring" state, lifting a probe sweeps UP through intermediates and a - // >50% step would read as a fresh part. The major-change gate suppresses drift - // within one held part; a probe lift clears the reference for the next. - if (isMajorChange(baseValue, last)) { - pfLastCapturedRef.current = baseValue; - const toBase = ENTRY_UNITS[r.mode].toBase; - const baseReference = entryToBase(r.mode, pfRefEntry); - const baseBand = pfBandEntry * toBase; - newVerdicts.push({ - id: pfRowIdRef.current++, - ts: r.ts, - iso: new Date(r.ts).toISOString(), - mode: r.mode, - baseValue, - baseReference, - baseBand, - toleranceMode: pfToleranceModeRef.current, - toleranceValue: pfToleranceValueRef.current!, - verdict: judge(baseValue, baseReference, baseBand), - deviation: baseValue - baseReference, - resolution: lsd, - }); - } - } - - // ---- Data Log / chart / statistics (gated on the recording session) ----- - if (recordingRef.current) { - // Stable filter on: log only a confirmed-stable value that differs from the - // last logged one by a major amount, suppressing drift and plateaus. - let logSample = true; - if (stableOnlyRef.current) { - logSample = stable && isMajorChange(baseValue, lastLoggedValueRef.current); - if (logSample) lastLoggedValueRef.current = baseValue; - } - - if (logSample) { - // Readings per LSD-snapped value: the histogram's bars and the dominant value - // that centres its window. INSIDE the `logSample` gate, with everything else. - // - // It sat outside once, counting every reading regardless of the filter. That - // made the histogram grow three times a second while the Samples tile stayed - // at the number of recorded entries — two readouts of the same session - // disagreeing by orders of magnitude. The filter is a gate: a reading it - // rejects is not recorded anywhere, and that has to include here. - if (lsd !== null) { - const k = Math.round(baseValue / lsd) * lsd; - const c = (rawCounts.get(k) ?? 0) + 1; - rawCounts.set(k, c); - if (c > dominantCountRef.current) { - dominantCountRef.current = c; - dominantValueRef.current = k; - } - } - // Everything in this block is behind the same gate, deliberately: the store, - // the histogram counts, the recorded resolution and the statistics. Hoisting - // any of it to the `recordingRef` level one scope out would silently disable - // "Stable values only" for that consumer — no compile error, and no visible - // symptom until two readouts of the same session disagree. - staged.push({ - ts: r.ts, - value: baseValue, - mode: r.mode, - unit: r.unit, - decimals: displayDecimals(r.display), - }); - touched = true; - // Track the coarsest LSD among logged readings → bin width + stat - // decimals reflect the recorded data, range-robust to auto-ranging. - if (lsd !== null) { - recordedResolutionRef.current = Math.max(recordedResolutionRef.current ?? 0, lsd); - } - const s = statsRef.current; - s.count += 1; - const delta = baseValue - s.mean; - s.mean += delta / s.count; - s.m2 += delta * (baseValue - s.mean); - if (baseValue < s.min) s.min = baseValue; - if (baseValue > s.max) s.max = baseValue; - // No clamping here. The operator's y-range is applied when the chart draws, so - // changing it re-clamps the whole session instead of only what arrives next. - } - } - + const r = engine.ingest(readings); - } + // Order matters: a reset's nulls must land before this batch's stats and session-start + // updater, or a cleared session keeps its old anchor and statistics. + if (r.unit !== null) { + // Display-only conversion: chartUnit feeds the trigger label; chartBaseUnit feeds + // the chart axis and statistics, which each derive their own display/SI prefix + // from the raw token. Neither touches the Reading's own raw unit. + setChartUnit(displayUnit(r.unit)); + setChartBaseUnit(r.unit); } - - // ---- Once per batch, never in the per-sample loop ------------------------ - flushStaged(); - - // Retention, applied to the ONE store: past seven days the oldest samples leave the - // table and the CSV too, not just the chart. A dropped chunk invalidates the chart's - // incremental tier, which is what `chartEpoch` tells it. - if (store.trim(Date.now() - RETENTION_MS)) { - setChartEpoch((e) => e + 1); - touched = true; + if (r.modeReset) { + setTriggerThreshold(''); + setTriggerArmed(false); + setPfReference(''); + setPfTolerance(''); + setPfReferenceSettled(''); + setPfToleranceSettled(''); } + if (r.reset) commitReset(); + if (r.trimmed) setChartEpoch((e) => e + 1); - // The chart's x-axis anchor: the oldest sample the chart can actually see, read from - // the store against the FINAL watermark rather than tracked during the batch. Stating - // it as a derivation instead of a running candidate is what makes it immune to a - // mid-batch watermark advance — the preserve-log mode change flushes old-unit samples - // and THEN advances, so anything captured before that advance is the previous unit. - // // Functional so it cannot race a reset earlier in this batch: that queues null first, // so `prev` is null here and this stamps. Cheap to evaluate every batch, and a no-op // once stamped. - const anchorIdx = Math.max(0, chartFromSeqRef.current - store.firstSeq); - if (store.count > anchorIdx) { - const at = store.tsAt(anchorIdx); + if (r.sessionStart !== null) { + const at = r.sessionStart; setSessionStart((prev) => prev ?? at); } - if (touched) setSampleVersion((v) => v + 1); + if (r.touched) setSampleVersion((v) => v + 1); - // Same batched append for the Pass/Fail store. - if (newVerdicts.length > 0) { - passFailRowsRef.current = [...passFailRowsRef.current, ...newVerdicts]; - setPassFailRows(passFailRowsRef.current); - // One tone per batch (its last verdict) — a batch spans ms, a part takes seconds. - if (verdictAudioRef.current) { - const t = newVerdicts[newVerdicts.length - 1].verdict === 'PASS' ? PASS_TONE : FAIL_TONE; - beeperRef.current?.beep(t.hz, t.ms); - } + if (r.rowsChanged) setPassFailRows(engine.passFailRows); + // One tone per batch (its last verdict) — a batch spans ms, a part takes seconds. + if (r.verdicts.length > 0 && verdictAudioRef.current) { + const t = r.verdicts[r.verdicts.length - 1].verdict === 'PASS' ? PASS_TONE : FAIL_TONE; + beeperRef.current?.beep(t.hz, t.ms); } - // Mirror the run state for render. After the loop the ref describes the LAST - // reading of the batch, which is the one `current` holds. - setCurrentStable(stableRunRef.current >= stabilityCountRef.current); + setCurrentStable(r.currentStable); // Commit a trigger-driven recording transition once (no per-sample setState). - if (recordingChanged) setRecording(recordingRef.current); + if (r.recordingChanged) setRecording(engine.recording); - // Only when something was actually logged: statsRef mutates inside `if (logSample)`, - // so with the stable filter on most batches change nothing and a fresh object identity - // would re-render StatisticsPanel three times a second for nothing. - if (appended > 0) setSessionStats({ ...statsRef.current }); + if (r.stats !== null) setSessionStats(r.stats); // Mirror the recorded resolution + dominant value to state. (Unchanged when // nothing was logged this batch — e.g. logging stopped → React bails out.) - setRecordedResolution(recordedResolutionRef.current); - setDominantValue(dominantValueRef.current); + setRecordedResolution(r.recordedResolution); + setDominantValue(r.dominantValue); }, - [flushSession, resetSessionDerived, store, rawCounts], + [engine, commitReset], ); const { status, error, connect, disconnect } = useSerial(handleReadings); @@ -787,33 +434,24 @@ export default function Home() { else beeper.stop(); }, [overlayVisible, noDataAudio]); - // Clears only the verdict batch — the Data Log's recorded rows are untouched, and - // the reference/tolerance are kept (the operator is usually still on the same part). - // - // Deliberately does NOT touch `pfLastCapturedRef`: that guard is what stops a held - // part from being captured twice, and it already re-arms itself on a genuine probe - // lift (baseValue null/no-part, above). Resetting it here too used to re-capture - // (and re-beep) the SAME still-connected part the instant Clear Batch was pressed — - // audible even after navigating away, since capture runs independent of which view - // is open. Clear Batch is a table boundary, not a probe-lift. + // Clears only the verdict batch; see `CaptureEngine.clearVerdicts` for why the capture + // latch is deliberately left alone. const clearPassFail = useCallback(() => { - passFailRowsRef.current = []; - pfRowIdRef.current = 0; - setPassFailRows([]); - }, []); + engine.clearVerdicts(); + setPassFailRows(engine.passFailRows); + }, [engine]); const handleConnect = useCallback(() => connect(baud), [connect, baud]); const handleToggleRecord = useCallback(() => { // Manual toggle → this session is not trigger-owned, so never auto-stop it. - triggerStartedRef.current = false; - setRec(!recordingRef.current); - }, [setRec]); + setRec(!engine.recording, true); + }, [engine, setRec]); const handleStableOnlyChange = useCallback((v: boolean) => { - setStableOnly(v); + updateSettings({ stableOnly: v }); // Enabling the filter auto-starts logging (mirrors the Record button); // disabling only stops filtering and leaves logging as-is. if (v) setRec(true); - }, [setRec]); + }, [setRec, updateSettings]); // Per-row note edit: annotation only — never touches the reading or statistics. Keyed on // `seq`, which survives retention trimming, so a note cannot migrate to another row. The @@ -835,11 +473,13 @@ export default function Home() { // 0.1 mV resolution is a different measurement from `0.145` at 1 mV. const exportCsv = useCallback(() => { function* lines() { + // The table's own formatter: local time with offset, so file and screen agree. + const isoOf = createIsoFormatter(); for (let i = 0; i < store.count; i++) { const sm = store.at(i); const factor = SCALE[sm.unit]?.factor ?? 1; yield [ - new Date(sm.ts).toISOString(), + isoOf(sm.ts), sm.mode, (sm.value / factor).toFixed(sm.decimals), sm.unit, @@ -850,28 +490,13 @@ export default function Home() { downloadCsv(csvBlob('Timestamp,Mode,Value,Unit,Notes', lines()), `multimeter-${Date.now()}.csv`); }, [store]); - // Numbers in the mode's ENTRY unit (ohms/volts/farads) with the unit in its own - // column, so the file holds plain numbers rather than SI-prefixed strings. + // Same unit convention as the Data Log export: the meter's own unit token per row. const exportVerdictCsv = useCallback(() => { function* lines() { - for (const row of passFailRowsRef.current) { - const { label, toBase } = ENTRY_UNITS[row.mode]; - const tol = - row.toleranceMode === 'percent' - ? `${row.toleranceValue}%` - : `\u00B1${formatEntryValue(row.toleranceValue, label)}`; - yield [ - row.iso, row.mode, String(row.baseValue / toBase), label, - String(row.baseReference / toBase), csvEsc(tol), - String(row.deviation / toBase), row.verdict, - ].join(','); - } + for (const row of engine.passFailRows) yield verdictCsvLine(row); } - downloadCsv( - csvBlob('Timestamp,Mode,Measured,Unit,Reference,Tolerance,Deviation,Verdict', lines()), - `multimeter-passfail-${Date.now()}.csv`, - ); - }, []); + downloadCsv(csvBlob(VERDICT_CSV_HEADER, lines()), `multimeter-passfail-${Date.now()}.csv`); + }, [engine]); // The Pass/Fail view's active mode: the live reading's mode when supported, else null // (which renders the unsupported-mode explanation). @@ -917,36 +542,7 @@ export default function Home() { return (
- {/* ── Header ── */} -
- {/* Logo */} -
- - - - Multimeter Visualizer -
- - {/* Connection status */} -
- - - {STATUS_LABEL[status]} - - {status === 'connected' && ( - - UART  ·  {baud} bps - - )} -
- - {/* Actions */} -
- -
-
+ {/* ── Body ── */}
@@ -954,7 +550,7 @@ export default function Home() { updateSettings({ baud: v })} onConnect={handleConnect} onDisconnect={disconnect} error={error} @@ -977,14 +573,14 @@ export default function Home() { sampleVersion={sampleVersion} chartFromSeq={chartFromSeq} chartEpoch={chartEpoch} - counts={rawCounts} + counts={engine.counts} stableOnly={stableOnly} sessionStart={sessionStart} unit={chartBaseUnit} yMin={effectiveYMin} yMax={effectiveYMax} timeRange={timeRange} - onTimeRangeChange={setTimeRange} + onTimeRangeChange={(v) => updateSettings({ timeRange: v })} binWidth={binWidth} centerValue={centerValue} /> @@ -995,12 +591,12 @@ export default function Home() { {/* Right panel */} updateSettings({ rangeMin: v })} + onRangeMaxChange={(v) => updateSettings({ rangeMax: v })} autoScale={autoScale} - onAutoScaleChange={setAutoScale} + onAutoScaleChange={(v) => updateSettings({ autoScale: v })} triggerThreshold={triggerThreshold} onTriggerThresholdChange={setTriggerThreshold} triggerArmed={triggerArmed} @@ -1013,7 +609,7 @@ export default function Home() { stableOnly={stableOnly} onStableOnlyChange={handleStableOnlyChange} onClear={requestClear} - canClear={sampleCount > 0} + canClear={sampleCount > 0} onExportCsv={exportCsv} canExport={canExport} /> @@ -1051,22 +647,7 @@ export default function Home() { onExportCsv={exportVerdictCsv} /> ) : ( - + )}
diff --git a/components/Controls.tsx b/components/Controls.tsx index aa1912e..0a84833 100644 --- a/components/Controls.tsx +++ b/components/Controls.tsx @@ -39,6 +39,26 @@ export function ActionButton({ ); } +export function RecordButton({ + recording, + canRecord, + onToggle, +}: { + recording: boolean; + canRecord: boolean; + onToggle: () => void; +}) { + return ( + : } + label={recording ? 'Stop Logging' : 'Start Logging'} + variant={recording ? 'danger' : 'success'} + disabled={!recording && !canRecord} + /> + ); +} + export function Controls({ rangeMin, rangeMax, @@ -171,13 +191,7 @@ export function Controls({
- : } - label={recording ? 'Stop Logging' : 'Start Logging'} - variant={recording ? 'danger' : 'success'} - disabled={!recording && !canRecord} - /> + } diff --git a/components/DataLog.tsx b/components/DataLog.tsx index af6bb87..0a9ca80 100644 --- a/components/DataLog.tsx +++ b/components/DataLog.tsx @@ -1,13 +1,14 @@ 'use client'; import { memo, useEffect, useMemo, useRef, useState } from 'react'; -import { Search, Download, Square, Play, Trash2 } from 'lucide-react'; +import { Search, Download, Trash2 } from 'lucide-react'; import { clsx } from 'clsx'; import { MODE_LABELS, SCALE, type Mode, type Reading, displayUnit } from '@/lib/parser'; import { MODES, UNITS, type SampleStore } from '@/lib/samples'; -import { couldMatchIso, couldMatchValue, createIsoFormatter } from '@/lib/search'; -import { StatisticsPanel, type SessionStats } from '@/components/StatisticsPanel'; -import { ActionButton } from '@/components/Controls'; +import { couldMatchIso, couldMatchValue, createIsoFormatter, localIso } from '@/lib/search'; +import { StatisticsPanel } from '@/components/StatisticsPanel'; +import type { SessionStats } from '@/lib/capture'; +import { ActionButton, RecordButton } from '@/components/Controls'; /** * Exactly what one rendered row needs, as SCALARS. Deliberately not the store's `Sample` @@ -27,7 +28,7 @@ type RowData = { /** The row timestamp as the table and the CSV both render it. Formatted where it is used, * never stored — at most 500 rows are visible, and the session may hold millions. */ -const rowIso = (ts: number): string => new Date(ts).toISOString(); +const rowIso = localIso; /** The value as the meter reported it: base value back through its unit's factor, at the * meter's own digit count. `toFixed`, never `String` — a number has no memory of trailing @@ -194,12 +195,12 @@ export function DataLog({ // Skip a predicate entirely when its output cannot contain the query. const testValue = couldMatchValue(q); const testIso = couldMatchIso(q); - // `toISOString` emits upper-case T/Z; matching against the raw string saves a per-row + // `localIso` emits an upper-case T; matching against the raw string saves a per-row // `toLowerCase()` allocation. const upper = q.toUpperCase(); // ~3 samples share a wall-clock second, so the second-prefix is rebuilt a third as often. // Formats a timestamp per scanned row, caching the per-second prefix. Defined in - // lib/search.ts so `scripts/check-search.mts` can assert it equals `toISOString()`. + // lib/search.ts so `scripts/check-search.mts` can assert it equals `localIso()`. const isoOf = createIsoFormatter(); let scanned = 0; @@ -376,13 +377,7 @@ export function DataLog({

Logging

- : } - label={recording ? 'Stop Logging' : 'Start Logging'} - variant={recording ? 'danger' : 'success'} - disabled={!recording && !canRecord} - /> + } diff --git a/components/PassFail.tsx b/components/PassFail.tsx index 36e6299..7ccf735 100644 --- a/components/PassFail.tsx +++ b/components/PassFail.tsx @@ -8,10 +8,10 @@ import { StatisticsPanel } from '@/components/StatisticsPanel'; import { normalizeReading, readingResolution, type Reading } from '@/lib/parser'; import { ENTRY_UNITS, entryToBase, formatEntryValue, isBandTooWide, isPlausibleReference, - judge, parseSiValue, resolveAbsoluteTolerance, resolveBand, + judge, parseEntry, summarizeBatch, type SupportedMode, type ToleranceMode, type VerdictRow, } from '@/lib/passfail'; -import { siPrefixOf } from '@/lib/si'; +import { formatSiValue, siPrefixOf } from '@/lib/si'; // Same windowing rationale as DataLog: keep every row in memory and in the CSV, but // cap what is painted into the DOM. @@ -19,6 +19,21 @@ const MAX_RENDERED = 500; const GRID_COLS = 'grid grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_90px]'; +// Measured values at the reading's own resolution, the rule Measured Spread and the chart use. +// Never a fixed digit count: meters on this protocol range from 25,000 to 60,000 counts, and a +// trailing zero the meter displayed is part of the measurement (`formatEntryValue` drops it). +const fmtMeasured = (baseUnit: string, v: number, lsd: number | null): string => { + const { text, unit } = formatSiValue(baseUnit, v, lsd ?? undefined); + return `${text} ${unit}`; +}; + +// Snapped to the LSD first: a reference carries float noise (22e-9 F -> 22.000000000000004 nF), +// so an exact match would otherwise pick its prefix from 4e-15 and print `−0 pF`. +const fmtDeviation = (baseUnit: string, dev: number, lsd: number | null): string => { + const d = lsd ? Math.round(dev / lsd) * lsd : dev; + return `${d >= 0 ? '+' : '\u2212'}${fmtMeasured(baseUnit, Math.abs(d), lsd)}`; +}; + function Th({ children, className }: { children: React.ReactNode; className?: string }) { return (
@@ -81,6 +96,39 @@ function EntryField({ ); } +function UnsupportedMode() { + return ( +
+
+

Pass/Fail

+

+ Pass/Fail testing is available in Resistance,{' '} + Diode and{' '} + Capacitance modes. +

+

+ Voltage and Current are excluded because lifting the probes reads a small + number rather than OL, so there is no + reliable way to tell one part from the next. Continuity is excluded because + the meter’s own buzzer already is the pass/fail indicator. +

+

+ Turn the meter’s dial to a supported mode to begin. +

+
+
+ ); +} + +function Tile({ value, label, className }: { value: React.ReactNode; label: string; className?: string }) { + return ( +
+
{value}
+
{label}
+
+ ); +} + export function PassFail({ reading, stable, @@ -119,19 +167,12 @@ export function PassFail({ // Parsed on read, not on keystroke (see EntryField). // Everything below reads the SETTLED (debounced) values, never the live inputs: typing // "10" passes through "1", so a 10% tolerance would flash FAIL at 1% on the way. - const refEntry = parseSiValue(referenceSettled); + const { ref: refEntry, tol: tolEntry, band: bandEntry } = + parseEntry(referenceSettled, toleranceSettled, toleranceMode); // An absolute tolerance is read in the REFERENCE's SI range (a bare `30` against a // `300p` reference = 30 pF); the field's unit label shows that range. - const tolPrefix = toleranceMode === 'absolute' ? siPrefixOf(refEntry) : null; const toleranceUnitLabel = - toleranceMode === 'percent' ? '%' : `${tolPrefix?.symbol ?? ''}${entryUnit}`; - const tolEntry = - toleranceMode === 'absolute' - ? resolveAbsoluteTolerance(toleranceSettled, refEntry) - : parseSiValue(toleranceSettled); - const bandEntry = refEntry !== null && tolEntry !== null - ? resolveBand(refEntry, tolEntry, toleranceMode) - : null; + toleranceMode === 'percent' ? '%' : `${siPrefixOf(refEntry).symbol}${entryUnit}`; // Validity uses the settled value too, so a field doesn't flash red mid-typing. const refInvalid = referenceSettled.trim() !== '' && refEntry === null; @@ -170,29 +211,9 @@ export function PassFail({ mode !== null && bandEntry !== null && lsd !== null && bandEntry * toBase < lsd; - // One memoized pass for yield AND the value spread. This component re-renders on - // every serial batch while `rows` is unbounded, so an unmemoized scan here is O(n) - // work many times a second. `rows` is append-only, so the memo is exact. - const summary = useMemo(() => { - if (rows.length === 0) return null; - let mean = 0, m2 = 0, min = Infinity, max = -Infinity, count = 0, passed = 0; - // Coarsest LSD across the CAPTURED rows (not every live reading) — same "coarsest - // seen" idea as page.tsx's recordedResolutionRef, scoped to this batch so it clears - // with Clear Batch instead of needing its own reset. - let resolution: number | null = null; - for (const r of rows) { - count += 1; - if (r.verdict === 'PASS') passed += 1; - const delta = r.baseValue - mean; - mean += delta / count; - m2 += delta * (r.baseValue - mean); - if (r.baseValue < min) min = r.baseValue; - if (r.baseValue > max) max = r.baseValue; - if (r.resolution !== null) resolution = Math.max(resolution ?? 0, r.resolution); - } - return { stats: { count, mean, m2, min, max }, passed, failed: count - passed, - yieldPct: (passed / count) * 100, resolution }; - }, [rows]); + // Memoized: this component re-renders on every serial batch while `rows` is unbounded, + // so an unmemoized scan here is O(n) work many times a second. + const summary = useMemo(() => summarizeBatch(rows, mode), [rows, mode]); // Memoized for the same reason: a fresh 500-element array per batch makes React // reconcile 500 children each time (memo on VRow saves the render, not the diff). @@ -212,32 +233,10 @@ export function PassFail({ ? 'Enter a reference and tolerance to compare' : verdict === null || deviation === null ? 'Settling\u2026' - : `${deviation >= 0 ? '+' : '\u2212'}${formatEntryValue(Math.abs(deviation) / toBase, entryUnit)} from reference`; + : `${fmtDeviation(baseUnit, deviation, lsd)} from reference`; // Unsupported mode: no controls, no capture, just an explanation. - if (mode === null) { - return ( -
-
-

Pass/Fail

-

- Pass/Fail testing is available in Resistance,{' '} - Diode and{' '} - Capacitance modes. -

-

- Voltage and Current are excluded because lifting the probes reads a small - number rather than OL, so there is no - reliable way to tell one part from the next. Continuity is excluded because - the meter’s own buzzer already is the pass/fail indicator. -

-

- Turn the meter’s dial to a supported mode to begin. -

-
-
- ); - } + if (mode === null) return ; return (
@@ -270,7 +269,7 @@ export function PassFail({ > {measuredBase === null ? '\u2014' - : formatEntryValue(measuredBase / toBase, entryUnit)} + : fmtMeasured(baseUnit, measuredBase, lsd)}
@@ -430,26 +429,12 @@ export function PassFail({ {/* Yield */}

Batch

-
-
-
{rows.length}
-
Tested
-
-
-
{summary?.passed ?? 0}
-
Pass
-
-
-
{summary?.failed ?? 0}
-
Fail
-
+
+ + +
- {summary && ( -
-
{summary.yieldPct.toFixed(1)}%
-
Yield
-
- )} + {summary && } } @@ -459,7 +444,7 @@ export function PassFail({ />
- {summary && ( + {summary?.stats && (
- {new Date(row.ts).toLocaleTimeString('en')} + {/* The time part of the CSV's own timestamp: 24-hour, local, with milliseconds. */} + {row.iso.slice(11, 23)}
- {formatEntryValue(row.baseValue / toBase, unit)} + {fmtMeasured(baseUnit, row.baseValue, row.resolution)}
{formatEntryValue(row.baseReference / toBase, unit)}
- {row.deviation >= 0 ? '+' : '−'} - {formatEntryValue(Math.abs(row.deviation) / toBase, unit)} + {fmtDeviation(baseUnit, row.deviation, row.resolution)}
0 decimals, -// 0.001 -> 3); avoids the duplicate labels fixed-precision formatting would produce. -const formatBinLabel = (v: number, width: number): string => v.toFixed(resolutionDecimals(width)); - -type Histogram = { labels: string[]; counts: number[]; colors: string[] }; - -/** - * One distinct measured value and how many readings landed on it. The histogram is fed - * counts, not samples: the source is a map keyed on the LSD-snapped value, whose size is - * bounded by how many distinct values the meter displayed rather than by how long the - * session ran. That is what lets a multi-day distribution cost ~100 KB instead of - * re-binning a growing array on every update. - */ -type Entry = readonly [value: number, count: number]; - -/** - * LSD grid: bins are integer multiples of `width`, anchored at zero, so each bar is - * centered on (and labeled with) an actual value the meter can display. The window - * is centered on `centerValue` (the dominant/most-held value when recording, or the - * live value when empty), shows at least MIN_BINS, and expands to include data - * within MAX_HALF_BINS of the center. Values beyond the window collect into a single - * under-range bin (left) and over-range bin (right) so a lone outlier doesn't - * stretch the whole view. - */ -function buildLsdHistogram(entries: Entry[], width: number, centerValue: number | undefined): Histogram { - // Fallback centre when the caller supplies none. `entries` comes from a Map, so - // `entries[0]` is whichever value happened to be logged FIRST this session — session-order - // noise, not a centre. The most-counted value is the same thing `centerValue` normally - // carries, so falling back to it degrades gracefully instead of arbitrarily. - let c = Number.isFinite(centerValue) ? (centerValue as number) : 0; - if (!Number.isFinite(centerValue) && entries.length > 0) { - let best = -1; - for (const [v, n] of entries) if (n > best) { best = n; c = v; } - } - const centerBin = Math.round(c / width); - const minHalf = Math.floor((MIN_BINS - 1) / 2); - - // Window: MIN_BINS around the center, expanded to include any data within - // MAX_HALF_BINS of the center (so a genuine spread still shows in full). - let lo = centerBin - minHalf; - let hi = centerBin + minHalf; - for (const [v] of entries) { - const b = Math.round(v / width); - if (b >= centerBin - MAX_HALF_BINS && b <= centerBin + MAX_HALF_BINS) { - if (b < lo) lo = b; - if (b > hi) hi = b; - } - } - - const inCount = hi - lo + 1; - const inRange = new Array(inCount).fill(0); - let under = 0; - let over = 0; - // `+= n`, never `+= 1`: the input is counts per distinct value, so counting each - // distinct value once would make every bar height wrong — most visibly on the steady - // readings a distribution exists to show. - for (const [v, n] of entries) { - const b = Math.round(v / width); - if (b < lo) under += n; - else if (b > hi) over += n; - else inRange[b - lo] += n; - } - - const labels: string[] = []; - const counts: number[] = []; - const colors: string[] = []; - if (under > 0) { - labels.push(`< ${formatBinLabel(lo * width, width)}`); - counts.push(under); - colors.push(OUTLIER_COLOR); - } - for (let i = 0; i < inCount; i++) { - labels.push(formatBinLabel((lo + i) * width, width)); - counts.push(inRange[i]); - colors.push(IN_RANGE_COLOR); - } - if (over > 0) { - labels.push(`> ${formatBinLabel(hi * width, width)}`); - counts.push(over); - colors.push(OUTLIER_COLOR); - } - return { labels, counts, colors }; -} - -/** Fallback when the device LSD is unknown: BIN_COUNT equal-width bins over min→max. - * Reachable whenever `binWidth` is undefined — not a dead path. */ -function buildEqualWidthHistogram(entries: Entry[]): Histogram { - if (entries.length === 0) return { labels: [], counts: [], colors: [] }; - let min = Infinity; - let max = -Infinity; - let total = 0; - for (const [v, n] of entries) { - if (v < min) min = v; - if (v > max) max = v; - total += n; - } - if (min === max) return { labels: [formatBinLabel(min, 1)], counts: [total], colors: [IN_RANGE_COLOR] }; - const width = (max - min) / BIN_COUNT; - const counts = new Array(BIN_COUNT).fill(0); - const labels = new Array(BIN_COUNT); - for (let i = 0; i < BIN_COUNT; i++) labels[i] = formatBinLabel(min + i * width, width); - for (const [v, n] of entries) counts[Math.min(BIN_COUNT - 1, Math.floor((v - min) / width))] += n; - return { labels, counts, colors: new Array(BIN_COUNT).fill(IN_RANGE_COLOR) }; -} - -/** - * Bin numeric measurements (base-unit, OL/null pre-filtered) into a frequency - * histogram. Uses the LSD grid when `binWidth` is a valid resolution, else the - * equal-width fallback. `centerValue` frames the empty (pre-recording) window. - */ -function buildHistogram(entries: Entry[], binWidth: number | undefined, centerValue: number | undefined): Histogram { - return typeof binWidth === 'number' && binWidth > 0 && Number.isFinite(binWidth) - ? buildLsdHistogram(entries, binWidth, centerValue) - : buildEqualWidthHistogram(entries); -} - /** * Column width the 'all' view starts at, before it begins doubling. Deliberately far finer * than the meter's ~330 ms sample interval so a short session is drawn sample-for-sample @@ -245,7 +118,7 @@ function buildChartConfig( { label: 'samples', data: [], - backgroundColor: 'rgba(59,130,246,0.6)', + backgroundColor: IN_RANGE_COLOR, borderColor: '#3b82f6', borderWidth: 1, categoryPercentage: 1, @@ -429,14 +302,10 @@ function buildChartConfig( // manual y-range from a prior session could show a bare prefix letter with // nothing to attach it to (e.g. "1.000 M") before any real reading arrives. if (!baseUnit) return this.getLabelForValue(Number(value)); - const extent = toPhysicalUnit(baseUnit, Math.max(Math.abs(this.min), Math.abs(this.max))); - const prefix = siPrefixOf(extent); - const scale = Math.pow(10, prefix.exp); - const bw = binWidthRef.current; - const decimals = - bw === undefined ? 3 : resolutionDecimals(toPhysicalUnit(baseUnit, bw) / scale); + const { scale, decimals, unit: unitLabel } = siAxisScale( + baseUnit, Math.max(Math.abs(this.min), Math.abs(this.max)), binWidthRef.current, + ); const text = (toPhysicalUnit(baseUnit, Number(value)) / scale).toFixed(decimals); - const unitLabel = `${prefix.symbol}${physicalUnitLabel(baseUnit)}`; return unitLabel ? `${text} ${unitLabel}` : text; }, }, @@ -467,6 +336,52 @@ function buildChartConfig( }; } +function drawHistogram( + chart: Chart, + counts: Map, + unit: string, + binWidth: number | undefined, + centerValue: number | undefined, +): void { + // Counts per distinct value, accumulated as entries are recorded — NOT a re-bin of + // the stored samples, which is what keeps a multi-day distribution affordable. Fed by + // the same `logSample` gate as everything else, so the bars always total the session's + // sample count. They have no time dimension, though, so the distribution covers the + // whole recording session even after retention has dropped the oldest samples from the + // table and the CSV. (OL and no-part readings are excluded upstream, as everywhere.) + const entries: Entry[] = [...counts]; + const { bins, width } = buildHistogram(entries, binWidth, centerValue); + // One prefix for the whole axis (in the title), same rule as the line y-axis. No unit + // yet -> plain base-unit numbers, so the pre-session window reads as it always did. + let extent = 0; + for (const b of bins) extent = Math.max(extent, Math.abs(b.value)); + const axis = unit + ? siAxisScale(unit, extent, width) + : { scale: 1, decimals: resolutionDecimals(width), unit: displayUnit(unit) }; + const edge = { under: '< ', in: '', over: '> ' }; + + chart.data.labels = bins.map( + (b) => edge[b.kind] + (toPhysicalUnit(unit, b.value) / axis.scale).toFixed(axis.decimals), + ); + chart.data.datasets[0].data = bins.map((b) => b.count); + // Per-bar color so under-/over-range outlier bins read as distinct (amber). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (chart.data.datasets[0] as any).backgroundColor = bins.map((b) => + b.kind === 'in' ? IN_RANGE_COLOR : OUTLIER_COLOR, + ); + + const yScale = chart.options.scales?.y as { min?: number; max?: number } | undefined; + if (yScale) { + // Counts only grow — always auto-scale the y-axis. + yScale.min = undefined; + yScale.max = undefined; + } + const xScale = chart.options.scales?.x as { title?: { text?: string } } | undefined; + if (xScale?.title) xScale.title.text = axis.unit; + + chart.update('none'); +} + export function RealtimeChart({ store, sampleVersion, @@ -577,37 +492,12 @@ export function RealtimeChart({ if (!chart || lineUnavailable) return; if (chartType === 'histogram') { - // Counts per distinct value, accumulated as entries are recorded — NOT a re-bin of - // the stored samples, which is what keeps a multi-day distribution affordable. Fed by - // the same `logSample` gate as everything else, so the bars always total the session's - // sample count. They have no time dimension, though, so the distribution covers the - // whole recording session even after retention has dropped the oldest samples from the - // table and the CSV. (OL and no-part readings are excluded upstream, as everywhere.) - const entries: Entry[] = [...counts]; - const { labels, counts: binCounts, colors } = buildHistogram(entries, binWidth, centerValue); - - chart.data.labels = labels; - chart.data.datasets[0].data = binCounts; - // Per-bar color so under-/over-range outlier bins read as distinct (amber). - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (chart.data.datasets[0] as any).backgroundColor = colors; - - const yScale = chart.options.scales?.y as { min?: number; max?: number } | undefined; - if (yScale) { - // Counts only grow — always auto-scale the y-axis. - yScale.min = undefined; - yScale.max = undefined; - } - const xScale = chart.options.scales?.x as { title?: { text?: string } } | undefined; - if (xScale?.title) xScale.title.text = displayUnit(unit); - - chart.update('none'); + drawHistogram(chart, counts, unit, binWidth, centerValue); return; } const now = Date.now(); nowRef.current = now; // what the tick and tooltip labels are offsets from - unitRef.current = unit; // what the y-axis tick callback appends const windowMs = TIME_RANGE_MS[timeRange]; const tier = tierRef.current; // Physical index the chart may start at. A preserve-log mode change advances the @@ -794,11 +684,10 @@ export function RealtimeChart({ @@ -819,7 +708,7 @@ export function RealtimeChart({ That filter records one entry per settled measurement, so entries can be minutes apart. A line drawn between two of them would claim the value was held in between, when the meter was measuring something else — a probe lift, or the next - The histogram plots the recorded entries, so a batch of parts reads as a + part. The histogram plots the recorded entries, so a batch of parts reads as a distribution of their measured values.

@@ -229,13 +210,13 @@ export function Settings({
- + onChange({ noDataWarning: v })} /> - + onChange({ noDataAudio: v })} disabled={!noDataWarning} /> - + onChange({ verdictAudio: v })} />
diff --git a/components/StatisticsPanel.tsx b/components/StatisticsPanel.tsx index e4ad10c..c7f24ed 100644 --- a/components/StatisticsPanel.tsx +++ b/components/StatisticsPanel.tsx @@ -2,8 +2,7 @@ import { clsx } from 'clsx'; import { formatSiValue } from '@/lib/si'; - -export type SessionStats = { count: number; mean: number; m2: number; min: number; max: number }; +import type { SessionStats } from '@/lib/capture'; export function StatisticsPanel({ stats, diff --git a/lib/capture.ts b/lib/capture.ts new file mode 100644 index 0000000..8a2d362 --- /dev/null +++ b/lib/capture.ts @@ -0,0 +1,520 @@ +// The capture engine: what the read loop does with each batch of readings — stable-run +// detection, trigger edges, discontinuities, both capture latches, mode-change flushes, the +// staged append to the store. No React, no DOM: `scripts/check-capture.mts` drives it under +// Node, because a wrong capture rule records the wrong rows and nothing on screen says so. +// +// It never touches React state. `ingest` returns what changed and the page commits it, once +// per batch — which is what keeps setState out of the per-sample loop by construction. + +import { + displayDecimals, normalizeReading, readingResolution, withinStableBand, + type Mode, type Reading, +} from './parser.ts'; +import { RETENTION_MS, type SampleStore } from './samples.ts'; +import { + ENTRY_UNITS, entryToBase, isSupportedMode, judge, + type ToleranceMode, type VerdictRow, +} from './passfail.ts'; +import { DEFAULT_SETTINGS, type Settings } from './settings.ts'; +import { localIso } from './search.ts'; + +// Stable-only logging: once a settled value is logged, the next is logged only if it +// differs by >= this fraction — ignores ±1 LSD drift. NOTE: relative, so very sensitive +// near zero (0.0001 -> 0.0002 is +100%); add an absolute floor if that proves noisy. +const MAJOR_CHANGE_RATIO = 0.5; + +// Shared by both capture sites. The `v !== last` clause only matters when last === 0, +// which is reachable on the Data Log path (a genuine logged 0) but not on the Pass/Fail +// one (zero is intercepted as "no part") — keep it, it is load-bearing for one caller. +const isMajorChange = (v: number, last: number | null): boolean => + last === null || (v !== last && Math.abs(v - last) >= MAJOR_CHANGE_RATIO * Math.abs(last)); + +export type SessionStats = { count: number; mean: number; m2: number; min: number; max: number }; + +export type CaptureSettings = Pick< + Settings, + 'stabilityCount' | 'hysteresisPct' | 'stableOnly' | 'preserveOnModeChange' | 'capNoPartFloor' +>; + +export interface BatchResult { + // Base unit of the last mode/unit change in this batch, else null. + unit: string | null; + // A real (not first-detection) mode change: the trigger and Pass/Fail entry fields clear. + modeReset: boolean; + // The derived projections were reset; `chartFromSeq` holds the new watermark. + reset: boolean; + touched: boolean; + trimmed: boolean; + sessionStart: number | null; + // A copy when this batch appended since its last reset; null means "do not commit". + stats: SessionStats | null; + verdicts: VerdictRow[]; + rowsChanged: boolean; + recordingChanged: boolean; + currentStable: boolean; + recordedResolution: number | null; + dominantValue: number | null; +} + +export class CaptureEngine { + // Readings per LSD-snapped value -> the dominant (most-held) value, which centers the + // histogram window so a brief outlier can't pull it off. CLEARED in place rather than + // replaced, which is what keeps its identity stable across a session reset. + readonly counts = new Map(); + recording = false; + // Captured verdicts for the current batch. Separate from the sample store: the two + // have different lifecycles and clear independently. + passFailRows: VerdictRow[] = []; + chartFromSeq = 0; + + private stats: SessionStats = { count: 0, mean: 0, m2: 0, min: Infinity, max: -Infinity }; + private mode: string | null = null; + private unit = ''; + private triggerArmed = false; + private triggerThreshold: number | null = null; + // Whether the current session was auto-started by the trigger (scopes auto-stop). + private triggerStarted = false; + private stabilityCount = DEFAULT_SETTINGS.stabilityCount; + private hysteresisPct = DEFAULT_SETTINGS.hysteresisPct; + private stableOnly = DEFAULT_SETTINGS.stableOnly; + private preserveOnModeChange = DEFAULT_SETTINGS.preserveOnModeChange; + private capNoPartFloor = DEFAULT_SETTINGS.capNoPartFloor; + // Pass/Fail config, in ENTRY units (converted per-sample with the reading's own mode, so + // a mid-batch mode change can't apply the wrong factor). + private pfRefEntry: number | null = null; + private pfBandEntry: number | null = null; + private pfToleranceMode: ToleranceMode = 'percent'; + private pfToleranceValue: number | null = null; + // "Already captured this part" latch for the Pass/Fail store. Separate from + // `lastLoggedValue` because the two stores clear independently; both are cleared + // by the same discontinuity (OL / capacitance no-part). + private pfLastCaptured: number | null = null; + private pfRowId = 0; + // Capture (rows, tones) only while the Pass/Fail view is open. Starts off: the page's + // first view is the Dashboard. + private passFailActive = false; + // Base-unit value anchoring the current run. A reading joins the run while it stays + // within STABLE_LSD_TOLERANCE of this; anything further starts a new run anchored at + // itself. Anchored rather than compared to the immediately previous reading, so a + // slow ramp cannot creep along one LSD at a time and look settled forever. + private runAnchor: number | null = null; + private stableRun = 0; + private lastLoggedValue: number | null = null; + // Coarsest LSD among LOGGED readings — the basis for histogram bin width AND stat + // decimals, so both reflect stored data, not the live (possibly auto-ranged) reading. + // Frozen while logging is stopped; reset in flush. + private recordedResolution: number | null = null; + private dominantValue: number | null = null; + private dominantCount = 0; + + private readonly store: SampleStore; + + constructor(store: SampleStore) { + this.store = store; + } + + configure(s: CaptureSettings): void { + this.stabilityCount = s.stabilityCount; + this.hysteresisPct = s.hysteresisPct; + this.stableOnly = s.stableOnly; + this.preserveOnModeChange = s.preserveOnModeChange; + this.capNoPartFloor = s.capNoPartFloor; + } + + // Two setters, fed by two effects, on purpose: a mode change clears both inside `ingest`, + // and a stale effect landing after it must not be able to restore a whole, firing trigger. + setTriggerArmed(armed: boolean): void { + this.triggerArmed = armed; + } + + setTriggerThreshold(threshold: number | null): void { + this.triggerThreshold = threshold; + } + + setPassFailConfig( + refEntry: number | null, + toleranceValue: number | null, + bandEntry: number | null, + toleranceMode: ToleranceMode, + ): void { + this.pfRefEntry = refEntry; + this.pfToleranceValue = toleranceValue; + this.pfBandEntry = bandEntry; + this.pfToleranceMode = toleranceMode; + } + + // Leaving the view keeps the reference, tolerance and rows; it only stops capturing. The + // latch still re-arms on a probe lift while away, so a part already captured and still + // held is not captured again on return, and a new part held on return is. + setPassFailActive(active: boolean): void { + this.passFailActive = active; + } + + // `manual`: a toggle by the operator, so this session is not trigger-owned and is never + // auto-stopped. + setRecording(v: boolean, manual = false): void { + if (manual) this.triggerStarted = false; + this.recording = v; + // A stop (manual or disconnect) ends any trigger-started session. + if (!v) this.triggerStarted = false; + // A fresh start must not inherit a stale predecessor/run from before it began. + if (v) { this.runAnchor = null; this.stableRun = 0; this.lastLoggedValue = null; } + } + + // Clears only the verdict batch — the Data Log's recorded rows are untouched, and + // the reference/tolerance are kept (the operator is usually still on the same part). + // + // Deliberately does NOT touch `pfLastCaptured`: that guard is what stops a held + // part from being captured twice, and it already re-arms itself on a genuine probe + // lift (baseValue null/no-part, in ingest). Resetting it here too used to re-capture + // (and re-beep) the SAME still-connected part the instant Clear Batch was pressed. + // Clear Batch is a table boundary, not a probe-lift. + clearVerdicts(): void { + this.passFailRows = []; + this.pfRowId = 0; + } + + // Full session flush: clear the canonical store AND every derived projection. + flush(): void { + // `clear()` keeps seq monotonic, so the watermark below lands past every seq ever used + // and a stale watermark can never sit ahead of new samples. + this.store.clear(); + this.resetDerived(); + } + + // Reset the single-unit projections (chart, stats, histogram derivation, stable run) + // while KEEPING the canonical samples — the "keep log on mode change" path. Those views + // can't mix units; the per-row table/CSV can (each row carries its own mode/unit). + // + // This is the ONE thing two parallel buffers were genuinely buying, and it costs one + // integer: the store is untouched and the chart simply starts reading later. Clearing the + // store here would take the operator's log with it. + private resetDerived(): void { + this.stats = { count: 0, mean: 0, m2: 0, min: Infinity, max: -Infinity }; + this.runAnchor = null; + this.stableRun = 0; + this.lastLoggedValue = null; + this.recordedResolution = null; + this.counts.clear(); + this.dominantValue = null; + this.dominantCount = 0; + // Next unused seq: everything already stored belongs to the previous unit. + this.chartFromSeq = this.store.firstSeq + this.store.count; + } + + ingest(readings: Reading[]): BatchResult { + const store = this.store; + let armed = this.triggerArmed; + let threshold = this.triggerThreshold; + let release = threshold !== null ? threshold * (1 - this.hysteresisPct / 100) : null; + + let unitChanged: string | null = null; + let modeReset = false; + let reset = false; + let rowsChanged = false; + let recordingChanged = false; + // ONE staging array, appended to the store once at the end of the batch. Not a + // performance choice — a batch is three to ten samples — but a control-flow one: the + // loop discards already-collected samples on a mode change and on a trigger edge, and + // a store appended per sample has no un-append. + // + // Collapsing the old `newPoints`/`newRows` pair into one also repairs a live bug: the + // trigger branch below used to clear only `newPoints`, so a re-trigger mid-batch kept + // rows in the log whose chart points had been dropped — two consumers disagreeing, in + // the function whose comment claims their membership is identical by construction. + const staged: { ts: number; value: number; mode: Mode; unit: string; decimals: number }[] = []; + // Whether this batch changed anything the UI reads: a staged sample, or a retention + // trim that dropped one. Everything the UI reads now lives behind the same gate. + let touched = false; + const newVerdicts: VerdictRow[] = []; + + // Append what is staged so far. Called at a mode change that preserves the log — + // those samples belong to the OLD unit, so they must reach the store before the + // watermark moves past them — and once at the end of the batch. + // + // It deliberately computes nothing about `sessionStart`. A candidate captured here + // would be captured against whatever the watermark was AT THE TIME, and the preserve + // path advances the watermark immediately afterwards — so the candidate would be an + // old-unit sample that the advance then excludes from the chart. The anchor is derived + // once, at the end of the batch, from the FINAL watermark. + // + // Samples appended SINCE THE LAST RESET in this batch. Zeroed at every reset site, + // because stats is zeroed there too: counting across a reset would commit a + // freshly-zeroed stats object over the `null` the reset queued. + let appended = 0; + const flushStaged = () => { + for (const st of staged) store.append(st.ts, st.value, st.mode, st.unit, st.decimals); + appended += staged.length; + staged.length = 0; + }; + for (const r of readings) { + const { baseValue, baseUnit } = normalizeReading(r); + + if (baseUnit !== '' && (this.mode !== r.mode || this.unit !== baseUnit)) { + const wasInitialized = this.mode !== null; + this.mode = r.mode; + this.unit = baseUnit; + unitChanged = baseUnit; + // Keep the recorded log across a real mode change if opted in (chart/stats are + // single-unit and always reset). The first detection has no prior data, so a + // full flush is equivalent. Old-unit rows stay valid in their original unit. + if (wasInitialized && this.preserveOnModeChange) { + // Samples staged before the change are old-unit rows the operator asked to keep. + // They must reach the store BEFORE the watermark advances, or the watermark + // lands behind them and the chart draws the previous unit. + flushStaged(); + this.resetDerived(); + reset = true; + appended = 0; + } else { + staged.length = 0; + this.flush(); + reset = true; + touched = true; + appended = 0; + } + // A real mode/unit change makes the in-progress data and threshold + // meaningless in the new unit — stop logging, then reset the trigger + // (clear threshold + disarm). (Skip on the first reading, which is + // initial detection, not a change, so a pre-typed threshold survives.) + if (wasInitialized) { + if (this.recording) { + this.recording = false; + recordingChanged = true; + } + this.triggerThreshold = null; + this.triggerArmed = false; + this.triggerStarted = false; + armed = false; + threshold = null; + release = null; + // The reference belongs to the old mode's unit and is meaningless in the new one — + // cleared for the same reason the trigger threshold is. The captured verdicts + // follow "Keep log on mode change" like the Data Log does: each row carries its own + // mode, so a kept batch stays valid in its original unit. + this.pfRefEntry = null; + this.pfBandEntry = null; + this.pfToleranceValue = null; + this.pfLastCaptured = null; + if (!this.preserveOnModeChange) { + this.passFailRows = []; + this.pfRowId = 0; + newVerdicts.length = 0; + rowsChanged = true; + } + modeReset = true; + } + } + + // Trigger edges (evaluated before recording so the crossing sample is captured). + const mag = baseValue !== null ? Math.abs(baseValue) : null; + if (armed && threshold !== null && !this.recording && mag !== null && mag > threshold) { + // Clears EVERYTHING staged, chart and log alike. Previously only the chart half was + // dropped here; see the `staged` comment above. + staged.length = 0; + this.flush(); + reset = true; + touched = true; + appended = 0; + this.recording = true; + this.triggerStarted = true; + recordingChanged = true; + } else if (this.triggerStarted && this.recording && release !== null && mag !== null && mag < release) { + this.recording = false; + this.triggerStarted = false; + recordingChanged = true; + } + + // Capacitance has no OL on lifted probes — it reads lead stray capacitance (pF), + // which is numeric, so without this it appends a junk near-zero row on every probe + // lift and never clears the last-logged reference. A floor of 0 disables it. + const noPart = + baseValue !== null && + r.mode === 'CAPACITANCE' && + Math.abs(baseValue) < this.capNoPartFloor; + + // Stable-run tracking runs regardless of the recording session, because Pass/Fail + // capture has no session of its own (its batch boundary is Clear). Behavior- + // preserving for the Data Log: every path that STARTS recording resets these + // first, so anything accumulated while idle is wiped when recording begins. + if (baseValue === null || noPart) { + // OL (and a capacitance no-part reading) are excluded from the filtered + // dataset entirely — table, CSV, chart, and stats. Both are still a + // discontinuity — reset the run and BOTH stores' last-captured references. + this.runAnchor = null; + this.stableRun = 0; + this.lastLoggedValue = null; + this.pfLastCaptured = null; + } else { + // Maintain the run of consecutive readings belonging to the same measurement. + // Membership is a band around the run's anchor, not equality + // (see STABLE_LSD_TOLERANCE in lib/parser.ts). + const lsd = readingResolution(r); + const anchor = this.runAnchor; + if (anchor !== null && withinStableBand(baseValue, anchor, lsd)) { + this.stableRun += 1; + } else { + this.stableRun = 1; + this.runAnchor = baseValue; + } + // Capacitance needs no extra count: the meter sends "-.--" (isMeasuring) until + // it has a number — confirmed on a 3.3 mF electrolytic — so its first digit + // value is already settled. + const stable = this.stableRun >= this.stabilityCount; + + // ---- Pass/Fail capture (independent of the recording session; Pass/Fail view only) + // An exact zero means nothing is connected, not a part measuring zero: with the + // probes floating the meter settles on 0 in every supported mode, and a real + // component never reads a clean 0 (a 0R link still shows lead resistance). + // Scoped to Pass/Fail — the Data Log records a genuine 0 as a real measurement. + const pfRefEntry = this.pfRefEntry; + const pfBandEntry = this.pfBandEntry; + if (baseValue === 0) { + this.pfLastCaptured = null; + } else if ( + this.passFailActive && + stable && + pfRefEntry !== null && + pfBandEntry !== null && + isSupportedMode(r.mode) && + // Guards a stale reference being judged in the wrong unit: the mode-change + // reset is gated on `baseUnit !== ''`, so an unrecognized unit string + // (documented as possible above nF) skips it and leaves the old reference + // live while r.mode has already changed. + baseUnit === ENTRY_UNITS[r.mode].baseUnit + ) { + const last = this.pfLastCaptured; + // Gated on the stable run, same flag the live verdict uses (via currentStable), + // so display and table never disagree. Resistance and diode need it: with no + // "measuring" state, lifting a probe sweeps UP through intermediates and a + // >50% step would read as a fresh part. The major-change gate suppresses drift + // within one held part; a probe lift clears the reference for the next. + if (isMajorChange(baseValue, last)) { + this.pfLastCaptured = baseValue; + const toBase = ENTRY_UNITS[r.mode].toBase; + const baseReference = entryToBase(r.mode, pfRefEntry); + const baseBand = pfBandEntry * toBase; + newVerdicts.push({ + id: this.pfRowId++, + ts: r.ts, + iso: localIso(r.ts), + mode: r.mode, + baseValue, + baseReference, + baseBand, + toleranceMode: this.pfToleranceMode, + toleranceValue: this.pfToleranceValue!, + verdict: judge(baseValue, baseReference, baseBand), + deviation: baseValue - baseReference, + resolution: lsd, + unit: r.unit, + decimals: displayDecimals(r.display), + }); + } + } + + // ---- Data Log / chart / statistics (gated on the recording session) ----- + if (this.recording) { + // Stable filter on: log only a confirmed-stable value that differs from the + // last logged one by a major amount, suppressing drift and plateaus. + let logSample = true; + if (this.stableOnly) { + logSample = stable && isMajorChange(baseValue, this.lastLoggedValue); + if (logSample) this.lastLoggedValue = baseValue; + } + + if (logSample) { + // Readings per LSD-snapped value: the histogram's bars and the dominant value + // that centres its window. INSIDE the `logSample` gate, with everything else. + // + // It sat outside once, counting every reading regardless of the filter. That + // made the histogram grow three times a second while the Samples tile stayed + // at the number of recorded entries — two readouts of the same session + // disagreeing by orders of magnitude. The filter is a gate: a reading it + // rejects is not recorded anywhere, and that has to include here. + if (lsd !== null) { + const k = Math.round(baseValue / lsd) * lsd; + const c = (this.counts.get(k) ?? 0) + 1; + this.counts.set(k, c); + if (c > this.dominantCount) { + this.dominantCount = c; + this.dominantValue = k; + } + } + // Everything in this block is behind the same gate, deliberately: the store, + // the histogram counts, the recorded resolution and the statistics. Hoisting + // any of it to the `recording` level one scope out would silently disable + // "Stable values only" for that consumer — no compile error, and no visible + // symptom until two readouts of the same session disagree. + staged.push({ + ts: r.ts, + value: baseValue, + mode: r.mode, + unit: r.unit, + decimals: displayDecimals(r.display), + }); + touched = true; + // Track the coarsest LSD among logged readings → bin width + stat + // decimals reflect the recorded data, range-robust to auto-ranging. + if (lsd !== null) { + this.recordedResolution = Math.max(this.recordedResolution ?? 0, lsd); + } + const s = this.stats; + s.count += 1; + const delta = baseValue - s.mean; + s.mean += delta / s.count; + s.m2 += delta * (baseValue - s.mean); + if (baseValue < s.min) s.min = baseValue; + if (baseValue > s.max) s.max = baseValue; + // No clamping here. The operator's y-range is applied when the chart draws, so + // changing it re-clamps the whole session instead of only what arrives next. + } + } + } + } + + // ---- Once per batch, never in the per-sample loop ------------------------ + flushStaged(); + + // Retention, applied to the ONE store: past seven days the oldest samples leave the + // table and the CSV too, not just the chart. A dropped chunk invalidates the chart's + // incremental tier, which is what `chartEpoch` tells it. + const trimmed = store.trim(Date.now() - RETENTION_MS); + if (trimmed) touched = true; + + // The chart's x-axis anchor: the oldest sample the chart can actually see, read from + // the store against the FINAL watermark rather than tracked during the batch. Stating + // it as a derivation instead of a running candidate is what makes it immune to a + // mid-batch watermark advance — the preserve-log mode change flushes old-unit samples + // and THEN advances, so anything captured before that advance is the previous unit. + const anchorIdx = Math.max(0, this.chartFromSeq - store.firstSeq); + const sessionStart = store.count > anchorIdx ? store.tsAt(anchorIdx) : null; + + // Same batched append for the Pass/Fail store. + if (newVerdicts.length > 0) { + this.passFailRows = [...this.passFailRows, ...newVerdicts]; + rowsChanged = true; + } + + return { + unit: unitChanged, + modeReset, + reset, + touched, + trimmed, + sessionStart, + // Only when something was actually logged: stats mutates inside `if (logSample)`, + // so with the stable filter on most batches change nothing and a fresh object identity + // would re-render StatisticsPanel three times a second for nothing. + stats: appended > 0 ? { ...this.stats } : null, + verdicts: newVerdicts, + rowsChanged, + recordingChanged, + // After the loop the run describes the LAST reading of the batch, which is the one + // the page's `current` holds. + currentStable: this.stableRun >= this.stabilityCount, + recordedResolution: this.recordedResolution, + dominantValue: this.dominantValue, + }; + } +} diff --git a/lib/csv.ts b/lib/csv.ts index 7a3705e..f1f42f3 100644 --- a/lib/csv.ts +++ b/lib/csv.ts @@ -33,7 +33,7 @@ export const CHUNKS_PER_BLOB = 100; * Blob is safe at 200,000 parts; the chain is the only shape that fails. * * The separator goes BETWEEN chunks, never after a line, so the result is exactly - * `[header, ...lines].join('\n')` — no trailing newline, no visible chunk boundary. That + * `'\uFEFF' + [header, ...lines].join('\n')` — no trailing newline, no visible chunk boundary. That * equality is the contract; scripts/check-csv.mts asserts it against the finished file, * because a corrupt export is silent. */ @@ -41,7 +41,9 @@ export function csvBlob(header: string, lines: Iterable): Blob { const type = 'text/csv;charset=utf-8'; const blobs: Blob[] = []; let pending: string[] = []; - let chunk: string[] = [header]; + // UTF-8 BOM: without it Excel decodes the file as the system code page and shows `±25 Ω` + // or a Cyrillic note as `±25 Ω`. `Blob.text()` and most CSV readers strip it. + let chunk: string[] = ['\uFEFF' + header]; let started = false; const endChunk = () => { diff --git a/lib/histogram.ts b/lib/histogram.ts new file mode 100644 index 0000000..2f55ea7 --- /dev/null +++ b/lib/histogram.ts @@ -0,0 +1,121 @@ +// Histogram binning: what distribution the operator sees. Pure numbers, no React and no +// formatting — `scripts/check-histogram.mts` loads this under Node, because a wrong bin draws +// a plausible-looking distribution over correct data. Labels (SI prefix, decimals) are the +// chart's job; that is why bins carry a numeric `value` and not a string. + +// Fallback bin count when the device resolution (LSD) isn't known, dividing the +// value range into this many equal-width buckets. +const BIN_COUNT = 25; +// Minimum window the LSD histogram always shows: the center bin plus 10 bins on +// each side, so a steady (or not-yet-started) reading still has visible context +// around it instead of a single fat bar. +const MIN_BINS = 21; +// Cap on the in-range window: the center bin plus this many bins on each side. +// Data within the window shows at native LSD resolution; values beyond it collect +// into under-/over-range edge bins instead of stretching (and flattening) the view. +const MAX_HALF_BINS = 100; + +/** + * One distinct measured value and how many readings landed on it. The histogram is fed + * counts, not samples: the source is a map keyed on the LSD-snapped value, whose size is + * bounded by how many distinct values the meter displayed rather than by how long the + * session ran. That is what lets a multi-day distribution cost ~100 KB instead of + * re-binning a growing array on every update. + */ +export type Entry = readonly [value: number, count: number]; + +/** `value` is what the bin is labeled with: the LSD bin centre, the edge bins' window bound + * (read as `< value` / `> value`), or an equal-width bin's left edge. */ +type Bin = { value: number; count: number; kind: 'under' | 'in' | 'over' }; + +/** `width` is the effective bin width, which the label decimals derive from. */ +type Histogram = { bins: Bin[]; width: number }; + +/** + * LSD grid: bins are integer multiples of `width`, anchored at zero, so each bar is + * centered on (and labeled with) an actual value the meter can display. The window + * is centered on `centerValue` (the dominant/most-held value when recording, or the + * live value when empty), shows at least MIN_BINS, and expands to include data + * within MAX_HALF_BINS of the center. Values beyond the window collect into a single + * under-range bin (left) and over-range bin (right) so a lone outlier doesn't + * stretch the whole view. + */ +function buildLsdHistogram(entries: readonly Entry[], width: number, centerValue: number | undefined): Histogram { + // Fallback centre when the caller supplies none. `entries` comes from a Map, so + // `entries[0]` is whichever value happened to be logged FIRST this session — session-order + // noise, not a centre. The most-counted value is the same thing `centerValue` normally + // carries, so falling back to it degrades gracefully instead of arbitrarily. + let c = Number.isFinite(centerValue) ? (centerValue as number) : 0; + if (!Number.isFinite(centerValue) && entries.length > 0) { + let best = -1; + for (const [v, n] of entries) if (n > best) { best = n; c = v; } + } + const centerBin = Math.round(c / width); + const minHalf = Math.floor((MIN_BINS - 1) / 2); + + // Window: MIN_BINS around the center, expanded to include any data within + // MAX_HALF_BINS of the center (so a genuine spread still shows in full). + let lo = centerBin - minHalf; + let hi = centerBin + minHalf; + for (const [v] of entries) { + const b = Math.round(v / width); + if (b >= centerBin - MAX_HALF_BINS && b <= centerBin + MAX_HALF_BINS) { + if (b < lo) lo = b; + if (b > hi) hi = b; + } + } + + const inCount = hi - lo + 1; + const inRange = new Array(inCount).fill(0); + let under = 0; + let over = 0; + // `+= n`, never `+= 1`: the input is counts per distinct value, so counting each + // distinct value once would make every bar height wrong — most visibly on the steady + // readings a distribution exists to show. + for (const [v, n] of entries) { + const b = Math.round(v / width); + if (b < lo) under += n; + else if (b > hi) over += n; + else inRange[b - lo] += n; + } + + const bins: Bin[] = []; + if (under > 0) bins.push({ value: lo * width, count: under, kind: 'under' }); + for (let i = 0; i < inCount; i++) bins.push({ value: (lo + i) * width, count: inRange[i], kind: 'in' }); + if (over > 0) bins.push({ value: hi * width, count: over, kind: 'over' }); + return { bins, width }; +} + +/** Fallback when the device LSD is unknown: BIN_COUNT equal-width bins over min→max. + * Reachable whenever `binWidth` is undefined — not a dead path. */ +function buildEqualWidthHistogram(entries: readonly Entry[]): Histogram { + if (entries.length === 0) return { bins: [], width: 1 }; + let min = Infinity; + let max = -Infinity; + let total = 0; + for (const [v, n] of entries) { + if (v < min) min = v; + if (v > max) max = v; + total += n; + } + if (min === max) return { bins: [{ value: min, count: total, kind: 'in' }], width: 1 }; + const width = (max - min) / BIN_COUNT; + const counts = new Array(BIN_COUNT).fill(0); + for (const [v, n] of entries) counts[Math.min(BIN_COUNT - 1, Math.floor((v - min) / width))] += n; + return { bins: counts.map((count, i) => ({ value: min + i * width, count, kind: 'in' })), width }; +} + +/** + * Bin numeric measurements (base-unit, OL/null pre-filtered) into a frequency + * histogram. Uses the LSD grid when `binWidth` is a valid resolution, else the + * equal-width fallback. `centerValue` frames the empty (pre-recording) window. + */ +export function buildHistogram( + entries: readonly Entry[], + binWidth: number | undefined, + centerValue: number | undefined, +): Histogram { + return typeof binWidth === 'number' && binWidth > 0 && Number.isFinite(binWidth) + ? buildLsdHistogram(entries, binWidth, centerValue) + : buildEqualWidthHistogram(entries); +} diff --git a/lib/parser.ts b/lib/parser.ts index 622cb7f..f106415 100644 --- a/lib/parser.ts +++ b/lib/parser.ts @@ -93,6 +93,9 @@ const DISPLAY_UNITS: Record = { OM: '\u03A9', // Ω KOM: 'k\u03A9', // kΩ MOM: 'M\u03A9', // MΩ + // The meter sends ASCII `uF`; every SI-formatted view (chart, statistics, Pass/Fail) says µF. + uF: '\u00B5F', + '\u03BCF': '\u00B5F', }; /** The operator-facing spelling of a raw meter unit. Unmapped units pass through. */ diff --git a/lib/passfail.ts b/lib/passfail.ts index 3b7ffeb..20ee780 100644 --- a/lib/passfail.ts +++ b/lib/passfail.ts @@ -3,8 +3,10 @@ // normalizeReading() produces (parser.ts SCALE). They differ for Capacitance, whose base // is nF — keeping the two apart is what lets the operator type `22m` and not do maths. -import type { Mode } from '@/lib/parser'; +import { SCALE, type Mode } from './parser.ts'; import { siPrefixOf } from './si.ts'; +import { csvEsc } from './csv.ts'; +import type { SessionStats } from './capture.ts'; // ---------------------------------------------------------------- SI value parsing @@ -201,6 +203,15 @@ export function resolveBand( return isBandTooWide(reference, tolerance, mode) ? null : band; } +/** The operator's entry, parsed. Shared by the engine's config and the view, so the band + * on screen is always the band being judged. */ +export function parseEntry(reference: string, tolerance: string, mode: ToleranceMode) { + const ref = parseSiValue(reference); + const tol = mode === 'absolute' ? resolveAbsoluteTolerance(tolerance, ref) : parseSiValue(tolerance); + const band = ref !== null && tol !== null ? resolveBand(ref, tol, mode) : null; + return { ref, tol, band }; +} + /** * PASS when |measured − reference| is within the band, edge inclusive. The epsilon buys * that inclusivity: a float-computed band can land a hair under the exact edge, and a @@ -234,4 +245,83 @@ export interface VerdictRow { * Measured Spread panel show the batch's real demonstrated resolution instead of a * flat fallback — see `StatisticsPanel`'s `resolution` prop. */ resolution: number | null; + /** The meter's own unit token and digit count at capture (`KOM`, 4) — what the CSV writes + * in, so it matches the Data Log export row for row. */ + unit: string; + decimals: number; +} + +// --------------------------------------------------------------- batch summary + +/** + * Yield over the whole batch; the value spread only over the trailing run of rows in `mode`. + * With "Keep log on mode change" on, the batch can hold several modes, and a mean of ohms and + * volts is meaningless. A detour through another mode that captured nothing leaves the run + * unbroken — same unit, fine. `resolution` is the coarsest LSD across that run, so it clears + * with Clear Batch instead of needing its own reset. + */ +export function summarizeBatch(rows: readonly VerdictRow[], mode: SupportedMode | null) { + if (rows.length === 0) return null; + let passed = 0; + for (const r of rows) if (r.verdict === 'PASS') passed += 1; + let from = rows.length; + while (from > 0 && rows[from - 1].mode === mode) from -= 1; + + let mean = 0, m2 = 0, min = Infinity, max = -Infinity, count = 0; + let resolution: number | null = null; + for (let i = from; i < rows.length; i++) { + const r = rows[i]; + count += 1; + const delta = r.baseValue - mean; + mean += delta / count; + m2 += delta * (r.baseValue - mean); + if (r.baseValue < min) min = r.baseValue; + if (r.baseValue > max) max = r.baseValue; + if (r.resolution !== null) resolution = Math.max(resolution ?? 0, r.resolution); + } + const stats: SessionStats | null = count > 0 ? { count, mean, m2, min, max } : null; + return { stats, passed, failed: rows.length - passed, yieldPct: (passed / rows.length) * 100, resolution }; +} + +// ------------------------------------------------------------------- CSV export + +export const VERDICT_CSV_HEADER = 'Timestamp,Mode,Measured,Unit,Reference,Tolerance,Deviation,Verdict'; + +// Base -> entry is a division, so the error is relative and far below 12 significant digits: +// rounding there strips it (`1.0010000000000001e-7` -> `1e-7`-style noise) without touching +// a digit the meter or the operator produced. +const entryNumber = (x: number): number => Number(x.toPrecision(12)); + +// Decimal places of a number's shortest form, exponent included (`1.001e-7` -> 10). +const decimalsOf = (x: number): number => { + const [mantissa, exp] = String(x).split('e'); + return Math.max(0, (mantissa.split('.')[1]?.length ?? 0) - Number(exp ?? 0)); +}; + +/** + * One verdict row as a CSV line, in the unit the meter displayed (as the Data Log export + * does), Measured keeping the meter's digits. Reference and Deviation use the same unit as + * that row's Measured, so one row never mixes kΩ and Ω. Deviation is NOT the + * stored `deviation` rescaled: a subtraction of near-equal values loses precision relative to + * its operands, not its result (`10000.001 - 10000` = `0.0009999999999763531`), so no + * significant-digit rounding of the result can clean it. The difference of two decimals has + * at most as many places as the finer operand, so it is recomputed from the exported values + * and rounded there — exact. + */ +export function verdictCsvLine(row: VerdictRow): string { + const { label } = ENTRY_UNITS[row.mode]; + const factor = SCALE[row.unit]?.factor ?? 1; + const measured = (row.baseValue / factor).toFixed(row.decimals); + const reference = entryNumber(row.baseReference / factor); + const places = Math.min(20, Math.max(row.decimals, decimalsOf(reference))); + const deviation = Number((Number(measured) - reference).toFixed(places)); + const tol = + row.toleranceMode === 'percent' + ? `${row.toleranceValue}%` + : `\u00B1${formatEntryValue(row.toleranceValue, label)}`; + return [ + row.iso, row.mode, measured, row.unit, + String(reference), csvEsc(tol), + String(deviation), row.verdict, + ].join(','); } diff --git a/lib/samples.ts b/lib/samples.ts index 390d7df..c074734 100644 --- a/lib/samples.ts +++ b/lib/samples.ts @@ -108,6 +108,14 @@ const newChunk = (): Chunk => ({ decimals: new Uint8Array(CHUNK), }); +const copyChunk = (c: Chunk): Chunk => ({ + ts: c.ts.slice(), + value: c.value.slice(), + mode: c.mode.slice(), + unit: c.unit.slice(), + decimals: c.decimals.slice(), +}); + /** A structured-cloneable snapshot. Carries its dictionaries so `from` can remap. */ export interface Snapshot { version: 1; @@ -289,13 +297,7 @@ export class SampleStore { // slot N of the same chunk, each silently overwriting the other's samples, and // `from`'s dictionary remap would rewrite the original store in place. The copy is // one memcpy — the same cost `structuredClone` would pay on the way to storage. - chunks: this.#chunks.map((c) => ({ - ts: c.ts.slice(), - value: c.value.slice(), - mode: c.mode.slice(), - unit: c.unit.slice(), - decimals: c.decimals.slice(), - })), + chunks: this.#chunks.map(copyChunk), notes: [...this.#notes], }; } @@ -315,13 +317,7 @@ export class SampleStore { const store = new SampleStore(); store.#firstSeq = snap.firstSeq; store.#count = snap.count; - store.#chunks = snap.chunks.map((c) => ({ - ts: c.ts.slice(), - value: c.value.slice(), - mode: c.mode.slice(), - unit: c.unit.slice(), - decimals: c.decimals.slice(), - })); + store.#chunks = snap.chunks.map(copyChunk); store.#notes = new Map(snap.notes); // Units auto-extend (`#unitId`), so they can never fail to resolve. MODES is fixed and diff --git a/lib/search.ts b/lib/search.ts index c9e38ab..7f4a533 100644 --- a/lib/search.ts +++ b/lib/search.ts @@ -6,21 +6,14 @@ // `true`. That is also why these live here rather than inline in the component — a `.tsx` // is unreachable from a self-check, and both bugs noted below shipped in the inline version. -// `toISOString()` as fixed-width templates. `0` = any digit, `±` = a sign. +// `localIso()` as fixed-width templates. `0` = any digit, `±` = a sign. // -// TWO of them, because the output is not always 24 characters — outside years 0000-9999 it -// carries a sign and six year digits: -// new Date(253402300800000).toISOString() === '+010000-01-01T00:00:00.000Z' +// TWO of them, because the output is not always 29 characters — outside years 0000-9999 it +// carries a sign and six year digits, as `toISOString` does: +// '+010000-01-01T02:00:00.000+02:00' // Modelling only the normal form rejected '+' and '-053243', which really do occur. -const ISO_TEMPLATE = '0000-00-00T00:00:00.000Z'; -const ISO_EXPANDED = '±000000-00-00T00:00:00.000Z'; - -/** First ms needing the expanded form, and the earliest still in the 24-char form. */ -const ISO_NORMAL_MAX = 253402300800000; -const ISO_NORMAL_MIN = -62167219200000; - -const isNormalIsoTime = (ts: number): boolean => - Number.isFinite(ts) && ts >= ISO_NORMAL_MIN && ts < ISO_NORMAL_MAX; +const ISO_TEMPLATE = '0000-00-00T00:00:00.000±00:00'; +const ISO_EXPANDED = '±000000-00-00T00:00:00.000±00:00'; const fitsAt = (q: string, tpl: string, p: number): boolean => { for (let k = 0; k < q.length; k++) { @@ -47,9 +40,8 @@ const fitsTemplate = (q: string, tpl: string): boolean => { * Whether `q` could occur inside SOME ISO timestamp. Conservative: tests digit-ness, not * ranges, so `'9-99'` is admitted though no month is 99. * - * A charset test would not do: `'zzz'` is all ISO characters, but neither template holds more - * than one `Z` and it is last. Normalizes case rather than trusting a caller precondition - * nothing enforces. + * A charset test would not do: `'tt'` is all ISO characters, but each template holds one `T`. + * Normalizes case rather than trusting a caller precondition nothing enforces. */ export const couldMatchIso = (query: string): boolean => { const q = query.toLowerCase(); @@ -69,27 +61,53 @@ const VALUE_CHARS = /^[0-9.\-]+$/; /** Whether `q` could occur inside a formatted reading. Empty matches everything. */ export const couldMatchValue = (q: string): boolean => q.length === 0 || VALUE_CHARS.test(q); +const pad = (n: number, width: number): string => String(n).padStart(width, '0'); + +/** + * `ts` as ISO 8601 in the user's own timezone, with the offset spelled out + * (`2026-10-03T17:29:05.123+03:00`): the clock the operator saw, and still unambiguous across + * a DST change or when the file is opened elsewhere. UTC is `+00:00`, never `Z` — one shape. + * Years outside 0000-9999 take `toISOString`'s expanded `±YYYYYY` form. + */ +export const localIso = (ts: number): string => { + const d = new Date(ts); + const y = d.getFullYear(); + const year = y >= 0 && y <= 9999 ? pad(y, 4) : (y < 0 ? '-' : '+') + pad(Math.abs(y), 6); + const off = -d.getTimezoneOffset(); + const abs = Math.abs(off); + return ( + `${year}-${pad(d.getMonth() + 1, 2)}-${pad(d.getDate(), 2)}` + + `T${pad(d.getHours(), 2)}:${pad(d.getMinutes(), 2)}:${pad(d.getSeconds(), 2)}.${pad(d.getMilliseconds(), 3)}` + + `${off < 0 ? '-' : '+'}${pad(Math.floor(abs / 60), 2)}:${pad(abs % 60, 2)}` + ); +}; + /** - * An ISO formatter caching the per-second prefix — ~3 samples share a wall-clock second, so - * the `Date` work drops to a third. Returns exactly `new Date(ts).toISOString()` for every - * `ts`; `check-search.mts` asserts that equality, because two silent bugs lived here: + * `localIso` caching the per-second head and offset — ~3 samples share a wall-clock second, + * so the `Date` work drops to a third. Returns exactly `localIso(ts)` for every `ts`; + * `check-search.mts` asserts that equality, because two silent bugs lived here: * - * - `ts % 1000` keeps the dividend's sign, so a pre-1970 row built `…59.00-1Z`. + * - `ts % 1000` keeps the dividend's sign, so a pre-1970 row built `…59.00-1`. * `ts - sec * 1000` is always in [0, 1000) since `sec` is a floor. * - `-1` as the "nothing cached" sentinel collides with a real second (the last before the * epoch), reusing a stale prefix. `NaN` cannot collide. + * + * Caching per second is safe for the offset too: zones change offset on whole seconds. */ export const createIsoFormatter = (): ((ts: number) => string) => { let cachedSec = NaN; - let cachedPrefix = ''; + let head = ''; + let offset = ''; return (ts: number): string => { - if (!isNormalIsoTime(ts)) return new Date(ts).toISOString(); + if (!Number.isFinite(ts)) return localIso(ts); const sec = Math.floor(ts / 1000); if (sec !== cachedSec) { cachedSec = sec; - cachedPrefix = new Date(sec * 1000).toISOString().slice(0, 19); + const full = localIso(sec * 1000); + head = full.slice(0, -10); // drop '.000+HH:MM' + offset = full.slice(-6); } const ms = ts - sec * 1000; - return `${cachedPrefix}.${ms < 10 ? '00' : ms < 100 ? '0' : ''}${ms}Z`; + return `${head}.${ms < 10 ? '00' : ms < 100 ? '0' : ''}${ms}${offset}`; }; }; diff --git a/lib/si.ts b/lib/si.ts index 187e8e5..a35be3d 100644 --- a/lib/si.ts +++ b/lib/si.ts @@ -65,8 +65,9 @@ export function formatSiValue( // still be huge once converted to a physical unit far from the base (capacitance's // nF->F correction turns a normal ~0.001 nF resolution into 1e-12) — without this, // a genuine zero briefly renders as "0.00000000000000F" instead of "0". Mirrors - // formatEntryValue's own zero case (lib/passfail.ts). - if (value === 0) return { text: '0', unit: physicalUnitLabel(baseUnit) }; + // formatEntryValue's own zero case (lib/passfail.ts). The unit still gets a prefix: + // the meter tops out near 100 mF, so a bare "F" is never a real reading. + if (value === 0) return { text: '0', unit: siAxisScale(baseUnit, 0, resolution).unit }; const physicalValue = toPhysicalUnit(baseUnit, value); const prefix = siPrefixOf(physicalValue); const scale = Math.pow(10, prefix.exp); @@ -77,3 +78,23 @@ export function formatSiValue( const text = (physicalValue / scale).toFixed(decimals); return { text, unit: `${prefix.symbol}${physicalUnitLabel(baseUnit)}` }; } + +/** + * One shared prefix for a whole axis, chosen from `extent` (the largest base-unit magnitude + * on display), so neighbouring ticks never read `999.9 k` next to `1.000 M`. Decimals come + * from `resolution` exactly as in `formatSiValue`. Format a tick as + * `(toPhysicalUnit(baseUnit, v) / scale).toFixed(decimals)`. + */ +export function siAxisScale( + baseUnit: string, + extent: number, + resolution?: number, +): { scale: number; decimals: number; unit: string } { + // A zero extent has no magnitude; fall back to one base unit, so an all-zero capacitance + // axis reads nF (never a bare F) and every other unit keeps its unprefixed form. + const prefix = siPrefixOf(toPhysicalUnit(baseUnit, extent || 1)); + const scale = Math.pow(10, prefix.exp); + const decimals = + resolution === undefined ? 3 : resolutionDecimals(toPhysicalUnit(baseUnit, resolution) / scale); + return { scale, decimals, unit: `${prefix.symbol}${physicalUnitLabel(baseUnit)}` }; +} diff --git a/lib/tier.ts b/lib/tier.ts index 133ff76..e0548c9 100644 --- a/lib/tier.ts +++ b/lib/tier.ts @@ -178,16 +178,11 @@ export class RenderTier { continue; } // Both occupied: the surviving extreme keeps ITS OWN timestamp, not the column's. - if (this.#minV[b] < this.#minV[a]) { - this.#minV[j] = this.#minV[b]; this.#minTs[j] = this.#minTs[b]; - } else { - this.#minV[j] = this.#minV[a]; this.#minTs[j] = this.#minTs[a]; - } - if (this.#maxV[b] > this.#maxV[a]) { - this.#maxV[j] = this.#maxV[b]; this.#maxTs[j] = this.#maxTs[b]; - } else { - this.#maxV[j] = this.#maxV[a]; this.#maxTs[j] = this.#maxTs[a]; - } + // Ties keep `a`, the earlier column. + const lo = this.#minV[b] < this.#minV[a] ? b : a; + const hi = this.#maxV[b] > this.#maxV[a] ? b : a; + this.#minV[j] = this.#minV[lo]; this.#minTs[j] = this.#minTs[lo]; + this.#maxV[j] = this.#maxV[hi]; this.#maxTs[j] = this.#maxTs[hi]; this.#count[j] = ca + cb; } this.#count.fill(0, half); diff --git a/package-lock.json b/package-lock.json index ada0609..ab10f88 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,18 @@ { "name": "multimeter-live", - "version": "1.8.0", + "version": "2.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "multimeter-live", - "version": "1.8.0", + "version": "2.0.2", "license": "MIT", "dependencies": { "chart.js": "^4.5.1", "clsx": "^2.1.1", - "lucide-react": "^1.48.0", - "next": "16.3.6", + "lucide-react": "^1.51.0", + "next": "16.3.8", "react": "19.3.0", "react-dom": "19.3.0" }, @@ -22,7 +22,8 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "16.3.6", + "eslint-config-next": "16.3.8", + "fallow": "3.32.0", "tailwindcss": "^4.3.1", "typescript": "^5" }, @@ -460,6 +461,118 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fallow-cli/darwin-arm64": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@fallow-cli/darwin-arm64/-/darwin-arm64-3.32.0.tgz", + "integrity": "sha512-+h1q0byIelDXRUBFZvTaNNbFg6uGvXyv3G55drCxIHA4/FUk01QZjEb/N9qoj2ZcQiQ1NwJuz9U+YkJPYSXghw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@fallow-cli/darwin-x64": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@fallow-cli/darwin-x64/-/darwin-x64-3.32.0.tgz", + "integrity": "sha512-WZvKyrCc1FwsYWxYyhCCQs/tLIgRhlh70Se2BPwQuYc0slXOPwc6/oiAcdKc4lg0e3tDTQA6/nvQvVYVGYqObg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@fallow-cli/linux-arm64-gnu": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@fallow-cli/linux-arm64-gnu/-/linux-arm64-gnu-3.32.0.tgz", + "integrity": "sha512-hUvjjWxHSYocd/84Kk8CPe6Gf7WeD0+Zo/6/HUkTdKGknZk8H/zOqhiJGnJYwk0IRViaJci91qxhna5RejwP4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@fallow-cli/linux-arm64-musl": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@fallow-cli/linux-arm64-musl/-/linux-arm64-musl-3.32.0.tgz", + "integrity": "sha512-BQvTFPL6FYNGjmRSJEnrqZn+V7D3CtDYjmO8jbUwzui80bUUPSKNkdPBVZ804JoyQo69mCi8Mmjtd4wHKIPtdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@fallow-cli/linux-x64-gnu": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@fallow-cli/linux-x64-gnu/-/linux-x64-gnu-3.32.0.tgz", + "integrity": "sha512-h8ZdgCeo8eDKfexQNFTMykEFyCQLukPl4SRm73QpuDo18RYTjGtMYxtv50DgFiuhSxnHUkeV8voly7QfcJS8uw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@fallow-cli/linux-x64-musl": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@fallow-cli/linux-x64-musl/-/linux-x64-musl-3.32.0.tgz", + "integrity": "sha512-WRzZKv2rgGGgq6rvY94qgjiDlCKiDQliWoL0DmmVpXAoVt3E82Kv2SMlJn+T/pBcreifWrkqakC10aj+y3Y8qw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@fallow-cli/win32-arm64-msvc": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@fallow-cli/win32-arm64-msvc/-/win32-arm64-msvc-3.32.0.tgz", + "integrity": "sha512-6n9bzCcHVwpfniyscUs1NH014akO8YL4lTIEwT8XtGI3aYisGb/BF09nYY/eTMZMFRFDf+60WlNLzVjqdAdJmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@fallow-cli/win32-x64-msvc": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@fallow-cli/win32-x64-msvc/-/win32-x64-msvc-3.32.0.tgz", + "integrity": "sha512-IK123RccF6liIbWCs3fUyzdlMWi9AQdc7zivewpjtVQFqVZ+1g44hTkiOzKOL7GR0bRcIGh+56QlXTmmeDI6pQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1154,15 +1267,15 @@ } }, "node_modules/@next/env": { - "version": "16.3.6", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.6.tgz", - "integrity": "sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.8.tgz", + "integrity": "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.3.6", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.6.tgz", - "integrity": "sha512-jowwDX+7DOlDIjJLgTMxudw+k37QnWu1JkZLkSi9MaJBfDYcfhAPMKBhXL0idYzFN/AGg//axnOR4cLkHX/Rng==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.8.tgz", + "integrity": "sha512-eCR9RcLTZrVS+K3+VOhi0xMdUUGNuriCHHhU1ZoABC/QQ/Gp0OWbLKQxV2/9DkmihYdYRcQWFmZXW8M1AMUXYw==", "dev": true, "license": "MIT", "dependencies": { @@ -1203,9 +1316,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.3.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.6.tgz", - "integrity": "sha512-E/7GEqaUkt8mk/T8v9lAnrhzR06kdq1ZBkC12F8tAMkdIadwNp3H1KqHynDHrpcTlGCUdq/qu6vUL2aYVyYBdw==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.8.tgz", + "integrity": "sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==", "cpu": [ "arm64" ], @@ -1219,9 +1332,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.3.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.6.tgz", - "integrity": "sha512-yBE893/nDWTlaiBD1p+qgt7NUen4U5R6FXyH0s67Npq1S3E0cVSef1WIXC2xBRgQvwAvJq6DnS6Y6PrY0cy4Ew==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.8.tgz", + "integrity": "sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==", "cpu": [ "x64" ], @@ -1235,9 +1348,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.3.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.6.tgz", - "integrity": "sha512-KJDpjBqBPYlvkivmyrp+Qys6k/7ksbqGQvRVc6ZEGfR+cjQxx+nUkJaWmNZJsmoOrqYNbaXByF8wa0lBwDhB3Q==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.8.tgz", + "integrity": "sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==", "cpu": [ "arm64" ], @@ -1254,9 +1367,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.3.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.6.tgz", - "integrity": "sha512-mqNg2K+hvWskSRb/QM+Ix412DvBsuSF0XV+frTSw5vmoucNnIlynFwKYew8D01bfATErMOM7Bujrf0BA5DRKFA==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.8.tgz", + "integrity": "sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==", "cpu": [ "arm64" ], @@ -1273,9 +1386,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.3.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.6.tgz", - "integrity": "sha512-nFncBNGAYouRHjRVaITs9beZRfhX4ssVwpnvPIAbkZVH6LtGoAVlH4bJ8Cnf9SOo9bsXgPFer/GdHtEE3JNOkw==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.8.tgz", + "integrity": "sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==", "cpu": [ "x64" ], @@ -1292,9 +1405,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.3.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.6.tgz", - "integrity": "sha512-5Mf3cHDGR/Iz0ng2Bj3zUR3p5QS9YK3Hn2QiAfavFmyF48zwThAjpFoiTKNIcOHLYS4zEk+gzyJ/9deQ2ZB8yQ==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.8.tgz", + "integrity": "sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==", "cpu": [ "x64" ], @@ -1311,9 +1424,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.3.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.6.tgz", - "integrity": "sha512-0jkJy0C2kbrJWTk4YLa3xk80pVBpx8FCHJym7CnUfDAXe/FWv5qT7SQJbR0KuemyxaEDlEx5WT4VQJoTW+/9Qw==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.8.tgz", + "integrity": "sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==", "cpu": [ "arm64" ], @@ -1327,9 +1440,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.3.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.6.tgz", - "integrity": "sha512-/YXjI1e5OXcZ7YpxRwgP/1jAV/SBKTzeVKqN2mk7mLpcICsyn3Gl5+dIfDTJp70M0ccMhyMMRso4v6mPDCGepg==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.8.tgz", + "integrity": "sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==", "cpu": [ "x64" ], @@ -1722,9 +1835,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.20.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz", - "integrity": "sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==", + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", "dev": true, "license": "MIT", "dependencies": { @@ -1735,7 +1848,7 @@ "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2046,6 +2159,346 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", @@ -2940,7 +3393,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -3415,13 +3868,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.3.6", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.6.tgz", - "integrity": "sha512-1Upt3U7BDwU+ilpe2byZjAfts9oNq4d4fv/zXEvs8/4yS+cwOQW/WCxUNy8gCDquX67SzeehDvKblVC6ZBMocQ==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.8.tgz", + "integrity": "sha512-81vovwMe6NGnoFsl0KUJWzlS+y239i3fdsxs49gSEX3pDQW2cuKy+fxUPXNkQqqx6OCPheYPtBlSufc9aZ0k+w==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.3.6", + "@next/eslint-plugin-next": "16.3.8", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -3760,6 +4213,91 @@ "node": ">=0.10.0" } }, + "node_modules/fallow": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/fallow/-/fallow-3.32.0.tgz", + "integrity": "sha512-bBIykYqWClscRllE/g89qVYQjvOOSFz1z0hqJ/HT+zZoaaBLAKjtn2U23E90EvsXKMteTgyk54Vcy+juABxZuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-libc": "2.1.2" + }, + "bin": { + "fallow": "bin/fallow", + "fallow-lsp": "bin/fallow-lsp", + "fallow-mcp": "bin/fallow-mcp" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/fallow-rs" + }, + "optionalDependencies": { + "@fallow-cli/darwin-arm64": "3.32.0", + "@fallow-cli/darwin-x64": "3.32.0", + "@fallow-cli/linux-arm64-gnu": "3.32.0", + "@fallow-cli/linux-arm64-musl": "3.32.0", + "@fallow-cli/linux-x64-gnu": "3.32.0", + "@fallow-cli/linux-x64-musl": "3.32.0", + "@fallow-cli/win32-arm64-msvc": "3.32.0", + "@fallow-cli/win32-x64-msvc": "3.32.0", + "fallow-type-aware": "3.32.0" + } + }, + "node_modules/fallow-type-aware": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/fallow-type-aware/-/fallow-type-aware-3.32.0.tgz", + "integrity": "sha512-p1jFVESH7Y9DHlvgEn1xOeugiVgU1LbPnsTAYsXG9iykimYuof+r95eF5IqpBeR7Z42N527FNyU3OO/GHfXwVw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "typescript": "7.0.2" + }, + "bin": { + "fallow-type-aware": "fallow-type-aware.mjs" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fallow-type-aware/node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5191,12 +5729,18 @@ } }, "node_modules/lucide-react": { - "version": "1.48.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.48.0.tgz", - "integrity": "sha512-R0CIKY/fXiC6y9xRBADgsK+VW2p/pcTJOMhLZf1T+uG+vJUvyUR02nGOgmIIC7MPkiNK4Ox8QDnbqF8rJYWPZQ==", + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.51.0.tgz", + "integrity": "sha512-9929jimol5y7VbXUlVKcgURMSeIxFJB7dtWCkRAaNnF7KiCRkSkhmqWPUmAmUFYPezNawFk6qvz3Mu0gEnfI4g==", "license": "ISC", "peerDependencies": { + "@types/react": "*", "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/magic-string": { @@ -5315,12 +5859,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.3.6", - "resolved": "https://registry.npmjs.org/next/-/next-16.3.6.tgz", - "integrity": "sha512-L+otWM/aQbYTx98aZhgEoMb4bZAXx1YVW4UMA/vuCyCoWG5HJyZUili8QAkqzrcC+5///tsz3s0M+SlyB5bLMw==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.8.tgz", + "integrity": "sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==", "license": "MIT", "dependencies": { - "@next/env": "16.3.6", + "@next/env": "16.3.8", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -5334,14 +5878,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.3.6", - "@next/swc-darwin-x64": "16.3.6", - "@next/swc-linux-arm64-gnu": "16.3.6", - "@next/swc-linux-arm64-musl": "16.3.6", - "@next/swc-linux-x64-gnu": "16.3.6", - "@next/swc-linux-x64-musl": "16.3.6", - "@next/swc-win32-arm64-msvc": "16.3.6", - "@next/swc-win32-x64-msvc": "16.3.6", + "@next/swc-darwin-arm64": "16.3.8", + "@next/swc-darwin-x64": "16.3.8", + "@next/swc-linux-arm64-gnu": "16.3.8", + "@next/swc-linux-arm64-musl": "16.3.8", + "@next/swc-linux-x64-gnu": "16.3.8", + "@next/swc-linux-x64-musl": "16.3.8", + "@next/swc-win32-arm64-msvc": "16.3.8", + "@next/swc-win32-x64-msvc": "16.3.8", "sharp": "^0.35.4" }, "peerDependencies": { diff --git a/package.json b/package.json index 20a7752..b8d1359 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "multimeter-live", - "version": "1.8.0", + "version": "2.0.3", "private": true, "engines": { "node": ">=22.18.0" @@ -29,13 +29,14 @@ "dev": "next dev", "build": "next build && node scripts/finalize-pwa.mjs", "lint": "eslint", - "check": "node scripts/check-parser.mts && node scripts/check-settings.mts && node scripts/check-passfail.mts && node scripts/check-si.mts && node scripts/check-csv.mts && node scripts/check-search.mts && node scripts/check-samples.mts && node scripts/check-tier.mts" + "check": "node scripts/check-parser.mts && node scripts/check-settings.mts && node scripts/check-passfail.mts && node scripts/check-si.mts && node scripts/check-csv.mts && node scripts/check-search.mts && node scripts/check-samples.mts && node scripts/check-tier.mts && node scripts/check-capture.mts && node scripts/check-histogram.mts", + "fallow": "fallow audit" }, "dependencies": { "chart.js": "^4.5.1", "clsx": "^2.1.1", - "lucide-react": "^1.48.0", - "next": "16.3.6", + "lucide-react": "^1.51.0", + "next": "16.3.8", "react": "19.3.0", "react-dom": "19.3.0" }, @@ -45,7 +46,8 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "16.3.6", + "eslint-config-next": "16.3.8", + "fallow": "3.32.0", "tailwindcss": "^4.3.1", "typescript": "^5" } diff --git a/scripts/check-capture.mts b/scripts/check-capture.mts new file mode 100644 index 0000000..2a9b0ca --- /dev/null +++ b/scripts/check-capture.mts @@ -0,0 +1,245 @@ +// Self-check for lib/capture.ts. Run by `npm run check` (see docs/quality.md). +// +// Characterization: these assert what the engine does TODAY, including the accepted +// limitations in docs/limitations.md. A wrong capture rule records the wrong rows and the +// screen still looks fine, so this is the only place a regression in what gets recorded shows. + +import assert from 'node:assert/strict'; +import { CaptureEngine, type CaptureSettings } from '../lib/capture.ts'; +import { SampleStore, CHUNK, RETENTION_MS } from '../lib/samples.ts'; +import { resolveBand } from '../lib/passfail.ts'; +import type { Mode, Reading } from '../lib/parser.ts'; + +let checks = 0; +const eq = (a: unknown, b: unknown, m: string) => { assert.deepEqual(a, b, m); checks++; }; + +let clock = Date.now() - 60_000; +const rd = (mode: Mode, display: string, unit: string, ts = clock++): Reading => { + const ol = display === 'OL'; + return { mode, value: ol ? null : Number.parseFloat(display), display, unit, isOverload: ol, isMeasuring: false, ts }; +}; +const R = (d: string) => rd('RESISTANCE', d, 'KOM'); // "10.000" -> 10 000 Ω, LSD 1 Ω +const OL = () => rd('RESISTANCE', 'OL', 'KOM'); +const times = (n: number, f: () => T): T[] => Array.from({ length: n }, f); + +const BASE: CaptureSettings = { + stabilityCount: 2, hysteresisPct: 10, stableOnly: false, preserveOnModeChange: false, capNoPartFloor: 0.005, +}; +const fresh = (s: Partial = {}) => { + const store = new SampleStore(); + const e = new CaptureEngine(store); + e.configure({ ...BASE, ...s }); + e.setPassFailActive(true); + return { store, e }; +}; +const passFail = (e: CaptureEngine, ref: number, pct: number) => + e.setPassFailConfig(ref, pct, resolveBand(ref, pct, 'percent'), 'percent'); +// A 10 kΩ part already on the probes when Pass/Fail is set to 10 kΩ ±5%; the next R('10.000') captures. +const tenKOnView = () => { + const { e } = fresh(); + e.ingest([R('10.000')]); + passFail(e, 10_000, 5); + return e; +}; + +// --- first detection is not a mode change --------------------------------------------- +{ + const { e } = fresh(); + e.setTriggerArmed(true); e.setTriggerThreshold(5); + const r = e.ingest([rd('VOLTAGE', '10.00', 'V')]); + eq([r.unit, r.reset, r.modeReset], ['V', true, false], 'first reading: flush, no mode reset'); + eq(e.recording, true, 'a pre-typed threshold survives first detection and fires'); +} + +// --- stable run: band around the anchor, not pairwise --------------------------------- +{ + const { e } = fresh(); + eq(e.ingest([R('10.000')]).currentStable, false, 'one reading is not a run'); + eq(e.ingest([R('10.015')]).currentStable, true, '15 LSD from the anchor joins the run'); + eq(e.ingest([R('10.030')]).currentStable, false, '30 LSD from the ANCHOR restarts, though 15 from the previous'); +} + +// --- Data Log: recording gate, stats and counts behind the same gate ----------------- +{ + const { store, e } = fresh(); + e.ingest([R('10.000')]); + const idle = e.ingest(times(3, () => R('10.000'))); + eq([store.count, idle.touched, idle.stats], [0, false, null], 'nothing recorded while idle'); + e.setRecording(true, true); + const r = e.ingest([R('10.000'), R('10.002'), OL()]); + eq(store.count, 2, 'OL is never stored'); + eq([r.stats?.count, r.stats?.min, r.stats?.max, r.stats?.mean], [2, 10_000, 10_002, 10_001], 'Welford stats'); + eq([r.recordedResolution, r.dominantValue], [1, 10_000], 'resolution + dominant from logged readings'); + eq(e.ingest([OL()]).stats, null, 'a batch that appends nothing commits no stats'); +} + +// --- stable-only: first settled value, then only a >= 50% change --------------------- +{ + const { store, e } = fresh({ stableOnly: true }); + e.setRecording(true, true); + e.ingest(times(5, () => R('10.000'))); + eq(store.count, 1, 'a held part logs once'); + e.ingest([R('10.010'), R('10.010')]); + eq(store.count, 1, 'drift is not logged'); + e.ingest([OL(), R('10.000'), R('10.000')]); + eq(store.count, 2, 'OL re-arms the latch: the same value logs again'); + e.ingest([R('14.000'), R('14.000')]); + eq(store.count, 2, 'accepted: a second part within 50% and no probe lift is not logged'); + const r = e.ingest([R('16.000'), R('16.000')]); + eq([store.count, r.stats?.count, [...e.counts.values()].reduce((a, b) => a + b)], [3, 3, 3], + 'store, stats and histogram counts agree under the filter'); +} + +{ + const { store, e } = fresh({ stableOnly: true }); + e.ingest([R('10.000'), R('10.000'), R('10.000')]); + e.setRecording(true); + e.ingest([R('10.000')]); + eq(store.count, 0, 'starting a session does not inherit a run settled while idle'); + e.ingest([R('10.000')]); + eq(store.count, 1, 'it logs once its own run settles'); +} + +// --- Pass/Fail capture ---------------------------------------------------------------- +{ + const e = tenKOnView(); + const first = e.ingest([R('10.000')]); + eq([first.rowsChanged, first.verdicts.map((v) => v.verdict)], [true, ['PASS']], 'captured once stable'); + eq(e.ingest(times(4, () => R('10.001'))).verdicts.length, 0, 'a held part is not captured twice'); + e.ingest([OL(), R('50.000'), R('30.000'), R('10.000'), R('10.000')]); + eq(e.passFailRows.map((v) => v.verdict), ['PASS', 'PASS'], 'a probe sweeping through intermediates is not captured'); + e.ingest([OL(), R('12.000'), R('12.000')]); + eq(e.passFailRows.map((v) => v.verdict), ['PASS', 'PASS', 'FAIL'], 'a probe lift re-arms; out of band is FAIL'); + e.ingest([R('10.000'), R('10.000')]); + eq(e.passFailRows.length, 3, 'accepted: a re-seat within 50% without a lift is not re-captured'); + e.ingest([rd('RESISTANCE', '0.000', 'OM'), R('12.000'), R('12.000')]); + eq(e.passFailRows.length, 4, 'an exact zero re-arms and records nothing itself'); + e.clearVerdicts(); + const held = e.ingest([R('12.000'), R('12.000')]); + eq([e.passFailRows.length, held.rowsChanged], [0, false], 'Clear Batch does not re-capture a held part'); + e.ingest([rd('RESISTANCE', '5', ''), rd('RESISTANCE', '5', '')]); + eq(e.passFailRows.length, 0, 'an unrecognized unit is never judged against the reference'); +} +{ + const { store, e } = fresh(); + const C = (d: string) => rd('CAPACITANCE', d, 'nF'); + e.ingest([C('100.0')]); + e.setPassFailConfig(100e-9, 10, resolveBand(100e-9, 10, 'percent'), 'percent'); + e.setRecording(true, true); + e.ingest([C('100.0'), C('100.0'), C('0.004'), C('100.0'), C('100.0')]); + eq(e.passFailRows.length, 2, 'below the capacitance floor is a probe lift'); + eq(store.count, 4, 'and is never stored'); +} +{ + const { e } = fresh(); + e.ingest([rd('VOLTAGE', '1.000', 'V')]); + e.setPassFailConfig(1, 5, resolveBand(1, 5, 'percent'), 'percent'); + e.ingest(times(3, () => rd('VOLTAGE', '1.000', 'V'))); + eq(e.passFailRows.length, 0, 'unsupported mode captures nothing'); +} + +// --- Pass/Fail works only on its own view ------------------------------------------- +{ + const e = tenKOnView(); + e.ingest([R('10.000')]); + eq(e.passFailRows.length, 1, 'captured on the view'); + e.setPassFailActive(false); + const away = e.ingest([OL(), R('12.000'), R('12.000'), OL(), R('10.000'), R('10.000')]); + eq([away.verdicts.length, away.rowsChanged, e.passFailRows.length], [0, false, 1], + 'off the view: no verdict, no tone, stored rows untouched'); + eq(away.currentStable, true, 'stability is still tracked off the view'); + e.setPassFailActive(true); + e.ingest([R('10.000')]); + eq(e.passFailRows.length, 2, 'back on the view: a part swapped in while away is captured'); + e.ingest([R('10.000'), R('10.000')]); + eq(e.passFailRows.length, 2, 'and only once'); +} +{ + const e = tenKOnView(); + e.ingest([R('10.000')]); + e.setPassFailActive(false); + e.ingest([R('10.000'), R('10.000')]); + e.setPassFailActive(true); + e.ingest([R('10.000'), R('10.000')]); + eq(e.passFailRows.length, 1, 'a part captured before leaving, still held, is not captured again'); +} + +// --- trigger -------------------------------------------------------------------------- +{ + const { store, e } = fresh(); + e.ingest([R('1.000')]); + e.setRecording(true, true); + e.ingest([R('1.000'), R('1.000')]); + e.setRecording(false); + e.setTriggerArmed(true); e.setTriggerThreshold(5_000); + const r = e.ingest([R('1.000'), R('6.000'), R('6.000'), R('4.000'), R('6.000')]); + eq([r.recordingChanged, e.recording], [true, true], 'crossing starts a trigger-owned session'); + eq(store.count, 1, 'a re-trigger mid-batch flushes everything staged; the crossing sample is kept'); + eq(r.reset, true, 'the trigger edge resets the projections'); + e.ingest([R('4.600')]); + eq(e.recording, true, 'above the release level keeps recording'); + e.ingest([R('4.400')]); + eq(e.recording, false, 'below threshold x (1 - hysteresis) stops it'); + e.setTriggerArmed(false); e.setTriggerThreshold(5_000); + e.setRecording(true, true); + e.ingest([R('1.000')]); + eq(e.recording, true, 'a manual session is never auto-stopped'); +} + +// --- mode change ------------------------------------------------------------------------- +{ + const { store, e } = fresh(); + e.ingest([R('10.000')]); + passFail(e, 10_000, 5); + e.setTriggerArmed(true); e.setTriggerThreshold(50_000); + e.setRecording(true, true); + e.ingest([R('10.000'), R('10.000')]); + const r = e.ingest([R('10.000'), rd('VOLTAGE', '99.00', 'V'), rd('VOLTAGE', '99.00', 'V')]); + eq([r.unit, r.modeReset, r.reset, r.recordingChanged, r.rowsChanged], ['V', true, true, true, true], 'flags'); + eq([store.count, e.recording, e.passFailRows.length], [0, false, 0], 'flushed, stopped, verdicts cleared'); + eq(r.stats, null, 'nothing appended after the reset'); +} +{ + const { store, e } = fresh({ preserveOnModeChange: true }); + e.ingest([R('10.000')]); + e.setRecording(true, true); + const s0 = e.ingest([R('10.000'), R('10.000')]).sessionStart; + const r = e.ingest([R('10.000'), rd('VOLTAGE', '1.000', 'V')]); + eq(store.count, 3, 'old-unit rows, including the one staged this batch, are kept'); + eq(r.stats, null, 'rows flushed before the reset do not commit the zeroed stats'); + eq([e.chartFromSeq, r.sessionStart], [store.firstSeq + 3, null], 'watermark past them; no anchor yet'); + eq(s0 !== null, true, 'the first session had an anchor'); + e.setRecording(true, true); + const v = rd('VOLTAGE', '1.000', 'V'); + const r2 = e.ingest([v, rd('VOLTAGE', '1.000', 'V')]); + eq([store.count, r2.sessionStart], [5, v.ts], 'the anchor is the first NEW-unit sample'); +} + +// --- Pass/Fail rows follow "Keep log on mode change" ----------------------------------- +{ + const { e } = fresh({ preserveOnModeChange: true }); + const D = (d: string) => rd('DIODE', d, 'V'); + e.ingest([R('10.000')]); + passFail(e, 10_000, 5); + e.ingest([R('10.000')]); + const r = e.ingest([R('10.000'), D('0.650'), D('0.650')]); + eq([r.modeReset, r.rowsChanged, e.passFailRows.length], [true, false, 1], 'keep on: stored verdicts survive'); + eq(r.verdicts.length, 0, 'the old reference is still cleared: nothing judged in the new unit'); + passFail(e, 0.65, 5); + e.ingest([D('0.650')]); + eq(e.passFailRows.map((v) => v.mode), ['RESISTANCE', 'DIODE'], 'new-mode rows append after the kept ones'); + eq(new Set(e.passFailRows.map((v) => v.id)).size, 2, 'row ids stay unique across the change'); +} + +// --- retention ------------------------------------------------------------------------- +{ + const { store, e } = fresh(); + const old = Date.now() - RETENTION_MS - 60_000; + e.ingest([rd('RESISTANCE', '1.000', 'KOM', old)]); + e.setRecording(true, true); + const r = e.ingest(Array.from({ length: CHUNK + 1 }, (_, i) => rd('RESISTANCE', '1.000', 'KOM', old + i))); + eq([r.trimmed, r.touched, store.count], [true, true, 0], 'samples past retention are trimmed'); + eq(e.ingest([R('1.000')]).trimmed, false, 'recent samples are not'); +} + +console.log(`check-capture: ${checks} assertions passed`); diff --git a/scripts/check-csv.mts b/scripts/check-csv.mts index 8e5f973..fecb243 100644 --- a/scripts/check-csv.mts +++ b/scripts/check-csv.mts @@ -10,6 +10,9 @@ const { csvEsc, csvBlob, CSV_CHUNK_LINES, CHUNKS_PER_BLOB } = await import('../l let checks = 0; const HEADER = 'Timestamp,Mode,Value,Unit,Notes'; +// `blob.text()` strips a leading BOM, so the text asserts below cannot see it; sizes and the +// raw bytes can. +const BOM = '\uFEFF'; const mkLines = (n: number) => Array.from({ length: n }, (_, i) => `2026-09-23T00:00:00.000Z,Resistance,${i},OM,`); @@ -27,11 +30,15 @@ for (const n of [0, 1, 2, CSV_CHUNK_LINES - 2, CSV_CHUNK_LINES - 1, CSV_CHUNK_LI const expected = [HEADER, ...lines].join('\n'); const blob = csvBlob(HEADER, lines); assert.equal(await blob.text(), expected, `${n} lines: the assembled file is byte-identical`); - assert.equal(blob.size, Buffer.byteLength(expected), `${n} lines: byte length matches`); + assert.equal(blob.size, Buffer.byteLength(BOM + expected), `${n} lines: byte length matches`); assert.equal(blob.type, 'text/csv;charset=utf-8', `${n} lines: media type is set`); checks += 3; } +assert.deepEqual([...new Uint8Array(await csvBlob(HEADER, []).arrayBuffer()).slice(0, 3)], + [0xef, 0xbb, 0xbf], 'starts with the UTF-8 BOM, so Excel decodes it as UTF-8'); +checks++; + // No trailing newline — the property most likely to be "tidied up" by a later edit. assert.ok(!(await csvBlob(HEADER, mkLines(5)).text()).endsWith('\n'), 'no trailing newline'); assert.equal(await csvBlob(HEADER, []).text(), HEADER, 'header alone, nothing appended'); @@ -70,7 +77,7 @@ checks += 4; const blob = csvBlob(HEADER, lines); const expected = [HEADER, ...lines].join('\n'); assert.equal(await blob.text(), expected, 'non-ASCII round-trips'); - assert.equal(blob.size, Buffer.byteLength(expected, 'utf8'), 'size is UTF-8 bytes, not characters'); + assert.equal(blob.size, Buffer.byteLength(BOM + expected, 'utf8'), 'size is UTF-8 bytes, not characters'); assert.ok(blob.size > expected.length, 'multi-byte characters really are multi-byte'); checks += 3; } diff --git a/scripts/check-histogram.mts b/scripts/check-histogram.mts new file mode 100644 index 0000000..0e8b47d --- /dev/null +++ b/scripts/check-histogram.mts @@ -0,0 +1,65 @@ +// Self-check for lib/histogram.ts. Run by `npm run check` (see docs/quality.md). +// +// A wrong bin is silent: the chart still draws a tidy distribution, just not the one the +// meter measured. Everything goes through the one public `buildHistogram`. + +import assert from 'node:assert/strict'; +import { buildHistogram, type Entry } from '../lib/histogram.ts'; + +let checks = 0; +const eq = (a: unknown, b: unknown, m: string) => { assert.deepEqual(a, b, m); checks++; }; +const close = (a: number, b: number, m: string) => { assert.ok(Math.abs(a - b) < 1e-9, `${m}: got ${a}, want ${b}`); checks++; }; +const total = (h: ReturnType) => h.bins.reduce((s, b) => s + b.count, 0); +const inRange = (h: ReturnType) => h.bins.filter((b) => b.kind === 'in'); +const binOf = (h: ReturnType, v: number) => + h.bins.findIndex((b) => b.kind === 'in' && Math.abs(b.value - v) < h.width / 2); + +// --- totals: bars are counts per distinct value, summed (+= n), on every path ---------- +const spread: Entry[] = [[9.977, 50], [9.976, 10], [9.978, 7], [12.5, 1], [-3, 2]]; +const lsd = buildHistogram(spread, 0.001, 9.977); +eq(total(lsd), 70, 'LSD bars total the entry counts, outliers included'); +eq(total(buildHistogram(spread, undefined, undefined)), 70, 'equal-width bars total the entry counts'); + +// --- LSD grid: zero-anchored multiples of the width, adjacent values in adjacent bins ---- +const steps = inRange(lsd).map((b) => Math.round(b.value / lsd.width)); +eq(steps.every((k, i) => i === 0 || k === steps[i - 1] + 1), true, 'in-range bins are consecutive LSD multiples'); +const [a, b, c] = [9.976, 9.977, 9.978].map((v) => binOf(lsd, v)); +eq([b - a, c - b], [1, 1], '9.976 / 9.977 / 9.978 at 0.001 land in adjacent, distinct bins'); +eq([lsd.bins[a].count, lsd.bins[b].count, lsd.bins[c].count], [10, 50, 7], 'each LSD value counts into its own bin'); +// 0.29 / 0.01 is 28.999999999999996 in floats: snapping must round, not truncate. +const inexact = buildHistogram([[0.29, 3]], 0.01, 0.29); +eq(inexact.bins[binOf(inexact, 0.29)].count, 3, 'a value whose division is inexact still lands in its own bin'); + +// --- window: MIN_BINS around the centre; most-counted value when no centre is given ------ +const empty = buildHistogram([], 0.001, 9.977); +eq(empty.bins.length, 21, 'empty window shows exactly MIN_BINS bins'); +eq(binOf(empty, 9.977), 10, 'empty window is centred on the supplied centre'); +const noCentre = buildHistogram([[100, 1], [105, 9], [106, 3]], 1, undefined); +eq(noCentre.bins[10].value, 105, 'no centre -> centred on the most-counted value, not the first inserted'); + +// --- edge bins: beyond MAX_HALF_BINS collects into under/over; absent when nothing is out -- +eq(lsd.bins[0].kind, 'under', '-3 (far below) lands in the under-range bin'); +eq(lsd.bins[0].count, 2, 'under-range bin carries its count'); +eq(lsd.bins.at(-1)?.kind, 'over', '12.5 (far above) lands in the over-range bin'); +eq(lsd.bins.at(-1)?.count, 1, 'over-range bin carries its count'); +// The edge bins read `< lo` / `> hi`: the window bounds, 10 LSD either side of 9.977 here. +close(lsd.bins[0].value, 9.967, 'under-range bin is labeled with the window low bound'); +close(lsd.bins.at(-1)!.value, 9.987, 'over-range bin is labeled with the window high bound'); +const tight = buildHistogram([[9.977, 5], [9.99, 1]], 0.001, 9.977); +eq(tight.bins.some((x) => x.kind !== 'in'), false, 'no edge bins when all data is within the window'); +eq(binOf(tight, 9.99) >= 0, true, 'data within MAX_HALF_BINS widens the window instead of going to an edge bin'); + +// --- fallback: invalid LSD -> BIN_COUNT equal-width bins over min..max ------------------- +for (const w of [undefined, 0, NaN, Infinity, -1]) { + eq(buildHistogram(spread, w, 9.977).bins.length, 25, `binWidth ${w} -> equal-width fallback`); +} +const eqw = buildHistogram([[1, 1], [2, 1], [11, 4]], undefined, undefined); +eq(eqw.bins.at(-1)?.count, 4, 'max lands in the last bin, not off the end'); +eq(eqw.width, 10 / 25, 'equal-width width is the range over BIN_COUNT'); +close(eqw.bins[1].value, 1.4, 'equal-width bins are labeled with their left edge'); +close(eqw.bins[24].value, 10.6, 'the last equal-width bin starts one width below max'); +eq(buildHistogram([[3.3, 8]], undefined, undefined), { bins: [{ value: 3.3, count: 8, kind: 'in' }], width: 1 }, + 'min === max -> one bin carrying the whole total'); +eq(buildHistogram([], undefined, undefined).bins, [], 'no data, no LSD -> no bins'); + +console.log(`check-histogram: ${checks} assertions passed`); diff --git a/scripts/check-parser.mts b/scripts/check-parser.mts index 05f402e..7cb3760 100644 --- a/scripts/check-parser.mts +++ b/scripts/check-parser.mts @@ -145,6 +145,7 @@ checks += 3; assert.equal(displayUnit('OM'), '\u03A9', 'OM renders as the ohm symbol'); assert.equal(displayUnit('KOM'), 'k\u03A9', 'KOM renders as kilo-ohm'); assert.equal(displayUnit('MOM'), 'M\u03A9', 'MOM renders as mega-ohm'); +assert.equal(displayUnit('uF'), '\u00B5F', 'ASCII uF renders as µF, the spelling si.ts uses'); // Everything else passes through untouched — a unit with no prettier spelling must not // be silently dropped or blanked. assert.equal(displayUnit('V'), 'V', 'volts pass through'); @@ -156,6 +157,6 @@ assert.equal(parseMeasurement('RESISTANCE', 'Resistance:', '009.79 OM').unit, 'O 'Reading.unit keeps the wire spelling'); assert.equal(normalizeReading(parseMeasurement('RESISTANCE', 'Resistance:', '009.79 KOM')).baseUnit, 'OM', 'normalizeReading still keys off the wire spelling'); -checks += 9; +checks += 10; console.log(`check-parser: ${checks} assertions passed`); diff --git a/scripts/check-passfail.mts b/scripts/check-passfail.mts index 48a0cc3..dfe4575 100644 --- a/scripts/check-passfail.mts +++ b/scripts/check-passfail.mts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { parseSiValue, entryToBase, ENTRY_UNITS, formatEntryValue, isPlausibleReference, isSupportedMode, resolveBand, isBandTooWide, judge, SUPPORTED_MODES, - parseSiEntry, resolveAbsoluteTolerance, + parseSiEntry, resolveAbsoluteTolerance, verdictCsvLine, summarizeBatch, type VerdictRow, } from '../lib/passfail.ts'; let checks = 0; @@ -178,4 +178,44 @@ close(parseSiValue('9' + '0'.repeat(20) + 'G'), 9e29, 'a large but finite value close(parseSiValue('-4k7'), -4700, 'negative infix'); close(parseSiValue('+4k7'), 4700, 'explicitly positive infix'); +// --- verdict CSV: the meter's own unit per row (as the Data Log), no float noise ---------- +const vrow = ( + mode: VerdictRow['mode'], baseValue: number, baseReference: number, + unit: string, decimals: number, tol = '1%', +): VerdictRow => ({ + id: 0, ts: 0, iso: 'T', mode, baseValue, baseReference, baseBand: 0, + toleranceMode: tol.endsWith('%') ? 'percent' : 'absolute', + toleranceValue: Number.parseFloat(tol), verdict: 'FAIL', deviation: baseValue - baseReference, + resolution: null, unit, decimals, +}); +const csvCols = (r: VerdictRow) => verdictCsvLine(r).split(','); +// the bench report: the table showed -11.1, the file -11.100000000000023 +eq(verdictCsvLine(vrow('RESISTANCE', 9988.9, 10_000, 'OM', 1)), 'T,RESISTANCE,9988.9,OM,10000,1%,-11.1,FAIL', 'deviation without noise'); +// cancellation: rounding the RESULT to significant digits cannot fix this one +eq(csvCols(vrow('RESISTANCE', 10_000.001, 10_000, 'OM', 3))[6], '0.001', 'near-equal operands'); +eq(verdictCsvLine(vrow('RESISTANCE', 9988.9, 10_000, 'KOM', 4)), 'T,RESISTANCE,9.9889,KOM,10,1%,-0.0111,FAIL', + 'kΩ range: Measured, Reference and Deviation all in the meter\'s unit'); +eq(csvCols(vrow('RESISTANCE', 3_000_000, 3_300_000, 'MOM', 3)).slice(2, 5).join(' '), '3.000 MOM 3.3', + 'MΩ range: 3.000 as the meter showed it, not 3000000'); +const cap = csvCols(vrow('CAPACITANCE', 470, 470, 'nF', 1)); +eq([cap[2], cap[3], cap[4], cap[6]].join(' '), '470.0 nF 470 0', 'capacitance in nF keeps the meter\'s trailing zero'); +const uf = csvCols(vrow('CAPACITANCE', 4700.1, 4700, 'uF', 4)); +eq([uf[2], uf[3], uf[4], uf[6]].join(' '), '4.7001 uF 4.7 0.0001', 'µF range: no nF -> F division noise'); +eq(csvCols(vrow('DIODE', 0.652, 0.65, 'V', 3, '0.01'))[5], '±10 mV', 'absolute tolerance column unchanged'); + +// --- batch summary: yield over every row, spread over the trailing run in the live mode --- +const sr = (mode: VerdictRow['mode'], v: number, verdict: 'PASS' | 'FAIL', resolution: number | null) => + ({ ...vrow(mode, v, v, 'OM', 0), verdict, resolution }); +const batch = [ + sr('RESISTANCE', 100, 'PASS', 1), sr('DIODE', 0.6, 'FAIL', 0.001), + sr('RESISTANCE', 10, 'PASS', 0.1), sr('RESISTANCE', 30, 'FAIL', 0.01), +]; +const sum = summarizeBatch(batch, 'RESISTANCE')!; +eq(`${sum.passed}/${sum.failed}/${sum.yieldPct}`, '2/2/50', 'yield counts every mode'); +eq(sum.stats?.count, 2, 'spread stops at the earlier DIODE row: the 100 Ω row is excluded'); +close(sum.stats!.mean, 20, 'mean of the trailing run'); +eq(sum.resolution, 0.1, 'coarsest LSD of the run, not of the whole batch'); +eq(summarizeBatch(batch, 'CAPACITANCE')?.stats, null, 'live mode has no rows -> no spread, yield kept'); +eq(summarizeBatch([], 'RESISTANCE'), null, 'empty batch'); + console.log(`check-passfail: ${checks} assertions passed`); diff --git a/scripts/check-search.mts b/scripts/check-search.mts index 5850352..b9714ca 100644 --- a/scripts/check-search.mts +++ b/scripts/check-search.mts @@ -5,14 +5,18 @@ // An earlier version of this file sampled only years 2019-2033 and an alphabet without `+`, // which made the expanded-year ISO form (`+010000-…`) invisible to it. The corpora below // deliberately straddle both forms. -import { couldMatchIso, couldMatchValue, createIsoFormatter } from '../lib/search.ts'; +import { couldMatchIso, couldMatchValue, createIsoFormatter, localIso } from '../lib/search.ts'; -// Boundaries of the 24-character ISO form. Declared here, not exported from lib/search.ts: -// nothing in the app needs them, and exporting internals just so a check can reach them is -// how a module ends up with an API shaped by its tests. +// UTC bounds of the 4-digit-year form, used as corpus points: in a local zone the switch to the +// expanded form sits an offset away from them, so the points just around them straddle it. const NORMAL_MAX = 253402300800000; const NORMAL_MIN = -62167219200000; +// The output depends on the machine's zone, so every timestamp section runs in several: +// UTC (`+00:00`), DST either side of UTC, half-hour (St John's, -03:30) and 45-minute +// (Kathmandu +05:45, Chatham +12:45) offsets. Node re-reads TZ on assignment. +const ZONES = ['UTC', 'Europe/Kyiv', 'America/St_Johns', 'Asia/Kathmandu', 'Pacific/Chatham']; + let pass = 0; let fail = 0; const ok = (cond: boolean, what: string) => { @@ -34,13 +38,18 @@ for (let i = 0; i < 120; i++) { timestamps.push(NORMAL_MAX + i * 31557600000); timestamps.push(NORMAL_MIN - i * 31557600000); } -const isos = timestamps.map((t) => new Date(t).toISOString().toLowerCase()); -ok(isos.some((s) => s.length === 24), 'corpus contains the normal 24-char ISO form'); -ok(isos.some((s) => s.length === 27), 'corpus contains the EXPANDED 27-char ISO form'); +const isos: string[] = []; +for (const tz of ZONES) { + process.env.TZ = tz; + for (const t of timestamps) isos.push(localIso(t).toLowerCase()); +} +ok(isos.some((s) => s.length === 29), 'corpus contains the normal 29-char form'); +ok(isos.some((s) => s.length === 32), 'corpus contains the EXPANDED 32-char form'); +ok(isos.some((s) => s.endsWith('-03:30')) && isos.some((s) => s.endsWith('+05:45')), 'corpus has negative and 45-minute offsets'); ok(isos.some((s) => s.startsWith('+')), 'corpus contains a `+`-signed expanded year'); ok(isos.some((s) => s.startsWith('-')), 'corpus contains a `-`-signed expanded year'); -// ---- 1. SOUNDNESS: every substring of a real ISO string must be admitted ---------------- +// ---- 1. SOUNDNESS: every substring of a real local-ISO string must be admitted ---------------- // This is the direction that loses data, so it is exhaustive over the substrings of every // string in the corpus above (the corpus itself is sampled; the substrings are not). let subs = 0; @@ -57,7 +66,7 @@ for (const iso of isos) { // ---- 2. COMPLETENESS: exhaustively enumerate short queries; a rejection must be genuine -- // Every string of length <= 3 over the full ISO alphabet INCLUDING `+`. Checked against the // set of real corpus substrings, so a guard that got too clever fails here. -const ALPHABET = '0123456789-:.tz+'; +const ALPHABET = '0123456789-:.tz+'; // `z` kept: it must now be rejected const corpusSubs = new Set(); for (const iso of isos) { for (let a = 0; a < iso.length; a++) for (let b = a + 1; b <= Math.min(iso.length, a + 4); b++) corpusSubs.add(iso.slice(a, b)); @@ -76,12 +85,13 @@ const walk = (prefix: string) => { walk(''); // ---- 3. The cases that motivated the guard ---------------------------------------------- -ok(couldMatchIso('zzz') === false, "'zzz' rejected (one Z per template, and it is last)"); +ok(couldMatchIso('z') === false, "'z' rejected (local form has an offset, never Z)"); +ok(couldMatchIso('+03:00') === true, "'+03:00' admitted (an offset)"); +ok(couldMatchIso('-03:30') === true, "'-03:30' admitted (a negative half-hour offset)"); ok(couldMatchIso('tt') === false, "'tt' rejected (single T)"); ok(couldMatchIso('voltage') === false, "'voltage' rejected"); ok(couldMatchIso('mv') === false, "'mv' rejected"); ok(couldMatchIso('calibration') === false, "'calibration' rejected"); -ok(couldMatchIso('z') === true, "'z' admitted"); ok(couldMatchIso('t10:') === true, "'t10:' admitted"); ok(couldMatchIso('2026-09-18') === true, 'full date admitted'); ok(couldMatchIso('5.00') === true, "'5.00' admitted"); @@ -91,8 +101,9 @@ ok(couldMatchIso('+') === true, "'+' admitted (expanded-year sign)"); ok(couldMatchIso('-053243') === true, "'-053243' admitted (occurs in an expanded year)"); ok(couldMatchIso('+010000') === true, "'+010000' admitted"); ok(couldMatchIso('271821') === true, 'a 6-digit run admitted (expanded years are 6 digits)'); -ok(couldMatchIso('2026-09-24t10:33:01.123z') === true, 'a whole normal timestamp admitted'); -ok(couldMatchIso('+010000-01-01t00:00:00.000z') === true, 'a whole EXPANDED timestamp admitted'); +ok(couldMatchIso('2026-09-24t10:33:01.123+03:00') === true, 'a whole normal timestamp admitted'); +ok(couldMatchIso('+010000-01-01t00:00:00.000-03:30') === true, 'a whole EXPANDED timestamp admitted'); +ok(couldMatchIso('2026-09-24t10:33:01.123z') === false, 'the old UTC `Z` form is no longer produced, so rejected'); ok(couldMatchIso('x'.repeat(40)) === false, 'longer than both templates rejected'); // Case-insensitivity: the caller lowercases, but nothing enforces it, so it normalizes. ok(couldMatchIso('T') === true, "uppercase 'T' admitted (input is normalized)"); @@ -130,8 +141,11 @@ ok(couldMatchValue('') === true, 'empty admits everything'); ok((1e21).toFixed(2) === '1e+21', 'toFixed DOES fall back to exponential above 1e21'); ok(couldMatchValue('1e+21') === false, 'exponential form rejected — outside the documented domain'); -// ---- 5. createIsoFormatter must equal toISOString() for every timestamp ------------------ -// The cached prefix is an optimization; a divergence here silently mis-renders a row AND +// ---- 5. localIso is the local wall clock; createIsoFormatter equals it for every timestamp -- +// Independent reference for localIso: shift by the zone's offset and read the UTC form. Only +// for modern timestamps — V8 rounds pre-1900 local-mean-time offsets to whole minutes while +// the getters keep the seconds, so there the two legitimately differ. +// The cached formatter is an optimization; a divergence silently mis-renders a row AND // silently changes which rows a timestamp query matches. Two real bugs lived here: `ts % // 1000` going negative pre-1970, and `-1` as a sentinel colliding with a real second. let isoChecks = 0; @@ -144,22 +158,40 @@ const adversarial: number[] = [ for (const anchorTs of [0, -1, -1000, 1000, Date.now(), NORMAL_MIN]) { for (let k = -1200; k <= 1200; k += 137) adversarial.push(anchorTs + k); } -for (const order of ['asc', 'desc'] as const) { - const list = order === 'asc' ? [...adversarial].sort((a, b) => a - b) : [...adversarial].sort((a, b) => b - a); - // ONE formatter across the whole sequence — a fresh one per call would hide cache bugs. - const isoOf = createIsoFormatter(); - for (const ts of list) { +// Across a DST switch (Kyiv, 2026-03-29 01:00 UTC): the offset changes between cached seconds. +const dst = Date.UTC(2026, 2, 29, 1, 0, 0); +for (let k = -2500; k <= 2500; k += 333) adversarial.push(dst + k); +for (const tz of ZONES) { + process.env.TZ = tz; + for (const ts of [dst - 1, dst, Date.UTC(2026, 9, 3, 14, 29, 5, 123), Date.now()]) { + const off = -new Date(ts).getTimezoneOffset(); + const sign = off < 0 ? '-' : '+'; + const ref = new Date(ts + off * 60000).toISOString().slice(0, -1) + sign + + `${String(Math.floor(Math.abs(off) / 60)).padStart(2, '0')}:${String(Math.abs(off) % 60).padStart(2, '0')}`; isoChecks++; - ok(isoOf(ts) === new Date(ts).toISOString(), `createIsoFormatter()(${ts}) must equal toISOString() (${order})`); + ok(localIso(ts) === ref, `localIso(${ts}) in ${tz}: ${localIso(ts)} must equal ${ref}`); + } + for (const order of ['asc', 'desc'] as const) { + const list = order === 'asc' ? [...adversarial].sort((a, b) => a - b) : [...adversarial].sort((a, b) => b - a); + // ONE formatter across the whole sequence — a fresh one per call would hide cache bugs. + const isoOf = createIsoFormatter(); + for (const ts of list) { + isoChecks++; + ok(isoOf(ts) === localIso(ts), `createIsoFormatter()(${ts}) must equal localIso() (${order}, ${tz})`); + } + } + // Interleaving distant timestamps must not let a stale prefix survive. + const mixed = createIsoFormatter(); + for (let i = 0; i < 400; i++) { + const ts = i % 2 === 0 ? -1 - (i % 5) : Date.now() + i; + isoChecks++; + ok(mixed(ts) === localIso(ts), `interleaved createIsoFormatter()(${ts}) must equal localIso() (${tz})`); } } -// Interleaving distant timestamps must not let a stale prefix survive. -const mixed = createIsoFormatter(); -for (let i = 0; i < 400; i++) { - const ts = i % 2 === 0 ? -1 - (i % 5) : Date.now() + i; - isoChecks++; - ok(mixed(ts) === new Date(ts).toISOString(), `interleaved createIsoFormatter()(${ts}) must equal toISOString()`); -} +process.env.TZ = 'Europe/Kyiv'; +ok(localIso(Date.UTC(2026, 9, 3, 14, 29, 5, 123)) === '2026-10-03T17:29:05.123+03:00', 'the motivating case: 14:29 UTC is 17:29 in Kyiv'); +process.env.TZ = 'UTC'; +ok(localIso(0) === '1970-01-01T00:00:00.000+00:00', 'UTC spells +00:00, never Z'); console.log( `check-search: ${pass} assertions passed (${subs} ISO substrings, ${enumerated} enumerated queries, ` + diff --git a/scripts/check-si.mts b/scripts/check-si.mts index 46f0c68..f7b71e3 100644 --- a/scripts/check-si.mts +++ b/scripts/check-si.mts @@ -1,6 +1,6 @@ // Self-check for lib/si.ts. Run: `node scripts/check-si.mts` import assert from 'node:assert/strict'; -import { formatSiValue, physicalUnitLabel, siPrefixOf, toPhysicalUnit } from '../lib/si.ts'; +import { formatSiValue, physicalUnitLabel, siAxisScale, siPrefixOf, toPhysicalUnit } from '../lib/si.ts'; let checks = 0; const eq = (a: unknown, b: unknown, m: string) => { assert.equal(a, b, m); checks++; }; @@ -48,8 +48,8 @@ eq( ); eq( JSON.stringify(formatSiValue('nF', 0, 0.001)), - JSON.stringify({ text: '0', unit: 'F' }), - 'a real zero (e.g. P2P before a 2nd sample) never inflates into a long decimal string', + JSON.stringify({ text: '0', unit: 'nF' }), + 'a real zero (e.g. P2P before a 2nd sample): short text, and a prefixed unit, never bare F', ); // --- no rounding across a prefix boundary: the prefix comes from the TRUE magnitude, --- @@ -65,4 +65,23 @@ eq( 'coarse resolution: rounds to look like the next decade but stays at the lower prefix', ); +// --- siAxisScale: ONE prefix per axis, from the extent, so a straddling axis never mixes --- +eq( + JSON.stringify(siAxisScale('OM', 1_000_100, 100)), + JSON.stringify({ scale: 1e6, decimals: 4, unit: 'MΩ' }), + 'straddling 999.9k..1.0001M: the extent picks M, and 999_900 then reads 0.9999, not 999.9 k', +); +eq( + JSON.stringify(siAxisScale('nF', 4700, 1)), + JSON.stringify({ scale: 1e-6, decimals: 3, unit: 'µF' }), + 'capacitance axis is in farads: 4700 nF extent -> µF at 1 nF = 0.001 µF resolution', +); +eq(siAxisScale('OM', 11_113_000).decimals, 3, 'unknown resolution -> flat 3 decimals'); +eq( + JSON.stringify(siAxisScale('nF', 0, 0.001)), + JSON.stringify({ scale: 1e-9, decimals: 3, unit: 'nF' }), + 'all-zero capacitance axis: one base unit (nF), not bare F with 12 decimals', +); +eq(formatSiValue('OM', 0, 0.1).unit, 'Ω', 'zero ohms keep the plain unit, not the resolution\'s mΩ (Statistics P2P and the chart axis share this path)'); + console.log(`check-si: ${checks} assertions passed`); diff --git a/scripts/check-tier.mts b/scripts/check-tier.mts index c0770e5..cabbec9 100644 --- a/scripts/check-tier.mts +++ b/scripts/check-tier.mts @@ -19,20 +19,11 @@ let checks = 0; interface S { ts: number; v: number } -/** Brute-force reference: bucket by hand, keep min/max and the timestamp each occurred at. */ -function reference( - samples: S[], - t0: number, - bucketMs: number, - columns: number, - clampMin?: number, - clampMax?: number, -): TierPoint[] { +/** Bucket by hand: per column, the min and max and the timestamp each occurred at. */ +function columnExtremes(samples: S[], t0: number, bucketMs: number) { const cols = new Map(); for (const { ts, v } of samples) { - let i = Math.floor((ts - t0) / bucketMs); - if (i < 0) i = 0; - assert.ok(i < columns, 'reference: sample outside the tier — widen bucketMs in the test'); + const i = Math.max(0, Math.floor((ts - t0) / bucketMs)); const b = cols.get(i); if (!b) cols.set(i, { lo: v, hi: v, loTs: ts, hiTs: ts }); else { @@ -40,6 +31,19 @@ function reference( if (v > b.hi) { b.hi = v; b.hiTs = ts; } } } + return cols; +} + +/** Brute-force reference of the tier's output, built on columnExtremes(). */ +function reference( + samples: S[], + t0: number, + bucketMs: number, + columns: number, + clampMin?: number, + clampMax?: number, +): TierPoint[] { + const cols = columnExtremes(samples, t0, bucketMs); const clamp = (v: number) => { if (clampMax !== undefined && v > clampMax) return clampMax; if (clampMin !== undefined && v < clampMin) return clampMin; @@ -47,6 +51,7 @@ function reference( }; const out: TierPoint[] = []; for (const i of [...cols.keys()].sort((a, b) => a - b)) { + assert.ok(i < columns, 'reference: sample outside the tier — widen bucketMs in the test'); const b = cols.get(i)!; const loY = clamp(b.lo); const hiY = clamp(b.hi); @@ -67,16 +72,7 @@ function reference( const everyExtremeSurvives = ( samples: S[], pts: TierPoint[], t0: number, bucketMs: number, where: string, ) => { - const cols = new Map(); - for (const { ts, v } of samples) { - const i = Math.max(0, Math.floor((ts - t0) / bucketMs)); - const b = cols.get(i); - if (!b) cols.set(i, { lo: v, hi: v, loTs: ts, hiTs: ts }); - else { - if (v < b.lo) { b.lo = v; b.loTs = ts; } - if (v > b.hi) { b.hi = v; b.hiTs = ts; } - } - } + const cols = columnExtremes(samples, t0, bucketMs); const has = (x: number, y: number) => pts.some((p) => p.x === x && p.y === y); for (const b of cols.values()) { assert.ok(has(b.loTs, b.lo), `${where}: column minimum ${b.lo}@${b.loTs} survived`); @@ -93,6 +89,17 @@ const samePoints = (a: TierPoint[], b: TierPoint[], where: string) => { } }; +/** A tier grown from 1 ms by doublings, and one built directly at the width it grew to. */ +function grownAndDirect(columns: number, samples: S[], t0: number) { + const grown = new RenderTier(columns); + grown.reset(t0, 1); // absurdly narrow on purpose: forces many doublings + for (const s of samples) grown.add(s.ts, s.v); + const direct = new RenderTier(columns); + direct.reset(t0, grown.bucketMs); + for (const s of samples) direct.add(s.ts, s.v); + return { grown, direct }; +} + /** Deterministic pseudo-random, so a failure is reproducible. */ function makeSamples(n: number, t0: number, stepMs: number): S[] { let seed = 12345; @@ -110,15 +117,9 @@ function makeSamples(n: number, t0: number, stepMs: number): S[] { const t0 = 1_000_000; const samples = makeSamples(400, t0, 33); - const grown = new RenderTier(COLUMNS); - grown.reset(t0, 1); // absurdly narrow on purpose: forces many doublings - for (const s of samples) grown.add(s.ts, s.v); + const { grown, direct } = grownAndDirect(COLUMNS, samples, t0); assert.ok(grown.bucketMs > 4, `at least two doublings happened (bucketMs=${grown.bucketMs})`); - const direct = new RenderTier(COLUMNS); - direct.reset(t0, grown.bucketMs); - for (const s of samples) direct.add(s.ts, s.v); - samePoints(grown.points(), direct.points(), 'grown vs direct'); assert.equal(grown.t0, direct.t0, 'doubling does not move t0'); samePoints(grown.points(), reference(samples, t0, grown.bucketMs, COLUMNS), 'grown vs reference'); @@ -133,13 +134,7 @@ function makeSamples(n: number, t0: number, stepMs: number): S[] { for (const n of [1, 2, 17, 500, 3000]) { const t0 = 500_000; const samples = makeSamples(n, t0, 17); - const grown = new RenderTier(COLUMNS); - grown.reset(t0, 1); - for (const s of samples) grown.add(s.ts, s.v); - - const direct = new RenderTier(COLUMNS); - direct.reset(t0, grown.bucketMs); - for (const s of samples) direct.add(s.ts, s.v); + const { grown, direct } = grownAndDirect(COLUMNS, samples, t0); samePoints(grown.points(), direct.points(), `grown vs direct (cols=${COLUMNS}, n=${n})`); samePoints( grown.points(),