feat(ui): reshape all-time graph and add dashboard stats panels
Transpose AllTimeGraph to show years on X axis and months on Y axis instead of year-per-row with weekly columns. Add TopLanguages bar chart (all-time code volume by language) and ContributionStats panel (current and longest streaks, busiest day, active days, weekday averages) in a three-column row matching the project card grid. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -156,7 +156,7 @@ export function ContributionGraph() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** All-time weekly contribution graph — one circle per week. */
|
/** All-time monthly contribution graph — years on X axis, months on Y axis. */
|
||||||
export function AllTimeGraph() {
|
export function AllTimeGraph() {
|
||||||
const sourcesQ = useQuery({
|
const sourcesQ = useQuery({
|
||||||
queryKey: ['sources'],
|
queryKey: ['sources'],
|
||||||
@@ -194,59 +194,57 @@ export function AllTimeGraph() {
|
|||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { yearRows, thresholds, totalCount } = useMemo(() => {
|
const { years, monthGrid, thresholds, totalCount } = useMemo(() => {
|
||||||
const counts = dailyQ.data ?? [];
|
const counts = dailyQ.data ?? [];
|
||||||
if (counts.length === 0) return { yearRows: [], thresholds: [1, 2, 3], totalCount: 0 };
|
if (counts.length === 0) return { years: [], monthGrid: [], 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]));
|
||||||
|
|
||||||
// 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 startYear = from.getFullYear();
|
||||||
const endYear = to.getFullYear();
|
const endYear = to.getFullYear();
|
||||||
const yearRows: { year: number; weeks: { weekStart: string; weekEnd: string; count: number; col: number }[] }[] = [];
|
const years: number[] = [];
|
||||||
|
for (let yr = startYear; yr <= endYear; yr++) years.push(yr);
|
||||||
|
|
||||||
for (let yr = startYear; yr <= endYear; yr++) {
|
// Build a 12 x years grid of monthly totals
|
||||||
const yearStart = new Date(yr, 0, 1);
|
const monthGrid: { year: number; month: number; count: number; monthStart: string; monthEnd: string }[][] = [];
|
||||||
const yearEnd = yr === endYear ? to : new Date(yr, 11, 31);
|
for (let m = 0; m < 12; m++) {
|
||||||
// Align to preceding Sunday
|
const row: typeof monthGrid[0] = [];
|
||||||
const cursor = new Date(yearStart);
|
for (const yr of years) {
|
||||||
cursor.setDate(cursor.getDate() - cursor.getDay());
|
const monthStart = new Date(yr, m, 1);
|
||||||
|
const monthEnd = new Date(yr, m + 1, 0); // last day of month
|
||||||
const weeks: typeof yearRows[0]['weeks'] = [];
|
// Don't include months entirely outside our data range
|
||||||
let col = 0;
|
if (monthStart > to || monthEnd < from) {
|
||||||
while (cursor <= yearEnd) {
|
row.push({ year: yr, month: m, count: 0, monthStart: fmt(monthStart), monthEnd: fmt(monthEnd) });
|
||||||
const weekStart = fmt(cursor);
|
continue;
|
||||||
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);
|
let total = 0;
|
||||||
|
const cursor = new Date(monthStart);
|
||||||
|
while (cursor <= monthEnd && cursor <= to) {
|
||||||
|
total += countMap.get(fmt(cursor)) ?? 0;
|
||||||
cursor.setDate(cursor.getDate() + 1);
|
cursor.setDate(cursor.getDate() + 1);
|
||||||
weeks.push({ weekStart, weekEnd, count: weekCount, col });
|
|
||||||
col++;
|
|
||||||
}
|
}
|
||||||
yearRows.push({ year: yr, weeks });
|
row.push({ year: yr, month: m, count: total, monthStart: fmt(monthStart), monthEnd: fmt(monthEnd) });
|
||||||
|
}
|
||||||
|
monthGrid.push(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
const allWeekCounts = yearRows.flatMap((r) => r.weeks.map((w) => w.count));
|
const allCounts = monthGrid.flat().map((c) => c.count);
|
||||||
const nonZero = allWeekCounts.filter((c) => c > 0).sort((a, b) => a - b);
|
const nonZero = allCounts.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 { yearRows, thresholds, totalCount };
|
return { years, monthGrid, 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 (yearRows.length === 0) return null;
|
if (years.length === 0) return null;
|
||||||
|
|
||||||
const maxCols = 53; // max weeks in a year
|
const monthLabelWidth = 28;
|
||||||
const yearLabelWidth = 32;
|
const topLabelHeight = 16;
|
||||||
const svgWidth = yearLabelWidth + maxCols * (CELL_SIZE + GAP);
|
const numCols = years.length;
|
||||||
const rows = yearRows.length;
|
const svgWidth = monthLabelWidth + numCols * (CELL_SIZE + GAP);
|
||||||
const svgHeight = rows * (CELL_SIZE + GAP);
|
const svgHeight = topLabelHeight + 12 * (CELL_SIZE + GAP);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="contribution-graph mb-4">
|
<div className="contribution-graph mb-4">
|
||||||
@@ -256,32 +254,47 @@ 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">
|
||||||
{yearRows.map(({ year, weeks }, rowIdx) => (
|
{/* Year labels along the top */}
|
||||||
<g key={year}>
|
{years.map((year, colIdx) => (
|
||||||
<text
|
<text
|
||||||
x={yearLabelWidth - 4}
|
key={year}
|
||||||
y={rowIdx * (CELL_SIZE + GAP) + CELL_SIZE / 2}
|
x={monthLabelWidth + colIdx * (CELL_SIZE + GAP) + RADIUS}
|
||||||
|
y={10}
|
||||||
|
textAnchor="middle"
|
||||||
|
className="graph-label"
|
||||||
|
>
|
||||||
|
{String(year).slice(2)}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
{/* Month labels along the left */}
|
||||||
|
{MONTH_LABELS.map((label, rowIdx) => (
|
||||||
|
<text
|
||||||
|
key={rowIdx}
|
||||||
|
x={monthLabelWidth - 6}
|
||||||
|
y={topLabelHeight + rowIdx * (CELL_SIZE + GAP) + CELL_SIZE / 2}
|
||||||
textAnchor="end"
|
textAnchor="end"
|
||||||
dominantBaseline="central"
|
dominantBaseline="central"
|
||||||
className="graph-label"
|
className="graph-label"
|
||||||
>
|
>
|
||||||
{year}
|
{label}
|
||||||
</text>
|
</text>
|
||||||
{weeks.map(({ weekStart, weekEnd, count, col }) => (
|
))}
|
||||||
|
{/* Monthly contribution circles */}
|
||||||
|
{monthGrid.map((row, rowIdx) =>
|
||||||
|
row.map(({ year, count, monthStart, monthEnd }, colIdx) => (
|
||||||
<circle
|
<circle
|
||||||
key={weekStart}
|
key={`${year}-${rowIdx}`}
|
||||||
cx={yearLabelWidth + col * (CELL_SIZE + GAP) + RADIUS}
|
cx={monthLabelWidth + colIdx * (CELL_SIZE + GAP) + RADIUS}
|
||||||
cy={rowIdx * (CELL_SIZE + GAP) + RADIUS}
|
cy={topLabelHeight + 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/${monthStart}..${monthEnd}`)}
|
||||||
>
|
>
|
||||||
<title>{`${weekStart} — ${weekEnd}: ${count} ${count === 1 ? 'contribution' : 'contributions'}`}</title>
|
<title>{`${MONTH_LABELS[rowIdx]} ${year}: ${count} ${count === 1 ? 'contribution' : 'contributions'}`}</title>
|
||||||
</circle>
|
</circle>
|
||||||
))}
|
)),
|
||||||
</g>
|
)}
|
||||||
))}
|
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
150
ui/src/components/ContributionStats.tsx
Normal file
150
ui/src/components/ContributionStats.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { fetchDailyCounts, fetchSources } from '../api/client';
|
||||||
|
|
||||||
|
export function ContributionStats() {
|
||||||
|
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 stats = useMemo(() => {
|
||||||
|
const counts = dailyQ.data ?? [];
|
||||||
|
if (counts.length === 0) return null;
|
||||||
|
|
||||||
|
// Build a set of dates with contributions
|
||||||
|
const countMap = new Map(counts.map((d) => [d.date, d.count]));
|
||||||
|
|
||||||
|
// Current streak (consecutive days ending today or yesterday with contributions)
|
||||||
|
let currentStreak = 0;
|
||||||
|
const cursor = new Date(to);
|
||||||
|
// Allow today to have 0 (day isn't over yet) — start from yesterday if today is 0
|
||||||
|
if ((countMap.get(fmt(cursor)) ?? 0) > 0) {
|
||||||
|
currentStreak = 1;
|
||||||
|
cursor.setDate(cursor.getDate() - 1);
|
||||||
|
} else {
|
||||||
|
cursor.setDate(cursor.getDate() - 1);
|
||||||
|
if ((countMap.get(fmt(cursor)) ?? 0) > 0) {
|
||||||
|
currentStreak = 1;
|
||||||
|
cursor.setDate(cursor.getDate() - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentStreak > 0) {
|
||||||
|
while ((countMap.get(fmt(cursor)) ?? 0) > 0) {
|
||||||
|
currentStreak++;
|
||||||
|
cursor.setDate(cursor.getDate() - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Longest streak
|
||||||
|
let longestStreak = 0;
|
||||||
|
let streak = 0;
|
||||||
|
const sorted = [...counts].sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
for (let i = 0; i < sorted.length; i++) {
|
||||||
|
if (sorted[i].count > 0) {
|
||||||
|
streak++;
|
||||||
|
if (streak > longestStreak) longestStreak = streak;
|
||||||
|
} else {
|
||||||
|
streak = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Busiest day
|
||||||
|
const busiest = sorted.reduce((best, d) => (d.count > best.count ? d : best), sorted[0]);
|
||||||
|
|
||||||
|
// Day-of-week averages
|
||||||
|
const dayTotals = [0, 0, 0, 0, 0, 0, 0];
|
||||||
|
const dayCounts = [0, 0, 0, 0, 0, 0, 0];
|
||||||
|
for (const d of sorted) {
|
||||||
|
const dow = new Date(d.date + 'T00:00:00').getDay();
|
||||||
|
dayTotals[dow] += d.count;
|
||||||
|
dayCounts[dow]++;
|
||||||
|
}
|
||||||
|
const dayNames = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
|
||||||
|
const dayAvgs = dayNames.map((name, i) => ({
|
||||||
|
name,
|
||||||
|
avg: dayCounts[i] > 0 ? dayTotals[i] / dayCounts[i] : 0,
|
||||||
|
}));
|
||||||
|
const maxAvg = Math.max(...dayAvgs.map((d) => d.avg));
|
||||||
|
|
||||||
|
// Total active days
|
||||||
|
const activeDays = sorted.filter((d) => d.count > 0).length;
|
||||||
|
|
||||||
|
return { currentStreak, longestStreak, busiest, dayAvgs, maxAvg, activeDays };
|
||||||
|
}, [dailyQ.data]);
|
||||||
|
|
||||||
|
if (!stats) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p style={{ fontSize: '0.8rem', opacity: 0.6 }}>contribution stats</p>
|
||||||
|
<div className="d-flex flex-column gap-2" style={{ fontSize: '0.8rem' }}>
|
||||||
|
<div className="d-flex justify-content-between">
|
||||||
|
<span style={{ opacity: 0.7 }}>current streak</span>
|
||||||
|
<span>{stats.currentStreak} {stats.currentStreak === 1 ? 'day' : 'days'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex justify-content-between">
|
||||||
|
<span style={{ opacity: 0.7 }}>longest streak</span>
|
||||||
|
<span>{stats.longestStreak} {stats.longestStreak === 1 ? 'day' : 'days'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex justify-content-between">
|
||||||
|
<span style={{ opacity: 0.7 }}>busiest day</span>
|
||||||
|
<span>{stats.busiest.count} on {stats.busiest.date}</span>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex justify-content-between">
|
||||||
|
<span style={{ opacity: 0.7 }}>active days</span>
|
||||||
|
<span>{stats.activeDays.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
<span style={{ opacity: 0.7, fontSize: '0.75rem' }}>avg by weekday</span>
|
||||||
|
<div className="d-flex flex-column gap-1 mt-1">
|
||||||
|
{stats.dayAvgs.map(({ name, avg }) => (
|
||||||
|
<div key={name} className="d-flex align-items-center gap-2" style={{ fontSize: '0.75rem' }}>
|
||||||
|
<span style={{ width: 24, textAlign: 'right', opacity: 0.7 }}>{name}</span>
|
||||||
|
<div style={{ flex: 1, height: 8, borderRadius: 3, background: 'rgba(255,255,255,0.05)' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: stats.maxAvg > 0 ? `${(avg / stats.maxAvg) * 100}%` : '0%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: '#39d353',
|
||||||
|
opacity: 0.7,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span style={{ width: 28, textAlign: 'right', opacity: 0.6 }}>{avg.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(d: Date): string {
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
60
ui/src/components/TopLanguages.tsx
Normal file
60
ui/src/components/TopLanguages.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { fetchRepoLanguages } from '../api/client';
|
||||||
|
|
||||||
|
const MAX_LANGS = 10;
|
||||||
|
|
||||||
|
export function TopLanguages() {
|
||||||
|
const langsQ = useQuery({
|
||||||
|
queryKey: ['repo-languages'],
|
||||||
|
queryFn: fetchRepoLanguages,
|
||||||
|
staleTime: 10 * 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ranked = useMemo(() => {
|
||||||
|
if (!langsQ.data) return [];
|
||||||
|
const totals = new Map<string, { bytes: number; color: string }>();
|
||||||
|
for (const e of langsQ.data) {
|
||||||
|
const cur = totals.get(e.language);
|
||||||
|
if (cur) {
|
||||||
|
cur.bytes += e.bytes;
|
||||||
|
} else {
|
||||||
|
totals.set(e.language, { bytes: e.bytes, color: e.color ?? '#8b8b8b' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...totals.entries()]
|
||||||
|
.sort(([, a], [, b]) => b.bytes - a.bytes)
|
||||||
|
.slice(0, MAX_LANGS);
|
||||||
|
}, [langsQ.data]);
|
||||||
|
|
||||||
|
if (!langsQ.data || ranked.length === 0) return null;
|
||||||
|
|
||||||
|
const maxBytes = ranked[0][1].bytes;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p style={{ fontSize: '0.8rem', opacity: 0.6 }}>
|
||||||
|
top languages by code volume
|
||||||
|
</p>
|
||||||
|
<div className="d-flex flex-column gap-1">
|
||||||
|
{ranked.map(([lang, { bytes, color }]) => (
|
||||||
|
<div key={lang} className="d-flex align-items-center gap-2" style={{ fontSize: '0.75rem' }}>
|
||||||
|
<span style={{ width: 70, textAlign: 'right', opacity: 0.8, flexShrink: 0 }}>{lang}</span>
|
||||||
|
<div style={{ flex: 1, height: 10, borderRadius: 3, background: 'rgba(255,255,255,0.05)' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${(bytes / maxBytes) * 100}%`,
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: color,
|
||||||
|
opacity: 0.85,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,7 +7,9 @@ import Row from 'react-bootstrap/Row';
|
|||||||
import { fetchProjects, fetchRepoLanguages, type ProjectSummary } from '../api/client';
|
import { fetchProjects, fetchRepoLanguages, type ProjectSummary } from '../api/client';
|
||||||
import { LanguageBar } from '../components/LanguageBar';
|
import { LanguageBar } from '../components/LanguageBar';
|
||||||
import { ContributionGraph, AllTimeGraph } from '../components/ContributionGraph';
|
import { ContributionGraph, AllTimeGraph } from '../components/ContributionGraph';
|
||||||
|
import { ContributionStats } from '../components/ContributionStats';
|
||||||
import { LanguageStreamGraph } from '../components/LanguageStreamGraph';
|
import { LanguageStreamGraph } from '../components/LanguageStreamGraph';
|
||||||
|
import { TopLanguages } from '../components/TopLanguages';
|
||||||
|
|
||||||
export function DashPage() {
|
export function DashPage() {
|
||||||
const projectsQ = useQuery({
|
const projectsQ = useQuery({
|
||||||
@@ -55,7 +57,17 @@ export function DashPage() {
|
|||||||
</Row>
|
</Row>
|
||||||
<ContributionGraph />
|
<ContributionGraph />
|
||||||
<LanguageStreamGraph />
|
<LanguageStreamGraph />
|
||||||
|
<Row xs={1} md={2} lg={3} className="g-3 mb-3">
|
||||||
|
<Col>
|
||||||
<AllTimeGraph />
|
<AllTimeGraph />
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<TopLanguages />
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<ContributionStats />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
{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>
|
||||||
|
|||||||
Reference in New Issue
Block a user