feat(ui): add avg-by-hour panel to dashboard stats
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>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { fetchDailyCounts, fetchSources } from '../api/client';
|
||||
import { fetchDailyCounts, fetchHourlyAvgs, fetchSources } from '../api/client';
|
||||
|
||||
export function ContributionStats() {
|
||||
const sourcesQ = useQuery({
|
||||
@@ -31,6 +31,21 @@ export function ContributionStats() {
|
||||
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;
|
||||
@@ -96,6 +111,17 @@ export function ContributionStats() {
|
||||
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 (
|
||||
@@ -139,6 +165,31 @@ export function ContributionStats() {
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user