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({