feat(ui): contribution graph with daily activity heatmap

Add /v1/activity/daily endpoint returning per-day event counts via
generate_series + LEFT JOIN. Frontend renders an SVG contribution
graph with circles colored by quantile-based thresholds. Clicking a
day navigates to /activity/YYYY-MM-DD showing that day's events.

New /activity/:timespan route parses single dates (YYYY-MM-DD) and
ranges (YYYY-MM-DD..YYYY-MM-DD) from the URL to initialize the
activity timeline filter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 17:05:28 +03:00
parent 7de23303bd
commit 27ce16e630
10 changed files with 274 additions and 4 deletions

View File

@@ -1,4 +1,5 @@
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';
@@ -11,7 +12,24 @@ import { TimelineEntry } from '../components/TimelineEntry';
const RANGE_MIN = new Date('2010-01-01T00:00:00Z').getTime();
const RANGE_MAX = Date.now();
function parseTimespan(timespan?: string): [number, number] | null {
if (!timespan) return null;
if (timespan.includes('..')) {
const [a, b] = timespan.split('..');
const from = new Date(a + 'T00:00:00Z').getTime();
const to = new Date(b + 'T23:59:59Z').getTime();
if (!isNaN(from) && !isNaN(to)) return [from, to];
} else {
const from = new Date(timespan + 'T00:00:00Z').getTime();
const to = new Date(timespan + 'T23:59:59Z').getTime();
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,
@@ -19,6 +37,8 @@ export function TimelineHome() {
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];