feat(ui): reshape all-time graph and add dashboard stats panels

Transpose AllTimeGraph to show years on X axis and months on Y axis
instead of year-per-row with weekly columns. Add TopLanguages bar chart
(all-time code volume by language) and ContributionStats panel (current
and longest streaks, busiest day, active days, weekday averages) in a
three-column row matching the project card grid.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 15:27:39 +03:00
parent 111a2af573
commit f386e0b574
4 changed files with 296 additions and 61 deletions

View File

@@ -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() { export function AllTimeGraph() {
const sourcesQ = useQuery({ const sourcesQ = useQuery({
queryKey: ['sources'], queryKey: ['sources'],
@@ -194,59 +194,57 @@ export function AllTimeGraph() {
const navigate = useNavigate(); const navigate = useNavigate();
const { yearRows, thresholds, totalCount } = useMemo(() => { const { years, monthGrid, thresholds, totalCount } = useMemo(() => {
const counts = dailyQ.data ?? []; 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])); 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 startYear = from.getFullYear();
const endYear = to.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++) { // Build a 12 x years grid of monthly totals
const yearStart = new Date(yr, 0, 1); const monthGrid: { year: number; month: number; count: number; monthStart: string; monthEnd: string }[][] = [];
const yearEnd = yr === endYear ? to : new Date(yr, 11, 31); for (let m = 0; m < 12; m++) {
// Align to preceding Sunday const row: typeof monthGrid[0] = [];
const cursor = new Date(yearStart); for (const yr of years) {
cursor.setDate(cursor.getDate() - cursor.getDay()); const monthStart = new Date(yr, m, 1);
const monthEnd = new Date(yr, m + 1, 0); // last day of month
const weeks: typeof yearRows[0]['weeks'] = []; // Don't include months entirely outside our data range
let col = 0; if (monthStart > to || monthEnd < from) {
while (cursor <= yearEnd) { row.push({ year: yr, month: m, count: 0, monthStart: fmt(monthStart), monthEnd: fmt(monthEnd) });
const weekStart = fmt(cursor); continue;
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); let total = 0;
const cursor = new Date(monthStart);
while (cursor <= monthEnd && cursor <= to) {
total += countMap.get(fmt(cursor)) ?? 0;
cursor.setDate(cursor.getDate() + 1); cursor.setDate(cursor.getDate() + 1);
weeks.push({ weekStart, weekEnd, count: weekCount, col });
col++;
} }
yearRows.push({ year: yr, weeks }); row.push({ year: yr, month: m, count: total, monthStart: fmt(monthStart), monthEnd: fmt(monthEnd) });
}
monthGrid.push(row);
} }
const allWeekCounts = yearRows.flatMap((r) => r.weeks.map((w) => w.count)); const allCounts = monthGrid.flat().map((c) => c.count);
const nonZero = allWeekCounts.filter((c) => c > 0).sort((a, b) => a - b); const nonZero = allCounts.filter((c) => c > 0).sort((a, b) => a - b);
const thresholds = computeThresholds(nonZero); const thresholds = computeThresholds(nonZero);
const totalCount = counts.reduce((sum, d) => sum + d.count, 0); const totalCount = counts.reduce((sum, d) => sum + d.count, 0);
return { yearRows, thresholds, totalCount }; return { years, monthGrid, thresholds, totalCount };
}, [dailyQ.data]); }, [dailyQ.data]);
if (!earliest || dailyQ.isLoading) return null; if (!earliest || dailyQ.isLoading) return null;
if (dailyQ.isError) 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 monthLabelWidth = 28;
const yearLabelWidth = 32; const topLabelHeight = 16;
const svgWidth = yearLabelWidth + maxCols * (CELL_SIZE + GAP); const numCols = years.length;
const rows = yearRows.length; const svgWidth = monthLabelWidth + numCols * (CELL_SIZE + GAP);
const svgHeight = rows * (CELL_SIZE + GAP); const svgHeight = topLabelHeight + 12 * (CELL_SIZE + GAP);
return ( return (
<div className="contribution-graph mb-4"> <div className="contribution-graph mb-4">
@@ -256,32 +254,47 @@ export function AllTimeGraph() {
</p> </p>
<div> <div>
<svg viewBox={`0 0 ${svgWidth} ${svgHeight}`} width="100%" className="d-block"> <svg viewBox={`0 0 ${svgWidth} ${svgHeight}`} width="100%" className="d-block">
{yearRows.map(({ year, weeks }, rowIdx) => ( {/* Year labels along the top */}
<g key={year}> {years.map((year, colIdx) => (
<text <text
x={yearLabelWidth - 4} key={year}
y={rowIdx * (CELL_SIZE + GAP) + CELL_SIZE / 2} x={monthLabelWidth + colIdx * (CELL_SIZE + GAP) + RADIUS}
y={10}
textAnchor="middle"
className="graph-label"
>
{String(year).slice(2)}
</text>
))}
{/* Month labels along the left */}
{MONTH_LABELS.map((label, rowIdx) => (
<text
key={rowIdx}
x={monthLabelWidth - 6}
y={topLabelHeight + rowIdx * (CELL_SIZE + GAP) + CELL_SIZE / 2}
textAnchor="end" textAnchor="end"
dominantBaseline="central" dominantBaseline="central"
className="graph-label" className="graph-label"
> >
{year} {label}
</text> </text>
{weeks.map(({ weekStart, weekEnd, count, col }) => ( ))}
{/* Monthly contribution circles */}
{monthGrid.map((row, rowIdx) =>
row.map(({ year, count, monthStart, monthEnd }, colIdx) => (
<circle <circle
key={weekStart} key={`${year}-${rowIdx}`}
cx={yearLabelWidth + col * (CELL_SIZE + GAP) + RADIUS} cx={monthLabelWidth + colIdx * (CELL_SIZE + GAP) + RADIUS}
cy={rowIdx * (CELL_SIZE + GAP) + RADIUS} cy={topLabelHeight + rowIdx * (CELL_SIZE + GAP) + RADIUS}
r={RADIUS - 1} r={RADIUS - 1}
fill={colorFor(count, thresholds)} fill={colorFor(count, thresholds)}
className="graph-cell" className="graph-cell"
onClick={() => navigate(`/activity/${weekStart}..${weekEnd}`)} onClick={() => navigate(`/activity/${monthStart}..${monthEnd}`)}
> >
<title>{`${weekStart} ${weekEnd}: ${count} ${count === 1 ? 'contribution' : 'contributions'}`}</title> <title>{`${MONTH_LABELS[rowIdx]} ${year}: ${count} ${count === 1 ? 'contribution' : 'contributions'}`}</title>
</circle> </circle>
))} )),
</g> )}
))}
</svg> </svg>
</div> </div>
</div> </div>

View File

@@ -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 (
<div>
<p style={{ fontSize: '0.8rem', opacity: 0.6 }}>contribution stats</p>
<div className="d-flex flex-column gap-2" style={{ fontSize: '0.8rem' }}>
<div className="d-flex justify-content-between">
<span style={{ opacity: 0.7 }}>current streak</span>
<span>{stats.currentStreak} {stats.currentStreak === 1 ? 'day' : 'days'}</span>
</div>
<div className="d-flex justify-content-between">
<span style={{ opacity: 0.7 }}>longest streak</span>
<span>{stats.longestStreak} {stats.longestStreak === 1 ? 'day' : 'days'}</span>
</div>
<div className="d-flex justify-content-between">
<span style={{ opacity: 0.7 }}>busiest day</span>
<span>{stats.busiest.count} on {stats.busiest.date}</span>
</div>
<div className="d-flex justify-content-between">
<span style={{ opacity: 0.7 }}>active days</span>
<span>{stats.activeDays.toLocaleString()}</span>
</div>
<div className="mt-1">
<span style={{ opacity: 0.7, fontSize: '0.75rem' }}>avg by weekday</span>
<div className="d-flex flex-column gap-1 mt-1">
{stats.dayAvgs.map(({ name, avg }) => (
<div key={name} className="d-flex align-items-center gap-2" style={{ fontSize: '0.75rem' }}>
<span style={{ width: 24, textAlign: 'right', opacity: 0.7 }}>{name}</span>
<div style={{ flex: 1, height: 8, borderRadius: 3, background: 'rgba(255,255,255,0.05)' }}>
<div
style={{
width: stats.maxAvg > 0 ? `${(avg / stats.maxAvg) * 100}%` : '0%',
height: '100%',
borderRadius: 3,
backgroundColor: '#39d353',
opacity: 0.7,
}}
/>
</div>
<span style={{ width: 28, textAlign: 'right', opacity: 0.6 }}>{avg.toFixed(1)}</span>
</div>
))}
</div>
</div>
</div>
</div>
);
}
function fmt(d: Date): string {
return d.toISOString().slice(0, 10);
}

View File

@@ -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<string, { bytes: number; color: string }>();
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 (
<div>
<p style={{ fontSize: '0.8rem', opacity: 0.6 }}>
top languages by code volume
</p>
<div className="d-flex flex-column gap-1">
{ranked.map(([lang, { bytes, color }]) => (
<div key={lang} className="d-flex align-items-center gap-2" style={{ fontSize: '0.75rem' }}>
<span style={{ width: 70, textAlign: 'right', opacity: 0.8, flexShrink: 0 }}>{lang}</span>
<div style={{ flex: 1, height: 10, borderRadius: 3, background: 'rgba(255,255,255,0.05)' }}>
<div
style={{
width: `${(bytes / maxBytes) * 100}%`,
height: '100%',
borderRadius: 3,
backgroundColor: color,
opacity: 0.85,
}}
/>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -7,7 +7,9 @@ import Row from 'react-bootstrap/Row';
import { fetchProjects, fetchRepoLanguages, type ProjectSummary } from '../api/client'; import { fetchProjects, fetchRepoLanguages, type ProjectSummary } from '../api/client';
import { LanguageBar } from '../components/LanguageBar'; import { LanguageBar } from '../components/LanguageBar';
import { ContributionGraph, AllTimeGraph } from '../components/ContributionGraph'; import { ContributionGraph, AllTimeGraph } from '../components/ContributionGraph';
import { ContributionStats } from '../components/ContributionStats';
import { LanguageStreamGraph } from '../components/LanguageStreamGraph'; import { LanguageStreamGraph } from '../components/LanguageStreamGraph';
import { TopLanguages } from '../components/TopLanguages';
export function DashPage() { export function DashPage() {
const projectsQ = useQuery({ const projectsQ = useQuery({
@@ -55,7 +57,17 @@ export function DashPage() {
</Row> </Row>
<ContributionGraph /> <ContributionGraph />
<LanguageStreamGraph /> <LanguageStreamGraph />
<Row xs={1} md={2} lg={3} className="g-3 mb-3">
<Col>
<AllTimeGraph /> <AllTimeGraph />
</Col>
<Col>
<TopLanguages />
</Col>
<Col>
<ContributionStats />
</Col>
</Row>
{projectsQ.isLoading && <p>loading...</p>} {projectsQ.isLoading && <p>loading...</p>}
{projectsQ.isError && ( {projectsQ.isError && (
<p>error: {(projectsQ.error as Error).message}</p> <p>error: {(projectsQ.error as Error).message}</p>