From 03c18b37ec3f1edcab4c7ec2e35e118019770e4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Wed, 18 Feb 2026 10:44:19 +0100 Subject: [PATCH] feat: add weekly trends --- .../overview/overview-weekly-trends.tsx | 256 ++++++++++++++++++ .../_app.$organizationId.$projectId.index.tsx | 8 +- .../src/routes/share.overview.$shareId.tsx | 4 + 3 files changed, 266 insertions(+), 2 deletions(-) create mode 100644 apps/start/src/components/overview/overview-weekly-trends.tsx diff --git a/apps/start/src/components/overview/overview-weekly-trends.tsx b/apps/start/src/components/overview/overview-weekly-trends.tsx new file mode 100644 index 00000000..53181030 --- /dev/null +++ b/apps/start/src/components/overview/overview-weekly-trends.tsx @@ -0,0 +1,256 @@ +import { + ChartTooltipContainer, + ChartTooltipHeader, + ChartTooltipItem, +} from '@/components/charts/chart-tooltip'; +import { useEventQueryFilters } from '@/hooks/use-event-query-filters'; +import { useNumber } from '@/hooks/use-numer-formatter'; +import { useTRPC } from '@/integrations/trpc/react'; +import { cn } from '@/utils/cn'; +import { useQuery } from '@tanstack/react-query'; +import { useMemo, useState } from 'react'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '../ui/tooltip'; +import { Widget, WidgetBody } from '../widget'; +import { WidgetHeadSearchable } from './overview-widget'; +import { useOverviewOptions } from './useOverviewOptions'; + +interface OverviewWeeklyTrendsProps { + projectId: string; + shareId?: string; +} + +type MetricKey = + | 'unique_visitors' + | 'total_sessions' + | 'total_screen_views' + | 'bounce_rate' + | 'views_per_session' + | 'avg_session_duration'; + +const METRICS: { key: MetricKey; label: string; unit: string }[] = [ + { key: 'unique_visitors', label: 'Unique Visitors', unit: '' }, + { key: 'total_sessions', label: 'Sessions', unit: '' }, + { key: 'total_screen_views', label: 'Pageviews', unit: '' }, + { key: 'bounce_rate', label: 'Bounce Rate', unit: 'pct' }, + { key: 'views_per_session', label: 'Pages / Session', unit: '' }, + { key: 'avg_session_duration', label: 'Session Duration', unit: 'min' }, +]; + +const SHORT_DAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; +const LONG_DAY_NAMES = [ + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday', +]; + +function formatHourRange(hour: number) { + const pad = (n: number) => String(n).padStart(2, '0'); + return `${pad(hour)}:00 – ${pad(hour)}:59`; +} + +function getColorClass(ratio: number) { + if(ratio === 0) return 'bg-transparent'; + if (ratio < 0.1) return 'bg-chart-0/5'; + if (ratio < 0.2) return 'bg-chart-0/10'; + if (ratio < 0.3) return 'bg-chart-0/20'; + if (ratio < 0.4) return 'bg-chart-0/30'; + if (ratio < 0.5) return 'bg-chart-0/40'; + if (ratio < 0.6) return 'bg-chart-0/50'; + if (ratio < 0.7) return 'bg-chart-0/60'; + if (ratio < 0.8) return 'bg-chart-0/70'; + if (ratio < 0.9) return 'bg-chart-0/90'; + return 'bg-chart-0'; +} + +export default function OverviewWeeklyTrends({ + projectId, + shareId, +}: OverviewWeeklyTrendsProps) { + const { range, startDate, endDate } = useOverviewOptions(); + const [filters] = useEventQueryFilters(); + const [metric, setMetric] = useState('unique_visitors'); + const trpc = useTRPC(); + const number = useNumber(); + + const query = useQuery( + trpc.overview.stats.queryOptions({ + projectId, + shareId, + range, + interval: 'hour', + filters, + startDate, + endDate, + }), + ); + + // Build a 7×24 heatmap: aggregated[dayOfWeek][hour] averaged over all weeks + const heatmap = useMemo(() => { + const series = query.data?.series; + if (!series?.length) return null; + + // aggregated[day 0=Mon..6=Sun][hour] + const sums: number[][] = Array.from({ length: 7 }, () => + Array(24).fill(0), + ); + const counts: number[][] = Array.from({ length: 7 }, () => + Array(24).fill(0), + ); + + for (const item of series) { + const value = item[metric]; + if (typeof value !== 'number' || !Number.isFinite(value)) continue; + + const d = new Date(item.date); + // JS getDay(): 0=Sun,1=Mon,...,6=Sat → remap to 0=Mon..6=Sun + const jsDay = d.getDay(); + const day = jsDay === 0 ? 6 : jsDay - 1; + const hour = d.getHours(); + + sums[day]![hour]! += value; + counts[day]![hour]! += 1; + } + + const averages: number[][] = sums.map((row, day) => + row.map((sum, hour) => { + const count = counts[day]![hour]!; + return count > 0 ? sum / count : 0; + }), + ); + + let max = 0; + for (const row of averages) { + for (const v of row) { + if (v > max) max = v; + } + } + + return { averages, max }; + }, [query.data, metric]); + + const activeMetric = METRICS.find((m) => m.key === metric)!; + + return ( + + ({ key: m.key, label: m.label }))} + activeTab={metric} + onTabChange={setMetric} + /> + + {query.isLoading ? ( +
+ Loading... +
+ ) : !heatmap ? ( +
+ No data available +
+ ) : ( +
+ {/* Hour labels */} +
+ {/* Spacer for the day-label row */} +
+ {Array.from({ length: 24 }, (_, hour) => ( +
+ {hour % 3 === 0 + ? `${String(hour).padStart(2, '0')}:00` + : ''} +
+ ))} +
+ + {/* Grid */} +
+ {/* Day labels */} +
+ {SHORT_DAY_NAMES.map((day) => ( +
+ {day} +
+ ))} +
+ + + {/* Rows = hours, columns = days */} + {Array.from({ length: 24 }, (_, hour) => ( +
+ {Array.from({ length: 7 }, (_, day) => { + const value = heatmap.averages[day]![hour]!; + const ratio = + heatmap.max > 0 && value > 0 + ? value / heatmap.max + : 0; + const colorClass = getColorClass(ratio) + + return ( + + +
+
+
+ + + + +
+ {LONG_DAY_NAMES[day]}, {formatHourRange(hour)} +
+
+ +
+
+ {activeMetric.label} +
+
+ {activeMetric.unit === 'pct' + ? `${number.format(value)} %` + : number.formatWithUnit( + value, + activeMetric.unit || null, + )} +
+
+
+
+
+ + ); + })} +
+ ))} + +
+
+ )} + + + ); +} diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.index.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.index.tsx index bf38b063..834ce162 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.index.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.index.tsx @@ -1,3 +1,4 @@ +import { createFileRoute } from '@tanstack/react-router'; import { LazyComponent } from '@/components/lazy-component'; import { OverviewFilterButton, @@ -15,8 +16,8 @@ import OverviewTopGeo from '@/components/overview/overview-top-geo'; import OverviewTopPages from '@/components/overview/overview-top-pages'; import OverviewTopSources from '@/components/overview/overview-top-sources'; import OverviewUserJourney from '@/components/overview/overview-user-journey'; -import { PAGE_TITLES, createProjectTitle } from '@/utils/title'; -import { createFileRoute } from '@tanstack/react-router'; +import OverviewWeeklyTrends from '@/components/overview/overview-weekly-trends'; +import { createProjectTitle, PAGE_TITLES } from '@/utils/title'; export const Route = createFileRoute('/_app/$organizationId/$projectId/')({ component: ProjectDashboard, @@ -59,6 +60,9 @@ function ProjectDashboard() { + + + diff --git a/apps/start/src/routes/share.overview.$shareId.tsx b/apps/start/src/routes/share.overview.$shareId.tsx index e20fa3e6..8befd547 100644 --- a/apps/start/src/routes/share.overview.$shareId.tsx +++ b/apps/start/src/routes/share.overview.$shareId.tsx @@ -13,6 +13,7 @@ import OverviewTopGeo from '@/components/overview/overview-top-geo'; import OverviewTopPages from '@/components/overview/overview-top-pages'; import OverviewTopSources from '@/components/overview/overview-top-sources'; import OverviewUserJourney from '@/components/overview/overview-user-journey'; +import OverviewWeeklyTrends from '@/components/overview/overview-weekly-trends'; import { useTRPC } from '@/integrations/trpc/react'; import { useSuspenseQuery } from '@tanstack/react-query'; import { createFileRoute, notFound, useSearch } from '@tanstack/react-router'; @@ -125,6 +126,9 @@ function RouteComponent() { + + +