feat: add weekly trends

This commit is contained in:
Carl-Gerhard Lindesvärd
2026-02-18 10:44:19 +01:00
parent b81a2e0de6
commit 03c18b37ec
3 changed files with 266 additions and 2 deletions

View File

@@ -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<MetricKey>('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 (
<Widget className="col-span-6">
<WidgetHeadSearchable
tabs={METRICS.map((m) => ({ key: m.key, label: m.label }))}
activeTab={metric}
onTabChange={setMetric}
/>
<WidgetBody>
{query.isLoading ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
Loading...
</div>
) : !heatmap ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
No data available
</div>
) : (
<div className="flex">
{/* Hour labels */}
<div className="w-14 shrink-0 pr-2">
{/* Spacer for the day-label row */}
<div className="h-6" />
{Array.from({ length: 24 }, (_, hour) => (
<div
key={hour}
className="flex h-4 items-center justify-end text-[10px] text-muted-foreground"
>
{hour % 3 === 0
? `${String(hour).padStart(2, '0')}:00`
: ''}
</div>
))}
</div>
{/* Grid */}
<div className="flex-1 min-w-0">
{/* Day labels */}
<div className="flex h-6">
{SHORT_DAY_NAMES.map((day) => (
<div
key={day}
className="flex-1 text-center text-[11px] text-muted-foreground"
>
{day}
</div>
))}
</div>
<TooltipProvider disableHoverableContent delayDuration={0}>
{/* Rows = hours, columns = days */}
{Array.from({ length: 24 }, (_, hour) => (
<div key={hour} className="flex h-4">
{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 (
<Tooltip key={day}>
<TooltipTrigger asChild>
<div className={cn(
'flex-1 p-0.5 group',
)}>
<div className={cn(
'size-full rounded-sm transition-all group-hover:ring-1 group-hover:ring-emerald-400',
colorClass,
)}
/>
</div>
</TooltipTrigger>
<TooltipContent
side="top"
className="border-0 bg-transparent p-0 shadow-none"
>
<ChartTooltipContainer>
<ChartTooltipHeader>
<div className="text-sm font-medium">
{LONG_DAY_NAMES[day]}, {formatHourRange(hour)}
</div>
</ChartTooltipHeader>
<ChartTooltipItem color="#10b981">
<div className="flex items-center justify-between gap-6 font-mono font-medium text-sm">
<div className="text-muted-foreground">
{activeMetric.label}
</div>
<div>
{activeMetric.unit === 'pct'
? `${number.format(value)} %`
: number.formatWithUnit(
value,
activeMetric.unit || null,
)}
</div>
</div>
</ChartTooltipItem>
</ChartTooltipContainer>
</TooltipContent>
</Tooltip>
);
})}
</div>
))}
</TooltipProvider>
</div>
</div>
)}
</WidgetBody>
</Widget>
);
}

View File

@@ -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() {
<OverviewTopDevices projectId={projectId} />
<OverviewTopEvents projectId={projectId} />
<OverviewTopGeo projectId={projectId} />
<LazyComponent className="col-span-6">
<OverviewWeeklyTrends projectId={projectId} />
</LazyComponent>
<LazyComponent className="col-span-6">
<OverviewUserJourney projectId={projectId} />
</LazyComponent>

View File

@@ -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() {
<OverviewTopDevices projectId={projectId} shareId={shareId} />
<OverviewTopEvents projectId={projectId} shareId={shareId} />
<OverviewTopGeo projectId={projectId} shareId={shareId} />
<LazyComponent className="col-span-6">
<OverviewWeeklyTrends projectId={projectId} shareId={shareId} />
</LazyComponent>
<LazyComponent className="col-span-6">
<OverviewUserJourney projectId={projectId} shareId={shareId} />
</LazyComponent>