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() {
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 }]) => (
+
+ ))}
+
+
+ );
+}
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}