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,57 +169,59 @@ 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);
let col = 0; const yearEnd = yr === endYear ? to : new Date(yr, 11, 31);
let prevYear = -1; // Align to preceding Sunday
const cursor = new Date(start); const cursor = new Date(yearStart);
cursor.setDate(cursor.getDate() - cursor.getDay());
while (cursor <= to) { const weeks: typeof yearRows[0]['weeks'] = [];
const weekStart = fmt(cursor); let col = 0;
let weekCount = 0; while (cursor <= yearEnd) {
for (let d = 0; d < 7; d++) { const weekStart = fmt(cursor);
weekCount += countMap.get(fmt(cursor)) ?? 0; let weekCount = 0;
if (d < 6) cursor.setDate(cursor.getDate() + 1); 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);
cursor.setDate(cursor.getDate() + 1);
weeks.push({ weekStart, weekEnd, count: weekCount, col });
col++;
} }
const weekEnd = fmt(cursor); yearRows.push({ year: yr, weeks });
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 });
col++;
} }
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,29 +230,31 @@ 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) => (
<text <g key={year}>
key={i} <text
x={col * (CELL_SIZE + GAP) + RADIUS} x={yearLabelWidth - 4}
y={10} y={rowIdx * (CELL_SIZE + GAP) + CELL_SIZE / 2}
textAnchor="middle" textAnchor="end"
className="graph-label" dominantBaseline="central"
> className="graph-label"
{label} >
</text> {year}
))} </text>
{weeklyData.map(({ weekStart, weekEnd, count, col }) => ( {weeks.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"
onClick={() => navigate(`/activity/${weekStart}..${weekEnd}`)} onClick={() => navigate(`/activity/${weekStart}..${weekEnd}`)}
> >
<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>