feat: add weekly trends
This commit is contained in:
256
apps/start/src/components/overview/overview-weekly-trends.tsx
Normal file
256
apps/start/src/components/overview/overview-weekly-trends.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { LazyComponent } from '@/components/lazy-component';
|
import { LazyComponent } from '@/components/lazy-component';
|
||||||
import {
|
import {
|
||||||
OverviewFilterButton,
|
OverviewFilterButton,
|
||||||
@@ -15,8 +16,8 @@ import OverviewTopGeo from '@/components/overview/overview-top-geo';
|
|||||||
import OverviewTopPages from '@/components/overview/overview-top-pages';
|
import OverviewTopPages from '@/components/overview/overview-top-pages';
|
||||||
import OverviewTopSources from '@/components/overview/overview-top-sources';
|
import OverviewTopSources from '@/components/overview/overview-top-sources';
|
||||||
import OverviewUserJourney from '@/components/overview/overview-user-journey';
|
import OverviewUserJourney from '@/components/overview/overview-user-journey';
|
||||||
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
|
import OverviewWeeklyTrends from '@/components/overview/overview-weekly-trends';
|
||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createProjectTitle, PAGE_TITLES } from '@/utils/title';
|
||||||
|
|
||||||
export const Route = createFileRoute('/_app/$organizationId/$projectId/')({
|
export const Route = createFileRoute('/_app/$organizationId/$projectId/')({
|
||||||
component: ProjectDashboard,
|
component: ProjectDashboard,
|
||||||
@@ -59,6 +60,9 @@ function ProjectDashboard() {
|
|||||||
<OverviewTopDevices projectId={projectId} />
|
<OverviewTopDevices projectId={projectId} />
|
||||||
<OverviewTopEvents projectId={projectId} />
|
<OverviewTopEvents projectId={projectId} />
|
||||||
<OverviewTopGeo projectId={projectId} />
|
<OverviewTopGeo projectId={projectId} />
|
||||||
|
<LazyComponent className="col-span-6">
|
||||||
|
<OverviewWeeklyTrends projectId={projectId} />
|
||||||
|
</LazyComponent>
|
||||||
<LazyComponent className="col-span-6">
|
<LazyComponent className="col-span-6">
|
||||||
<OverviewUserJourney projectId={projectId} />
|
<OverviewUserJourney projectId={projectId} />
|
||||||
</LazyComponent>
|
</LazyComponent>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import OverviewTopGeo from '@/components/overview/overview-top-geo';
|
|||||||
import OverviewTopPages from '@/components/overview/overview-top-pages';
|
import OverviewTopPages from '@/components/overview/overview-top-pages';
|
||||||
import OverviewTopSources from '@/components/overview/overview-top-sources';
|
import OverviewTopSources from '@/components/overview/overview-top-sources';
|
||||||
import OverviewUserJourney from '@/components/overview/overview-user-journey';
|
import OverviewUserJourney from '@/components/overview/overview-user-journey';
|
||||||
|
import OverviewWeeklyTrends from '@/components/overview/overview-weekly-trends';
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||||
import { createFileRoute, notFound, useSearch } from '@tanstack/react-router';
|
import { createFileRoute, notFound, useSearch } from '@tanstack/react-router';
|
||||||
@@ -125,6 +126,9 @@ function RouteComponent() {
|
|||||||
<OverviewTopDevices projectId={projectId} shareId={shareId} />
|
<OverviewTopDevices projectId={projectId} shareId={shareId} />
|
||||||
<OverviewTopEvents projectId={projectId} shareId={shareId} />
|
<OverviewTopEvents projectId={projectId} shareId={shareId} />
|
||||||
<OverviewTopGeo 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">
|
<LazyComponent className="col-span-6">
|
||||||
<OverviewUserJourney projectId={projectId} shareId={shareId} />
|
<OverviewUserJourney projectId={projectId} shareId={shareId} />
|
||||||
</LazyComponent>
|
</LazyComponent>
|
||||||
|
|||||||
Reference in New Issue
Block a user