Complements the existing avg-by-weekday chart with its orthogonal partner: which hour of the day the user typically commits. The api buckets events by EXTRACT(hour FROM occurred_at AT TIME ZONE $tz) so the chart matches the clock the user sees rather than UTC; the UI passes the browser's resolved IANA timezone. Renders as 24 mini-bars below the weekday chart with labels every 4 hours and per-bar tooltips showing the average events/day at that hour. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
201 lines
7.6 KiB
TypeScript
201 lines
7.6 KiB
TypeScript
import { useMemo } from 'react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
|
|
import { fetchDailyCounts, fetchHourlyAvgs, 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,
|
|
});
|
|
|
|
// Bucket hour-of-day in the user's local timezone so the chart matches
|
|
// the clock they see. Browser may report e.g. "Europe/Helsinki"; fall
|
|
// back to UTC if the resolver returns something the server won't
|
|
// accept (it validates the string before binding).
|
|
const tz = useMemo(
|
|
() => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
|
[],
|
|
);
|
|
const hourlyQ = useQuery({
|
|
queryKey: ['hourly-avgs-alltime', fromStr, toStr, tz],
|
|
queryFn: () => fetchHourlyAvgs(fromStr, toStr, tz),
|
|
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]);
|
|
|
|
const hourly = useMemo(() => {
|
|
const data = hourlyQ.data ?? [];
|
|
if (data.length === 0) return null;
|
|
const byHour = new Array(24).fill(0);
|
|
for (const { hour, avg } of data) {
|
|
if (hour >= 0 && hour < 24) byHour[hour] = avg;
|
|
}
|
|
const max = Math.max(...byHour);
|
|
return { hours: byHour, max };
|
|
}, [hourlyQ.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 align-items-end gap-1 mt-1" style={{ height: 64 }}>
|
|
{stats.dayAvgs.map(({ name, avg }) => (
|
|
<div key={name} className="d-flex flex-column align-items-center" style={{ flex: 1, height: '100%', justifyContent: 'flex-end' }}>
|
|
<span style={{ fontSize: '0.65rem', opacity: 0.6, marginBottom: 2 }}>{avg.toFixed(1)}</span>
|
|
<div style={{ width: '100%', maxWidth: 20, borderRadius: 3, background: 'rgba(255,255,255,0.05)', flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'flex-end' }}>
|
|
<div
|
|
style={{
|
|
height: stats.maxAvg > 0 ? `${(avg / stats.maxAvg) * 100}%` : '0%',
|
|
borderRadius: 3,
|
|
backgroundColor: '#39d353',
|
|
opacity: 0.7,
|
|
}}
|
|
/>
|
|
</div>
|
|
<span style={{ fontSize: '0.65rem', opacity: 0.7, marginTop: 2 }}>{name}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{hourly && (
|
|
<div className="mt-3">
|
|
<span style={{ opacity: 0.7, fontSize: '0.75rem' }}>avg by hour ({tz})</span>
|
|
<div className="d-flex align-items-end gap-1 mt-1" style={{ height: 64 }}>
|
|
{hourly.hours.map((avg, h) => (
|
|
<div key={h} className="d-flex flex-column align-items-center" style={{ flex: 1, height: '100%', justifyContent: 'flex-end' }}>
|
|
<div style={{ width: '100%', borderRadius: 2, background: 'rgba(255,255,255,0.05)', flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'flex-end' }}>
|
|
<div
|
|
style={{
|
|
height: hourly.max > 0 ? `${(avg / hourly.max) * 100}%` : '0%',
|
|
borderRadius: 2,
|
|
backgroundColor: '#39d353',
|
|
opacity: 0.7,
|
|
}}
|
|
title={`${h.toString().padStart(2, '0')}:00 — ${avg.toFixed(2)}/day`}
|
|
/>
|
|
</div>
|
|
<span style={{ fontSize: '0.6rem', opacity: 0.7, marginTop: 2, minHeight: '0.7rem' }}>
|
|
{h % 4 === 0 ? h.toString().padStart(2, '0') : ''}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function fmt(d: Date): string {
|
|
return d.toISOString().slice(0, 10);
|
|
}
|