From a189952fad9697f6f24bf103fa7373c9b1fbff28 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 11 Mar 2026 23:59:22 +0100 Subject: [PATCH] feat: add Peak Hours heatmap dashboard panel --- CHANGELOG.md | 1 + app/sites/[id]/page.tsx | 4 +- components/dashboard/PeakHours.tsx | 131 +++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 components/dashboard/PeakHours.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b632cd..ba27dca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- **Peak Hours heatmap.** A new panel on your dashboard shows a 7×24 grid of when your visitors are most active — every day of the week against every hour of the day. Cells glow brighter in brand orange the busier that hour is. Hover any cell to see the exact pageview count. No other indie analytics tool surfaces this on the main dashboard. - **Interactive 3D Globe.** The Locations panel now has a "Globe" tab showing your visitor locations on a beautiful, interactive 3D globe. Drag to rotate, and orange markers highlight where your visitors are — sized by how much traffic each country sends. The globe slowly auto-rotates and adapts to light and dark mode. - **Dotted world map.** The "Map" tab in Locations now uses a sleek dotted map style instead of the old filled map. Country markers glow in brand orange and show a tooltip with the country name and pageview count when you hover. - **Hide unknown locations.** New toggle in Site Settings under Data & Privacy to hide "Unknown" entries from your Locations panel. When geographic data can't be determined for a visitor, it normally shows as "Unknown" in countries, cities, and regions. Turn this on to keep your location stats clean and only show resolved locations. diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index 4392886..639ef53 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -36,6 +36,7 @@ const PerformanceStats = dynamic(() => import('@/components/dashboard/Performanc const GoalStats = dynamic(() => import('@/components/dashboard/GoalStats')) const ScrollDepth = dynamic(() => import('@/components/dashboard/ScrollDepth')) const Campaigns = dynamic(() => import('@/components/dashboard/Campaigns')) +const PeakHours = dynamic(() => import('@/components/dashboard/PeakHours')) const EventProperties = dynamic(() => import('@/components/dashboard/EventProperties')) const ExportModal = dynamic(() => import('@/components/dashboard/ExportModal')) import { type DimensionFilter, serializeFilters, parseFiltersFromURL } from '@/lib/filters' @@ -612,8 +613,9 @@ export default function SiteDashboardPage() { /> -
+
+
diff --git a/components/dashboard/PeakHours.tsx b/components/dashboard/PeakHours.tsx new file mode 100644 index 0000000..4622522 --- /dev/null +++ b/components/dashboard/PeakHours.tsx @@ -0,0 +1,131 @@ +'use client' + +import { useState, useEffect, useMemo } from 'react' +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 HOUR_LABELS: Record = { 0: '12am', 6: '6am', 12: '12pm', 18: '6pm' } + +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) + + useEffect(() => { + const fetchData = async () => { + setIsLoading(true) + try { + const result = await getDailyStats(siteId, dateRange.start, dateRange.end, 'hour') + setData(result) + } catch (e) { + logger.error(e) + } finally { + setIsLoading(false) + } + } + fetchData() + }, [siteId, dateRange]) + + const { grid, max } = useMemo(() => { + // grid[adjustedDay][hour] where Mon=0 ... Sun=6 + 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 hour = date.getHours() + const adjustedDay = day === 0 ? 6 : day - 1 // Mon=0 ... Sun=6 + grid[adjustedDay][hour] += d.pageviews + } + const max = Math.max(...grid.flat(), 1) + return { grid, max } + }, [data]) + + const hasData = data.some(d => d.pageviews > 0) + + return ( +
+
+

Peak Hours

+
+

+ When your visitors are most active +

+ + {isLoading ? ( +
+ {Array.from({ length: 7 }).map((_, i) => ( +
+
+
+
+ ))} +
+ ) : hasData ? ( +
+ {grid.map((hours, dayIdx) => ( +
+ + {DAYS[dayIdx]} + +
+ {hours.map((value, hour) => ( +
setTooltip({ day: dayIdx, hour, value })} + onMouseLeave={() => setTooltip(null)} + /> + ))} +
+
+ ))} + + {/* Hour axis labels */} +
+ +
+ {Object.entries(HOUR_LABELS).map(([h, label]) => ( + + {label} + + ))} + + 12am + +
+
+ + {/* Tooltip */} + {tooltip && ( +
+ {DAYS[tooltip.day]} {tooltip.hour}:00 — {tooltip.value} pageviews +
+ )} +
+ ) : ( +
+

+ No data available for this period +

+
+ )} +
+ ) +}