From 1ca85fe632690ed888c3ea733b21c12e40a6db5b Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Tue, 5 May 2026 17:13:49 +0300 Subject: [PATCH] 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) --- ui/src/components/ContributionGraph.tsx | 144 +++++++++++++++++++++--- ui/src/pages/DashPage.tsx | 3 +- ui/src/pages/TimelineHome.tsx | 19 +++- 3 files changed, 146 insertions(+), 20 deletions(-) diff --git a/ui/src/components/ContributionGraph.tsx b/ui/src/components/ContributionGraph.tsx index c70482e..44763ce 100644 --- a/ui/src/components/ContributionGraph.tsx +++ b/ui/src/components/ContributionGraph.tsx @@ -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 ( -
+

{totalCount} contributions in the last year

- {/* Day-of-week labels */} {DAY_LABELS.map((label, i) => label ? ( ) : null, )} - {/* Month labels */} {monthMarkers.map(({ col, label }, i) => ( ))} - {/* Circles */} {weeks.flatMap((week) => week.map(({ date, count, col, row }) => ( { + 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 ( +
+

+ {totalCount} contributions since {fmt(from)} +

+
+ + {yearMarkers.map(({ col, label }, i) => ( + + {label} + + ))} + {weeklyData.map(({ weekStart, weekEnd, count, col }) => ( + navigate(`/activity/${weekStart}..${weekEnd}`)} + > + {`${weekStart} — ${weekEnd}: ${count} ${count === 1 ? 'contribution' : 'contributions'}`} + + ))} + +
+
+ ); +} + function fmt(d: Date): string { return d.toISOString().slice(0, 10); } diff --git a/ui/src/pages/DashPage.tsx b/ui/src/pages/DashPage.tsx index fac4a4a..cea0d6f 100644 --- a/ui/src/pages/DashPage.tsx +++ b/ui/src/pages/DashPage.tsx @@ -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() { + {projectsQ.isLoading &&

loading...

} {projectsQ.isError && (

error: {(projectsQ.error as Error).message}

diff --git a/ui/src/pages/TimelineHome.tsx b/ui/src/pages/TimelineHome.tsx index b79b44b..119b411 100644 --- a/ui/src/pages/TimelineHome.tsx +++ b/ui/src/pages/TimelineHome.tsx @@ -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;