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:
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);
|
||||
}
|
||||
Reference in New Issue
Block a user