From c66ce33bd98616d52ea54482c69a3d25aa6304e5 Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Sun, 2 Aug 2026 11:38:39 +0300 Subject: [PATCH] feat(web): fixed summary windows with a paging range navigator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary mode no longer exposes the two-thumb start/end slider — each bucket now has a fixed window (daily: 30d, weekly: 91d, monthly: 365d, yearly: 5y) and a single-handle slider with ‹/› pagers that moves the window through time one span at a time, keeping its length constant. Bucket switches stay anchored to the window's end. The per-event drill-down view keeps the adjustable range slider and activity limit. Bounded windows also cap the scan the summary query asks of postgres, where the old view's arbitrary ranges did not. The prerender shares the new summaryRange helper so the baked daily key stays byte-identical to the client's first render. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LY4nbfHC9qt726gVrdAh3X --- ui/src/components/Filters.tsx | 67 ++++++++++++++++++++++++++++------- ui/src/lib/ranges.ts | 25 ++++++++++++- ui/src/pages/TimelineHome.tsx | 26 +++++++++++--- ui/src/prerender/prefetch.ts | 11 +++--- 4 files changed, 106 insertions(+), 23 deletions(-) diff --git a/ui/src/components/Filters.tsx b/ui/src/components/Filters.tsx index b42890b..d437690 100644 --- a/ui/src/components/Filters.tsx +++ b/ui/src/components/Filters.tsx @@ -1,3 +1,4 @@ +import Button from 'react-bootstrap/Button'; import Col from 'react-bootstrap/Col'; import Form from 'react-bootstrap/Form'; import Row from 'react-bootstrap/Row'; @@ -18,6 +19,10 @@ interface Props { summaries: SourceSummary[] | undefined; /** Hide the activity-count slider (summary mode has no event cap). */ showLimit?: boolean; + /** Fixed window length (ms). When set, the two-thumb range slider becomes a + * single position control: the handle (or the ‹/› pagers, stepping one full + * window) moves a constant-size window through time. */ + windowMs?: number; } export function Filters({ @@ -31,9 +36,16 @@ export function Filters({ onLimitChange, summaries, showLimit = true, + windowMs, }: Props) { const summaryFor = (src: Source) => summaries?.find((s) => s.source === src); + const setWindowEnd = (end: number) => { + if (windowMs == null) return; + const clamped = Math.min(Math.max(end, rangeMin + windowMs), rangeMax); + onRangeChange([clamped - windowMs, clamped]); + }; + return ( <> @@ -70,18 +82,49 @@ export function Filters({ - { - if (Array.isArray(v) && v.length === 2) { - onRangeChange([v[0], v[1]]); - } - }} - /> + {windowMs == null ? ( + { + if (Array.isArray(v) && v.length === 2) { + onRangeChange([v[0], v[1]]); + } + }} + /> + ) : ( +
+ +
+ setWindowEnd(Array.isArray(v) ? v[0] : v)} + /> +
+ +
+ )}

{formatDate(rangeValue[0])} to{' '} {formatDate(rangeValue[1])} diff --git a/ui/src/lib/ranges.ts b/ui/src/lib/ranges.ts index c15fe8c..84093f1 100644 --- a/ui/src/lib/ranges.ts +++ b/ui/src/lib/ranges.ts @@ -7,12 +7,35 @@ // from the baked snapshot; loads on a later day compute a different key and // refetch live — the intended freshness tradeoff for the activity data. -import type { SourceSummary } from '../api/client'; +import type { SourceSummary, SummaryBucket } from '../api/client'; export function fmtDate(d: Date): string { return d.toISOString().slice(0, 10); } +const DAY_MS = 24 * 60 * 60 * 1000; + +/** Fixed summary window per bucket (ms). In summary mode the range is not + * length-adjustable — only its position is, paging window-by-window. */ +export const SUMMARY_WINDOW_MS: Record = { + day: 30 * DAY_MS, + week: 91 * DAY_MS, + month: 365 * DAY_MS, + year: 5 * 365 * DAY_MS, +}; + +/** Summary window ending at `endMs`: a fixed span per bucket, day-stamped so + * the prerender and the client's first render share the same query keys. */ +export function summaryRange(bucket: SummaryBucket, endMs: number) { + const from = endMs - SUMMARY_WINDOW_MS[bucket]; + return { + from, + to: endMs, + fromStr: fmtDate(new Date(from)), + toStr: fmtDate(new Date(endMs)), + }; +} + /** Contribution graph + language stream: trailing 365 days. */ export function lastYearRange(now: Date = new Date()) { const to = new Date(now); diff --git a/ui/src/pages/TimelineHome.tsx b/ui/src/pages/TimelineHome.tsx index ea3a014..460ac50 100644 --- a/ui/src/pages/TimelineHome.tsx +++ b/ui/src/pages/TimelineHome.tsx @@ -19,7 +19,13 @@ import { import { Filters } from '../components/Filters'; import { SummaryEntry } from '../components/SummaryEntry'; import { TimelineEntry } from '../components/TimelineEntry'; -import { defaultActivityRange, endOfTodayMs, fmtDate } from '../lib/ranges'; +import { + defaultActivityRange, + endOfTodayMs, + fmtDate, + SUMMARY_WINDOW_MS, + summaryRange, +} from '../lib/ranges'; const RANGE_MIN = new Date('2010-01-01T00:00:00Z').getTime(); const RANGE_MAX = endOfTodayMs(); @@ -78,6 +84,9 @@ export function TimelineHome() { }); const [limit, setLimit] = useState(100); const [bucket, setBucket] = useState('day'); + // Summary mode's window has a fixed length per bucket; only its end is + // navigable (the single-handle slider / ‹ › pagers in Filters). + const [summaryEnd, setSummaryEnd] = useState(RANGE_MAX); // The summary and event views are the same component at the same route // position, so React keeps state across navigation between them. Sync the @@ -99,10 +108,12 @@ export function TimelineHome() { [enabledSources], ); + const summaryWindow = summaryRange(bucket, summaryEnd); + // Day-stamped keys (rather than raw millisecond bounds) so the prerendered // snapshot and the client's first render agree on the same UTC day. - const fromStr = fmtDate(new Date(rangeValue[0])); - const toStr = fmtDate(new Date(rangeValue[1])); + const fromStr = summaryMode ? summaryWindow.fromStr : fmtDate(new Date(rangeValue[0])); + const toStr = summaryMode ? summaryWindow.toStr : fmtDate(new Date(rangeValue[1])); const eventsQ = useQuery({ queryKey: ['events', fromStr, toStr, activeSources, limit], @@ -161,12 +172,17 @@ export function TimelineHome() { } rangeMin={RANGE_MIN} rangeMax={RANGE_MAX} - rangeValue={rangeValue} - onRangeChange={setRangeValue} + rangeValue={ + summaryMode ? [summaryWindow.from, summaryWindow.to] : rangeValue + } + onRangeChange={ + summaryMode ? ([, to]) => setSummaryEnd(to) : setRangeValue + } limit={limit} onLimitChange={setLimit} summaries={sourcesQ.data} showLimit={!summaryMode} + windowMs={summaryMode ? SUMMARY_WINDOW_MS[bucket] : undefined} /> {summaryMode && ( diff --git a/ui/src/prerender/prefetch.ts b/ui/src/prerender/prefetch.ts index 6454e04..7063a0e 100644 --- a/ui/src/prerender/prefetch.ts +++ b/ui/src/prerender/prefetch.ts @@ -26,10 +26,11 @@ import { import { fetchCv } from '../api/cv'; import { allTimeRange, - defaultActivityRange, earliestFrom, + endOfTodayMs, lastYearRange, resolvedTimeZone, + summaryRange, } from '../lib/ranges'; // Sources the timeline shows by default, in the same insertion order as @@ -73,10 +74,10 @@ async function prefetchDash(qc: QueryClient): Promise { async function prefetchActivity(qc: QueryClient): Promise { // Only the default `/activity` view is prerendered — the per-period summary - // with the default daily bucket. Timespan-parameterised routes - // (`/activity/:timespan`) are unbounded, so they SPA-fall-back to the - // client and refetch. - const range = defaultActivityRange(); + // with the default daily bucket over its fixed window ending today. + // Timespan-parameterised routes (`/activity/:timespan`) are unbounded, so + // they SPA-fall-back to the client and refetch. + const range = summaryRange('day', endOfTodayMs()); await Promise.all([ qc.prefetchQuery({ queryKey: ['sources'], queryFn: fetchSources }), qc.prefetchQuery({