diff --git a/ui/src/components/ContributionGraph.tsx b/ui/src/components/ContributionGraph.tsx index 6250b90..e816520 100644 --- a/ui/src/components/ContributionGraph.tsx +++ b/ui/src/components/ContributionGraph.tsx @@ -1,8 +1,13 @@ -import { useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { useNavigate } from 'react-router-dom'; +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useNavigate } from "react-router-dom"; -import { fetchDailyCounts, fetchLanguageDailyCounts, fetchProjects, fetchSources } from '../api/client'; +import { + fetchDailyCounts, + fetchLanguageDailyCounts, + fetchProjects, + fetchSources, +} from "../api/client"; const CELL_SIZE = 12; const GAP = 3; @@ -11,11 +16,24 @@ const ROWS = 7; const LEFT_LABEL_WIDTH = 28; const TOP_LABEL_HEIGHT = 16; -const DAY_LABELS = ['', 'mon', '', 'wed', '', 'fri', '']; -const MONTH_LABELS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; +const DAY_LABELS = ["", "mon", "", "wed", "", "fri", ""]; +const MONTH_LABELS = [ + "jan", + "feb", + "mar", + "apr", + "may", + "jun", + "jul", + "aug", + "sep", + "oct", + "nov", + "dec", +]; -const EMPTY_COLOR = 'rgba(255,255,255,0.05)'; -const FALLBACK_COLOR = '#39d353'; +const EMPTY_COLOR = "rgba(255,255,255,0.05)"; +const FALLBACK_COLOR = "#39d353"; /** Daily contribution graph — last 1 year, one circle per day. */ export function ContributionGraph() { @@ -27,19 +45,19 @@ export function ContributionGraph() { const toStr = fmt(to); const dailyQ = useQuery({ - queryKey: ['daily-counts', fromStr, toStr], + queryKey: ["daily-counts", fromStr, toStr], queryFn: () => fetchDailyCounts(fromStr, toStr), staleTime: 5 * 60_000, }); const langQ = useQuery({ - queryKey: ['language-daily', fromStr, toStr], + queryKey: ["language-daily", fromStr, toStr], queryFn: () => fetchLanguageDailyCounts(fromStr, toStr), staleTime: 5 * 60_000, }); const projectsQ = useQuery({ - queryKey: ['projects'], + queryKey: ["projects"], queryFn: fetchProjects, staleTime: 60_000, }); @@ -49,7 +67,9 @@ export function ContributionGraph() { const fromMs = from.getTime(); const toMs = to.getTime(); return projectsQ.data.filter((p) => { - const first = p.first_activity ? new Date(p.first_activity).getTime() : Infinity; + const first = p.first_activity + ? new Date(p.first_activity).getTime() + : Infinity; const last = p.last_activity ? new Date(p.last_activity).getTime() : 0; return last >= fromMs && first <= toMs; }).length; @@ -69,14 +89,15 @@ export function ContributionGraph() { const start = new Date(from); start.setDate(start.getDate() - start.getDay()); - const weeks: { date: string; count: number; col: number; row: number }[][] = []; + const weeks: { date: string; count: number; col: number; row: number }[][] = + []; const monthMarkers: { col: number; label: string }[] = []; let col = 0; let prevMonth = -1; const cursor = new Date(start); while (cursor <= to) { - const week: typeof weeks[0] = []; + const week: (typeof weeks)[0] = []; for (let row = 0; row < ROWS; row++) { const dateStr = fmt(cursor); const count = countMap.get(dateStr) ?? 0; @@ -94,7 +115,10 @@ export function ContributionGraph() { col++; } - const nonZero = counts.map((d) => d.count).filter((c) => c > 0).sort((a, b) => a - b); + 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); @@ -105,17 +129,23 @@ export function ContributionGraph() { const svgWidth = LEFT_LABEL_WIDTH + cols * (CELL_SIZE + GAP); const svgHeight = TOP_LABEL_HEIGHT + ROWS * (CELL_SIZE + GAP); - if (dailyQ.isLoading) return

loading contribution graph...

; + if (dailyQ.isLoading) + return

loading contribution graph...

; if (dailyQ.isError) return null; return (
-

- {totalCount} contributions in the last year - {repoCount > 0 && ` in ${repoCount} repositories`} +

+ {new Intl.NumberFormat().format(totalCount)} contributions + {repoCount > 0 && `, across ${repoCount} repositories, `} + in the last year

- + {DAY_LABELS.map((label, i) => label ? ( navigate(`/activity/${date}`)} > - {`${date}: ${count} ${count === 1 ? 'contribution' : 'contributions'}`} + {`${date}: ${count} ${count === 1 ? "contribution" : "contributions"}`} )), )} @@ -166,7 +200,7 @@ export function ContributionGraph() { /** All-time monthly contribution graph — years on X axis, months on Y axis. */ export function AllTimeGraph() { const sourcesQ = useQuery({ - queryKey: ['sources'], + queryKey: ["sources"], queryFn: fetchSources, staleTime: 60_000, }); @@ -177,11 +211,13 @@ export function AllTimeGraph() { .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; + return dates.length > 0 + ? new Date(Math.min(...dates.map((d) => d.getTime()))) + : null; }, [sourcesQ.data]); const projectsQ = useQuery({ - queryKey: ['projects'], + queryKey: ["projects"], queryFn: fetchProjects, staleTime: 60_000, }); @@ -193,14 +229,14 @@ export function AllTimeGraph() { const toStr = fmt(to); const dailyQ = useQuery({ - queryKey: ['daily-counts-alltime', fromStr, toStr], + queryKey: ["daily-counts-alltime", fromStr, toStr], queryFn: () => fetchDailyCounts(fromStr, toStr), enabled: !!earliest, staleTime: 10 * 60_000, }); const langQ = useQuery({ - queryKey: ['language-daily-alltime', fromStr, toStr], + queryKey: ["language-daily-alltime", fromStr, toStr], queryFn: () => fetchLanguageDailyCounts(fromStr, toStr), enabled: !!earliest, staleTime: 10 * 60_000, @@ -212,7 +248,10 @@ export function AllTimeGraph() { const monthColorMap = useMemo(() => { const entries = langQ.data ?? []; if (entries.length === 0) return new Map(); - const map = new Map>(); + const map = new Map< + string, + Map + >(); for (const e of entries) { const key = e.date.slice(0, 7); // YYYY-MM if (!map.has(key)) map.set(key, new Map()); @@ -221,7 +260,10 @@ export function AllTimeGraph() { if (cur) { cur.commits += e.commits; } else { - langMap.set(e.language, { commits: e.commits, color: e.color ?? FALLBACK_COLOR }); + langMap.set(e.language, { + commits: e.commits, + color: e.color ?? FALLBACK_COLOR, + }); } } const result = new Map(); @@ -237,7 +279,8 @@ export function AllTimeGraph() { const { years, monthGrid, thresholds, totalCount } = useMemo(() => { const counts = dailyQ.data ?? []; - if (counts.length === 0) return { years: [], monthGrid: [], thresholds: [1, 2, 3], totalCount: 0 }; + if (counts.length === 0) + return { years: [], monthGrid: [], thresholds: [1, 2, 3], totalCount: 0 }; const countMap = new Map(counts.map((d) => [d.date, d.count])); @@ -247,16 +290,30 @@ export function AllTimeGraph() { for (let yr = startYear; yr <= endYear; yr++) years.push(yr); // Build a 12 x years grid of monthly totals - const monthGrid: { year: number; month: number; count: number; monthStart: string; monthEnd: string; monthKey: string }[][] = []; + const monthGrid: { + year: number; + month: number; + count: number; + monthStart: string; + monthEnd: string; + monthKey: string; + }[][] = []; for (let m = 0; m < 12; m++) { - const row: typeof monthGrid[0] = []; + const row: (typeof monthGrid)[0] = []; for (const yr of years) { const monthStart = new Date(yr, m, 1); const monthEnd = new Date(yr, m + 1, 0); // last day of month - const monthKey = `${yr}-${String(m + 1).padStart(2, '0')}`; + const monthKey = `${yr}-${String(m + 1).padStart(2, "0")}`; // Don't include months entirely outside our data range if (monthStart > to || monthEnd < from) { - row.push({ year: yr, month: m, count: 0, monthStart: fmt(monthStart), monthEnd: fmt(monthEnd), monthKey }); + row.push({ + year: yr, + month: m, + count: 0, + monthStart: fmt(monthStart), + monthEnd: fmt(monthEnd), + monthKey, + }); continue; } let total = 0; @@ -265,7 +322,14 @@ export function AllTimeGraph() { total += countMap.get(fmt(cursor)) ?? 0; cursor.setDate(cursor.getDate() + 1); } - row.push({ year: yr, month: m, count: total, monthStart: fmt(monthStart), monthEnd: fmt(monthEnd), monthKey }); + row.push({ + year: yr, + month: m, + count: total, + monthStart: fmt(monthStart), + monthEnd: fmt(monthEnd), + monthKey, + }); } monthGrid.push(row); } @@ -290,12 +354,17 @@ export function AllTimeGraph() { return (
-

- {totalCount} contributions since {fmt(from)} - {repoCount > 0 && ` in ${repoCount} repositories`} +

+ {new Intl.NumberFormat().format(totalCount)} contributions + {repoCount > 0 && `, across ${repoCount} repos, `} + since {fmt(from).split("-")[0]}

- + {/* Year labels along the top */} {years.map((year, colIdx) => ( - row.map(({ year, count, monthStart, monthEnd, monthKey }, colIdx) => ( - navigate(`/activity/${monthStart}..${monthEnd}`)} - > - {`${MONTH_LABELS[rowIdx]} ${year}: ${count} ${count === 1 ? 'contribution' : 'contributions'}`} - - )), + row.map( + ({ year, count, monthStart, monthEnd, monthKey }, colIdx) => ( + + navigate(`/activity/${monthStart}..${monthEnd}`) + } + > + {`${MONTH_LABELS[rowIdx]} ${year}: ${count} ${count === 1 ? "contribution" : "contributions"}`} + + ), + ), )}
@@ -349,7 +426,14 @@ function fmt(d: Date): string { } /** Build a map of date → dominant (highest commit count) language color. */ -function buildDominantColorMap(entries: { date: string; language: string; color: string | null; commits: number }[]): Map { +function buildDominantColorMap( + entries: { + date: string; + language: string; + color: string | null; + commits: number; + }[], +): Map { const map = new Map(); for (const e of entries) { const cur = map.get(e.date); @@ -374,6 +458,7 @@ function opacityFor(count: number, thresholds: number[]): number { function computeThresholds(sorted: number[]): number[] { if (sorted.length === 0) return [1, 2, 3]; - const p = (pct: number) => sorted[Math.min(Math.floor(pct * sorted.length), sorted.length - 1)]; + const p = (pct: number) => + sorted[Math.min(Math.floor(pct * sorted.length), sorted.length - 1)]; return [p(0.25), p(0.5), p(0.75)]; }