diff --git a/apps/api/src/routes/misc.router.ts b/apps/api/src/routes/misc.router.ts index dabe1322..29f51440 100644 --- a/apps/api/src/routes/misc.router.ts +++ b/apps/api/src/routes/misc.router.ts @@ -44,27 +44,6 @@ const miscRouter: FastifyPluginCallback = async (fastify) => { url: '/geo', handler: controller.getGeo, }); - - fastify.route({ - method: 'GET', - url: '/insights/test', - handler: async (req, reply) => { - const projectId = req.query.projectId as string; - const job = await insightsQueue.add( - 'insightsProject', - { - type: 'insightsProject', - payload: { - projectId: projectId, - date: new Date().toISOString().slice(0, 10), - }, - }, - { jobId: `manual:${Date.now()}:${projectId}` }, - ); - - return { jobId: job.id }; - }, - }); }; export default miscRouter; diff --git a/apps/start/src/components/insights/insight-card.tsx b/apps/start/src/components/insights/insight-card.tsx index 80d5e7f5..abeff89a 100644 --- a/apps/start/src/components/insights/insight-card.tsx +++ b/apps/start/src/components/insights/insight-card.tsx @@ -1,41 +1,13 @@ import { countries } from '@/translations/countries'; +import type { RouterOutputs } from '@/trpc/client'; import { cn } from '@/utils/cn'; -import { ArrowDown, ArrowUp } from 'lucide-react'; +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'; -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': @@ -49,82 +21,95 @@ function formatWindowKind(windowKind: string): string { } interface InsightCardProps { - insight: Insight; + insight: RouterOutputs['insight']['list'][number]; className?: string; + onFilter?: () => void; } -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; +export function InsightCard({ + insight, + className, + onFilter, +}: InsightCardProps) { + const payload = insight.payload; + const dimensions = payload?.dimensions; + const availableMetrics = Object.entries(payload?.metrics ?? {}); - // Determine if this is a share-based insight (geo, devices) - const isShareBased = metric === 'share'; + // 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]; - // 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); + const metricUnit = currentMetricEntry?.unit; + const currentValue = currentMetricEntry?.current ?? null; + const compareValue = currentMetricEntry?.compare ?? null; - // Get direction and change - const direction = insight.direction ?? 'flat'; + const direction = currentMetricEntry?.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)}%`; + 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 (isShareBased) return `${(value * 100).toFixed(1)}%`; + if (metricUnit === 'ratio') return `${(value * 100).toFixed(1)}%`; return Math.round(value).toLocaleString(); }; // Get the metric label - const metricLabel = isShareBased - ? 'Share' - : metric === 'pageviews' - ? 'Pageviews' - : 'Sessions'; + const metricKeyToLabel = (key: string) => + key === 'share' ? 'Share' : key === 'pageviews' ? 'Pageviews' : 'Sessions'; + + const metricLabel = metricKeyToLabel(currentMetricKey); const renderTitle = () => { - const t = insight.title.replace(/↑.*$/, '').replace(/↓.*$/, '').trim(); if ( - dimension && - (dimension.type === 'country' || - dimension.type === 'referrer' || - dimension.type === 'device') + dimensions[0]?.key === 'country' || + dimensions[0]?.key === 'referrer_name' || + dimensions[0]?.key === 'device' ) { return ( - {' '} - {countries[dimension.displayName as keyof typeof countries] || t} + {insight.displayName} ); } - return t; + if (insight.displayName.startsWith('http')) { + return ( + + + {dimensions[1]?.displayName} + + ); + } + + return insight.displayName; }; return (
-
+
{formatWindowKind(insight.windowKind)} - / - {dimension?.type ?? 'unknown'} {/* Severity: subtle dot instead of big pill */} {insight.severityBand && ( @@ -145,6 +130,36 @@ export function InsightCard({ insight, className }: InsightCardProps) {
)}
+ {onFilter && ( +
+ {availableMetrics.length > 1 ? ( + + ) : ( +
+ )} + +
+ )}
{renderTitle()}
@@ -157,7 +172,7 @@ export function InsightCard({ insight, className }: InsightCardProps) { {metricLabel}
-
+
{formatValue(currentValue)}
diff --git a/apps/start/src/components/overview/overview-insights.tsx b/apps/start/src/components/overview/overview-insights.tsx index 7121a9be..995bf3f7 100644 --- a/apps/start/src/components/overview/overview-insights.tsx +++ b/apps/start/src/components/overview/overview-insights.tsx @@ -1,3 +1,4 @@ +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'; @@ -16,6 +17,7 @@ interface OverviewInsightsProps { export default function OverviewInsights({ projectId }: OverviewInsightsProps) { const trpc = useTRPC(); + const [filters, setFilter] = useEventQueryFilters(); const { data: insights, isLoading } = useQuery( trpc.insight.list.queryOptions({ projectId, @@ -54,7 +56,14 @@ export default function OverviewInsights({ projectId }: OverviewInsightsProps) { key={insight.id} className="pl-4 basis-full sm:basis-1/2 lg:basis-1/4" > - + { + insight.payload.dimensions.forEach((dim) => { + void setFilter(dim.key, dim.value, 'is'); + }); + }} + /> ))} diff --git a/apps/start/src/components/sidebar.tsx b/apps/start/src/components/sidebar.tsx index 5562247d..cc7869eb 100644 --- a/apps/start/src/components/sidebar.tsx +++ b/apps/start/src/components/sidebar.tsx @@ -123,7 +123,7 @@ export function SidebarContainer({
diff --git a/apps/start/src/components/ui/carousel.tsx b/apps/start/src/components/ui/carousel.tsx index 68a123a1..664335d6 100644 --- a/apps/start/src/components/ui/carousel.tsx +++ b/apps/start/src/components/ui/carousel.tsx @@ -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', diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.insights.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.insights.tsx index cf26d933..116371f5 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.insights.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.insights.tsx @@ -3,6 +3,13 @@ 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, @@ -13,10 +20,12 @@ import { } 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 } from '@tanstack/react-router'; -import { useMemo, useState } from 'react'; +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', @@ -26,7 +35,7 @@ export const Route = createFileRoute( return { meta: [ { - title: createProjectTitle('Insights'), + title: createProjectTitle(PAGE_TITLES.INSIGHTS), }, ], }; @@ -40,6 +49,19 @@ type SortOption = | 'severity-asc' | 'recent'; +function getModuleDisplayName(moduleKey: string): string { + const displayNames: Record = { + 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(); @@ -49,13 +71,45 @@ function Component() { limit: 500, }), ); + const navigate = useNavigate(); - const [search, setSearch] = useState(''); - const [moduleFilter, setModuleFilter] = useState('all'); - const [windowKindFilter, setWindowKindFilter] = useState('all'); - const [severityFilter, setSeverityFilter] = useState('all'); - const [directionFilter, setDirectionFilter] = useState('all'); - const [sortBy, setSortBy] = useState('impact-desc'); + 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([ + 'impact-desc', + 'impact-asc', + 'severity-desc', + 'severity-asc', + 'recent', + ]).withDefault('impact-desc'), + ); const filteredAndSorted = useMemo(() => { if (!insights) return []; @@ -155,18 +209,60 @@ function Component() { sortBy, ]); - const uniqueModules = useMemo(() => { - if (!insights) return []; - return Array.from(new Set(insights.map((i) => i.moduleKey))).sort(); - }, [insights]); + // Group insights by module + const groupedByModule = useMemo(() => { + const groups = new Map(); + + 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 ( -
- {Array.from({ length: 8 }, (_, i) => `skeleton-${i}`).map((key) => ( - +
+ {Array.from({ length: 3 }, (_, i) => `section-${i}`).map((key) => ( +
+ + + + {Array.from({ length: 4 }, (_, i) => `skeleton-${i}`).map( + (cardKey) => ( + + + + ), + )} + + +
))}
@@ -180,27 +276,19 @@ function Component() { description="Discover trends and changes in your analytics" className="mb-8" /> - + setSearch(e.target.value)} + value={search ?? ''} + onChange={(e) => void setSearch(e.target.value || null)} className="max-w-xs" /> - - + void setWindowKindFilter(v as typeof windowKindFilter) + } + > @@ -211,7 +299,12 @@ function Component() { 30 Days - + void setSeverityFilter(v as typeof severityFilter) + } + > @@ -223,7 +316,12 @@ function Component() { No Severity - + void setDirectionFilter(v as typeof directionFilter) + } + > @@ -235,8 +333,8 @@ function Component() {