feat(web): fixed summary windows with a paging range navigator
All checks were successful
deploy / Build prerendered web (push) Successful in 8m5s
deploy / Build api + worker (static musl) (push) Successful in 5m46s
deploy / Deploy moments-api to nikola (push) Successful in 24s
deploy / Deploy web to oolon (push) Successful in 28s
deploy / Deploy moments-worker to frootmig (push) Successful in 17s
refresh / Rebuild prerendered web (push) Successful in 7m41s
refresh / Deploy refreshed web to oolon (push) Successful in 22s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LY4nbfHC9qt726gVrdAh3X
This commit is contained in:
2026-08-02 11:38:39 +03:00
parent 957770257c
commit c66ce33bd9
4 changed files with 106 additions and 23 deletions

View File

@@ -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 (
<>
<Row className="mb-3">
@@ -70,6 +82,7 @@ export function Filters({
</Row>
<Row className="mb-3">
<Col>
{windowMs == null ? (
<Slider
range
allowCross={false}
@@ -82,6 +95,36 @@ export function Filters({
}
}}
/>
) : (
<div className="d-flex align-items-center" style={{ gap: '1rem' }}>
<Button
variant="outline-secondary"
size="sm"
aria-label="older"
disabled={rangeValue[1] <= rangeMin + windowMs}
onClick={() => setWindowEnd(rangeValue[1] - windowMs)}
>
</Button>
<div style={{ flexGrow: 1 }}>
<Slider
value={rangeValue[1]}
min={rangeMin + windowMs}
max={rangeMax}
onChange={(v) => setWindowEnd(Array.isArray(v) ? v[0] : v)}
/>
</div>
<Button
variant="outline-secondary"
size="sm"
aria-label="newer"
disabled={rangeValue[1] >= rangeMax}
onClick={() => setWindowEnd(rangeValue[1] + windowMs)}
>
</Button>
</div>
)}
<p className="text-center" style={{ fontSize: '85%' }}>
<em>{formatDate(rangeValue[0])}</em> to{' '}
<em>{formatDate(rangeValue[1])}</em>

View File

@@ -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<SummaryBucket, number> = {
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);

View File

@@ -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<number>(100);
const [bucket, setBucket] = useState<SummaryBucket>('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<number>(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 && (

View File

@@ -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<void> {
async function prefetchActivity(qc: QueryClient): Promise<void> {
// 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({