diff --git a/ui/src/components/ContributionGraph.tsx b/ui/src/components/ContributionGraph.tsx index f5e30b2..a1a47f1 100644 --- a/ui/src/components/ContributionGraph.tsx +++ b/ui/src/components/ContributionGraph.tsx @@ -156,7 +156,7 @@ export function ContributionGraph() { ); } -/** All-time weekly contribution graph — one circle per week. */ +/** All-time monthly contribution graph — years on X axis, months on Y axis. */ export function AllTimeGraph() { const sourcesQ = useQuery({ queryKey: ['sources'], @@ -194,59 +194,57 @@ export function AllTimeGraph() { const navigate = useNavigate(); - const { yearRows, thresholds, totalCount } = useMemo(() => { + const { years, monthGrid, thresholds, totalCount } = useMemo(() => { const counts = dailyQ.data ?? []; - if (counts.length === 0) return { yearRows: [], 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])); - // Group into year rows, each with ~52 weekly columns - // Start each year from Jan 1, aligned to its preceding Sunday const startYear = from.getFullYear(); const endYear = to.getFullYear(); - const yearRows: { year: number; weeks: { weekStart: string; weekEnd: string; count: number; col: number }[] }[] = []; + const years: number[] = []; + for (let yr = startYear; yr <= endYear; yr++) years.push(yr); - for (let yr = startYear; yr <= endYear; yr++) { - const yearStart = new Date(yr, 0, 1); - const yearEnd = yr === endYear ? to : new Date(yr, 11, 31); - // Align to preceding Sunday - const cursor = new Date(yearStart); - cursor.setDate(cursor.getDate() - cursor.getDay()); - - const weeks: typeof yearRows[0]['weeks'] = []; - let col = 0; - while (cursor <= yearEnd) { - 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); + // Build a 12 x years grid of monthly totals + const monthGrid: { year: number; month: number; count: number; monthStart: string; monthEnd: 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 + // 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) }); + continue; } - const weekEnd = fmt(cursor); - cursor.setDate(cursor.getDate() + 1); - weeks.push({ weekStart, weekEnd, count: weekCount, col }); - col++; + let total = 0; + const cursor = new Date(monthStart); + while (cursor <= monthEnd && cursor <= to) { + 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) }); } - yearRows.push({ year: yr, weeks }); + monthGrid.push(row); } - const allWeekCounts = yearRows.flatMap((r) => r.weeks.map((w) => w.count)); - const nonZero = allWeekCounts.filter((c) => c > 0).sort((a, b) => a - b); + const allCounts = monthGrid.flat().map((c) => c.count); + const nonZero = allCounts.filter((c) => c > 0).sort((a, b) => a - b); const thresholds = computeThresholds(nonZero); const totalCount = counts.reduce((sum, d) => sum + d.count, 0); - return { yearRows, thresholds, totalCount }; + return { years, monthGrid, thresholds, totalCount }; }, [dailyQ.data]); if (!earliest || dailyQ.isLoading) return null; if (dailyQ.isError) return null; - if (yearRows.length === 0) return null; + if (years.length === 0) return null; - const maxCols = 53; // max weeks in a year - const yearLabelWidth = 32; - const svgWidth = yearLabelWidth + maxCols * (CELL_SIZE + GAP); - const rows = yearRows.length; - const svgHeight = rows * (CELL_SIZE + GAP); + const monthLabelWidth = 28; + const topLabelHeight = 16; + const numCols = years.length; + const svgWidth = monthLabelWidth + numCols * (CELL_SIZE + GAP); + const svgHeight = topLabelHeight + 12 * (CELL_SIZE + GAP); return (
@@ -256,32 +254,47 @@ export function AllTimeGraph() {

- {yearRows.map(({ year, weeks }, rowIdx) => ( - - - {year} - - {weeks.map(({ weekStart, weekEnd, count, col }) => ( - navigate(`/activity/${weekStart}..${weekEnd}`)} - > - {`${weekStart} — ${weekEnd}: ${count} ${count === 1 ? 'contribution' : 'contributions'}`} - - ))} - + {/* Year labels along the top */} + {years.map((year, colIdx) => ( + + {String(year).slice(2)} + ))} + {/* Month labels along the left */} + {MONTH_LABELS.map((label, rowIdx) => ( + + {label} + + ))} + {/* Monthly contribution circles */} + {monthGrid.map((row, rowIdx) => + row.map(({ year, count, monthStart, monthEnd }, colIdx) => ( + navigate(`/activity/${monthStart}..${monthEnd}`)} + > + {`${MONTH_LABELS[rowIdx]} ${year}: ${count} ${count === 1 ? 'contribution' : 'contributions'}`} + + )), + )}
diff --git a/ui/src/components/ContributionStats.tsx b/ui/src/components/ContributionStats.tsx new file mode 100644 index 0000000..897a595 --- /dev/null +++ b/ui/src/components/ContributionStats.tsx @@ -0,0 +1,150 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; + +import { fetchDailyCounts, fetchSources } from '../api/client'; + +export function ContributionStats() { + 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 stats = useMemo(() => { + const counts = dailyQ.data ?? []; + if (counts.length === 0) return null; + + // Build a set of dates with contributions + const countMap = new Map(counts.map((d) => [d.date, d.count])); + + // Current streak (consecutive days ending today or yesterday with contributions) + let currentStreak = 0; + const cursor = new Date(to); + // Allow today to have 0 (day isn't over yet) — start from yesterday if today is 0 + if ((countMap.get(fmt(cursor)) ?? 0) > 0) { + currentStreak = 1; + cursor.setDate(cursor.getDate() - 1); + } else { + cursor.setDate(cursor.getDate() - 1); + if ((countMap.get(fmt(cursor)) ?? 0) > 0) { + currentStreak = 1; + cursor.setDate(cursor.getDate() - 1); + } + } + if (currentStreak > 0) { + while ((countMap.get(fmt(cursor)) ?? 0) > 0) { + currentStreak++; + cursor.setDate(cursor.getDate() - 1); + } + } + + // Longest streak + let longestStreak = 0; + let streak = 0; + const sorted = [...counts].sort((a, b) => a.date.localeCompare(b.date)); + for (let i = 0; i < sorted.length; i++) { + if (sorted[i].count > 0) { + streak++; + if (streak > longestStreak) longestStreak = streak; + } else { + streak = 0; + } + } + + // Busiest day + const busiest = sorted.reduce((best, d) => (d.count > best.count ? d : best), sorted[0]); + + // Day-of-week averages + const dayTotals = [0, 0, 0, 0, 0, 0, 0]; + const dayCounts = [0, 0, 0, 0, 0, 0, 0]; + for (const d of sorted) { + const dow = new Date(d.date + 'T00:00:00').getDay(); + dayTotals[dow] += d.count; + dayCounts[dow]++; + } + const dayNames = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; + const dayAvgs = dayNames.map((name, i) => ({ + name, + avg: dayCounts[i] > 0 ? dayTotals[i] / dayCounts[i] : 0, + })); + const maxAvg = Math.max(...dayAvgs.map((d) => d.avg)); + + // Total active days + const activeDays = sorted.filter((d) => d.count > 0).length; + + return { currentStreak, longestStreak, busiest, dayAvgs, maxAvg, activeDays }; + }, [dailyQ.data]); + + if (!stats) return null; + + return ( +
+

contribution stats

+
+
+ current streak + {stats.currentStreak} {stats.currentStreak === 1 ? 'day' : 'days'} +
+
+ longest streak + {stats.longestStreak} {stats.longestStreak === 1 ? 'day' : 'days'} +
+
+ busiest day + {stats.busiest.count} on {stats.busiest.date} +
+
+ active days + {stats.activeDays.toLocaleString()} +
+
+ avg by weekday +
+ {stats.dayAvgs.map(({ name, avg }) => ( +
+ {name} +
+
0 ? `${(avg / stats.maxAvg) * 100}%` : '0%', + height: '100%', + borderRadius: 3, + backgroundColor: '#39d353', + opacity: 0.7, + }} + /> +
+ {avg.toFixed(1)} +
+ ))} +
+
+
+
+ ); +} + +function fmt(d: Date): string { + return d.toISOString().slice(0, 10); +} diff --git a/ui/src/components/TopLanguages.tsx b/ui/src/components/TopLanguages.tsx new file mode 100644 index 0000000..51ec148 --- /dev/null +++ b/ui/src/components/TopLanguages.tsx @@ -0,0 +1,60 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; + +import { fetchRepoLanguages } from '../api/client'; + +const MAX_LANGS = 10; + +export function TopLanguages() { + const langsQ = useQuery({ + queryKey: ['repo-languages'], + queryFn: fetchRepoLanguages, + staleTime: 10 * 60_000, + }); + + const ranked = useMemo(() => { + if (!langsQ.data) return []; + const totals = new Map(); + for (const e of langsQ.data) { + const cur = totals.get(e.language); + if (cur) { + cur.bytes += e.bytes; + } else { + totals.set(e.language, { bytes: e.bytes, color: e.color ?? '#8b8b8b' }); + } + } + return [...totals.entries()] + .sort(([, a], [, b]) => b.bytes - a.bytes) + .slice(0, MAX_LANGS); + }, [langsQ.data]); + + if (!langsQ.data || ranked.length === 0) return null; + + const maxBytes = ranked[0][1].bytes; + + return ( +
+

+ top languages by code volume +

+
+ {ranked.map(([lang, { bytes, color }]) => ( +
+ {lang} +
+
+
+
+ ))} +
+
+ ); +} diff --git a/ui/src/pages/DashPage.tsx b/ui/src/pages/DashPage.tsx index 1629e34..72b13b8 100644 --- a/ui/src/pages/DashPage.tsx +++ b/ui/src/pages/DashPage.tsx @@ -7,7 +7,9 @@ import Row from 'react-bootstrap/Row'; import { fetchProjects, fetchRepoLanguages, type ProjectSummary } from '../api/client'; import { LanguageBar } from '../components/LanguageBar'; import { ContributionGraph, AllTimeGraph } from '../components/ContributionGraph'; +import { ContributionStats } from '../components/ContributionStats'; import { LanguageStreamGraph } from '../components/LanguageStreamGraph'; +import { TopLanguages } from '../components/TopLanguages'; export function DashPage() { const projectsQ = useQuery({ @@ -55,7 +57,17 @@ export function DashPage() { - + + + + + + + + + + + {projectsQ.isLoading &&

loading...

} {projectsQ.isError && (

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