From c15737b9c6bcd94992d56c83f5984c73216037b2 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 12 Mar 2026 00:05:46 +0100 Subject: [PATCH] feat: interactive Peak Hours heatmap - Row + column highlight on cell hover with dim effect - Cell-anchored tooltip showing pageviews and % of week's traffic - Best time callout: "Your busiest time is Xdays at Ypm" - Staggered entrance animation on data load --- components/dashboard/PeakHours.tsx | 218 ++++++++++++++++++++++------- 1 file changed, 168 insertions(+), 50 deletions(-) diff --git a/components/dashboard/PeakHours.tsx b/components/dashboard/PeakHours.tsx index 4622522..55847ca 100644 --- a/components/dashboard/PeakHours.tsx +++ b/components/dashboard/PeakHours.tsx @@ -1,6 +1,7 @@ 'use client' -import { useState, useEffect, useMemo } from 'react' +import { useState, useEffect, useMemo, useRef } from 'react' +import { motion, AnimatePresence } from 'framer-motion' import { logger } from '@/lib/utils/logger' import { getDailyStats } from '@/lib/api/stats' import type { DailyStat } from '@/lib/api/stats' @@ -13,10 +14,19 @@ interface PeakHoursProps { const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] const HOUR_LABELS: Record = { 0: '12am', 6: '6am', 12: '12pm', 18: '6pm' } +function formatHour(hour: number): string { + if (hour === 0) return '12am' + if (hour === 12) return '12pm' + return hour < 12 ? `${hour}am` : `${hour - 12}pm` +} + export default function PeakHours({ siteId, dateRange }: PeakHoursProps) { const [data, setData] = useState([]) const [isLoading, setIsLoading] = useState(true) - const [tooltip, setTooltip] = useState<{ day: number; hour: number; value: number } | null>(null) + const [animKey, setAnimKey] = useState(0) + const [hovered, setHovered] = useState<{ day: number; hour: number } | null>(null) + const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null) + const gridRef = useRef(null) useEffect(() => { const fetchData = async () => { @@ -24,6 +34,7 @@ export default function PeakHours({ siteId, dateRange }: PeakHoursProps) { try { const result = await getDailyStats(siteId, dateRange.start, dateRange.end, 'hour') setData(result) + setAnimKey(k => k + 1) } catch (e) { logger.error(e) } finally { @@ -33,22 +44,65 @@ export default function PeakHours({ siteId, dateRange }: PeakHoursProps) { fetchData() }, [siteId, dateRange]) - const { grid, max } = useMemo(() => { - // grid[adjustedDay][hour] where Mon=0 ... Sun=6 + const { grid, max, dayTotals, hourTotals, weekTotal } = useMemo(() => { const grid: number[][] = Array.from({ length: 7 }, () => Array(24).fill(0)) for (const d of data) { const date = new Date(d.date) - const day = date.getDay() // 0=Sun + const day = date.getDay() const hour = date.getHours() - const adjustedDay = day === 0 ? 6 : day - 1 // Mon=0 ... Sun=6 + const adjustedDay = day === 0 ? 6 : day - 1 grid[adjustedDay][hour] += d.pageviews } const max = Math.max(...grid.flat(), 1) - return { grid, max } + const dayTotals = grid.map(hours => hours.reduce((a, b) => a + b, 0)) + const hourTotals = Array.from({ length: 24 }, (_, h) => grid.reduce((a, row) => a + row[h], 0)) + const weekTotal = dayTotals.reduce((a, b) => a + b, 0) + return { grid, max, dayTotals, hourTotals, weekTotal } }, [data]) const hasData = data.some(d => d.pageviews > 0) + const bestTime = useMemo(() => { + if (!hasData) return null + let bestDay = 0, bestHour = 0, bestVal = 0 + for (let d = 0; d < 7; d++) { + for (let h = 0; h < 24; h++) { + if (grid[d][h] > bestVal) { + bestVal = grid[d][h] + bestDay = d + bestHour = h + } + } + } + return { day: bestDay, hour: bestHour } + }, [grid, hasData]) + + const tooltipData = useMemo(() => { + if (!hovered) return null + const { day, hour } = hovered + const value = grid[day][hour] + const dayTotal = dayTotals[day] + const hourTotal = hourTotals[hour] + const pct = weekTotal > 0 ? Math.round((value / weekTotal) * 100) : 0 + return { value, dayTotal, hourTotal, pct } + }, [hovered, grid, dayTotals, hourTotals, weekTotal]) + + const handleCellMouseEnter = ( + e: React.MouseEvent, + dayIdx: number, + hour: number + ) => { + setHovered({ day: dayIdx, hour }) + if (gridRef.current) { + const gridRect = gridRef.current.getBoundingClientRect() + const cellRect = (e.currentTarget as HTMLDivElement).getBoundingClientRect() + setTooltipPos({ + x: cellRect.left - gridRect.left + cellRect.width / 2, + y: cellRect.top - gridRect.top, + }) + } + } + return (
@@ -68,57 +122,121 @@ export default function PeakHours({ siteId, dateRange }: PeakHoursProps) { ))}
) : hasData ? ( -
- {grid.map((hours, dayIdx) => ( -
- - {DAYS[dayIdx]} - -
- {hours.map((value, hour) => ( -
setTooltip({ day: dayIdx, hour, value })} - onMouseLeave={() => setTooltip(null)} - /> + <> +
+ {grid.map((hours, dayIdx) => ( +
+ + {DAYS[dayIdx]} + +
+ {hours.map((value, hour) => { + const isHoveredCell = hovered?.day === dayIdx && hovered?.hour === hour + const inRow = hovered?.day === dayIdx + const inCol = hovered?.hour === hour + const highlight = inRow || inCol + const dimmed = hovered !== null && !highlight + + return ( + handleCellMouseEnter(e, dayIdx, hour)} + onMouseLeave={() => { setHovered(null); setTooltipPos(null) }} + /> + ) + })} +
+
+ ))} + + {/* Hour axis labels */} +
+ +
+ {Object.entries(HOUR_LABELS).map(([h, label]) => ( + + {label} + ))} + + 12am +
- ))} - {/* Hour axis labels */} -
- -
- {Object.entries(HOUR_LABELS).map(([h, label]) => ( - + {hovered && tooltipData && tooltipPos && ( + - {label} - - ))} - - 12am - -
+
+
+ {DAYS[hovered.day]} {formatHour(hovered.hour)} +
+
+ {tooltipData.value.toLocaleString()} pageviews + {tooltipData.pct}% of week's traffic +
+
+ {/* Arrow */} +
+ + )} +
- {/* Tooltip */} - {tooltip && ( -
- {DAYS[tooltip.day]} {tooltip.hour}:00 — {tooltip.value} pageviews -
+ {/* Best time callout */} + {bestTime && ( + + Your busiest time is{' '} + + {DAYS[bestTime.day]}s at {formatHour(bestTime.hour)} + + )} -
+ ) : (