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 { weeklyData, yearMarkers, thresholds, totalCount } = useMemo(() => {
const { yearRows, thresholds, totalCount } = useMemo(() => {
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]));
// Start from the Sunday before earliest
const start = new Date(from);
start.setDate(start.getDate() - start.getDay());
// 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 weeklyData: { weekStart: string; weekEnd: string; count: number; col: number }[] = [];
const yearMarkers: { col: number; label: string }[] = [];
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;
let prevYear = -1;
const cursor = new Date(start);
while (cursor <= to) {
while (cursor <= yearEnd) {
const weekStart = fmt(cursor);
let weekCount = 0;
for (let d = 0; d < 7; d++) {
@@ -194,32 +199,29 @@ export function AllTimeGraph() {
}
const weekEnd = fmt(cursor);
cursor.setDate(cursor.getDate() + 1);
const yr = new Date(weekStart).getFullYear();
if (yr !== prevYear) {
yearMarkers.push({ col, label: String(yr) });
prevYear = yr;
}
weeklyData.push({ weekStart, weekEnd, count: weekCount, col });
weeks.push({ weekStart, weekEnd, count: weekCount, 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 totalCount = counts.reduce((sum, d) => sum + d.count, 0);
return { weeklyData, yearMarkers, thresholds, totalCount };
return { yearRows, thresholds, totalCount };
}, [dailyQ.data]);
if (!earliest || dailyQ.isLoading) return null;
if (dailyQ.isError) return null;
if (weeklyData.length === 0) return null;
if (yearRows.length === 0) return null;
const cols = weeklyData.length;
const labelHeight = 14;
const svgWidth = cols * (CELL_SIZE + GAP);
const svgHeight = labelHeight + CELL_SIZE + GAP;
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);
return (
<div className="contribution-graph mb-4">
@@ -228,22 +230,22 @@ export function AllTimeGraph() {
</p>
<div>
<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
key={i}
x={col * (CELL_SIZE + GAP) + RADIUS}
y={10}
textAnchor="middle"
x={yearLabelWidth - 4}
y={rowIdx * (CELL_SIZE + GAP) + CELL_SIZE / 2}
textAnchor="end"
dominantBaseline="central"
className="graph-label"
>
{label}
{year}
</text>
))}
{weeklyData.map(({ weekStart, weekEnd, count, col }) => (
{weeks.map(({ weekStart, weekEnd, count, col }) => (
<circle
key={weekStart}
cx={col * (CELL_SIZE + GAP) + RADIUS}
cy={labelHeight + RADIUS}
cx={yearLabelWidth + col * (CELL_SIZE + GAP) + RADIUS}
cy={rowIdx * (CELL_SIZE + GAP) + RADIUS}
r={RADIUS - 1}
fill={colorFor(count, thresholds)}
className="graph-cell"
@@ -252,6 +254,8 @@ export function AllTimeGraph() {
<title>{`${weekStart}${weekEnd}: ${count} ${count === 1 ? 'contribution' : 'contributions'}`}</title>
</circle>
))}
</g>
))}
</svg>
</div>
</div>