fix(ui): all-time graph as year rows with 52 weekly columns each

Restructure the all-time contribution graph from a single row of ~700
circles (sub-pixel when scaled) to one row per year with ~52 weekly
columns, matching the width of the daily graph above. Year labels on
the left.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 17:15:49 +03:00
parent 1ca85fe632
commit 2284a886d0

View File

@@ -169,23 +169,28 @@ export function AllTimeGraph() {
const navigate = useNavigate(); const navigate = useNavigate();
const { weeklyData, yearMarkers, thresholds, totalCount } = useMemo(() => { const { yearRows, thresholds, totalCount } = useMemo(() => {
const counts = dailyQ.data ?? []; const counts = dailyQ.data ?? [];
if (counts.length === 0) return { weeklyData: [], yearMarkers: [], thresholds: [1, 2, 3], totalCount: 0 }; if (counts.length === 0) return { yearRows: [], 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]));
// Start from the Sunday before earliest // Group into year rows, each with ~52 weekly columns
const start = new Date(from); // Start each year from Jan 1, aligned to its preceding Sunday
start.setDate(start.getDate() - start.getDay()); const startYear = from.getFullYear();
const endYear = to.getFullYear();
const yearRows: { year: number; weeks: { weekStart: string; weekEnd: string; count: number; col: number }[] }[] = [];
const weeklyData: { weekStart: string; weekEnd: string; count: number; col: number }[] = []; for (let yr = startYear; yr <= endYear; yr++) {
const yearMarkers: { col: number; label: string }[] = []; 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; let col = 0;
let prevYear = -1; while (cursor <= yearEnd) {
const cursor = new Date(start);
while (cursor <= to) {
const weekStart = fmt(cursor); const weekStart = fmt(cursor);
let weekCount = 0; let weekCount = 0;
for (let d = 0; d < 7; d++) { for (let d = 0; d < 7; d++) {
@@ -194,32 +199,29 @@ export function AllTimeGraph() {
} }
const weekEnd = fmt(cursor); const weekEnd = fmt(cursor);
cursor.setDate(cursor.getDate() + 1); cursor.setDate(cursor.getDate() + 1);
weeks.push({ weekStart, weekEnd, count: weekCount, col });
const yr = new Date(weekStart).getFullYear();
if (yr !== prevYear) {
yearMarkers.push({ col, label: String(yr) });
prevYear = yr;
}
weeklyData.push({ weekStart, weekEnd, count: weekCount, col });
col++; col++;
} }
yearRows.push({ year: yr, weeks });
}
const nonZero = weeklyData.map((w) => w.count).filter((c) => c > 0).sort((a, b) => a - b); const allWeekCounts = yearRows.flatMap((r) => r.weeks.map((w) => w.count));
const nonZero = allWeekCounts.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 { weeklyData, yearMarkers, thresholds, totalCount }; return { yearRows, 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 (weeklyData.length === 0) return null; if (yearRows.length === 0) return null;
const cols = weeklyData.length; const maxCols = 53; // max weeks in a year
const labelHeight = 14; const yearLabelWidth = 32;
const svgWidth = cols * (CELL_SIZE + GAP); const svgWidth = yearLabelWidth + maxCols * (CELL_SIZE + GAP);
const svgHeight = labelHeight + CELL_SIZE + GAP; const rows = yearRows.length;
const svgHeight = rows * (CELL_SIZE + GAP);
return ( return (
<div className="contribution-graph mb-4"> <div className="contribution-graph mb-4">
@@ -228,22 +230,22 @@ 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">
{yearMarkers.map(({ col, label }, i) => ( {yearRows.map(({ year, weeks }, rowIdx) => (
<g key={year}>
<text <text
key={i} x={yearLabelWidth - 4}
x={col * (CELL_SIZE + GAP) + RADIUS} y={rowIdx * (CELL_SIZE + GAP) + CELL_SIZE / 2}
y={10} textAnchor="end"
textAnchor="middle" dominantBaseline="central"
className="graph-label" className="graph-label"
> >
{label} {year}
</text> </text>
))} {weeks.map(({ weekStart, weekEnd, count, col }) => (
{weeklyData.map(({ weekStart, weekEnd, count, col }) => (
<circle <circle
key={weekStart} key={weekStart}
cx={col * (CELL_SIZE + GAP) + RADIUS} cx={yearLabelWidth + col * (CELL_SIZE + GAP) + RADIUS}
cy={labelHeight + RADIUS} cy={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"
@@ -252,6 +254,8 @@ export function AllTimeGraph() {
<title>{`${weekStart}${weekEnd}: ${count} ${count === 1 ? 'contribution' : 'contributions'}`}</title> <title>{`${weekStart}${weekEnd}: ${count} ${count === 1 ? 'contribution' : 'contributions'}`}</title>
</circle> </circle>
))} ))}
</g>
))}
</svg> </svg>
</div> </div>
</div> </div>