Add AllTimeGraph component showing one circle per week across the full history (earliest event to today). Uses the /sources endpoint to find the earliest date, then fetches daily counts and aggregates to weekly. Clicking a week navigates to /activity/YYYY-MM-DD..YYYY-MM-DD. Update parseTimespan to handle both date-only (YYYY-MM-DD) and full ISO datetime strings in range expressions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
120 lines
3.4 KiB
TypeScript
120 lines
3.4 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import { useParams } from 'react-router-dom';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import Col from 'react-bootstrap/Col';
|
|
import Row from 'react-bootstrap/Row';
|
|
import { VerticalTimeline } from 'react-vertical-timeline-component';
|
|
|
|
import { fetchEvents, fetchSources, type Source } from '../api/client';
|
|
import { Filters } from '../components/Filters';
|
|
import { TimelineEntry } from '../components/TimelineEntry';
|
|
|
|
const RANGE_MIN = new Date('2010-01-01T00:00:00Z').getTime();
|
|
const RANGE_MAX = Date.now();
|
|
|
|
function parseDate(s: string): number {
|
|
// Accept YYYY-MM-DD or full ISO datetime
|
|
const t = new Date(s.includes('T') ? s : s + 'T00:00:00Z').getTime();
|
|
return isNaN(t) ? NaN : t;
|
|
}
|
|
|
|
function endOfDay(s: string): number {
|
|
const t = new Date(s.includes('T') ? s : s + 'T23:59:59Z').getTime();
|
|
return isNaN(t) ? NaN : t;
|
|
}
|
|
|
|
function parseTimespan(timespan?: string): [number, number] | null {
|
|
if (!timespan) return null;
|
|
if (timespan.includes('..')) {
|
|
const [a, b] = timespan.split('..');
|
|
const from = parseDate(a);
|
|
const to = endOfDay(b);
|
|
if (!isNaN(from) && !isNaN(to)) return [from, to];
|
|
} else {
|
|
const from = parseDate(timespan);
|
|
const to = endOfDay(timespan);
|
|
if (!isNaN(from)) return [from, to];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function TimelineHome() {
|
|
const { timespan } = useParams();
|
|
|
|
const [enabledSources, setEnabledSources] = useState<Record<Source, boolean>>({
|
|
github: true,
|
|
gitea: true,
|
|
hg: true,
|
|
bugzilla: true,
|
|
});
|
|
const [rangeValue, setRangeValue] = useState<[number, number]>(() => {
|
|
const parsed = parseTimespan(timespan);
|
|
if (parsed) return parsed;
|
|
const now = Date.now();
|
|
const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000;
|
|
return [thirtyDaysAgo, now];
|
|
});
|
|
const [limit, setLimit] = useState<number>(100);
|
|
|
|
const sourcesQ = useQuery({
|
|
queryKey: ['sources'],
|
|
queryFn: fetchSources,
|
|
refetchInterval: 60_000,
|
|
});
|
|
|
|
const activeSources = useMemo(
|
|
() =>
|
|
(Object.keys(enabledSources) as Source[]).filter((s) => enabledSources[s]),
|
|
[enabledSources],
|
|
);
|
|
|
|
const eventsQ = useQuery({
|
|
queryKey: ['events', rangeValue, activeSources, limit],
|
|
queryFn: () =>
|
|
fetchEvents({
|
|
from: new Date(rangeValue[0]),
|
|
to: new Date(rangeValue[1]),
|
|
sources: activeSources,
|
|
limit,
|
|
}),
|
|
refetchInterval: 60_000,
|
|
});
|
|
|
|
const events = eventsQ.data ?? [];
|
|
|
|
return (
|
|
<>
|
|
<Filters
|
|
enabledSources={enabledSources}
|
|
onSourceToggle={(s, on) =>
|
|
setEnabledSources((prev) => ({ ...prev, [s]: on }))
|
|
}
|
|
rangeMin={RANGE_MIN}
|
|
rangeMax={RANGE_MAX}
|
|
rangeValue={rangeValue}
|
|
onRangeChange={setRangeValue}
|
|
limit={limit}
|
|
onLimitChange={setLimit}
|
|
summaries={sourcesQ.data}
|
|
/>
|
|
|
|
<Row>
|
|
<Col>
|
|
<p className="text-center" style={{ fontSize: '85%' }}>
|
|
{eventsQ.isLoading
|
|
? 'loading…'
|
|
: eventsQ.isError
|
|
? `error: ${(eventsQ.error as Error).message}`
|
|
: `showing ${events.length} ${events.length === 1 ? 'activity' : 'activities'}`}
|
|
</p>
|
|
<VerticalTimeline>
|
|
{events.map((item) => (
|
|
<TimelineEntry key={item.id} item={item} />
|
|
))}
|
|
</VerticalTimeline>
|
|
</Col>
|
|
</Row>
|
|
</>
|
|
);
|
|
}
|