feat: insights

* fix: migration for newly created self-hosting instances

* fix: build script

* wip

* wip

* wip

* fix: tailwind css
This commit is contained in:
Carl-Gerhard Lindesvärd
2025-12-19 09:37:15 +01:00
committed by GitHub
parent 1e4f02fb5e
commit 5f38560373
48 changed files with 4072 additions and 25 deletions

View File

@@ -0,0 +1,229 @@
import { countries } from '@/translations/countries';
import type { RouterOutputs } from '@/trpc/client';
import { cn } from '@/utils/cn';
import type { InsightPayload } from '@openpanel/validation';
import { ArrowDown, ArrowUp, FilterIcon, RotateCcwIcon } from 'lucide-react';
import { last } from 'ramda';
import { useState } from 'react';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { Badge } from '../ui/badge';
function formatWindowKind(windowKind: string): string {
switch (windowKind) {
case 'yesterday':
return 'Yesterday';
case 'rolling_7d':
return '7 Days';
case 'rolling_30d':
return '30 Days';
}
return windowKind;
}
interface InsightCardProps {
insight: RouterOutputs['insight']['list'][number];
className?: string;
onFilter?: () => void;
}
export function InsightCard({
insight,
className,
onFilter,
}: InsightCardProps) {
const payload = insight.payload;
const dimensions = payload?.dimensions;
const availableMetrics = Object.entries(payload?.metrics ?? {});
// Pick what to display: prefer share if available (geo/devices), else primaryMetric
const [metricIndex, setMetricIndex] = useState(
availableMetrics.findIndex(([key]) => key === payload?.primaryMetric),
);
const currentMetricKey = availableMetrics[metricIndex][0];
const currentMetricEntry = availableMetrics[metricIndex][1];
const metricUnit = currentMetricEntry?.unit;
const currentValue = currentMetricEntry?.current ?? null;
const compareValue = currentMetricEntry?.compare ?? null;
const direction = currentMetricEntry?.direction ?? 'flat';
const isIncrease = direction === 'up';
const isDecrease = direction === 'down';
const deltaText =
metricUnit === 'ratio'
? `${Math.abs((currentMetricEntry?.delta ?? 0) * 100).toFixed(1)}pp`
: `${Math.abs((currentMetricEntry?.changePct ?? 0) * 100).toFixed(1)}%`;
// Format metric values
const formatValue = (value: number | null): string => {
if (value == null) return '-';
if (metricUnit === 'ratio') return `${(value * 100).toFixed(1)}%`;
return Math.round(value).toLocaleString();
};
// Get the metric label
const metricKeyToLabel = (key: string) =>
key === 'share' ? 'Share' : key === 'pageviews' ? 'Pageviews' : 'Sessions';
const metricLabel = metricKeyToLabel(currentMetricKey);
const renderTitle = () => {
if (
dimensions[0]?.key === 'country' ||
dimensions[0]?.key === 'referrer_name' ||
dimensions[0]?.key === 'device'
) {
return (
<span className="capitalize flex items-center gap-2">
<SerieIcon name={dimensions[0]?.value} /> {insight.displayName}
</span>
);
}
if (insight.displayName.startsWith('http')) {
return (
<span className="flex items-center gap-2">
<SerieIcon
name={dimensions[0]?.displayName ?? dimensions[0]?.value}
/>
<span className="line-clamp-2">{dimensions[1]?.displayName}</span>
</span>
);
}
return insight.displayName;
};
return (
<div
className={cn(
'card p-4 h-full flex flex-col hover:bg-def-50 transition-colors group/card',
className,
)}
>
<div
className={cn(
'row justify-between h-4 items-center',
onFilter && 'group-hover/card:hidden',
)}
>
<Badge variant="outline" className="-ml-2">
{formatWindowKind(insight.windowKind)}
</Badge>
{/* Severity: subtle dot instead of big pill */}
{insight.severityBand && (
<div className="flex items-center gap-1 shrink-0">
<span
className={cn(
'h-2 w-2 rounded-full',
insight.severityBand === 'severe'
? 'bg-red-500'
: insight.severityBand === 'moderate'
? 'bg-yellow-500'
: 'bg-blue-500',
)}
/>
<span className="text-[11px] text-muted-foreground capitalize">
{insight.severityBand}
</span>
</div>
)}
</div>
{onFilter && (
<div className="row group-hover/card:flex hidden h-4 justify-between gap-2">
{availableMetrics.length > 1 ? (
<button
type="button"
className="text-[11px] text-muted-foreground capitalize flex items-center gap-1"
onClick={() =>
setMetricIndex((metricIndex + 1) % availableMetrics.length)
}
>
<RotateCcwIcon className="size-2" />
Show{' '}
{metricKeyToLabel(
availableMetrics[
(metricIndex + 1) % availableMetrics.length
][0],
)}
</button>
) : (
<div />
)}
<button
type="button"
className="text-[11px] text-muted-foreground capitalize flex items-center gap-1"
onClick={onFilter}
>
Filter <FilterIcon className="size-2" />
</button>
</div>
)}
<div className="font-semibold text-sm leading-snug line-clamp-2 mt-2">
{renderTitle()}
</div>
{/* Metric row */}
<div className="mt-auto pt-2">
<div className="flex items-end justify-between gap-3">
<div className="min-w-0">
<div className="text-[11px] text-muted-foreground mb-1">
{metricLabel}
</div>
<div className="col gap-1">
<div className="text-2xl font-semibold tracking-tight">
{formatValue(currentValue)}
</div>
{/* Inline compare, smaller */}
{compareValue != null && (
<div className="text-xs text-muted-foreground">
vs {formatValue(compareValue)}
</div>
)}
</div>
</div>
{/* Delta chip */}
<DeltaChip
isIncrease={isIncrease}
isDecrease={isDecrease}
deltaText={deltaText}
/>
</div>
</div>
</div>
);
}
function DeltaChip({
isIncrease,
isDecrease,
deltaText,
}: {
isIncrease: boolean;
isDecrease: boolean;
deltaText: string;
}) {
return (
<div
className={cn(
'flex items-center gap-1 rounded-full px-2 py-1 text-sm font-semibold',
isIncrease
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: isDecrease
? 'bg-red-500/10 text-red-600 dark:text-red-400'
: 'bg-muted text-muted-foreground',
)}
>
{isIncrease ? (
<ArrowUp size={16} className="shrink-0" />
) : isDecrease ? (
<ArrowDown size={16} className="shrink-0" />
) : null}
<span>{deltaText}</span>
</div>
);
}

View File

@@ -0,0 +1,75 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import { InsightCard } from '../insights/insight-card';
import { Skeleton } from '../skeleton';
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from '../ui/carousel';
interface OverviewInsightsProps {
projectId: string;
}
export default function OverviewInsights({ projectId }: OverviewInsightsProps) {
const trpc = useTRPC();
const [filters, setFilter] = useEventQueryFilters();
const { data: insights, isLoading } = useQuery(
trpc.insight.list.queryOptions({
projectId,
limit: 20,
}),
);
if (isLoading) {
const keys = Array.from({ length: 4 }, (_, i) => `insight-skeleton-${i}`);
return (
<div className="col-span-6">
<Carousel opts={{ align: 'start' }} className="w-full">
<CarouselContent className="-ml-4">
{keys.map((key) => (
<CarouselItem
key={key}
className="pl-4 basis-full sm:basis-1/2 lg:basis-1/4"
>
<Skeleton className="h-36 w-full" />
</CarouselItem>
))}
</CarouselContent>
</Carousel>
</div>
);
}
if (!insights || insights.length === 0) return null;
return (
<div className="col-span-6 -mx-4">
<Carousel opts={{ align: 'start' }} className="w-full group">
<CarouselContent className="mr-4">
{insights.map((insight) => (
<CarouselItem
key={insight.id}
className="pl-4 basis-full sm:basis-1/2 lg:basis-1/4"
>
<InsightCard
insight={insight}
onFilter={() => {
insight.payload.dimensions.forEach((dim) => {
void setFilter(dim.key, dim.value, 'is');
});
}}
/>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious className="!opacity-0 pointer-events-none transition-opacity group-hover:!opacity-100 group-hover:pointer-events-auto group-focus:opacity-100 group-focus:pointer-events-auto" />
<CarouselNext className="!opacity-0 pointer-events-none transition-opacity group-hover:!opacity-100 group-hover:pointer-events-auto group-focus:opacity-100 group-focus:pointer-events-auto" />
</Carousel>
</div>
);
}

View File

@@ -17,6 +17,7 @@ import {
LayoutPanelTopIcon,
PlusIcon,
SparklesIcon,
TrendingUpDownIcon,
UndoDotIcon,
UsersIcon,
WallpaperIcon,
@@ -39,13 +40,18 @@ export default function SidebarProjectMenu({
}: SidebarProjectMenuProps) {
return (
<>
<div className="mb-2 font-medium text-muted-foreground">Insights</div>
<div className="mb-2 font-medium text-muted-foreground">Analytics</div>
<SidebarLink icon={WallpaperIcon} label="Overview" href={'/'} />
<SidebarLink
icon={LayoutPanelTopIcon}
label="Dashboards"
href={'/dashboards'}
/>
<SidebarLink
icon={TrendingUpDownIcon}
label="Insights"
href={'/insights'}
/>
<SidebarLink icon={LayersIcon} label="Pages" href={'/pages'} />
<SidebarLink icon={Globe2Icon} label="Realtime" href={'/realtime'} />
<SidebarLink icon={GanttChartIcon} label="Events" href={'/events'} />

View File

@@ -123,7 +123,7 @@ export function SidebarContainer({
</div>
<div
className={cn([
'flex flex-grow col gap-1 overflow-auto p-4',
'flex flex-grow col gap-1 overflow-auto p-4 hide-scrollbar',
"[&_a[data-status='active']]:bg-def-200",
])}
>

View File

@@ -208,7 +208,7 @@ const CarouselPrevious = React.forwardRef<
variant={variant}
size={size}
className={cn(
'absolute h-10 w-10 rounded-full hover:scale-100 hover:translate-y-[-50%] transition-all duration-200',
'absolute h-10 w-10 rounded-full hover:scale-100 hover:translate-y-[-50%] transition-all duration-200',
orientation === 'horizontal'
? 'left-6 top-1/2 -translate-y-1/2'
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',

View File

@@ -38,6 +38,7 @@ import { Route as AppOrganizationIdProjectIdReportsRouteImport } from './routes/
import { Route as AppOrganizationIdProjectIdReferencesRouteImport } from './routes/_app.$organizationId.$projectId.references'
import { Route as AppOrganizationIdProjectIdRealtimeRouteImport } from './routes/_app.$organizationId.$projectId.realtime'
import { Route as AppOrganizationIdProjectIdPagesRouteImport } from './routes/_app.$organizationId.$projectId.pages'
import { Route as AppOrganizationIdProjectIdInsightsRouteImport } from './routes/_app.$organizationId.$projectId.insights'
import { Route as AppOrganizationIdProjectIdDashboardsRouteImport } from './routes/_app.$organizationId.$projectId.dashboards'
import { Route as AppOrganizationIdProjectIdChatRouteImport } from './routes/_app.$organizationId.$projectId.chat'
import { Route as AppOrganizationIdMembersTabsIndexRouteImport } from './routes/_app.$organizationId.members._tabs.index'
@@ -273,6 +274,12 @@ const AppOrganizationIdProjectIdPagesRoute =
path: '/pages',
getParentRoute: () => AppOrganizationIdProjectIdRoute,
} as any)
const AppOrganizationIdProjectIdInsightsRoute =
AppOrganizationIdProjectIdInsightsRouteImport.update({
id: '/insights',
path: '/insights',
getParentRoute: () => AppOrganizationIdProjectIdRoute,
} as any)
const AppOrganizationIdProjectIdDashboardsRoute =
AppOrganizationIdProjectIdDashboardsRouteImport.update({
id: '/dashboards',
@@ -495,6 +502,7 @@ export interface FileRoutesByFullPath {
'/$organizationId/': typeof AppOrganizationIdIndexRoute
'/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
'/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
'/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute
'/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
'/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
@@ -552,6 +560,7 @@ export interface FileRoutesByTo {
'/$organizationId': typeof AppOrganizationIdIndexRoute
'/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
'/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
'/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute
'/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
'/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
@@ -609,6 +618,7 @@ export interface FileRoutesById {
'/_app/$organizationId/': typeof AppOrganizationIdIndexRoute
'/_app/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
'/_app/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
'/_app/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute
'/_app/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute
'/_app/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
'/_app/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
@@ -677,6 +687,7 @@ export interface FileRouteTypes {
| '/$organizationId/'
| '/$organizationId/$projectId/chat'
| '/$organizationId/$projectId/dashboards'
| '/$organizationId/$projectId/insights'
| '/$organizationId/$projectId/pages'
| '/$organizationId/$projectId/realtime'
| '/$organizationId/$projectId/references'
@@ -734,6 +745,7 @@ export interface FileRouteTypes {
| '/$organizationId'
| '/$organizationId/$projectId/chat'
| '/$organizationId/$projectId/dashboards'
| '/$organizationId/$projectId/insights'
| '/$organizationId/$projectId/pages'
| '/$organizationId/$projectId/realtime'
| '/$organizationId/$projectId/references'
@@ -790,6 +802,7 @@ export interface FileRouteTypes {
| '/_app/$organizationId/'
| '/_app/$organizationId/$projectId/chat'
| '/_app/$organizationId/$projectId/dashboards'
| '/_app/$organizationId/$projectId/insights'
| '/_app/$organizationId/$projectId/pages'
| '/_app/$organizationId/$projectId/realtime'
| '/_app/$organizationId/$projectId/references'
@@ -1085,6 +1098,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdPagesRouteImport
parentRoute: typeof AppOrganizationIdProjectIdRoute
}
'/_app/$organizationId/$projectId/insights': {
id: '/_app/$organizationId/$projectId/insights'
path: '/insights'
fullPath: '/$organizationId/$projectId/insights'
preLoaderRoute: typeof AppOrganizationIdProjectIdInsightsRouteImport
parentRoute: typeof AppOrganizationIdProjectIdRoute
}
'/_app/$organizationId/$projectId/dashboards': {
id: '/_app/$organizationId/$projectId/dashboards'
path: '/dashboards'
@@ -1528,6 +1548,7 @@ const AppOrganizationIdProjectIdSettingsRouteWithChildren =
interface AppOrganizationIdProjectIdRouteChildren {
AppOrganizationIdProjectIdChatRoute: typeof AppOrganizationIdProjectIdChatRoute
AppOrganizationIdProjectIdDashboardsRoute: typeof AppOrganizationIdProjectIdDashboardsRoute
AppOrganizationIdProjectIdInsightsRoute: typeof AppOrganizationIdProjectIdInsightsRoute
AppOrganizationIdProjectIdPagesRoute: typeof AppOrganizationIdProjectIdPagesRoute
AppOrganizationIdProjectIdRealtimeRoute: typeof AppOrganizationIdProjectIdRealtimeRoute
AppOrganizationIdProjectIdReferencesRoute: typeof AppOrganizationIdProjectIdReferencesRoute
@@ -1548,6 +1569,8 @@ const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteCh
AppOrganizationIdProjectIdChatRoute: AppOrganizationIdProjectIdChatRoute,
AppOrganizationIdProjectIdDashboardsRoute:
AppOrganizationIdProjectIdDashboardsRoute,
AppOrganizationIdProjectIdInsightsRoute:
AppOrganizationIdProjectIdInsightsRoute,
AppOrganizationIdProjectIdPagesRoute: AppOrganizationIdProjectIdPagesRoute,
AppOrganizationIdProjectIdRealtimeRoute:
AppOrganizationIdProjectIdRealtimeRoute,

View File

@@ -3,6 +3,7 @@ import {
OverviewFiltersButtons,
} from '@/components/overview/filters/overview-filters-buttons';
import { LiveCounter } from '@/components/overview/live-counter';
import OverviewInsights from '@/components/overview/overview-insights';
import { OverviewInterval } from '@/components/overview/overview-interval';
import OverviewMetrics from '@/components/overview/overview-metrics';
import { OverviewRange } from '@/components/overview/overview-range';
@@ -50,6 +51,7 @@ function ProjectDashboard() {
</div>
<div className="grid grid-cols-6 gap-4 p-4 pt-0">
<OverviewMetrics projectId={projectId} />
<OverviewInsights projectId={projectId} />
<OverviewTopSources projectId={projectId} />
<OverviewTopPages projectId={projectId} />
<OverviewTopDevices projectId={projectId} />

View File

@@ -0,0 +1,431 @@
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { InsightCard } from '@/components/insights/insight-card';
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { Skeleton } from '@/components/skeleton';
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from '@/components/ui/carousel';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { TableButtons } from '@/components/ui/table';
import { useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/utils/cn';
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
import { useQuery } from '@tanstack/react-query';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { parseAsString, parseAsStringEnum, useQueryState } from 'nuqs';
import { useMemo } from 'react';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId/insights',
)({
component: Component,
head: () => {
return {
meta: [
{
title: createProjectTitle(PAGE_TITLES.INSIGHTS),
},
],
};
},
});
type SortOption =
| 'impact-desc'
| 'impact-asc'
| 'severity-desc'
| 'severity-asc'
| 'recent';
function getModuleDisplayName(moduleKey: string): string {
const displayNames: Record<string, string> = {
geo: 'Geographic',
devices: 'Devices',
referrers: 'Referrers',
'entry-pages': 'Entry Pages',
'page-trends': 'Page Trends',
'exit-pages': 'Exit Pages',
'traffic-anomalies': 'Anomalies',
};
return displayNames[moduleKey] || moduleKey.replace('-', ' ');
}
function Component() {
const { projectId } = Route.useParams();
const trpc = useTRPC();
const { data: insights, isLoading } = useQuery(
trpc.insight.listAll.queryOptions({
projectId,
limit: 500,
}),
);
const navigate = useNavigate();
const [search, setSearch] = useQueryState(
'search',
parseAsString.withDefault(''),
);
const [moduleFilter, setModuleFilter] = useQueryState(
'module',
parseAsString.withDefault('all'),
);
const [windowKindFilter, setWindowKindFilter] = useQueryState(
'window',
parseAsStringEnum([
'all',
'yesterday',
'rolling_7d',
'rolling_30d',
]).withDefault('all'),
);
const [severityFilter, setSeverityFilter] = useQueryState(
'severity',
parseAsStringEnum(['all', 'severe', 'moderate', 'low', 'none']).withDefault(
'all',
),
);
const [directionFilter, setDirectionFilter] = useQueryState(
'direction',
parseAsStringEnum(['all', 'up', 'down', 'flat']).withDefault('all'),
);
const [sortBy, setSortBy] = useQueryState(
'sort',
parseAsStringEnum<SortOption>([
'impact-desc',
'impact-asc',
'severity-desc',
'severity-asc',
'recent',
]).withDefault('impact-desc'),
);
const filteredAndSorted = useMemo(() => {
if (!insights) return [];
const filtered = insights.filter((insight) => {
// Search filter
if (search) {
const searchLower = search.toLowerCase();
const matchesTitle = insight.title.toLowerCase().includes(searchLower);
const matchesSummary = insight.summary
?.toLowerCase()
.includes(searchLower);
const matchesDimension = insight.dimensionKey
.toLowerCase()
.includes(searchLower);
if (!matchesTitle && !matchesSummary && !matchesDimension) {
return false;
}
}
// Module filter
if (moduleFilter !== 'all' && insight.moduleKey !== moduleFilter) {
return false;
}
// Window kind filter
if (
windowKindFilter !== 'all' &&
insight.windowKind !== windowKindFilter
) {
return false;
}
// Severity filter
if (severityFilter !== 'all') {
if (severityFilter === 'none' && insight.severityBand) return false;
if (
severityFilter !== 'none' &&
insight.severityBand !== severityFilter
)
return false;
}
// Direction filter
if (directionFilter !== 'all' && insight.direction !== directionFilter) {
return false;
}
return true;
});
// Sort (create new array to avoid mutation)
const sorted = [...filtered].sort((a, b) => {
switch (sortBy) {
case 'impact-desc':
return (b.impactScore ?? 0) - (a.impactScore ?? 0);
case 'impact-asc':
return (a.impactScore ?? 0) - (b.impactScore ?? 0);
case 'severity-desc': {
const severityOrder: Record<string, number> = {
severe: 3,
moderate: 2,
low: 1,
};
const aSev = severityOrder[a.severityBand ?? ''] ?? 0;
const bSev = severityOrder[b.severityBand ?? ''] ?? 0;
return bSev - aSev;
}
case 'severity-asc': {
const severityOrder: Record<string, number> = {
severe: 3,
moderate: 2,
low: 1,
};
const aSev = severityOrder[a.severityBand ?? ''] ?? 0;
const bSev = severityOrder[b.severityBand ?? ''] ?? 0;
return aSev - bSev;
}
case 'recent':
return (
new Date(b.firstDetectedAt ?? 0).getTime() -
new Date(a.firstDetectedAt ?? 0).getTime()
);
default:
return 0;
}
});
return sorted;
}, [
insights,
search,
moduleFilter,
windowKindFilter,
severityFilter,
directionFilter,
sortBy,
]);
// Group insights by module
const groupedByModule = useMemo(() => {
const groups = new Map<string, typeof filteredAndSorted>();
for (const insight of filteredAndSorted) {
const existing = groups.get(insight.moduleKey) ?? [];
existing.push(insight);
groups.set(insight.moduleKey, existing);
}
// Sort modules by impact (referrers first, then by average impact score)
return Array.from(groups.entries()).sort(
([keyA, insightsA], [keyB, insightsB]) => {
// Referrers always first
if (keyA === 'referrers') return -1;
if (keyB === 'referrers') return 1;
// Calculate average impact for each module
const avgImpactA =
insightsA.reduce((sum, i) => sum + (i.impactScore ?? 0), 0) /
insightsA.length;
const avgImpactB =
insightsB.reduce((sum, i) => sum + (i.impactScore ?? 0), 0) /
insightsB.length;
// Sort by average impact (high to low)
return avgImpactB - avgImpactA;
},
);
}, [filteredAndSorted]);
if (isLoading) {
return (
<PageContainer>
<PageHeader title="Insights" className="mb-8" />
<div className="space-y-8">
{Array.from({ length: 3 }, (_, i) => `section-${i}`).map((key) => (
<div key={key} className="space-y-4">
<Skeleton className="h-8 w-32" />
<Carousel opts={{ align: 'start' }} className="w-full">
<CarouselContent className="-ml-4">
{Array.from({ length: 4 }, (_, i) => `skeleton-${i}`).map(
(cardKey) => (
<CarouselItem
key={cardKey}
className="pl-4 basis-full sm:basis-1/2 lg:basis-1/3 xl:basis-1/4"
>
<Skeleton className="h-48 w-full" />
</CarouselItem>
),
)}
</CarouselContent>
</Carousel>
</div>
))}
</div>
</PageContainer>
);
}
return (
<PageContainer>
<PageHeader
title="Insights"
description="Discover trends and changes in your analytics"
className="mb-8"
/>
<TableButtons className="mb-8">
<Input
placeholder="Search insights..."
value={search ?? ''}
onChange={(e) => void setSearch(e.target.value || null)}
className="max-w-xs"
/>
<Select
value={windowKindFilter ?? 'all'}
onValueChange={(v) =>
void setWindowKindFilter(v as typeof windowKindFilter)
}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Time Window" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Windows</SelectItem>
<SelectItem value="yesterday">Yesterday</SelectItem>
<SelectItem value="rolling_7d">7 Days</SelectItem>
<SelectItem value="rolling_30d">30 Days</SelectItem>
</SelectContent>
</Select>
<Select
value={severityFilter ?? 'all'}
onValueChange={(v) =>
void setSeverityFilter(v as typeof severityFilter)
}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Severity" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Severity</SelectItem>
<SelectItem value="severe">Severe</SelectItem>
<SelectItem value="moderate">Moderate</SelectItem>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="none">No Severity</SelectItem>
</SelectContent>
</Select>
<Select
value={directionFilter ?? 'all'}
onValueChange={(v) =>
void setDirectionFilter(v as typeof directionFilter)
}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Direction" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Directions</SelectItem>
<SelectItem value="up">Increasing</SelectItem>
<SelectItem value="down">Decreasing</SelectItem>
<SelectItem value="flat">Flat</SelectItem>
</SelectContent>
</Select>
<Select
value={sortBy ?? 'impact-desc'}
onValueChange={(v) => void setSortBy(v as SortOption)}
>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="impact-desc">Impact (High Low)</SelectItem>
<SelectItem value="impact-asc">Impact (Low High)</SelectItem>
<SelectItem value="severity-desc">Severity (High Low)</SelectItem>
<SelectItem value="severity-asc">Severity (Low High)</SelectItem>
<SelectItem value="recent">Most Recent</SelectItem>
</SelectContent>
</Select>
</TableButtons>
{filteredAndSorted.length === 0 && !isLoading && (
<FullPageEmptyState
title="No insights found"
description={
search || moduleFilter !== 'all' || windowKindFilter !== 'all'
? 'Try adjusting your filters to see more insights.'
: 'Insights will appear here as trends are detected in your analytics.'
}
/>
)}
{groupedByModule.length > 0 && (
<div className="space-y-8">
{groupedByModule.map(([moduleKey, moduleInsights]) => (
<div key={moduleKey} className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold capitalize">
{getModuleDisplayName(moduleKey)}
</h2>
<span className="text-sm text-muted-foreground">
{moduleInsights.length}{' '}
{moduleInsights.length === 1 ? 'insight' : 'insights'}
</span>
</div>
<div className="-mx-8">
<Carousel
opts={{ align: 'start', dragFree: true }}
className="w-full group"
>
<CarouselContent className="mx-4 mr-8">
{moduleInsights.map((insight, index) => (
<CarouselItem
key={insight.id}
className={cn(
'pl-4 basis-full sm:basis-1/2 lg:basis-1/3 xl:basis-1/4',
)}
>
<InsightCard
insight={insight}
onFilter={(() => {
const filterString = insight.payload?.dimensions
.map(
(dim) =>
`${dim.key},is,${encodeURIComponent(dim.value)}`,
)
.join(';');
if (filterString) {
return () => {
navigate({
to: '/$organizationId/$projectId',
from: Route.fullPath,
search: {
f: filterString,
},
});
};
}
return undefined;
})()}
/>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious className="opacity-0 [&:disabled]:opacity-0 pointer-events-none transition-opacity group-hover:opacity-100 group-hover:pointer-events-auto left-3" />
<CarouselNext className="opacity-0 [&:disabled]:opacity-0 pointer-events-none transition-opacity group-hover:opacity-100 group-hover:pointer-events-auto right-3" />
</Carousel>
</div>
</div>
))}
</div>
)}
{filteredAndSorted.length > 0 && (
<div className="mt-8 text-sm text-muted-foreground text-center">
Showing {filteredAndSorted.length} of {insights?.length ?? 0} insights
</div>
)}
</PageContainer>
);
}

View File

@@ -90,6 +90,7 @@ export const PAGE_TITLES = {
CHAT: 'AI Assistant',
REALTIME: 'Realtime',
REFERENCES: 'References',
INSIGHTS: 'Insights',
// Profiles
PROFILES: 'Profiles',
PROFILE_EVENTS: 'Profile events',