From 90881e5ffb686fbff0d9abc8a667cf17db2f130f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Fri, 6 Mar 2026 09:00:10 +0100 Subject: [PATCH] wip --- apps/api/scripts/test.ts | 1 + apps/api/src/controllers/track.controller.ts | 24 +- .../dashboard/understand-the-overview.mdx | 11 +- .../src/components/events/table/columns.tsx | 28 +- .../src/components/groups/table/columns.tsx | 53 + .../src/components/groups/table/index.tsx | 114 ++ .../components/profiles/profile-groups.tsx | 52 + .../src/components/profiles/table/columns.tsx | 44 +- .../components/report-chart/funnel/chart.tsx | 3 +- .../src/components/sessions/table/columns.tsx | 21 + .../ui/data-table/data-table-toolbar.tsx | 83 +- apps/start/src/components/ui/select.tsx | 37 +- apps/start/src/modals/add-group.tsx | 118 +++ apps/start/src/modals/edit-group.tsx | 120 +++ apps/start/src/modals/index.tsx | 6 +- apps/start/src/modals/view-chart-users.tsx | 7 +- apps/start/src/routeTree.gen.ts | 157 ++- ..._app.$organizationId.$projectId.groups.tsx | 134 +-- ...rojectId.groups_.$groupId._tabs.events.tsx | 46 + ...projectId.groups_.$groupId._tabs.index.tsx | 208 ++++ ...ojectId.groups_.$groupId._tabs.members.tsx | 42 + ...onId.$projectId.groups_.$groupId._tabs.tsx | 161 +++ ...nizationId.$projectId.groups_.$groupId.tsx | 244 ----- ...jectId.profiles.$profileId._tabs.index.tsx | 12 + apps/testbed/.gitignore | 4 + apps/testbed/index.html | 12 + apps/testbed/package.json | 24 + apps/testbed/scripts/copy-op1.mjs | 16 + apps/testbed/src/App.tsx | 218 ++++ apps/testbed/src/analytics.ts | 10 + apps/testbed/src/main.tsx | 10 + apps/testbed/src/pages/Cart.tsx | 63 ++ apps/testbed/src/pages/Checkout.tsx | 43 + apps/testbed/src/pages/Login.tsx | 186 ++++ apps/testbed/src/pages/Product.tsx | 61 ++ apps/testbed/src/pages/Shop.tsx | 36 + apps/testbed/src/styles.css | 358 +++++++ apps/testbed/src/types.ts | 23 + apps/testbed/tsconfig.json | 17 + apps/testbed/vite.config.ts | 9 + .../src/jobs/events.incoming-events.test.ts | 1 + docker-compose.yml | 2 +- packages/common/scripts/get-referrers.ts | 69 +- packages/db/code-migrations/11-add-groups.ts | 65 +- .../20260218164139_groups/migration.sql | 15 - packages/db/prisma/schema.prisma | 15 - packages/db/src/buffers/event-buffer.ts | 23 +- .../db/src/buffers/profile-buffer.test.ts | 151 +++ packages/db/src/buffers/profile-buffer.ts | 200 ++-- packages/db/src/buffers/session-buffer.ts | 7 + packages/db/src/clickhouse/query-builder.ts | 12 + packages/db/src/services/chart.service.ts | 147 ++- .../db/src/services/conversion.service.ts | 22 +- packages/db/src/services/event.service.ts | 48 +- packages/db/src/services/funnel.service.ts | 29 +- packages/db/src/services/group.service.ts | 353 +++++-- packages/db/src/services/profile.service.ts | 9 +- packages/db/src/services/session.service.ts | 5 + packages/importer/src/providers/mixpanel.ts | 2 + packages/importer/src/providers/umami.ts | 1 + packages/logger/index.ts | 14 +- packages/sdks/sdk/src/index.ts | 7 + packages/trpc/src/routers/chart.ts | 113 +- packages/trpc/src/routers/event.ts | 1 + packages/trpc/src/routers/group.ts | 85 +- packages/validation/src/track.validation.ts | 1 + pnpm-lock.yaml | 974 ++---------------- scripts/seed-events.mjs | 599 +++++++++++ 68 files changed, 4092 insertions(+), 1694 deletions(-) create mode 100644 apps/start/src/components/groups/table/columns.tsx create mode 100644 apps/start/src/components/groups/table/index.tsx create mode 100644 apps/start/src/components/profiles/profile-groups.tsx create mode 100644 apps/start/src/modals/add-group.tsx create mode 100644 apps/start/src/modals/edit-group.tsx create mode 100644 apps/start/src/routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.events.tsx create mode 100644 apps/start/src/routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.index.tsx create mode 100644 apps/start/src/routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.members.tsx create mode 100644 apps/start/src/routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.tsx delete mode 100644 apps/start/src/routes/_app.$organizationId.$projectId.groups_.$groupId.tsx create mode 100644 apps/testbed/.gitignore create mode 100644 apps/testbed/index.html create mode 100644 apps/testbed/package.json create mode 100644 apps/testbed/scripts/copy-op1.mjs create mode 100644 apps/testbed/src/App.tsx create mode 100644 apps/testbed/src/analytics.ts create mode 100644 apps/testbed/src/main.tsx create mode 100644 apps/testbed/src/pages/Cart.tsx create mode 100644 apps/testbed/src/pages/Checkout.tsx create mode 100644 apps/testbed/src/pages/Login.tsx create mode 100644 apps/testbed/src/pages/Product.tsx create mode 100644 apps/testbed/src/pages/Shop.tsx create mode 100644 apps/testbed/src/styles.css create mode 100644 apps/testbed/src/types.ts create mode 100644 apps/testbed/tsconfig.json create mode 100644 apps/testbed/vite.config.ts delete mode 100644 packages/db/prisma/migrations/20260218164139_groups/migration.sql create mode 100644 packages/db/src/buffers/profile-buffer.test.ts create mode 100644 scripts/seed-events.mjs diff --git a/apps/api/scripts/test.ts b/apps/api/scripts/test.ts index b1ab8938..08fe47e7 100644 --- a/apps/api/scripts/test.ts +++ b/apps/api/scripts/test.ts @@ -63,6 +63,7 @@ async function main() { imported_at: null, sdk_name: 'test-script', sdk_version: '1.0.0', + groups: [], }); } diff --git a/apps/api/src/controllers/track.controller.ts b/apps/api/src/controllers/track.controller.ts index 1e03175a..60c06ae3 100644 --- a/apps/api/src/controllers/track.controller.ts +++ b/apps/api/src/controllers/track.controller.ts @@ -341,13 +341,23 @@ async function handleGroup( context: TrackContext ): Promise { const { id, type, name, properties = {} } = payload; - await upsertGroup({ - id, - projectId: context.projectId, - type, - name, - properties, - }); + const profileId = payload.profileId ?? context.deviceId; + + await Promise.all([ + upsertGroup({ + id, + projectId: context.projectId, + type, + name, + properties, + }), + upsertProfile({ + id: profileId, + projectId: context.projectId, + isExternal: !!(payload.profileId ?? context.identity?.profileId), + groups: [id], + }), + ]); } export async function handler( diff --git a/apps/public/content/docs/dashboard/understand-the-overview.mdx b/apps/public/content/docs/dashboard/understand-the-overview.mdx index 4ebff0f0..5a92a4f7 100644 --- a/apps/public/content/docs/dashboard/understand-the-overview.mdx +++ b/apps/public/content/docs/dashboard/understand-the-overview.mdx @@ -59,7 +59,16 @@ The trailing edge of the line (the current, incomplete interval) is shown as a d ## Insights -If you have configured insights for your project, a scrollable row of insight cards appears below the chart. Each card shows a pre-configured metric with its current value and trend. Clicking a card applies that insight's filter to the entire overview page. Insights are optional—this section is hidden when none have been configured. +A scrollable row of insight cards appears below the chart once your project has at least 30 days of data. OpenPanel automatically detects significant trends across pageviews, entry pages, referrers, and countries—no configuration needed. + +Each card shows: +- **Share**: The percentage of total traffic that property represents (e.g., "United States: 42% of all sessions") +- **Absolute change**: The raw increase or decrease in sessions compared to the previous period +- **Percentage change**: How much that property grew or declined relative to its own previous value + +For example, if the US had 1,000 sessions last week and 1,200 this week, the card shows "+200 sessions (+20%)". + +Clicking any insight card filters the entire overview page to show only data matching that property—letting you drill into what's driving the trend. --- diff --git a/apps/start/src/components/events/table/columns.tsx b/apps/start/src/components/events/table/columns.tsx index b14fde85..27478b33 100644 --- a/apps/start/src/components/events/table/columns.tsx +++ b/apps/start/src/components/events/table/columns.tsx @@ -76,7 +76,6 @@ export function useColumns() { )} @@ -99,20 +98,22 @@ function DataTableToolbarFilter({ { const columnMeta = column.columnDef.meta; - const getTitle = React.useCallback(() => { + const getTitle = useCallback(() => { return columnMeta?.label ?? columnMeta?.placeholder ?? column.id; }, [columnMeta, column]); - const onFilterRender = React.useCallback(() => { - if (!columnMeta?.variant) return null; + const onFilterRender = useCallback(() => { + if (!columnMeta?.variant) { + return null; + } switch (columnMeta.variant) { case 'text': return ( column.setFilterValue(value)} placeholder={columnMeta.placeholder ?? columnMeta.label} value={(column.getFilterValue() as string) ?? ''} - onChange={(value) => column.setFilterValue(value)} /> ); @@ -120,12 +121,12 @@ function DataTableToolbarFilter({ return (
column.setFilterValue(event.target.value)} className={cn('h-8 w-[120px]', columnMeta.unit && 'pr-8')} + inputMode="numeric" + onChange={(event) => column.setFilterValue(event.target.value)} + placeholder={getTitle()} + type="number" + value={(column.getFilterValue() as string) ?? ''} /> {columnMeta.unit && ( @@ -143,8 +144,8 @@ function DataTableToolbarFilter({ return ( ); @@ -153,9 +154,9 @@ function DataTableToolbarFilter({ return ( ); @@ -179,11 +180,11 @@ export function AnimatedSearchInput({ value, onChange, }: AnimatedSearchInputProps) { - const [isFocused, setIsFocused] = React.useState(false); - const inputRef = React.useRef(null); + const [isFocused, setIsFocused] = useState(false); + const inputRef = useRef(null); const isExpanded = isFocused || (value?.length ?? 0) > 0; - const handleClear = React.useCallback(() => { + const handleClear = useCallback(() => { onChange(''); // Re-focus after clearing requestAnimationFrame(() => inputRef.current?.focus()); @@ -191,34 +192,35 @@ export function AnimatedSearchInput({ return (
- + onChange(e.target.value)} - placeholder={placeholder} className={cn( - 'absolute inset-0 -top-px h-8 w-full rounded-md border-0 bg-transparent pl-7 pr-7 shadow-none', + 'absolute inset-0 h-full w-full rounded-md border-0 bg-transparent py-2 pr-7 pl-7 shadow-none', 'focus-visible:ring-0 focus-visible:ring-offset-0', 'transition-opacity duration-200', - 'font-medium text-[14px] truncate align-baseline', + 'truncate align-baseline font-medium text-[14px]' )} - onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} + onChange={(e) => onChange(e.target.value)} + onFocus={() => setIsFocused(true)} + placeholder={placeholder} + ref={inputRef} + size="sm" + value={value} /> {isExpanded && value && ( diff --git a/apps/start/src/components/ui/select.tsx b/apps/start/src/components/ui/select.tsx index 46077950..ae29853c 100644 --- a/apps/start/src/components/ui/select.tsx +++ b/apps/start/src/components/ui/select.tsx @@ -1,7 +1,6 @@ import * as SelectPrimitive from '@radix-ui/react-select'; import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'; import type * as React from 'react'; - import { cn } from '@/lib/utils'; function Select({ @@ -32,12 +31,12 @@ function SelectTrigger({ }) { return ( {children} @@ -57,13 +56,13 @@ function SelectContent({ return ( @@ -72,7 +71,7 @@ function SelectContent({ className={cn( 'p-1', position === 'popper' && - 'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1', + 'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1' )} > {children} @@ -89,8 +88,8 @@ function SelectLabel({ }: React.ComponentProps) { return ( ); @@ -103,11 +102,11 @@ function SelectItem({ }: React.ComponentProps) { return ( @@ -126,8 +125,8 @@ function SelectSeparator({ }: React.ComponentProps) { return ( ); @@ -139,11 +138,11 @@ function SelectScrollUpButton({ }: React.ComponentProps) { return ( @@ -157,11 +156,11 @@ function SelectScrollDownButton({ }: React.ComponentProps) { return ( diff --git a/apps/start/src/modals/add-group.tsx b/apps/start/src/modals/add-group.tsx new file mode 100644 index 00000000..d6cc0baf --- /dev/null +++ b/apps/start/src/modals/add-group.tsx @@ -0,0 +1,118 @@ +import { ButtonContainer } from '@/components/button-container'; +import { InputWithLabel } from '@/components/forms/input-with-label'; +import { Button } from '@/components/ui/button'; +import { useAppParams } from '@/hooks/use-app-params'; +import { handleError, useTRPC } from '@/integrations/trpc/react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { PlusIcon, Trash2Icon } from 'lucide-react'; +import { useFieldArray, useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { popModal } from '.'; +import { ModalContent, ModalHeader } from './Modal/Container'; + +interface IForm { + id: string; + type: string; + name: string; + properties: { key: string; value: string }[]; +} + +export default function AddGroup() { + const { projectId } = useAppParams(); + const queryClient = useQueryClient(); + const trpc = useTRPC(); + + const { register, handleSubmit, control, formState } = useForm({ + defaultValues: { + id: '', + type: '', + name: '', + properties: [], + }, + }); + + const { fields, append, remove } = useFieldArray({ + control, + name: 'properties', + }); + + const mutation = useMutation( + trpc.group.create.mutationOptions({ + onSuccess() { + queryClient.invalidateQueries(trpc.group.list.pathFilter()); + queryClient.invalidateQueries(trpc.group.types.pathFilter()); + toast('Success', { description: 'Group created.' }); + popModal(); + }, + onError: handleError, + }), + ); + + return ( + + +
{ + const props = Object.fromEntries( + properties + .filter((p) => p.key.trim() !== '') + .map((p) => [p.key.trim(), String(p.value)]), + ); + mutation.mutate({ projectId, ...values, properties: props }); + })} + > + + + + +
+
+ Properties + +
+ {fields.map((field, index) => ( +
+ + + +
+ ))} +
+ + + + + + +
+ ); +} diff --git a/apps/start/src/modals/edit-group.tsx b/apps/start/src/modals/edit-group.tsx new file mode 100644 index 00000000..7940fad7 --- /dev/null +++ b/apps/start/src/modals/edit-group.tsx @@ -0,0 +1,120 @@ +import { ButtonContainer } from '@/components/button-container'; +import { InputWithLabel } from '@/components/forms/input-with-label'; +import { Button } from '@/components/ui/button'; +import { handleError, useTRPC } from '@/integrations/trpc/react'; +import type { IServiceGroup } from '@openpanel/db'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { PlusIcon, Trash2Icon } from 'lucide-react'; +import { useFieldArray, useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { popModal } from '.'; +import { ModalContent, ModalHeader } from './Modal/Container'; + +interface IForm { + type: string; + name: string; + properties: { key: string; value: string }[]; +} + +type EditGroupProps = Pick; + +export default function EditGroup({ id, projectId, name, type, properties }: EditGroupProps) { + const queryClient = useQueryClient(); + const trpc = useTRPC(); + + const { register, handleSubmit, control, formState } = useForm({ + defaultValues: { + type, + name, + properties: Object.entries(properties as Record).map(([key, value]) => ({ + key, + value: String(value), + })), + }, + }); + + const { fields, append, remove } = useFieldArray({ + control, + name: 'properties', + }); + + const mutation = useMutation( + trpc.group.update.mutationOptions({ + onSuccess() { + queryClient.invalidateQueries(trpc.group.list.pathFilter()); + queryClient.invalidateQueries(trpc.group.byId.pathFilter()); + queryClient.invalidateQueries(trpc.group.types.pathFilter()); + toast('Success', { description: 'Group updated.' }); + popModal(); + }, + onError: handleError, + }), + ); + + return ( + + +
{ + const props = Object.fromEntries( + formProps + .filter((p) => p.key.trim() !== '') + .map((p) => [p.key.trim(), String(p.value)]), + ); + mutation.mutate({ id, projectId, ...values, properties: props }); + })} + > + + + +
+
+ Properties + +
+ {fields.map((field, index) => ( +
+ + + +
+ ))} +
+ + + + + + +
+ ); +} diff --git a/apps/start/src/modals/index.tsx b/apps/start/src/modals/index.tsx index 91c70424..36188f4a 100644 --- a/apps/start/src/modals/index.tsx +++ b/apps/start/src/modals/index.tsx @@ -1,7 +1,7 @@ -import PageDetails from './page-details'; import { createPushModal } from 'pushmodal'; import AddClient from './add-client'; import AddDashboard from './add-dashboard'; +import AddGroup from './add-group'; import AddImport from './add-import'; import AddIntegration from './add-integration'; import AddNotificationRule from './add-notification-rule'; @@ -16,6 +16,7 @@ import DateTimePicker from './date-time-picker'; import EditClient from './edit-client'; import EditDashboard from './edit-dashboard'; import EditEvent from './edit-event'; +import EditGroup from './edit-group'; import EditMember from './edit-member'; import EditReference from './edit-reference'; import EditReport from './edit-report'; @@ -23,6 +24,7 @@ import EventDetails from './event-details'; import Instructions from './Instructions'; import OverviewChartDetails from './overview-chart-details'; import OverviewFilters from './overview-filters'; +import PageDetails from './page-details'; import RequestPasswordReset from './request-reset-password'; import SaveReport from './save-report'; import SelectBillingPlan from './select-billing-plan'; @@ -36,6 +38,8 @@ import { op } from '@/utils/op'; const modals = { PageDetails, + AddGroup, + EditGroup, OverviewTopPagesModal, OverviewTopGenericModal, RequestPasswordReset, diff --git a/apps/start/src/modals/view-chart-users.tsx b/apps/start/src/modals/view-chart-users.tsx index d2e926ac..f9aafadc 100644 --- a/apps/start/src/modals/view-chart-users.tsx +++ b/apps/start/src/modals/view-chart-users.tsx @@ -281,9 +281,10 @@ function ChartUsersView({ chartData, report, date }: ChartUsersViewProps) { interface FunnelUsersViewProps { report: IReportInput; stepIndex: number; + breakdownValues?: string[]; } -function FunnelUsersView({ report, stepIndex }: FunnelUsersViewProps) { +function FunnelUsersView({ report, stepIndex, breakdownValues }: FunnelUsersViewProps) { const trpc = useTRPC(); const [showDropoffs, setShowDropoffs] = useState(false); @@ -306,6 +307,7 @@ function FunnelUsersView({ report, stepIndex }: FunnelUsersViewProps) { ? report.options.funnelGroup : undefined, breakdowns: report.breakdowns, + breakdownValues: breakdownValues, }, { enabled: stepIndex !== undefined, @@ -384,13 +386,14 @@ type ViewChartUsersProps = type: 'funnel'; report: IReportInput; stepIndex: number; + breakdownValues?: string[]; }; // Main component that routes to the appropriate view export default function ViewChartUsers(props: ViewChartUsersProps) { if (props.type === 'funnel') { return ( - + ); } diff --git a/apps/start/src/routeTree.gen.ts b/apps/start/src/routeTree.gen.ts index e5d54379..3b22c2a1 100644 --- a/apps/start/src/routeTree.gen.ts +++ b/apps/start/src/routeTree.gen.ts @@ -64,7 +64,6 @@ import { Route as AppOrganizationIdProjectIdSessionsSessionIdRouteImport } from import { Route as AppOrganizationIdProjectIdReportsReportIdRouteImport } from './routes/_app.$organizationId.$projectId.reports_.$reportId' import { Route as AppOrganizationIdProjectIdProfilesTabsRouteImport } from './routes/_app.$organizationId.$projectId.profiles._tabs' import { Route as AppOrganizationIdProjectIdNotificationsTabsRouteImport } from './routes/_app.$organizationId.$projectId.notifications._tabs' -import { Route as AppOrganizationIdProjectIdGroupsGroupIdRouteImport } from './routes/_app.$organizationId.$projectId.groups_.$groupId' import { Route as AppOrganizationIdProjectIdEventsTabsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs' import { Route as AppOrganizationIdProjectIdDashboardsDashboardIdRouteImport } from './routes/_app.$organizationId.$projectId.dashboards_.$dashboardId' import { Route as AppOrganizationIdProjectIdSettingsTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.index' @@ -84,12 +83,16 @@ import { Route as AppOrganizationIdProjectIdProfilesTabsAnonymousRouteImport } f import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs' import { Route as AppOrganizationIdProjectIdNotificationsTabsRulesRouteImport } from './routes/_app.$organizationId.$projectId.notifications._tabs.rules' import { Route as AppOrganizationIdProjectIdNotificationsTabsNotificationsRouteImport } from './routes/_app.$organizationId.$projectId.notifications._tabs.notifications' +import { Route as AppOrganizationIdProjectIdGroupsGroupIdTabsRouteImport } from './routes/_app.$organizationId.$projectId.groups_.$groupId._tabs' import { Route as AppOrganizationIdProjectIdEventsTabsStatsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.stats' import { Route as AppOrganizationIdProjectIdEventsTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.events' import { Route as AppOrganizationIdProjectIdEventsTabsConversionsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.conversions' import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.index' +import { Route as AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.index' import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.sessions' import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.events' +import { Route as AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRouteImport } from './routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.members' +import { Route as AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.events' const AppOrganizationIdProfileRouteImport = createFileRoute( '/_app/$organizationId/profile', @@ -115,6 +118,9 @@ const AppOrganizationIdProjectIdEventsRouteImport = createFileRoute( const AppOrganizationIdProjectIdProfilesProfileIdRouteImport = createFileRoute( '/_app/$organizationId/$projectId/profiles/$profileId', )() +const AppOrganizationIdProjectIdGroupsGroupIdRouteImport = createFileRoute( + '/_app/$organizationId/$projectId/groups_/$groupId', +)() const UnsubscribeRoute = UnsubscribeRouteImport.update({ id: '/unsubscribe', @@ -376,6 +382,12 @@ const AppOrganizationIdProjectIdProfilesProfileIdRoute = path: '/$profileId', getParentRoute: () => AppOrganizationIdProjectIdProfilesRoute, } as any) +const AppOrganizationIdProjectIdGroupsGroupIdRoute = + AppOrganizationIdProjectIdGroupsGroupIdRouteImport.update({ + id: '/groups_/$groupId', + path: '/groups/$groupId', + getParentRoute: () => AppOrganizationIdProjectIdRoute, + } as any) const AppOrganizationIdProfileTabsIndexRoute = AppOrganizationIdProfileTabsIndexRouteImport.update({ id: '/', @@ -451,12 +463,6 @@ const AppOrganizationIdProjectIdNotificationsTabsRoute = id: '/_tabs', getParentRoute: () => AppOrganizationIdProjectIdNotificationsRoute, } as any) -const AppOrganizationIdProjectIdGroupsGroupIdRoute = - AppOrganizationIdProjectIdGroupsGroupIdRouteImport.update({ - id: '/groups_/$groupId', - path: '/groups/$groupId', - getParentRoute: () => AppOrganizationIdProjectIdRoute, - } as any) const AppOrganizationIdProjectIdEventsTabsRoute = AppOrganizationIdProjectIdEventsTabsRouteImport.update({ id: '/_tabs', @@ -569,6 +575,11 @@ const AppOrganizationIdProjectIdNotificationsTabsNotificationsRoute = path: '/notifications', getParentRoute: () => AppOrganizationIdProjectIdNotificationsTabsRoute, } as any) +const AppOrganizationIdProjectIdGroupsGroupIdTabsRoute = + AppOrganizationIdProjectIdGroupsGroupIdTabsRouteImport.update({ + id: '/_tabs', + getParentRoute: () => AppOrganizationIdProjectIdGroupsGroupIdRoute, + } as any) const AppOrganizationIdProjectIdEventsTabsStatsRoute = AppOrganizationIdProjectIdEventsTabsStatsRouteImport.update({ id: '/stats', @@ -593,6 +604,12 @@ const AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute = path: '/', getParentRoute: () => AppOrganizationIdProjectIdProfilesProfileIdTabsRoute, } as any) +const AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute = + AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AppOrganizationIdProjectIdGroupsGroupIdTabsRoute, + } as any) const AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute = AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRouteImport.update({ id: '/sessions', @@ -605,6 +622,18 @@ const AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute = path: '/events', getParentRoute: () => AppOrganizationIdProjectIdProfilesProfileIdTabsRoute, } as any) +const AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute = + AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRouteImport.update({ + id: '/members', + path: '/members', + getParentRoute: () => AppOrganizationIdProjectIdGroupsGroupIdTabsRoute, + } as any) +const AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute = + AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRouteImport.update({ + id: '/events', + path: '/events', + getParentRoute: () => AppOrganizationIdProjectIdGroupsGroupIdTabsRoute, + } as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -645,7 +674,6 @@ export interface FileRoutesByFullPath { '/$organizationId/$projectId/': typeof AppOrganizationIdProjectIdIndexRoute '/$organizationId/$projectId/dashboards/$dashboardId': typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute '/$organizationId/$projectId/events': typeof AppOrganizationIdProjectIdEventsTabsRouteWithChildren - '/$organizationId/$projectId/groups/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdRoute '/$organizationId/$projectId/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsRouteWithChildren '/$organizationId/$projectId/profiles': typeof AppOrganizationIdProjectIdProfilesTabsRouteWithChildren '/$organizationId/$projectId/reports/$reportId': typeof AppOrganizationIdProjectIdReportsReportIdRoute @@ -662,6 +690,7 @@ export interface FileRoutesByFullPath { '/$organizationId/$projectId/events/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute '/$organizationId/$projectId/events/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute '/$organizationId/$projectId/events/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute + '/$organizationId/$projectId/groups/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRouteWithChildren '/$organizationId/$projectId/notifications/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsNotificationsRoute '/$organizationId/$projectId/notifications/rules': typeof AppOrganizationIdProjectIdNotificationsTabsRulesRoute '/$organizationId/$projectId/profiles/$profileId': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsRouteWithChildren @@ -679,8 +708,11 @@ export interface FileRoutesByFullPath { '/$organizationId/$projectId/notifications/': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute '/$organizationId/$projectId/profiles/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute '/$organizationId/$projectId/settings/': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute + '/$organizationId/$projectId/groups/$groupId/events': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute + '/$organizationId/$projectId/groups/$groupId/members': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute '/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute '/$organizationId/$projectId/profiles/$profileId/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute + '/$organizationId/$projectId/groups/$groupId/': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute '/$organizationId/$projectId/profiles/$profileId/': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute } export interface FileRoutesByTo { @@ -720,7 +752,6 @@ export interface FileRoutesByTo { '/$organizationId/$projectId': typeof AppOrganizationIdProjectIdIndexRoute '/$organizationId/$projectId/dashboards/$dashboardId': typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute '/$organizationId/$projectId/events': typeof AppOrganizationIdProjectIdEventsTabsIndexRoute - '/$organizationId/$projectId/groups/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdRoute '/$organizationId/$projectId/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute '/$organizationId/$projectId/profiles': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute '/$organizationId/$projectId/reports/$reportId': typeof AppOrganizationIdProjectIdReportsReportIdRoute @@ -734,6 +765,7 @@ export interface FileRoutesByTo { '/$organizationId/$projectId/events/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute '/$organizationId/$projectId/events/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute '/$organizationId/$projectId/events/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute + '/$organizationId/$projectId/groups/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute '/$organizationId/$projectId/notifications/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsNotificationsRoute '/$organizationId/$projectId/notifications/rules': typeof AppOrganizationIdProjectIdNotificationsTabsRulesRoute '/$organizationId/$projectId/profiles/$profileId': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute @@ -747,6 +779,8 @@ export interface FileRoutesByTo { '/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute '/$organizationId/$projectId/settings/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute '/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute + '/$organizationId/$projectId/groups/$groupId/events': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute + '/$organizationId/$projectId/groups/$groupId/members': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute '/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute '/$organizationId/$projectId/profiles/$profileId/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute } @@ -798,7 +832,6 @@ export interface FileRoutesById { '/_app/$organizationId/$projectId/dashboards_/$dashboardId': typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute '/_app/$organizationId/$projectId/events': typeof AppOrganizationIdProjectIdEventsRouteWithChildren '/_app/$organizationId/$projectId/events/_tabs': typeof AppOrganizationIdProjectIdEventsTabsRouteWithChildren - '/_app/$organizationId/$projectId/groups_/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdRoute '/_app/$organizationId/$projectId/notifications': typeof AppOrganizationIdProjectIdNotificationsRouteWithChildren '/_app/$organizationId/$projectId/notifications/_tabs': typeof AppOrganizationIdProjectIdNotificationsTabsRouteWithChildren '/_app/$organizationId/$projectId/profiles': typeof AppOrganizationIdProjectIdProfilesRouteWithChildren @@ -818,6 +851,8 @@ export interface FileRoutesById { '/_app/$organizationId/$projectId/events/_tabs/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute '/_app/$organizationId/$projectId/events/_tabs/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute '/_app/$organizationId/$projectId/events/_tabs/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute + '/_app/$organizationId/$projectId/groups_/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdRouteWithChildren + '/_app/$organizationId/$projectId/groups_/$groupId/_tabs': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRouteWithChildren '/_app/$organizationId/$projectId/notifications/_tabs/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsNotificationsRoute '/_app/$organizationId/$projectId/notifications/_tabs/rules': typeof AppOrganizationIdProjectIdNotificationsTabsRulesRoute '/_app/$organizationId/$projectId/profiles/$profileId': typeof AppOrganizationIdProjectIdProfilesProfileIdRouteWithChildren @@ -836,8 +871,11 @@ export interface FileRoutesById { '/_app/$organizationId/$projectId/notifications/_tabs/': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute '/_app/$organizationId/$projectId/profiles/_tabs/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute '/_app/$organizationId/$projectId/settings/_tabs/': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute + '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/events': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute + '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/members': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute + '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute } export interface FileRouteTypes { @@ -881,7 +919,6 @@ export interface FileRouteTypes { | '/$organizationId/$projectId/' | '/$organizationId/$projectId/dashboards/$dashboardId' | '/$organizationId/$projectId/events' - | '/$organizationId/$projectId/groups/$groupId' | '/$organizationId/$projectId/notifications' | '/$organizationId/$projectId/profiles' | '/$organizationId/$projectId/reports/$reportId' @@ -898,6 +935,7 @@ export interface FileRouteTypes { | '/$organizationId/$projectId/events/conversions' | '/$organizationId/$projectId/events/events' | '/$organizationId/$projectId/events/stats' + | '/$organizationId/$projectId/groups/$groupId' | '/$organizationId/$projectId/notifications/notifications' | '/$organizationId/$projectId/notifications/rules' | '/$organizationId/$projectId/profiles/$profileId' @@ -915,8 +953,11 @@ export interface FileRouteTypes { | '/$organizationId/$projectId/notifications/' | '/$organizationId/$projectId/profiles/' | '/$organizationId/$projectId/settings/' + | '/$organizationId/$projectId/groups/$groupId/events' + | '/$organizationId/$projectId/groups/$groupId/members' | '/$organizationId/$projectId/profiles/$profileId/events' | '/$organizationId/$projectId/profiles/$profileId/sessions' + | '/$organizationId/$projectId/groups/$groupId/' | '/$organizationId/$projectId/profiles/$profileId/' fileRoutesByTo: FileRoutesByTo to: @@ -956,7 +997,6 @@ export interface FileRouteTypes { | '/$organizationId/$projectId' | '/$organizationId/$projectId/dashboards/$dashboardId' | '/$organizationId/$projectId/events' - | '/$organizationId/$projectId/groups/$groupId' | '/$organizationId/$projectId/notifications' | '/$organizationId/$projectId/profiles' | '/$organizationId/$projectId/reports/$reportId' @@ -970,6 +1010,7 @@ export interface FileRouteTypes { | '/$organizationId/$projectId/events/conversions' | '/$organizationId/$projectId/events/events' | '/$organizationId/$projectId/events/stats' + | '/$organizationId/$projectId/groups/$groupId' | '/$organizationId/$projectId/notifications/notifications' | '/$organizationId/$projectId/notifications/rules' | '/$organizationId/$projectId/profiles/$profileId' @@ -983,6 +1024,8 @@ export interface FileRouteTypes { | '/$organizationId/$projectId/settings/imports' | '/$organizationId/$projectId/settings/tracking' | '/$organizationId/$projectId/settings/widgets' + | '/$organizationId/$projectId/groups/$groupId/events' + | '/$organizationId/$projectId/groups/$groupId/members' | '/$organizationId/$projectId/profiles/$profileId/events' | '/$organizationId/$projectId/profiles/$profileId/sessions' id: @@ -1033,7 +1076,6 @@ export interface FileRouteTypes { | '/_app/$organizationId/$projectId/dashboards_/$dashboardId' | '/_app/$organizationId/$projectId/events' | '/_app/$organizationId/$projectId/events/_tabs' - | '/_app/$organizationId/$projectId/groups_/$groupId' | '/_app/$organizationId/$projectId/notifications' | '/_app/$organizationId/$projectId/notifications/_tabs' | '/_app/$organizationId/$projectId/profiles' @@ -1053,6 +1095,8 @@ export interface FileRouteTypes { | '/_app/$organizationId/$projectId/events/_tabs/conversions' | '/_app/$organizationId/$projectId/events/_tabs/events' | '/_app/$organizationId/$projectId/events/_tabs/stats' + | '/_app/$organizationId/$projectId/groups_/$groupId' + | '/_app/$organizationId/$projectId/groups_/$groupId/_tabs' | '/_app/$organizationId/$projectId/notifications/_tabs/notifications' | '/_app/$organizationId/$projectId/notifications/_tabs/rules' | '/_app/$organizationId/$projectId/profiles/$profileId' @@ -1071,8 +1115,11 @@ export interface FileRouteTypes { | '/_app/$organizationId/$projectId/notifications/_tabs/' | '/_app/$organizationId/$projectId/profiles/_tabs/' | '/_app/$organizationId/$projectId/settings/_tabs/' + | '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/events' + | '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/members' | '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events' | '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions' + | '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/' | '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/' fileRoutesById: FileRoutesById } @@ -1432,6 +1479,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdRouteImport parentRoute: typeof AppOrganizationIdProjectIdProfilesRoute } + '/_app/$organizationId/$projectId/groups_/$groupId': { + id: '/_app/$organizationId/$projectId/groups_/$groupId' + path: '/groups/$groupId' + fullPath: '/$organizationId/$projectId/groups/$groupId' + preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdRouteImport + parentRoute: typeof AppOrganizationIdProjectIdRoute + } '/_app/$organizationId/profile/_tabs/': { id: '/_app/$organizationId/profile/_tabs/' path: '/' @@ -1523,13 +1577,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppOrganizationIdProjectIdNotificationsTabsRouteImport parentRoute: typeof AppOrganizationIdProjectIdNotificationsRoute } - '/_app/$organizationId/$projectId/groups_/$groupId': { - id: '/_app/$organizationId/$projectId/groups_/$groupId' - path: '/groups/$groupId' - fullPath: '/$organizationId/$projectId/groups/$groupId' - preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdRouteImport - parentRoute: typeof AppOrganizationIdProjectIdRoute - } '/_app/$organizationId/$projectId/events/_tabs': { id: '/_app/$organizationId/$projectId/events/_tabs' path: '/events' @@ -1663,6 +1710,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppOrganizationIdProjectIdNotificationsTabsNotificationsRouteImport parentRoute: typeof AppOrganizationIdProjectIdNotificationsTabsRoute } + '/_app/$organizationId/$projectId/groups_/$groupId/_tabs': { + id: '/_app/$organizationId/$projectId/groups_/$groupId/_tabs' + path: '/groups/$groupId' + fullPath: '/$organizationId/$projectId/groups/$groupId' + preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRouteImport + parentRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdRoute + } '/_app/$organizationId/$projectId/events/_tabs/stats': { id: '/_app/$organizationId/$projectId/events/_tabs/stats' path: '/stats' @@ -1691,6 +1745,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRouteImport parentRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsRoute } + '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/': { + id: '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/' + path: '/' + fullPath: '/$organizationId/$projectId/groups/$groupId/' + preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRouteImport + parentRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRoute + } '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions': { id: '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions' path: '/sessions' @@ -1705,6 +1766,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport parentRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsRoute } + '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/members': { + id: '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/members' + path: '/members' + fullPath: '/$organizationId/$projectId/groups/$groupId/members' + preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRouteImport + parentRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRoute + } + '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/events': { + id: '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/events' + path: '/events' + fullPath: '/$organizationId/$projectId/groups/$groupId/events' + preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRouteImport + parentRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRoute + } } } @@ -1912,6 +1987,42 @@ const AppOrganizationIdProjectIdSettingsRouteWithChildren = AppOrganizationIdProjectIdSettingsRouteChildren, ) +interface AppOrganizationIdProjectIdGroupsGroupIdTabsRouteChildren { + AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute + AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute + AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute +} + +const AppOrganizationIdProjectIdGroupsGroupIdTabsRouteChildren: AppOrganizationIdProjectIdGroupsGroupIdTabsRouteChildren = + { + AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute: + AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute, + AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute: + AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute, + AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute: + AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute, + } + +const AppOrganizationIdProjectIdGroupsGroupIdTabsRouteWithChildren = + AppOrganizationIdProjectIdGroupsGroupIdTabsRoute._addFileChildren( + AppOrganizationIdProjectIdGroupsGroupIdTabsRouteChildren, + ) + +interface AppOrganizationIdProjectIdGroupsGroupIdRouteChildren { + AppOrganizationIdProjectIdGroupsGroupIdTabsRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRouteWithChildren +} + +const AppOrganizationIdProjectIdGroupsGroupIdRouteChildren: AppOrganizationIdProjectIdGroupsGroupIdRouteChildren = + { + AppOrganizationIdProjectIdGroupsGroupIdTabsRoute: + AppOrganizationIdProjectIdGroupsGroupIdTabsRouteWithChildren, + } + +const AppOrganizationIdProjectIdGroupsGroupIdRouteWithChildren = + AppOrganizationIdProjectIdGroupsGroupIdRoute._addFileChildren( + AppOrganizationIdProjectIdGroupsGroupIdRouteChildren, + ) + interface AppOrganizationIdProjectIdRouteChildren { AppOrganizationIdProjectIdChatRoute: typeof AppOrganizationIdProjectIdChatRoute AppOrganizationIdProjectIdDashboardsRoute: typeof AppOrganizationIdProjectIdDashboardsRoute @@ -1926,12 +2037,12 @@ interface AppOrganizationIdProjectIdRouteChildren { AppOrganizationIdProjectIdIndexRoute: typeof AppOrganizationIdProjectIdIndexRoute AppOrganizationIdProjectIdDashboardsDashboardIdRoute: typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute AppOrganizationIdProjectIdEventsRoute: typeof AppOrganizationIdProjectIdEventsRouteWithChildren - AppOrganizationIdProjectIdGroupsGroupIdRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdRoute AppOrganizationIdProjectIdNotificationsRoute: typeof AppOrganizationIdProjectIdNotificationsRouteWithChildren AppOrganizationIdProjectIdProfilesRoute: typeof AppOrganizationIdProjectIdProfilesRouteWithChildren AppOrganizationIdProjectIdReportsReportIdRoute: typeof AppOrganizationIdProjectIdReportsReportIdRoute AppOrganizationIdProjectIdSessionsSessionIdRoute: typeof AppOrganizationIdProjectIdSessionsSessionIdRoute AppOrganizationIdProjectIdSettingsRoute: typeof AppOrganizationIdProjectIdSettingsRouteWithChildren + AppOrganizationIdProjectIdGroupsGroupIdRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdRouteWithChildren } const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteChildren = @@ -1958,8 +2069,6 @@ const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteCh AppOrganizationIdProjectIdDashboardsDashboardIdRoute, AppOrganizationIdProjectIdEventsRoute: AppOrganizationIdProjectIdEventsRouteWithChildren, - AppOrganizationIdProjectIdGroupsGroupIdRoute: - AppOrganizationIdProjectIdGroupsGroupIdRoute, AppOrganizationIdProjectIdNotificationsRoute: AppOrganizationIdProjectIdNotificationsRouteWithChildren, AppOrganizationIdProjectIdProfilesRoute: @@ -1970,6 +2079,8 @@ const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteCh AppOrganizationIdProjectIdSessionsSessionIdRoute, AppOrganizationIdProjectIdSettingsRoute: AppOrganizationIdProjectIdSettingsRouteWithChildren, + AppOrganizationIdProjectIdGroupsGroupIdRoute: + AppOrganizationIdProjectIdGroupsGroupIdRouteWithChildren, } const AppOrganizationIdProjectIdRouteWithChildren = diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.groups.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.groups.tsx index b3180b10..f685db87 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.groups.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.groups.tsx @@ -1,11 +1,12 @@ import { keepPreviousData, useQuery } from '@tanstack/react-query'; -import { createFileRoute, Link } from '@tanstack/react-router'; -import { Building2Icon } from 'lucide-react'; +import { createFileRoute } from '@tanstack/react-router'; +import { PlusIcon } from 'lucide-react'; import { parseAsString, useQueryState } from 'nuqs'; +import { GroupsTable } from '@/components/groups/table'; import { PageContainer } from '@/components/page-container'; import { PageHeader } from '@/components/page-header'; -import { Badge } from '@/components/ui/badge'; -import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { useDataTablePagination } from '@/components/ui/data-table/data-table-hooks'; import { Select, SelectContent, @@ -15,9 +16,11 @@ import { } from '@/components/ui/select'; import { useSearchQueryState } from '@/hooks/use-search-query-state'; import { useTRPC } from '@/integrations/trpc/react'; -import { formatDateTime } from '@/utils/date'; +import { pushModal } from '@/modals'; import { createProjectTitle } from '@/utils/title'; +const PAGE_SIZE = 50; + export const Route = createFileRoute('/_app/$organizationId/$projectId/groups')( { component: Component, @@ -28,13 +31,14 @@ export const Route = createFileRoute('/_app/$organizationId/$projectId/groups')( ); function Component() { - const { projectId, organizationId } = Route.useParams(); + const { projectId } = Route.useParams(); const trpc = useTRPC(); - const { search, setSearch, debouncedSearch } = useSearchQueryState(); + const { debouncedSearch } = useSearchQueryState(); const [typeFilter, setTypeFilter] = useQueryState( 'type', parseAsString.withDefault('') ); + const { page } = useDataTablePagination(PAGE_SIZE); const typesQuery = useQuery(trpc.group.types.queryOptions({ projectId })); @@ -44,103 +48,53 @@ function Component() { projectId, search: debouncedSearch || undefined, type: typeFilter || undefined, - take: 100, + take: PAGE_SIZE, + cursor: (page - 1) * PAGE_SIZE, }, { placeholderData: keepPreviousData } ) ); - const groups = groupsQuery.data?.data ?? []; const types = typesQuery.data ?? []; return ( pushModal('AddGroup')}> + + Add group + + } + className="mb-8" description="Groups represent companies, teams, or other entities that events belong to." title="Groups" /> -
- setSearch(e.target.value)} - placeholder="Search groups..." - value={search} - /> - {types.length > 0 && ( - - )} -
- - {groups.length === 0 ? ( -
- -

No groups found

-
- ) : ( -
- - - - - - - - - - - {groups.map((group) => ( - - - - - - - ))} - -
- Name - - ID - - Type - - Created -
- - {group.name} - - - {group.id} - - {group.type} - - {formatDateTime(new Date(group.createdAt))} -
-
- )} + 0 ? ( + + ) : null + } + />
); } diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.events.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.events.tsx new file mode 100644 index 00000000..6ca89c85 --- /dev/null +++ b/apps/start/src/routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.events.tsx @@ -0,0 +1,46 @@ +import { EventsTable } from '@/components/events/table'; +import { useReadColumnVisibility } from '@/components/ui/data-table/data-table-hooks'; +import { useEventQueryNamesFilter } from '@/hooks/use-event-query-filters'; +import { useTRPC } from '@/integrations/trpc/react'; +import { createProjectTitle } from '@/utils/title'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { createFileRoute } from '@tanstack/react-router'; +import { parseAsIsoDateTime, useQueryState } from 'nuqs'; + +export const Route = createFileRoute( + '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/events' +)({ + component: Component, + head: () => ({ + meta: [{ title: createProjectTitle('Group events') }], + }), +}); + +function Component() { + const { projectId, groupId } = Route.useParams(); + const trpc = useTRPC(); + const [startDate] = useQueryState('startDate', parseAsIsoDateTime); + const [endDate] = useQueryState('endDate', parseAsIsoDateTime); + const [eventNames] = useEventQueryNamesFilter(); + const columnVisibility = useReadColumnVisibility('events'); + + const query = useInfiniteQuery( + trpc.event.events.infiniteQueryOptions( + { + projectId, + groupId, + filters: [], // Always scope to group only; date + event names from toolbar still apply + startDate: startDate || undefined, + endDate: endDate || undefined, + events: eventNames, + columnVisibility: columnVisibility ?? {}, + }, + { + enabled: columnVisibility !== null, + getNextPageParam: (lastPage) => lastPage.meta.next, + } + ) + ); + + return ; +} diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.index.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.index.tsx new file mode 100644 index 00000000..e4ddbd4c --- /dev/null +++ b/apps/start/src/routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.index.tsx @@ -0,0 +1,208 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; +import { createFileRoute, Link } from '@tanstack/react-router'; +import { UsersIcon } from 'lucide-react'; +import FullPageLoadingState from '@/components/full-page-loading-state'; +import { OverviewMetricCard } from '@/components/overview/overview-metric-card'; +import { WidgetHead, WidgetTitle } from '@/components/overview/overview-widget'; +import { ProfileActivity } from '@/components/profiles/profile-activity'; +import { KeyValueGrid } from '@/components/ui/key-value-grid'; +import { Widget, WidgetBody } from '@/components/widget'; +import { WidgetTable } from '@/components/widget-table'; +import { useTRPC } from '@/integrations/trpc/react'; +import { formatDateTime, formatTimeAgoOrDateTime } from '@/utils/date'; +import { createProjectTitle } from '@/utils/title'; + +const MEMBERS_PREVIEW_LIMIT = 13; + +export const Route = createFileRoute( + '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/' +)({ + component: Component, + loader: async ({ context, params }) => { + await Promise.all([ + context.queryClient.prefetchQuery( + context.trpc.group.activity.queryOptions({ + id: params.groupId, + projectId: params.projectId, + }) + ), + ]); + }, + pendingComponent: FullPageLoadingState, + head: () => ({ + meta: [{ title: createProjectTitle('Group') }], + }), +}); + +function Component() { + const { projectId, organizationId, groupId } = Route.useParams(); + const trpc = useTRPC(); + + const group = useSuspenseQuery( + trpc.group.byId.queryOptions({ id: groupId, projectId }) + ); + const metrics = useSuspenseQuery( + trpc.group.metrics.queryOptions({ id: groupId, projectId }) + ); + const activity = useSuspenseQuery( + trpc.group.activity.queryOptions({ id: groupId, projectId }) + ); + const members = useSuspenseQuery( + trpc.group.members.queryOptions({ id: groupId, projectId }) + ); + + const g = group.data; + const m = metrics.data?.[0]; + + if (!g) { + return null; + } + + const properties = g.properties as Record; + + return ( +
+ {/* Metrics */} + {m && ( +
+
+ + + + +
+
+ )} + + {/* Properties */} +
+ + +
Group Information
+
+ v !== undefined && v !== '') + .map(([k, v]) => ({ + name: k, + value: String(v), + })), + ]} + /> +
+
+ + {/* Activity heatmap */} +
+ +
+ + {/* Members preview */} +
+ + + Members + + + {members.data.length === 0 ? ( +

+ No members found +

+ ) : ( + ( + + {member.profileId} + + ), + }, + { + key: 'events', + name: 'Events', + width: '60px', + className: 'text-muted-foreground', + render: (member) => member.eventCount, + }, + { + key: 'lastSeen', + name: 'Last Seen', + width: '150px', + className: 'text-muted-foreground', + render: (member) => + formatTimeAgoOrDateTime(new Date(member.lastSeen)), + }, + ]} + data={members.data.slice(0, MEMBERS_PREVIEW_LIMIT)} + keyExtractor={(member) => member.profileId} + /> + )} + {members.data.length > MEMBERS_PREVIEW_LIMIT && ( +

+ {`${members.data.length} members found. View all in Members tab`} +

+ )} +
+
+
+
+ ); +} diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.members.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.members.tsx new file mode 100644 index 00000000..12468c40 --- /dev/null +++ b/apps/start/src/routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.members.tsx @@ -0,0 +1,42 @@ +import { ProfilesTable } from '@/components/profiles/table'; +import { useDataTablePagination } from '@/components/ui/data-table/data-table-hooks'; +import { useSearchQueryState } from '@/hooks/use-search-query-state'; +import { useTRPC } from '@/integrations/trpc/react'; +import { createProjectTitle } from '@/utils/title'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute( + '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/members' +)({ + component: Component, + head: () => ({ + meta: [{ title: createProjectTitle('Group members') }], + }), +}); + +function Component() { + const { projectId, groupId } = Route.useParams(); + const trpc = useTRPC(); + const { debouncedSearch } = useSearchQueryState(); + const { page } = useDataTablePagination(50); + + const query = useQuery({ + ...trpc.group.listProfiles.queryOptions({ + projectId, + groupId, + cursor: page - 1, + take: 50, + search: debouncedSearch || undefined, + }), + placeholderData: keepPreviousData, + }); + + return ( + [0]['query']} + type="profiles" + /> + ); +} diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.tsx new file mode 100644 index 00000000..3ba26ac4 --- /dev/null +++ b/apps/start/src/routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.tsx @@ -0,0 +1,161 @@ +import { + useMutation, + useQueryClient, + useSuspenseQuery, +} from '@tanstack/react-query'; +import { + createFileRoute, + Outlet, + useNavigate, + useRouter, +} from '@tanstack/react-router'; +import { Building2Icon, PencilIcon, Trash2Icon } from 'lucide-react'; +import FullPageLoadingState from '@/components/full-page-loading-state'; +import { PageContainer } from '@/components/page-container'; +import { PageHeader } from '@/components/page-header'; +import { Button } from '@/components/ui/button'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { usePageTabs } from '@/hooks/use-page-tabs'; +import { handleError, useTRPC } from '@/integrations/trpc/react'; +import { pushModal, showConfirm } from '@/modals'; +import { createProjectTitle } from '@/utils/title'; + +export const Route = createFileRoute( + '/_app/$organizationId/$projectId/groups_/$groupId/_tabs' +)({ + component: Component, + loader: async ({ context, params }) => { + await Promise.all([ + context.queryClient.prefetchQuery( + context.trpc.group.byId.queryOptions({ + id: params.groupId, + projectId: params.projectId, + }) + ), + context.queryClient.prefetchQuery( + context.trpc.group.metrics.queryOptions({ + id: params.groupId, + projectId: params.projectId, + }) + ), + ]); + }, + pendingComponent: FullPageLoadingState, + head: () => ({ + meta: [{ title: createProjectTitle('Group') }], + }), +}); + +function Component() { + const router = useRouter(); + const { projectId, organizationId, groupId } = Route.useParams(); + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + + const group = useSuspenseQuery( + trpc.group.byId.queryOptions({ id: groupId, projectId }) + ); + + const deleteMutation = useMutation( + trpc.group.delete.mutationOptions({ + onSuccess() { + queryClient.invalidateQueries(trpc.group.list.pathFilter()); + navigate({ + to: '/$organizationId/$projectId/groups', + params: { organizationId, projectId }, + }); + }, + onError: handleError, + }) + ); + + const { activeTab, tabs } = usePageTabs([ + { id: '/$organizationId/$projectId/groups/$groupId', label: 'Overview' }, + { id: 'members', label: 'Members' }, + { id: 'events', label: 'Events' }, + ]); + + const handleTabChange = (tabId: string) => { + router.navigate({ + from: Route.fullPath, + to: tabId, + }); + }; + + const g = group.data; + + if (!g) { + return ( + +
+ +

Group not found

+
+
+ ); + } + + return ( + + + + +
+ } + title={ +
+ + {g.name} +
+ } + /> + + + + {tabs.map((tab) => ( + + {tab.label} + + ))} + + + + + ); +} diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.groups_.$groupId.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.groups_.$groupId.tsx deleted file mode 100644 index 4edde3ed..00000000 --- a/apps/start/src/routes/_app.$organizationId.$projectId.groups_.$groupId.tsx +++ /dev/null @@ -1,244 +0,0 @@ -import { useSuspenseQuery } from '@tanstack/react-query'; -import { createFileRoute, Link } from '@tanstack/react-router'; -import { Building2Icon, UsersIcon } from 'lucide-react'; -import FullPageLoadingState from '@/components/full-page-loading-state'; -import { OverviewMetricCard } from '@/components/overview/overview-metric-card'; -import { WidgetHead, WidgetTitle } from '@/components/overview/overview-widget'; -import { PageContainer } from '@/components/page-container'; -import { PageHeader } from '@/components/page-header'; -import { ProfileActivity } from '@/components/profiles/profile-activity'; -import { Badge } from '@/components/ui/badge'; -import { KeyValueGrid } from '@/components/ui/key-value-grid'; -import { Widget, WidgetBody } from '@/components/widget'; -import { useTRPC } from '@/integrations/trpc/react'; -import { formatDateTime } from '@/utils/date'; -import { createProjectTitle } from '@/utils/title'; - -export const Route = createFileRoute( - '/_app/$organizationId/$projectId/groups_/$groupId' -)({ - component: Component, - loader: async ({ context, params }) => { - await Promise.all([ - context.queryClient.prefetchQuery( - context.trpc.group.byId.queryOptions({ - id: params.groupId, - projectId: params.projectId, - }) - ), - context.queryClient.prefetchQuery( - context.trpc.group.metrics.queryOptions({ - id: params.groupId, - projectId: params.projectId, - }) - ), - context.queryClient.prefetchQuery( - context.trpc.group.activity.queryOptions({ - id: params.groupId, - projectId: params.projectId, - }) - ), - context.queryClient.prefetchQuery( - context.trpc.group.members.queryOptions({ - id: params.groupId, - projectId: params.projectId, - }) - ), - ]); - }, - pendingComponent: FullPageLoadingState, - head: () => ({ - meta: [{ title: createProjectTitle('Group') }], - }), -}); - -function Component() { - const { projectId, organizationId, groupId } = Route.useParams(); - const trpc = useTRPC(); - - const group = useSuspenseQuery( - trpc.group.byId.queryOptions({ id: groupId, projectId }) - ); - - const metrics = useSuspenseQuery( - trpc.group.metrics.queryOptions({ id: groupId, projectId }) - ); - - const activity = useSuspenseQuery( - trpc.group.activity.queryOptions({ id: groupId, projectId }) - ); - - const members = useSuspenseQuery( - trpc.group.members.queryOptions({ id: groupId, projectId }) - ); - - const g = group.data; - const m = metrics.data?.[0]; - - if (!g) { - return ( - -
- -

Group not found

-
-
- ); - } - - const properties = g.properties as Record; - - return ( - - - - {g.name} - - {g.type} - -
- } - > -

{g.id}

- - -
- {/* Metrics */} - {m && ( -
-
- - - - -
-
- )} - - {/* Properties */} -
- - -
Group Information
-
- v !== undefined && v !== '') - .map(([k, v]) => ({ - name: k, - value: String(v), - })), - ]} - /> -
-
- - {/* Activity heatmap */} -
- -
- - {/* Members */} -
- - - Members - - - {members.data.length === 0 ? ( -

- No members found -

- ) : ( - - - - - - - - - {members.data.map((member) => ( - - - - - ))} - -
- Profile - - Events -
- - {member.profileId} - - - {member.eventCount} -
- )} -
-
-
-
- - ); -} diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.index.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.index.tsx index 77d75000..44fca842 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.index.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.index.tsx @@ -4,6 +4,7 @@ import { MostEvents } from '@/components/profiles/most-events'; import { PopularRoutes } from '@/components/profiles/popular-routes'; import { ProfileActivity } from '@/components/profiles/profile-activity'; import { ProfileCharts } from '@/components/profiles/profile-charts'; +import { ProfileGroups } from '@/components/profiles/profile-groups'; import { ProfileMetrics } from '@/components/profiles/profile-metrics'; import { ProfileProperties } from '@/components/profiles/profile-properties'; import { useTRPC } from '@/integrations/trpc/react'; @@ -107,6 +108,17 @@ function Component() { + {/* Groups - full width, only if profile belongs to groups */} + {profile.data?.groups?.length ? ( +
+ +
+ ) : null} + {/* Heatmap / Activity */}
diff --git a/apps/testbed/.gitignore b/apps/testbed/.gitignore new file mode 100644 index 00000000..f2770ead --- /dev/null +++ b/apps/testbed/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +public/op1.js +.env diff --git a/apps/testbed/index.html b/apps/testbed/index.html new file mode 100644 index 00000000..41a794b6 --- /dev/null +++ b/apps/testbed/index.html @@ -0,0 +1,12 @@ + + + + + + Testbed | OpenPanel SDK + + +
+ + + diff --git a/apps/testbed/package.json b/apps/testbed/package.json new file mode 100644 index 00000000..73515a91 --- /dev/null +++ b/apps/testbed/package.json @@ -0,0 +1,24 @@ +{ + "name": "@openpanel/testbed", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --port 3100", + "build": "tsc && vite build", + "postinstall": "node scripts/copy-op1.mjs" + }, + "dependencies": { + "@openpanel/web": "workspace:*", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.13.1" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "catalog:", + "vite": "^6.0.0" + } +} diff --git a/apps/testbed/scripts/copy-op1.mjs b/apps/testbed/scripts/copy-op1.mjs new file mode 100644 index 00000000..ace0b4c7 --- /dev/null +++ b/apps/testbed/scripts/copy-op1.mjs @@ -0,0 +1,16 @@ +import { copyFileSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const src = join(__dirname, '../../public/public/op1.js'); +const dest = join(__dirname, '../public/op1.js'); + +mkdirSync(join(__dirname, '../public'), { recursive: true }); + +try { + copyFileSync(src, dest); + console.log('✓ Copied op1.js to public/'); +} catch (e) { + console.warn('⚠ Could not copy op1.js:', e.message); +} diff --git a/apps/testbed/src/App.tsx b/apps/testbed/src/App.tsx new file mode 100644 index 00000000..db91fe87 --- /dev/null +++ b/apps/testbed/src/App.tsx @@ -0,0 +1,218 @@ +import { useEffect, useState } from 'react'; +import { Link, Route, Routes, useNavigate } from 'react-router-dom'; +import { op } from './analytics'; +import { CartPage } from './pages/Cart'; +import { CheckoutPage } from './pages/Checkout'; +import { LoginPage, PRESET_GROUPS } from './pages/Login'; +import { ProductPage } from './pages/Product'; +import { ShopPage } from './pages/Shop'; +import type { CartItem, Product, User } from './types'; + +const PRODUCTS: Product[] = [ + { id: 'p1', name: 'Classic T-Shirt', price: 25, category: 'clothing' }, + { id: 'p2', name: 'Coffee Mug', price: 15, category: 'accessories' }, + { id: 'p3', name: 'Hoodie', price: 60, category: 'clothing' }, + { id: 'p4', name: 'Sticker Pack', price: 10, category: 'accessories' }, + { id: 'p5', name: 'Cap', price: 35, category: 'clothing' }, +]; + +export default function App() { + const navigate = useNavigate(); + const [cart, setCart] = useState([]); + const [user, setUser] = useState(null); + + useEffect(() => { + const stored = localStorage.getItem('op_testbed_user'); + if (stored) { + const u = JSON.parse(stored) as User; + setUser(u); + op.identify({ + profileId: u.id, + firstName: u.firstName, + lastName: u.lastName, + email: u.email, + }); + applyGroups(u); + } + op.ready(); + }, []); + + function applyGroups(u: User) { + op.setGroups(u.groupIds); + for (const id of u.groupIds) { + const meta = PRESET_GROUPS.find((g) => g.id === id); + console.log('meta', meta); + if (meta) { + op.setGroup(id, meta); + } + } + } + + function login(u: User) { + localStorage.setItem('op_testbed_user', JSON.stringify(u)); + setUser(u); + op.identify({ + profileId: u.id, + firstName: u.firstName, + lastName: u.lastName, + email: u.email, + }); + applyGroups(u); + op.track('user_login', { method: 'form', group_count: u.groupIds.length }); + navigate('/'); + } + + function logout() { + localStorage.removeItem('op_testbed_user'); + op.clear(); + setUser(null); + } + + function addToCart(product: Product) { + setCart((prev) => { + const existing = prev.find((i) => i.id === product.id); + if (existing) { + return prev.map((i) => + i.id === product.id ? { ...i, qty: i.qty + 1 } : i + ); + } + return [...prev, { ...product, qty: 1 }]; + }); + op.track('add_to_cart', { + product_id: product.id, + product_name: product.name, + price: product.price, + category: product.category, + }); + } + + function removeFromCart(id: string) { + const item = cart.find((i) => i.id === id); + if (item) { + op.track('remove_from_cart', { + product_id: item.id, + product_name: item.name, + }); + } + setCart((prev) => prev.filter((i) => i.id !== id)); + } + + function startCheckout() { + const total = cart.reduce((sum, i) => sum + i.price * i.qty, 0); + op.track('checkout_started', { + total, + item_count: cart.reduce((sum, i) => sum + i.qty, 0), + items: cart.map((i) => i.id), + }); + navigate('/checkout'); + } + + function pay(succeed: boolean) { + const total = cart.reduce((sum, i) => sum + i.price * i.qty, 0); + op.track('payment_attempted', { total, success: succeed }); + + if (succeed) { + op.revenue(total, { + items: cart.map((i) => i.id), + item_count: cart.reduce((sum, i) => sum + i.qty, 0), + }); + op.track('purchase_completed', { total }); + setCart([]); + navigate('/success'); + } else { + op.track('purchase_failed', { total, reason: 'declined' }); + navigate('/error'); + } + } + + const cartCount = cart.reduce((sum, i) => sum + i.qty, 0); + + return ( +
+ + +
+ + } + path="/" + /> + + } + path="/product/:id" + /> + } path="/login" /> + + } + path="/cart" + /> + } + path="/checkout" + /> + +
[OK]
+
Payment successful
+

Your order has been placed. Thanks for testing!

+
+ + + +
+
+ } + path="/success" + /> + +
[ERR]
+
Payment failed
+

Card declined. Try again or go back to cart.

+
+ + + + + + +
+
+ } + path="/error" + /> + + + + ); +} diff --git a/apps/testbed/src/analytics.ts b/apps/testbed/src/analytics.ts new file mode 100644 index 00000000..f522971a --- /dev/null +++ b/apps/testbed/src/analytics.ts @@ -0,0 +1,10 @@ +import { OpenPanel } from '@openpanel/web'; + +export const op = new OpenPanel({ + clientId: import.meta.env.VITE_OPENPANEL_CLIENT_ID ?? 'testbed-client', + apiUrl: import.meta.env.VITE_OPENPANEL_API_URL ?? 'http://localhost:3333', + trackScreenViews: true, + trackOutgoingLinks: true, + trackAttributes: true, + disabled: true, +}); diff --git a/apps/testbed/src/main.tsx b/apps/testbed/src/main.tsx new file mode 100644 index 00000000..227a6902 --- /dev/null +++ b/apps/testbed/src/main.tsx @@ -0,0 +1,10 @@ +import { createRoot } from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import App from './App'; +import './styles.css'; + +createRoot(document.getElementById('root')!).render( + + + +); diff --git a/apps/testbed/src/pages/Cart.tsx b/apps/testbed/src/pages/Cart.tsx new file mode 100644 index 00000000..f1c9983c --- /dev/null +++ b/apps/testbed/src/pages/Cart.tsx @@ -0,0 +1,63 @@ +import { Link } from 'react-router-dom'; +import type { CartItem } from '../types'; + +type Props = { + cart: CartItem[]; + onRemove: (id: string) => void; + onCheckout: () => void; +}; + +export function CartPage({ cart, onRemove, onCheckout }: Props) { + const total = cart.reduce((sum, i) => sum + i.price * i.qty, 0); + + if (cart.length === 0) { + return ( +
+
Cart
+
Your cart is empty.
+ +
+ ); + } + + return ( +
+
Cart
+ + + + + + + + + + + + {cart.map((item) => ( + + + + + + + + ))} + +
ProductPriceQtySubtotal
{item.name}${item.price}{item.qty}${item.price * item.qty} + +
+
+
Total: ${total}
+
+ + +
+
+
+ ); +} diff --git a/apps/testbed/src/pages/Checkout.tsx b/apps/testbed/src/pages/Checkout.tsx new file mode 100644 index 00000000..94c9ecb1 --- /dev/null +++ b/apps/testbed/src/pages/Checkout.tsx @@ -0,0 +1,43 @@ +import { Link } from 'react-router-dom'; +import type { CartItem } from '../types'; + +type Props = { + cart: CartItem[]; + onPay: (succeed: boolean) => void; +}; + +export function CheckoutPage({ cart, onPay }: Props) { + const total = cart.reduce((sum, i) => sum + i.price * i.qty, 0); + + return ( +
+
Checkout
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
Total: ${total}
+
+ + + +
+
+
+ ); +} diff --git a/apps/testbed/src/pages/Login.tsx b/apps/testbed/src/pages/Login.tsx new file mode 100644 index 00000000..ec5b2908 --- /dev/null +++ b/apps/testbed/src/pages/Login.tsx @@ -0,0 +1,186 @@ +import { useState } from 'react'; +import type { Group, User } from '../types'; + +export const PRESET_GROUPS: Group[] = [ + { + type: 'company', + id: 'grp_acme', + name: 'Acme Corp', + properties: { plan: 'enterprise' }, + }, + { + type: 'company', + id: 'grp_globex', + name: 'Globex', + properties: { plan: 'pro' }, + }, + { + type: 'company', + id: 'grp_initech', + name: 'Initech', + properties: { plan: 'pro' }, + }, + { + type: 'company', + id: 'grp_umbrella', + name: 'Umbrella Ltd', + properties: { plan: 'enterprise' }, + }, + { + type: 'company', + id: 'grp_stark', + name: 'Stark Industries', + properties: { plan: 'enterprise' }, + }, + { + type: 'company', + id: 'grp_wayne', + name: 'Wayne Enterprises', + properties: { plan: 'pro' }, + }, + { + type: 'company', + id: 'grp_dunder', + name: 'Dunder Mifflin', + properties: { plan: 'free' }, + }, + { + type: 'company', + id: 'grp_pied', + name: 'Pied Piper', + properties: { plan: 'free' }, + }, + { + type: 'company', + id: 'grp_hooli', + name: 'Hooli', + properties: { plan: 'pro' }, + }, + { + type: 'company', + id: 'grp_vandelay', + name: 'Vandelay Industries', + properties: { plan: 'free' }, + }, +]; + +const FIRST_NAMES = ['Alice', 'Bob', 'Carol', 'Dave', 'Eve', 'Frank', 'Grace', 'Hank', 'Iris', 'Jack']; +const LAST_NAMES = ['Smith', 'Jones', 'Brown', 'Taylor', 'Wilson', 'Davis', 'Clark', 'Hall', 'Lewis', 'Young']; + +function randomMock(): User { + const first = FIRST_NAMES[Math.floor(Math.random() * FIRST_NAMES.length)]; + const last = LAST_NAMES[Math.floor(Math.random() * LAST_NAMES.length)]; + const id = Math.random().toString(36).slice(2, 8); + return { + id: `usr_${id}`, + firstName: first, + lastName: last, + email: `${first.toLowerCase()}.${last.toLowerCase()}@example.com`, + groupIds: [], + }; +} + +type Props = { + onLogin: (user: User) => void; +}; + +export function LoginPage({ onLogin }: Props) { + const [form, setForm] = useState(randomMock); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + onLogin(form); + } + + function set(field: keyof User, value: string) { + setForm((prev) => ({ ...prev, [field]: value })); + } + + function toggleGroup(id: string) { + setForm((prev) => ({ + ...prev, + groupIds: prev.groupIds.includes(id) + ? prev.groupIds.filter((g) => g !== id) + : [...prev.groupIds, id], + })); + } + + return ( +
+
Login
+
+
+ + set('id', e.target.value)} + required + value={form.id} + /> +
+
+ + set('firstName', e.target.value)} + required + value={form.firstName} + /> +
+
+ + set('lastName', e.target.value)} + required + value={form.lastName} + /> +
+
+ + set('email', e.target.value)} + required + type="email" + value={form.email} + /> +
+ +
+
+ Groups (optional) +
+
+ {PRESET_GROUPS.map((group) => { + const selected = form.groupIds.includes(group.id); + return ( + + ); + })} +
+
+ + +
+
+ ); +} diff --git a/apps/testbed/src/pages/Product.tsx b/apps/testbed/src/pages/Product.tsx new file mode 100644 index 00000000..9eb8d6d3 --- /dev/null +++ b/apps/testbed/src/pages/Product.tsx @@ -0,0 +1,61 @@ +import { useEffect } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { op } from '../analytics'; +import type { Product } from '../types'; + +type Props = { + products: Product[]; + onAddToCart: (product: Product) => void; +}; + +export function ProductPage({ products, onAddToCart }: Props) { + const { id } = useParams<{ id: string }>(); + const product = products.find((p) => p.id === id); + + useEffect(() => { + if (product) { + op.track('product_viewed', { + product_id: product.id, + product_name: product.name, + price: product.price, + category: product.category, + }); + } + }, [product]); + + if (!product) { + return ( +
+
Product not found
+ +
+ ); + } + + return ( +
+
+ ← Back to shop +
+
+
[img]
+
+
{product.category}
+
{product.name}
+
${product.price}
+

+ A high quality {product.name.toLowerCase()} for testing purposes. + Lorem ipsum dolor sit amet consectetur adipiscing elit. +

+ +
+
+
+ ); +} diff --git a/apps/testbed/src/pages/Shop.tsx b/apps/testbed/src/pages/Shop.tsx new file mode 100644 index 00000000..2a07027f --- /dev/null +++ b/apps/testbed/src/pages/Shop.tsx @@ -0,0 +1,36 @@ +import { Link } from 'react-router-dom'; +import type { Product } from '../types'; + +type Props = { + products: Product[]; + onAddToCart: (product: Product) => void; +}; + +export function ShopPage({ products, onAddToCart }: Props) { + return ( +
+
Products
+
+ {products.map((product) => ( +
+
{product.category}
+ + {product.name} + +
${product.price}
+
+ +
+
+ ))} +
+
+ ); +} diff --git a/apps/testbed/src/styles.css b/apps/testbed/src/styles.css new file mode 100644 index 00000000..ba8857d8 --- /dev/null +++ b/apps/testbed/src/styles.css @@ -0,0 +1,358 @@ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --border: 1px solid #999; + --bg: #f5f5f5; + --surface: #fff; + --text: #111; + --muted: #666; + --accent: #1a1a1a; + --gap: 16px; +} + +body { + font-family: monospace; + font-size: 14px; + background: var(--bg); + color: var(--text); + line-height: 1.5; +} + +button, input, select { + font-family: monospace; + font-size: 14px; +} + +button { + cursor: pointer; + border: var(--border); + background: var(--surface); + padding: 6px 14px; +} + +button:hover { + background: var(--accent); + color: #fff; +} + +button.primary { + background: var(--accent); + color: #fff; +} + +button.primary:hover { + opacity: 0.85; +} + +button.danger { + border-color: #c00; + color: #c00; +} + +button.danger:hover { + background: #c00; + color: #fff; +} + +input { + border: var(--border); + background: var(--surface); + padding: 6px 10px; + width: 100%; +} + +input:focus { + outline: 2px solid var(--accent); +} + +/* Layout */ + +.app { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.nav { + border-bottom: var(--border); + padding: 12px var(--gap); + background: var(--surface); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--gap); +} + +.nav-brand { + font-weight: bold; + font-size: 16px; + cursor: pointer; + letter-spacing: 1px; + text-decoration: none; + color: inherit; +} + +.nav-links a { + color: inherit; + text-decoration: underline; +} + +.nav-links a:hover { + color: var(--muted); +} + +.nav-links { + display: flex; + align-items: center; + gap: 16px; +} + +.nav-links span { + cursor: default; +} + +.nav-user { + text-decoration: none !important; + cursor: default !important; + color: var(--muted); +} + +.main { + flex: 1; + padding: var(--gap); + max-width: 900px; + margin: 0 auto; + width: 100%; +} + +/* Page common */ + +.page-title { + font-size: 20px; + font-weight: bold; + margin-bottom: 20px; + padding-bottom: 8px; + border-bottom: var(--border); +} + +/* Shop */ + +.product-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: var(--gap); +} + +.product-card { + border: var(--border); + background: var(--surface); + padding: var(--gap); + display: flex; + flex-direction: column; + gap: 8px; +} + +.product-card-name { + font-weight: bold; +} + +.product-card-category { + color: var(--muted); + font-size: 12px; +} + +.product-card-price { + font-size: 16px; +} + +.product-card-actions { + margin-top: auto; +} + +/* Cart */ + +.cart-empty { + color: var(--muted); + padding: 40px 0; + text-align: center; +} + +.cart-table { + width: 100%; + border-collapse: collapse; + margin-bottom: var(--gap); +} + +.cart-table th, +.cart-table td { + border: var(--border); + padding: 8px 12px; + text-align: left; +} + +.cart-table th { + background: var(--bg); +} + +.cart-summary { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 0; + border-top: var(--border); + margin-top: 8px; +} + +.cart-total { + font-size: 16px; + font-weight: bold; +} + +.cart-actions { + display: flex; + gap: 8px; +} + +/* Checkout */ + +.checkout-form { + border: var(--border); + background: var(--surface); + padding: var(--gap); + max-width: 400px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 12px; +} + +.form-label { + font-size: 12px; + color: var(--muted); + text-transform: uppercase; +} + +.checkout-total { + margin: 16px 0; + padding: 12px; + border: var(--border); + background: var(--bg); + font-weight: bold; +} + +.checkout-pay-buttons { + display: flex; + gap: 8px; + margin-top: 16px; +} + +/* Login */ + +.login-form { + border: var(--border); + background: var(--surface); + padding: var(--gap); + max-width: 360px; +} + +/* Product detail */ + +.product-detail { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 32px; + max-width: 700px; +} + +.product-detail-img { + border: var(--border); + background: var(--surface); + aspect-ratio: 1; + display: flex; + align-items: center; + justify-content: center; + font-size: 48px; + color: var(--muted); +} + +.product-detail-info { + display: flex; + flex-direction: column; + gap: 12px; +} + +.product-detail-name { + font-size: 22px; + font-weight: bold; +} + +.product-detail-price { + font-size: 20px; +} + +.product-detail-desc { + color: var(--muted); + line-height: 1.6; +} + +.product-card-name { + font-weight: bold; + color: inherit; +} + +/* Group picker */ + +.group-picker { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.group-picker button { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + font-size: 13px; +} + +.group-plan { + font-size: 11px; + opacity: 0.6; + border-left: 1px solid currentColor; + padding-left: 6px; +} + +/* Result pages */ + +.result-page { + text-align: center; + padding: 60px 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; +} + +.result-icon { + font-size: 48px; + line-height: 1; +} + +.result-title { + font-size: 22px; + font-weight: bold; +} + +.result-actions { + display: flex; + gap: 8px; + margin-top: 8px; +} diff --git a/apps/testbed/src/types.ts b/apps/testbed/src/types.ts new file mode 100644 index 00000000..9d6759eb --- /dev/null +++ b/apps/testbed/src/types.ts @@ -0,0 +1,23 @@ +export type Product = { + id: string; + name: string; + price: number; + category: string; +}; + +export type CartItem = Product & { qty: number }; + +export type User = { + id: string; + firstName: string; + lastName: string; + email: string; + groupIds: string[]; +}; + +export type Group = { + id: string; + name: string; + type: string; + properties: Record; +}; diff --git a/apps/testbed/tsconfig.json b/apps/testbed/tsconfig.json new file mode 100644 index 00000000..4c77361f --- /dev/null +++ b/apps/testbed/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true + }, + "include": ["src"] +} diff --git a/apps/testbed/vite.config.ts b/apps/testbed/vite.config.ts new file mode 100644 index 00000000..a6a2e812 --- /dev/null +++ b/apps/testbed/vite.config.ts @@ -0,0 +1,9 @@ +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [react()], + define: { + 'process.env': {}, + }, +}); diff --git a/apps/worker/src/jobs/events.incoming-events.test.ts b/apps/worker/src/jobs/events.incoming-events.test.ts index f483fb74..951ddee2 100644 --- a/apps/worker/src/jobs/events.incoming-events.test.ts +++ b/apps/worker/src/jobs/events.incoming-events.test.ts @@ -312,6 +312,7 @@ describe('incomingEvent', () => { screen_views: [], sign: 1, version: 1, + groups: [], } satisfies IClickhouseSession); await incomingEvent(jobData); diff --git a/docker-compose.yml b/docker-compose.yml index bcec81bf..0511328f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,7 +22,7 @@ services: - 6379:6379 op-ch: - image: clickhouse/clickhouse-server:25.10.2.65 + image: clickhouse/clickhouse-server:26.1.3.52 restart: always volumes: - ./docker/data/op-ch-data:/var/lib/clickhouse diff --git a/packages/common/scripts/get-referrers.ts b/packages/common/scripts/get-referrers.ts index 27302eb7..761a153a 100644 --- a/packages/common/scripts/get-referrers.ts +++ b/packages/common/scripts/get-referrers.ts @@ -1,6 +1,5 @@ import fs from 'node:fs'; -import path from 'node:path'; -import { dirname } from 'node:path'; +import path, { dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const __filename = fileURLToPath(import.meta.url); @@ -44,6 +43,66 @@ const extraReferrers = { 'squarespace.com': { type: 'commerce', name: 'Squarespace' }, 'stackoverflow.com': { type: 'tech', name: 'Stack Overflow' }, 'teams.microsoft.com': { type: 'social', name: 'Microsoft Teams' }, + 'chat.com': { type: 'ai', name: 'Chat.com' }, + 'chatgpt.com': { type: 'ai', name: 'ChatGPT' }, + 'openai.com': { type: 'ai', name: 'OpenAI' }, + 'anthropic.com': { type: 'ai', name: 'Anthropic' }, + 'claude.ai': { type: 'ai', name: 'Claude' }, + 'gemini.google.com': { type: 'ai', name: 'Google Gemini' }, + 'bard.google.com': { type: 'ai', name: 'Google Bard' }, + 'copilot.microsoft.com': { type: 'ai', name: 'Microsoft Copilot' }, + 'copilot.cloud.microsoft': { type: 'ai', name: 'Microsoft Copilot' }, + 'perplexity.ai': { type: 'ai', name: 'Perplexity' }, + 'you.com': { type: 'ai', name: 'You.com' }, + 'poe.com': { type: 'ai', name: 'Poe' }, + 'phind.com': { type: 'ai', name: 'Phind' }, + 'huggingface.co': { type: 'ai', name: 'Hugging Face' }, + 'hf.co': { type: 'ai', name: 'Hugging Face' }, + 'character.ai': { type: 'ai', name: 'Character.AI' }, + 'meta.ai': { type: 'ai', name: 'Meta AI' }, + 'mistral.ai': { type: 'ai', name: 'Mistral' }, + 'chat.mistral.ai': { type: 'ai', name: 'Mistral Le Chat' }, + 'deepseek.com': { type: 'ai', name: 'DeepSeek' }, + 'chat.deepseek.com': { type: 'ai', name: 'DeepSeek Chat' }, + 'pi.ai': { type: 'ai', name: 'Pi' }, + 'inflection.ai': { type: 'ai', name: 'Inflection' }, + 'cohere.com': { type: 'ai', name: 'Cohere' }, + 'coral.cohere.com': { type: 'ai', name: 'Cohere Coral' }, + 'jasper.ai': { type: 'ai', name: 'Jasper' }, + 'writesonic.com': { type: 'ai', name: 'Writesonic' }, + 'copy.ai': { type: 'ai', name: 'Copy.ai' }, + 'rytr.me': { type: 'ai', name: 'Rytr' }, + 'notion.ai': { type: 'ai', name: 'Notion AI' }, + 'grammarly.com': { type: 'ai', name: 'Grammarly' }, + 'bing.com': { type: 'ai', name: 'Bing AI' }, + 'grok.com': { type: 'ai', name: 'Grok' }, + 'x.ai': { type: 'ai', name: 'xAI' }, + 'aistudio.google.com': { type: 'ai', name: 'Google AI Studio' }, + 'labs.google.com': { type: 'ai', name: 'Google Labs' }, + 'ai.google': { type: 'ai', name: 'Google AI' }, + 'forefront.ai': { type: 'ai', name: 'Forefront' }, + 'together.ai': { type: 'ai', name: 'Together AI' }, + 'groq.com': { type: 'ai', name: 'Groq' }, + 'replicate.com': { type: 'ai', name: 'Replicate' }, + 'vercel.ai': { type: 'ai', name: 'Vercel AI' }, + 'v0.dev': { type: 'ai', name: 'v0' }, + 'bolt.new': { type: 'ai', name: 'Bolt' }, + 'replit.com': { type: 'ai', name: 'Replit' }, + 'cursor.com': { type: 'ai', name: 'Cursor' }, + 'tabnine.com': { type: 'ai', name: 'Tabnine' }, + 'codeium.com': { type: 'ai', name: 'Codeium' }, + 'sourcegraph.com': { type: 'ai', name: 'Sourcegraph Cody' }, + 'kimi.moonshot.cn': { type: 'ai', name: 'Kimi' }, + 'moonshot.ai': { type: 'ai', name: 'Moonshot AI' }, + 'doubao.com': { type: 'ai', name: 'Doubao' }, + 'tongyi.aliyun.com': { type: 'ai', name: 'Tongyi Qianwen' }, + 'yiyan.baidu.com': { type: 'ai', name: 'Ernie Bot' }, + 'chatglm.cn': { type: 'ai', name: 'ChatGLM' }, + 'zhipu.ai': { type: 'ai', name: 'Zhipu AI' }, + 'minimax.chat': { type: 'ai', name: 'MiniMax' }, + 'lmsys.org': { type: 'ai', name: 'LMSYS' }, + 'chat.lmsys.org': { type: 'ai', name: 'LMSYS Chat' }, + 'llama.meta.com': { type: 'ai', name: 'Meta Llama' }, }; function transform(data: any) { @@ -67,7 +126,7 @@ async function main() { // Get document, or throw exception on error try { const data = await fetch( - 'https://s3-eu-west-1.amazonaws.com/snowplow-hosted-assets/third-party/referer-parser/referers-latest.json', + 'https://s3-eu-west-1.amazonaws.com/snowplow-hosted-assets/third-party/referer-parser/referers-latest.json' ).then((res) => res.json()); fs.writeFileSync( @@ -82,11 +141,11 @@ async function main() { { ...transform(data), ...extraReferrers, - }, + } )} as const;`, 'export default referrers;', ].join('\n'), - 'utf-8', + 'utf-8' ); } catch (e) { console.log(e); diff --git a/packages/db/code-migrations/11-add-groups.ts b/packages/db/code-migrations/11-add-groups.ts index 6d9f2e69..e23ae819 100644 --- a/packages/db/code-migrations/11-add-groups.ts +++ b/packages/db/code-migrations/11-add-groups.ts @@ -1,7 +1,9 @@ import fs from 'node:fs'; import path from 'node:path'; +import { TABLE_NAMES } from '../src/clickhouse/client'; import { addColumns, + createTable, runClickhouseMigrationCommands, } from '../src/clickhouse/migration'; import { getIsCluster } from './helpers'; @@ -9,45 +11,40 @@ import { getIsCluster } from './helpers'; export async function up() { const isClustered = getIsCluster(); - const databaseUrl = process.env.DATABASE_URL ?? ''; - // Parse postgres connection string: postgresql://user:password@host:port/dbname - const match = databaseUrl.match( - /postgresql:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/(.+?)(\?.*)?$/ - ); - - if (!match) { - throw new Error(`Could not parse DATABASE_URL: ${databaseUrl}`); - } - - const [, pgUser, pgPassword, pgHost, pgPort, pgDb] = match; - - const dictSql = `CREATE DICTIONARY IF NOT EXISTS groups_dict -( - id String, - project_id String, - type String, - name String, - properties String -) -PRIMARY KEY id, project_id -SOURCE(POSTGRESQL( - host '${pgHost}' - port ${pgPort} - user '${pgUser}' - password '${pgPassword}' - db '${pgDb}' - table 'groups' -)) -LIFETIME(MIN 300 MAX 600) -LAYOUT(COMPLEX_KEY_HASHED())`; - const sqls: string[] = [ ...addColumns( 'events', - ['`groups` Array(String) DEFAULT [] CODEC(ZSTD(3))'], + ['`groups` Array(String) DEFAULT [] CODEC(ZSTD(3)) AFTER session_id'], isClustered ), - dictSql, + ...addColumns( + 'sessions', + ['`groups` Array(String) DEFAULT [] CODEC(ZSTD(3)) AFTER device_id'], + isClustered + ), + ...addColumns( + 'profiles', + ['`groups` Array(String) DEFAULT [] CODEC(ZSTD(3)) AFTER project_id'], + isClustered + ), + ...createTable({ + name: TABLE_NAMES.groups, + columns: [ + '`id` String', + '`project_id` String', + '`type` String', + '`name` String', + '`properties` Map(String, String)', + '`created_at` DateTime', + '`version` UInt64', + '`deleted` UInt8 DEFAULT 0', + ], + engine: 'ReplacingMergeTree(version, deleted)', + orderBy: ['project_id', 'id'], + distributionHash: 'cityHash64(project_id, id)', + replicatedVersion: '1', + isClustered, + }), ]; fs.writeFileSync( diff --git a/packages/db/prisma/migrations/20260218164139_groups/migration.sql b/packages/db/prisma/migrations/20260218164139_groups/migration.sql deleted file mode 100644 index e0b7190d..00000000 --- a/packages/db/prisma/migrations/20260218164139_groups/migration.sql +++ /dev/null @@ -1,15 +0,0 @@ --- CreateTable -CREATE TABLE "public"."groups" ( - "id" TEXT NOT NULL DEFAULT '', - "projectId" TEXT NOT NULL, - "type" TEXT NOT NULL, - "name" TEXT NOT NULL, - "properties" JSONB NOT NULL DEFAULT '{}', - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "groups_pkey" PRIMARY KEY ("projectId","id") -); - --- AddForeignKey -ALTER TABLE "public"."groups" ADD CONSTRAINT "groups_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."projects"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index d6146174..1af69d32 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -199,8 +199,6 @@ model Project { meta EventMeta[] references Reference[] access ProjectAccess[] - groups Group[] - notificationRules NotificationRule[] notifications Notification[] imports Import[] @@ -216,19 +214,6 @@ model Project { @@map("projects") } -model Group { - id String @default("") - projectId String - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - type String - name String - properties Json @default("{}") - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt - - @@id([projectId, id]) - @@map("groups") -} enum AccessLevel { read diff --git a/packages/db/src/buffers/event-buffer.ts b/packages/db/src/buffers/event-buffer.ts index 6b5dc8ca..2e5d43dc 100644 --- a/packages/db/src/buffers/event-buffer.ts +++ b/packages/db/src/buffers/event-buffer.ts @@ -1,11 +1,7 @@ import { getSafeJson } from '@openpanel/json'; -import { - type Redis, - getRedisCache, - publishEvent, -} from '@openpanel/redis'; +import { getRedisCache, publishEvent, type Redis } from '@openpanel/redis'; import { ch } from '../clickhouse/client'; -import { type IClickhouseEvent } from '../services/event.service'; +import type { IClickhouseEvent } from '../services/event.service'; import { BaseBuffer } from './base-buffer'; export class EventBuffer extends BaseBuffer { @@ -95,7 +91,7 @@ export class EventBuffer extends BaseBuffer { this.incrementActiveVisitorCount( multi, event.project_id, - event.profile_id, + event.profile_id ); } } @@ -116,7 +112,7 @@ export class EventBuffer extends BaseBuffer { error, eventCount: eventsToFlush.length, flushRetryCount: this.flushRetryCount, - }, + } ); } finally { this.isFlushing = false; @@ -137,7 +133,7 @@ export class EventBuffer extends BaseBuffer { const queueEvents = await redis.lrange( this.queueKey, 0, - this.batchSize - 1, + this.batchSize - 1 ); if (queueEvents.length === 0) { @@ -149,6 +145,9 @@ export class EventBuffer extends BaseBuffer { for (const eventStr of queueEvents) { const event = getSafeJson(eventStr); if (event) { + if (!Array.isArray(event.groups)) { + event.groups = []; + } eventsToClickhouse.push(event); } } @@ -161,7 +160,7 @@ export class EventBuffer extends BaseBuffer { eventsToClickhouse.sort( (a, b) => new Date(a.created_at || 0).getTime() - - new Date(b.created_at || 0).getTime(), + new Date(b.created_at || 0).getTime() ); this.logger.info('Inserting events into ClickHouse', { @@ -181,7 +180,7 @@ export class EventBuffer extends BaseBuffer { for (const event of eventsToClickhouse) { countByProject.set( event.project_id, - (countByProject.get(event.project_id) ?? 0) + 1, + (countByProject.get(event.project_id) ?? 0) + 1 ); } for (const [projectId, count] of countByProject) { @@ -222,7 +221,7 @@ export class EventBuffer extends BaseBuffer { private incrementActiveVisitorCount( multi: ReturnType, projectId: string, - profileId: string, + profileId: string ) { const key = `${projectId}:${profileId}`; const now = Date.now(); diff --git a/packages/db/src/buffers/profile-buffer.test.ts b/packages/db/src/buffers/profile-buffer.test.ts new file mode 100644 index 00000000..bc7d39d2 --- /dev/null +++ b/packages/db/src/buffers/profile-buffer.test.ts @@ -0,0 +1,151 @@ +import { getRedisCache } from '@openpanel/redis'; +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getSafeJson } from '@openpanel/json'; +import type { IClickhouseProfile } from '../services/profile.service'; + +// Mock chQuery to avoid hitting real ClickHouse +vi.mock('../clickhouse/client', () => ({ + ch: { + insert: vi.fn().mockResolvedValue(undefined), + }, + chQuery: vi.fn().mockResolvedValue([]), + TABLE_NAMES: { + profiles: 'profiles', + }, +})); + +import { ProfileBuffer } from './profile-buffer'; +import { chQuery } from '../clickhouse/client'; + +const redis = getRedisCache(); + +function makeProfile(overrides: Partial): IClickhouseProfile { + return { + id: 'profile-1', + project_id: 'project-1', + first_name: '', + last_name: '', + email: '', + avatar: '', + properties: {}, + is_external: true, + created_at: new Date().toISOString(), + groups: [], + ...overrides, + }; +} + +beforeEach(async () => { + await redis.flushdb(); + vi.mocked(chQuery).mockResolvedValue([]); +}); + +afterAll(async () => { + try { + await redis.quit(); + } catch {} +}); + +describe('ProfileBuffer', () => { + let profileBuffer: ProfileBuffer; + + beforeEach(() => { + profileBuffer = new ProfileBuffer(); + }); + + it('adds a profile to the buffer', async () => { + const profile = makeProfile({ first_name: 'John', email: 'john@example.com' }); + + const sizeBefore = await profileBuffer.getBufferSize(); + await profileBuffer.add(profile); + const sizeAfter = await profileBuffer.getBufferSize(); + + expect(sizeAfter).toBe(sizeBefore + 1); + }); + + it('merges subsequent updates via cache (sequential calls)', async () => { + const identifyProfile = makeProfile({ + first_name: 'John', + email: 'john@example.com', + groups: [], + }); + + const groupProfile = makeProfile({ + first_name: '', + email: '', + groups: ['group-abc'], + }); + + // Sequential: identify first, then group + await profileBuffer.add(identifyProfile); + await profileBuffer.add(groupProfile); + + // Second add should read the cached identify profile and merge groups in + const cached = await profileBuffer.fetchFromCache('profile-1', 'project-1'); + expect(cached?.first_name).toBe('John'); + expect(cached?.email).toBe('john@example.com'); + expect(cached?.groups).toContain('group-abc'); + }); + + it('race condition: concurrent identify + group calls preserve all data', async () => { + const identifyProfile = makeProfile({ + first_name: 'John', + email: 'john@example.com', + groups: [], + }); + + const groupProfile = makeProfile({ + first_name: '', + email: '', + groups: ['group-abc'], + }); + + // Both calls run concurrently — the per-profile lock serializes them so the + // second one reads the first's result from cache and merges correctly. + await Promise.all([ + profileBuffer.add(identifyProfile), + profileBuffer.add(groupProfile), + ]); + + const cached = await profileBuffer.fetchFromCache('profile-1', 'project-1'); + + expect(cached?.first_name).toBe('John'); + expect(cached?.email).toBe('john@example.com'); + expect(cached?.groups).toContain('group-abc'); + }); + + it('race condition: concurrent writes produce one merged buffer entry', async () => { + const identifyProfile = makeProfile({ + first_name: 'John', + email: 'john@example.com', + groups: [], + }); + + const groupProfile = makeProfile({ + first_name: '', + email: '', + groups: ['group-abc'], + }); + + const sizeBefore = await profileBuffer.getBufferSize(); + + await Promise.all([ + profileBuffer.add(identifyProfile), + profileBuffer.add(groupProfile), + ]); + + const sizeAfter = await profileBuffer.getBufferSize(); + + // The second add merges into the first — only 2 buffer entries total + // (one from identify, one merged update with group) + expect(sizeAfter).toBe(sizeBefore + 2); + + // The last entry in the buffer should have both name and group + const rawEntries = await redis.lrange('profile-buffer', 0, -1); + const entries = rawEntries.map((e) => getSafeJson(e)); + const lastEntry = entries[entries.length - 1]; + + expect(lastEntry?.first_name).toBe('John'); + expect(lastEntry?.groups).toContain('group-abc'); + }); +}); diff --git a/packages/db/src/buffers/profile-buffer.ts b/packages/db/src/buffers/profile-buffer.ts index d568bb23..334869d1 100644 --- a/packages/db/src/buffers/profile-buffer.ts +++ b/packages/db/src/buffers/profile-buffer.ts @@ -1,9 +1,10 @@ import { deepMergeObjects } from '@openpanel/common'; +import { generateSecureId } from '@openpanel/common/server'; import { getSafeJson } from '@openpanel/json'; import type { ILogger } from '@openpanel/logger'; import { getRedisCache, type Redis } from '@openpanel/redis'; import shallowEqual from 'fast-deep-equal'; -import { omit } from 'ramda'; +import { omit, uniq } from 'ramda'; import sqlstring from 'sqlstring'; import { ch, chQuery, TABLE_NAMES } from '../clickhouse/client'; import type { IClickhouseProfile } from '../services/profile.service'; @@ -24,6 +25,15 @@ export class ProfileBuffer extends BaseBuffer { private readonly redisProfilePrefix = 'profile-cache:'; private redis: Redis; + private releaseLockSha: string | null = null; + + private readonly releaseLockScript = ` + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) + else + return 0 + end + `; constructor() { super({ @@ -33,6 +43,9 @@ export class ProfileBuffer extends BaseBuffer { }, }); this.redis = getRedisCache(); + this.redis.script('LOAD', this.releaseLockScript).then((sha) => { + this.releaseLockSha = sha as string; + }); } private getProfileCacheKey({ @@ -45,6 +58,42 @@ export class ProfileBuffer extends BaseBuffer { return `${this.redisProfilePrefix}${projectId}:${profileId}`; } + private async withProfileLock( + profileId: string, + projectId: string, + fn: () => Promise + ): Promise { + const lockKey = `profile-lock:${projectId}:${profileId}`; + const lockId = generateSecureId('lock'); + const maxRetries = 10; + const retryDelayMs = 25; + + for (let i = 0; i < maxRetries; i++) { + const acquired = await this.redis.set(lockKey, lockId, 'EX', 5, 'NX'); + if (acquired === 'OK') { + try { + return await fn(); + } finally { + if (this.releaseLockSha) { + await this.redis.evalsha(this.releaseLockSha, 1, lockKey, lockId); + } else { + await this.redis.eval(this.releaseLockScript, 1, lockKey, lockId); + } + } + } + await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); + } + + this.logger.error( + 'Failed to acquire profile lock, proceeding without lock', + { + profileId, + projectId, + } + ); + return fn(); + } + async alreadyExists(profile: IClickhouseProfile) { const cacheKey = this.getProfileCacheKey({ profileId: profile.id, @@ -67,83 +116,94 @@ export class ProfileBuffer extends BaseBuffer { return; } - const existingProfile = await this.fetchProfile(profile, logger); + await this.withProfileLock(profile.id, profile.project_id, async () => { + const existingProfile = await this.fetchProfile(profile, logger); - // Delete any properties that are not server related if we have a non-server profile - if ( - existingProfile?.properties.device !== 'server' && - profile.properties.device === 'server' - ) { - profile.properties = omit( - [ - 'city', - 'country', - 'region', - 'longitude', - 'latitude', - 'os', - 'osVersion', - 'browser', - 'device', - 'isServer', - 'os_version', - 'browser_version', - ], - profile.properties - ); - } + // Delete any properties that are not server related if we have a non-server profile + if ( + existingProfile?.properties.device !== 'server' && + profile.properties.device === 'server' + ) { + profile.properties = omit( + [ + 'city', + 'country', + 'region', + 'longitude', + 'latitude', + 'os', + 'osVersion', + 'browser', + 'device', + 'isServer', + 'os_version', + 'browser_version', + ], + profile.properties + ); + } - const mergedProfile: IClickhouseProfile = existingProfile - ? deepMergeObjects(existingProfile, omit(['created_at'], profile)) - : profile; + const mergedProfile: IClickhouseProfile = existingProfile + ? { + ...deepMergeObjects( + existingProfile, + omit(['created_at', 'groups'], profile) + ), + groups: uniq([ + ...(existingProfile.groups ?? []), + ...(profile.groups ?? []), + ]), + } + : profile; - if ( - profile && - existingProfile && - shallowEqual( - omit(['created_at'], existingProfile), - omit(['created_at'], mergedProfile) - ) - ) { - this.logger.debug('Profile not changed, skipping'); - return; - } + if ( + profile && + existingProfile && + shallowEqual( + omit(['created_at'], existingProfile), + omit(['created_at'], mergedProfile) + ) + ) { + this.logger.debug('Profile not changed, skipping'); + return; + } - this.logger.debug('Merged profile will be inserted', { - mergedProfile, - existingProfile, - profile, - }); - - const cacheKey = this.getProfileCacheKey({ - profileId: profile.id, - projectId: profile.project_id, - }); - - const result = await this.redis - .multi() - .set(cacheKey, JSON.stringify(mergedProfile), 'EX', this.ttlInSeconds) - .rpush(this.redisKey, JSON.stringify(mergedProfile)) - .incr(this.bufferCounterKey) - .llen(this.redisKey) - .exec(); - - if (!result) { - this.logger.error('Failed to add profile to Redis', { + this.logger.debug('Merged profile will be inserted', { + mergedProfile, + existingProfile, profile, - cacheKey, }); - return; - } - const bufferLength = (result?.[3]?.[1] as number) ?? 0; - this.logger.debug('Current buffer length', { - bufferLength, - batchSize: this.batchSize, + const cacheKey = this.getProfileCacheKey({ + profileId: profile.id, + projectId: profile.project_id, + }); + + const result = await this.redis + .multi() + .set(cacheKey, JSON.stringify(mergedProfile), 'EX', this.ttlInSeconds) + .rpush(this.redisKey, JSON.stringify(mergedProfile)) + .incr(this.bufferCounterKey) + .llen(this.redisKey) + .exec(); + + if (!result) { + this.logger.error('Failed to add profile to Redis', { + profile, + cacheKey, + }); + return; + } + const bufferLength = (result?.[3]?.[1] as number) ?? 0; + + this.logger.debug('Current buffer length', { + bufferLength, + batchSize: this.batchSize, + }); + if (bufferLength >= this.batchSize) { + await this.tryFlush(); + } }); - if (bufferLength >= this.batchSize) { - await this.tryFlush(); - } } catch (error) { this.logger.error('Failed to add profile', { error, profile }); } diff --git a/packages/db/src/buffers/session-buffer.ts b/packages/db/src/buffers/session-buffer.ts index 35939bd1..537652eb 100644 --- a/packages/db/src/buffers/session-buffer.ts +++ b/packages/db/src/buffers/session-buffer.ts @@ -109,6 +109,12 @@ export class SessionBuffer extends BaseBuffer { newSession.profile_id = event.profile_id; } + if (event.groups) { + newSession.groups = [ + ...new Set([...newSession.groups, ...event.groups]), + ]; + } + return [newSession, oldSession]; } @@ -119,6 +125,7 @@ export class SessionBuffer extends BaseBuffer { profile_id: event.profile_id, project_id: event.project_id, device_id: event.device_id, + groups: event.groups, created_at: event.created_at, ended_at: event.created_at, event_count: event.name === 'screen_view' ? 0 : 1, diff --git a/packages/db/src/clickhouse/query-builder.ts b/packages/db/src/clickhouse/query-builder.ts index cdbb23da..a75787cd 100644 --- a/packages/db/src/clickhouse/query-builder.ts +++ b/packages/db/src/clickhouse/query-builder.ts @@ -66,6 +66,7 @@ export class Query { alias?: string; }[] = []; private _skipNext = false; + private _rawJoins: string[] = []; private _fill?: { from: string | Date; to: string | Date; @@ -329,6 +330,12 @@ export class Query { return this.joinWithType('CROSS', table, '', alias); } + rawJoin(sql: string): this { + if (this._skipNext) return this; + this._rawJoins.push(sql); + return this; + } + private joinWithType( type: JoinType, table: string | Expression | Query, @@ -414,6 +421,10 @@ export class Query { `${join.type} JOIN ${join.table instanceof Query ? `(${join.table.toSQL()})` : join.table instanceof Expression ? `(${join.table.toString()})` : join.table}${aliasClause}${conditionStr}`, ); }); + // Add raw joins (e.g. ARRAY JOIN) + this._rawJoins.forEach((join) => { + parts.push(join); + }); } // WHERE @@ -590,6 +601,7 @@ export class Query { // Merge JOINS this._joins = [...this._joins, ...query._joins]; + this._rawJoins = [...this._rawJoins, ...query._rawJoins]; // Merge settings this._settings = { ...this._settings, ...query._settings }; diff --git a/packages/db/src/services/chart.service.ts b/packages/db/src/services/chart.service.ts index 711caf97..cc69b054 100644 --- a/packages/db/src/services/chart.service.ts +++ b/packages/db/src/services/chart.service.ts @@ -1,3 +1,4 @@ +/** biome-ignore-all lint/style/useDefaultSwitchClause: */ import { DateTime, stripLeadingAndTrailingSlashes } from '@openpanel/common'; import type { IChartEventFilter, @@ -30,35 +31,77 @@ export function transformPropertyKey(property: string) { return `${match}['${property.replace(new RegExp(`^${match}.`), '')}']`; } -// Returns a SQL expression for a group property using dictGet +// Returns a SQL expression for a group property via the _g JOIN alias // property format: "group.name", "group.type", "group.properties.plan" -export function getGroupPropertySql( - property: string, - projectId: string -): string { +export function getGroupPropertySql(property: string): string { const withoutPrefix = property.replace(/^group\./, ''); if (withoutPrefix === 'name') { - return `dictGet('${TABLE_NAMES.groups_dict}', 'name', tuple(_group_id, ${sqlstring.escape(projectId)}))`; + return '_g.name'; } if (withoutPrefix === 'type') { - return `dictGet('${TABLE_NAMES.groups_dict}', 'type', tuple(_group_id, ${sqlstring.escape(projectId)}))`; + return '_g.type'; } if (withoutPrefix.startsWith('properties.')) { const propKey = withoutPrefix.replace(/^properties\./, ''); - // properties is stored as JSON string in dict; use JSONExtractString - return `JSONExtractString(dictGet('${TABLE_NAMES.groups_dict}', 'properties', tuple(_group_id, ${sqlstring.escape(projectId)})), ${sqlstring.escape(propKey)})`; + return `_g.properties[${sqlstring.escape(propKey)}]`; } return '_group_id'; } +// Returns the SELECT expression when querying the groups table directly (no join alias). +// Use for fetching distinct values for group.* properties. +export function getGroupPropertySelect(property: string): string { + const withoutPrefix = property.replace(/^group\./, ''); + if (withoutPrefix === 'name') { + return 'name'; + } + if (withoutPrefix === 'type') { + return 'type'; + } + if (withoutPrefix === 'id') { + return 'id'; + } + if (withoutPrefix.startsWith('properties.')) { + const propKey = withoutPrefix.replace(/^properties\./, ''); + return `properties[${sqlstring.escape(propKey)}]`; + } + return 'id'; +} + +// Returns the SELECT expression when querying the profiles table directly (no join alias). +// Use for fetching distinct values for profile.* properties. +export function getProfilePropertySelect(property: string): string { + const withoutPrefix = property.replace(/^profile\./, ''); + if (withoutPrefix === 'id') { + return 'id'; + } + if (withoutPrefix === 'first_name') { + return 'first_name'; + } + if (withoutPrefix === 'last_name') { + return 'last_name'; + } + if (withoutPrefix === 'email') { + return 'email'; + } + if (withoutPrefix === 'avatar') { + return 'avatar'; + } + if (withoutPrefix.startsWith('properties.')) { + const propKey = withoutPrefix.replace(/^properties\./, ''); + return `properties[${sqlstring.escape(propKey)}]`; + } + return 'id'; +} + export function getSelectPropertyKey(property: string, projectId?: string) { if (property === 'has_profile') { return `if(profile_id != device_id, 'true', 'false')`; } - // Handle group properties — requires ARRAY JOIN to be present in query + // Handle group properties — requires ARRAY JOIN + _g JOIN to be present in query if (property.startsWith('group.') && projectId) { - return getGroupPropertySql(property, projectId); + return getGroupPropertySql(property); } const propertyPatterns = ['properties', 'profile.properties']; @@ -86,9 +129,7 @@ export function getChartSql({ startDate, endDate, projectId, - limit, timezone, - chartType, }: IGetChartDataInput & { timezone: string }) { const { sb, @@ -130,7 +171,12 @@ export function getChartSql({ anyFilterOnGroup || anyBreakdownOnGroup || event.segment === 'group'; if (needsGroupArrayJoin) { + addCte( + '_g', + `SELECT id, name, type, properties FROM ${TABLE_NAMES.groups} FINAL WHERE project_id = ${sqlstring.escape(projectId)}` + ); sb.joins.groups = 'ARRAY JOIN groups AS _group_id'; + sb.joins.groups_table = 'LEFT ANY JOIN _g ON _g.id = _group_id'; } // Build WHERE clause without the bar filter (for use in subqueries and CTEs) @@ -263,31 +309,6 @@ export function getChartSql({ sb.where.endDate = `created_at <= toDateTime('${formatClickhouseDate(endDate)}')`; } - // Use CTE to define top breakdown values once, then reference in WHERE clause - if (breakdowns.length > 0 && limit) { - const breakdownSelects = breakdowns - .map((b) => getSelectPropertyKey(b.name, projectId)) - .join(', '); - - const groupArrayJoinClause = needsGroupArrayJoin - ? 'ARRAY JOIN groups AS _group_id' - : ''; - - // Add top_breakdowns CTE using the builder - addCte( - 'top_breakdowns', - `SELECT ${breakdownSelects} - FROM ${TABLE_NAMES.events} e - ${profilesJoinRef ? `${profilesJoinRef} ` : ''}${groupArrayJoinClause ? `${groupArrayJoinClause} ` : ''}${getWhereWithoutBar()} - GROUP BY ${breakdownSelects} - ORDER BY count(*) DESC - LIMIT ${limit}` - ); - - // Filter main query to only include top breakdown values - sb.where.bar = `(${breakdowns.map((b) => getSelectPropertyKey(b.name, projectId)).join(',')}) IN (SELECT * FROM top_breakdowns)`; - } - breakdowns.forEach((breakdown, index) => { // Breakdowns start at label_1 (label_0 is reserved for event name) const key = `label_${index + 1}`; @@ -350,6 +371,10 @@ export function getChartSql({ } // Note: The profile CTE (if it exists) is available in subqueries, so we can reference it directly + const subqueryGroupJoins = needsGroupArrayJoin + ? 'ARRAY JOIN groups AS _group_id LEFT ANY JOIN _g ON _g.id = _group_id ' + : ''; + if (breakdowns.length > 0) { // Match breakdown properties in subquery with outer query's grouped values // Since outer query groups by label_X, we reference those in the correlation @@ -370,7 +395,7 @@ export function getChartSql({ sb.select.total_unique_count = `( SELECT uniq(profile_id) FROM ${TABLE_NAMES.events} e2 - ${profilesJoinRef ? `${profilesJoinRef} ` : ''}${subqueryWhere} + ${subqueryGroupJoins}${profilesJoinRef ? `${profilesJoinRef} ` : ''}${subqueryWhere} AND ${breakdownMatches} ) as total_count`; } else { @@ -383,7 +408,7 @@ export function getChartSql({ sb.select.total_unique_count = `( SELECT uniq(profile_id) FROM ${TABLE_NAMES.events} e2 - ${profilesJoinRef ? `${profilesJoinRef} ` : ''}${subqueryWhere} + ${subqueryGroupJoins}${profilesJoinRef ? `${profilesJoinRef} ` : ''}${subqueryWhere} ) as total_count`; } @@ -432,18 +457,14 @@ export function getAggregateChartSql({ anyFilterOnGroup || anyBreakdownOnGroup || event.segment === 'group'; if (needsGroupArrayJoin) { + addCte( + '_g', + `SELECT id, name, type, properties FROM ${TABLE_NAMES.groups} FINAL WHERE project_id = ${sqlstring.escape(projectId)}` + ); sb.joins.groups = 'ARRAY JOIN groups AS _group_id'; + sb.joins.groups_table = 'LEFT ANY JOIN _g ON _g.id = _group_id'; } - // Build WHERE clause without the bar filter (for use in subqueries and CTEs) - const getWhereWithoutBar = () => { - const whereWithoutBar = { ...sb.where }; - delete whereWithoutBar.bar; - return Object.keys(whereWithoutBar).length - ? `WHERE ${join(whereWithoutBar, ' AND ')}` - : ''; - }; - // Collect all profile fields used in filters and breakdowns const getProfileFields = () => { const fields = new Set(); @@ -534,30 +555,6 @@ export function getAggregateChartSql({ // Use startDate as the date value since we're aggregating across the entire range sb.select.date = `${sqlstring.escape(startDate)} as date`; - // Use CTE to define top breakdown values once, then reference in WHERE clause - if (breakdowns.length > 0 && limit) { - const breakdownSelects = breakdowns - .map((b) => getSelectPropertyKey(b.name, projectId)) - .join(', '); - - const groupArrayJoinClause = needsGroupArrayJoin - ? 'ARRAY JOIN groups AS _group_id' - : ''; - - addCte( - 'top_breakdowns', - `SELECT ${breakdownSelects} - FROM ${TABLE_NAMES.events} e - ${profilesJoinRef ? `${profilesJoinRef} ` : ''}${groupArrayJoinClause ? `${groupArrayJoinClause} ` : ''}${getWhereWithoutBar()} - GROUP BY ${breakdownSelects} - ORDER BY count(*) DESC - LIMIT ${limit}` - ); - - // Filter main query to only include top breakdown values - sb.where.bar = `(${breakdowns.map((b) => getSelectPropertyKey(b.name, projectId)).join(',')}) IN (SELECT * FROM top_breakdowns)`; - } - // Add breakdowns to SELECT and GROUP BY breakdowns.forEach((breakdown, index) => { // Breakdowns start at label_1 (label_0 is reserved for event name) @@ -673,9 +670,9 @@ export function getEventFiltersWhereClause( return; } - // Handle group. prefixed filters using dictGet (requires ARRAY JOIN in query) + // Handle group. prefixed filters (requires ARRAY JOIN + _g JOIN in query) if (name.startsWith('group.') && projectId) { - const whereFrom = getGroupPropertySql(name, projectId); + const whereFrom = getGroupPropertySql(name); switch (operator) { case 'is': { if (value.length === 1) { diff --git a/packages/db/src/services/conversion.service.ts b/packages/db/src/services/conversion.service.ts index 4ef921f0..2e985bf3 100644 --- a/packages/db/src/services/conversion.service.ts +++ b/packages/db/src/services/conversion.service.ts @@ -31,14 +31,14 @@ export class ConversionService { const funnelWindow = funnelOptions?.funnelWindow ?? 24; const group = funnelGroup === 'profile_id' ? 'profile_id' : 'session_id'; const breakdownExpressions = breakdowns.map( - (b) => getSelectPropertyKey(b.name), + (b) => getSelectPropertyKey(b.name, projectId), ); const breakdownSelects = breakdownExpressions.map( (expr, index) => `${expr} as b_${index}`, ); const breakdownGroupBy = breakdowns.map((_, index) => `b_${index}`); - // Check if any breakdown uses profile fields and build profile JOIN if needed + // Check if any breakdown or filter uses profile fields const profileBreakdowns = breakdowns.filter((b) => b.name.startsWith('profile.'), ); @@ -71,6 +71,15 @@ export class ConversionService { const events = onlyReportEvents(series); + // Check if any breakdown or filter uses group fields + const anyBreakdownOnGroup = breakdowns.some((b) => + b.name.startsWith('group.'), + ); + const anyFilterOnGroup = events.some((e) => + e.filters?.some((f) => f.name.startsWith('group.')), + ); + const needsGroupArrayJoin = anyBreakdownOnGroup || anyFilterOnGroup; + if (events.length !== 2) { throw new Error('events must be an array of two events'); } @@ -82,10 +91,10 @@ export class ConversionService { const eventA = events[0]!; const eventB = events[1]!; const whereA = Object.values( - getEventFiltersWhereClause(eventA.filters), + getEventFiltersWhereClause(eventA.filters, projectId), ).join(' AND '); const whereB = Object.values( - getEventFiltersWhereClause(eventB.filters), + getEventFiltersWhereClause(eventB.filters, projectId), ).join(' AND '); const funnelWindowSeconds = funnelWindow * 3600; @@ -98,6 +107,10 @@ export class ConversionService { ? `(name = '${eventB.name}' AND ${whereB})` : `name = '${eventB.name}'`; + const groupJoin = needsGroupArrayJoin + ? `ARRAY JOIN groups AS _group_id LEFT ANY JOIN (SELECT id, name, type, properties FROM ${TABLE_NAMES.groups} FINAL WHERE project_id = ${sqlstring.escape(projectId)}) AS _g ON _g.id = _group_id` + : ''; + // Use windowFunnel approach - single scan, no JOIN const query = clix(this.client, timezone) .select<{ @@ -126,6 +139,7 @@ export class ConversionService { ) as steps FROM ${TABLE_NAMES.events} ${profileJoin} + ${groupJoin} WHERE project_id = '${projectId}' AND name IN ('${eventA.name}', '${eventB.name}') AND created_at BETWEEN toDateTime('${startDate}') AND toDateTime('${endDate}') diff --git a/packages/db/src/services/event.service.ts b/packages/db/src/services/event.service.ts index ba369736..e9f92b27 100644 --- a/packages/db/src/services/event.service.ts +++ b/packages/db/src/services/event.service.ts @@ -32,14 +32,14 @@ export type IImportedEvent = Omit< properties: Record; }; -export type IServicePage = { +export interface IServicePage { path: string; count: number; project_id: string; first_seen: string; title: string; origin: string; -}; +} export interface IClickhouseBotEvent { id: string; @@ -335,6 +335,7 @@ export async function getEvents( projectId, isExternal: false, properties: {}, + groups: [], }; } } @@ -439,6 +440,7 @@ export interface GetEventListOptions { projectId: string; profileId?: string; sessionId?: string; + groupId?: string; take: number; cursor?: number | Date; events?: string[] | null; @@ -457,6 +459,7 @@ export async function getEventList(options: GetEventListOptions) { projectId, profileId, sessionId, + groupId, events, filters, startDate, @@ -594,6 +597,10 @@ export async function getEventList(options: GetEventListOptions) { sb.select.revenue = 'revenue'; } + if (select.groups) { + sb.select.groups = 'groups'; + } + if (profileId) { sb.where.deviceId = `(device_id IN (SELECT device_id as did FROM ${TABLE_NAMES.events} WHERE project_id = ${sqlstring.escape(projectId)} AND device_id != '' AND profile_id = ${sqlstring.escape(profileId)} group by did) OR profile_id = ${sqlstring.escape(profileId)})`; } @@ -602,6 +609,10 @@ export async function getEventList(options: GetEventListOptions) { sb.where.sessionId = `session_id = ${sqlstring.escape(sessionId)}`; } + if (groupId) { + sb.where.groupId = `has(groups, ${sqlstring.escape(groupId)})`; + } + if (startDate && endDate) { sb.where.created_at = `toDate(created_at) BETWEEN toDate('${formatClickhouseDate(startDate)}') AND toDate('${formatClickhouseDate(endDate)}')`; } @@ -616,7 +627,7 @@ export async function getEventList(options: GetEventListOptions) { if (filters) { sb.where = { ...sb.where, - ...getEventFiltersWhereClause(filters), + ...getEventFiltersWhereClause(filters, projectId), }; // Join profiles table if any filter uses profile fields @@ -627,6 +638,13 @@ export async function getEventList(options: GetEventListOptions) { if (profileFilters.length > 0) { sb.joins.profiles = `LEFT ANY JOIN (SELECT id, ${uniq(profileFilters.map((f) => f.split('.')[0])).join(', ')} FROM ${TABLE_NAMES.profiles} FINAL WHERE project_id = ${sqlstring.escape(projectId)}) as profile on profile.id = profile_id`; } + + // Join groups table if any filter uses group fields + const groupFilters = filters.filter((f) => f.name.startsWith('group.')); + if (groupFilters.length > 0) { + sb.joins.groups = 'ARRAY JOIN groups AS _group_id'; + sb.joins.groups_cte = `LEFT ANY JOIN (SELECT id, name, type, properties FROM ${TABLE_NAMES.groups} FINAL WHERE project_id = ${sqlstring.escape(projectId)}) AS _g ON _g.id = _group_id`; + } } sb.orderBy.created_at = 'created_at DESC, id ASC'; @@ -652,6 +670,8 @@ export async function getEventList(options: GetEventListOptions) { }); } + console.log('getSql', getSql()); + return data; } @@ -683,7 +703,7 @@ export async function getEventsCount({ if (filters) { sb.where = { ...sb.where, - ...getEventFiltersWhereClause(filters), + ...getEventFiltersWhereClause(filters, projectId), }; // Join profiles table if any filter uses profile fields @@ -694,6 +714,13 @@ export async function getEventsCount({ if (profileFilters.length > 0) { sb.joins.profiles = `LEFT ANY JOIN (SELECT id, ${uniq(profileFilters.map((f) => f.split('.')[0])).join(', ')} FROM ${TABLE_NAMES.profiles} FINAL WHERE project_id = ${sqlstring.escape(projectId)}) as profile on profile.id = profile_id`; } + + // Join groups table if any filter uses group fields + const groupFilters = filters.filter((f) => f.name.startsWith('group.')); + if (groupFilters.length > 0) { + sb.joins.groups = 'ARRAY JOIN groups AS _group_id'; + sb.joins.groups_cte = `LEFT ANY JOIN (SELECT id, name, type, properties FROM ${TABLE_NAMES.groups} FINAL WHERE project_id = ${sqlstring.escape(projectId)}) AS _g ON _g.id = _group_id`; + } } const res = await chQuery<{ count: number }>( @@ -1057,8 +1084,19 @@ class EventService { } if (filters) { q.rawWhere( - Object.values(getEventFiltersWhereClause(filters)).join(' AND ') + Object.values( + getEventFiltersWhereClause(filters, projectId) + ).join(' AND ') ); + const groupFilters = filters.filter((f) => + f.name.startsWith('group.') + ); + if (groupFilters.length > 0) { + q.rawJoin('ARRAY JOIN groups AS _group_id'); + q.rawJoin( + `LEFT ANY JOIN (SELECT id, name, type, properties FROM ${TABLE_NAMES.groups} FINAL WHERE project_id = ${sqlstring.escape(projectId)}) AS _g ON _g.id = _group_id` + ); + } } }, session: (q) => { diff --git a/packages/db/src/services/funnel.service.ts b/packages/db/src/services/funnel.service.ts index 5ba99d7c..547b4e49 100644 --- a/packages/db/src/services/funnel.service.ts +++ b/packages/db/src/services/funnel.service.ts @@ -34,10 +34,10 @@ export class FunnelService { return group === 'profile_id' ? 'profile_id' : 'session_id'; } - getFunnelConditions(events: IChartEvent[] = []): string[] { + getFunnelConditions(events: IChartEvent[] = [], projectId?: string): string[] { return events.map((event) => { const { sb, getWhere } = createSqlBuilder(); - sb.where = getEventFiltersWhereClause(event.filters); + sb.where = getEventFiltersWhereClause(event.filters, projectId); sb.where.name = `name = ${sqlstring.escape(event.name)}`; return getWhere().replace('WHERE ', ''); }); @@ -71,7 +71,7 @@ export class FunnelService { additionalGroupBy?: string[]; group?: 'session_id' | 'profile_id'; }) { - const funnels = this.getFunnelConditions(eventSeries); + const funnels = this.getFunnelConditions(eventSeries, projectId); const primaryKey = group === 'profile_id' ? 'profile_id' : 'session_id'; return clix(this.client, timezone) @@ -236,10 +236,18 @@ export class FunnelService { const anyBreakdownOnProfile = breakdowns.some((b) => b.name.startsWith('profile.'), ); + const anyFilterOnGroup = eventSeries.some((e) => + e.filters?.some((f) => f.name.startsWith('group.')), + ); + const anyBreakdownOnGroup = breakdowns.some((b) => + b.name.startsWith('group.'), + ); + const needsGroupArrayJoin = + anyFilterOnGroup || anyBreakdownOnGroup || funnelGroup === 'group'; // Create the funnel CTE (session-level) const breakdownSelects = breakdowns.map( - (b, index) => `${getSelectPropertyKey(b.name)} as b_${index}`, + (b, index) => `${getSelectPropertyKey(b.name, projectId)} as b_${index}`, ); const breakdownGroupBy = breakdowns.map((b, index) => `b_${index}`); @@ -277,8 +285,21 @@ export class FunnelService { ); } + if (needsGroupArrayJoin) { + funnelCte.rawJoin('ARRAY JOIN groups AS _group_id'); + funnelCte.rawJoin('LEFT ANY JOIN _g ON _g.id = _group_id'); + } + // Base funnel query with CTEs const funnelQuery = clix(this.client, timezone); + + if (needsGroupArrayJoin) { + funnelQuery.with( + '_g', + `SELECT id, name, type, properties FROM ${TABLE_NAMES.groups} FINAL WHERE project_id = ${sqlstring.escape(projectId)}`, + ); + } + funnelQuery.with('session_funnel', funnelCte); // windowFunnel is computed per the primary key (profile_id or session_id), diff --git a/packages/db/src/services/group.service.ts b/packages/db/src/services/group.service.ts index 687caf03..d3b46c82 100644 --- a/packages/db/src/services/group.service.ts +++ b/packages/db/src/services/group.service.ts @@ -1,4 +1,13 @@ -import { db } from '../prisma-client'; +import { toDots } from '@openpanel/common'; +import sqlstring from 'sqlstring'; +import { + ch, + chQuery, + formatClickhouseDate, + TABLE_NAMES, +} from '../clickhouse/client'; +import type { IServiceProfile } from './profile.service'; +import { getProfiles } from './profile.service'; export type IServiceGroup = { id: string; @@ -18,26 +27,69 @@ export type IServiceUpsertGroup = { properties?: Record; }; -export async function upsertGroup(input: IServiceUpsertGroup) { - const { id, projectId, type, name, properties = {} } = input; +type IClickhouseGroup = { + project_id: string; + id: string; + type: string; + name: string; + properties: Record; + created_at: string; + version: string; +}; - await db.group.upsert({ - where: { - projectId_id: { projectId, id }, - }, - update: { - type, - name, - properties: properties as Record, - updatedAt: new Date(), - }, - create: { - id, - projectId, - type, - name, - properties: properties as Record, - }, +function transformGroup(row: IClickhouseGroup): IServiceGroup { + return { + id: row.id, + projectId: row.project_id, + type: row.type, + name: row.name, + properties: row.properties, + createdAt: new Date(row.created_at), + updatedAt: new Date(Number(row.version)), + }; +} + +async function writeGroupToCh( + group: { + id: string; + projectId: string; + type: string; + name: string; + properties: Record; + createdAt?: Date; + }, + deleted = 0 +) { + await ch.insert({ + format: 'JSONEachRow', + table: TABLE_NAMES.groups, + values: [ + { + project_id: group.projectId, + id: group.id, + type: group.type, + name: group.name, + properties: group.properties, + created_at: formatClickhouseDate(group.createdAt ?? new Date()), + version: Date.now(), + deleted, + }, + ], + }); +} + +export async function upsertGroup(input: IServiceUpsertGroup) { + const existing = await getGroupById(input.id, input.projectId); + await writeGroupToCh({ + id: input.id, + projectId: input.projectId, + type: input.type, + name: input.name, + properties: toDots({ + ...(existing?.properties ?? {}), + ...(input.properties ?? {}), + }), + createdAt: existing?.createdAt, }); } @@ -45,23 +97,13 @@ export async function getGroupById( id: string, projectId: string ): Promise { - const group = await db.group.findUnique({ - where: { projectId_id: { projectId, id } }, - }); - - if (!group) { - return null; - } - - return { - id: group.id, - projectId: group.projectId, - type: group.type, - name: group.name, - properties: (group.properties as Record) ?? {}, - createdAt: group.createdAt, - updatedAt: group.updatedAt, - }; + const rows = await chQuery(` + SELECT project_id, id, type, name, properties, created_at, version + FROM ${TABLE_NAMES.groups} FINAL + WHERE project_id = ${sqlstring.escape(projectId)} + AND id = ${sqlstring.escape(id)} + `); + return rows[0] ? transformGroup(rows[0]) : null; } export async function getGroupList({ @@ -77,33 +119,25 @@ export async function getGroupList({ search?: string; type?: string; }): Promise { - const groups = await db.group.findMany({ - where: { - projectId, - ...(type ? { type } : {}), - ...(search - ? { - OR: [ - { name: { contains: search, mode: 'insensitive' } }, - { id: { contains: search, mode: 'insensitive' } }, - ], - } - : {}), - }, - orderBy: { createdAt: 'desc' }, - take, - skip: cursor, - }); + const conditions = [ + `project_id = ${sqlstring.escape(projectId)}`, + ...(type ? [`type = ${sqlstring.escape(type)}`] : []), + ...(search + ? [ + `(name ILIKE ${sqlstring.escape(`%${search}%`)} OR id ILIKE ${sqlstring.escape(`%${search}%`)})`, + ] + : []), + ]; - return groups.map((group) => ({ - id: group.id, - projectId: group.projectId, - type: group.type, - name: group.name, - properties: (group.properties as Record) ?? {}, - createdAt: group.createdAt, - updatedAt: group.updatedAt, - })); + const rows = await chQuery(` + SELECT project_id, id, type, name, properties, created_at, version + FROM ${TABLE_NAMES.groups} FINAL + WHERE ${conditions.join(' AND ')} + ORDER BY created_at DESC + LIMIT ${take} + OFFSET ${cursor ?? 0} + `); + return rows.map(transformGroup); } export async function getGroupListCount({ @@ -115,33 +149,182 @@ export async function getGroupListCount({ type?: string; search?: string; }): Promise { - return db.group.count({ - where: { - projectId, - ...(type ? { type } : {}), - ...(search - ? { - OR: [ - { name: { contains: search, mode: 'insensitive' } }, - { id: { contains: search, mode: 'insensitive' } }, - ], - } - : {}), - }, - }); + const conditions = [ + `project_id = ${sqlstring.escape(projectId)}`, + ...(type ? [`type = ${sqlstring.escape(type)}`] : []), + ...(search + ? [ + `(name ILIKE ${sqlstring.escape(`%${search}%`)} OR id ILIKE ${sqlstring.escape(`%${search}%`)})`, + ] + : []), + ]; + + const rows = await chQuery<{ count: number }>(` + SELECT count() as count + FROM ${TABLE_NAMES.groups} FINAL + WHERE ${conditions.join(' AND ')} + `); + return rows[0]?.count ?? 0; } export async function getGroupTypes(projectId: string): Promise { - const groups = await db.group.findMany({ - where: { projectId }, - select: { type: true }, - distinct: ['type'], + const rows = await chQuery<{ type: string }>(` + SELECT DISTINCT type + FROM ${TABLE_NAMES.groups} FINAL + WHERE project_id = ${sqlstring.escape(projectId)} + `); + return rows.map((r) => r.type); +} + +export async function createGroup(input: IServiceUpsertGroup) { + const { id, projectId, type, name, properties = {} } = input; + await writeGroupToCh({ + id, + projectId, + type, + name, + properties: properties as Record, + createdAt: new Date(), }); - return groups.map((g) => g.type); + return getGroupById(id, projectId); +} + +export async function updateGroup( + id: string, + projectId: string, + data: { type?: string; name?: string; properties?: Record } +) { + const existing = await getGroupById(id, projectId); + if (!existing) { + throw new Error(`Group ${id} not found`); + } + const updated = { + id, + projectId, + type: data.type ?? existing.type, + name: data.name ?? existing.name, + properties: (data.properties ?? existing.properties) as Record< + string, + string + >, + createdAt: existing.createdAt, + }; + await writeGroupToCh(updated); + return { ...existing, ...updated }; } export async function deleteGroup(id: string, projectId: string) { - return db.group.delete({ - where: { projectId_id: { projectId, id } }, - }); + const existing = await getGroupById(id, projectId); + if (!existing) { + throw new Error(`Group ${id} not found`); + } + await writeGroupToCh( + { + id, + projectId, + type: existing.type, + name: existing.name, + properties: existing.properties as Record, + createdAt: existing.createdAt, + }, + 1 + ); + return existing; +} + +export async function getGroupPropertyKeys( + projectId: string +): Promise { + const rows = await chQuery<{ key: string }>(` + SELECT DISTINCT arrayJoin(mapKeys(properties)) as key + FROM ${TABLE_NAMES.groups} FINAL + WHERE project_id = ${sqlstring.escape(projectId)} + `); + return rows.map((r) => r.key).sort(); +} + +export async function getGroupsByIds( + projectId: string, + ids: string[] +): Promise { + if (ids.length === 0) { + return []; + } + + const rows = await chQuery(` + SELECT project_id, id, type, name, properties, created_at, version + FROM ${TABLE_NAMES.groups} FINAL + WHERE project_id = ${sqlstring.escape(projectId)} + AND id IN (${ids.map((id) => sqlstring.escape(id)).join(',')}) + AND deleted = 0 + `); + return rows.map(transformGroup); +} + +export async function getGroupMemberProfiles({ + projectId, + groupId, + cursor, + take, + search, +}: { + projectId: string; + groupId: string; + cursor?: number; + take: number; + search?: string; +}): Promise<{ data: IServiceProfile[]; count: number }> { + const offset = Math.max(0, (cursor ?? 0) * take); + const searchCondition = search?.trim() + ? `AND (email ILIKE ${sqlstring.escape(`%${search.trim()}%`)} OR first_name ILIKE ${sqlstring.escape(`%${search.trim()}%`)} OR last_name ILIKE ${sqlstring.escape(`%${search.trim()}%`)})` + : ''; + + const countResult = await chQuery<{ count: number }>(` + SELECT count() AS count + FROM ( + SELECT profile_id + FROM ${TABLE_NAMES.events} + WHERE project_id = ${sqlstring.escape(projectId)} + AND has(groups, ${sqlstring.escape(groupId)}) + AND profile_id != device_id + GROUP BY profile_id + ) gm + INNER JOIN ( + SELECT id FROM ${TABLE_NAMES.profiles} FINAL + WHERE project_id = ${sqlstring.escape(projectId)} + ${searchCondition} + ) p ON p.id = gm.profile_id + `); + const count = countResult[0]?.count ?? 0; + + const idRows = await chQuery<{ profile_id: string }>(` + SELECT gm.profile_id + FROM ( + SELECT profile_id, max(created_at) AS last_seen + FROM ${TABLE_NAMES.events} + WHERE project_id = ${sqlstring.escape(projectId)} + AND has(groups, ${sqlstring.escape(groupId)}) + AND profile_id != device_id + GROUP BY profile_id + ORDER BY last_seen DESC + ) gm + INNER JOIN ( + SELECT id FROM ${TABLE_NAMES.profiles} FINAL + WHERE project_id = ${sqlstring.escape(projectId)} + ${searchCondition} + ) p ON p.id = gm.profile_id + ORDER BY gm.last_seen DESC + LIMIT ${take} + OFFSET ${offset} + `); + const profileIds = idRows.map((r) => r.profile_id); + if (profileIds.length === 0) { + return { data: [], count }; + } + const profiles = await getProfiles(profileIds, projectId); + const byId = new Map(profiles.map((p) => [p.id, p])); + const data = profileIds + .map((id) => byId.get(id)) + .filter(Boolean) as IServiceProfile[]; + return { data, count }; } diff --git a/packages/db/src/services/profile.service.ts b/packages/db/src/services/profile.service.ts index 424382b4..bdd677a7 100644 --- a/packages/db/src/services/profile.service.ts +++ b/packages/db/src/services/profile.service.ts @@ -165,7 +165,8 @@ export async function getProfiles(ids: string[], projectId: string) { any(nullIf(avatar, '')) as avatar, last_value(is_external) as is_external, any(properties) as properties, - any(created_at) as created_at + any(created_at) as created_at, + any(groups) as groups FROM ${TABLE_NAMES.profiles} WHERE project_id = ${sqlstring.escape(projectId)} AND @@ -232,6 +233,7 @@ export interface IServiceProfile { createdAt: Date; isExternal: boolean; projectId: string; + groups: string[]; properties: Record & { region?: string; country?: string; @@ -259,6 +261,7 @@ export interface IClickhouseProfile { project_id: string; is_external: boolean; created_at: string; + groups: string[]; } export interface IServiceUpsertProfile { @@ -270,6 +273,7 @@ export interface IServiceUpsertProfile { avatar?: string; properties?: Record; isExternal: boolean; + groups?: string[]; } export function transformProfile({ @@ -288,6 +292,7 @@ export function transformProfile({ id: profile.id, email: profile.email, avatar: profile.avatar, + groups: profile.groups ?? [], }; } @@ -301,6 +306,7 @@ export function upsertProfile( properties, projectId, isExternal, + groups, }: IServiceUpsertProfile, isFromEvent = false ) { @@ -314,6 +320,7 @@ export function upsertProfile( project_id: projectId, created_at: formatClickhouseDate(new Date()), is_external: isExternal, + groups: groups ?? [], }; return profileBuffer.add(profile, isFromEvent); diff --git a/packages/db/src/services/session.service.ts b/packages/db/src/services/session.service.ts index ed95f625..636cf452 100644 --- a/packages/db/src/services/session.service.ts +++ b/packages/db/src/services/session.service.ts @@ -55,6 +55,7 @@ export interface IClickhouseSession { version: number; // Dynamically added has_replay?: boolean; + groups: string[]; } export interface IServiceSession { @@ -95,6 +96,7 @@ export interface IServiceSession { revenue: number; profile?: IServiceProfile; hasReplay?: boolean; + groups: string[]; } export interface GetSessionListOptions { @@ -152,6 +154,7 @@ export function transformSession(session: IClickhouseSession): IServiceSession { revenue: session.revenue, profile: undefined, hasReplay: session.has_replay, + groups: session.groups, }; } @@ -244,6 +247,7 @@ export async function getSessionList(options: GetSessionListOptions) { 'screen_view_count', 'event_count', 'revenue', + 'groups', ]; columns.forEach((column) => { @@ -292,6 +296,7 @@ export async function getSessionList(options: GetSessionListOptions) { projectId, isExternal: false, properties: {}, + groups: [], }, })); diff --git a/packages/importer/src/providers/mixpanel.ts b/packages/importer/src/providers/mixpanel.ts index c3676b4e..43cf6eb8 100644 --- a/packages/importer/src/providers/mixpanel.ts +++ b/packages/importer/src/providers/mixpanel.ts @@ -396,6 +396,7 @@ export class MixpanelProvider extends BaseImportProvider { properties, created_at: createdAt, is_external: true, + groups: [], }; } @@ -536,6 +537,7 @@ export class MixpanelProvider extends BaseImportProvider { ? `${this.provider} (${props.mp_lib})` : this.provider, sdk_version: this.version, + groups: [], }; // TODO: Remove this diff --git a/packages/importer/src/providers/umami.ts b/packages/importer/src/providers/umami.ts index c6947329..d2b5b043 100644 --- a/packages/importer/src/providers/umami.ts +++ b/packages/importer/src/providers/umami.ts @@ -337,6 +337,7 @@ export class UmamiProvider extends BaseImportProvider { imported_at: new Date().toISOString(), sdk_name: this.provider, sdk_version: this.version, + groups: [], }; } diff --git a/packages/logger/index.ts b/packages/logger/index.ts index 3c471a93..938e7665 100644 --- a/packages/logger/index.ts +++ b/packages/logger/index.ts @@ -66,7 +66,9 @@ export function createLogger({ name }: { name: string }): ILogger { const redactSensitiveInfo = winston.format((info) => { const redactObject = (obj: any): any => { - if (!obj || typeof obj !== 'object') return obj; + if (!obj || typeof obj !== 'object') { + return obj; + } return Object.keys(obj).reduce((acc, key) => { const lowerKey = key.toLowerCase(); @@ -85,7 +87,7 @@ export function createLogger({ name }: { name: string }): ILogger { }, {} as any); }; - return Object.assign({}, info, redactObject(info)); + return { ...info, ...redactObject(info) }; }); const transports: winston.transport[] = []; @@ -96,12 +98,12 @@ export function createLogger({ name }: { name: string }): ILogger { HyperDX.getWinstonTransport(logLevel, { detectResources: true, service, - }), + }) ); format = winston.format.combine( errorFormatter(), redactSensitiveInfo(), - winston.format.json(), + winston.format.json() ); } else { transports.push(new winston.transports.Console()); @@ -116,7 +118,7 @@ export function createLogger({ name }: { name: string }): ILogger { const metaStr = Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta)}` : ''; return `${level} ${message}${metaStr}`; - }), + }) ); } @@ -126,7 +128,7 @@ export function createLogger({ name }: { name: string }): ILogger { format, transports, silent, - levels: Object.assign({}, customLevels, winston.config.syslog.levels), + levels: { ...customLevels, ...winston.config.syslog.levels }, }); return logger; diff --git a/packages/sdks/sdk/src/index.ts b/packages/sdks/sdk/src/index.ts index 34f52ec7..e2b2bf76 100644 --- a/packages/sdks/sdk/src/index.ts +++ b/packages/sdks/sdk/src/index.ts @@ -203,6 +203,7 @@ export class OpenPanel { payload: { id: groupId, ...metadata, + profileId: this.profileId, }, }); } @@ -297,6 +298,12 @@ export class OpenPanel { ), } as TrackHandlerPayload['payload']; } + if (item.type === 'group') { + return { + ...item.payload, + profileId: item.payload.profileId ?? this.profileId, + }; + } return item.payload; } diff --git a/packages/trpc/src/routers/chart.ts b/packages/trpc/src/routers/chart.ts index cd6b07e1..1eec7dde 100644 --- a/packages/trpc/src/routers/chart.ts +++ b/packages/trpc/src/routers/chart.ts @@ -13,11 +13,12 @@ import { getChartStartEndDate, getEventFiltersWhereClause, getEventMetasCached, + getGroupPropertySelect, + getProfilePropertySelect, getProfilesCached, getReportById, getSelectPropertyKey, getSettingsForProject, - type IClickhouseProfile, type IServiceProfile, onlyReportEvents, sankeyService, @@ -354,6 +355,32 @@ export const chartRouter = createTRPCRouter({ const res = await query.execute(); values.push(...res.map((e) => e.property_value)); + } else if (property.startsWith('profile.')) { + const selectExpr = getProfilePropertySelect(property); + const query = clix(ch) + .select<{ values: string }>([`distinct ${selectExpr} as values`]) + .from(TABLE_NAMES.profiles, true) + .where('project_id', '=', projectId) + .where(selectExpr, '!=', '') + .where(selectExpr, 'IS NOT NULL', null) + .orderBy('created_at', 'DESC') + .limit(100_000); + + const res = await query.execute(); + values.push(...res.map((r) => String(r.values)).filter(Boolean)); + } else if (property.startsWith('group.')) { + const selectExpr = getGroupPropertySelect(property); + const query = clix(ch) + .select<{ values: string }>([`distinct ${selectExpr} as values`]) + .from(TABLE_NAMES.groups, true) + .where('project_id', '=', projectId) + .where(selectExpr, '!=', '') + .where(selectExpr, 'IS NOT NULL', null) + .orderBy('created_at', 'DESC') + .limit(100_000); + + const res = await query.execute(); + values.push(...res.map((r) => String(r.values)).filter(Boolean)); } else { const query = clix(ch) .select<{ values: string[] }>([ @@ -369,17 +396,6 @@ export const chartRouter = createTRPCRouter({ query.where('name', '=', event); } - if (property.startsWith('profile.')) { - query.leftAnyJoin( - clix(ch) - .select([]) - .from(TABLE_NAMES.profiles) - .where('project_id', '=', projectId), - 'profile.id = profile_id', - 'profile' - ); - } - const events = await query.execute(); values.push( @@ -785,7 +801,7 @@ export const chartRouter = createTRPCRouter({ const { sb, getSql } = createSqlBuilder(); sb.select.profile_id = 'DISTINCT profile_id'; - sb.where = getEventFiltersWhereClause(serie.filters); + sb.where = getEventFiltersWhereClause(serie.filters, projectId); sb.where.projectId = `project_id = ${sqlstring.escape(projectId)}`; sb.where.dateRange = `${clix.toStartOf('created_at', input.interval)} = ${clix.toDate(sqlstring.escape(formatClickhouseDate(dateObj)), input.interval)}`; if (serie.name !== '*') { @@ -812,10 +828,22 @@ export const chartRouter = createTRPCRouter({ sb.joins.profiles = `LEFT ANY JOIN (SELECT id, ${fieldsToSelect} FROM ${TABLE_NAMES.profiles} FINAL WHERE project_id = ${sqlstring.escape(projectId)}) as profile on profile.id = profile_id`; } + // Check for group filters/breakdowns and add ARRAY JOIN if needed + const anyFilterOnGroup = serie.filters.some((f) => + f.name.startsWith('group.') + ); + const anyBreakdownOnGroup = input.breakdowns + ? Object.keys(input.breakdowns).some((key) => key.startsWith('group.')) + : false; + if (anyFilterOnGroup || anyBreakdownOnGroup) { + sb.joins.groups = 'ARRAY JOIN groups AS _group_id'; + sb.joins.groups_cte = `LEFT ANY JOIN (SELECT id, name, type, properties FROM ${TABLE_NAMES.groups} FINAL WHERE project_id = ${sqlstring.escape(projectId)}) AS _g ON _g.id = _group_id`; + } + if (input.breakdowns) { Object.entries(input.breakdowns).forEach(([key, value]) => { // Transform property keys (e.g., properties.method -> properties['method']) - const propertyKey = getSelectPropertyKey(key); + const propertyKey = getSelectPropertyKey(key, projectId); sb.where[`breakdown_${key}`] = `${propertyKey} = ${sqlstring.escape(value)}`; }); @@ -858,6 +886,7 @@ export const chartRouter = createTRPCRouter({ funnelWindow: z.number().optional(), funnelGroup: z.string().optional(), breakdowns: z.array(z.object({ name: z.string() })).optional(), + breakdownValues: z.array(z.string()).optional(), range: zRange, }) ) @@ -870,6 +899,8 @@ export const chartRouter = createTRPCRouter({ showDropoffs = false, funnelWindow, funnelGroup, + breakdowns = [], + breakdownValues = [], } = input; const { startDate, endDate } = getChartStartEndDate(input, timezone); @@ -889,9 +920,21 @@ export const chartRouter = createTRPCRouter({ // Get the grouping strategy (profile_id or session_id) const group = funnelService.getFunnelGroup(funnelGroup); + const anyFilterOnGroup = (eventSeries as IChartEvent[]).some((e) => + e.filters?.some((f) => f.name.startsWith('group.')) + ); + const anyBreakdownOnGroup = breakdowns.some((b) => + b.name.startsWith('group.') + ); + const needsGroupArrayJoin = anyFilterOnGroup || anyBreakdownOnGroup; + + // Breakdown selects/groupBy so we can filter by specific breakdown values + const breakdownSelects = breakdowns.map( + (b, index) => `${getSelectPropertyKey(b.name, projectId)} as b_${index}` + ); + const breakdownGroupBy = breakdowns.map((_, index) => `b_${index}`); + // Create funnel CTE using funnel service - // Note: buildFunnelCte always computes windowFunnel per session_id and extracts - // profile_id via argMax to handle identity changes mid-session correctly. const funnelCte = funnelService.buildFunnelCte({ projectId, startDate, @@ -899,8 +942,8 @@ export const chartRouter = createTRPCRouter({ eventSeries: eventSeries as IChartEvent[], funnelWindowMilliseconds, timezone, - // No need to add profile_id to additionalSelects/additionalGroupBy - // since buildFunnelCte already extracts it via argMax(profile_id, created_at) + additionalSelects: breakdownSelects, + additionalGroupBy: breakdownGroupBy, }); // Check for profile filters and add profile join if needed @@ -917,36 +960,50 @@ export const chartRouter = createTRPCRouter({ ); } + if (needsGroupArrayJoin) { + funnelCte.rawJoin('ARRAY JOIN groups AS _group_id'); + funnelCte.rawJoin('LEFT ANY JOIN _g ON _g.id = _group_id'); + } + // Build main query const query = clix(ch, timezone); + if (needsGroupArrayJoin) { + query.with( + '_g', + `SELECT id, name, type, properties FROM ${TABLE_NAMES.groups} FINAL WHERE project_id = ${sqlstring.escape(projectId)}` + ); + } query.with('session_funnel', funnelCte); if (group === 'profile_id') { - // For profile grouping: re-aggregate by profile_id, taking MAX level per profile. - // This ensures a user who completed the funnel with identity change is counted correctly. - // NOTE: Wrap in subquery to avoid ClickHouse resolving `level` in WHERE to the - // `max(level) AS level` alias (ILLEGAL_AGGREGATION error). + const breakdownAggregates = + breakdowns.length > 0 + ? `, ${breakdowns.map((_, index) => `any(b_${index}) AS b_${index}`).join(', ')}` + : ''; query.with( 'funnel', - 'SELECT profile_id, max(level) AS level FROM (SELECT * FROM session_funnel WHERE level != 0) GROUP BY profile_id' + `SELECT profile_id, max(level) AS level${breakdownAggregates} FROM (SELECT * FROM session_funnel WHERE level != 0) GROUP BY profile_id` ); } else { - // For session grouping: filter out level = 0 inside the CTE query.with('funnel', 'SELECT * FROM session_funnel WHERE level != 0'); } - // Get distinct profile IDs - // NOTE: level != 0 is already filtered inside the funnel CTE above query.select(['DISTINCT profile_id']).from('funnel'); if (showDropoffs) { - // Show users who dropped off at this step (completed this step but not the next) query.where('level', '=', targetLevel); } else { - // Show users who completed at least this step query.where('level', '>=', targetLevel); } + // Filter by specific breakdown values when a breakdown row was clicked + breakdowns.forEach((_, index) => { + const value = breakdownValues[index]; + if (value !== undefined) { + query.where(`b_${index}`, '=', value); + } + }); + // Cap the number of profiles to avoid exceeding ClickHouse max_query_size // when passing IDs to the next query query.limit(1000); diff --git a/packages/trpc/src/routers/event.ts b/packages/trpc/src/routers/event.ts index 385de6b1..e7ac6c06 100644 --- a/packages/trpc/src/routers/event.ts +++ b/packages/trpc/src/routers/event.ts @@ -122,6 +122,7 @@ export const eventRouter = createTRPCRouter({ projectId: z.string(), profileId: z.string().optional(), sessionId: z.string().optional(), + groupId: z.string().optional(), cursor: z.string().optional(), filters: z.array(zChartEventFilter).default([]), startDate: z.date().optional(), diff --git a/packages/trpc/src/routers/group.ts b/packages/trpc/src/routers/group.ts index 31fcc01e..23377db9 100644 --- a/packages/trpc/src/routers/group.ts +++ b/packages/trpc/src/routers/group.ts @@ -1,12 +1,16 @@ import { chQuery, - db, + createGroup, deleteGroup, getGroupById, getGroupList, getGroupListCount, + getGroupMemberProfiles, + getGroupPropertyKeys, + getGroupsByIds, getGroupTypes, TABLE_NAMES, + updateGroup, } from '@openpanel/db'; import sqlstring from 'sqlstring'; import { z } from 'zod'; @@ -37,6 +41,34 @@ export const groupRouter = createTRPCRouter({ return getGroupById(id, projectId); }), + create: protectedProcedure + .input( + z.object({ + id: z.string().min(1), + projectId: z.string(), + type: z.string().min(1), + name: z.string().min(1), + properties: z.record(z.string()).default({}), + }) + ) + .mutation(async ({ input }) => { + return createGroup(input); + }), + + update: protectedProcedure + .input( + z.object({ + id: z.string().min(1), + projectId: z.string(), + type: z.string().min(1).optional(), + name: z.string().min(1).optional(), + properties: z.record(z.string()).optional(), + }) + ) + .mutation(async ({ input: { id, projectId, ...data } }) => { + return updateGroup(id, projectId, data); + }), + delete: protectedProcedure .input(z.object({ id: z.string(), projectId: z.string() })) .mutation(async ({ input: { id, projectId } }) => { @@ -84,7 +116,7 @@ export const groupRouter = createTRPCRouter({ members: protectedProcedure .input(z.object({ id: z.string(), projectId: z.string() })) - .query(async ({ input: { id, projectId } }) => { + .query(({ input: { id, projectId } }) => { return chQuery<{ profileId: string; lastSeen: string; @@ -99,27 +131,44 @@ export const groupRouter = createTRPCRouter({ AND has(groups, ${sqlstring.escape(id)}) AND profile_id != device_id GROUP BY profile_id - ORDER BY lastSeen DESC - LIMIT 100 + ORDER BY lastSeen DESC, eventCount DESC + LIMIT 50 `); }), + listProfiles: protectedProcedure + .input( + z.object({ + projectId: z.string(), + groupId: z.string(), + cursor: z.number().optional(), + take: z.number().default(50), + search: z.string().optional(), + }) + ) + .query(async ({ input }) => { + const { data, count } = await getGroupMemberProfiles({ + projectId: input.projectId, + groupId: input.groupId, + cursor: input.cursor, + take: input.take, + search: input.search, + }); + return { + data, + meta: { count, pageCount: input.take }, + }; + }), + properties: protectedProcedure .input(z.object({ projectId: z.string() })) .query(async ({ input: { projectId } }) => { - // Returns distinct property keys across all groups for this project - // Used by breakdown/filter pickers in the chart builder - const groups = await db.group.findMany({ - where: { projectId }, - select: { properties: true }, - }); - const keys = new Set(); - for (const group of groups) { - const props = group.properties as Record; - for (const key of Object.keys(props)) { - keys.add(key); - } - } - return Array.from(keys).sort(); + return getGroupPropertyKeys(projectId); + }), + + listByIds: protectedProcedure + .input(z.object({ projectId: z.string(), ids: z.array(z.string()) })) + .query(async ({ input: { projectId, ids } }) => { + return getGroupsByIds(projectId, ids); }), }); diff --git a/packages/validation/src/track.validation.ts b/packages/validation/src/track.validation.ts index 93b65329..99c295fc 100644 --- a/packages/validation/src/track.validation.ts +++ b/packages/validation/src/track.validation.ts @@ -7,6 +7,7 @@ export const zGroupPayload = z.object({ type: z.string().min(1), name: z.string().min(1), properties: z.record(z.unknown()).optional(), + profileId: z.string().optional(), }); export const zTrackPayload = z diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68ec0464..65e8676c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -325,13 +325,13 @@ importers: version: 12.23.25(@emotion/is-prop-valid@0.8.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) fumadocs-core: specifier: 16.2.2 - version: 16.2.2(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 16.2.2(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) fumadocs-mdx: specifier: 14.0.4 - version: 14.0.4(fumadocs-core@16.2.2(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2)) + version: 14.0.4(fumadocs-core@16.2.2(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2)) fumadocs-ui: specifier: 16.2.2 - version: 16.2.2(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.17) + version: 16.2.2(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.17) geist: specifier: 1.5.1 version: 1.5.1(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) @@ -686,7 +686,7 @@ importers: version: 3.0.1 nuqs: specifier: ^2.5.2 - version: 2.5.2(patch_hash=4f93812bf7a04685f9477b2935e3acbf2aa24dfbb1b00b9ae0ed8f7a2877a98e)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.0.7(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + version: 2.5.2(patch_hash=4f93812bf7a04685f9477b2935e3acbf2aa24dfbb1b00b9ae0ed8f7a2877a98e)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.0.7(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-router-dom@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) prisma-error-enum: specifier: ^0.1.3 version: 0.1.3 @@ -884,6 +884,37 @@ importers: specifier: 4.59.1 version: 4.59.1 + apps/testbed: + dependencies: + '@openpanel/web': + specifier: workspace:* + version: link:../../packages/sdks/web + react: + specifier: ^19.0.0 + version: 19.2.3 + react-dom: + specifier: ^19.0.0 + version: 19.2.3(react@19.2.3) + react-router-dom: + specifier: ^7.13.1 + version: 7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + devDependencies: + '@types/react': + specifier: ^19.0.0 + version: 19.2.7 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.3(@types/react@19.2.7) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@6.3.5(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2)) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vite: + specifier: ^6.0.0 + version: 6.3.5(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2) + apps/worker: dependencies: '@bull-board/api': @@ -3374,12 +3405,6 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.25.9': - resolution: {integrity: sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.27.0': resolution: {integrity: sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==} engines: {node: '>=18'} @@ -3434,12 +3459,6 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.25.9': - resolution: {integrity: sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.27.0': resolution: {integrity: sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==} engines: {node: '>=18'} @@ -3494,12 +3513,6 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.25.9': - resolution: {integrity: sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.27.0': resolution: {integrity: sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==} engines: {node: '>=18'} @@ -3554,12 +3567,6 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.25.9': - resolution: {integrity: sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.27.0': resolution: {integrity: sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==} engines: {node: '>=18'} @@ -3614,12 +3621,6 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.25.9': - resolution: {integrity: sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.27.0': resolution: {integrity: sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==} engines: {node: '>=18'} @@ -3674,12 +3675,6 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.25.9': - resolution: {integrity: sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.27.0': resolution: {integrity: sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==} engines: {node: '>=18'} @@ -3734,12 +3729,6 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.25.9': - resolution: {integrity: sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.27.0': resolution: {integrity: sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==} engines: {node: '>=18'} @@ -3794,12 +3783,6 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.9': - resolution: {integrity: sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.27.0': resolution: {integrity: sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==} engines: {node: '>=18'} @@ -3854,12 +3837,6 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.25.9': - resolution: {integrity: sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.27.0': resolution: {integrity: sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==} engines: {node: '>=18'} @@ -3914,12 +3891,6 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.25.9': - resolution: {integrity: sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.27.0': resolution: {integrity: sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==} engines: {node: '>=18'} @@ -3974,12 +3945,6 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.25.9': - resolution: {integrity: sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.27.0': resolution: {integrity: sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==} engines: {node: '>=18'} @@ -4034,12 +3999,6 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.25.9': - resolution: {integrity: sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.27.0': resolution: {integrity: sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==} engines: {node: '>=18'} @@ -4094,12 +4053,6 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.25.9': - resolution: {integrity: sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.27.0': resolution: {integrity: sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==} engines: {node: '>=18'} @@ -4154,12 +4107,6 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.25.9': - resolution: {integrity: sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.27.0': resolution: {integrity: sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==} engines: {node: '>=18'} @@ -4214,12 +4161,6 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.25.9': - resolution: {integrity: sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.27.0': resolution: {integrity: sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==} engines: {node: '>=18'} @@ -4274,12 +4215,6 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.25.9': - resolution: {integrity: sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.27.0': resolution: {integrity: sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==} engines: {node: '>=18'} @@ -4334,12 +4269,6 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.25.9': - resolution: {integrity: sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.27.0': resolution: {integrity: sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==} engines: {node: '>=18'} @@ -4376,12 +4305,6 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.25.9': - resolution: {integrity: sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-arm64@0.27.0': resolution: {integrity: sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==} engines: {node: '>=18'} @@ -4436,12 +4359,6 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.9': - resolution: {integrity: sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.27.0': resolution: {integrity: sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==} engines: {node: '>=18'} @@ -4478,12 +4395,6 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.25.9': - resolution: {integrity: sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-arm64@0.27.0': resolution: {integrity: sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==} engines: {node: '>=18'} @@ -4538,12 +4449,6 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.9': - resolution: {integrity: sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.27.0': resolution: {integrity: sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==} engines: {node: '>=18'} @@ -4568,12 +4473,6 @@ packages: cpu: [arm64] os: [openharmony] - '@esbuild/openharmony-arm64@0.25.9': - resolution: {integrity: sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/openharmony-arm64@0.27.0': resolution: {integrity: sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==} engines: {node: '>=18'} @@ -4628,12 +4527,6 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.25.9': - resolution: {integrity: sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.27.0': resolution: {integrity: sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==} engines: {node: '>=18'} @@ -4688,12 +4581,6 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.25.9': - resolution: {integrity: sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.27.0': resolution: {integrity: sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==} engines: {node: '>=18'} @@ -4748,12 +4635,6 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.25.9': - resolution: {integrity: sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.27.0': resolution: {integrity: sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==} engines: {node: '>=18'} @@ -4808,12 +4689,6 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.25.9': - resolution: {integrity: sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.27.0': resolution: {integrity: sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==} engines: {node: '>=18'} @@ -8344,16 +8219,6 @@ packages: cpu: [arm] os: [android] - '@rollup/rollup-android-arm-eabi@4.40.1': - resolution: {integrity: sha512-kxz0YeeCrRUHz3zyqvd7n+TVRlNyTifBsmnmNPtk3hQURUyG9eAB+usz6DAwagMusjx/zb3AjvDUvhFGDAexGw==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm-eabi@4.48.1': - resolution: {integrity: sha512-rGmb8qoG/zdmKoYELCBwu7vt+9HxZ7Koos3pD0+sH5fR3u3Wb/jGcpnqxcnWsPEKDUyzeLSqksN8LJtgXjqBYw==} - cpu: [arm] - os: [android] - '@rollup/rollup-android-arm-eabi@4.52.5': resolution: {integrity: sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==} cpu: [arm] @@ -8364,16 +8229,6 @@ packages: cpu: [arm64] os: [android] - '@rollup/rollup-android-arm64@4.40.1': - resolution: {integrity: sha512-PPkxTOisoNC6TpnDKatjKkjRMsdaWIhyuMkA4UsBXT9WEZY4uHezBTjs6Vl4PbqQQeu6oION1w2voYZv9yquCw==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-android-arm64@4.48.1': - resolution: {integrity: sha512-4e9WtTxrk3gu1DFE+imNJr4WsL13nWbD/Y6wQcyku5qadlKHY3OQ3LJ/INrrjngv2BJIHnIzbqMk1GTAC2P8yQ==} - cpu: [arm64] - os: [android] - '@rollup/rollup-android-arm64@4.52.5': resolution: {integrity: sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==} cpu: [arm64] @@ -8384,16 +8239,6 @@ packages: cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-arm64@4.40.1': - resolution: {integrity: sha512-VWXGISWFY18v/0JyNUy4A46KCFCb9NVsH+1100XP31lud+TzlezBbz24CYzbnA4x6w4hx+NYCXDfnvDVO6lcAA==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-arm64@4.48.1': - resolution: {integrity: sha512-+XjmyChHfc4TSs6WUQGmVf7Hkg8ferMAE2aNYYWjiLzAS/T62uOsdfnqv+GHRjq7rKRnYh4mwWb4Hz7h/alp8A==} - cpu: [arm64] - os: [darwin] - '@rollup/rollup-darwin-arm64@4.52.5': resolution: {integrity: sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==} cpu: [arm64] @@ -8404,46 +8249,16 @@ packages: cpu: [x64] os: [darwin] - '@rollup/rollup-darwin-x64@4.40.1': - resolution: {integrity: sha512-nIwkXafAI1/QCS7pxSpv/ZtFW6TXcNUEHAIA9EIyw5OzxJZQ1YDrX+CL6JAIQgZ33CInl1R6mHet9Y/UZTg2Bw==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.48.1': - resolution: {integrity: sha512-upGEY7Ftw8M6BAJyGwnwMw91rSqXTcOKZnnveKrVWsMTF8/k5mleKSuh7D4v4IV1pLxKAk3Tbs0Lo9qYmii5mQ==} - cpu: [x64] - os: [darwin] - '@rollup/rollup-darwin-x64@4.52.5': resolution: {integrity: sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.40.1': - resolution: {integrity: sha512-BdrLJ2mHTrIYdaS2I99mriyJfGGenSaP+UwGi1kB9BLOCu9SR8ZpbkmmalKIALnRw24kM7qCN0IOm6L0S44iWw==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-arm64@4.48.1': - resolution: {integrity: sha512-P9ViWakdoynYFUOZhqq97vBrhuvRLAbN/p2tAVJvhLb8SvN7rbBnJQcBu8e/rQts42pXGLVhfsAP0k9KXWa3nQ==} - cpu: [arm64] - os: [freebsd] - '@rollup/rollup-freebsd-arm64@4.52.5': resolution: {integrity: sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.40.1': - resolution: {integrity: sha512-VXeo/puqvCG8JBPNZXZf5Dqq7BzElNJzHRRw3vjBE27WujdzuOPecDPc/+1DcdcTptNBep3861jNq0mYkT8Z6Q==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.48.1': - resolution: {integrity: sha512-VLKIwIpnBya5/saccM8JshpbxfyJt0Dsli0PjXozHwbSVaHTvWXJH1bbCwPXxnMzU4zVEfgD1HpW3VQHomi2AQ==} - cpu: [x64] - os: [freebsd] - '@rollup/rollup-freebsd-x64@4.52.5': resolution: {integrity: sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==} cpu: [x64] @@ -8454,31 +8269,11 @@ packages: cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-gnueabihf@4.40.1': - resolution: {integrity: sha512-ehSKrewwsESPt1TgSE/na9nIhWCosfGSFqv7vwEtjyAqZcvbGIg4JAcV7ZEh2tfj/IlfBeZjgOXm35iOOjadcg==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm-gnueabihf@4.48.1': - resolution: {integrity: sha512-3zEuZsXfKaw8n/yF7t8N6NNdhyFw3s8xJTqjbTDXlipwrEHo4GtIKcMJr5Ed29leLpB9AugtAQpAHW0jvtKKaQ==} - cpu: [arm] - os: [linux] - '@rollup/rollup-linux-arm-gnueabihf@4.52.5': resolution: {integrity: sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.40.1': - resolution: {integrity: sha512-m39iO/aaurh5FVIu/F4/Zsl8xppd76S4qoID8E+dSRQvTyZTOI2gVk3T4oqzfq1PtcvOfAVlwLMK3KRQMaR8lg==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm-musleabihf@4.48.1': - resolution: {integrity: sha512-leo9tOIlKrcBmmEypzunV/2w946JeLbTdDlwEZ7OnnsUyelZ72NMnT4B2vsikSgwQifjnJUbdXzuW4ToN1wV+Q==} - cpu: [arm] - os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.52.5': resolution: {integrity: sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==} cpu: [arm] @@ -8489,16 +8284,6 @@ packages: cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.40.1': - resolution: {integrity: sha512-Y+GHnGaku4aVLSgrT0uWe2o2Rq8te9hi+MwqGF9r9ORgXhmHK5Q71N757u0F8yU1OIwUIFy6YiJtKjtyktk5hg==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-arm64-gnu@4.48.1': - resolution: {integrity: sha512-Vy/WS4z4jEyvnJm+CnPfExIv5sSKqZrUr98h03hpAMbE2aI0aD2wvK6GiSe8Gx2wGp3eD81cYDpLLBqNb2ydwQ==} - cpu: [arm64] - os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.52.5': resolution: {integrity: sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==} cpu: [arm64] @@ -8509,16 +8294,6 @@ packages: cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.40.1': - resolution: {integrity: sha512-jEwjn3jCA+tQGswK3aEWcD09/7M5wGwc6+flhva7dsQNRZZTe30vkalgIzV4tjkopsTS9Jd7Y1Bsj6a4lzz8gQ==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-arm64-musl@4.48.1': - resolution: {integrity: sha512-x5Kzn7XTwIssU9UYqWDB9VpLpfHYuXw5c6bJr4Mzv9kIv242vmJHbI5PJJEnmBYitUIfoMCODDhR7KoZLot2VQ==} - cpu: [arm64] - os: [linux] - '@rollup/rollup-linux-arm64-musl@4.52.5': resolution: {integrity: sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==} cpu: [arm64] @@ -8529,26 +8304,6 @@ packages: cpu: [loong64] os: [linux] - '@rollup/rollup-linux-loongarch64-gnu@4.40.1': - resolution: {integrity: sha512-ySyWikVhNzv+BV/IDCsrraOAZ3UaC8SZB67FZlqVwXwnFhPihOso9rPOxzZbjp81suB1O2Topw+6Ug3JNegejQ==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-loongarch64-gnu@4.48.1': - resolution: {integrity: sha512-yzCaBbwkkWt/EcgJOKDUdUpMHjhiZT/eDktOPWvSRpqrVE04p0Nd6EGV4/g7MARXXeOqstflqsKuXVM3H9wOIQ==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-powerpc64le-gnu@4.40.1': - resolution: {integrity: sha512-BvvA64QxZlh7WZWqDPPdt0GH4bznuL6uOO1pmgPnnv86rpUpc8ZxgZwcEgXvo02GRIZX1hQ0j0pAnhwkhwPqWg==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-ppc64-gnu@4.48.1': - resolution: {integrity: sha512-UK0WzWUjMAJccHIeOpPhPcKBqax7QFg47hwZTp6kiMhQHeOYJeaMwzeRZe1q5IiTKsaLnHu9s6toSYVUlZ2QtQ==} - cpu: [ppc64] - os: [linux] - '@rollup/rollup-linux-ppc64-gnu@4.52.5': resolution: {integrity: sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==} cpu: [ppc64] @@ -8559,46 +8314,16 @@ packages: cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.40.1': - resolution: {integrity: sha512-EQSP+8+1VuSulm9RKSMKitTav89fKbHymTf25n5+Yr6gAPZxYWpj3DzAsQqoaHAk9YX2lwEyAf9S4W8F4l3VBQ==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-riscv64-gnu@4.48.1': - resolution: {integrity: sha512-3NADEIlt+aCdCbWVZ7D3tBjBX1lHpXxcvrLt/kdXTiBrOds8APTdtk2yRL2GgmnSVeX4YS1JIf0imFujg78vpw==} - cpu: [riscv64] - os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.52.5': resolution: {integrity: sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-riscv64-musl@4.40.1': - resolution: {integrity: sha512-n/vQ4xRZXKuIpqukkMXZt9RWdl+2zgGNx7Uda8NtmLJ06NL8jiHxUawbwC+hdSq1rrw/9CghCpEONor+l1e2gA==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-riscv64-musl@4.48.1': - resolution: {integrity: sha512-euuwm/QTXAMOcyiFCcrx0/S2jGvFlKJ2Iro8rsmYL53dlblp3LkUQVFzEidHhvIPPvcIsxDhl2wkBE+I6YVGzA==} - cpu: [riscv64] - os: [linux] - '@rollup/rollup-linux-riscv64-musl@4.52.5': resolution: {integrity: sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.40.1': - resolution: {integrity: sha512-h8d28xzYb98fMQKUz0w2fMc1XuGzLLjdyxVIbhbil4ELfk5/orZlSTpF/xdI9C8K0I8lCkq+1En2RJsawZekkg==} - cpu: [s390x] - os: [linux] - - '@rollup/rollup-linux-s390x-gnu@4.48.1': - resolution: {integrity: sha512-w8mULUjmPdWLJgmTYJx/W6Qhln1a+yqvgwmGXcQl2vFBkWsKGUBRbtLRuKJUln8Uaimf07zgJNxOhHOvjSQmBQ==} - cpu: [s390x] - os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.52.5': resolution: {integrity: sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==} cpu: [s390x] @@ -8609,16 +8334,6 @@ packages: cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.40.1': - resolution: {integrity: sha512-XiK5z70PEFEFqcNj3/zRSz/qX4bp4QIraTy9QjwJAb/Z8GM7kVUsD0Uk8maIPeTyPCP03ChdI+VVmJriKYbRHQ==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-linux-x64-gnu@4.48.1': - resolution: {integrity: sha512-90taWXCWxTbClWuMZD0DKYohY1EovA+W5iytpE89oUPmT5O1HFdf8cuuVIylE6vCbrGdIGv85lVRzTcpTRZ+kA==} - cpu: [x64] - os: [linux] - '@rollup/rollup-linux-x64-gnu@4.52.5': resolution: {integrity: sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==} cpu: [x64] @@ -8629,16 +8344,6 @@ packages: cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.40.1': - resolution: {integrity: sha512-2BRORitq5rQ4Da9blVovzNCMaUlyKrzMSvkVR0D4qPuOy/+pMCrh1d7o01RATwVy+6Fa1WBw+da7QPeLWU/1mQ==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-linux-x64-musl@4.48.1': - resolution: {integrity: sha512-2Gu29SkFh1FfTRuN1GR1afMuND2GKzlORQUP3mNMJbqdndOg7gNsa81JnORctazHRokiDzQ5+MLE5XYmZW5VWg==} - cpu: [x64] - os: [linux] - '@rollup/rollup-linux-x64-musl@4.52.5': resolution: {integrity: sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==} cpu: [x64] @@ -8654,16 +8359,6 @@ packages: cpu: [arm64] os: [win32] - '@rollup/rollup-win32-arm64-msvc@4.40.1': - resolution: {integrity: sha512-b2bcNm9Kbde03H+q+Jjw9tSfhYkzrDUf2d5MAd1bOJuVplXvFhWz7tRtWvD8/ORZi7qSCy0idW6tf2HgxSXQSg==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-arm64-msvc@4.48.1': - resolution: {integrity: sha512-6kQFR1WuAO50bxkIlAVeIYsz3RUx+xymwhTo9j94dJ+kmHe9ly7muH23sdfWduD0BA8pD9/yhonUvAjxGh34jQ==} - cpu: [arm64] - os: [win32] - '@rollup/rollup-win32-arm64-msvc@4.52.5': resolution: {integrity: sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==} cpu: [arm64] @@ -8674,16 +8369,6 @@ packages: cpu: [ia32] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.40.1': - resolution: {integrity: sha512-DfcogW8N7Zg7llVEfpqWMZcaErKfsj9VvmfSyRjCyo4BI3wPEfrzTtJkZG6gKP/Z92wFm6rz2aDO7/JfiR/whA==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.48.1': - resolution: {integrity: sha512-RUyZZ/mga88lMI3RlXFs4WQ7n3VyU07sPXmMG7/C1NOi8qisUg57Y7LRarqoGoAiopmGmChUhSwfpvQ3H5iGSQ==} - cpu: [ia32] - os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.52.5': resolution: {integrity: sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==} cpu: [ia32] @@ -8699,16 +8384,6 @@ packages: cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.40.1': - resolution: {integrity: sha512-ECyOuDeH3C1I8jH2MK1RtBJW+YPMvSfT0a5NN0nHfQYnDSJ6tUiZH3gzwVP5/Kfh/+Tt7tpWVF9LXNTnhTJ3kA==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.48.1': - resolution: {integrity: sha512-8a/caCUN4vkTChxkaIJcMtwIVcBhi4X2PQRoT+yCK3qRYaZ7cURrmJFL5Ux9H9RaMIXj9RuihckdmkBX3zZsgg==} - cpu: [x64] - os: [win32] - '@rollup/rollup-win32-x64-msvc@4.52.5': resolution: {integrity: sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==} cpu: [x64] @@ -9878,9 +9553,6 @@ packages: '@types/estree@1.0.5': resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} - '@types/estree@1.0.7': - resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==} - '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -10943,11 +10615,6 @@ packages: brotli@1.3.3: resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==} - browserslist@4.24.2: - resolution: {integrity: sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - browserslist@4.28.0: resolution: {integrity: sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -12195,9 +11862,6 @@ packages: electron-to-chromium@1.5.262: resolution: {integrity: sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==} - electron-to-chromium@1.5.49: - resolution: {integrity: sha512-ZXfs1Of8fDb6z7WEYZjXpgIRF6MEu8JdeGA0A40aZq6OQbS+eJpnnV49epZRna2DU/YsEjSQuGtQPPtvt6J65A==} - embla-carousel-autoplay@8.6.0: resolution: {integrity: sha512-OBu5G3nwaSXkZCo1A6LTaFMZ8EpkYbwIaH+bPqdBnDGQ2fh4+NbzjXjs2SktoPNKCtflfVMc75njaDHOYXcrsA==} peerDependencies: @@ -12374,11 +12038,6 @@ packages: engines: {node: '>=18'} hasBin: true - esbuild@0.25.9: - resolution: {integrity: sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==} - engines: {node: '>=18'} - hasBin: true - esbuild@0.27.0: resolution: {integrity: sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==} engines: {node: '>=18'} @@ -15284,9 +14943,6 @@ packages: node-mock-http@1.0.3: resolution: {integrity: sha512-jN8dK25fsfnMrVsEhluUTPkBFY+6ybu7jSB1n+ri/vOGjJxU8J9CZhpSGkHXSkFjtUhbmoncG/YG9ta5Ludqog==} - node-releases@2.0.18: - resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==} - node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} @@ -16487,6 +16143,23 @@ packages: peerDependencies: react: '>=16.8.0' + react-router-dom@7.13.1: + resolution: {integrity: sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.13.1: + resolution: {integrity: sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + react-shallow-renderer@16.15.0: resolution: {integrity: sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==} peerDependencies: @@ -16957,16 +16630,6 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - rollup@4.40.1: - resolution: {integrity: sha512-C5VvvgCCyfyotVITIAv+4efVytl5F7wt+/I2i9q9GZcEXW9BP52YYOXC58igUi+LFZVHukErIIqQSWwv/M3WRw==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - - rollup@4.48.1: - resolution: {integrity: sha512-jVG20NvbhTYDkGAty2/Yh7HK6/q3DGSRH4o8ALKGArmMuaauM9kLfoMZ+WliPwA5+JHr2lTn3g557FxBV87ifg==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - rollup@4.52.5: resolution: {integrity: sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -17830,10 +17493,6 @@ packages: resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==} engines: {node: '>=12.0.0'} - tinyglobby@0.2.14: - resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} - engines: {node: '>=12.0.0'} - tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -18515,12 +18174,6 @@ packages: unwasm@0.3.11: resolution: {integrity: sha512-Vhp5gb1tusSQw5of/g3Q697srYgMXvwMgXMjcG4ZNga02fDX9coxJ9fAb0Ci38hM2Hv/U1FXRPGgjP2BYqhNoQ==} - update-browserslist-db@1.1.1: - resolution: {integrity: sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - update-browserslist-db@1.1.4: resolution: {integrity: sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==} hasBin: true @@ -18783,46 +18436,6 @@ packages: terser: optional: true - vite@6.3.3: - resolution: {integrity: sha512-5nXH+QsELbFKhsEfWLkHrvgRpTdGJzqOZ+utSdmPTvwHmvU6ITTm3xx+mRusihkcI8GeC7lCDyn3kDtiki9scw==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - hasBin: true - peerDependencies: - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - jiti: '>=1.21.0' - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - vite@6.3.5: resolution: {integrity: sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -18863,46 +18476,6 @@ packages: yaml: optional: true - vite@7.1.9: - resolution: {integrity: sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - jiti: '>=1.21.0' - less: ^4.0.0 - lightningcss: ^1.21.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - vite@7.3.0: resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -20467,23 +20040,10 @@ snapshots: dependencies: '@babel/compat-data': 7.28.0 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.24.2 + browserslist: 4.28.0 lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.28.3(@babel/core@7.28.3)': - dependencies: - '@babel/core': 7.28.3 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-member-expression-to-functions': 7.27.1 - '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/helper-replace-supers': 7.27.1(@babel/core@7.28.3) - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/traverse': 7.28.5 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - '@babel/helper-create-class-features-plugin@7.28.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -20659,6 +20219,7 @@ snapshots: '@babel/traverse': 7.28.5 transitivePeerDependencies: - supports-color + optional: true '@babel/helper-replace-supers@7.27.1(@babel/core@7.28.5)': dependencies: @@ -21499,6 +21060,7 @@ snapshots: '@babel/helper-plugin-utils': 7.27.1 transitivePeerDependencies: - supports-color + optional: true '@babel/plugin-transform-modules-commonjs@7.27.1(@babel/core@7.28.5)': dependencies: @@ -21746,6 +21308,7 @@ snapshots: dependencies: '@babel/core': 7.28.3 '@babel/helper-plugin-utils': 7.27.1 + optional: true '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.5)': dependencies: @@ -21756,6 +21319,7 @@ snapshots: dependencies: '@babel/core': 7.28.3 '@babel/helper-plugin-utils': 7.27.1 + optional: true '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.5)': dependencies: @@ -21901,17 +21465,6 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-typescript@7.28.0(@babel/core@7.28.3)': - dependencies: - '@babel/core': 7.28.3 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.28.3(@babel/core@7.28.3) - '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.3) - transitivePeerDependencies: - - supports-color - '@babel/plugin-transform-typescript@7.28.0(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -22203,17 +21756,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/preset-typescript@7.27.1(@babel/core@7.28.3)': - dependencies: - '@babel/core': 7.28.3 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.3) - '@babel/plugin-transform-modules-commonjs': 7.27.1(@babel/core@7.28.3) - '@babel/plugin-transform-typescript': 7.28.0(@babel/core@7.28.3) - transitivePeerDependencies: - - supports-color - '@babel/preset-typescript@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -22688,9 +22230,6 @@ snapshots: '@esbuild/aix-ppc64@0.25.4': optional: true - '@esbuild/aix-ppc64@0.25.9': - optional: true - '@esbuild/aix-ppc64@0.27.0': optional: true @@ -22718,9 +22257,6 @@ snapshots: '@esbuild/android-arm64@0.25.4': optional: true - '@esbuild/android-arm64@0.25.9': - optional: true - '@esbuild/android-arm64@0.27.0': optional: true @@ -22748,9 +22284,6 @@ snapshots: '@esbuild/android-arm@0.25.4': optional: true - '@esbuild/android-arm@0.25.9': - optional: true - '@esbuild/android-arm@0.27.0': optional: true @@ -22778,9 +22311,6 @@ snapshots: '@esbuild/android-x64@0.25.4': optional: true - '@esbuild/android-x64@0.25.9': - optional: true - '@esbuild/android-x64@0.27.0': optional: true @@ -22808,9 +22338,6 @@ snapshots: '@esbuild/darwin-arm64@0.25.4': optional: true - '@esbuild/darwin-arm64@0.25.9': - optional: true - '@esbuild/darwin-arm64@0.27.0': optional: true @@ -22838,9 +22365,6 @@ snapshots: '@esbuild/darwin-x64@0.25.4': optional: true - '@esbuild/darwin-x64@0.25.9': - optional: true - '@esbuild/darwin-x64@0.27.0': optional: true @@ -22868,9 +22392,6 @@ snapshots: '@esbuild/freebsd-arm64@0.25.4': optional: true - '@esbuild/freebsd-arm64@0.25.9': - optional: true - '@esbuild/freebsd-arm64@0.27.0': optional: true @@ -22898,9 +22419,6 @@ snapshots: '@esbuild/freebsd-x64@0.25.4': optional: true - '@esbuild/freebsd-x64@0.25.9': - optional: true - '@esbuild/freebsd-x64@0.27.0': optional: true @@ -22928,9 +22446,6 @@ snapshots: '@esbuild/linux-arm64@0.25.4': optional: true - '@esbuild/linux-arm64@0.25.9': - optional: true - '@esbuild/linux-arm64@0.27.0': optional: true @@ -22958,9 +22473,6 @@ snapshots: '@esbuild/linux-arm@0.25.4': optional: true - '@esbuild/linux-arm@0.25.9': - optional: true - '@esbuild/linux-arm@0.27.0': optional: true @@ -22988,9 +22500,6 @@ snapshots: '@esbuild/linux-ia32@0.25.4': optional: true - '@esbuild/linux-ia32@0.25.9': - optional: true - '@esbuild/linux-ia32@0.27.0': optional: true @@ -23018,9 +22527,6 @@ snapshots: '@esbuild/linux-loong64@0.25.4': optional: true - '@esbuild/linux-loong64@0.25.9': - optional: true - '@esbuild/linux-loong64@0.27.0': optional: true @@ -23048,9 +22554,6 @@ snapshots: '@esbuild/linux-mips64el@0.25.4': optional: true - '@esbuild/linux-mips64el@0.25.9': - optional: true - '@esbuild/linux-mips64el@0.27.0': optional: true @@ -23078,9 +22581,6 @@ snapshots: '@esbuild/linux-ppc64@0.25.4': optional: true - '@esbuild/linux-ppc64@0.25.9': - optional: true - '@esbuild/linux-ppc64@0.27.0': optional: true @@ -23108,9 +22608,6 @@ snapshots: '@esbuild/linux-riscv64@0.25.4': optional: true - '@esbuild/linux-riscv64@0.25.9': - optional: true - '@esbuild/linux-riscv64@0.27.0': optional: true @@ -23138,9 +22635,6 @@ snapshots: '@esbuild/linux-s390x@0.25.4': optional: true - '@esbuild/linux-s390x@0.25.9': - optional: true - '@esbuild/linux-s390x@0.27.0': optional: true @@ -23168,9 +22662,6 @@ snapshots: '@esbuild/linux-x64@0.25.4': optional: true - '@esbuild/linux-x64@0.25.9': - optional: true - '@esbuild/linux-x64@0.27.0': optional: true @@ -23189,9 +22680,6 @@ snapshots: '@esbuild/netbsd-arm64@0.25.4': optional: true - '@esbuild/netbsd-arm64@0.25.9': - optional: true - '@esbuild/netbsd-arm64@0.27.0': optional: true @@ -23219,9 +22707,6 @@ snapshots: '@esbuild/netbsd-x64@0.25.4': optional: true - '@esbuild/netbsd-x64@0.25.9': - optional: true - '@esbuild/netbsd-x64@0.27.0': optional: true @@ -23240,9 +22725,6 @@ snapshots: '@esbuild/openbsd-arm64@0.25.4': optional: true - '@esbuild/openbsd-arm64@0.25.9': - optional: true - '@esbuild/openbsd-arm64@0.27.0': optional: true @@ -23270,9 +22752,6 @@ snapshots: '@esbuild/openbsd-x64@0.25.4': optional: true - '@esbuild/openbsd-x64@0.25.9': - optional: true - '@esbuild/openbsd-x64@0.27.0': optional: true @@ -23285,9 +22764,6 @@ snapshots: '@esbuild/openharmony-arm64@0.25.11': optional: true - '@esbuild/openharmony-arm64@0.25.9': - optional: true - '@esbuild/openharmony-arm64@0.27.0': optional: true @@ -23315,9 +22791,6 @@ snapshots: '@esbuild/sunos-x64@0.25.4': optional: true - '@esbuild/sunos-x64@0.25.9': - optional: true - '@esbuild/sunos-x64@0.27.0': optional: true @@ -23345,9 +22818,6 @@ snapshots: '@esbuild/win32-arm64@0.25.4': optional: true - '@esbuild/win32-arm64@0.25.9': - optional: true - '@esbuild/win32-arm64@0.27.0': optional: true @@ -23375,9 +22845,6 @@ snapshots: '@esbuild/win32-ia32@0.25.4': optional: true - '@esbuild/win32-ia32@0.25.9': - optional: true - '@esbuild/win32-ia32@0.27.0': optional: true @@ -23405,9 +22872,6 @@ snapshots: '@esbuild/win32-x64@0.25.4': optional: true - '@esbuild/win32-x64@0.25.9': - optional: true - '@esbuild/win32-x64@0.27.0': optional: true @@ -27859,183 +27323,81 @@ snapshots: '@rollup/rollup-android-arm-eabi@4.12.0': optional: true - '@rollup/rollup-android-arm-eabi@4.40.1': - optional: true - - '@rollup/rollup-android-arm-eabi@4.48.1': - optional: true - '@rollup/rollup-android-arm-eabi@4.52.5': optional: true '@rollup/rollup-android-arm64@4.12.0': optional: true - '@rollup/rollup-android-arm64@4.40.1': - optional: true - - '@rollup/rollup-android-arm64@4.48.1': - optional: true - '@rollup/rollup-android-arm64@4.52.5': optional: true '@rollup/rollup-darwin-arm64@4.12.0': optional: true - '@rollup/rollup-darwin-arm64@4.40.1': - optional: true - - '@rollup/rollup-darwin-arm64@4.48.1': - optional: true - '@rollup/rollup-darwin-arm64@4.52.5': optional: true '@rollup/rollup-darwin-x64@4.12.0': optional: true - '@rollup/rollup-darwin-x64@4.40.1': - optional: true - - '@rollup/rollup-darwin-x64@4.48.1': - optional: true - '@rollup/rollup-darwin-x64@4.52.5': optional: true - '@rollup/rollup-freebsd-arm64@4.40.1': - optional: true - - '@rollup/rollup-freebsd-arm64@4.48.1': - optional: true - '@rollup/rollup-freebsd-arm64@4.52.5': optional: true - '@rollup/rollup-freebsd-x64@4.40.1': - optional: true - - '@rollup/rollup-freebsd-x64@4.48.1': - optional: true - '@rollup/rollup-freebsd-x64@4.52.5': optional: true '@rollup/rollup-linux-arm-gnueabihf@4.12.0': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.40.1': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.48.1': - optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.52.5': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.40.1': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.48.1': - optional: true - '@rollup/rollup-linux-arm-musleabihf@4.52.5': optional: true '@rollup/rollup-linux-arm64-gnu@4.12.0': optional: true - '@rollup/rollup-linux-arm64-gnu@4.40.1': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.48.1': - optional: true - '@rollup/rollup-linux-arm64-gnu@4.52.5': optional: true '@rollup/rollup-linux-arm64-musl@4.12.0': optional: true - '@rollup/rollup-linux-arm64-musl@4.40.1': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.48.1': - optional: true - '@rollup/rollup-linux-arm64-musl@4.52.5': optional: true '@rollup/rollup-linux-loong64-gnu@4.52.5': optional: true - '@rollup/rollup-linux-loongarch64-gnu@4.40.1': - optional: true - - '@rollup/rollup-linux-loongarch64-gnu@4.48.1': - optional: true - - '@rollup/rollup-linux-powerpc64le-gnu@4.40.1': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.48.1': - optional: true - '@rollup/rollup-linux-ppc64-gnu@4.52.5': optional: true '@rollup/rollup-linux-riscv64-gnu@4.12.0': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.40.1': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.48.1': - optional: true - '@rollup/rollup-linux-riscv64-gnu@4.52.5': optional: true - '@rollup/rollup-linux-riscv64-musl@4.40.1': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.48.1': - optional: true - '@rollup/rollup-linux-riscv64-musl@4.52.5': optional: true - '@rollup/rollup-linux-s390x-gnu@4.40.1': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.48.1': - optional: true - '@rollup/rollup-linux-s390x-gnu@4.52.5': optional: true '@rollup/rollup-linux-x64-gnu@4.12.0': optional: true - '@rollup/rollup-linux-x64-gnu@4.40.1': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.48.1': - optional: true - '@rollup/rollup-linux-x64-gnu@4.52.5': optional: true '@rollup/rollup-linux-x64-musl@4.12.0': optional: true - '@rollup/rollup-linux-x64-musl@4.40.1': - optional: true - - '@rollup/rollup-linux-x64-musl@4.48.1': - optional: true - '@rollup/rollup-linux-x64-musl@4.52.5': optional: true @@ -28045,24 +27407,12 @@ snapshots: '@rollup/rollup-win32-arm64-msvc@4.12.0': optional: true - '@rollup/rollup-win32-arm64-msvc@4.40.1': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.48.1': - optional: true - '@rollup/rollup-win32-arm64-msvc@4.52.5': optional: true '@rollup/rollup-win32-ia32-msvc@4.12.0': optional: true - '@rollup/rollup-win32-ia32-msvc@4.40.1': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.48.1': - optional: true - '@rollup/rollup-win32-ia32-msvc@4.52.5': optional: true @@ -28072,12 +27422,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.12.0': optional: true - '@rollup/rollup-win32-x64-msvc@4.40.1': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.48.1': - optional: true - '@rollup/rollup-win32-x64-msvc@4.52.5': optional: true @@ -29023,7 +28367,7 @@ snapshots: '@tanstack/directive-functions-plugin@1.132.53(vite@6.3.5(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2))': dependencies: '@babel/code-frame': 7.27.1 - '@babel/core': 7.28.3 + '@babel/core': 7.28.5 '@babel/traverse': 7.28.5 '@babel/types': 7.28.5 '@tanstack/router-utils': 1.132.51 @@ -29110,7 +28454,7 @@ snapshots: '@tanstack/router-devtools-core': 1.132.51(@tanstack/router-core@1.132.47)(@types/node@24.10.1)(csstype@3.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(solid-js@1.9.9)(terser@5.27.1)(tiny-invariant@1.3.3)(tsx@4.20.5)(yaml@2.8.2) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - vite: 7.1.9(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2) + vite: 7.3.0(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2) transitivePeerDependencies: - '@tanstack/router-core' - '@types/node' @@ -29235,7 +28579,7 @@ snapshots: goober: 2.1.16(csstype@3.2.3) solid-js: 1.9.9 tiny-invariant: 1.3.3 - vite: 7.1.9(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2) + vite: 7.3.0(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2) optionalDependencies: csstype: 3.2.3 transitivePeerDependencies: @@ -29293,10 +28637,10 @@ snapshots: '@tanstack/router-utils@1.132.51': dependencies: - '@babel/core': 7.28.3 + '@babel/core': 7.28.5 '@babel/generator': 7.28.3 '@babel/parser': 7.28.5 - '@babel/preset-typescript': 7.27.1(@babel/core@7.28.3) + '@babel/preset-typescript': 7.27.1(@babel/core@7.28.5) ansis: 4.2.0 diff: 8.0.2 pathe: 2.0.3 @@ -29307,9 +28651,9 @@ snapshots: '@tanstack/server-functions-plugin@1.132.53(vite@6.3.5(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2))': dependencies: '@babel/code-frame': 7.27.1 - '@babel/core': 7.28.3 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.3) - '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.3) + '@babel/core': 7.28.5 + '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.5) '@babel/template': 7.27.2 '@babel/traverse': 7.28.5 '@babel/types': 7.28.5 @@ -29331,7 +28675,7 @@ snapshots: '@tanstack/start-plugin-core@1.132.56(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@6.3.5(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2))': dependencies: '@babel/code-frame': 7.26.2 - '@babel/core': 7.28.3 + '@babel/core': 7.28.5 '@babel/types': 7.28.2 '@rolldown/pluginutils': 1.0.0-beta.40 '@tanstack/router-core': 1.132.47 @@ -29699,8 +29043,6 @@ snapshots: '@types/estree@1.0.5': {} - '@types/estree@1.0.7': {} - '@types/estree@1.0.8': {} '@types/etag@1.8.3': @@ -30121,9 +29463,9 @@ snapshots: '@vitejs/plugin-react@4.7.0(vite@6.3.5(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2))': dependencies: - '@babel/core': 7.28.3 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.3) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.3) + '@babel/core': 7.28.5 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.5) '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 @@ -30177,13 +29519,13 @@ snapshots: optionalDependencies: vite: 5.4.21(@types/node@24.10.1)(lightningcss@1.30.2)(terser@5.27.1) - '@vitest/mocker@3.1.3(vite@6.3.3(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2))': + '@vitest/mocker@3.1.3(vite@6.3.5(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.1.3 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 6.3.3(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2) + vite: 6.3.5(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2) '@vitest/pretty-format@2.1.9': dependencies: @@ -30847,7 +30189,7 @@ snapshots: babel-dead-code-elimination@1.0.10: dependencies: - '@babel/core': 7.28.3 + '@babel/core': 7.28.5 '@babel/parser': 7.28.5 '@babel/traverse': 7.28.5 '@babel/types': 7.28.2 @@ -31157,13 +30499,6 @@ snapshots: dependencies: base64-js: 1.5.1 - browserslist@4.24.2: - dependencies: - caniuse-lite: 1.0.30001757 - electron-to-chromium: 1.5.49 - node-releases: 2.0.18 - update-browserslist-db: 1.1.1(browserslist@4.24.2) - browserslist@4.28.0: dependencies: baseline-browser-mapping: 2.8.31 @@ -32481,8 +31816,6 @@ snapshots: electron-to-chromium@1.5.262: {} - electron-to-chromium@1.5.49: {} - embla-carousel-autoplay@8.6.0(embla-carousel@8.0.0-rc22): dependencies: embla-carousel: 8.0.0-rc22 @@ -32887,35 +32220,6 @@ snapshots: '@esbuild/win32-ia32': 0.25.4 '@esbuild/win32-x64': 0.25.4 - esbuild@0.25.9: - optionalDependencies: - '@esbuild/aix-ppc64': 0.25.9 - '@esbuild/android-arm': 0.25.9 - '@esbuild/android-arm64': 0.25.9 - '@esbuild/android-x64': 0.25.9 - '@esbuild/darwin-arm64': 0.25.9 - '@esbuild/darwin-x64': 0.25.9 - '@esbuild/freebsd-arm64': 0.25.9 - '@esbuild/freebsd-x64': 0.25.9 - '@esbuild/linux-arm': 0.25.9 - '@esbuild/linux-arm64': 0.25.9 - '@esbuild/linux-ia32': 0.25.9 - '@esbuild/linux-loong64': 0.25.9 - '@esbuild/linux-mips64el': 0.25.9 - '@esbuild/linux-ppc64': 0.25.9 - '@esbuild/linux-riscv64': 0.25.9 - '@esbuild/linux-s390x': 0.25.9 - '@esbuild/linux-x64': 0.25.9 - '@esbuild/netbsd-arm64': 0.25.9 - '@esbuild/netbsd-x64': 0.25.9 - '@esbuild/openbsd-arm64': 0.25.9 - '@esbuild/openbsd-x64': 0.25.9 - '@esbuild/openharmony-arm64': 0.25.9 - '@esbuild/sunos-x64': 0.25.9 - '@esbuild/win32-arm64': 0.25.9 - '@esbuild/win32-ia32': 0.25.9 - '@esbuild/win32-x64': 0.25.9 - esbuild@0.27.0: optionalDependencies: '@esbuild/aix-ppc64': 0.27.0 @@ -33794,7 +33098,7 @@ snapshots: fsevents@2.3.3: optional: true - fumadocs-core@16.2.2(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + fumadocs-core@16.2.2(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3): dependencies: '@formatjs/intl-localematcher': 0.6.2 '@orama/orama': 3.1.16 @@ -33821,17 +33125,18 @@ snapshots: next: 16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) + react-router: 7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) transitivePeerDependencies: - supports-color - fumadocs-mdx@14.0.4(fumadocs-core@16.2.2(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2)): + fumadocs-mdx@14.0.4(fumadocs-core@16.2.2(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2)): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.0.0 chokidar: 5.0.0 esbuild: 0.27.0 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.2.2(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + fumadocs-core: 16.2.2(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) js-yaml: 4.1.1 lru-cache: 11.2.2 mdast-util-to-markdown: 2.1.2 @@ -33852,7 +33157,7 @@ snapshots: transitivePeerDependencies: - supports-color - fumadocs-ui@16.2.2(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.17): + fumadocs-ui@16.2.2(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.17): dependencies: '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -33865,7 +33170,7 @@ snapshots: '@radix-ui/react-slot': 1.2.4(@types/react@19.2.7)(react@19.2.3) '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) class-variance-authority: 0.7.1 - fumadocs-core: 16.2.2(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + fumadocs-core: 16.2.2(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) lodash.merge: 4.6.2 next-themes: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) postcss-selector-parser: 7.1.1 @@ -36902,8 +36207,6 @@ snapshots: node-mock-http@1.0.3: {} - node-releases@2.0.18: {} - node-releases@2.0.27: {} node-stream-zip@1.15.0: {} @@ -36956,13 +36259,15 @@ snapshots: dependencies: esm-env: esm-env-runtime@0.1.1 - nuqs@2.5.2(patch_hash=4f93812bf7a04685f9477b2935e3acbf2aa24dfbb1b00b9ae0ed8f7a2877a98e)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.0.7(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3): + nuqs@2.5.2(patch_hash=4f93812bf7a04685f9477b2935e3acbf2aa24dfbb1b00b9ae0ed8f7a2877a98e)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.0.7(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-router-dom@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3): dependencies: '@standard-schema/spec': 1.0.0 react: 19.2.3 optionalDependencies: '@tanstack/react-router': 1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next: 16.0.7(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react-router: 7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react-router-dom: 7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) nuxt@4.2.2(@biomejs/biome@2.3.15)(@parcel/watcher@2.5.1)(@types/node@24.10.1)(@vue/compiler-sfc@3.5.25)(aws4fetch@1.0.20)(cac@6.7.14)(db0@0.3.4)(ioredis@5.8.2)(lightningcss@1.30.2)(magicast@0.5.1)(rolldown@1.0.0-beta.43)(rollup@4.52.5)(terser@5.27.1)(tsx@4.20.5)(typescript@5.9.3)(vite@7.3.0(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2))(yaml@2.8.2): dependencies: @@ -38373,6 +37678,20 @@ snapshots: react: 19.2.3 shallow-equal: 1.2.1 + react-router-dom@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-router: 7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + + react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + cookie: 1.0.2 + react: 19.2.3 + set-cookie-parser: 2.6.0 + optionalDependencies: + react-dom: 19.2.3(react@19.2.3) + react-shallow-renderer@16.15.0(react@19.2.3): dependencies: object-assign: 4.1.1 @@ -38990,58 +38309,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.12.0 fsevents: 2.3.3 - rollup@4.40.1: - dependencies: - '@types/estree': 1.0.7 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.40.1 - '@rollup/rollup-android-arm64': 4.40.1 - '@rollup/rollup-darwin-arm64': 4.40.1 - '@rollup/rollup-darwin-x64': 4.40.1 - '@rollup/rollup-freebsd-arm64': 4.40.1 - '@rollup/rollup-freebsd-x64': 4.40.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.40.1 - '@rollup/rollup-linux-arm-musleabihf': 4.40.1 - '@rollup/rollup-linux-arm64-gnu': 4.40.1 - '@rollup/rollup-linux-arm64-musl': 4.40.1 - '@rollup/rollup-linux-loongarch64-gnu': 4.40.1 - '@rollup/rollup-linux-powerpc64le-gnu': 4.40.1 - '@rollup/rollup-linux-riscv64-gnu': 4.40.1 - '@rollup/rollup-linux-riscv64-musl': 4.40.1 - '@rollup/rollup-linux-s390x-gnu': 4.40.1 - '@rollup/rollup-linux-x64-gnu': 4.40.1 - '@rollup/rollup-linux-x64-musl': 4.40.1 - '@rollup/rollup-win32-arm64-msvc': 4.40.1 - '@rollup/rollup-win32-ia32-msvc': 4.40.1 - '@rollup/rollup-win32-x64-msvc': 4.40.1 - fsevents: 2.3.3 - - rollup@4.48.1: - dependencies: - '@types/estree': 1.0.8 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.48.1 - '@rollup/rollup-android-arm64': 4.48.1 - '@rollup/rollup-darwin-arm64': 4.48.1 - '@rollup/rollup-darwin-x64': 4.48.1 - '@rollup/rollup-freebsd-arm64': 4.48.1 - '@rollup/rollup-freebsd-x64': 4.48.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.48.1 - '@rollup/rollup-linux-arm-musleabihf': 4.48.1 - '@rollup/rollup-linux-arm64-gnu': 4.48.1 - '@rollup/rollup-linux-arm64-musl': 4.48.1 - '@rollup/rollup-linux-loongarch64-gnu': 4.48.1 - '@rollup/rollup-linux-ppc64-gnu': 4.48.1 - '@rollup/rollup-linux-riscv64-gnu': 4.48.1 - '@rollup/rollup-linux-riscv64-musl': 4.48.1 - '@rollup/rollup-linux-s390x-gnu': 4.48.1 - '@rollup/rollup-linux-x64-gnu': 4.48.1 - '@rollup/rollup-linux-x64-musl': 4.48.1 - '@rollup/rollup-win32-arm64-msvc': 4.48.1 - '@rollup/rollup-win32-ia32-msvc': 4.48.1 - '@rollup/rollup-win32-x64-msvc': 4.48.1 - fsevents: 2.3.3 - rollup@4.52.5: dependencies: '@types/estree': 1.0.8 @@ -40106,11 +39373,6 @@ snapshots: fdir: 6.4.4(picomatch@4.0.2) picomatch: 4.0.2 - tinyglobby@0.2.14: - dependencies: - fdir: 6.4.4(picomatch@4.0.2) - picomatch: 4.0.2 - tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -40723,12 +39985,6 @@ snapshots: pkg-types: 2.3.0 unplugin: 2.3.10 - update-browserslist-db@1.1.1(browserslist@4.24.2): - dependencies: - browserslist: 4.24.2 - escalade: 3.2.0 - picocolors: 1.1.1 - update-browserslist-db@1.1.4(browserslist@4.28.0): dependencies: browserslist: 4.28.0 @@ -41029,47 +40285,13 @@ snapshots: lightningcss: 1.30.2 terser: 5.27.1 - vite@6.3.3(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2): - dependencies: - esbuild: 0.25.9 - fdir: 6.4.4(picomatch@4.0.2) - picomatch: 4.0.2 - postcss: 8.5.6 - rollup: 4.40.1 - tinyglobby: 0.2.13 - optionalDependencies: - '@types/node': 24.10.1 - fsevents: 2.3.3 - jiti: 2.6.1 - lightningcss: 1.30.2 - terser: 5.27.1 - tsx: 4.20.5 - yaml: 2.8.2 - vite@6.3.5(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2): - dependencies: - esbuild: 0.25.3 - fdir: 6.4.4(picomatch@4.0.2) - picomatch: 4.0.2 - postcss: 8.5.6 - rollup: 4.40.1 - tinyglobby: 0.2.14 - optionalDependencies: - '@types/node': 24.10.1 - fsevents: 2.3.3 - jiti: 2.6.1 - lightningcss: 1.30.2 - terser: 5.27.1 - tsx: 4.20.5 - yaml: 2.8.2 - - vite@7.1.9(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2): dependencies: esbuild: 0.25.11 fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.6 - rollup: 4.48.1 + rollup: 4.52.5 tinyglobby: 0.2.15 optionalDependencies: '@types/node': 24.10.1 @@ -41179,7 +40401,7 @@ snapshots: vitest@3.1.3(@types/debug@4.1.12)(@types/node@24.10.1)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2): dependencies: '@vitest/expect': 3.1.3 - '@vitest/mocker': 3.1.3(vite@6.3.3(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2)) + '@vitest/mocker': 3.1.3(vite@6.3.5(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2)) '@vitest/pretty-format': 3.1.3 '@vitest/runner': 3.1.3 '@vitest/snapshot': 3.1.3 @@ -41196,7 +40418,7 @@ snapshots: tinyglobby: 0.2.13 tinypool: 1.0.2 tinyrainbow: 2.0.0 - vite: 6.3.3(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2) + vite: 6.3.5(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2) vite-node: 3.1.3(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: diff --git a/scripts/seed-events.mjs b/scripts/seed-events.mjs new file mode 100644 index 00000000..45fa895d --- /dev/null +++ b/scripts/seed-events.mjs @@ -0,0 +1,599 @@ +#!/usr/bin/env node +/** + * Seed script for generating realistic analytics events. + * + * Usage: + * node scripts/seed-events.mjs [--timeline=30] [--sessions=500] [--url=http://localhost:3333] + * + * Options: + * --timeline=N Duration in minutes to spread events over (default: 30) + * --sessions=N Number of sessions to generate (default: 500) + * --url=URL API base URL (default: http://localhost:3333) + * --clientId=ID Client ID to use (required or set CLIENT_ID env var) + */ + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +const args = Object.fromEntries( + process.argv.slice(2).map((a) => { + const [k, v] = a.replace(/^--/, '').split('='); + return [k, v ?? true]; + }) +); + +const TIMELINE_MINUTES = Number(args.timeline ?? 30); +const SESSION_COUNT = Number(args.sessions ?? 500); +const BASE_URL = args.url ?? 'http://localhost:3333'; +const CLIENT_ID = args.clientId ?? process.env.CLIENT_ID ?? ''; +const ORIGIN = args.origin ?? process.env.ORIGIN ?? 'https://shop.example.com'; +const CONCURRENCY = 20; // max parallel requests + +if (!CLIENT_ID) { + console.error('ERROR: provide --clientId= or set CLIENT_ID env var'); + process.exit(1); +} + +const TRACK_URL = `${BASE_URL}/track`; + +// --------------------------------------------------------------------------- +// Deterministic seeded random (mulberry32) — keeps identities stable across runs +// --------------------------------------------------------------------------- + +function mulberry32(seed) { + return function () { + seed |= 0; + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// Non-deterministic random for events (differs on each run) +const eventRng = Math.random.bind(Math); + +function pick(arr, rng = eventRng) { + return arr[Math.floor(rng() * arr.length)]; +} + +function randInt(min, max, rng = eventRng) { + return Math.floor(rng() * (max - min + 1)) + min; +} + +function randFloat(min, max, rng = eventRng) { + return rng() * (max - min) + min; +} + +// --------------------------------------------------------------------------- +// Fake data pools +// --------------------------------------------------------------------------- + +const FIRST_NAMES = [ + 'Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank', 'Grace', 'Hank', + 'Iris', 'Jack', 'Karen', 'Leo', 'Mia', 'Noah', 'Olivia', 'Pete', + 'Quinn', 'Rachel', 'Sam', 'Tina', 'Uma', 'Victor', 'Wendy', 'Xavier', + 'Yara', 'Zoe', 'Aaron', 'Bella', 'Carlos', 'Dani', 'Ethan', 'Fiona', +]; + +const LAST_NAMES = [ + 'Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', + 'Davis', 'Wilson', 'Taylor', 'Anderson', 'Thomas', 'Jackson', 'White', + 'Harris', 'Martin', 'Thompson', 'Moore', 'Young', 'Allen', +]; + +const EMAIL_DOMAINS = ['gmail.com', 'yahoo.com', 'outlook.com', 'icloud.com', 'proton.me']; + +const USER_AGENTS = [ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36', + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1', + 'Mozilla/5.0 (iPad; CPU OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:122.0) Gecko/20100101 Firefox/122.0', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14.3; rv:122.0) Gecko/20100101 Firefox/122.0', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_3) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0', + 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36', + 'Mozilla/5.0 (Linux; Android 13; Samsung Galaxy S23) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/23.0 Chrome/115.0.0.0 Mobile Safari/537.36', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36 OPR/107.0.0.0', +]; + +// Ensure each session has a unique UA by appending a suffix +function makeUniqueUA(base, index) { + return `${base} Session/${index}`; +} + +// Generate a plausible IP address (avoiding private ranges) +function makeIP(index) { + // Use a spread across several /8 public ranges + const ranges = [ + [34, 0, 0, 0], + [52, 0, 0, 0], + [104, 0, 0, 0], + [185, 0, 0, 0], + [213, 0, 0, 0], + ]; + const base = ranges[index % ranges.length]; + const a = base[0]; + const b = Math.floor(index / 65025) % 256; + const c = Math.floor(index / 255) % 256; + const d = index % 255 + 1; + return `${a}.${b}.${c}.${d}`; +} + +// --------------------------------------------------------------------------- +// Products & categories +// --------------------------------------------------------------------------- + +const PRODUCTS = [ + { id: 'prod_001', name: 'Wireless Headphones', category: 'Electronics', price: 8999 }, + { id: 'prod_002', name: 'Running Shoes', category: 'Sports', price: 12999 }, + { id: 'prod_003', name: 'Coffee Maker', category: 'Kitchen', price: 5499 }, + { id: 'prod_004', name: 'Yoga Mat', category: 'Sports', price: 2999 }, + { id: 'prod_005', name: 'Smart Watch', category: 'Electronics', price: 29999 }, + { id: 'prod_006', name: 'Blender', category: 'Kitchen', price: 7999 }, + { id: 'prod_007', name: 'Backpack', category: 'Travel', price: 4999 }, + { id: 'prod_008', name: 'Sunglasses', category: 'Accessories', price: 3499 }, + { id: 'prod_009', name: 'Novel: The Last Algorithm', category: 'Books', price: 1499 }, + { id: 'prod_010', name: 'Standing Desk', category: 'Furniture', price: 45999 }, +]; + +const CATEGORIES = ['Electronics', 'Sports', 'Kitchen', 'Travel', 'Accessories', 'Books', 'Furniture']; + +// --------------------------------------------------------------------------- +// Groups (3 pre-defined companies) +// --------------------------------------------------------------------------- + +const GROUPS = [ + { id: 'org_acme', type: 'company', name: 'Acme Corp', properties: { plan: 'enterprise', industry: 'Technology', employees: 500 } }, + { id: 'org_globex', type: 'company', name: 'Globex Inc', properties: { plan: 'pro', industry: 'Finance', employees: 120 } }, + { id: 'org_initech', type: 'company', name: 'Initech LLC', properties: { plan: 'starter', industry: 'Consulting', employees: 45 } }, +]; + +// --------------------------------------------------------------------------- +// Scenarios — 20 distinct user journeys +// --------------------------------------------------------------------------- + +/** + * Each scenario returns a list of event descriptors. + * screen_view events use a `path` property (origin + pathname). + */ + +const SCENARIOS = [ + // 1. Full e-commerce checkout success + (product) => [ + { name: 'screen_view', props: { path: `${ORIGIN}/`, title: 'Home', referrer: 'https://google.com' } }, + { name: 'screen_view', props: { path: `${ORIGIN}/products/${product.id}`, title: product.name } }, + { name: 'product_viewed', props: { product_id: product.id, product_name: product.name, price: product.price, category: product.category } }, + { name: 'add_to_cart', props: { product_id: product.id, product_name: product.name, price: product.price, quantity: 1 } }, + { name: 'screen_view', props: { path: `${ORIGIN}/cart`, title: 'Cart' } }, + { name: 'checkout_started', props: { cart_total: product.price, item_count: 1 } }, + { name: 'screen_view', props: { path: `${ORIGIN}/checkout/shipping`, title: 'Shipping Info' } }, + { name: 'shipping_info_submitted', props: { shipping_method: 'standard', estimated_days: 5 } }, + { name: 'screen_view', props: { path: `${ORIGIN}/checkout/payment`, title: 'Payment' } }, + { name: 'payment_info_submitted', props: { payment_method: 'credit_card' } }, + { name: 'screen_view', props: { path: `${ORIGIN}/checkout/review`, title: 'Order Review' } }, + { name: 'purchase', props: { order_id: `ord_${Date.now()}`, revenue: product.price, product_id: product.id, product_name: product.name, quantity: 1 }, revenue: true }, + { name: 'screen_view', props: { path: `${ORIGIN}/checkout/success`, title: 'Order Confirmed' } }, + { name: 'checkout_success', props: { order_id: `ord_${Date.now()}`, revenue: product.price } }, + ], + + // 2. Checkout failed (payment declined) + (product) => [ + { name: 'screen_view', props: { path: `${ORIGIN}/`, title: 'Home' } }, + { name: 'screen_view', props: { path: `${ORIGIN}/products/${product.id}`, title: product.name } }, + { name: 'product_viewed', props: { product_id: product.id, product_name: product.name, price: product.price, category: product.category } }, + { name: 'add_to_cart', props: { product_id: product.id, product_name: product.name, price: product.price, quantity: 1 } }, + { name: 'checkout_started', props: { cart_total: product.price, item_count: 1 } }, + { name: 'screen_view', props: { path: `${ORIGIN}/checkout/shipping`, title: 'Shipping Info' } }, + { name: 'shipping_info_submitted', props: { shipping_method: 'express', estimated_days: 2 } }, + { name: 'screen_view', props: { path: `${ORIGIN}/checkout/payment`, title: 'Payment' } }, + { name: 'payment_info_submitted', props: { payment_method: 'credit_card' } }, + { name: 'checkout_failed', props: { reason: 'payment_declined', error_code: 'insufficient_funds' } }, + { name: 'screen_view', props: { path: `${ORIGIN}/checkout/payment`, title: 'Payment' } }, + ], + + // 3. Browse only — no purchase + (product) => [ + { name: 'screen_view', props: { path: `${ORIGIN}/`, title: 'Home', referrer: 'https://facebook.com' } }, + { name: 'screen_view', props: { path: `${ORIGIN}/categories/${product.category.toLowerCase()}`, title: product.category } }, + { name: 'category_viewed', props: { category: product.category } }, + { name: 'screen_view', props: { path: `${ORIGIN}/products/${product.id}`, title: product.name } }, + { name: 'product_viewed', props: { product_id: product.id, product_name: product.name, price: product.price, category: product.category } }, + { name: 'screen_view', props: { path: `${ORIGIN}/products/${PRODUCTS[1].id}`, title: PRODUCTS[1].name } }, + { name: 'product_viewed', props: { product_id: PRODUCTS[1].id, product_name: PRODUCTS[1].name, price: PRODUCTS[1].price, category: PRODUCTS[1].category } }, + ], + + // 4. Add to cart then abandon + (product) => [ + { name: 'screen_view', props: { path: `${ORIGIN}/`, title: 'Home' } }, + { name: 'screen_view', props: { path: `${ORIGIN}/products/${product.id}`, title: product.name } }, + { name: 'product_viewed', props: { product_id: product.id, product_name: product.name, price: product.price, category: product.category } }, + { name: 'add_to_cart', props: { product_id: product.id, product_name: product.name, price: product.price, quantity: 1 } }, + { name: 'screen_view', props: { path: `${ORIGIN}/cart`, title: 'Cart' } }, + { name: 'cart_abandoned', props: { cart_total: product.price, item_count: 1 } }, + ], + + // 5. Search → product → purchase + (product) => [ + { name: 'screen_view', props: { path: `${ORIGIN}/`, title: 'Home' } }, + { name: 'search', props: { query: product.name.split(' ')[0], result_count: randInt(3, 20) } }, + { name: 'screen_view', props: { path: `${ORIGIN}/search?q=${encodeURIComponent(product.name)}`, title: 'Search Results' } }, + { name: 'screen_view', props: { path: `${ORIGIN}/products/${product.id}`, title: product.name } }, + { name: 'product_viewed', props: { product_id: product.id, product_name: product.name, price: product.price, category: product.category } }, + { name: 'add_to_cart', props: { product_id: product.id, product_name: product.name, price: product.price, quantity: 1 } }, + { name: 'checkout_started', props: { cart_total: product.price, item_count: 1 } }, + { name: 'shipping_info_submitted', props: { shipping_method: 'standard', estimated_days: 5 } }, + { name: 'payment_info_submitted', props: { payment_method: 'paypal' } }, + { name: 'purchase', props: { order_id: `ord_${Date.now()}`, revenue: product.price, product_id: product.id, product_name: product.name, quantity: 1 }, revenue: true }, + { name: 'checkout_success', props: { order_id: `ord_${Date.now()}`, revenue: product.price } }, + ], + + // 6. Sign up flow + (_product) => [ + { name: 'screen_view', props: { path: `${ORIGIN}/`, title: 'Home', referrer: 'https://twitter.com' } }, + { name: 'screen_view', props: { path: `${ORIGIN}/signup`, title: 'Sign Up' } }, + { name: 'signup_started', props: {} }, + { name: 'signup_step_completed', props: { step: 'email', step_number: 1 } }, + { name: 'signup_step_completed', props: { step: 'password', step_number: 2 } }, + { name: 'signup_step_completed', props: { step: 'profile', step_number: 3 } }, + { name: 'signup_completed', props: { method: 'email' } }, + { name: 'screen_view', props: { path: `${ORIGIN}/dashboard`, title: 'Dashboard' } }, + ], + + // 7. Login → browse → wishlist + (product) => [ + { name: 'screen_view', props: { path: `${ORIGIN}/login`, title: 'Login' } }, + { name: 'login', props: { method: 'email' } }, + { name: 'screen_view', props: { path: `${ORIGIN}/`, title: 'Home' } }, + { name: 'screen_view', props: { path: `${ORIGIN}/products/${product.id}`, title: product.name } }, + { name: 'product_viewed', props: { product_id: product.id, product_name: product.name, price: product.price, category: product.category } }, + { name: 'add_to_wishlist', props: { product_id: product.id, product_name: product.name, price: product.price } }, + { name: 'screen_view', props: { path: `${ORIGIN}/wishlist`, title: 'Wishlist' } }, + ], + + // 8. Promo code → purchase + (product) => [ + { name: 'screen_view', props: { path: `${ORIGIN}/`, title: 'Home', referrer: 'https://newsletter.example.com' } }, + { name: 'screen_view', props: { path: `${ORIGIN}/products/${product.id}`, title: product.name } }, + { name: 'product_viewed', props: { product_id: product.id, product_name: product.name, price: product.price, category: product.category } }, + { name: 'add_to_cart', props: { product_id: product.id, product_name: product.name, price: product.price, quantity: 1 } }, + { name: 'checkout_started', props: { cart_total: product.price, item_count: 1 } }, + { name: 'promo_code_applied', props: { code: 'SAVE20', discount_percent: 20, discount_amount: Math.round(product.price * 0.2) } }, + { name: 'shipping_info_submitted', props: { shipping_method: 'standard', estimated_days: 5 } }, + { name: 'payment_info_submitted', props: { payment_method: 'credit_card' } }, + { name: 'purchase', props: { order_id: `ord_${Date.now()}`, revenue: Math.round(product.price * 0.8), product_id: product.id, product_name: product.name, quantity: 1, promo_code: 'SAVE20' }, revenue: true }, + { name: 'checkout_success', props: { order_id: `ord_${Date.now()}`, revenue: Math.round(product.price * 0.8) } }, + ], + + // 9. Multi-item purchase + (_product) => { + const p1 = PRODUCTS[0]; + const p2 = PRODUCTS[3]; + const total = p1.price + p2.price; + return [ + { name: 'screen_view', props: { path: `${ORIGIN}/`, title: 'Home' } }, + { name: 'screen_view', props: { path: `${ORIGIN}/products/${p1.id}`, title: p1.name } }, + { name: 'product_viewed', props: { product_id: p1.id, product_name: p1.name, price: p1.price, category: p1.category } }, + { name: 'add_to_cart', props: { product_id: p1.id, product_name: p1.name, price: p1.price, quantity: 1 } }, + { name: 'screen_view', props: { path: `${ORIGIN}/products/${p2.id}`, title: p2.name } }, + { name: 'product_viewed', props: { product_id: p2.id, product_name: p2.name, price: p2.price, category: p2.category } }, + { name: 'add_to_cart', props: { product_id: p2.id, product_name: p2.name, price: p2.price, quantity: 1 } }, + { name: 'checkout_started', props: { cart_total: total, item_count: 2 } }, + { name: 'shipping_info_submitted', props: { shipping_method: 'express', estimated_days: 2 } }, + { name: 'payment_info_submitted', props: { payment_method: 'credit_card' } }, + { name: 'purchase', props: { order_id: `ord_${Date.now()}`, revenue: total, item_count: 2 }, revenue: true }, + { name: 'checkout_success', props: { order_id: `ord_${Date.now()}`, revenue: total } }, + ]; + }, + + // 10. Help center visit + (_product) => [ + { name: 'screen_view', props: { path: `${ORIGIN}/`, title: 'Home' } }, + { name: 'screen_view', props: { path: `${ORIGIN}/help`, title: 'Help Center' } }, + { name: 'help_search', props: { query: 'return policy', result_count: 4 } }, + { name: 'screen_view', props: { path: `${ORIGIN}/help/returns`, title: 'Return Policy' } }, + { name: 'help_article_read', props: { article: 'return_policy', time_on_page: randInt(60, 180) } }, + { name: 'screen_view', props: { path: `${ORIGIN}/help/shipping`, title: 'Shipping Info' } }, + { name: 'help_article_read', props: { article: 'shipping_times', time_on_page: randInt(30, 120) } }, + ], + + // 11. Product review submitted + (product) => [ + { name: 'screen_view', props: { path: `${ORIGIN}/`, title: 'Home' } }, + { name: 'screen_view', props: { path: `${ORIGIN}/products/${product.id}`, title: product.name } }, + { name: 'product_viewed', props: { product_id: product.id, product_name: product.name, price: product.price, category: product.category } }, + { name: 'review_started', props: { product_id: product.id } }, + { name: 'review_submitted', props: { product_id: product.id, rating: randInt(3, 5), has_text: true } }, + ], + + // 12. Newsletter signup only + (_product) => [ + { name: 'screen_view', props: { path: `${ORIGIN}/`, title: 'Home', referrer: 'https://instagram.com' } }, + { name: 'screen_view', props: { path: `${ORIGIN}/blog`, title: 'Blog' } }, + { name: 'screen_view', props: { path: `${ORIGIN}/blog/top-10-gadgets-2024`, title: 'Top 10 Gadgets 2024' } }, + { name: 'newsletter_signup', props: { source: 'blog_article', campaign: 'gadgets_2024' } }, + ], + + // 13. Account settings update + (_product) => [ + { name: 'screen_view', props: { path: `${ORIGIN}/login`, title: 'Login' } }, + { name: 'login', props: { method: 'google' } }, + { name: 'screen_view', props: { path: `${ORIGIN}/account`, title: 'Account' } }, + { name: 'screen_view', props: { path: `${ORIGIN}/account/settings`, title: 'Settings' } }, + { name: 'settings_updated', props: { field: 'notification_preferences', value: 'email_only' } }, + { name: 'screen_view', props: { path: `${ORIGIN}/account/address`, title: 'Addresses' } }, + { name: 'address_added', props: { is_default: true } }, + ], + + // 14. Referral program engagement + (product) => [ + { name: 'screen_view', props: { path: `${ORIGIN}/`, title: 'Home', referrer: 'https://referral.example.com/?ref=abc123' } }, + { name: 'referral_link_clicked', props: { referrer_id: 'usr_ref123', campaign: 'summer_referral' } }, + { name: 'screen_view', props: { path: `${ORIGIN}/products/${product.id}`, title: product.name } }, + { name: 'product_viewed', props: { product_id: product.id, product_name: product.name, price: product.price, category: product.category } }, + { name: 'add_to_cart', props: { product_id: product.id, product_name: product.name, price: product.price, quantity: 1 } }, + { name: 'checkout_started', props: { cart_total: product.price, item_count: 1 } }, + { name: 'shipping_info_submitted', props: { shipping_method: 'standard', estimated_days: 5 } }, + { name: 'payment_info_submitted', props: { payment_method: 'credit_card' } }, + { name: 'purchase', props: { order_id: `ord_${Date.now()}`, revenue: product.price, referral_code: 'abc123' }, revenue: true }, + { name: 'checkout_success', props: { order_id: `ord_${Date.now()}`, revenue: product.price } }, + ], + + // 15. Mobile quick browse — short session + (product) => [ + { name: 'screen_view', props: { path: `${ORIGIN}/`, title: 'Home' } }, + { name: 'screen_view', props: { path: `${ORIGIN}/categories/${product.category.toLowerCase()}`, title: product.category } }, + { name: 'screen_view', props: { path: `${ORIGIN}/products/${product.id}`, title: product.name } }, + { name: 'product_viewed', props: { product_id: product.id, product_name: product.name, price: product.price, category: product.category } }, + ], + + // 16. Compare products + (product) => [ + { name: 'screen_view', props: { path: `${ORIGIN}/`, title: 'Home' } }, + { name: 'screen_view', props: { path: `${ORIGIN}/products/${product.id}`, title: product.name } }, + { name: 'product_viewed', props: { product_id: product.id, product_name: product.name, price: product.price, category: product.category } }, + { name: 'compare_added', props: { product_id: product.id } }, + { name: 'screen_view', props: { path: `${ORIGIN}/products/${PRODUCTS[4].id}`, title: PRODUCTS[4].name } }, + { name: 'product_viewed', props: { product_id: PRODUCTS[4].id, product_name: PRODUCTS[4].name, price: PRODUCTS[4].price, category: PRODUCTS[4].category } }, + { name: 'compare_added', props: { product_id: PRODUCTS[4].id } }, + { name: 'screen_view', props: { path: `${ORIGIN}/compare?ids=${product.id},${PRODUCTS[4].id}`, title: 'Compare Products' } }, + { name: 'compare_viewed', props: { product_ids: [product.id, PRODUCTS[4].id] } }, + ], + + // 17. Shipping failure retry → success + (product) => [ + { name: 'screen_view', props: { path: `${ORIGIN}/`, title: 'Home' } }, + { name: 'screen_view', props: { path: `${ORIGIN}/products/${product.id}`, title: product.name } }, + { name: 'product_viewed', props: { product_id: product.id, product_name: product.name, price: product.price, category: product.category } }, + { name: 'add_to_cart', props: { product_id: product.id, product_name: product.name, price: product.price, quantity: 1 } }, + { name: 'checkout_started', props: { cart_total: product.price, item_count: 1 } }, + { name: 'screen_view', props: { path: `${ORIGIN}/checkout/shipping`, title: 'Shipping Info' } }, + { name: 'shipping_info_error', props: { error: 'invalid_address', attempt: 1 } }, + { name: 'shipping_info_submitted', props: { shipping_method: 'standard', estimated_days: 5, attempt: 2 } }, + { name: 'payment_info_submitted', props: { payment_method: 'credit_card' } }, + { name: 'purchase', props: { order_id: `ord_${Date.now()}`, revenue: product.price, product_id: product.id }, revenue: true }, + { name: 'checkout_success', props: { order_id: `ord_${Date.now()}`, revenue: product.price } }, + ], + + // 18. Subscription / SaaS upgrade + (_product) => [ + { name: 'screen_view', props: { path: `${ORIGIN}/pricing`, title: 'Pricing' } }, + { name: 'pricing_viewed', props: {} }, + { name: 'plan_selected', props: { plan: 'pro', billing: 'annual', price: 9900 } }, + { name: 'screen_view', props: { path: `${ORIGIN}/checkout/subscription`, title: 'Subscribe' } }, + { name: 'payment_info_submitted', props: { payment_method: 'credit_card' } }, + { name: 'subscription_started', props: { plan: 'pro', billing: 'annual', revenue: 9900 }, revenue: true }, + { name: 'screen_view', props: { path: `${ORIGIN}/dashboard`, title: 'Dashboard' } }, + ], + + // 19. Deep content engagement (blog) + (_product) => [ + { name: 'screen_view', props: { path: `${ORIGIN}/blog`, title: 'Blog', referrer: 'https://google.com' } }, + { name: 'screen_view', props: { path: `${ORIGIN}/blog/buying-guide-headphones`, title: 'Headphones Buying Guide' } }, + { name: 'content_read', props: { article: 'headphones_buying_guide', reading_time: randInt(120, 480), scroll_depth: randFloat(0.6, 1.0) } }, + { name: 'screen_view', props: { path: `${ORIGIN}/blog/best-running-shoes-2024`, title: 'Best Running Shoes 2024' } }, + { name: 'content_read', props: { article: 'best_running_shoes_2024', reading_time: randInt(90, 300), scroll_depth: randFloat(0.5, 1.0) } }, + ], + + // 20. Error / 404 bounce + (_product) => [ + { name: 'screen_view', props: { path: `${ORIGIN}/products/old-discontinued-product`, title: 'Product Not Found' } }, + { name: 'page_error', props: { error_code: 404, path: '/products/old-discontinued-product' } }, + { name: 'screen_view', props: { path: `${ORIGIN}/`, title: 'Home' } }, + ], +]; + +// --------------------------------------------------------------------------- +// Identity generation (deterministic by session index) +// --------------------------------------------------------------------------- + +function generateIdentity(sessionIndex, sessionRng) { + const firstName = pick(FIRST_NAMES, sessionRng); + const lastName = pick(LAST_NAMES, sessionRng); + const emailDomain = pick(EMAIL_DOMAINS, sessionRng); + const profileId = `user_${String(sessionIndex + 1).padStart(4, '0')}`; + return { + profileId, + firstName, + lastName, + email: `${firstName.toLowerCase()}.${lastName.toLowerCase()}${sessionIndex}@${emailDomain}`, + }; +} + +// Which sessions belong to which group (roughly 1/6 each) +function getGroupForSession(sessionIndex) { + if (sessionIndex % 6 === 0) return GROUPS[0]; + if (sessionIndex % 6 === 1) return GROUPS[1]; + if (sessionIndex % 6 === 2) return GROUPS[2]; + return null; +} + +// --------------------------------------------------------------------------- +// HTTP helper +// --------------------------------------------------------------------------- + +async function sendEvent(payload, ua, ip) { + const headers = { + 'Content-Type': 'application/json', + 'user-agent': ua, + 'openpanel-client-id': CLIENT_ID, + 'x-forwarded-for': ip, + origin: ORIGIN, + }; + + const res = await fetch(TRACK_URL, { + method: 'POST', + headers, + body: JSON.stringify(payload), + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + console.warn(` [WARN] ${res.status} ${payload.type}/${payload.payload?.name ?? ''}: ${text.slice(0, 120)}`); + } + return res; +} + +// --------------------------------------------------------------------------- +// Build session event list +// --------------------------------------------------------------------------- + +function buildSession(sessionIndex) { + const sessionRng = mulberry32(sessionIndex * 9973 + 1337); // deterministic per session + + const identity = generateIdentity(sessionIndex, sessionRng); + const group = getGroupForSession(sessionIndex); + const ua = makeUniqueUA(pick(USER_AGENTS, sessionRng), sessionIndex); + const ip = makeIP(sessionIndex); + const product = pick(PRODUCTS, sessionRng); + const scenarioFn = SCENARIOS[sessionIndex % SCENARIOS.length]; + const events = scenarioFn(product); + + return { identity, group, ua, ip, events }; +} + +// --------------------------------------------------------------------------- +// Schedule events across timeline +// --------------------------------------------------------------------------- + +function scheduleSession(session, sessionIndex, totalSessions) { + const timelineMs = TIMELINE_MINUTES * 60 * 1000; + const now = Date.now(); + + // Sessions are spread across the timeline + const sessionStartOffset = (sessionIndex / totalSessions) * timelineMs; + const sessionStart = now - timelineMs + sessionStartOffset; + + // Events within session: spread over 2-10 minutes + const sessionDurationMs = randInt(2, 10) * 60 * 1000; + const eventCount = session.events.length; + + return session.events.map((event, i) => { + const eventOffset = eventCount > 1 ? (i / (eventCount - 1)) * sessionDurationMs : 0; + return { + ...event, + timestamp: Math.round(sessionStart + eventOffset), + }; + }); +} + +// --------------------------------------------------------------------------- +// Concurrency limiter +// --------------------------------------------------------------------------- + +async function withConcurrency(tasks, limit) { + const results = []; + const executing = []; + for (const task of tasks) { + const p = Promise.resolve().then(task); + results.push(p); + const e = p.then(() => executing.splice(executing.indexOf(e), 1)); + executing.push(e); + if (executing.length >= limit) { + await Promise.race(executing); + } + } + return Promise.all(results); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + console.log(`\nSeeding ${SESSION_COUNT} sessions over ${TIMELINE_MINUTES} minutes`); + console.log(`API: ${TRACK_URL}`); + console.log(`Client ID: ${CLIENT_ID}\n`); + + let totalEvents = 0; + let errors = 0; + + const sessionTasks = Array.from({ length: SESSION_COUNT }, (_, i) => async () => { + const session = buildSession(i); + const scheduledEvents = scheduleSession(session, i, SESSION_COUNT); + const { identity, group, ua, ip } = session; + + // 1. Identify + try { + await sendEvent({ type: 'identify', payload: identity }, ua, ip); + } catch (e) { + errors++; + console.error(` [ERROR] identify session ${i}:`, e.message); + } + + // 2. Group (if applicable) + if (group) { + try { + await sendEvent({ type: 'group', payload: { ...group, profileId: identity.profileId } }, ua, ip); + } catch (e) { + errors++; + console.error(` [ERROR] group session ${i}:`, e.message); + } + } + + // 3. Track events in order + for (const ev of scheduledEvents) { + const trackPayload = { + name: ev.name, + profileId: identity.profileId, + properties: { + ...ev.props, + __timestamp: new Date(ev.timestamp).toISOString(), + ...(group ? { __group: group.id } : {}), + }, + groups: group ? [group.id] : [], + }; + + if (ev.revenue) { + trackPayload.properties.__revenue = ev.props.revenue; + } + + try { + await sendEvent({ type: 'track', payload: trackPayload }, ua, ip); + totalEvents++; + } catch (e) { + errors++; + console.error(` [ERROR] track ${ev.name} session ${i}:`, e.message); + } + } + + if ((i + 1) % 50 === 0 || i + 1 === SESSION_COUNT) { + console.log(` Progress: ${i + 1}/${SESSION_COUNT} sessions`); + } + }); + + await withConcurrency(sessionTasks, CONCURRENCY); + + console.log(`\nDone!`); + console.log(` Sessions: ${SESSION_COUNT}`); + console.log(` Events sent: ${totalEvents}`); + console.log(` Errors: ${errors}`); +} + +main().catch((err) => { + console.error('Fatal:', err); + process.exit(1); +});