feat(ui): all-time weekly contribution graph + date range timespan support

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>
This commit is contained in:
2026-05-05 17:13:49 +03:00
parent 822def3227
commit 1ca85fe632
3 changed files with 146 additions and 20 deletions

View File

@@ -2,12 +2,12 @@ import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { fetchDailyCounts } from '../api/client';
import { fetchDailyCounts, fetchSources } from '../api/client';
const CELL_SIZE = 12;
const GAP = 3;
const RADIUS = CELL_SIZE / 2;
const ROWS = 7; // days per week
const ROWS = 7;
const LEFT_LABEL_WIDTH = 28;
const TOP_LABEL_HEIGHT = 16;
@@ -15,13 +15,14 @@ const DAY_LABELS = ['', 'mon', '', 'wed', '', 'fri', ''];
const MONTH_LABELS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
const COLORS = [
'rgba(255,255,255,0.05)', // 0: empty
'#0e4429', // 1: low
'#006d32', // 2: medium-low
'#26a641', // 3: medium
'#39d353', // 4: high
'rgba(255,255,255,0.05)',
'#0e4429',
'#006d32',
'#26a641',
'#39d353',
];
/** Daily contribution graph — last 1 year, one circle per day. */
export function ContributionGraph() {
const to = new Date();
const from = new Date(to);
@@ -42,7 +43,6 @@ export function ContributionGraph() {
const counts = dailyQ.data ?? [];
const countMap = new Map(counts.map((d) => [d.date, d.count]));
// Start from the Sunday before `from`
const start = new Date(from);
start.setDate(start.getDate() - start.getDay());
@@ -58,8 +58,6 @@ export function ContributionGraph() {
const dateStr = fmt(cursor);
const count = countMap.get(dateStr) ?? 0;
week.push({ date: dateStr, count, col, row });
// Track month transitions (on the first day of each week)
if (row === 0) {
const m = cursor.getMonth();
if (m !== prevMonth) {
@@ -73,7 +71,6 @@ export function ContributionGraph() {
col++;
}
// Compute quantile thresholds from non-zero counts
const nonZero = counts.map((d) => d.count).filter((c) => c > 0).sort((a, b) => a - b);
const thresholds = computeThresholds(nonZero);
const totalCount = counts.reduce((sum, d) => sum + d.count, 0);
@@ -89,13 +86,12 @@ export function ContributionGraph() {
if (dailyQ.isError) return null;
return (
<div className="contribution-graph mb-4">
<div className="contribution-graph mb-3">
<p style={{ fontSize: '0.8rem', opacity: 0.6 }}>
{totalCount} contributions in the last year
</p>
<div>
<svg viewBox={`0 0 ${svgWidth} ${svgHeight}`} width="100%" className="d-block">
{/* Day-of-week labels */}
{DAY_LABELS.map((label, i) =>
label ? (
<text
@@ -110,7 +106,6 @@ export function ContributionGraph() {
</text>
) : null,
)}
{/* Month labels */}
{monthMarkers.map(({ col, label }, i) => (
<text
key={i}
@@ -122,7 +117,6 @@ export function ContributionGraph() {
{label}
</text>
))}
{/* Circles */}
{weeks.flatMap((week) =>
week.map(({ date, count, col, row }) => (
<circle
@@ -144,6 +138,126 @@ export function ContributionGraph() {
);
}
/** All-time weekly contribution graph — one circle per week. */
export function AllTimeGraph() {
const sourcesQ = useQuery({
queryKey: ['sources'],
queryFn: fetchSources,
staleTime: 60_000,
});
const earliest = useMemo(() => {
if (!sourcesQ.data) return null;
const dates = sourcesQ.data
.map((s) => s.earliest)
.filter((d): d is string => d != null)
.map((d) => new Date(d));
return dates.length > 0 ? new Date(Math.min(...dates.map((d) => d.getTime()))) : null;
}, [sourcesQ.data]);
const to = new Date();
const from = earliest ?? new Date(to.getFullYear() - 5, 0, 1);
const fromStr = fmt(from);
const toStr = fmt(to);
const dailyQ = useQuery({
queryKey: ['daily-counts-alltime', fromStr, toStr],
queryFn: () => fetchDailyCounts(fromStr, toStr),
enabled: !!earliest,
staleTime: 10 * 60_000,
});
const navigate = useNavigate();
const { weeklyData, yearMarkers, thresholds, totalCount } = useMemo(() => {
const counts = dailyQ.data ?? [];
if (counts.length === 0) return { weeklyData: [], yearMarkers: [], thresholds: [1, 2, 3], totalCount: 0 };
const countMap = new Map(counts.map((d) => [d.date, d.count]));
// Start from the Sunday before earliest
const start = new Date(from);
start.setDate(start.getDate() - start.getDay());
const weeklyData: { weekStart: string; weekEnd: string; count: number; col: number }[] = [];
const yearMarkers: { col: number; label: string }[] = [];
let col = 0;
let prevYear = -1;
const cursor = new Date(start);
while (cursor <= to) {
const weekStart = fmt(cursor);
let weekCount = 0;
for (let d = 0; d < 7; d++) {
weekCount += countMap.get(fmt(cursor)) ?? 0;
if (d < 6) cursor.setDate(cursor.getDate() + 1);
}
const weekEnd = fmt(cursor);
cursor.setDate(cursor.getDate() + 1);
const yr = new Date(weekStart).getFullYear();
if (yr !== prevYear) {
yearMarkers.push({ col, label: String(yr) });
prevYear = yr;
}
weeklyData.push({ weekStart, weekEnd, count: weekCount, col });
col++;
}
const nonZero = weeklyData.map((w) => w.count).filter((c) => c > 0).sort((a, b) => a - b);
const thresholds = computeThresholds(nonZero);
const totalCount = counts.reduce((sum, d) => sum + d.count, 0);
return { weeklyData, yearMarkers, thresholds, totalCount };
}, [dailyQ.data]);
if (!earliest || dailyQ.isLoading) return null;
if (dailyQ.isError) return null;
if (weeklyData.length === 0) return null;
const cols = weeklyData.length;
const labelHeight = 14;
const svgWidth = cols * (CELL_SIZE + GAP);
const svgHeight = labelHeight + CELL_SIZE + GAP;
return (
<div className="contribution-graph mb-4">
<p style={{ fontSize: '0.8rem', opacity: 0.6 }}>
{totalCount} contributions since {fmt(from)}
</p>
<div>
<svg viewBox={`0 0 ${svgWidth} ${svgHeight}`} width="100%" className="d-block">
{yearMarkers.map(({ col, label }, i) => (
<text
key={i}
x={col * (CELL_SIZE + GAP) + RADIUS}
y={10}
textAnchor="middle"
className="graph-label"
>
{label}
</text>
))}
{weeklyData.map(({ weekStart, weekEnd, count, col }) => (
<circle
key={weekStart}
cx={col * (CELL_SIZE + GAP) + RADIUS}
cy={labelHeight + RADIUS}
r={RADIUS - 1}
fill={colorFor(count, thresholds)}
className="graph-cell"
onClick={() => navigate(`/activity/${weekStart}..${weekEnd}`)}
>
<title>{`${weekStart}${weekEnd}: ${count} ${count === 1 ? 'contribution' : 'contributions'}`}</title>
</circle>
))}
</svg>
</div>
</div>
);
}
function fmt(d: Date): string {
return d.toISOString().slice(0, 10);
}

View File

@@ -4,7 +4,7 @@ import Col from 'react-bootstrap/Col';
import Row from 'react-bootstrap/Row';
import { fetchProjects, fetchLanguages, type ProjectSummary, type Source } from '../api/client';
import { ContributionGraph } from '../components/ContributionGraph';
import { ContributionGraph, AllTimeGraph } from '../components/ContributionGraph';
export function DashPage() {
const projectsQ = useQuery({
@@ -27,6 +27,7 @@ export function DashPage() {
</Col>
</Row>
<ContributionGraph />
<AllTimeGraph />
{projectsQ.isLoading && <p>loading...</p>}
{projectsQ.isError && (
<p>error: {(projectsQ.error as Error).message}</p>

View File

@@ -12,16 +12,27 @@ 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 = new Date(a + 'T00:00:00Z').getTime();
const to = new Date(b + 'T23:59:59Z').getTime();
const from = parseDate(a);
const to = endOfDay(b);
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();
const from = parseDate(timespan);
const to = endOfDay(timespan);
if (!isNaN(from)) return [from, to];
}
return null;