wip
This commit is contained in:
214
apps/start/src/components/insights/insight-card.tsx
Normal file
214
apps/start/src/components/insights/insight-card.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { countries } from '@/translations/countries';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { ArrowDown, ArrowUp } from 'lucide-react';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { Badge } from '../ui/badge';
|
||||
|
||||
type InsightPayload = {
|
||||
metric?: 'sessions' | 'pageviews' | 'share';
|
||||
primaryDimension?: {
|
||||
type: string;
|
||||
displayName: string;
|
||||
};
|
||||
extra?: {
|
||||
currentShare?: number;
|
||||
compareShare?: number;
|
||||
shareShiftPp?: number;
|
||||
isNew?: boolean;
|
||||
isGone?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type Insight = {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string | null;
|
||||
payload: unknown;
|
||||
currentValue: number | null;
|
||||
compareValue: number | null;
|
||||
changePct: number | null;
|
||||
direction: string | null;
|
||||
moduleKey: string;
|
||||
dimensionKey: string;
|
||||
windowKind: string;
|
||||
severityBand: string | null;
|
||||
impactScore?: number | null;
|
||||
firstDetectedAt?: string | Date;
|
||||
};
|
||||
|
||||
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: Insight;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InsightCard({ insight, className }: InsightCardProps) {
|
||||
const payload = insight.payload as InsightPayload | null;
|
||||
const dimension = payload?.primaryDimension;
|
||||
const metric = payload?.metric ?? 'sessions';
|
||||
const extra = payload?.extra;
|
||||
|
||||
// Determine if this is a share-based insight (geo, devices)
|
||||
const isShareBased = metric === 'share';
|
||||
|
||||
// Get the values to display based on metric type
|
||||
const currentValue = isShareBased
|
||||
? (extra?.currentShare ?? null)
|
||||
: (insight.currentValue ?? null);
|
||||
const compareValue = isShareBased
|
||||
? (extra?.compareShare ?? null)
|
||||
: (insight.compareValue ?? null);
|
||||
|
||||
// Get direction and change
|
||||
const direction = insight.direction ?? 'flat';
|
||||
const isIncrease = direction === 'up';
|
||||
const isDecrease = direction === 'down';
|
||||
|
||||
// Format the delta display
|
||||
const deltaText = isShareBased
|
||||
? `${Math.abs(extra?.shareShiftPp ?? 0).toFixed(1)}pp`
|
||||
: `${Math.abs((insight.changePct ?? 0) * 100).toFixed(1)}%`;
|
||||
|
||||
// Format metric values
|
||||
const formatValue = (value: number | null): string => {
|
||||
if (value == null) return '-';
|
||||
if (isShareBased) return `${(value * 100).toFixed(1)}%`;
|
||||
return Math.round(value).toLocaleString();
|
||||
};
|
||||
|
||||
// Get the metric label
|
||||
const metricLabel = isShareBased
|
||||
? 'Share'
|
||||
: metric === 'pageviews'
|
||||
? 'Pageviews'
|
||||
: 'Sessions';
|
||||
|
||||
const renderTitle = () => {
|
||||
const t = insight.title.replace(/↑.*$/, '').replace(/↓.*$/, '').trim();
|
||||
if (
|
||||
dimension &&
|
||||
(dimension.type === 'country' ||
|
||||
dimension.type === 'referrer' ||
|
||||
dimension.type === 'device')
|
||||
) {
|
||||
return (
|
||||
<span className="capitalize flex items-center gap-2">
|
||||
<SerieIcon name={dimension.displayName} />{' '}
|
||||
{countries[dimension.displayName as keyof typeof countries] || t}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return t;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'card p-4 h-full flex flex-col hover:bg-def-50 transition-colors',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="row justify-between">
|
||||
<Badge variant="outline" className="-ml-2">
|
||||
{formatWindowKind(insight.windowKind)}
|
||||
<span className="text-muted-foreground mx-1">/</span>
|
||||
<span className="capitalize">{dimension?.type ?? 'unknown'}</span>
|
||||
</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>
|
||||
<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="flex items-baseline gap-2">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
66
apps/start/src/components/overview/overview-insights.tsx
Normal file
66
apps/start/src/components/overview/overview-insights.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
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 { 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} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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'} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
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 { 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 { PAGE_TITLES, createProjectTitle } from '@/utils/title';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId/insights',
|
||||
)({
|
||||
component: Component,
|
||||
head: () => {
|
||||
return {
|
||||
meta: [
|
||||
{
|
||||
title: createProjectTitle('Insights'),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
type SortOption =
|
||||
| 'impact-desc'
|
||||
| 'impact-asc'
|
||||
| 'severity-desc'
|
||||
| 'severity-asc'
|
||||
| 'recent';
|
||||
|
||||
function Component() {
|
||||
const { projectId } = Route.useParams();
|
||||
const trpc = useTRPC();
|
||||
const { data: insights, isLoading } = useQuery(
|
||||
trpc.insight.listAll.queryOptions({
|
||||
projectId,
|
||||
limit: 500,
|
||||
}),
|
||||
);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [moduleFilter, setModuleFilter] = useState<string>('all');
|
||||
const [windowKindFilter, setWindowKindFilter] = useState<string>('all');
|
||||
const [severityFilter, setSeverityFilter] = useState<string>('all');
|
||||
const [directionFilter, setDirectionFilter] = useState<string>('all');
|
||||
const [sortBy, setSortBy] = useState<SortOption>('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,
|
||||
]);
|
||||
|
||||
const uniqueModules = useMemo(() => {
|
||||
if (!insights) return [];
|
||||
return Array.from(new Set(insights.map((i) => i.moduleKey))).sort();
|
||||
}, [insights]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader title="Insights" className="mb-8" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 8 }, (_, i) => `skeleton-${i}`).map((key) => (
|
||||
<Skeleton key={key} className="h-48" />
|
||||
))}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Insights"
|
||||
description="Discover trends and changes in your analytics"
|
||||
className="mb-8"
|
||||
/>
|
||||
<TableButtons>
|
||||
<Input
|
||||
placeholder="Search insights..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<Select value={moduleFilter} onValueChange={setModuleFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Module" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Modules</SelectItem>
|
||||
{uniqueModules.map((module) => (
|
||||
<SelectItem key={module} value={module}>
|
||||
{module.replace('-', ' ')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={windowKindFilter} onValueChange={setWindowKindFilter}>
|
||||
<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} onValueChange={setSeverityFilter}>
|
||||
<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} onValueChange={setDirectionFilter}>
|
||||
<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}
|
||||
onValueChange={(v) => 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.'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{filteredAndSorted.map((insight) => (
|
||||
<InsightCard key={insight.id} insight={insight} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredAndSorted.length > 0 && (
|
||||
<div className="mt-4 text-sm text-muted-foreground text-center">
|
||||
Showing {filteredAndSorted.length} of {insights?.length ?? 0} insights
|
||||
</div>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user