6 Commits

Author SHA1 Message Date
Carl-Gerhard Lindesvärd
f00d1bd256 fix: tailwind css 2025-12-19 09:25:21 +01:00
Carl-Gerhard Lindesvärd
5757cb2fac wip 2025-12-18 13:17:25 +01:00
Carl-Gerhard Lindesvärd
ccff90829b wip 2025-12-17 23:00:33 +01:00
Carl-Gerhard Lindesvärd
bc84404235 wip 2025-12-17 09:18:31 +01:00
Carl-Gerhard Lindesvärd
dad9baa581 fix: build script 2025-12-17 09:01:23 +01:00
Carl-Gerhard Lindesvärd
ea6b69d3ec fix: migration for newly created self-hosting instances 2025-12-16 22:11:06 +01:00
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, LayoutPanelTopIcon,
PlusIcon, PlusIcon,
SparklesIcon, SparklesIcon,
TrendingUpDownIcon,
UndoDotIcon, UndoDotIcon,
UsersIcon, UsersIcon,
WallpaperIcon, WallpaperIcon,
@@ -39,13 +40,18 @@ export default function SidebarProjectMenu({
}: SidebarProjectMenuProps) { }: SidebarProjectMenuProps) {
return ( 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={WallpaperIcon} label="Overview" href={'/'} />
<SidebarLink <SidebarLink
icon={LayoutPanelTopIcon} icon={LayoutPanelTopIcon}
label="Dashboards" label="Dashboards"
href={'/dashboards'} href={'/dashboards'}
/> />
<SidebarLink
icon={TrendingUpDownIcon}
label="Insights"
href={'/insights'}
/>
<SidebarLink icon={LayersIcon} label="Pages" href={'/pages'} /> <SidebarLink icon={LayersIcon} label="Pages" href={'/pages'} />
<SidebarLink icon={Globe2Icon} label="Realtime" href={'/realtime'} /> <SidebarLink icon={Globe2Icon} label="Realtime" href={'/realtime'} />
<SidebarLink icon={GanttChartIcon} label="Events" href={'/events'} /> <SidebarLink icon={GanttChartIcon} label="Events" href={'/events'} />

View File

@@ -123,7 +123,7 @@ export function SidebarContainer({
</div> </div>
<div <div
className={cn([ 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", "[&_a[data-status='active']]:bg-def-200",
])} ])}
> >

View File

@@ -208,7 +208,7 @@ const CarouselPrevious = React.forwardRef<
variant={variant} variant={variant}
size={size} size={size}
className={cn( 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' orientation === 'horizontal'
? 'left-6 top-1/2 -translate-y-1/2' ? 'left-6 top-1/2 -translate-y-1/2'
: '-top-12 left-1/2 -translate-x-1/2 rotate-90', : '-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 AppOrganizationIdProjectIdReferencesRouteImport } from './routes/_app.$organizationId.$projectId.references'
import { Route as AppOrganizationIdProjectIdRealtimeRouteImport } from './routes/_app.$organizationId.$projectId.realtime' import { Route as AppOrganizationIdProjectIdRealtimeRouteImport } from './routes/_app.$organizationId.$projectId.realtime'
import { Route as AppOrganizationIdProjectIdPagesRouteImport } from './routes/_app.$organizationId.$projectId.pages' 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 AppOrganizationIdProjectIdDashboardsRouteImport } from './routes/_app.$organizationId.$projectId.dashboards'
import { Route as AppOrganizationIdProjectIdChatRouteImport } from './routes/_app.$organizationId.$projectId.chat' import { Route as AppOrganizationIdProjectIdChatRouteImport } from './routes/_app.$organizationId.$projectId.chat'
import { Route as AppOrganizationIdMembersTabsIndexRouteImport } from './routes/_app.$organizationId.members._tabs.index' import { Route as AppOrganizationIdMembersTabsIndexRouteImport } from './routes/_app.$organizationId.members._tabs.index'
@@ -273,6 +274,12 @@ const AppOrganizationIdProjectIdPagesRoute =
path: '/pages', path: '/pages',
getParentRoute: () => AppOrganizationIdProjectIdRoute, getParentRoute: () => AppOrganizationIdProjectIdRoute,
} as any) } as any)
const AppOrganizationIdProjectIdInsightsRoute =
AppOrganizationIdProjectIdInsightsRouteImport.update({
id: '/insights',
path: '/insights',
getParentRoute: () => AppOrganizationIdProjectIdRoute,
} as any)
const AppOrganizationIdProjectIdDashboardsRoute = const AppOrganizationIdProjectIdDashboardsRoute =
AppOrganizationIdProjectIdDashboardsRouteImport.update({ AppOrganizationIdProjectIdDashboardsRouteImport.update({
id: '/dashboards', id: '/dashboards',
@@ -495,6 +502,7 @@ export interface FileRoutesByFullPath {
'/$organizationId/': typeof AppOrganizationIdIndexRoute '/$organizationId/': typeof AppOrganizationIdIndexRoute
'/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute '/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
'/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute '/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
'/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute
'/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute '/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute '/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
'/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute '/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
@@ -552,6 +560,7 @@ export interface FileRoutesByTo {
'/$organizationId': typeof AppOrganizationIdIndexRoute '/$organizationId': typeof AppOrganizationIdIndexRoute
'/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute '/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
'/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute '/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
'/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute
'/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute '/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute '/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
'/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute '/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
@@ -609,6 +618,7 @@ export interface FileRoutesById {
'/_app/$organizationId/': typeof AppOrganizationIdIndexRoute '/_app/$organizationId/': typeof AppOrganizationIdIndexRoute
'/_app/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute '/_app/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
'/_app/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute '/_app/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
'/_app/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute
'/_app/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute '/_app/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute
'/_app/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute '/_app/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
'/_app/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute '/_app/$organizationId/$projectId/references': typeof AppOrganizationIdProjectIdReferencesRoute
@@ -677,6 +687,7 @@ export interface FileRouteTypes {
| '/$organizationId/' | '/$organizationId/'
| '/$organizationId/$projectId/chat' | '/$organizationId/$projectId/chat'
| '/$organizationId/$projectId/dashboards' | '/$organizationId/$projectId/dashboards'
| '/$organizationId/$projectId/insights'
| '/$organizationId/$projectId/pages' | '/$organizationId/$projectId/pages'
| '/$organizationId/$projectId/realtime' | '/$organizationId/$projectId/realtime'
| '/$organizationId/$projectId/references' | '/$organizationId/$projectId/references'
@@ -734,6 +745,7 @@ export interface FileRouteTypes {
| '/$organizationId' | '/$organizationId'
| '/$organizationId/$projectId/chat' | '/$organizationId/$projectId/chat'
| '/$organizationId/$projectId/dashboards' | '/$organizationId/$projectId/dashboards'
| '/$organizationId/$projectId/insights'
| '/$organizationId/$projectId/pages' | '/$organizationId/$projectId/pages'
| '/$organizationId/$projectId/realtime' | '/$organizationId/$projectId/realtime'
| '/$organizationId/$projectId/references' | '/$organizationId/$projectId/references'
@@ -790,6 +802,7 @@ export interface FileRouteTypes {
| '/_app/$organizationId/' | '/_app/$organizationId/'
| '/_app/$organizationId/$projectId/chat' | '/_app/$organizationId/$projectId/chat'
| '/_app/$organizationId/$projectId/dashboards' | '/_app/$organizationId/$projectId/dashboards'
| '/_app/$organizationId/$projectId/insights'
| '/_app/$organizationId/$projectId/pages' | '/_app/$organizationId/$projectId/pages'
| '/_app/$organizationId/$projectId/realtime' | '/_app/$organizationId/$projectId/realtime'
| '/_app/$organizationId/$projectId/references' | '/_app/$organizationId/$projectId/references'
@@ -1085,6 +1098,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdPagesRouteImport preLoaderRoute: typeof AppOrganizationIdProjectIdPagesRouteImport
parentRoute: typeof AppOrganizationIdProjectIdRoute 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': { '/_app/$organizationId/$projectId/dashboards': {
id: '/_app/$organizationId/$projectId/dashboards' id: '/_app/$organizationId/$projectId/dashboards'
path: '/dashboards' path: '/dashboards'
@@ -1528,6 +1548,7 @@ const AppOrganizationIdProjectIdSettingsRouteWithChildren =
interface AppOrganizationIdProjectIdRouteChildren { interface AppOrganizationIdProjectIdRouteChildren {
AppOrganizationIdProjectIdChatRoute: typeof AppOrganizationIdProjectIdChatRoute AppOrganizationIdProjectIdChatRoute: typeof AppOrganizationIdProjectIdChatRoute
AppOrganizationIdProjectIdDashboardsRoute: typeof AppOrganizationIdProjectIdDashboardsRoute AppOrganizationIdProjectIdDashboardsRoute: typeof AppOrganizationIdProjectIdDashboardsRoute
AppOrganizationIdProjectIdInsightsRoute: typeof AppOrganizationIdProjectIdInsightsRoute
AppOrganizationIdProjectIdPagesRoute: typeof AppOrganizationIdProjectIdPagesRoute AppOrganizationIdProjectIdPagesRoute: typeof AppOrganizationIdProjectIdPagesRoute
AppOrganizationIdProjectIdRealtimeRoute: typeof AppOrganizationIdProjectIdRealtimeRoute AppOrganizationIdProjectIdRealtimeRoute: typeof AppOrganizationIdProjectIdRealtimeRoute
AppOrganizationIdProjectIdReferencesRoute: typeof AppOrganizationIdProjectIdReferencesRoute AppOrganizationIdProjectIdReferencesRoute: typeof AppOrganizationIdProjectIdReferencesRoute
@@ -1548,6 +1569,8 @@ const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteCh
AppOrganizationIdProjectIdChatRoute: AppOrganizationIdProjectIdChatRoute, AppOrganizationIdProjectIdChatRoute: AppOrganizationIdProjectIdChatRoute,
AppOrganizationIdProjectIdDashboardsRoute: AppOrganizationIdProjectIdDashboardsRoute:
AppOrganizationIdProjectIdDashboardsRoute, AppOrganizationIdProjectIdDashboardsRoute,
AppOrganizationIdProjectIdInsightsRoute:
AppOrganizationIdProjectIdInsightsRoute,
AppOrganizationIdProjectIdPagesRoute: AppOrganizationIdProjectIdPagesRoute, AppOrganizationIdProjectIdPagesRoute: AppOrganizationIdProjectIdPagesRoute,
AppOrganizationIdProjectIdRealtimeRoute: AppOrganizationIdProjectIdRealtimeRoute:
AppOrganizationIdProjectIdRealtimeRoute, AppOrganizationIdProjectIdRealtimeRoute,

View File

@@ -3,6 +3,7 @@ import {
OverviewFiltersButtons, OverviewFiltersButtons,
} from '@/components/overview/filters/overview-filters-buttons'; } from '@/components/overview/filters/overview-filters-buttons';
import { LiveCounter } from '@/components/overview/live-counter'; import { LiveCounter } from '@/components/overview/live-counter';
import OverviewInsights from '@/components/overview/overview-insights';
import { OverviewInterval } from '@/components/overview/overview-interval'; import { OverviewInterval } from '@/components/overview/overview-interval';
import OverviewMetrics from '@/components/overview/overview-metrics'; import OverviewMetrics from '@/components/overview/overview-metrics';
import { OverviewRange } from '@/components/overview/overview-range'; import { OverviewRange } from '@/components/overview/overview-range';
@@ -50,6 +51,7 @@ function ProjectDashboard() {
</div> </div>
<div className="grid grid-cols-6 gap-4 p-4 pt-0"> <div className="grid grid-cols-6 gap-4 p-4 pt-0">
<OverviewMetrics projectId={projectId} /> <OverviewMetrics projectId={projectId} />
<OverviewInsights projectId={projectId} />
<OverviewTopSources projectId={projectId} /> <OverviewTopSources projectId={projectId} />
<OverviewTopPages projectId={projectId} /> <OverviewTopPages projectId={projectId} />
<OverviewTopDevices 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', CHAT: 'AI Assistant',
REALTIME: 'Realtime', REALTIME: 'Realtime',
REFERENCES: 'References', REFERENCES: 'References',
INSIGHTS: 'Insights',
// Profiles // Profiles
PROFILES: 'Profiles', PROFILES: 'Profiles',
PROFILE_EVENTS: 'Profile events', PROFILE_EVENTS: 'Profile events',

View File

@@ -34,6 +34,11 @@ export async function bootCron() {
type: 'flushSessions', type: 'flushSessions',
pattern: 1000 * 10, pattern: 1000 * 10,
}, },
{
name: 'insightsDaily',
type: 'insightsDaily',
pattern: '0 2 * * *',
},
]; ];
if (process.env.SELF_HOSTED && process.env.NODE_ENV === 'production') { if (process.env.SELF_HOSTED && process.env.NODE_ENV === 'production') {

View File

@@ -7,6 +7,7 @@ import {
cronQueue, cronQueue,
eventsGroupQueues, eventsGroupQueues,
importQueue, importQueue,
insightsQueue,
miscQueue, miscQueue,
notificationQueue, notificationQueue,
queueLogger, queueLogger,
@@ -21,6 +22,7 @@ import { Worker as GroupWorker } from 'groupmq';
import { cronJob } from './jobs/cron'; import { cronJob } from './jobs/cron';
import { incomingEvent } from './jobs/events.incoming-event'; import { incomingEvent } from './jobs/events.incoming-event';
import { importJob } from './jobs/import'; import { importJob } from './jobs/import';
import { insightsProjectJob } from './jobs/insights';
import { miscJob } from './jobs/misc'; import { miscJob } from './jobs/misc';
import { notificationJob } from './jobs/notification'; import { notificationJob } from './jobs/notification';
import { sessionsJob } from './jobs/sessions'; import { sessionsJob } from './jobs/sessions';
@@ -49,7 +51,15 @@ function getEnabledQueues(): QueueName[] {
logger.info('No ENABLED_QUEUES specified, starting all queues', { logger.info('No ENABLED_QUEUES specified, starting all queues', {
totalEventShards: EVENTS_GROUP_QUEUES_SHARDS, totalEventShards: EVENTS_GROUP_QUEUES_SHARDS,
}); });
return ['events', 'sessions', 'cron', 'notification', 'misc', 'import']; return [
'events',
'sessions',
'cron',
'notification',
'misc',
'import',
'insights',
];
} }
const queues = enabledQueuesEnv const queues = enabledQueuesEnv
@@ -187,6 +197,17 @@ export async function bootWorkers() {
logger.info('Started worker for import', { concurrency }); logger.info('Started worker for import', { concurrency });
} }
// Start insights worker
if (enabledQueues.includes('insights')) {
const concurrency = getConcurrencyFor('insights', 5);
const insightsWorker = new Worker(insightsQueue.name, insightsProjectJob, {
...workerOptions,
concurrency,
});
workers.push(insightsWorker);
logger.info('Started worker for insights', { concurrency });
}
if (workers.length === 0) { if (workers.length === 0) {
logger.warn( logger.warn(
'No workers started. Check ENABLED_QUEUES environment variable.', 'No workers started. Check ENABLED_QUEUES environment variable.',

View File

@@ -6,6 +6,7 @@ import {
cronQueue, cronQueue,
eventsGroupQueues, eventsGroupQueues,
importQueue, importQueue,
insightsQueue,
miscQueue, miscQueue,
notificationQueue, notificationQueue,
sessionsQueue, sessionsQueue,
@@ -42,6 +43,7 @@ async function start() {
new BullMQAdapter(notificationQueue), new BullMQAdapter(notificationQueue),
new BullMQAdapter(miscQueue), new BullMQAdapter(miscQueue),
new BullMQAdapter(importQueue), new BullMQAdapter(importQueue),
new BullMQAdapter(insightsQueue),
], ],
serverAdapter: serverAdapter, serverAdapter: serverAdapter,
}); });

View File

@@ -6,6 +6,7 @@ import type { CronQueuePayload } from '@openpanel/queue';
import { jobdeleteProjects } from './cron.delete-projects'; import { jobdeleteProjects } from './cron.delete-projects';
import { ping } from './cron.ping'; import { ping } from './cron.ping';
import { salt } from './cron.salt'; import { salt } from './cron.salt';
import { insightsDailyJob } from './insights';
export async function cronJob(job: Job<CronQueuePayload>) { export async function cronJob(job: Job<CronQueuePayload>) {
switch (job.data.type) { switch (job.data.type) {
@@ -27,5 +28,8 @@ export async function cronJob(job: Job<CronQueuePayload>) {
case 'deleteProjects': { case 'deleteProjects': {
return await jobdeleteProjects(job); return await jobdeleteProjects(job);
} }
case 'insightsDaily': {
return await insightsDailyJob(job);
}
} }
} }

View File

@@ -0,0 +1,72 @@
import { ch } from '@openpanel/db/src/clickhouse/client';
import {
createEngine,
devicesModule,
entryPagesModule,
geoModule,
insightStore,
pageTrendsModule,
referrersModule,
} from '@openpanel/db/src/services/insights';
import type {
CronQueuePayload,
InsightsQueuePayloadProject,
} from '@openpanel/queue';
import { insightsQueue } from '@openpanel/queue';
import type { Job } from 'bullmq';
const defaultEngineConfig = {
keepTopNPerModuleWindow: 20,
closeStaleAfterDays: 7,
dimensionBatchSize: 50,
globalThresholds: {
minTotal: 200,
minAbsDelta: 80,
minPct: 0.15,
},
};
export async function insightsDailyJob(job: Job<CronQueuePayload>) {
const projectIds = await insightStore.listProjectIdsForCadence('daily');
const date = new Date().toISOString().slice(0, 10);
for (const projectId of projectIds) {
await insightsQueue.add(
'insightsProject',
{
type: 'insightsProject',
payload: { projectId, date },
},
{
jobId: `daily:${date}:${projectId}`, // idempotent
},
);
}
}
export async function insightsProjectJob(
job: Job<InsightsQueuePayloadProject>,
) {
const { projectId, date } = job.data.payload;
const engine = createEngine({
store: insightStore,
modules: [
referrersModule,
entryPagesModule,
pageTrendsModule,
geoModule,
devicesModule,
],
db: ch,
config: defaultEngineConfig,
});
const projectCreatedAt = await insightStore.getProjectCreatedAt(projectId);
await engine.runProject({
projectId,
cadence: 'daily',
now: new Date(date),
projectCreatedAt,
});
}

View File

@@ -245,3 +245,259 @@ export function getDefaultIntervalByDates(
return null; return null;
} }
export const countries = {
AF: 'Afghanistan',
AL: 'Albania',
DZ: 'Algeria',
AS: 'American Samoa',
AD: 'Andorra',
AO: 'Angola',
AI: 'Anguilla',
AQ: 'Antarctica',
AG: 'Antigua and Barbuda',
AR: 'Argentina',
AM: 'Armenia',
AW: 'Aruba',
AU: 'Australia',
AT: 'Austria',
AZ: 'Azerbaijan',
BS: 'Bahamas',
BH: 'Bahrain',
BD: 'Bangladesh',
BB: 'Barbados',
BY: 'Belarus',
BE: 'Belgium',
BZ: 'Belize',
BJ: 'Benin',
BM: 'Bermuda',
BT: 'Bhutan',
BO: 'Bolivia',
BQ: 'Bonaire, Sint Eustatius and Saba',
BA: 'Bosnia and Herzegovina',
BW: 'Botswana',
BV: 'Bouvet Island',
BR: 'Brazil',
IO: 'British Indian Ocean Territory',
BN: 'Brunei Darussalam',
BG: 'Bulgaria',
BF: 'Burkina Faso',
BI: 'Burundi',
CV: 'Cabo Verde',
KH: 'Cambodia',
CM: 'Cameroon',
CA: 'Canada',
KY: 'Cayman Islands',
CF: 'Central African Republic',
TD: 'Chad',
CL: 'Chile',
CN: 'China',
CX: 'Christmas Island',
CC: 'Cocos (Keeling) Islands',
CO: 'Colombia',
KM: 'Comoros',
CD: 'Congo (Democratic Republic)',
CG: 'Congo',
CK: 'Cook Islands',
CR: 'Costa Rica',
HR: 'Croatia',
CU: 'Cuba',
CW: 'Curaçao',
CY: 'Cyprus',
CZ: 'Czechia',
CI: "Côte d'Ivoire",
DK: 'Denmark',
DJ: 'Djibouti',
DM: 'Dominica',
DO: 'Dominican Republic',
EC: 'Ecuador',
EG: 'Egypt',
SV: 'El Salvador',
GQ: 'Equatorial Guinea',
ER: 'Eritrea',
EE: 'Estonia',
SZ: 'Eswatina',
ET: 'Ethiopia',
FK: 'Falkland Islands',
FO: 'Faroe Islands',
FJ: 'Fiji',
FI: 'Finland',
FR: 'France',
GF: 'French Guiana',
PF: 'French Polynesia',
TF: 'French Southern Territories',
GA: 'Gabon',
GM: 'Gambia',
GE: 'Georgia',
DE: 'Germany',
GH: 'Ghana',
GI: 'Gibraltar',
GR: 'Greece',
GL: 'Greenland',
GD: 'Grenada',
GP: 'Guadeloupe',
GU: 'Guam',
GT: 'Guatemala',
GG: 'Guernsey',
GN: 'Guinea',
GW: 'Guinea-Bissau',
GY: 'Guyana',
HT: 'Haiti',
HM: 'Heard Island and McDonald Islands',
VA: 'Holy See',
HN: 'Honduras',
HK: 'Hong Kong',
HU: 'Hungary',
IS: 'Iceland',
IN: 'India',
ID: 'Indonesia',
IR: 'Iran',
IQ: 'Iraq',
IE: 'Ireland',
IM: 'Isle of Man',
IL: 'Israel',
IT: 'Italy',
JM: 'Jamaica',
JP: 'Japan',
JE: 'Jersey',
JO: 'Jordan',
KZ: 'Kazakhstan',
KE: 'Kenya',
KI: 'Kiribati',
KP: "Korea (Democratic People's Republic)",
KR: 'Korea (Republic)',
KW: 'Kuwait',
KG: 'Kyrgyzstan',
LA: "Lao People's Democratic Republic",
LV: 'Latvia',
LB: 'Lebanon',
LS: 'Lesotho',
LR: 'Liberia',
LY: 'Libya',
LI: 'Liechtenstein',
LT: 'Lithuania',
LU: 'Luxembourg',
MO: 'Macao',
MG: 'Madagascar',
MW: 'Malawi',
MY: 'Malaysia',
MV: 'Maldives',
ML: 'Mali',
MT: 'Malta',
MH: 'Marshall Islands',
MQ: 'Martinique',
MR: 'Mauritania',
MU: 'Mauritius',
YT: 'Mayotte',
MX: 'Mexico',
FM: 'Micronesia',
MD: 'Moldova',
MC: 'Monaco',
MN: 'Mongolia',
ME: 'Montenegro',
MS: 'Montserrat',
MA: 'Morocco',
MZ: 'Mozambique',
MM: 'Myanmar',
NA: 'Namibia',
NR: 'Nauru',
NP: 'Nepal',
NL: 'Netherlands',
NC: 'New Caledonia',
NZ: 'New Zealand',
NI: 'Nicaragua',
NE: 'Niger',
NG: 'Nigeria',
NU: 'Niue',
NF: 'Norfolk Island',
MP: 'Northern Mariana Islands',
NO: 'Norway',
OM: 'Oman',
PK: 'Pakistan',
PW: 'Palau',
PS: 'Palestine, State of',
PA: 'Panama',
PG: 'Papua New Guinea',
PY: 'Paraguay',
PE: 'Peru',
PH: 'Philippines',
PN: 'Pitcairn',
PL: 'Poland',
PT: 'Portugal',
PR: 'Puerto Rico',
QA: 'Qatar',
MK: 'Republic of North Macedonia',
RO: 'Romania',
RU: 'Russian Federation',
RW: 'Rwanda',
RE: 'Réunion',
BL: 'Saint Barthélemy',
SH: 'Saint Helena, Ascension and Tristan da Cunha',
KN: 'Saint Kitts and Nevis',
LC: 'Saint Lucia',
MF: 'Saint Martin (French part)',
PM: 'Saint Pierre and Miquelon',
VC: 'Saint Vincent and the Grenadines',
WS: 'Samoa',
SM: 'San Marino',
ST: 'Sao Tome and Principe',
SA: 'Saudi Arabia',
SN: 'Senegal',
RS: 'Serbia',
SC: 'Seychelles',
SL: 'Sierra Leone',
SG: 'Singapore',
SX: 'Sint Maarten (Dutch part)',
SK: 'Slovakia',
SI: 'Slovenia',
SB: 'Solomon Islands',
SO: 'Somalia',
ZA: 'South Africa',
GS: 'South Georgia and the South Sandwich Islands',
SS: 'South Sudan',
ES: 'Spain',
LK: 'Sri Lanka',
SD: 'Sudan',
SR: 'Suriname',
SJ: 'Svalbard and Jan Mayen',
SE: 'Sweden',
CH: 'Switzerland',
SY: 'Syrian Arab Republic',
TW: 'Taiwan',
TJ: 'Tajikistan',
TZ: 'Tanzania, United Republic of',
TH: 'Thailand',
TL: 'Timor-Leste',
TG: 'Togo',
TK: 'Tokelau',
TO: 'Tonga',
TT: 'Trinidad and Tobago',
TN: 'Tunisia',
TR: 'Turkey',
TM: 'Turkmenistan',
TC: 'Turks and Caicos Islands',
TV: 'Tuvalu',
UG: 'Uganda',
UA: 'Ukraine',
AE: 'United Arab Emirates',
GB: 'United Kingdom',
US: 'United States',
UM: 'United States Minor Outlying Islands',
UY: 'Uruguay',
UZ: 'Uzbekistan',
VU: 'Vanuatu',
VE: 'Venezuela',
VN: 'Viet Nam',
VG: 'Virgin Islands (British)',
VI: 'Virgin Islands (U.S.)',
WF: 'Wallis and Futuna',
EH: 'Western Sahara',
YE: 'Yemen',
ZM: 'Zambia',
ZW: 'Zimbabwe',
AX: 'Åland Islands',
} as const;
export function getCountry(code?: string) {
return countries[code as keyof typeof countries];
}

View File

@@ -100,6 +100,9 @@ async function createOldSessions() {
if (!row || row.count === '0') { if (!row || row.count === '0') {
return null; return null;
} }
if (row.created_at.startsWith('1970')) {
return null;
}
return new Date(row.created_at); return new Date(row.created_at);
} catch (e) { } catch (e) {
return defaultDate; return defaultDate;

View File

@@ -139,7 +139,10 @@ export async function up() {
const firstEventDateJson = await firstEventDateResponse.json<{ const firstEventDateJson = await firstEventDateResponse.json<{
created_at: string; created_at: string;
}>(); }>();
if (firstEventDateJson[0]?.created_at) { if (
firstEventDateJson[0]?.created_at &&
!firstEventDateJson[0]?.created_at.startsWith('1970')
) {
const firstEventDate = new Date(firstEventDateJson[0]?.created_at); const firstEventDate = new Date(firstEventDateJson[0]?.created_at);
// Step 2: Copy data from old tables to new tables (partitioned by month for efficiency) // Step 2: Copy data from old tables to new tables (partitioned by month for efficiency)
// Set endDate to first of next month to ensure we capture all data in the current month // Set endDate to first of next month to ensure we capture all data in the current month
@@ -174,7 +177,10 @@ export async function up() {
created_at: string; created_at: string;
}>(); }>();
if (firstSessionDateJson[0]?.created_at) { if (
firstSessionDateJson[0]?.created_at &&
!firstSessionDateJson[0]?.created_at.startsWith('1970')
) {
const firstSessionDate = new Date( const firstSessionDate = new Date(
firstSessionDateJson[0]?.created_at ?? '', firstSessionDateJson[0]?.created_at ?? '',
); );

View File

@@ -28,4 +28,5 @@ export * from './src/types';
export * from './src/clickhouse/query-builder'; export * from './src/clickhouse/query-builder';
export * from './src/services/import.service'; export * from './src/services/import.service';
export * from './src/services/overview.service'; export * from './src/services/overview.service';
export * from './src/services/insights';
export * from './src/session-context'; export * from './src/session-context';

View File

@@ -0,0 +1,57 @@
-- CreateEnum
CREATE TYPE "public"."InsightState" AS ENUM ('active', 'suppressed', 'closed');
-- CreateTable
CREATE TABLE "public"."project_insights" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"projectId" TEXT NOT NULL,
"moduleKey" TEXT NOT NULL,
"dimensionKey" TEXT NOT NULL,
"windowKind" TEXT NOT NULL,
"state" "public"."InsightState" NOT NULL DEFAULT 'active',
"title" TEXT NOT NULL,
"summary" TEXT,
"payload" JSONB,
"currentValue" DOUBLE PRECISION,
"compareValue" DOUBLE PRECISION,
"changePct" DOUBLE PRECISION,
"direction" TEXT,
"impactScore" DOUBLE PRECISION NOT NULL DEFAULT 0,
"severityBand" TEXT,
"version" INTEGER NOT NULL DEFAULT 1,
"threadId" UUID NOT NULL DEFAULT gen_random_uuid(),
"windowStart" TIMESTAMP(3),
"windowEnd" TIMESTAMP(3),
"firstDetectedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastUpdatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "project_insights_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."insight_events" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"insightId" UUID NOT NULL,
"eventKind" TEXT NOT NULL,
"changeFrom" JSONB,
"changeTo" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "insight_events_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "project_insights_projectId_impactScore_idx" ON "public"."project_insights"("projectId", "impactScore" DESC);
-- CreateIndex
CREATE INDEX "project_insights_projectId_moduleKey_windowKind_state_idx" ON "public"."project_insights"("projectId", "moduleKey", "windowKind", "state");
-- CreateIndex
CREATE UNIQUE INDEX "project_insights_projectId_moduleKey_dimensionKey_windowKin_key" ON "public"."project_insights"("projectId", "moduleKey", "dimensionKey", "windowKind", "state");
-- CreateIndex
CREATE INDEX "insight_events_insightId_createdAt_idx" ON "public"."insight_events"("insightId", "createdAt");
-- AddForeignKey
ALTER TABLE "public"."insight_events" ADD CONSTRAINT "insight_events_insightId_fkey" FOREIGN KEY ("insightId") REFERENCES "public"."project_insights"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,9 @@
/*
Warnings:
- Made the column `payload` on table `project_insights` required. This step will fail if there are existing NULL values in that column.
*/
-- AlterTable
ALTER TABLE "public"."project_insights" ALTER COLUMN "payload" SET NOT NULL,
ALTER COLUMN "payload" SET DEFAULT '{}';

View File

@@ -0,0 +1,13 @@
/*
Warnings:
- You are about to drop the column `changePct` on the `project_insights` table. All the data in the column will be lost.
- You are about to drop the column `compareValue` on the `project_insights` table. All the data in the column will be lost.
- You are about to drop the column `currentValue` on the `project_insights` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "public"."project_insights" DROP COLUMN "changePct",
DROP COLUMN "compareValue",
DROP COLUMN "currentValue",
ADD COLUMN "displayName" TEXT NOT NULL DEFAULT '';

View File

@@ -497,3 +497,58 @@ model Import {
@@map("imports") @@map("imports")
} }
enum InsightState {
active
suppressed
closed
}
model ProjectInsight {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
projectId String
moduleKey String // e.g. "referrers", "entry-pages"
dimensionKey String // e.g. "referrer:instagram", "page:/pricing"
windowKind String // "yesterday" | "rolling_7d" | "rolling_30d"
state InsightState @default(active)
title String
summary String?
displayName String @default("")
/// [IPrismaProjectInsightPayload]
payload Json @default("{}") // Rendered insight payload (typed)
direction String? // "up" | "down" | "flat"
impactScore Float @default(0)
severityBand String? // "low" | "moderate" | "severe"
version Int @default(1)
threadId String @default(dbgenerated("gen_random_uuid()")) @db.Uuid
windowStart DateTime?
windowEnd DateTime?
firstDetectedAt DateTime @default(now())
lastUpdatedAt DateTime @default(now()) @updatedAt
lastSeenAt DateTime @default(now())
events InsightEvent[]
@@unique([projectId, moduleKey, dimensionKey, windowKind, state])
@@index([projectId, impactScore(sort: Desc)])
@@index([projectId, moduleKey, windowKind, state])
@@map("project_insights")
}
model InsightEvent {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
insightId String @db.Uuid
insight ProjectInsight @relation(fields: [insightId], references: [id], onDelete: Cascade)
eventKind String // "created" | "updated" | "severity_up" | "direction_flip" | "closed" | etc
changeFrom Json?
changeTo Json?
createdAt DateTime @default(now())
@@index([insightId, createdAt])
@@map("insight_events")
}

View File

@@ -43,7 +43,7 @@ class Expression {
} }
export class Query<T = any> { export class Query<T = any> {
private _select: string[] = []; private _select: (string | Expression)[] = [];
private _except: string[] = []; private _except: string[] = [];
private _from?: string | Expression; private _from?: string | Expression;
private _where: WhereCondition[] = []; private _where: WhereCondition[] = [];
@@ -81,17 +81,19 @@ export class Query<T = any> {
// Select methods // Select methods
select<U>( select<U>(
columns: (string | null | undefined | false)[], columns: (string | Expression | null | undefined | false)[],
type: 'merge' | 'replace' = 'replace', type: 'merge' | 'replace' = 'replace',
): Query<U> { ): Query<U> {
if (this._skipNext) return this as unknown as Query<U>; if (this._skipNext) return this as unknown as Query<U>;
if (type === 'merge') { if (type === 'merge') {
this._select = [ this._select = [
...this._select, ...this._select,
...columns.filter((col): col is string => Boolean(col)), ...columns.filter((col): col is string | Expression => Boolean(col)),
]; ];
} else { } else {
this._select = columns.filter((col): col is string => Boolean(col)); this._select = columns.filter((col): col is string | Expression =>
Boolean(col),
);
} }
return this as unknown as Query<U>; return this as unknown as Query<U>;
} }
@@ -372,7 +374,14 @@ export class Query<T = any> {
if (this._select.length > 0) { if (this._select.length > 0) {
parts.push( parts.push(
'SELECT', 'SELECT',
this._select.map((col) => this.escapeDate(col)).join(', '), this._select
// Important: Expressions are treated as raw SQL; do not run escapeDate()
// on them, otherwise any embedded date strings get double-escaped
// (e.g. ''2025-12-16 23:59:59'') which ClickHouse rejects.
.map((col) =>
col instanceof Expression ? col.toString() : this.escapeDate(col),
)
.join(', '),
); );
} else { } else {
parts.push('SELECT *'); parts.push('SELECT *');

View File

@@ -42,11 +42,11 @@ const getPrismaClient = () => {
operation === 'update' || operation === 'update' ||
operation === 'delete' operation === 'delete'
) { ) {
logger.info('Prisma operation', { // logger.info('Prisma operation', {
operation, // operation,
args, // args,
model, // model,
}); // });
} }
return query(args); return query(args);
}, },

View File

@@ -0,0 +1,68 @@
import crypto from 'node:crypto';
import type { ClickHouseClient } from '@clickhouse/client';
import {
type Query,
clix as originalClix,
} from '../../clickhouse/query-builder';
/**
* Creates a cached wrapper around clix that automatically caches query results
* based on query hash. This eliminates duplicate queries within the same module/window context.
*
* @param client - ClickHouse client
* @param cache - Optional cache Map to store query results
* @param timezone - Timezone for queries (defaults to UTC)
* @returns A function that creates cached Query instances (compatible with clix API)
*/
export function createCachedClix(
client: ClickHouseClient,
cache?: Map<string, any>,
timezone?: string,
) {
function clixCached(): Query {
const query = originalClix(client, timezone);
const queryTimezone = timezone ?? 'UTC';
// Override execute() method to add caching
const originalExecute = query.execute.bind(query);
query.execute = async () => {
// Build the query SQL string
const querySQL = query.toSQL();
// Create cache key from query SQL + timezone
const cacheKey = crypto
.createHash('sha256')
.update(`${querySQL}|${queryTimezone}`)
.digest('hex');
// Check cache first
if (cache?.has(cacheKey)) {
return cache.get(cacheKey);
}
// Execute query
const result = await originalExecute();
// Cache the result
if (cache) {
cache.set(cacheKey, result);
}
return result;
};
return query;
}
// Copy static methods from original clix
clixCached.exp = originalClix.exp;
clixCached.date = originalClix.date;
clixCached.datetime = originalClix.datetime;
clixCached.dynamicDatetime = originalClix.dynamicDatetime;
clixCached.toStartOf = originalClix.toStartOf;
clixCached.toStartOfInterval = originalClix.toStartOfInterval;
clixCached.toInterval = originalClix.toInterval;
clixCached.toDate = originalClix.toDate;
return clixCached;
}

View File

@@ -0,0 +1,303 @@
import { createCachedClix } from './cached-clix';
import { materialDecision } from './material';
import { defaultImpactScore, severityBand } from './scoring';
import type {
Cadence,
ComputeContext,
ComputeResult,
InsightModule,
InsightStore,
WindowKind,
} from './types';
import { resolveWindow } from './windows';
const DEFAULT_WINDOWS: WindowKind[] = [
'yesterday',
'rolling_7d',
'rolling_30d',
];
export interface EngineConfig {
keepTopNPerModuleWindow: number; // e.g. 5
closeStaleAfterDays: number; // e.g. 7
dimensionBatchSize: number; // e.g. 50
globalThresholds: {
minTotal: number; // e.g. 200
minAbsDelta: number; // e.g. 80
minPct: number; // e.g. 0.15
};
}
/** Simple gating to cut noise; modules can override via thresholds. */
function passesThresholds(
r: ComputeResult,
mod: InsightModule,
cfg: EngineConfig,
): boolean {
const t = mod.thresholds ?? {};
const minTotal = t.minTotal ?? cfg.globalThresholds.minTotal;
const minAbsDelta = t.minAbsDelta ?? cfg.globalThresholds.minAbsDelta;
const minPct = t.minPct ?? cfg.globalThresholds.minPct;
const cur = r.currentValue ?? 0;
const cmp = r.compareValue ?? 0;
const total = cur + cmp;
const absDelta = Math.abs(cur - cmp);
const pct = Math.abs(r.changePct ?? 0);
if (total < minTotal) return false;
if (absDelta < minAbsDelta) return false;
if (pct < minPct) return false;
return true;
}
function chunk<T>(arr: T[], size: number): T[][] {
if (size <= 0) return [arr];
const out: T[][] = [];
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
return out;
}
export function createEngine(args: {
store: InsightStore;
modules: InsightModule[];
db: any;
logger?: Pick<Console, 'info' | 'warn' | 'error'>;
config: EngineConfig;
}) {
const { store, modules, db, config } = args;
const logger = args.logger ?? console;
function isProjectOldEnoughForWindow(
projectCreatedAt: Date | null | undefined,
baselineStart: Date,
): boolean {
if (!projectCreatedAt) return true; // best-effort; don't block if unknown
return projectCreatedAt.getTime() <= baselineStart.getTime();
}
async function runProject(opts: {
projectId: string;
cadence: Cadence;
now: Date;
projectCreatedAt?: Date | null;
}): Promise<void> {
const { projectId, cadence, now, projectCreatedAt } = opts;
const projLogger = logger;
const eligible = modules.filter((m) => m.cadence.includes(cadence));
for (const mod of eligible) {
const windows = mod.windows ?? DEFAULT_WINDOWS;
for (const windowKind of windows) {
let window: ReturnType<typeof resolveWindow>;
let ctx: ComputeContext;
try {
window = resolveWindow(windowKind, now);
if (
!isProjectOldEnoughForWindow(projectCreatedAt, window.baselineStart)
) {
continue;
}
// Initialize cache for this module+window combination.
// Cache is automatically garbage collected when context goes out of scope.
const cache = new Map<string, any>();
ctx = {
projectId,
window,
db,
now,
logger: projLogger,
clix: createCachedClix(db, cache),
};
} catch (e) {
projLogger.error('[insights] failed to create compute context', {
projectId,
module: mod.key,
windowKind,
err: e,
});
continue;
}
// 1) enumerate dimensions
let dims: string[] = [];
try {
dims = mod.enumerateDimensions
? await mod.enumerateDimensions(ctx)
: [];
} catch (e) {
// Important: enumeration failures should not abort the whole project run.
// Also avoid lifecycle close/suppression when we didn't actually evaluate dims.
projLogger.error('[insights] module enumerateDimensions failed', {
projectId,
module: mod.key,
windowKind,
err: e,
});
continue;
}
const maxDims = mod.thresholds?.maxDims ?? 25;
if (dims.length > maxDims) dims = dims.slice(0, maxDims);
if (dims.length === 0) {
// Still do lifecycle close / suppression based on "nothing emitted"
await store.closeMissingActiveInsights({
projectId,
moduleKey: mod.key,
windowKind,
seenDimensionKeys: [],
now,
staleDays: config.closeStaleAfterDays,
});
await store.applySuppression({
projectId,
moduleKey: mod.key,
windowKind,
keepTopN: config.keepTopNPerModuleWindow,
now,
});
continue;
}
// 2) compute in batches
const seen: string[] = [];
const dimBatches = chunk(dims, config.dimensionBatchSize);
for (const batch of dimBatches) {
let results: ComputeResult[] = [];
try {
results = await mod.computeMany(ctx, batch);
} catch (e) {
projLogger.error('[insights] module computeMany failed', {
projectId,
module: mod.key,
windowKind,
err: e,
});
continue;
}
for (const r of results) {
if (!r?.ok) continue;
if (!r.dimensionKey) continue;
// 3) gate noise
if (!passesThresholds(r, mod, config)) continue;
// 4) score
const impact = mod.score
? mod.score(r, ctx)
: defaultImpactScore(r);
const sev = severityBand(r.changePct);
// 5) dedupe/material change requires loading prev identity
const prev = await store.getActiveInsightByIdentity({
projectId,
moduleKey: mod.key,
dimensionKey: r.dimensionKey,
windowKind,
});
const decision = materialDecision(prev, {
changePct: r.changePct,
direction: r.direction,
});
// 6) render
const card = mod.render(r, ctx);
// 7) upsert
const persisted = await store.upsertInsight({
projectId,
moduleKey: mod.key,
dimensionKey: r.dimensionKey,
window,
card,
metrics: {
direction: r.direction,
impactScore: impact,
severityBand: sev,
},
now,
decision,
prev,
});
seen.push(r.dimensionKey);
// 8) events only when material
if (!prev) {
await store.insertEvent({
projectId,
insightId: persisted.id,
moduleKey: mod.key,
dimensionKey: r.dimensionKey,
windowKind,
eventKind: 'created',
changeFrom: null,
changeTo: {
title: card.title,
changePct: r.changePct,
direction: r.direction,
impact,
severityBand: sev,
},
now,
});
} else if (decision.material) {
const eventKind =
decision.reason === 'direction_flip'
? 'direction_flip'
: decision.reason === 'severity_change'
? sev && prev.severityBand && sev > prev.severityBand
? 'severity_up'
: 'severity_down'
: 'updated';
await store.insertEvent({
projectId,
insightId: persisted.id,
moduleKey: mod.key,
dimensionKey: r.dimensionKey,
windowKind,
eventKind,
changeFrom: {
direction: prev.direction,
impactScore: prev.impactScore,
severityBand: prev.severityBand,
},
changeTo: {
changePct: r.changePct,
direction: r.direction,
impactScore: impact,
severityBand: sev,
},
now,
});
}
}
}
// 10) lifecycle: close missing insights for this module/window
await store.closeMissingActiveInsights({
projectId,
moduleKey: mod.key,
windowKind,
seenDimensionKeys: seen,
now,
staleDays: config.closeStaleAfterDays,
});
// 11) suppression: keep top N
await store.applySuppression({
projectId,
moduleKey: mod.key,
windowKind,
keepTopN: config.keepTopNPerModuleWindow,
now,
});
}
}
}
return { runProject };
}

View File

@@ -0,0 +1,8 @@
export * from './types';
export * from './windows';
export * from './scoring';
export * from './material';
export * from './engine';
export * from './store';
export * from './utils';
export * from './modules';

View File

@@ -0,0 +1,43 @@
import { severityBand as band } from './scoring';
import type { MaterialDecision, PersistedInsight } from './types';
export function materialDecision(
prev: PersistedInsight | null,
next: {
changePct?: number;
direction?: 'up' | 'down' | 'flat';
},
): MaterialDecision {
const nextBand = band(next.changePct);
if (!prev) {
return { material: true, reason: 'created', newSeverityBand: nextBand };
}
// direction flip is always meaningful
const prevDir = (prev.direction ?? 'flat') as any;
const nextDir = next.direction ?? 'flat';
if (prevDir !== nextDir && (nextDir === 'up' || nextDir === 'down')) {
return {
material: true,
reason: 'direction_flip',
newSeverityBand: nextBand,
};
}
// severity band change
const prevBand = (prev.severityBand ?? null) as any;
if (prevBand !== nextBand && nextBand !== null) {
return {
material: true,
reason: 'severity_change',
newSeverityBand: nextBand,
};
}
// Otherwise: treat as non-material (silent refresh). You can add deadband crossing here if you store prior changePct.
return {
material: false,
reason: 'none',
newSeverityBand: prevBand ?? nextBand,
};
}

View File

@@ -0,0 +1,275 @@
import { TABLE_NAMES, formatClickhouseDate } from '../../../clickhouse/client';
import type {
ComputeContext,
ComputeResult,
InsightModule,
RenderedCard,
} from '../types';
import {
buildLookupMap,
computeChangePct,
computeDirection,
computeMedian,
getEndOfDay,
getWeekday,
selectTopDimensions,
} from '../utils';
async function fetchDeviceAggregates(ctx: ComputeContext): Promise<{
currentMap: Map<string, number>;
baselineMap: Map<string, number>;
totalCurrent: number;
totalBaseline: number;
}> {
if (ctx.window.kind === 'yesterday') {
const [currentResults, baselineResults, totals] = await Promise.all([
ctx
.clix()
.select<{ device: string; cnt: number }>(['device', 'count(*) as cnt'])
.from(TABLE_NAMES.sessions)
.where('project_id', '=', ctx.projectId)
.where('sign', '=', 1)
.where('created_at', 'BETWEEN', [
ctx.window.start,
getEndOfDay(ctx.window.end),
])
.groupBy(['device'])
.execute(),
ctx
.clix()
.select<{ date: string; device: string; cnt: number }>([
'toDate(created_at) as date',
'device',
'count(*) as cnt',
])
.from(TABLE_NAMES.sessions)
.where('project_id', '=', ctx.projectId)
.where('sign', '=', 1)
.where('created_at', 'BETWEEN', [
ctx.window.baselineStart,
getEndOfDay(ctx.window.baselineEnd),
])
.groupBy(['date', 'device'])
.execute(),
ctx
.clix()
.select<{ cur_total: number }>([
ctx.clix.exp(
`countIf(created_at BETWEEN '${formatClickhouseDate(ctx.window.start)}' AND '${formatClickhouseDate(getEndOfDay(ctx.window.end))}') as cur_total`,
),
])
.from(TABLE_NAMES.sessions)
.where('project_id', '=', ctx.projectId)
.where('sign', '=', 1)
.where('created_at', 'BETWEEN', [
ctx.window.baselineStart,
getEndOfDay(ctx.window.end),
])
.execute(),
]);
const currentMap = buildLookupMap(currentResults, (r) => r.device);
const targetWeekday = getWeekday(ctx.window.start);
const aggregated = new Map<string, { date: string; cnt: number }[]>();
for (const r of baselineResults) {
if (!aggregated.has(r.device)) {
aggregated.set(r.device, []);
}
const entries = aggregated.get(r.device)!;
const existing = entries.find((e) => e.date === r.date);
if (existing) {
existing.cnt += Number(r.cnt ?? 0);
} else {
entries.push({ date: r.date, cnt: Number(r.cnt ?? 0) });
}
}
const baselineMap = new Map<string, number>();
for (const [deviceType, entries] of aggregated) {
const sameWeekdayValues = entries
.filter((e) => getWeekday(new Date(e.date)) === targetWeekday)
.map((e) => e.cnt)
.sort((a, b) => a - b);
if (sameWeekdayValues.length > 0) {
baselineMap.set(deviceType, computeMedian(sameWeekdayValues));
}
}
const totalCurrent = totals[0]?.cur_total ?? 0;
const totalBaseline =
baselineMap.size > 0
? Array.from(baselineMap.values()).reduce((sum, val) => sum + val, 0)
: 0;
return { currentMap, baselineMap, totalCurrent, totalBaseline };
}
const curStart = formatClickhouseDate(ctx.window.start);
const curEnd = formatClickhouseDate(getEndOfDay(ctx.window.end));
const baseStart = formatClickhouseDate(ctx.window.baselineStart);
const baseEnd = formatClickhouseDate(getEndOfDay(ctx.window.baselineEnd));
const [results, totals] = await Promise.all([
ctx
.clix()
.select<{ device: string; cur: number; base: number }>([
'device',
ctx.clix.exp(
`countIf(created_at BETWEEN '${curStart}' AND '${curEnd}') as cur`,
),
ctx.clix.exp(
`countIf(created_at BETWEEN '${baseStart}' AND '${baseEnd}') as base`,
),
])
.from(TABLE_NAMES.sessions)
.where('project_id', '=', ctx.projectId)
.where('sign', '=', 1)
.where('created_at', 'BETWEEN', [
ctx.window.baselineStart,
getEndOfDay(ctx.window.end),
])
.groupBy(['device'])
.execute(),
ctx
.clix()
.select<{ cur_total: number; base_total: number }>([
ctx.clix.exp(
`countIf(created_at BETWEEN '${curStart}' AND '${curEnd}') as cur_total`,
),
ctx.clix.exp(
`countIf(created_at BETWEEN '${baseStart}' AND '${baseEnd}') as base_total`,
),
])
.from(TABLE_NAMES.sessions)
.where('project_id', '=', ctx.projectId)
.where('sign', '=', 1)
.where('created_at', 'BETWEEN', [
ctx.window.baselineStart,
getEndOfDay(ctx.window.end),
])
.execute(),
]);
const currentMap = buildLookupMap(
results,
(r) => r.device,
(r) => Number(r.cur ?? 0),
);
const baselineMap = buildLookupMap(
results,
(r) => r.device,
(r) => Number(r.base ?? 0),
);
const totalCurrent = totals[0]?.cur_total ?? 0;
const totalBaseline = totals[0]?.base_total ?? 0;
return { currentMap, baselineMap, totalCurrent, totalBaseline };
}
export const devicesModule: InsightModule = {
key: 'devices',
cadence: ['daily'],
thresholds: { minTotal: 100, minAbsDelta: 0, minPct: 0.08, maxDims: 5 },
async enumerateDimensions(ctx) {
const { currentMap, baselineMap } = await fetchDeviceAggregates(ctx);
const topDims = selectTopDimensions(
currentMap,
baselineMap,
this.thresholds?.maxDims ?? 5,
);
return topDims.map((dim) => `device:${dim}`);
},
async computeMany(ctx, dimensionKeys): Promise<ComputeResult[]> {
const { currentMap, baselineMap, totalCurrent, totalBaseline } =
await fetchDeviceAggregates(ctx);
const results: ComputeResult[] = [];
for (const dimKey of dimensionKeys) {
if (!dimKey.startsWith('device:')) continue;
const deviceType = dimKey.replace('device:', '');
const currentValue = currentMap.get(deviceType) ?? 0;
const compareValue = baselineMap.get(deviceType) ?? 0;
const currentShare = totalCurrent > 0 ? currentValue / totalCurrent : 0;
const compareShare = totalBaseline > 0 ? compareValue / totalBaseline : 0;
const shareShiftPp = (currentShare - compareShare) * 100;
const changePct = computeChangePct(currentValue, compareValue);
const direction = computeDirection(changePct);
results.push({
ok: true,
dimensionKey: dimKey,
currentValue,
compareValue,
changePct,
direction,
extra: {
shareShiftPp,
currentShare,
compareShare,
},
});
}
return results;
},
render(result, ctx): RenderedCard {
const device = result.dimensionKey.replace('device:', '');
const changePct = result.changePct ?? 0;
const isIncrease = changePct >= 0;
const sessionsCurrent = result.currentValue ?? 0;
const sessionsCompare = result.compareValue ?? 0;
const shareCurrent = Number(result.extra?.currentShare ?? 0);
const shareCompare = Number(result.extra?.compareShare ?? 0);
return {
title: `${device} ${isIncrease ? '↑' : '↓'} ${Math.abs(changePct * 100).toFixed(0)}%`,
summary: `${ctx.window.label}. Device traffic change.`,
displayName: device,
payload: {
kind: 'insight_v1',
dimensions: [{ key: 'device', value: device, displayName: device }],
primaryMetric: 'sessions',
metrics: {
sessions: {
current: sessionsCurrent,
compare: sessionsCompare,
delta: sessionsCurrent - sessionsCompare,
changePct: sessionsCompare > 0 ? (result.changePct ?? 0) : null,
direction: result.direction ?? 'flat',
unit: 'count',
},
share: {
current: shareCurrent,
compare: shareCompare,
delta: shareCurrent - shareCompare,
changePct:
shareCompare > 0
? (shareCurrent - shareCompare) / shareCompare
: null,
direction:
shareCurrent - shareCompare > 0.0005
? 'up'
: shareCurrent - shareCompare < -0.0005
? 'down'
: 'flat',
unit: 'ratio',
},
},
extra: {
// keep module-specific flags/fields if needed later
},
},
};
},
};

View File

@@ -0,0 +1,287 @@
import { TABLE_NAMES, formatClickhouseDate } from '../../../clickhouse/client';
import type {
ComputeContext,
ComputeResult,
InsightModule,
RenderedCard,
} from '../types';
import {
buildLookupMap,
computeChangePct,
computeDirection,
computeWeekdayMedians,
getEndOfDay,
getWeekday,
selectTopDimensions,
} from '../utils';
const DELIMITER = '|||';
async function fetchEntryPageAggregates(ctx: ComputeContext): Promise<{
currentMap: Map<string, number>;
baselineMap: Map<string, number>;
totalCurrent: number;
totalBaseline: number;
}> {
if (ctx.window.kind === 'yesterday') {
const [currentResults, baselineResults, totals] = await Promise.all([
ctx
.clix()
.select<{ entry_origin: string; entry_path: string; cnt: number }>([
'entry_origin',
'entry_path',
'count(*) as cnt',
])
.from(TABLE_NAMES.sessions)
.where('project_id', '=', ctx.projectId)
.where('sign', '=', 1)
.where('created_at', 'BETWEEN', [
ctx.window.start,
getEndOfDay(ctx.window.end),
])
.groupBy(['entry_origin', 'entry_path'])
.execute(),
ctx
.clix()
.select<{
date: string;
entry_origin: string;
entry_path: string;
cnt: number;
}>([
'toDate(created_at) as date',
'entry_origin',
'entry_path',
'count(*) as cnt',
])
.from(TABLE_NAMES.sessions)
.where('project_id', '=', ctx.projectId)
.where('sign', '=', 1)
.where('created_at', 'BETWEEN', [
ctx.window.baselineStart,
getEndOfDay(ctx.window.baselineEnd),
])
.groupBy(['date', 'entry_origin', 'entry_path'])
.execute(),
ctx
.clix()
.select<{ cur_total: number }>([
ctx.clix.exp(
`countIf(created_at BETWEEN '${formatClickhouseDate(ctx.window.start)}' AND '${formatClickhouseDate(getEndOfDay(ctx.window.end))}') as cur_total`,
),
])
.from(TABLE_NAMES.sessions)
.where('project_id', '=', ctx.projectId)
.where('sign', '=', 1)
.where('created_at', 'BETWEEN', [
ctx.window.baselineStart,
getEndOfDay(ctx.window.end),
])
.execute(),
]);
const currentMap = buildLookupMap(
currentResults,
(r) => `${r.entry_origin || ''}${DELIMITER}${r.entry_path || '/'}`,
);
const targetWeekday = getWeekday(ctx.window.start);
const baselineMap = computeWeekdayMedians(
baselineResults,
targetWeekday,
(r) => `${r.entry_origin || ''}${DELIMITER}${r.entry_path || '/'}`,
);
const totalCurrent = totals[0]?.cur_total ?? 0;
const totalBaseline = Array.from(baselineMap.values()).reduce(
(sum, val) => sum + val,
0,
);
return { currentMap, baselineMap, totalCurrent, totalBaseline };
}
const curStart = formatClickhouseDate(ctx.window.start);
const curEnd = formatClickhouseDate(getEndOfDay(ctx.window.end));
const baseStart = formatClickhouseDate(ctx.window.baselineStart);
const baseEnd = formatClickhouseDate(getEndOfDay(ctx.window.baselineEnd));
const [results, totals] = await Promise.all([
ctx
.clix()
.select<{
entry_origin: string;
entry_path: string;
cur: number;
base: number;
}>([
'entry_origin',
'entry_path',
ctx.clix.exp(
`countIf(created_at BETWEEN '${curStart}' AND '${curEnd}') as cur`,
),
ctx.clix.exp(
`countIf(created_at BETWEEN '${baseStart}' AND '${baseEnd}') as base`,
),
])
.from(TABLE_NAMES.sessions)
.where('project_id', '=', ctx.projectId)
.where('sign', '=', 1)
.where('created_at', 'BETWEEN', [
ctx.window.baselineStart,
getEndOfDay(ctx.window.end),
])
.groupBy(['entry_origin', 'entry_path'])
.execute(),
ctx
.clix()
.select<{ cur_total: number; base_total: number }>([
ctx.clix.exp(
`countIf(created_at BETWEEN '${curStart}' AND '${curEnd}') as cur_total`,
),
ctx.clix.exp(
`countIf(created_at BETWEEN '${baseStart}' AND '${baseEnd}') as base_total`,
),
])
.from(TABLE_NAMES.sessions)
.where('project_id', '=', ctx.projectId)
.where('sign', '=', 1)
.where('created_at', 'BETWEEN', [
ctx.window.baselineStart,
getEndOfDay(ctx.window.end),
])
.execute(),
]);
const currentMap = buildLookupMap(
results,
(r) => `${r.entry_origin || ''}${DELIMITER}${r.entry_path || '/'}`,
(r) => Number(r.cur ?? 0),
);
const baselineMap = buildLookupMap(
results,
(r) => `${r.entry_origin || ''}${DELIMITER}${r.entry_path || '/'}`,
(r) => Number(r.base ?? 0),
);
const totalCurrent = totals[0]?.cur_total ?? 0;
const totalBaseline = totals[0]?.base_total ?? 0;
return { currentMap, baselineMap, totalCurrent, totalBaseline };
}
export const entryPagesModule: InsightModule = {
key: 'entry-pages',
cadence: ['daily'],
thresholds: { minTotal: 100, minAbsDelta: 30, minPct: 0.2, maxDims: 100 },
async enumerateDimensions(ctx) {
const { currentMap, baselineMap } = await fetchEntryPageAggregates(ctx);
const topDims = selectTopDimensions(
currentMap,
baselineMap,
this.thresholds?.maxDims ?? 100,
);
return topDims.map((dim) => `entry:${dim}`);
},
async computeMany(ctx, dimensionKeys): Promise<ComputeResult[]> {
const { currentMap, baselineMap, totalCurrent, totalBaseline } =
await fetchEntryPageAggregates(ctx);
const results: ComputeResult[] = [];
for (const dimKey of dimensionKeys) {
if (!dimKey.startsWith('entry:')) continue;
const originPath = dimKey.replace('entry:', '');
const currentValue = currentMap.get(originPath) ?? 0;
const compareValue = baselineMap.get(originPath) ?? 0;
const currentShare = totalCurrent > 0 ? currentValue / totalCurrent : 0;
const compareShare = totalBaseline > 0 ? compareValue / totalBaseline : 0;
const shareShiftPp = (currentShare - compareShare) * 100;
const changePct = computeChangePct(currentValue, compareValue);
const direction = computeDirection(changePct);
results.push({
ok: true,
dimensionKey: dimKey,
currentValue,
compareValue,
changePct,
direction,
extra: {
shareShiftPp,
currentShare,
compareShare,
isNew: compareValue === 0 && currentValue > 0,
},
});
}
return results;
},
render(result, ctx): RenderedCard {
const originPath = result.dimensionKey.replace('entry:', '');
const [origin, path] = originPath.split(DELIMITER);
const displayValue = origin ? `${origin}${path}` : path || '/';
const pct = ((result.changePct ?? 0) * 100).toFixed(1);
const isIncrease = (result.changePct ?? 0) >= 0;
const isNew = result.extra?.isNew as boolean | undefined;
const title = isNew
? `New entry page: ${displayValue}`
: `Entry page ${displayValue} ${isIncrease ? '↑' : '↓'} ${Math.abs(Number(pct))}%`;
const sessionsCurrent = result.currentValue ?? 0;
const sessionsCompare = result.compareValue ?? 0;
const shareCurrent = Number(result.extra?.currentShare ?? 0);
const shareCompare = Number(result.extra?.compareShare ?? 0);
return {
title,
summary: `${ctx.window.label}. Sessions ${sessionsCurrent} vs ${sessionsCompare}.`,
displayName: displayValue,
payload: {
kind: 'insight_v1',
dimensions: [
{ key: 'origin', value: origin ?? '', displayName: origin ?? '' },
{ key: 'path', value: path ?? '', displayName: path ?? '' },
],
primaryMetric: 'sessions',
metrics: {
sessions: {
current: sessionsCurrent,
compare: sessionsCompare,
delta: sessionsCurrent - sessionsCompare,
changePct: sessionsCompare > 0 ? (result.changePct ?? 0) : null,
direction: result.direction ?? 'flat',
unit: 'count',
},
share: {
current: shareCurrent,
compare: shareCompare,
delta: shareCurrent - shareCompare,
changePct:
shareCompare > 0
? (shareCurrent - shareCompare) / shareCompare
: null,
direction:
shareCurrent - shareCompare > 0.0005
? 'up'
: shareCurrent - shareCompare < -0.0005
? 'down'
: 'flat',
unit: 'ratio',
},
},
extra: {
isNew: result.extra?.isNew,
},
},
};
},
};

View File

@@ -0,0 +1,271 @@
import { getCountry } from '@openpanel/constants';
import { TABLE_NAMES, formatClickhouseDate } from '../../../clickhouse/client';
import type {
ComputeContext,
ComputeResult,
InsightModule,
RenderedCard,
} from '../types';
import {
buildLookupMap,
computeChangePct,
computeDirection,
computeWeekdayMedians,
getEndOfDay,
getWeekday,
selectTopDimensions,
} from '../utils';
async function fetchGeoAggregates(ctx: ComputeContext): Promise<{
currentMap: Map<string, number>;
baselineMap: Map<string, number>;
totalCurrent: number;
totalBaseline: number;
}> {
if (ctx.window.kind === 'yesterday') {
const [currentResults, baselineResults, totals] = await Promise.all([
ctx
.clix()
.select<{ country: string; cnt: number }>([
'country',
'count(*) as cnt',
])
.from(TABLE_NAMES.sessions)
.where('project_id', '=', ctx.projectId)
.where('sign', '=', 1)
.where('created_at', 'BETWEEN', [
ctx.window.start,
getEndOfDay(ctx.window.end),
])
.groupBy(['country'])
.execute(),
ctx
.clix()
.select<{ date: string; country: string; cnt: number }>([
'toDate(created_at) as date',
'country',
'count(*) as cnt',
])
.from(TABLE_NAMES.sessions)
.where('project_id', '=', ctx.projectId)
.where('sign', '=', 1)
.where('created_at', 'BETWEEN', [
ctx.window.baselineStart,
getEndOfDay(ctx.window.baselineEnd),
])
.groupBy(['date', 'country'])
.execute(),
ctx
.clix()
.select<{ cur_total: number }>([
ctx.clix.exp(
`countIf(created_at BETWEEN '${formatClickhouseDate(ctx.window.start)}' AND '${formatClickhouseDate(getEndOfDay(ctx.window.end))}') as cur_total`,
),
])
.from(TABLE_NAMES.sessions)
.where('project_id', '=', ctx.projectId)
.where('sign', '=', 1)
.where('created_at', 'BETWEEN', [
ctx.window.baselineStart,
getEndOfDay(ctx.window.end),
])
.execute(),
]);
const currentMap = buildLookupMap(
currentResults,
(r) => r.country || 'unknown',
);
const targetWeekday = getWeekday(ctx.window.start);
const baselineMap = computeWeekdayMedians(
baselineResults,
targetWeekday,
(r) => r.country || 'unknown',
);
const totalCurrent = totals[0]?.cur_total ?? 0;
const totalBaseline = Array.from(baselineMap.values()).reduce(
(sum, val) => sum + val,
0,
);
return { currentMap, baselineMap, totalCurrent, totalBaseline };
}
const curStart = formatClickhouseDate(ctx.window.start);
const curEnd = formatClickhouseDate(getEndOfDay(ctx.window.end));
const baseStart = formatClickhouseDate(ctx.window.baselineStart);
const baseEnd = formatClickhouseDate(getEndOfDay(ctx.window.baselineEnd));
const [results, totals] = await Promise.all([
ctx
.clix()
.select<{ country: string; cur: number; base: number }>([
'country',
ctx.clix.exp(
`countIf(created_at BETWEEN '${curStart}' AND '${curEnd}') as cur`,
),
ctx.clix.exp(
`countIf(created_at BETWEEN '${baseStart}' AND '${baseEnd}') as base`,
),
])
.from(TABLE_NAMES.sessions)
.where('project_id', '=', ctx.projectId)
.where('sign', '=', 1)
.where('created_at', 'BETWEEN', [
ctx.window.baselineStart,
getEndOfDay(ctx.window.end),
])
.groupBy(['country'])
.execute(),
ctx
.clix()
.select<{ cur_total: number; base_total: number }>([
ctx.clix.exp(
`countIf(created_at BETWEEN '${curStart}' AND '${curEnd}') as cur_total`,
),
ctx.clix.exp(
`countIf(created_at BETWEEN '${baseStart}' AND '${baseEnd}') as base_total`,
),
])
.from(TABLE_NAMES.sessions)
.where('project_id', '=', ctx.projectId)
.where('sign', '=', 1)
.where('created_at', 'BETWEEN', [
ctx.window.baselineStart,
getEndOfDay(ctx.window.end),
])
.execute(),
]);
const currentMap = buildLookupMap(
results,
(r) => r.country || 'unknown',
(r) => Number(r.cur ?? 0),
);
const baselineMap = buildLookupMap(
results,
(r) => r.country || 'unknown',
(r) => Number(r.base ?? 0),
);
const totalCurrent = totals[0]?.cur_total ?? 0;
const totalBaseline = totals[0]?.base_total ?? 0;
return { currentMap, baselineMap, totalCurrent, totalBaseline };
}
export const geoModule: InsightModule = {
key: 'geo',
cadence: ['daily'],
thresholds: { minTotal: 100, minAbsDelta: 0, minPct: 0.08, maxDims: 30 },
async enumerateDimensions(ctx) {
const { currentMap, baselineMap } = await fetchGeoAggregates(ctx);
const topDims = selectTopDimensions(
currentMap,
baselineMap,
this.thresholds?.maxDims ?? 30,
);
return topDims.map((dim) => `country:${dim}`);
},
async computeMany(ctx, dimensionKeys): Promise<ComputeResult[]> {
const { currentMap, baselineMap, totalCurrent, totalBaseline } =
await fetchGeoAggregates(ctx);
const results: ComputeResult[] = [];
for (const dimKey of dimensionKeys) {
if (!dimKey.startsWith('country:')) continue;
const country = dimKey.replace('country:', '');
const currentValue = currentMap.get(country) ?? 0;
const compareValue = baselineMap.get(country) ?? 0;
const currentShare = totalCurrent > 0 ? currentValue / totalCurrent : 0;
const compareShare = totalBaseline > 0 ? compareValue / totalBaseline : 0;
const shareShiftPp = (currentShare - compareShare) * 100;
const changePct = computeChangePct(currentValue, compareValue);
const direction = computeDirection(changePct);
results.push({
ok: true,
dimensionKey: dimKey,
currentValue,
compareValue,
changePct,
direction,
extra: {
shareShiftPp,
currentShare,
compareShare,
isNew: compareValue === 0 && currentValue > 0,
},
});
}
return results;
},
render(result, ctx): RenderedCard {
const country = result.dimensionKey.replace('country:', '');
const changePct = result.changePct ?? 0;
const isIncrease = changePct >= 0;
const isNew = result.extra?.isNew as boolean | undefined;
const displayName = getCountry(country);
const title = isNew
? `New traffic from: ${displayName}`
: `${displayName} ${isIncrease ? '↑' : '↓'} ${Math.abs(changePct * 100).toFixed(0)}%`;
const sessionsCurrent = result.currentValue ?? 0;
const sessionsCompare = result.compareValue ?? 0;
const shareCurrent = Number(result.extra?.currentShare ?? 0);
const shareCompare = Number(result.extra?.compareShare ?? 0);
return {
title,
summary: `${ctx.window.label}. Traffic change from ${displayName}.`,
displayName,
payload: {
kind: 'insight_v1',
dimensions: [
{ key: 'country', value: country, displayName: displayName },
],
primaryMetric: 'sessions',
metrics: {
sessions: {
current: sessionsCurrent,
compare: sessionsCompare,
delta: sessionsCurrent - sessionsCompare,
changePct: sessionsCompare > 0 ? (result.changePct ?? 0) : null,
direction: result.direction ?? 'flat',
unit: 'count',
},
share: {
current: shareCurrent,
compare: shareCompare,
delta: shareCurrent - shareCompare,
changePct:
shareCompare > 0
? (shareCurrent - shareCompare) / shareCompare
: null,
direction:
shareCurrent - shareCompare > 0.0005
? 'up'
: shareCurrent - shareCompare < -0.0005
? 'down'
: 'flat',
unit: 'ratio',
},
},
extra: {
isNew: result.extra?.isNew,
},
},
};
},
};

View File

@@ -0,0 +1,5 @@
export { referrersModule } from './referrers.module';
export { entryPagesModule } from './entry-pages.module';
export { pageTrendsModule } from './page-trends.module';
export { geoModule } from './geo.module';
export { devicesModule } from './devices.module';

View File

@@ -0,0 +1,298 @@
import { TABLE_NAMES, formatClickhouseDate } from '../../../clickhouse/client';
import type {
ComputeContext,
ComputeResult,
InsightModule,
RenderedCard,
} from '../types';
import {
buildLookupMap,
computeChangePct,
computeDirection,
computeWeekdayMedians,
getEndOfDay,
getWeekday,
selectTopDimensions,
} from '../utils';
const DELIMITER = '|||';
async function fetchPageTrendAggregates(ctx: ComputeContext): Promise<{
currentMap: Map<string, number>;
baselineMap: Map<string, number>;
totalCurrent: number;
totalBaseline: number;
}> {
if (ctx.window.kind === 'yesterday') {
const [currentResults, baselineResults, totals] = await Promise.all([
ctx
.clix()
.select<{ origin: string; path: string; cnt: number }>([
'origin',
'path',
'count(*) as cnt',
])
.from(TABLE_NAMES.events)
.where('project_id', '=', ctx.projectId)
.where('name', '=', 'screen_view')
.where('created_at', 'BETWEEN', [
ctx.window.start,
getEndOfDay(ctx.window.end),
])
.groupBy(['origin', 'path'])
.execute(),
ctx
.clix()
.select<{ date: string; origin: string; path: string; cnt: number }>([
'toDate(created_at) as date',
'origin',
'path',
'count(*) as cnt',
])
.from(TABLE_NAMES.events)
.where('project_id', '=', ctx.projectId)
.where('name', '=', 'screen_view')
.where('created_at', 'BETWEEN', [
ctx.window.baselineStart,
getEndOfDay(ctx.window.baselineEnd),
])
.groupBy(['date', 'origin', 'path'])
.execute(),
ctx
.clix()
.select<{ cur_total: number }>([
ctx.clix.exp(
`countIf(created_at BETWEEN '${formatClickhouseDate(ctx.window.start)}' AND '${formatClickhouseDate(getEndOfDay(ctx.window.end))}') as cur_total`,
),
])
.from(TABLE_NAMES.events)
.where('project_id', '=', ctx.projectId)
.where('name', '=', 'screen_view')
.where('created_at', 'BETWEEN', [
ctx.window.baselineStart,
getEndOfDay(ctx.window.end),
])
.execute(),
]);
const currentMap = buildLookupMap(
currentResults,
(r) => `${r.origin || ''}${DELIMITER}${r.path || '/'}`,
);
const targetWeekday = getWeekday(ctx.window.start);
const baselineMap = computeWeekdayMedians(
baselineResults,
targetWeekday,
(r) => `${r.origin || ''}${DELIMITER}${r.path || '/'}`,
);
const totalCurrent = totals[0]?.cur_total ?? 0;
const totalBaseline = Array.from(baselineMap.values()).reduce(
(sum, val) => sum + val,
0,
);
return { currentMap, baselineMap, totalCurrent, totalBaseline };
}
const curStart = formatClickhouseDate(ctx.window.start);
const curEnd = formatClickhouseDate(getEndOfDay(ctx.window.end));
const baseStart = formatClickhouseDate(ctx.window.baselineStart);
const baseEnd = formatClickhouseDate(getEndOfDay(ctx.window.baselineEnd));
const [results, totals] = await Promise.all([
ctx
.clix()
.select<{ origin: string; path: string; cur: number; base: number }>([
'origin',
'path',
ctx.clix.exp(
`countIf(created_at BETWEEN '${curStart}' AND '${curEnd}') as cur`,
),
ctx.clix.exp(
`countIf(created_at BETWEEN '${baseStart}' AND '${baseEnd}') as base`,
),
])
.from(TABLE_NAMES.events)
.where('project_id', '=', ctx.projectId)
.where('name', '=', 'screen_view')
.where('created_at', 'BETWEEN', [
ctx.window.baselineStart,
getEndOfDay(ctx.window.end),
])
.groupBy(['origin', 'path'])
.execute(),
ctx
.clix()
.select<{ cur_total: number; base_total: number }>([
ctx.clix.exp(
`countIf(created_at BETWEEN '${curStart}' AND '${curEnd}') as cur_total`,
),
ctx.clix.exp(
`countIf(created_at BETWEEN '${baseStart}' AND '${baseEnd}') as base_total`,
),
])
.from(TABLE_NAMES.events)
.where('project_id', '=', ctx.projectId)
.where('name', '=', 'screen_view')
.where('created_at', 'BETWEEN', [
ctx.window.baselineStart,
getEndOfDay(ctx.window.end),
])
.execute(),
]);
const currentMap = buildLookupMap(
results,
(r) => `${r.origin || ''}${DELIMITER}${r.path || '/'}`,
(r) => Number(r.cur ?? 0),
);
const baselineMap = buildLookupMap(
results,
(r) => `${r.origin || ''}${DELIMITER}${r.path || '/'}`,
(r) => Number(r.base ?? 0),
);
const totalCurrent = totals[0]?.cur_total ?? 0;
const totalBaseline = totals[0]?.base_total ?? 0;
return { currentMap, baselineMap, totalCurrent, totalBaseline };
}
export const pageTrendsModule: InsightModule = {
key: 'page-trends',
cadence: ['daily'],
// Share-based thresholds (values in basis points: 100 = 1%)
// minTotal: require at least 0.5% combined share (current + baseline)
// minAbsDelta: require at least 0.5 percentage point shift
// minPct: require at least 25% relative change in share
thresholds: { minTotal: 50, minAbsDelta: 50, minPct: 0.25, maxDims: 100 },
async enumerateDimensions(ctx) {
const { currentMap, baselineMap } = await fetchPageTrendAggregates(ctx);
const topDims = selectTopDimensions(
currentMap,
baselineMap,
this.thresholds?.maxDims ?? 100,
);
return topDims.map((dim) => `page:${dim}`);
},
async computeMany(ctx, dimensionKeys): Promise<ComputeResult[]> {
const { currentMap, baselineMap, totalCurrent, totalBaseline } =
await fetchPageTrendAggregates(ctx);
const results: ComputeResult[] = [];
for (const dimKey of dimensionKeys) {
if (!dimKey.startsWith('page:')) continue;
const originPath = dimKey.replace('page:', '');
const pageviewsCurrent = currentMap.get(originPath) ?? 0;
const pageviewsCompare = baselineMap.get(originPath) ?? 0;
const currentShare =
totalCurrent > 0 ? pageviewsCurrent / totalCurrent : 0;
const compareShare =
totalBaseline > 0 ? pageviewsCompare / totalBaseline : 0;
// Use share values in basis points (100 = 1%) for thresholding
// This makes thresholds intuitive: minAbsDelta=50 means 0.5pp shift
const currentShareBp = currentShare * 10000;
const compareShareBp = compareShare * 10000;
const shareShiftPp = (currentShare - compareShare) * 100;
// changePct is relative change in share, not absolute pageviews
const shareChangePct = computeChangePct(currentShare, compareShare);
const direction = computeDirection(shareChangePct);
results.push({
ok: true,
dimensionKey: dimKey,
// Use share in basis points for threshold checks
currentValue: currentShareBp,
compareValue: compareShareBp,
changePct: shareChangePct,
direction,
extra: {
// Keep absolute values for display
pageviewsCurrent,
pageviewsCompare,
shareShiftPp,
currentShare,
compareShare,
isNew: pageviewsCompare === 0 && pageviewsCurrent > 0,
},
});
}
return results;
},
render(result, ctx): RenderedCard {
const originPath = result.dimensionKey.replace('page:', '');
const [origin, path] = originPath.split(DELIMITER);
const displayValue = origin ? `${origin}${path}` : path || '/';
// Get absolute pageviews from extra (currentValue/compareValue are now share-based)
const pageviewsCurrent = Number(result.extra?.pageviewsCurrent ?? 0);
const pageviewsCompare = Number(result.extra?.pageviewsCompare ?? 0);
const shareCurrent = Number(result.extra?.currentShare ?? 0);
const shareCompare = Number(result.extra?.compareShare ?? 0);
const shareShiftPp = Number(result.extra?.shareShiftPp ?? 0);
const isNew = result.extra?.isNew as boolean | undefined;
// Display share shift in percentage points
const isIncrease = shareShiftPp >= 0;
const shareShiftDisplay = Math.abs(shareShiftPp).toFixed(1);
const title = isNew
? `New page getting views: ${displayValue}`
: `Page ${displayValue} share ${isIncrease ? '↑' : '↓'} ${shareShiftDisplay}pp`;
return {
title,
summary: `${ctx.window.label}. Share ${(shareCurrent * 100).toFixed(1)}% vs ${(shareCompare * 100).toFixed(1)}%.`,
displayName: displayValue,
payload: {
kind: 'insight_v1',
dimensions: [
{ key: 'origin', value: origin ?? '', displayName: origin ?? '' },
{ key: 'path', value: path ?? '', displayName: path ?? '' },
],
primaryMetric: 'share',
metrics: {
pageviews: {
current: pageviewsCurrent,
compare: pageviewsCompare,
delta: pageviewsCurrent - pageviewsCompare,
changePct:
pageviewsCompare > 0
? (pageviewsCurrent - pageviewsCompare) / pageviewsCompare
: null,
direction:
pageviewsCurrent > pageviewsCompare
? 'up'
: pageviewsCurrent < pageviewsCompare
? 'down'
: 'flat',
unit: 'count',
},
share: {
current: shareCurrent,
compare: shareCompare,
delta: shareCurrent - shareCompare,
changePct: result.changePct ?? null, // This is now share-based
direction: result.direction ?? 'flat',
unit: 'ratio',
},
},
extra: {
isNew: result.extra?.isNew,
shareShiftPp,
},
},
};
},
};

View File

@@ -0,0 +1,275 @@
import { TABLE_NAMES, formatClickhouseDate } from '../../../clickhouse/client';
import type {
ComputeContext,
ComputeResult,
InsightModule,
RenderedCard,
} from '../types';
import {
buildLookupMap,
computeChangePct,
computeDirection,
computeWeekdayMedians,
getEndOfDay,
getWeekday,
selectTopDimensions,
} from '../utils';
async function fetchReferrerAggregates(ctx: ComputeContext): Promise<{
currentMap: Map<string, number>;
baselineMap: Map<string, number>;
totalCurrent: number;
totalBaseline: number;
}> {
if (ctx.window.kind === 'yesterday') {
const [currentResults, baselineResults, totals] = await Promise.all([
ctx
.clix()
.select<{ referrer_name: string; cnt: number }>([
'referrer_name',
'count(*) as cnt',
])
.from(TABLE_NAMES.sessions)
.where('project_id', '=', ctx.projectId)
.where('sign', '=', 1)
.where('created_at', 'BETWEEN', [
ctx.window.start,
getEndOfDay(ctx.window.end),
])
.groupBy(['referrer_name'])
.execute(),
ctx
.clix()
.select<{ date: string; referrer_name: string; cnt: number }>([
'toDate(created_at) as date',
'referrer_name',
'count(*) as cnt',
])
.from(TABLE_NAMES.sessions)
.where('project_id', '=', ctx.projectId)
.where('sign', '=', 1)
.where('created_at', 'BETWEEN', [
ctx.window.baselineStart,
getEndOfDay(ctx.window.baselineEnd),
])
.groupBy(['date', 'referrer_name'])
.execute(),
ctx
.clix()
.select<{ cur_total: number }>([
ctx.clix.exp(
`countIf(created_at BETWEEN '${formatClickhouseDate(ctx.window.start)}' AND '${formatClickhouseDate(getEndOfDay(ctx.window.end))}') as cur_total`,
),
])
.from(TABLE_NAMES.sessions)
.where('project_id', '=', ctx.projectId)
.where('sign', '=', 1)
.where('created_at', 'BETWEEN', [
ctx.window.baselineStart,
getEndOfDay(ctx.window.end),
])
.execute(),
]);
const currentMap = buildLookupMap(
currentResults,
(r) => r.referrer_name || 'direct',
);
const targetWeekday = getWeekday(ctx.window.start);
const baselineMap = computeWeekdayMedians(
baselineResults,
targetWeekday,
(r) => r.referrer_name || 'direct',
);
const totalCurrent = totals[0]?.cur_total ?? 0;
const totalBaseline = Array.from(baselineMap.values()).reduce(
(sum, val) => sum + val,
0,
);
return { currentMap, baselineMap, totalCurrent, totalBaseline };
}
const curStart = formatClickhouseDate(ctx.window.start);
const curEnd = formatClickhouseDate(getEndOfDay(ctx.window.end));
const baseStart = formatClickhouseDate(ctx.window.baselineStart);
const baseEnd = formatClickhouseDate(getEndOfDay(ctx.window.baselineEnd));
const [results, totals] = await Promise.all([
ctx
.clix()
.select<{ referrer_name: string; cur: number; base: number }>([
'referrer_name',
ctx.clix.exp(
`countIf(created_at BETWEEN '${curStart}' AND '${curEnd}') as cur`,
),
ctx.clix.exp(
`countIf(created_at BETWEEN '${baseStart}' AND '${baseEnd}') as base`,
),
])
.from(TABLE_NAMES.sessions)
.where('project_id', '=', ctx.projectId)
.where('sign', '=', 1)
.where('created_at', 'BETWEEN', [
ctx.window.baselineStart,
getEndOfDay(ctx.window.end),
])
.groupBy(['referrer_name'])
.execute(),
ctx
.clix()
.select<{ cur_total: number; base_total: number }>([
ctx.clix.exp(
`countIf(created_at BETWEEN '${curStart}' AND '${curEnd}') as cur_total`,
),
ctx.clix.exp(
`countIf(created_at BETWEEN '${baseStart}' AND '${baseEnd}') as base_total`,
),
])
.from(TABLE_NAMES.sessions)
.where('project_id', '=', ctx.projectId)
.where('sign', '=', 1)
.where('created_at', 'BETWEEN', [
ctx.window.baselineStart,
getEndOfDay(ctx.window.end),
])
.execute(),
]);
const currentMap = buildLookupMap(
results,
(r) => r.referrer_name || 'direct',
(r) => Number(r.cur ?? 0),
);
const baselineMap = buildLookupMap(
results,
(r) => r.referrer_name || 'direct',
(r) => Number(r.base ?? 0),
);
const totalCurrent = totals[0]?.cur_total ?? 0;
const totalBaseline = totals[0]?.base_total ?? 0;
return { currentMap, baselineMap, totalCurrent, totalBaseline };
}
export const referrersModule: InsightModule = {
key: 'referrers',
cadence: ['daily'],
thresholds: { minTotal: 100, minAbsDelta: 20, minPct: 0.15, maxDims: 50 },
async enumerateDimensions(ctx) {
const { currentMap, baselineMap } = await fetchReferrerAggregates(ctx);
const topDims = selectTopDimensions(
currentMap,
baselineMap,
this.thresholds?.maxDims ?? 50,
);
return topDims.map((dim) => `referrer:${dim}`);
},
async computeMany(ctx, dimensionKeys): Promise<ComputeResult[]> {
const { currentMap, baselineMap, totalCurrent, totalBaseline } =
await fetchReferrerAggregates(ctx);
const results: ComputeResult[] = [];
for (const dimKey of dimensionKeys) {
if (!dimKey.startsWith('referrer:')) continue;
const referrerName = dimKey.replace('referrer:', '');
const currentValue = currentMap.get(referrerName) ?? 0;
const compareValue = baselineMap.get(referrerName) ?? 0;
const currentShare = totalCurrent > 0 ? currentValue / totalCurrent : 0;
const compareShare = totalBaseline > 0 ? compareValue / totalBaseline : 0;
const shareShiftPp = (currentShare - compareShare) * 100;
const changePct = computeChangePct(currentValue, compareValue);
const direction = computeDirection(changePct);
results.push({
ok: true,
dimensionKey: dimKey,
currentValue,
compareValue,
changePct,
direction,
extra: {
shareShiftPp,
currentShare,
compareShare,
isNew: compareValue === 0 && currentValue > 0,
isGone: currentValue === 0 && compareValue > 0,
},
});
}
return results;
},
render(result, ctx): RenderedCard {
const referrer = result.dimensionKey.replace('referrer:', '');
const pct = ((result.changePct ?? 0) * 100).toFixed(1);
const isIncrease = (result.changePct ?? 0) >= 0;
const isNew = result.extra?.isNew as boolean | undefined;
const title = isNew
? `New traffic source: ${referrer}`
: `Traffic from ${referrer} ${isIncrease ? '↑' : '↓'} ${Math.abs(Number(pct))}%`;
const sessionsCurrent = result.currentValue ?? 0;
const sessionsCompare = result.compareValue ?? 0;
const shareCurrent = Number(result.extra?.currentShare ?? 0);
const shareCompare = Number(result.extra?.compareShare ?? 0);
return {
title,
summary: `${ctx.window.label}. Sessions ${sessionsCurrent} vs ${sessionsCompare}.`,
displayName: referrer,
payload: {
kind: 'insight_v1',
dimensions: [
{
key: 'referrer_name',
value: referrer,
displayName: referrer,
},
],
primaryMetric: 'sessions',
metrics: {
sessions: {
current: sessionsCurrent,
compare: sessionsCompare,
delta: sessionsCurrent - sessionsCompare,
changePct: sessionsCompare > 0 ? (result.changePct ?? 0) : null,
direction: result.direction ?? 'flat',
unit: 'count',
},
share: {
current: shareCurrent,
compare: shareCompare,
delta: shareCurrent - shareCompare,
changePct:
shareCompare > 0
? (shareCurrent - shareCompare) / shareCompare
: null,
direction:
shareCurrent - shareCompare > 0.0005
? 'up'
: shareCurrent - shareCompare < -0.0005
? 'down'
: 'flat',
unit: 'ratio',
},
},
extra: {
isNew: result.extra?.isNew,
isGone: result.extra?.isGone,
},
},
};
},
};

View File

@@ -0,0 +1,18 @@
import type { ComputeResult } from './types';
export function defaultImpactScore(r: ComputeResult): number {
const vol = (r.currentValue ?? 0) + (r.compareValue ?? 0);
const pct = Math.abs(r.changePct ?? 0);
// stable-ish: bigger change + bigger volume => higher impact
return Math.log1p(vol) * (pct * 100);
}
export function severityBand(
changePct?: number | null,
): 'low' | 'moderate' | 'severe' | null {
const p = Math.abs(changePct ?? 0);
if (p < 0.1) return null;
if (p < 0.5) return 'low';
if (p < 1) return 'moderate';
return 'severe';
}

View File

@@ -0,0 +1,343 @@
import { Prisma, db } from '../../prisma-client';
import type {
Cadence,
InsightStore,
PersistedInsight,
RenderedCard,
WindowKind,
WindowRange,
} from './types';
export const insightStore: InsightStore = {
async listProjectIdsForCadence(cadence: Cadence): Promise<string[]> {
const projects = await db.project.findMany({
where: {
deleteAt: null,
eventsCount: { gt: 10_000 },
updatedAt: { gt: new Date(Date.now() - 1000 * 60 * 60 * 24) },
organization: {
subscriptionStatus: 'active',
},
},
select: { id: true },
});
return projects.map((p) => p.id);
},
async getProjectCreatedAt(projectId: string): Promise<Date | null> {
const project = await db.project.findFirst({
where: { id: projectId, deleteAt: null },
select: { createdAt: true },
});
return project?.createdAt ?? null;
},
async getActiveInsightByIdentity({
projectId,
moduleKey,
dimensionKey,
windowKind,
}): Promise<PersistedInsight | null> {
const insight = await db.projectInsight.findFirst({
where: {
projectId,
moduleKey,
dimensionKey,
windowKind,
state: 'active',
},
});
if (!insight) return null;
return {
id: insight.id,
projectId: insight.projectId,
moduleKey: insight.moduleKey,
dimensionKey: insight.dimensionKey,
windowKind: insight.windowKind as WindowKind,
state: insight.state as 'active' | 'suppressed' | 'closed',
version: insight.version,
impactScore: insight.impactScore,
lastSeenAt: insight.lastSeenAt,
lastUpdatedAt: insight.lastUpdatedAt,
direction: insight.direction,
severityBand: insight.severityBand,
};
},
async upsertInsight({
projectId,
moduleKey,
dimensionKey,
window,
card,
metrics,
now,
decision,
prev,
}): Promise<PersistedInsight> {
const baseData = {
projectId,
moduleKey,
dimensionKey,
windowKind: window.kind,
state: prev?.state === 'closed' ? 'active' : (prev?.state ?? 'active'),
title: card.title,
summary: card.summary ?? null,
displayName: card.displayName,
payload: card.payload,
direction: metrics.direction ?? null,
impactScore: metrics.impactScore,
severityBand: metrics.severityBand ?? null,
version: prev ? (decision.material ? prev.version + 1 : prev.version) : 1,
windowStart: window.start,
windowEnd: window.end,
lastSeenAt: now,
lastUpdatedAt: now,
};
// Try to find existing insight first
const existing = prev
? await db.projectInsight.findFirst({
where: {
projectId,
moduleKey,
dimensionKey,
windowKind: window.kind,
state: prev.state,
},
})
: null;
let insight: any;
if (existing) {
// Update existing
insight = await db.projectInsight.update({
where: { id: existing.id },
data: {
...baseData,
threadId: existing.threadId, // Preserve threadId
},
});
} else {
// Create new - need to check if there's a closed/suppressed one to reopen
const closed = await db.projectInsight.findFirst({
where: {
projectId,
moduleKey,
dimensionKey,
windowKind: window.kind,
state: { in: ['closed', 'suppressed'] },
},
orderBy: { lastUpdatedAt: 'desc' },
});
if (closed) {
// Reopen and update
insight = await db.projectInsight.update({
where: { id: closed.id },
data: {
...baseData,
state: 'active',
threadId: closed.threadId, // Preserve threadId
},
});
} else {
// Create new
insight = await db.projectInsight.create({
data: {
...baseData,
firstDetectedAt: now,
},
});
}
}
return {
id: insight.id,
projectId: insight.projectId,
moduleKey: insight.moduleKey,
dimensionKey: insight.dimensionKey,
windowKind: insight.windowKind as WindowKind,
state: insight.state as 'active' | 'suppressed' | 'closed',
version: insight.version,
impactScore: insight.impactScore,
lastSeenAt: insight.lastSeenAt,
lastUpdatedAt: insight.lastUpdatedAt,
direction: insight.direction,
severityBand: insight.severityBand,
};
},
async insertEvent({
projectId,
insightId,
moduleKey,
dimensionKey,
windowKind,
eventKind,
changeFrom,
changeTo,
now,
}): Promise<void> {
await db.insightEvent.create({
data: {
insightId,
eventKind,
changeFrom: changeFrom
? (changeFrom as Prisma.InputJsonValue)
: Prisma.DbNull,
changeTo: changeTo
? (changeTo as Prisma.InputJsonValue)
: Prisma.DbNull,
createdAt: now,
},
});
},
async closeMissingActiveInsights({
projectId,
moduleKey,
windowKind,
seenDimensionKeys,
now,
staleDays,
}): Promise<number> {
const staleDate = new Date(now);
staleDate.setDate(staleDate.getDate() - staleDays);
const result = await db.projectInsight.updateMany({
where: {
projectId,
moduleKey,
windowKind,
state: 'active',
lastSeenAt: { lt: staleDate },
dimensionKey: { notIn: seenDimensionKeys },
},
data: {
state: 'closed',
lastUpdatedAt: now,
},
});
return result.count;
},
async applySuppression({
projectId,
moduleKey,
windowKind,
keepTopN,
now,
}): Promise<{ suppressed: number; unsuppressed: number }> {
// Get all active insights for this module/window, ordered by impactScore desc
const insights = await db.projectInsight.findMany({
where: {
projectId,
moduleKey,
windowKind,
state: { in: ['active', 'suppressed'] },
},
orderBy: { impactScore: 'desc' },
});
if (insights.length === 0) {
return { suppressed: 0, unsuppressed: 0 };
}
let suppressed = 0;
let unsuppressed = 0;
// For "yesterday" insights, suppress any that are stale (windowEnd is not actually yesterday)
// This prevents showing confusing insights like "Yesterday traffic dropped" when it's from 2+ days ago
if (windowKind === 'yesterday') {
const yesterday = new Date(now);
yesterday.setUTCHours(0, 0, 0, 0);
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
const yesterdayTime = yesterday.getTime();
for (const insight of insights) {
// If windowEnd is null, consider it stale
const isStale = insight.windowEnd
? new Date(insight.windowEnd).setUTCHours(0, 0, 0, 0) !==
yesterdayTime
: true;
if (isStale && insight.state === 'active') {
await db.projectInsight.update({
where: { id: insight.id },
data: { state: 'suppressed', lastUpdatedAt: now },
});
suppressed++;
}
}
// Filter to only non-stale insights for top-N logic
const freshInsights = insights.filter((insight) => {
if (!insight.windowEnd) return false;
const windowEndTime = new Date(insight.windowEnd).setUTCHours(
0,
0,
0,
0,
);
return windowEndTime === yesterdayTime;
});
const topN = freshInsights.slice(0, keepTopN);
const belowN = freshInsights.slice(keepTopN);
for (const insight of belowN) {
if (insight.state === 'active') {
await db.projectInsight.update({
where: { id: insight.id },
data: { state: 'suppressed', lastUpdatedAt: now },
});
suppressed++;
}
}
for (const insight of topN) {
if (insight.state === 'suppressed') {
await db.projectInsight.update({
where: { id: insight.id },
data: { state: 'active', lastUpdatedAt: now },
});
unsuppressed++;
}
}
return { suppressed, unsuppressed };
}
// For non-yesterday windows, apply standard top-N suppression
const topN = insights.slice(0, keepTopN);
const belowN = insights.slice(keepTopN);
// Suppress those below top N
for (const insight of belowN) {
if (insight.state === 'active') {
await db.projectInsight.update({
where: { id: insight.id },
data: { state: 'suppressed', lastUpdatedAt: now },
});
suppressed++;
}
}
// Unsuppress those in top N
for (const insight of topN) {
if (insight.state === 'suppressed') {
await db.projectInsight.update({
where: { id: insight.id },
data: { state: 'active', lastUpdatedAt: now },
});
unsuppressed++;
}
}
return { suppressed, unsuppressed };
},
};

View File

@@ -0,0 +1,191 @@
import type {
InsightDimension,
InsightMetricEntry,
InsightMetricKey,
InsightPayload,
} from '@openpanel/validation';
export type Cadence = 'daily';
export type WindowKind = 'yesterday' | 'rolling_7d' | 'rolling_30d';
export interface WindowRange {
kind: WindowKind;
start: Date; // inclusive
end: Date; // inclusive (or exclusive, but be consistent)
baselineStart: Date;
baselineEnd: Date;
label: string; // e.g. "Yesterday" / "Last 7 days"
}
export interface ComputeContext {
projectId: string;
window: WindowRange;
db: any; // your DB client
now: Date;
logger: Pick<Console, 'info' | 'warn' | 'error'>;
/**
* Cached clix function that automatically caches query results based on query hash.
* This eliminates duplicate queries within the same module+window context.
* Use this instead of importing clix directly to benefit from automatic caching.
*/
clix: ReturnType<typeof import('./cached-clix').createCachedClix>;
}
export interface ComputeResult {
ok: boolean;
dimensionKey: string; // e.g. "referrer:instagram" / "page:/pricing"
currentValue?: number;
compareValue?: number;
changePct?: number; // -0.15 = -15%
direction?: 'up' | 'down' | 'flat';
extra?: Record<string, unknown>; // share delta pp, rank, sparkline, etc.
}
// Types imported from @openpanel/validation:
// - InsightMetricKey
// - InsightMetricEntry
// - InsightDimension
// - InsightPayload
/**
* Render should be deterministic and safe to call multiple times.
* Returns the shape that matches ProjectInsight create input.
* The payload contains all metric data and display metadata.
*/
export interface RenderedCard {
title: string;
summary?: string;
displayName: string;
payload: InsightPayload; // Contains dimensions, primaryMetric, metrics, extra
}
/** Optional per-module thresholds (the engine can still apply global defaults) */
export interface ModuleThresholds {
minTotal?: number; // min current+baseline
minAbsDelta?: number; // min abs(current-compare)
minPct?: number; // min abs(changePct)
maxDims?: number; // cap enumerateDimensions
}
export interface InsightModule {
key: string;
cadence: Cadence[];
/** Optional per-module override; engine applies a default if omitted. */
windows?: WindowKind[];
thresholds?: ModuleThresholds;
enumerateDimensions?(ctx: ComputeContext): Promise<string[]>;
/** Preferred path: batch compute many dimensions in one go. */
computeMany(
ctx: ComputeContext,
dimensionKeys: string[],
): Promise<ComputeResult[]>;
/** Must not do DB reads; just format output. */
render(result: ComputeResult, ctx: ComputeContext): RenderedCard;
/** Score decides what to show (top-N). */
score?(result: ComputeResult, ctx: ComputeContext): number;
/** Optional: compute "drivers" for AI explain step */
drivers?(
result: ComputeResult,
ctx: ComputeContext,
): Promise<Record<string, unknown>>;
}
/** Insight row shape returned from persistence (minimal fields engine needs). */
export interface PersistedInsight {
id: string;
projectId: string;
moduleKey: string;
dimensionKey: string;
windowKind: WindowKind;
state: 'active' | 'suppressed' | 'closed';
version: number;
impactScore: number;
lastSeenAt: Date;
lastUpdatedAt: Date;
direction?: string | null;
severityBand?: string | null;
}
/** Material change decision used for events/notifications. */
export type MaterialReason =
| 'created'
| 'direction_flip'
| 'severity_change'
| 'cross_deadband'
| 'reopened'
| 'none';
export interface MaterialDecision {
material: boolean;
reason: MaterialReason;
newSeverityBand?: 'low' | 'moderate' | 'severe' | null;
}
/**
* Persistence interface: implement with Postgres.
* Keep engine independent of query builder choice.
*/
export interface InsightStore {
listProjectIdsForCadence(cadence: Cadence): Promise<string[]>;
/** Used by the engine/worker to decide if a window has enough baseline history. */
getProjectCreatedAt(projectId: string): Promise<Date | null>;
getActiveInsightByIdentity(args: {
projectId: string;
moduleKey: string;
dimensionKey: string;
windowKind: WindowKind;
}): Promise<PersistedInsight | null>;
upsertInsight(args: {
projectId: string;
moduleKey: string;
dimensionKey: string;
window: WindowRange;
card: RenderedCard;
metrics: {
direction?: 'up' | 'down' | 'flat';
impactScore: number;
severityBand?: string | null;
};
now: Date;
decision: MaterialDecision;
prev: PersistedInsight | null;
}): Promise<PersistedInsight>;
insertEvent(args: {
projectId: string;
insightId: string;
moduleKey: string;
dimensionKey: string;
windowKind: WindowKind;
eventKind:
| 'created'
| 'updated'
| 'severity_up'
| 'severity_down'
| 'direction_flip'
| 'closed'
| 'reopened'
| 'suppressed'
| 'unsuppressed';
changeFrom?: Record<string, unknown> | null;
changeTo?: Record<string, unknown> | null;
now: Date;
}): Promise<void>;
/** Mark insights as not seen this run if you prefer lifecycle via closeMissing() */
closeMissingActiveInsights(args: {
projectId: string;
moduleKey: string;
windowKind: WindowKind;
seenDimensionKeys: string[];
now: Date;
staleDays: number; // close if not seen for X days
}): Promise<number>; // count closed
/** Enforce top-N display by suppressing below-threshold insights. */
applySuppression(args: {
projectId: string;
moduleKey: string;
windowKind: WindowKind;
keepTopN: number;
now: Date;
}): Promise<{ suppressed: number; unsuppressed: number }>;
}

View File

@@ -0,0 +1,151 @@
/**
* Shared utilities for insight modules
*/
/**
* Get UTC weekday (0 = Sunday, 6 = Saturday)
*/
export function getWeekday(date: Date): number {
return date.getUTCDay();
}
/**
* Compute median of a sorted array of numbers
*/
export function computeMedian(sortedValues: number[]): number {
if (sortedValues.length === 0) return 0;
const mid = Math.floor(sortedValues.length / 2);
return sortedValues.length % 2 === 0
? ((sortedValues[mid - 1] ?? 0) + (sortedValues[mid] ?? 0)) / 2
: (sortedValues[mid] ?? 0);
}
/**
* Compute weekday medians from daily breakdown data.
* Groups by dimension, filters to matching weekday, computes median per dimension.
*
* @param data - Array of { date, dimension, cnt } rows
* @param targetWeekday - Weekday to filter to (0-6)
* @param getDimension - Function to extract normalized dimension from row
* @returns Map of dimension -> median value
*/
export function computeWeekdayMedians<T>(
data: T[],
targetWeekday: number,
getDimension: (row: T) => string,
): Map<string, number> {
// Group by dimension, filtered to target weekday
const byDimension = new Map<string, number[]>();
for (const row of data) {
const rowWeekday = getWeekday(new Date((row as any).date));
if (rowWeekday !== targetWeekday) continue;
const dim = getDimension(row);
const values = byDimension.get(dim) ?? [];
values.push(Number((row as any).cnt ?? 0));
byDimension.set(dim, values);
}
// Compute median per dimension
const result = new Map<string, number>();
for (const [dim, values] of byDimension) {
values.sort((a, b) => a - b);
result.set(dim, computeMedian(values));
}
return result;
}
/**
* Compute change percentage between current and compare values
*/
export function computeChangePct(
currentValue: number,
compareValue: number,
): number {
return compareValue > 0
? (currentValue - compareValue) / compareValue
: currentValue > 0
? 1
: 0;
}
/**
* Determine direction based on change percentage
*/
export function computeDirection(
changePct: number,
threshold = 0.05,
): 'up' | 'down' | 'flat' {
return changePct > threshold
? 'up'
: changePct < -threshold
? 'down'
: 'flat';
}
/**
* Get end of day timestamp (23:59:59.999) for a given date.
* Used to ensure BETWEEN queries include the full day.
*/
export function getEndOfDay(date: Date): Date {
const end = new Date(date);
end.setUTCHours(23, 59, 59, 999);
return end;
}
/**
* Build a lookup map from query results.
* Aggregates counts by key, handling duplicate keys by summing values.
*
* @param results - Array of result rows
* @param getKey - Function to extract the key from each row
* @param getCount - Function to extract the count from each row (defaults to 'cnt' field)
* @returns Map of key -> aggregated count
*/
export function buildLookupMap<T>(
results: T[],
getKey: (row: T) => string,
getCount: (row: T) => number = (row) => Number((row as any).cnt ?? 0),
): Map<string, number> {
const map = new Map<string, number>();
for (const row of results) {
const key = getKey(row);
const cnt = getCount(row);
map.set(key, (map.get(key) ?? 0) + cnt);
}
return map;
}
/**
* Select top-N dimensions by ranking on greatest(current, baseline).
* This preserves union behavior: dimensions with high values in either period are included.
*
* @param currentMap - Map of dimension -> current value
* @param baselineMap - Map of dimension -> baseline value
* @param maxDims - Maximum number of dimensions to return
* @returns Array of dimension keys, ranked by greatest(current, baseline)
*/
export function selectTopDimensions(
currentMap: Map<string, number>,
baselineMap: Map<string, number>,
maxDims: number,
): string[] {
// Merge all dimensions from both maps
const allDims = new Set<string>();
for (const dim of currentMap.keys()) allDims.add(dim);
for (const dim of baselineMap.keys()) allDims.add(dim);
// Rank by greatest(current, baseline)
const ranked = Array.from(allDims)
.map((dim) => ({
dim,
maxValue: Math.max(currentMap.get(dim) ?? 0, baselineMap.get(dim) ?? 0),
}))
.sort((a, b) => b.maxValue - a.maxValue)
.slice(0, maxDims)
.map((x) => x.dim);
return ranked;
}

View File

@@ -0,0 +1,59 @@
import type { WindowKind, WindowRange } from './types';
function atUtcMidnight(d: Date) {
const x = new Date(d);
x.setUTCHours(0, 0, 0, 0);
return x;
}
function addDays(d: Date, days: number) {
const x = new Date(d);
x.setUTCDate(x.getUTCDate() + days);
return x;
}
/**
* Convention: end is inclusive (end of day). If you prefer exclusive, adapt consistently.
*/
export function resolveWindow(kind: WindowKind, now: Date): WindowRange {
const today0 = atUtcMidnight(now);
const yesterday0 = addDays(today0, -1);
if (kind === 'yesterday') {
const start = yesterday0;
const end = yesterday0;
// Baseline: median of last 4 same weekdays -> engine/module implements the median.
// Here we just define the candidate range; module queries last 28 days and filters weekday.
const baselineStart = addDays(yesterday0, -28);
const baselineEnd = addDays(yesterday0, -1);
return { kind, start, end, baselineStart, baselineEnd, label: 'Yesterday' };
}
if (kind === 'rolling_7d') {
const end = yesterday0;
const start = addDays(end, -6); // 7 days inclusive
const baselineEnd = addDays(start, -1);
const baselineStart = addDays(baselineEnd, -6);
return {
kind,
start,
end,
baselineStart,
baselineEnd,
label: 'Last 7 days',
};
}
// rolling_30d
{
const end = yesterday0;
const start = addDays(end, -29);
const baselineEnd = addDays(start, -1);
const baselineStart = addDays(baselineEnd, -29);
return {
kind,
start,
end,
baselineStart,
baselineEnd,
label: 'Last 30 days',
};
}
}

View File

@@ -180,11 +180,11 @@ export function sessionConsistency() {
// For write operations with session: cache WAL LSN after write // For write operations with session: cache WAL LSN after write
if (isWriteOperation(operation)) { if (isWriteOperation(operation)) {
logger.info('Prisma operation', { // logger.info('Prisma operation', {
operation, // operation,
args, // args,
model, // model,
}); // });
const result = await query(args); const result = await query(args);

View File

@@ -3,6 +3,7 @@ import type {
IIntegrationConfig, IIntegrationConfig,
INotificationRuleConfig, INotificationRuleConfig,
IProjectFilters, IProjectFilters,
InsightPayload,
} from '@openpanel/validation'; } from '@openpanel/validation';
import type { import type {
IClickhouseBotEvent, IClickhouseBotEvent,
@@ -18,6 +19,7 @@ declare global {
type IPrismaIntegrationConfig = IIntegrationConfig; type IPrismaIntegrationConfig = IIntegrationConfig;
type IPrismaNotificationPayload = INotificationPayload; type IPrismaNotificationPayload = INotificationPayload;
type IPrismaProjectFilters = IProjectFilters[]; type IPrismaProjectFilters = IProjectFilters[];
type IPrismaProjectInsightPayload = InsightPayload;
type IPrismaClickhouseEvent = IClickhouseEvent; type IPrismaClickhouseEvent = IClickhouseEvent;
type IPrismaClickhouseProfile = IClickhouseProfile; type IPrismaClickhouseProfile = IClickhouseProfile;
type IPrismaClickhouseBotEvent = IClickhouseBotEvent; type IPrismaClickhouseBotEvent = IClickhouseBotEvent;

View File

@@ -111,13 +111,18 @@ export type CronQueuePayloadProject = {
type: 'deleteProjects'; type: 'deleteProjects';
payload: undefined; payload: undefined;
}; };
export type CronQueuePayloadInsightsDaily = {
type: 'insightsDaily';
payload: undefined;
};
export type CronQueuePayload = export type CronQueuePayload =
| CronQueuePayloadSalt | CronQueuePayloadSalt
| CronQueuePayloadFlushEvents | CronQueuePayloadFlushEvents
| CronQueuePayloadFlushSessions | CronQueuePayloadFlushSessions
| CronQueuePayloadFlushProfiles | CronQueuePayloadFlushProfiles
| CronQueuePayloadPing | CronQueuePayloadPing
| CronQueuePayloadProject; | CronQueuePayloadProject
| CronQueuePayloadInsightsDaily;
export type MiscQueuePayloadTrialEndingSoon = { export type MiscQueuePayloadTrialEndingSoon = {
type: 'trialEndingSoon'; type: 'trialEndingSoon';
@@ -235,6 +240,21 @@ export const importQueue = new Queue<ImportQueuePayload>(
}, },
); );
export type InsightsQueuePayloadProject = {
type: 'insightsProject';
payload: { projectId: string; date: string };
};
export const insightsQueue = new Queue<InsightsQueuePayloadProject>(
getQueueName('insights'),
{
connection: getRedisQueue(),
defaultJobOptions: {
removeOnComplete: 100,
},
},
);
export function addTrialEndingSoonJob(organizationId: string, delay: number) { export function addTrialEndingSoonJob(organizationId: string, delay: number) {
return miscQueue.add( return miscQueue.add(
'misc', 'misc',

View File

@@ -5,6 +5,7 @@ import { clientRouter } from './routers/client';
import { dashboardRouter } from './routers/dashboard'; import { dashboardRouter } from './routers/dashboard';
import { eventRouter } from './routers/event'; import { eventRouter } from './routers/event';
import { importRouter } from './routers/import'; import { importRouter } from './routers/import';
import { insightRouter } from './routers/insight';
import { integrationRouter } from './routers/integration'; import { integrationRouter } from './routers/integration';
import { notificationRouter } from './routers/notification'; import { notificationRouter } from './routers/notification';
import { onboardingRouter } from './routers/onboarding'; import { onboardingRouter } from './routers/onboarding';
@@ -47,6 +48,7 @@ export const appRouter = createTRPCRouter({
overview: overviewRouter, overview: overviewRouter,
realtime: realtimeRouter, realtime: realtimeRouter,
chat: chatRouter, chat: chatRouter,
insight: insightRouter,
}); });
// export type definition of API // export type definition of API

View File

@@ -0,0 +1,102 @@
import { db } from '@openpanel/db';
import { z } from 'zod';
import { getProjectAccess } from '../access';
import { TRPCAccessError } from '../errors';
import { createTRPCRouter, protectedProcedure } from '../trpc';
export const insightRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
projectId: z.string(),
limit: z.number().min(1).max(100).optional().default(50),
}),
)
.query(async ({ input: { projectId, limit }, ctx }) => {
const access = await getProjectAccess({
userId: ctx.session.userId,
projectId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
// Fetch more insights than needed to account for deduplication
const allInsights = await db.projectInsight.findMany({
where: {
projectId,
state: 'active',
moduleKey: {
notIn: ['page-trends', 'entry-pages'],
},
},
orderBy: {
impactScore: 'desc',
},
take: limit * 3, // Fetch 3x to account for deduplication
});
// WindowKind priority: yesterday (1) > rolling_7d (2) > rolling_30d (3)
const windowKindPriority: Record<string, number> = {
yesterday: 1,
rolling_7d: 2,
rolling_30d: 3,
};
// Group by moduleKey + dimensionKey, keep only highest priority windowKind
const deduplicated = new Map<string, (typeof allInsights)[0]>();
for (const insight of allInsights) {
const key = `${insight.moduleKey}:${insight.dimensionKey}`;
const existing = deduplicated.get(key);
const currentPriority = windowKindPriority[insight.windowKind] ?? 999;
const existingPriority = existing
? (windowKindPriority[existing.windowKind] ?? 999)
: 999;
// Keep if no existing, or if current has higher priority (lower number)
if (!existing || currentPriority < existingPriority) {
deduplicated.set(key, insight);
}
}
// Convert back to array, sort by impactScore, and limit
const insights = Array.from(deduplicated.values())
.sort((a, b) => (b.impactScore ?? 0) - (a.impactScore ?? 0))
.slice(0, limit)
.map(({ impactScore, ...rest }) => rest); // Remove impactScore from response
return insights;
}),
listAll: protectedProcedure
.input(
z.object({
projectId: z.string(),
limit: z.number().min(1).max(500).optional().default(200),
}),
)
.query(async ({ input: { projectId, limit }, ctx }) => {
const access = await getProjectAccess({
userId: ctx.session.userId,
projectId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
const insights = await db.projectInsight.findMany({
where: {
projectId,
state: 'active',
},
orderBy: {
impactScore: 'desc',
},
take: limit,
});
return insights;
}),
});

View File

@@ -1,2 +1,3 @@
export * from './src/index'; export * from './src/index';
export * from './src/types.validation'; export * from './src/types.validation';
export * from './src/types.insights';

View File

@@ -553,3 +553,5 @@ export const zCreateImport = z.object({
}); });
export type ICreateImport = z.infer<typeof zCreateImport>; export type ICreateImport = z.infer<typeof zCreateImport>;
export * from './types.insights';

View File

@@ -0,0 +1,43 @@
export type InsightMetricKey = 'sessions' | 'pageviews' | 'share';
export type InsightMetricUnit = 'count' | 'ratio';
export interface InsightMetricEntry {
current: number;
compare: number;
delta: number;
changePct: number | null;
direction: 'up' | 'down' | 'flat';
unit: InsightMetricUnit;
}
export interface InsightDimension {
key: string;
value: string;
displayName?: string;
}
export interface InsightExtra {
[key: string]: unknown;
currentShare?: number;
compareShare?: number;
shareShiftPp?: number;
isNew?: boolean;
isGone?: boolean;
}
/**
* Shared payload shape for insights cards. This is embedded in DB rows and
* shipped to the frontend, so it must remain backwards compatible.
*/
export interface InsightPayload {
kind?: 'insight_v1';
dimensions: InsightDimension[];
primaryMetric: InsightMetricKey;
metrics: Partial<Record<InsightMetricKey, InsightMetricEntry>>;
/**
* Module-specific extra data.
*/
extra?: Record<string, unknown>;
}

View File

@@ -38,7 +38,7 @@ docker buildx create --name multi-arch-builder --use || true
build_image() { build_image() {
local app=$1 local app=$1
local image_name="lindesvard/openpanel-$app" local image_name="lindesvard/openpanel-$app"
local full_version="$image_name:$VERSION-$PRERELEASE" local full_version="$image_name:$VERSION"
# Use apps/start/Dockerfile for dashboard app # Use apps/start/Dockerfile for dashboard app
local dockerfile="apps/$app/Dockerfile" local dockerfile="apps/$app/Dockerfile"
@@ -47,10 +47,10 @@ build_image() {
fi fi
if [ -n "$PRERELEASE" ]; then if [ -n "$PRERELEASE" ]; then
echo "(pre-release) Building multi-architecture image for $full_version" echo "(pre-release) Building multi-architecture image for $full_version-$PRERELEASE"
docker buildx build \ docker buildx build \
--platform linux/amd64,linux/arm64 \ --platform linux/amd64,linux/arm64 \
-t "$full_version" \ -t "$full_version-$PRERELEASE" \
--build-arg DATABASE_URL="postgresql://p@p:5432/p" \ --build-arg DATABASE_URL="postgresql://p@p:5432/p" \
-f "$dockerfile" \ -f "$dockerfile" \
--push \ --push \