Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
229 changes: 229 additions & 0 deletions apps/start/src/components/insights/insight-card.tsx
Original file line number Diff line number Diff line change
@@ -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];
Comment on lines +36 to +43
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Add bounds checking for metrics array access.

The code doesn't handle the case where availableMetrics is empty or when findIndex returns -1. This will cause runtime errors when accessing availableMetrics[metricIndex] if no metrics are available or the primary metric is not found.

🔎 Apply this diff to add proper bounds checking:
  const availableMetrics = Object.entries(payload?.metrics ?? {});

+ // Guard against empty metrics or missing primary metric
+ if (availableMetrics.length === 0) {
+   return null; // or render a fallback UI
+ }
+
  // Pick what to display: prefer share if available (geo/devices), else primaryMetric
  const [metricIndex, setMetricIndex] = useState(
    availableMetrics.findIndex(([key]) => key === payload?.primaryMetric),
  );
+ 
+ // Fallback to first metric if primary not found
+ const safeMetricIndex = metricIndex === -1 ? 0 : metricIndex;
- const currentMetricKey = availableMetrics[metricIndex][0];
- const currentMetricEntry = availableMetrics[metricIndex][1];
+ const currentMetricKey = availableMetrics[safeMetricIndex][0];
+ const currentMetricEntry = availableMetrics[safeMetricIndex][1];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 availableMetrics = Object.entries(payload?.metrics ?? {});
// Guard against empty metrics or missing primary metric
if (availableMetrics.length === 0) {
return null; // or render a fallback UI
}
// Pick what to display: prefer share if available (geo/devices), else primaryMetric
const [metricIndex, setMetricIndex] = useState(
availableMetrics.findIndex(([key]) => key === payload?.primaryMetric),
);
// Fallback to first metric if primary not found
const safeMetricIndex = metricIndex === -1 ? 0 : metricIndex;
const currentMetricKey = availableMetrics[safeMetricIndex][0];
const currentMetricEntry = availableMetrics[safeMetricIndex][1];
🤖 Prompt for AI Agents
In apps/start/src/components/insights/insight-card.tsx around lines 36 to 43,
the code assumes availableMetrics has items and that findIndex finds the primary
metric — add bounds checking so we never index out of range: initialize
metricIndex to 0 when availableMetrics is empty or when findIndex returns -1 (or
set state to -1 and handle that case), and when computing
currentMetricKey/currentMetricEntry only access availableMetrics[metricIndex]
after confirming metricIndex is inside [0, availableMetrics.length-1]; otherwise
set currentMetricKey/currentMetricEntry to undefined (or a safe fallback) so
rendering logic can handle the "no metrics" case.


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>
Comment on lines +135 to +150
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Division by zero risk in metric rotation.

The metric rotation logic uses % availableMetrics.length which would result in NaN if the array is empty. This is already addressed by the guard suggested in the previous comment, but ensure the fix is applied consistently.

🤖 Prompt for AI Agents
In apps/start/src/components/insights/insight-card.tsx around lines 135 to 150,
the metric rotation uses (metricIndex + 1) % availableMetrics.length which can
divide by zero if the array is empty; ensure you guard the calculation by
checking availableMetrics.length > 0 before computing the new index and only
call setMetricIndex when length > 0 (or length > 1 for rotation), and keep the
existing render guard consistent by disabling or hiding the button when
availableMetrics.length <= 1 so the modulo is never executed with zero.

) : (
<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>
);
}
75 changes: 75 additions & 0 deletions apps/start/src/components/overview/overview-insights.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
8 changes: 7 additions & 1 deletion apps/start/src/components/sidebar-project-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
LayoutPanelTopIcon,
PlusIcon,
SparklesIcon,
TrendingUpDownIcon,
UndoDotIcon,
UsersIcon,
WallpaperIcon,
Expand All @@ -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'} />
Expand Down
2 changes: 1 addition & 1 deletion apps/start/src/components/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
])}
>
Expand Down
2 changes: 1 addition & 1 deletion apps/start/src/components/ui/carousel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading