From fa78e63bc887e3edc169063ea20104fff1abb89b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Wed, 11 Mar 2026 21:27:41 +0100 Subject: [PATCH] wip --- apps/api/src/controllers/track.controller.ts | 48 ++++++++++--------- .../components/groups/group-member-growth.tsx | 39 ++++++++------- .../components/organization/billing-usage.tsx | 1 + .../src/components/profiles/most-events.tsx | 39 ++++++++------- .../components/profiles/popular-routes.tsx | 39 ++++++++------- apps/start/src/components/widget.tsx | 13 +++++ ...projectId.groups_.$groupId._tabs.index.tsx | 8 ++-- apps/testbed/src/App.tsx | 2 +- packages/db/src/clickhouse/client.ts | 8 ++++ packages/sdks/sdk/src/index.ts | 48 ++++++++++++------- packages/trpc/src/routers/group.ts | 32 ++++++++----- packages/validation/src/track.validation.ts | 9 ++++ 12 files changed, 178 insertions(+), 108 deletions(-) diff --git a/apps/api/src/controllers/track.controller.ts b/apps/api/src/controllers/track.controller.ts index e1e1161d..8c7e6ceb 100644 --- a/apps/api/src/controllers/track.controller.ts +++ b/apps/api/src/controllers/track.controller.ts @@ -14,6 +14,7 @@ import { } from '@openpanel/queue'; import { getRedisCache } from '@openpanel/redis'; import { + type IAssignGroupPayload, type IDecrementPayload, type IGroupPayload, type IIdentifyPayload, @@ -341,31 +342,29 @@ async function handleGroup( context: TrackContext ): Promise { const { id, type, name, properties = {} } = payload; + await groupBuffer.add({ + id, + projectId: context.projectId, + type, + name, + properties, + }); +} + +async function handleAssignGroup( + payload: IAssignGroupPayload, + context: TrackContext +): Promise { const profileId = payload.profileId ?? context.deviceId; - - const promises: Promise[] = []; - promises.push( - groupBuffer.add({ - id, - projectId: context.projectId, - type, - name, - properties, - }) - ); - - if (profileId) { - promises.push( - upsertProfile({ - id: String(profileId), - projectId: context.projectId, - isExternal: !!(payload.profileId ?? context.identity?.profileId), - groups: [id], - }) - ); + if (!profileId) { + return; } - - await Promise.all(promises); + await upsertProfile({ + id: String(profileId), + projectId: context.projectId, + isExternal: !!payload.profileId, + groups: payload.groupIds, + }); } export async function handler( @@ -419,6 +418,9 @@ export async function handler( case 'group': await handleGroup(validatedBody.payload, context); break; + case 'assign_group': + await handleAssignGroup(validatedBody.payload, context); + break; default: return reply.status(400).send({ status: 400, diff --git a/apps/start/src/components/groups/group-member-growth.tsx b/apps/start/src/components/groups/group-member-growth.tsx index 5c1fd75c..a5212ec0 100644 --- a/apps/start/src/components/groups/group-member-growth.tsx +++ b/apps/start/src/components/groups/group-member-growth.tsx @@ -1,11 +1,3 @@ -import { - useXAxisProps, - useYAxisProps, -} from '@/components/report-chart/common/axis'; -import { Widget, WidgetBody } from '@/components/widget'; -import { WidgetHead, WidgetTitle } from '../overview/overview-widget'; -import { useNumber } from '@/hooks/use-numer-formatter'; -import { useFormatDateInterval } from '@/hooks/use-format-date-interval'; import { TrendingUpIcon } from 'lucide-react'; import { Area, @@ -16,6 +8,14 @@ import { XAxis, YAxis, } from 'recharts'; +import { WidgetHead, WidgetTitle } from '../overview/overview-widget'; +import { + useXAxisProps, + useYAxisProps, +} from '@/components/report-chart/common/axis'; +import { Widget, WidgetBody } from '@/components/widget'; +import { useFormatDateInterval } from '@/hooks/use-format-date-interval'; +import { useNumber } from '@/hooks/use-numer-formatter'; import { getChartColor } from '@/utils/theme'; type Props = { @@ -37,10 +37,16 @@ function Tooltip(props: any) { {formatDate(new Date(payload.timestamp))}
-
+
Total members
-
+
{number.format(payload.cumulative)}
@@ -87,7 +93,7 @@ export function GroupMemberGrowth({ data }: Props) { - + @@ -97,17 +103,18 @@ export function GroupMemberGrowth({ data }: Props) { cursor={{ stroke: color, strokeOpacity: 0.3 }} /> - + { - const max = Math.max(...data.map((item) => item.count)); + const max = data.length > 0 ? Math.max(...data.map((item) => item.count)) : 0; return ( Popular events -
- {data.slice(0, 5).map((item) => ( -
-
-
-
{item.name}
-
{item.count}
+ {data.length === 0 ? ( + + ) : ( +
+ {data.slice(0, 5).map((item) => ( +
+
+
+
{item.name}
+
{item.count}
+
-
- ))} -
+ ))} +
+ )} ); }; diff --git a/apps/start/src/components/profiles/popular-routes.tsx b/apps/start/src/components/profiles/popular-routes.tsx index 17fb6bd4..c89b1d60 100644 --- a/apps/start/src/components/profiles/popular-routes.tsx +++ b/apps/start/src/components/profiles/popular-routes.tsx @@ -1,4 +1,5 @@ -import { Widget } from '@/components/widget'; +import { RouteIcon } from 'lucide-react'; +import { Widget, WidgetEmptyState } from '@/components/widget'; import { WidgetHead, WidgetTitle } from '../overview/overview-widget'; type Props = { @@ -6,28 +7,32 @@ type Props = { }; export const PopularRoutes = ({ data }: Props) => { - const max = Math.max(...data.map((item) => item.count)); + const max = data.length > 0 ? Math.max(...data.map((item) => item.count)) : 0; return ( Most visted pages -
- {data.slice(0, 5).map((item) => ( -
-
-
-
{item.path}
-
{item.count}
+ {data.length === 0 ? ( + + ) : ( +
+ {data.slice(0, 5).map((item) => ( +
+
+
+
{item.path}
+
{item.count}
+
-
- ))} -
+ ))} +
+ )} ); }; diff --git a/apps/start/src/components/widget.tsx b/apps/start/src/components/widget.tsx index 96a5e22c..f2435036 100644 --- a/apps/start/src/components/widget.tsx +++ b/apps/start/src/components/widget.tsx @@ -54,6 +54,19 @@ export function WidgetBody({ children, className }: WidgetBodyProps) { return
{children}
; } +export interface WidgetEmptyStateProps { + icon: LucideIcon; + text: string; +} +export function WidgetEmptyState({ icon: Icon, text }: WidgetEmptyStateProps) { + return ( +
+ +

{text}

+
+ ); +} + export interface WidgetProps { children: React.ReactNode; className?: string; 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 index 88c5d054..2ed808fe 100644 --- 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 @@ -9,7 +9,7 @@ import { MostEvents } from '@/components/profiles/most-events'; import { PopularRoutes } from '@/components/profiles/popular-routes'; import { ProfileActivity } from '@/components/profiles/profile-activity'; import { KeyValueGrid } from '@/components/ui/key-value-grid'; -import { Widget, WidgetBody } from '@/components/widget'; +import { Widget, WidgetBody, WidgetEmptyState } from '@/components/widget'; import { WidgetTable } from '@/components/widget-table'; import { useTRPC } from '@/integrations/trpc/react'; import { formatDateTime, formatTimeAgoOrDateTime } from '@/utils/date'; @@ -64,7 +64,7 @@ function Component() { ); const g = group.data; - const m = metrics.data?.[0]; + const m = metrics.data; if (!g) { return null; @@ -177,9 +177,7 @@ function Component() { {members.data.length === 0 ? ( -

- No members found -

+ ) : ( g.id === id); if (meta) { - op.setGroup(id, meta); + op.upsertGroup({ id, ...meta }); } } } diff --git a/packages/db/src/clickhouse/client.ts b/packages/db/src/clickhouse/client.ts index eb8bfd5a..dd81877a 100644 --- a/packages/db/src/clickhouse/client.ts +++ b/packages/db/src/clickhouse/client.ts @@ -299,3 +299,11 @@ const ROLLUP_DATE_PREFIX = '1970-01-01'; export function isClickhouseDefaultMinDate(date: string): boolean { return date.startsWith(ROLLUP_DATE_PREFIX) || date.startsWith('1969-12-31'); } +export function toNullIfDefaultMinDate(date?: string | null): Date | null { + if (!date) { + return null; + } + return isClickhouseDefaultMinDate(date) + ? null + : convertClickhouseDateToJs(date); +} diff --git a/packages/sdks/sdk/src/index.ts b/packages/sdks/sdk/src/index.ts index 373ce32d..71789d62 100644 --- a/packages/sdks/sdk/src/index.ts +++ b/packages/sdks/sdk/src/index.ts @@ -2,6 +2,7 @@ import type { IAliasPayload as AliasPayload, + IAssignGroupPayload as AssignGroupPayload, IDecrementPayload as DecrementPayload, IGroupPayload as GroupPayload, IIdentifyPayload as IdentifyPayload, @@ -13,6 +14,7 @@ import { Api } from './api'; export type { AliasPayload, + AssignGroupPayload, DecrementPayload, GroupPayload, IdentifyPayload, @@ -27,7 +29,7 @@ export interface TrackProperties { groups?: string[]; } -export type GroupMetadata = Omit; +export type UpsertGroupPayload = GroupPayload; export interface OpenPanelOptions { clientId: string; @@ -187,26 +189,38 @@ export class OpenPanel { } } - setGroups(groupIds: string[]) { - this.log('set groups', groupIds); - this.groups = groupIds; + upsertGroup(payload: UpsertGroupPayload) { + this.log('upsert group', payload); + return this.send({ + type: 'group', + payload, + }); } - setGroup(groupId: string, metadata?: GroupMetadata) { - this.log('set group', groupId, metadata); + setGroup(groupId: string) { + this.log('set group', groupId); if (!this.groups.includes(groupId)) { this.groups = [...this.groups, groupId]; } - if (metadata) { - return this.send({ - type: 'group', - payload: { - id: groupId, - ...metadata, - profileId: this.profileId, - }, - }); - } + return this.send({ + type: 'assign_group', + payload: { + groupIds: [groupId], + profileId: this.profileId, + }, + }); + } + + setGroups(groupIds: string[]) { + this.log('set groups', groupIds); + this.groups = [...new Set([...this.groups, ...groupIds])]; + return this.send({ + type: 'assign_group', + payload: { + groupIds, + profileId: this.profileId, + }, + }); } /** @@ -291,7 +305,7 @@ export class OpenPanel { profileId: item.payload.profileId ?? this.profileId, } as TrackHandlerPayload['payload']; } - if (item.type === 'group') { + if (item.type === 'assign_group') { return { ...item.payload, profileId: item.payload.profileId ?? this.profileId, diff --git a/packages/trpc/src/routers/group.ts b/packages/trpc/src/routers/group.ts index 79956e06..695bd474 100644 --- a/packages/trpc/src/routers/group.ts +++ b/packages/trpc/src/routers/group.ts @@ -11,6 +11,7 @@ import { getGroupsByIds, getGroupTypes, TABLE_NAMES, + toNullIfDefaultMinDate, updateGroup, } from '@openpanel/db'; import sqlstring from 'sqlstring'; @@ -49,7 +50,7 @@ export const groupRouter = createTRPCRouter({ byId: protectedProcedure .input(z.object({ id: z.string(), projectId: z.string() })) - .query(async ({ input: { id, projectId } }) => { + .query(({ input: { id, projectId } }) => { return getGroupById(id, projectId); }), @@ -63,7 +64,7 @@ export const groupRouter = createTRPCRouter({ properties: z.record(z.string()).default({}), }) ) - .mutation(async ({ input }) => { + .mutation(({ input }) => { return createGroup(input); }), @@ -77,26 +78,26 @@ export const groupRouter = createTRPCRouter({ properties: z.record(z.string()).optional(), }) ) - .mutation(async ({ input: { id, projectId, ...data } }) => { + .mutation(({ 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 } }) => { + .mutation(({ input: { id, projectId } }) => { return deleteGroup(id, projectId); }), types: protectedProcedure .input(z.object({ projectId: z.string() })) - .query(async ({ input: { projectId } }) => { + .query(({ input: { projectId } }) => { return getGroupTypes(projectId); }), metrics: protectedProcedure .input(z.object({ id: z.string(), projectId: z.string() })) .query(async ({ input: { id, projectId } }) => { - return chQuery<{ + const data = await chQuery<{ totalEvents: number; uniqueProfiles: number; firstSeen: string; @@ -111,11 +112,18 @@ export const groupRouter = createTRPCRouter({ WHERE project_id = ${sqlstring.escape(projectId)} AND has(groups, ${sqlstring.escape(id)}) `); + + return { + totalEvents: data[0]?.totalEvents ?? 0, + uniqueProfiles: data[0]?.uniqueProfiles ?? 0, + firstSeen: toNullIfDefaultMinDate(data[0]?.firstSeen), + lastSeen: toNullIfDefaultMinDate(data[0]?.lastSeen), + }; }), activity: protectedProcedure .input(z.object({ id: z.string(), projectId: z.string() })) - .query(async ({ input: { id, projectId } }) => { + .query(({ input: { id, projectId } }) => { return chQuery<{ count: number; date: string }>(` SELECT count() AS count, toStartOfDay(created_at) AS date FROM ${TABLE_NAMES.events} @@ -174,7 +182,7 @@ export const groupRouter = createTRPCRouter({ mostEvents: protectedProcedure .input(z.object({ id: z.string(), projectId: z.string() })) - .query(async ({ input: { id, projectId } }) => { + .query(({ input: { id, projectId } }) => { return chQuery<{ count: number; name: string }>(` SELECT count() as count, name FROM ${TABLE_NAMES.events} @@ -189,7 +197,7 @@ export const groupRouter = createTRPCRouter({ popularRoutes: protectedProcedure .input(z.object({ id: z.string(), projectId: z.string() })) - .query(async ({ input: { id, projectId } }) => { + .query(({ input: { id, projectId } }) => { return chQuery<{ count: number; path: string }>(` SELECT count() as count, path FROM ${TABLE_NAMES.events} @@ -204,7 +212,7 @@ export const groupRouter = createTRPCRouter({ memberGrowth: protectedProcedure .input(z.object({ id: z.string(), projectId: z.string() })) - .query(async ({ input: { id, projectId } }) => { + .query(({ input: { id, projectId } }) => { return chQuery<{ date: string; count: number }>(` SELECT toDate(toStartOfDay(min_date)) AS date, @@ -228,13 +236,13 @@ export const groupRouter = createTRPCRouter({ properties: protectedProcedure .input(z.object({ projectId: z.string() })) - .query(async ({ input: { projectId } }) => { + .query(({ input: { projectId } }) => { return getGroupPropertyKeys(projectId); }), listByIds: protectedProcedure .input(z.object({ projectId: z.string(), ids: z.array(z.string()) })) - .query(async ({ input: { projectId, ids } }) => { + .query(({ input: { projectId, ids } }) => { return getGroupsByIds(projectId, ids); }), }); diff --git a/packages/validation/src/track.validation.ts b/packages/validation/src/track.validation.ts index 43836f23..0bb9dcc2 100644 --- a/packages/validation/src/track.validation.ts +++ b/packages/validation/src/track.validation.ts @@ -7,6 +7,10 @@ export const zGroupPayload = z.object({ type: z.string().min(1), name: z.string().min(1), properties: z.record(z.unknown()).optional(), +}); + +export const zAssignGroupPayload = z.object({ + groupIds: z.array(z.string().min(1)), profileId: z.union([z.string().min(1), z.number()]).optional(), }); @@ -110,6 +114,10 @@ export const zTrackHandlerPayload = z.discriminatedUnion('type', [ type: z.literal('group'), payload: zGroupPayload, }), + z.object({ + type: z.literal('assign_group'), + payload: zAssignGroupPayload, + }), ]); export type ITrackPayload = z.infer; @@ -119,6 +127,7 @@ export type IDecrementPayload = z.infer; export type IAliasPayload = z.infer; export type IReplayPayload = z.infer; export type IGroupPayload = z.infer; +export type IAssignGroupPayload = z.infer; export type ITrackHandlerPayload = z.infer; // Deprecated types for beta version of the SDKs