'use client' import { useState, useEffect, useMemo, useRef, type CSSProperties } 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' interface PeakHoursProps { siteId: string dateRange: { start: string, end: string } } const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] const DAYS_FULL = ['Mondays', 'Tuesdays', 'Wednesdays', 'Thursdays', 'Fridays', 'Saturdays', 'Sundays'] const BUCKETS = 12 // 2-hour buckets // Label at bucket index 0=00:00, 3=06:00, 6=12:00, 9=18:00 const BUCKET_LABELS: Record = { 0: '00:00', 3: '06:00', 6: '12:00', 9: '18:00' } const HIGHLIGHT_COLORS = [ 'rgba(253,94,15,0.18)', 'rgba(253,94,15,0.38)', 'rgba(253,94,15,0.62)', '#FD5E0F', ] function formatBucket(bucket: number): string { const hour = bucket * 2 const end = hour + 2 return `${String(hour).padStart(2, '0')}:00–${String(end).padStart(2, '0')}:00` } function formatHour(hour: number): string { return `${String(hour).padStart(2, '0')}:00` } function getHighlightColor(value: number, max: number): string { if (value === 0) return HIGHLIGHT_COLORS[0] const ratio = value / max if (ratio < 0.25) return HIGHLIGHT_COLORS[1] if (ratio < 0.6) return HIGHLIGHT_COLORS[2] return HIGHLIGHT_COLORS[3] } export default function PeakHours({ siteId, dateRange }: PeakHoursProps) { const [data, setData] = useState([]) const [isLoading, setIsLoading] = useState(true) const [animKey, setAnimKey] = useState(0) const [hovered, setHovered] = useState<{ day: number; bucket: number } | null>(null) const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null) const gridRef = useRef(null) useEffect(() => { const fetchData = async () => { setIsLoading(true) try { const result = await getDailyStats(siteId, dateRange.start, dateRange.end, 'hour') setData(result) setAnimKey(k => k + 1) } catch (e) { logger.error(e) } finally { setIsLoading(false) } } fetchData() }, [siteId, dateRange]) const { grid, max, dayTotals, bucketTotals, weekTotal } = useMemo(() => { // grid[day][bucket] — aggregate 2-hour buckets const grid: number[][] = Array.from({ length: 7 }, () => Array(BUCKETS).fill(0)) for (const d of data) { const date = new Date(d.date) const day = date.getDay() const hour = date.getHours() const adjustedDay = day === 0 ? 6 : day - 1 const bucket = Math.floor(hour / 2) grid[adjustedDay][bucket] += d.pageviews } const max = Math.max(...grid.flat(), 1) const dayTotals = grid.map(buckets => buckets.reduce((a, b) => a + b, 0)) const bucketTotals = Array.from({ length: BUCKETS }, (_, b) => grid.reduce((a, row) => a + row[b], 0)) const weekTotal = dayTotals.reduce((a, b) => a + b, 0) return { grid, max, dayTotals, bucketTotals, weekTotal } }, [data]) const hasData = data.some(d => d.pageviews > 0) const bestTime = useMemo(() => { if (!hasData) return null let bestDay = 0, bestBucket = 0, bestVal = 0 for (let d = 0; d < 7; d++) { for (let b = 0; b < BUCKETS; b++) { if (grid[d][b] > bestVal) { bestVal = grid[d][b] bestDay = d bestBucket = b } } } return { day: bestDay, bucket: bestBucket } }, [grid, hasData]) const tooltipData = useMemo(() => { if (!hovered) return null const { day, bucket } = hovered const value = grid[day][bucket] const pct = weekTotal > 0 ? Math.round((value / weekTotal) * 100) : 0 return { value, dayTotal: dayTotals[day], bucketTotal: bucketTotals[bucket], pct } }, [hovered, grid, dayTotals, bucketTotals, weekTotal]) const handleCellMouseEnter = ( e: React.MouseEvent, dayIdx: number, bucket: number ) => { setHovered({ day: dayIdx, bucket }) 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 (

Peak Hours

When your visitors are most active

{isLoading ? (
{Array.from({ length: 7 }).map((_, i) => (
))}
) : hasData ? ( <>
{grid.map((buckets, dayIdx) => (
{DAYS[dayIdx]}
{buckets.map((value, bucket) => { const isHoveredCell = hovered?.day === dayIdx && hovered?.bucket === bucket const isBestCell = bestTime?.day === dayIdx && bestTime?.bucket === bucket const isActive = value > 0 const highlightColor = getHighlightColor(value, max) return (
handleCellMouseEnter(e, dayIdx, bucket)} onMouseLeave={() => { setHovered(null); setTooltipPos(null) }} /> ) })}
))} {/* Hour axis labels */}
{Object.entries(BUCKET_LABELS).map(([b, label]) => ( {label} ))} 24:00
{/* Cell-anchored tooltip */} {hovered && tooltipData && tooltipPos && (
{DAYS[hovered.day]} {formatBucket(hovered.bucket)}
{tooltipData.value.toLocaleString()} pageviews {tooltipData.pct}% of week's traffic
)}
{/* Best time callout */} {bestTime && ( Your busiest time is{' '} {DAYS_FULL[bestTime.day]} at {formatHour(bestTime.bucket * 2)} )} ) : (

No data available for this period

)}
) }