diff --git a/ui/src/components/ContributionGraph.tsx b/ui/src/components/ContributionGraph.tsx index a1a47f1..6250b90 100644 --- a/ui/src/components/ContributionGraph.tsx +++ b/ui/src/components/ContributionGraph.tsx @@ -2,7 +2,7 @@ import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; -import { fetchDailyCounts, fetchProjects, fetchSources } from '../api/client'; +import { fetchDailyCounts, fetchLanguageDailyCounts, fetchProjects, fetchSources } from '../api/client'; const CELL_SIZE = 12; const GAP = 3; @@ -14,13 +14,8 @@ 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 COLORS = [ - 'rgba(255,255,255,0.05)', - '#0e4429', - '#006d32', - '#26a641', - '#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() { @@ -37,6 +32,12 @@ export function ContributionGraph() { staleTime: 5 * 60_000, }); + const langQ = useQuery({ + queryKey: ['language-daily', fromStr, toStr], + queryFn: () => fetchLanguageDailyCounts(fromStr, toStr), + staleTime: 5 * 60_000, + }); + const projectsQ = useQuery({ queryKey: ['projects'], queryFn: fetchProjects, @@ -56,6 +57,11 @@ export function ContributionGraph() { const navigate = useNavigate(); + // Build map of date → dominant language color + const dayColorMap = useMemo(() => { + return buildDominantColorMap(langQ.data ?? []); + }, [langQ.data]); + const { weeks, monthMarkers, thresholds, totalCount } = useMemo(() => { const counts = dailyQ.data ?? []; const countMap = new Map(counts.map((d) => [d.date, d.count])); @@ -142,7 +148,8 @@ export function ContributionGraph() { cx={LEFT_LABEL_WIDTH + col * (CELL_SIZE + GAP) + RADIUS} cy={TOP_LABEL_HEIGHT + row * (CELL_SIZE + GAP) + RADIUS} r={RADIUS - 1} - fill={colorFor(count, thresholds)} + fill={count === 0 ? EMPTY_COLOR : (dayColorMap.get(date) ?? FALLBACK_COLOR)} + opacity={count === 0 ? 1 : opacityFor(count, thresholds)} className="graph-cell" onClick={() => navigate(`/activity/${date}`)} > @@ -192,8 +199,42 @@ export function AllTimeGraph() { staleTime: 10 * 60_000, }); + const langQ = useQuery({ + queryKey: ['language-daily-alltime', fromStr, toStr], + queryFn: () => fetchLanguageDailyCounts(fromStr, toStr), + enabled: !!earliest, + staleTime: 10 * 60_000, + }); + const navigate = useNavigate(); + // Aggregate daily language data to month level: pick the language with most commits + const monthColorMap = useMemo(() => { + const entries = langQ.data ?? []; + if (entries.length === 0) return new Map(); + const map = new Map>(); + for (const e of entries) { + const key = e.date.slice(0, 7); // YYYY-MM + if (!map.has(key)) map.set(key, new Map()); + const langMap = map.get(key)!; + const cur = langMap.get(e.language); + if (cur) { + cur.commits += e.commits; + } else { + langMap.set(e.language, { commits: e.commits, color: e.color ?? FALLBACK_COLOR }); + } + } + const result = new Map(); + for (const [key, langMap] of map) { + let best = { commits: 0, color: FALLBACK_COLOR }; + for (const v of langMap.values()) { + if (v.commits > best.commits) best = v; + } + result.set(key, best.color); + } + return result; + }, [langQ.data]); + const { years, monthGrid, thresholds, totalCount } = useMemo(() => { const counts = dailyQ.data ?? []; if (counts.length === 0) return { years: [], monthGrid: [], thresholds: [1, 2, 3], totalCount: 0 }; @@ -206,15 +247,16 @@ 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 }[][] = []; + 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] = []; 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')}`; // 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) }); + row.push({ year: yr, month: m, count: 0, monthStart: fmt(monthStart), monthEnd: fmt(monthEnd), monthKey }); continue; } let total = 0; @@ -223,7 +265,7 @@ 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) }); + row.push({ year: yr, month: m, count: total, monthStart: fmt(monthStart), monthEnd: fmt(monthEnd), monthKey }); } monthGrid.push(row); } @@ -281,13 +323,14 @@ export function AllTimeGraph() { ))} {/* Monthly contribution circles */} {monthGrid.map((row, rowIdx) => - row.map(({ year, count, monthStart, monthEnd }, colIdx) => ( + row.map(({ year, count, monthStart, monthEnd, monthKey }, colIdx) => ( navigate(`/activity/${monthStart}..${monthEnd}`)} > @@ -305,12 +348,28 @@ function fmt(d: Date): string { return d.toISOString().slice(0, 10); } -function colorFor(count: number, thresholds: number[]): string { - if (count === 0) return COLORS[0]; - if (count <= thresholds[0]) return COLORS[1]; - if (count <= thresholds[1]) return COLORS[2]; - if (count <= thresholds[2]) return COLORS[3]; - return COLORS[4]; +/** Build a map of date → dominant (highest commit count) language color. */ +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); + if (!cur || e.commits > cur.commits) { + map.set(e.date, { commits: e.commits, color: e.color ?? FALLBACK_COLOR }); + } + } + const result = new Map(); + for (const [date, { color }] of map) { + result.set(date, color); + } + return result; +} + +/** Map count to opacity (0.3 – 1.0) based on quartile thresholds. */ +function opacityFor(count: number, thresholds: number[]): number { + if (count <= thresholds[0]) return 0.35; + if (count <= thresholds[1]) return 0.55; + if (count <= thresholds[2]) return 0.75; + return 1; } function computeThresholds(sorted: number[]): number[] {