From 8b2d512c94e9ed56f5da220ac5895bc377c80532 Mon Sep 17 00:00:00 2001 From: Hugh Kaznowski Date: Mon, 7 Sep 2026 10:44:07 +0100 Subject: [PATCH] Extract shared useRangeSelection hook from App.tsx's three page variants StandaloneRanged, StandaloneLog, and FullApp each reimplemented the same range/preset state machine (range/activePreset state, a rangeRef synced via effect, selectPreset/handleManualRangeSelect, and rolling-preset re-resolution at the top of the poll loop). Move that bookkeeping into webapp/src/lib/useRangeSelection.ts; each component keeps only its own polling logic. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ --- webapp/src/App.tsx | 114 +++++----------------------- webapp/src/lib/useRangeSelection.ts | 48 ++++++++++++ 2 files changed, 65 insertions(+), 97 deletions(-) create mode 100644 webapp/src/lib/useRangeSelection.ts diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index a966e43..9cdef8a 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -8,7 +8,7 @@ import { saveLogSortSettings, type LogSortSettings, } from './components/LogTimeline' -import { TIME_RANGE_PRESETS, computePresetRange, type TimeRange, type TimeRangePresetId } from './lib/timeRange' +import { TIME_RANGE_PRESETS, type TimeRange, type TimeRangePresetId } from './lib/timeRange' import { LogPreview } from './components/LogPreview' import { Params } from './components/Params' import { Metrics } from './components/Metrics' @@ -26,6 +26,7 @@ import { type TimelineBucket, } from './lib/api' import { DocsNavProvider } from './lib/docsNav' +import { useRangeSelection } from './lib/useRangeSelection' import { useSettingsPanel } from './lib/useSettingsPanel' import { POLL_MS } from './lib/polling' @@ -186,47 +187,18 @@ function StandaloneRanged({ initialFixedRange: TimeRange | null }) { const [buckets, setBuckets] = useState([]) - const [range, setRange] = useState( - initialPresetId ? computePresetRange(initialPresetId) : initialFixedRange, - ) // No preset and no fixed range means the popout was opened with nothing carried over (e.g. a bare URL) — default to 'all', matching FullApp's own default. - const [activePreset, setActivePreset] = useState( - initialPresetId ?? (initialFixedRange ? null : 'all'), - ) - - const rangeRef = useRef(range) - useEffect(() => { - rangeRef.current = range - }, [range]) - - function selectPreset(id: TimeRangePresetId) { - setActivePreset(id) - setRange(computePresetRange(id)) - } - - function handleManualRangeSelect(next: TimeRange | null) { - if (next === null) { - selectPreset('all') - } else { - setActivePreset(null) - setRange(next) - } - } + const { range, activePreset, selectPreset, handleManualRangeSelect, resolveEffectiveRange } = useRangeSelection({ + preset: initialPresetId ?? (initialFixedRange ? null : 'all'), + range: initialFixedRange, + }) useEffect(() => { let cancelled = false async function poll() { // Rolling presets re-resolve on this same polling cadence instead of freezing the window at selection time — this is what makes a carried-over preset independently slide rather than track the source tab. - const effectiveRange = - activePreset && activePreset !== 'all' ? computePresetRange(activePreset) : rangeRef.current - if (activePreset && activePreset !== 'all') { - setRange((current) => - current && effectiveRange && current.startMs === effectiveRange.startMs && current.endMs === effectiveRange.endMs - ? current - : effectiveRange, - ) - } + const effectiveRange = resolveEffectiveRange() try { const nextBuckets = await fetchTimeline({ startMs: effectiveRange?.startMs, @@ -295,8 +267,10 @@ function StandaloneLog() { const [logs, setLogs] = useState([]) const [params, setParams] = useState([]) const [buckets, setBuckets] = useState([]) - const [range, setRange] = useState(null) - const [activePreset, setActivePreset] = useState('all') + const { range, activePreset, selectPreset, handleManualRangeSelect, resolveEffectiveRange } = useRangeSelection({ + preset: 'all', + range: null, + }) const [filter, setFilter] = useState('') const [filterError, setFilterError] = useState(null) const [sortSettings, setSortSettings] = useState(loadLogSortSettings) @@ -307,11 +281,6 @@ function StandaloneLog() { saveLogSortSettings(next) } - const rangeRef = useRef(range) - useEffect(() => { - rangeRef.current = range - }, [range]) - useEffect(() => { fetchParamDeclarations() .then(setParams) @@ -320,33 +289,11 @@ function StandaloneLog() { }) }, []) - function selectPreset(id: TimeRangePresetId) { - setActivePreset(id) - setRange(computePresetRange(id)) - } - - function handleManualRangeSelect(next: TimeRange | null) { - if (next === null) { - selectPreset('all') - } else { - setActivePreset(null) - setRange(next) - } - } - useEffect(() => { let cancelled = false async function poll() { - const effectiveRange = - activePreset && activePreset !== 'all' ? computePresetRange(activePreset) : rangeRef.current - if (activePreset && activePreset !== 'all') { - setRange((current) => - current && effectiveRange && current.startMs === effectiveRange.startMs && current.endMs === effectiveRange.endMs - ? current - : effectiveRange, - ) - } + const effectiveRange = resolveEffectiveRange() try { const [nextBuckets, nextLogs, nextParams] = await Promise.all([ fetchTimeline({ @@ -436,8 +383,10 @@ function FullApp() { const [logs, setLogs] = useState([]) const [params, setParams] = useState([]) const [buckets, setBuckets] = useState([]) - const [range, setRange] = useState(null) - const [activePreset, setActivePreset] = useState('all') + const { range, activePreset, selectPreset, handleManualRangeSelect, resolveEffectiveRange } = useRangeSelection({ + preset: 'all', + range: null, + }) const [filter, setFilter] = useState('') const [filterError, setFilterError] = useState(null) const [logViewerCollapsed, setLogViewerCollapsed] = useSectionCollapsed('log-viewer') @@ -461,12 +410,6 @@ function FullApp() { }) } - // Read via ref inside the poll loop instead of depending on `range` directly, which would restart the interval every rolling-preset tick. - const rangeRef = useRef(range) - useEffect(() => { - rangeRef.current = range - }, [range]) - async function loadParams() { try { setParams(await fetchParamDeclarations()) @@ -479,35 +422,12 @@ function FullApp() { loadParams() }, []) - function selectPreset(id: TimeRangePresetId) { - setActivePreset(id) - setRange(computePresetRange(id)) - } - - // A drag always yields a fixed (non-rolling) range, overriding any active preset. - function handleManualRangeSelect(next: TimeRange | null) { - if (next === null) { - selectPreset('all') - } else { - setActivePreset(null) - setRange(next) - } - } - useEffect(() => { let cancelled = false async function poll() { // Rolling presets re-resolve on this same polling cadence instead of freezing the window at selection time. - const effectiveRange = - activePreset && activePreset !== 'all' ? computePresetRange(activePreset) : rangeRef.current - if (activePreset && activePreset !== 'all') { - setRange((current) => - current && effectiveRange && current.startMs === effectiveRange.startMs && current.endMs === effectiveRange.endMs - ? current - : effectiveRange, - ) - } + const effectiveRange = resolveEffectiveRange() try { const [nextStats, nextBuckets, nextLogs, nextParams] = await Promise.all([ fetchStats(), diff --git a/webapp/src/lib/useRangeSelection.ts b/webapp/src/lib/useRangeSelection.ts new file mode 100644 index 0000000..1cba068 --- /dev/null +++ b/webapp/src/lib/useRangeSelection.ts @@ -0,0 +1,48 @@ +import { useEffect, useRef, useState } from 'react' +import { computePresetRange, type TimeRange, type TimeRangePresetId } from './timeRange' + +// `resolveEffectiveRange` is meant to be called once at the top of each variant's own poll +// callback: it re-resolves a rolling preset to "now" on every tick (instead of freezing the +// window at selection time) while reading a manually-selected fixed range via a ref, so the poll +// loop's own `useEffect` doesn't need `range` itself as a dependency and restart its interval on +// every tick. +export function useRangeSelection(initial: { preset: TimeRangePresetId | null; range: TimeRange | null }) { + const [range, setRange] = useState( + initial.preset ? computePresetRange(initial.preset) : initial.range, + ) + const [activePreset, setActivePreset] = useState(initial.preset) + + const rangeRef = useRef(range) + useEffect(() => { + rangeRef.current = range + }, [range]) + + function selectPreset(id: TimeRangePresetId) { + setActivePreset(id) + setRange(computePresetRange(id)) + } + + // A drag always yields a fixed (non-rolling) range, overriding any active preset. + function handleManualRangeSelect(next: TimeRange | null) { + if (next === null) { + selectPreset('all') + } else { + setActivePreset(null) + setRange(next) + } + } + + function resolveEffectiveRange(): TimeRange | null { + const effectiveRange = activePreset && activePreset !== 'all' ? computePresetRange(activePreset) : rangeRef.current + if (activePreset && activePreset !== 'all') { + setRange((current) => + current && effectiveRange && current.startMs === effectiveRange.startMs && current.endMs === effectiveRange.endMs + ? current + : effectiveRange, + ) + } + return effectiveRange + } + + return { range, activePreset, selectPreset, handleManualRangeSelect, resolveEffectiveRange } +}