feat(ui): contribution graph with daily activity heatmap
Add /v1/activity/daily endpoint returning per-day event counts via generate_series + LEFT JOIN. Frontend renders an SVG contribution graph with circles colored by quantile-based thresholds. Clicking a day navigates to /activity/YYYY-MM-DD showing that day's events. New /activity/:timespan route parses single dates (YYYY-MM-DD) and ranges (YYYY-MM-DD..YYYY-MM-DD) from the URL to initialize the activity timeline filter. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
163
ui/src/components/ContributionGraph.tsx
Normal file
163
ui/src/components/ContributionGraph.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { fetchDailyCounts } from '../api/client';
|
||||
|
||||
const CELL_SIZE = 12;
|
||||
const GAP = 3;
|
||||
const RADIUS = CELL_SIZE / 2;
|
||||
const ROWS = 7; // days per week
|
||||
const LEFT_LABEL_WIDTH = 28;
|
||||
const TOP_LABEL_HEIGHT = 16;
|
||||
|
||||
const DAY_LABELS = ['', 'mon', '', 'wed', '', 'fri', ''];
|
||||
const MONTH_LABELS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
|
||||
|
||||
const COLORS = [
|
||||
'rgba(255,255,255,0.05)', // 0: empty
|
||||
'#0e4429', // 1: low
|
||||
'#006d32', // 2: medium-low
|
||||
'#26a641', // 3: medium
|
||||
'#39d353', // 4: high
|
||||
];
|
||||
|
||||
export function ContributionGraph() {
|
||||
const to = new Date();
|
||||
const from = new Date(to);
|
||||
from.setFullYear(from.getFullYear() - 1);
|
||||
|
||||
const fromStr = fmt(from);
|
||||
const toStr = fmt(to);
|
||||
|
||||
const dailyQ = useQuery({
|
||||
queryKey: ['daily-counts', fromStr, toStr],
|
||||
queryFn: () => fetchDailyCounts(fromStr, toStr),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { weeks, monthMarkers, thresholds, totalCount } = useMemo(() => {
|
||||
const counts = dailyQ.data ?? [];
|
||||
const countMap = new Map(counts.map((d) => [d.date, d.count]));
|
||||
|
||||
// Start from the Sunday before `from`
|
||||
const start = new Date(from);
|
||||
start.setDate(start.getDate() - start.getDay());
|
||||
|
||||
const weeks: { date: string; count: number; col: number; row: number }[][] = [];
|
||||
const monthMarkers: { col: number; label: string }[] = [];
|
||||
let col = 0;
|
||||
let prevMonth = -1;
|
||||
const cursor = new Date(start);
|
||||
|
||||
while (cursor <= to) {
|
||||
const week: typeof weeks[0] = [];
|
||||
for (let row = 0; row < ROWS; row++) {
|
||||
const dateStr = fmt(cursor);
|
||||
const count = countMap.get(dateStr) ?? 0;
|
||||
week.push({ date: dateStr, count, col, row });
|
||||
|
||||
// Track month transitions (on the first day of each week)
|
||||
if (row === 0) {
|
||||
const m = cursor.getMonth();
|
||||
if (m !== prevMonth) {
|
||||
monthMarkers.push({ col, label: MONTH_LABELS[m] });
|
||||
prevMonth = m;
|
||||
}
|
||||
}
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
weeks.push(week);
|
||||
col++;
|
||||
}
|
||||
|
||||
// Compute quantile thresholds from non-zero counts
|
||||
const nonZero = counts.map((d) => d.count).filter((c) => c > 0).sort((a, b) => a - b);
|
||||
const thresholds = computeThresholds(nonZero);
|
||||
const totalCount = counts.reduce((sum, d) => sum + d.count, 0);
|
||||
|
||||
return { weeks, monthMarkers, thresholds, totalCount };
|
||||
}, [dailyQ.data]);
|
||||
|
||||
const cols = weeks.length;
|
||||
const svgWidth = LEFT_LABEL_WIDTH + cols * (CELL_SIZE + GAP);
|
||||
const svgHeight = TOP_LABEL_HEIGHT + ROWS * (CELL_SIZE + GAP);
|
||||
|
||||
if (dailyQ.isLoading) return <p style={{ fontSize: '0.8rem' }}>loading contribution graph...</p>;
|
||||
if (dailyQ.isError) return null;
|
||||
|
||||
return (
|
||||
<div className="contribution-graph mb-4">
|
||||
<p style={{ fontSize: '0.8rem', opacity: 0.6 }}>
|
||||
{totalCount} contributions in the last year
|
||||
</p>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<svg width={svgWidth} height={svgHeight} className="d-block">
|
||||
{/* Day-of-week labels */}
|
||||
{DAY_LABELS.map((label, i) =>
|
||||
label ? (
|
||||
<text
|
||||
key={i}
|
||||
x={LEFT_LABEL_WIDTH - 6}
|
||||
y={TOP_LABEL_HEIGHT + i * (CELL_SIZE + GAP) + CELL_SIZE / 2}
|
||||
textAnchor="end"
|
||||
dominantBaseline="central"
|
||||
className="graph-label"
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
) : null,
|
||||
)}
|
||||
{/* Month labels */}
|
||||
{monthMarkers.map(({ col, label }, i) => (
|
||||
<text
|
||||
key={i}
|
||||
x={LEFT_LABEL_WIDTH + col * (CELL_SIZE + GAP) + RADIUS}
|
||||
y={10}
|
||||
textAnchor="middle"
|
||||
className="graph-label"
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
))}
|
||||
{/* Circles */}
|
||||
{weeks.flatMap((week) =>
|
||||
week.map(({ date, count, col, row }) => (
|
||||
<circle
|
||||
key={date}
|
||||
cx={LEFT_LABEL_WIDTH + col * (CELL_SIZE + GAP) + RADIUS}
|
||||
cy={TOP_LABEL_HEIGHT + row * (CELL_SIZE + GAP) + RADIUS}
|
||||
r={RADIUS - 1}
|
||||
fill={colorFor(count, thresholds)}
|
||||
className="graph-cell"
|
||||
onClick={() => navigate(`/activity/${date}`)}
|
||||
>
|
||||
<title>{`${date}: ${count} ${count === 1 ? 'contribution' : 'contributions'}`}</title>
|
||||
</circle>
|
||||
)),
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmt(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function colorFor(count: number, thresholds: number[]): string {
|
||||
if (count === 0) return COLORS[0];
|
||||
if (count <= thresholds[0]) return COLORS[1];
|
||||
if (count <= thresholds[1]) return COLORS[2];
|
||||
if (count <= thresholds[2]) return COLORS[3];
|
||||
return COLORS[4];
|
||||
}
|
||||
|
||||
function computeThresholds(sorted: number[]): number[] {
|
||||
if (sorted.length === 0) return [1, 2, 3];
|
||||
const p = (pct: number) => sorted[Math.min(Math.floor(pct * sorted.length), sorted.length - 1)];
|
||||
return [p(0.25), p(0.5), p(0.75)];
|
||||
}
|
||||
Reference in New Issue
Block a user