feat(ui): all-time weekly contribution graph + date range timespan support
Add AllTimeGraph component showing one circle per week across the full history (earliest event to today). Uses the /sources endpoint to find the earliest date, then fetches daily counts and aggregates to weekly. Clicking a week navigates to /activity/YYYY-MM-DD..YYYY-MM-DD. Update parseTimespan to handle both date-only (YYYY-MM-DD) and full ISO datetime strings in range expressions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,12 +2,12 @@ import { useMemo } from 'react';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { fetchDailyCounts } from '../api/client';
|
import { fetchDailyCounts, fetchSources } from '../api/client';
|
||||||
|
|
||||||
const CELL_SIZE = 12;
|
const CELL_SIZE = 12;
|
||||||
const GAP = 3;
|
const GAP = 3;
|
||||||
const RADIUS = CELL_SIZE / 2;
|
const RADIUS = CELL_SIZE / 2;
|
||||||
const ROWS = 7; // days per week
|
const ROWS = 7;
|
||||||
const LEFT_LABEL_WIDTH = 28;
|
const LEFT_LABEL_WIDTH = 28;
|
||||||
const TOP_LABEL_HEIGHT = 16;
|
const TOP_LABEL_HEIGHT = 16;
|
||||||
|
|
||||||
@@ -15,13 +15,14 @@ const DAY_LABELS = ['', 'mon', '', 'wed', '', 'fri', ''];
|
|||||||
const MONTH_LABELS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
|
const MONTH_LABELS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
|
||||||
|
|
||||||
const COLORS = [
|
const COLORS = [
|
||||||
'rgba(255,255,255,0.05)', // 0: empty
|
'rgba(255,255,255,0.05)',
|
||||||
'#0e4429', // 1: low
|
'#0e4429',
|
||||||
'#006d32', // 2: medium-low
|
'#006d32',
|
||||||
'#26a641', // 3: medium
|
'#26a641',
|
||||||
'#39d353', // 4: high
|
'#39d353',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Daily contribution graph — last 1 year, one circle per day. */
|
||||||
export function ContributionGraph() {
|
export function ContributionGraph() {
|
||||||
const to = new Date();
|
const to = new Date();
|
||||||
const from = new Date(to);
|
const from = new Date(to);
|
||||||
@@ -42,7 +43,6 @@ export function ContributionGraph() {
|
|||||||
const counts = dailyQ.data ?? [];
|
const counts = dailyQ.data ?? [];
|
||||||
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 `from`
|
|
||||||
const start = new Date(from);
|
const start = new Date(from);
|
||||||
start.setDate(start.getDate() - start.getDay());
|
start.setDate(start.getDate() - start.getDay());
|
||||||
|
|
||||||
@@ -58,8 +58,6 @@ export function ContributionGraph() {
|
|||||||
const dateStr = fmt(cursor);
|
const dateStr = fmt(cursor);
|
||||||
const count = countMap.get(dateStr) ?? 0;
|
const count = countMap.get(dateStr) ?? 0;
|
||||||
week.push({ date: dateStr, count, col, row });
|
week.push({ date: dateStr, count, col, row });
|
||||||
|
|
||||||
// Track month transitions (on the first day of each week)
|
|
||||||
if (row === 0) {
|
if (row === 0) {
|
||||||
const m = cursor.getMonth();
|
const m = cursor.getMonth();
|
||||||
if (m !== prevMonth) {
|
if (m !== prevMonth) {
|
||||||
@@ -73,7 +71,6 @@ export function ContributionGraph() {
|
|||||||
col++;
|
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 nonZero = counts.map((d) => d.count).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);
|
||||||
@@ -89,13 +86,12 @@ export function ContributionGraph() {
|
|||||||
if (dailyQ.isError) return null;
|
if (dailyQ.isError) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="contribution-graph mb-4">
|
<div className="contribution-graph mb-3">
|
||||||
<p style={{ fontSize: '0.8rem', opacity: 0.6 }}>
|
<p style={{ fontSize: '0.8rem', opacity: 0.6 }}>
|
||||||
{totalCount} contributions in the last year
|
{totalCount} contributions in the last year
|
||||||
</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">
|
||||||
{/* Day-of-week labels */}
|
|
||||||
{DAY_LABELS.map((label, i) =>
|
{DAY_LABELS.map((label, i) =>
|
||||||
label ? (
|
label ? (
|
||||||
<text
|
<text
|
||||||
@@ -110,7 +106,6 @@ export function ContributionGraph() {
|
|||||||
</text>
|
</text>
|
||||||
) : null,
|
) : null,
|
||||||
)}
|
)}
|
||||||
{/* Month labels */}
|
|
||||||
{monthMarkers.map(({ col, label }, i) => (
|
{monthMarkers.map(({ col, label }, i) => (
|
||||||
<text
|
<text
|
||||||
key={i}
|
key={i}
|
||||||
@@ -122,7 +117,6 @@ export function ContributionGraph() {
|
|||||||
{label}
|
{label}
|
||||||
</text>
|
</text>
|
||||||
))}
|
))}
|
||||||
{/* Circles */}
|
|
||||||
{weeks.flatMap((week) =>
|
{weeks.flatMap((week) =>
|
||||||
week.map(({ date, count, col, row }) => (
|
week.map(({ date, count, col, row }) => (
|
||||||
<circle
|
<circle
|
||||||
@@ -144,6 +138,126 @@ export function ContributionGraph() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** All-time weekly contribution graph — one circle per week. */
|
||||||
|
export function AllTimeGraph() {
|
||||||
|
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 navigate = useNavigate();
|
||||||
|
|
||||||
|
const { weeklyData, yearMarkers, thresholds, totalCount } = useMemo(() => {
|
||||||
|
const counts = dailyQ.data ?? [];
|
||||||
|
if (counts.length === 0) return { weeklyData: [], yearMarkers: [], 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());
|
||||||
|
|
||||||
|
const weeklyData: { weekStart: string; weekEnd: string; count: number; col: number }[] = [];
|
||||||
|
const yearMarkers: { col: number; label: string }[] = [];
|
||||||
|
let col = 0;
|
||||||
|
let prevYear = -1;
|
||||||
|
const cursor = new Date(start);
|
||||||
|
|
||||||
|
while (cursor <= to) {
|
||||||
|
const weekStart = fmt(cursor);
|
||||||
|
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);
|
||||||
|
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 thresholds = computeThresholds(nonZero);
|
||||||
|
const totalCount = counts.reduce((sum, d) => sum + d.count, 0);
|
||||||
|
|
||||||
|
return { weeklyData, yearMarkers, thresholds, totalCount };
|
||||||
|
}, [dailyQ.data]);
|
||||||
|
|
||||||
|
if (!earliest || dailyQ.isLoading) return null;
|
||||||
|
if (dailyQ.isError) return null;
|
||||||
|
if (weeklyData.length === 0) return null;
|
||||||
|
|
||||||
|
const cols = weeklyData.length;
|
||||||
|
const labelHeight = 14;
|
||||||
|
const svgWidth = cols * (CELL_SIZE + GAP);
|
||||||
|
const svgHeight = labelHeight + CELL_SIZE + GAP;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="contribution-graph mb-4">
|
||||||
|
<p style={{ fontSize: '0.8rem', opacity: 0.6 }}>
|
||||||
|
{totalCount} contributions since {fmt(from)}
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<svg viewBox={`0 0 ${svgWidth} ${svgHeight}`} width="100%" className="d-block">
|
||||||
|
{yearMarkers.map(({ col, label }, i) => (
|
||||||
|
<text
|
||||||
|
key={i}
|
||||||
|
x={col * (CELL_SIZE + GAP) + RADIUS}
|
||||||
|
y={10}
|
||||||
|
textAnchor="middle"
|
||||||
|
className="graph-label"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
{weeklyData.map(({ weekStart, weekEnd, count, col }) => (
|
||||||
|
<circle
|
||||||
|
key={weekStart}
|
||||||
|
cx={col * (CELL_SIZE + GAP) + RADIUS}
|
||||||
|
cy={labelHeight + RADIUS}
|
||||||
|
r={RADIUS - 1}
|
||||||
|
fill={colorFor(count, thresholds)}
|
||||||
|
className="graph-cell"
|
||||||
|
onClick={() => navigate(`/activity/${weekStart}..${weekEnd}`)}
|
||||||
|
>
|
||||||
|
<title>{`${weekStart} — ${weekEnd}: ${count} ${count === 1 ? 'contribution' : 'contributions'}`}</title>
|
||||||
|
</circle>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function fmt(d: Date): string {
|
function fmt(d: Date): string {
|
||||||
return d.toISOString().slice(0, 10);
|
return d.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Col from 'react-bootstrap/Col';
|
|||||||
import Row from 'react-bootstrap/Row';
|
import Row from 'react-bootstrap/Row';
|
||||||
|
|
||||||
import { fetchProjects, fetchLanguages, type ProjectSummary, type Source } from '../api/client';
|
import { fetchProjects, fetchLanguages, type ProjectSummary, type Source } from '../api/client';
|
||||||
import { ContributionGraph } from '../components/ContributionGraph';
|
import { ContributionGraph, AllTimeGraph } from '../components/ContributionGraph';
|
||||||
|
|
||||||
export function DashPage() {
|
export function DashPage() {
|
||||||
const projectsQ = useQuery({
|
const projectsQ = useQuery({
|
||||||
@@ -27,6 +27,7 @@ export function DashPage() {
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
<ContributionGraph />
|
<ContributionGraph />
|
||||||
|
<AllTimeGraph />
|
||||||
{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>
|
||||||
|
|||||||
@@ -12,16 +12,27 @@ import { TimelineEntry } from '../components/TimelineEntry';
|
|||||||
const RANGE_MIN = new Date('2010-01-01T00:00:00Z').getTime();
|
const RANGE_MIN = new Date('2010-01-01T00:00:00Z').getTime();
|
||||||
const RANGE_MAX = Date.now();
|
const RANGE_MAX = Date.now();
|
||||||
|
|
||||||
|
function parseDate(s: string): number {
|
||||||
|
// Accept YYYY-MM-DD or full ISO datetime
|
||||||
|
const t = new Date(s.includes('T') ? s : s + 'T00:00:00Z').getTime();
|
||||||
|
return isNaN(t) ? NaN : t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function endOfDay(s: string): number {
|
||||||
|
const t = new Date(s.includes('T') ? s : s + 'T23:59:59Z').getTime();
|
||||||
|
return isNaN(t) ? NaN : t;
|
||||||
|
}
|
||||||
|
|
||||||
function parseTimespan(timespan?: string): [number, number] | null {
|
function parseTimespan(timespan?: string): [number, number] | null {
|
||||||
if (!timespan) return null;
|
if (!timespan) return null;
|
||||||
if (timespan.includes('..')) {
|
if (timespan.includes('..')) {
|
||||||
const [a, b] = timespan.split('..');
|
const [a, b] = timespan.split('..');
|
||||||
const from = new Date(a + 'T00:00:00Z').getTime();
|
const from = parseDate(a);
|
||||||
const to = new Date(b + 'T23:59:59Z').getTime();
|
const to = endOfDay(b);
|
||||||
if (!isNaN(from) && !isNaN(to)) return [from, to];
|
if (!isNaN(from) && !isNaN(to)) return [from, to];
|
||||||
} else {
|
} else {
|
||||||
const from = new Date(timespan + 'T00:00:00Z').getTime();
|
const from = parseDate(timespan);
|
||||||
const to = new Date(timespan + 'T23:59:59Z').getTime();
|
const to = endOfDay(timespan);
|
||||||
if (!isNaN(from)) return [from, to];
|
if (!isNaN(from)) return [from, to];
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user