This commit is contained in:
Carl-Gerhard Lindesvärd
2026-03-11 21:27:41 +01:00
parent e6d0b6544b
commit fa78e63bc8
12 changed files with 178 additions and 108 deletions

View File

@@ -14,6 +14,7 @@ import {
} from '@openpanel/queue'; } from '@openpanel/queue';
import { getRedisCache } from '@openpanel/redis'; import { getRedisCache } from '@openpanel/redis';
import { import {
type IAssignGroupPayload,
type IDecrementPayload, type IDecrementPayload,
type IGroupPayload, type IGroupPayload,
type IIdentifyPayload, type IIdentifyPayload,
@@ -341,31 +342,29 @@ async function handleGroup(
context: TrackContext context: TrackContext
): Promise<void> { ): Promise<void> {
const { id, type, name, properties = {} } = payload; const { id, type, name, properties = {} } = payload;
await groupBuffer.add({
id,
projectId: context.projectId,
type,
name,
properties,
});
}
async function handleAssignGroup(
payload: IAssignGroupPayload,
context: TrackContext
): Promise<void> {
const profileId = payload.profileId ?? context.deviceId; const profileId = payload.profileId ?? context.deviceId;
if (!profileId) {
const promises: Promise<unknown>[] = []; return;
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],
})
);
} }
await upsertProfile({
await Promise.all(promises); id: String(profileId),
projectId: context.projectId,
isExternal: !!payload.profileId,
groups: payload.groupIds,
});
} }
export async function handler( export async function handler(
@@ -419,6 +418,9 @@ export async function handler(
case 'group': case 'group':
await handleGroup(validatedBody.payload, context); await handleGroup(validatedBody.payload, context);
break; break;
case 'assign_group':
await handleAssignGroup(validatedBody.payload, context);
break;
default: default:
return reply.status(400).send({ return reply.status(400).send({
status: 400, status: 400,

View File

@@ -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 { TrendingUpIcon } from 'lucide-react';
import { import {
Area, Area,
@@ -16,6 +8,14 @@ import {
XAxis, XAxis,
YAxis, YAxis,
} from 'recharts'; } 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'; import { getChartColor } from '@/utils/theme';
type Props = { type Props = {
@@ -37,10 +37,16 @@ function Tooltip(props: any) {
{formatDate(new Date(payload.timestamp))} {formatDate(new Date(payload.timestamp))}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-10 w-1 rounded-full" style={{ background: getChartColor(0) }} /> <div
className="h-10 w-1 rounded-full"
style={{ background: getChartColor(0) }}
/>
<div className="col gap-1"> <div className="col gap-1">
<div className="text-muted-foreground text-sm">Total members</div> <div className="text-muted-foreground text-sm">Total members</div>
<div className="font-semibold text-lg" style={{ color: getChartColor(0) }}> <div
className="font-semibold text-lg"
style={{ color: getChartColor(0) }}
>
{number.format(payload.cumulative)} {number.format(payload.cumulative)}
</div> </div>
</div> </div>
@@ -87,7 +93,7 @@ export function GroupMemberGrowth({ data }: Props) {
<ResponsiveContainer> <ResponsiveContainer>
<AreaChart data={chartData}> <AreaChart data={chartData}>
<defs> <defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1"> <linearGradient id={gradientId} x1="0" x2="0" y1="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.3} /> <stop offset="5%" stopColor={color} stopOpacity={0.3} />
<stop offset="95%" stopColor={color} stopOpacity={0.02} /> <stop offset="95%" stopColor={color} stopOpacity={0.02} />
</linearGradient> </linearGradient>
@@ -97,17 +103,18 @@ export function GroupMemberGrowth({ data }: Props) {
cursor={{ stroke: color, strokeOpacity: 0.3 }} cursor={{ stroke: color, strokeOpacity: 0.3 }}
/> />
<Area <Area
type="monotone"
dataKey="cumulative" dataKey="cumulative"
dot={false}
fill={`url(#${gradientId})`}
isAnimationActive={false}
stroke={color} stroke={color}
strokeWidth={2} strokeWidth={2}
fill={`url(#${gradientId})`} type="monotone"
dot={false}
isAnimationActive={false}
/> />
<XAxis {...xAxisProps} /> <XAxis {...xAxisProps} />
<YAxis {...yAxisProps} domain={[0, 'dataMax']} /> <YAxis {...yAxisProps} />
<CartesianGrid <CartesianGrid
className="stroke-border"
horizontal={true} horizontal={true}
strokeDasharray="3 3" strokeDasharray="3 3"
strokeOpacity={0.5} strokeOpacity={0.5}

View File

@@ -242,6 +242,7 @@ export default function BillingUsage({ organization }: Props) {
<XAxis {...xAxisProps} dataKey="date" /> <XAxis {...xAxisProps} dataKey="date" />
<YAxis {...yAxisProps} domain={[0, 'dataMax']} /> <YAxis {...yAxisProps} domain={[0, 'dataMax']} />
<CartesianGrid <CartesianGrid
className="stroke-border"
horizontal={true} horizontal={true}
strokeDasharray="3 3" strokeDasharray="3 3"
strokeOpacity={0.5} strokeOpacity={0.5}

View File

@@ -1,4 +1,5 @@
import { Widget } from '@/components/widget'; import { ZapIcon } from 'lucide-react';
import { Widget, WidgetEmptyState } from '@/components/widget';
import { WidgetHead, WidgetTitle } from '../overview/overview-widget'; import { WidgetHead, WidgetTitle } from '../overview/overview-widget';
type Props = { type Props = {
@@ -6,28 +7,32 @@ type Props = {
}; };
export const MostEvents = ({ data }: Props) => { export const MostEvents = ({ 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 ( return (
<Widget className="w-full"> <Widget className="w-full">
<WidgetHead> <WidgetHead>
<WidgetTitle>Popular events</WidgetTitle> <WidgetTitle>Popular events</WidgetTitle>
</WidgetHead> </WidgetHead>
<div className="flex flex-col gap-1 p-1"> {data.length === 0 ? (
{data.slice(0, 5).map((item) => ( <WidgetEmptyState icon={ZapIcon} text="No events yet" />
<div key={item.name} className="relative px-3 py-2"> ) : (
<div <div className="flex flex-col gap-1 p-1">
className="absolute bottom-0 left-0 top-0 rounded bg-def-200" {data.slice(0, 5).map((item) => (
style={{ <div key={item.name} className="relative px-3 py-2">
width: `${(item.count / max) * 100}%`, <div
}} className="absolute bottom-0 left-0 top-0 rounded bg-def-200"
/> style={{
<div className="relative flex justify-between "> width: `${(item.count / max) * 100}%`,
<div>{item.name}</div> }}
<div>{item.count}</div> />
<div className="relative flex justify-between ">
<div>{item.name}</div>
<div>{item.count}</div>
</div>
</div> </div>
</div> ))}
))} </div>
</div> )}
</Widget> </Widget>
); );
}; };

View File

@@ -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'; import { WidgetHead, WidgetTitle } from '../overview/overview-widget';
type Props = { type Props = {
@@ -6,28 +7,32 @@ type Props = {
}; };
export const PopularRoutes = ({ data }: 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 ( return (
<Widget className="w-full"> <Widget className="w-full">
<WidgetHead> <WidgetHead>
<WidgetTitle>Most visted pages</WidgetTitle> <WidgetTitle>Most visted pages</WidgetTitle>
</WidgetHead> </WidgetHead>
<div className="flex flex-col gap-1 p-1"> {data.length === 0 ? (
{data.slice(0, 5).map((item) => ( <WidgetEmptyState icon={RouteIcon} text="No pages visited yet" />
<div key={item.path} className="relative px-3 py-2"> ) : (
<div <div className="flex flex-col gap-1 p-1">
className="absolute bottom-0 left-0 top-0 rounded bg-def-200" {data.slice(0, 5).map((item) => (
style={{ <div key={item.path} className="relative px-3 py-2">
width: `${(item.count / max) * 100}%`, <div
}} className="absolute bottom-0 left-0 top-0 rounded bg-def-200"
/> style={{
<div className="relative flex justify-between "> width: `${(item.count / max) * 100}%`,
<div>{item.path}</div> }}
<div>{item.count}</div> />
<div className="relative flex justify-between ">
<div>{item.path}</div>
<div>{item.count}</div>
</div>
</div> </div>
</div> ))}
))} </div>
</div> )}
</Widget> </Widget>
); );
}; };

View File

@@ -54,6 +54,19 @@ export function WidgetBody({ children, className }: WidgetBodyProps) {
return <div className={cn('p-4', className)}>{children}</div>; return <div className={cn('p-4', className)}>{children}</div>;
} }
export interface WidgetEmptyStateProps {
icon: LucideIcon;
text: string;
}
export function WidgetEmptyState({ icon: Icon, text }: WidgetEmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center gap-2 py-8 text-muted-foreground">
<Icon size={28} strokeWidth={1.5} />
<p className="text-sm">{text}</p>
</div>
);
}
export interface WidgetProps { export interface WidgetProps {
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;

View File

@@ -9,7 +9,7 @@ import { MostEvents } from '@/components/profiles/most-events';
import { PopularRoutes } from '@/components/profiles/popular-routes'; import { PopularRoutes } from '@/components/profiles/popular-routes';
import { ProfileActivity } from '@/components/profiles/profile-activity'; import { ProfileActivity } from '@/components/profiles/profile-activity';
import { KeyValueGrid } from '@/components/ui/key-value-grid'; 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 { WidgetTable } from '@/components/widget-table';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { formatDateTime, formatTimeAgoOrDateTime } from '@/utils/date'; import { formatDateTime, formatTimeAgoOrDateTime } from '@/utils/date';
@@ -64,7 +64,7 @@ function Component() {
); );
const g = group.data; const g = group.data;
const m = metrics.data?.[0]; const m = metrics.data;
if (!g) { if (!g) {
return null; return null;
@@ -177,9 +177,7 @@ function Component() {
</WidgetHead> </WidgetHead>
<WidgetBody className="p-0"> <WidgetBody className="p-0">
{members.data.length === 0 ? ( {members.data.length === 0 ? (
<p className="py-4 text-center text-muted-foreground text-sm"> <WidgetEmptyState icon={UsersIcon} text="No members yet" />
No members found
</p>
) : ( ) : (
<WidgetTable <WidgetTable
columnClassName="px-2" columnClassName="px-2"

View File

@@ -42,7 +42,7 @@ export default function App() {
for (const id of u.groupIds) { for (const id of u.groupIds) {
const meta = PRESET_GROUPS.find((g) => g.id === id); const meta = PRESET_GROUPS.find((g) => g.id === id);
if (meta) { if (meta) {
op.setGroup(id, meta); op.upsertGroup({ id, ...meta });
} }
} }
} }

View File

@@ -299,3 +299,11 @@ const ROLLUP_DATE_PREFIX = '1970-01-01';
export function isClickhouseDefaultMinDate(date: string): boolean { export function isClickhouseDefaultMinDate(date: string): boolean {
return date.startsWith(ROLLUP_DATE_PREFIX) || date.startsWith('1969-12-31'); 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);
}

View File

@@ -2,6 +2,7 @@
import type { import type {
IAliasPayload as AliasPayload, IAliasPayload as AliasPayload,
IAssignGroupPayload as AssignGroupPayload,
IDecrementPayload as DecrementPayload, IDecrementPayload as DecrementPayload,
IGroupPayload as GroupPayload, IGroupPayload as GroupPayload,
IIdentifyPayload as IdentifyPayload, IIdentifyPayload as IdentifyPayload,
@@ -13,6 +14,7 @@ import { Api } from './api';
export type { export type {
AliasPayload, AliasPayload,
AssignGroupPayload,
DecrementPayload, DecrementPayload,
GroupPayload, GroupPayload,
IdentifyPayload, IdentifyPayload,
@@ -27,7 +29,7 @@ export interface TrackProperties {
groups?: string[]; groups?: string[];
} }
export type GroupMetadata = Omit<GroupPayload, 'id'>; export type UpsertGroupPayload = GroupPayload;
export interface OpenPanelOptions { export interface OpenPanelOptions {
clientId: string; clientId: string;
@@ -187,26 +189,38 @@ export class OpenPanel {
} }
} }
setGroups(groupIds: string[]) { upsertGroup(payload: UpsertGroupPayload) {
this.log('set groups', groupIds); this.log('upsert group', payload);
this.groups = groupIds; return this.send({
type: 'group',
payload,
});
} }
setGroup(groupId: string, metadata?: GroupMetadata) { setGroup(groupId: string) {
this.log('set group', groupId, metadata); this.log('set group', groupId);
if (!this.groups.includes(groupId)) { if (!this.groups.includes(groupId)) {
this.groups = [...this.groups, groupId]; this.groups = [...this.groups, groupId];
} }
if (metadata) { return this.send({
return this.send({ type: 'assign_group',
type: 'group', payload: {
payload: { groupIds: [groupId],
id: groupId, profileId: this.profileId,
...metadata, },
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, profileId: item.payload.profileId ?? this.profileId,
} as TrackHandlerPayload['payload']; } as TrackHandlerPayload['payload'];
} }
if (item.type === 'group') { if (item.type === 'assign_group') {
return { return {
...item.payload, ...item.payload,
profileId: item.payload.profileId ?? this.profileId, profileId: item.payload.profileId ?? this.profileId,

View File

@@ -11,6 +11,7 @@ import {
getGroupsByIds, getGroupsByIds,
getGroupTypes, getGroupTypes,
TABLE_NAMES, TABLE_NAMES,
toNullIfDefaultMinDate,
updateGroup, updateGroup,
} from '@openpanel/db'; } from '@openpanel/db';
import sqlstring from 'sqlstring'; import sqlstring from 'sqlstring';
@@ -49,7 +50,7 @@ export const groupRouter = createTRPCRouter({
byId: protectedProcedure byId: protectedProcedure
.input(z.object({ id: z.string(), projectId: z.string() })) .input(z.object({ id: z.string(), projectId: z.string() }))
.query(async ({ input: { id, projectId } }) => { .query(({ input: { id, projectId } }) => {
return getGroupById(id, projectId); return getGroupById(id, projectId);
}), }),
@@ -63,7 +64,7 @@ export const groupRouter = createTRPCRouter({
properties: z.record(z.string()).default({}), properties: z.record(z.string()).default({}),
}) })
) )
.mutation(async ({ input }) => { .mutation(({ input }) => {
return createGroup(input); return createGroup(input);
}), }),
@@ -77,26 +78,26 @@ export const groupRouter = createTRPCRouter({
properties: z.record(z.string()).optional(), properties: z.record(z.string()).optional(),
}) })
) )
.mutation(async ({ input: { id, projectId, ...data } }) => { .mutation(({ input: { id, projectId, ...data } }) => {
return updateGroup(id, projectId, data); return updateGroup(id, projectId, data);
}), }),
delete: protectedProcedure delete: protectedProcedure
.input(z.object({ id: z.string(), projectId: z.string() })) .input(z.object({ id: z.string(), projectId: z.string() }))
.mutation(async ({ input: { id, projectId } }) => { .mutation(({ input: { id, projectId } }) => {
return deleteGroup(id, projectId); return deleteGroup(id, projectId);
}), }),
types: protectedProcedure types: protectedProcedure
.input(z.object({ projectId: z.string() })) .input(z.object({ projectId: z.string() }))
.query(async ({ input: { projectId } }) => { .query(({ input: { projectId } }) => {
return getGroupTypes(projectId); return getGroupTypes(projectId);
}), }),
metrics: protectedProcedure metrics: protectedProcedure
.input(z.object({ id: z.string(), projectId: z.string() })) .input(z.object({ id: z.string(), projectId: z.string() }))
.query(async ({ input: { id, projectId } }) => { .query(async ({ input: { id, projectId } }) => {
return chQuery<{ const data = await chQuery<{
totalEvents: number; totalEvents: number;
uniqueProfiles: number; uniqueProfiles: number;
firstSeen: string; firstSeen: string;
@@ -111,11 +112,18 @@ export const groupRouter = createTRPCRouter({
WHERE project_id = ${sqlstring.escape(projectId)} WHERE project_id = ${sqlstring.escape(projectId)}
AND has(groups, ${sqlstring.escape(id)}) 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 activity: protectedProcedure
.input(z.object({ id: z.string(), projectId: z.string() })) .input(z.object({ id: z.string(), projectId: z.string() }))
.query(async ({ input: { id, projectId } }) => { .query(({ input: { id, projectId } }) => {
return chQuery<{ count: number; date: string }>(` return chQuery<{ count: number; date: string }>(`
SELECT count() AS count, toStartOfDay(created_at) AS date SELECT count() AS count, toStartOfDay(created_at) AS date
FROM ${TABLE_NAMES.events} FROM ${TABLE_NAMES.events}
@@ -174,7 +182,7 @@ export const groupRouter = createTRPCRouter({
mostEvents: protectedProcedure mostEvents: protectedProcedure
.input(z.object({ id: z.string(), projectId: z.string() })) .input(z.object({ id: z.string(), projectId: z.string() }))
.query(async ({ input: { id, projectId } }) => { .query(({ input: { id, projectId } }) => {
return chQuery<{ count: number; name: string }>(` return chQuery<{ count: number; name: string }>(`
SELECT count() as count, name SELECT count() as count, name
FROM ${TABLE_NAMES.events} FROM ${TABLE_NAMES.events}
@@ -189,7 +197,7 @@ export const groupRouter = createTRPCRouter({
popularRoutes: protectedProcedure popularRoutes: protectedProcedure
.input(z.object({ id: z.string(), projectId: z.string() })) .input(z.object({ id: z.string(), projectId: z.string() }))
.query(async ({ input: { id, projectId } }) => { .query(({ input: { id, projectId } }) => {
return chQuery<{ count: number; path: string }>(` return chQuery<{ count: number; path: string }>(`
SELECT count() as count, path SELECT count() as count, path
FROM ${TABLE_NAMES.events} FROM ${TABLE_NAMES.events}
@@ -204,7 +212,7 @@ export const groupRouter = createTRPCRouter({
memberGrowth: protectedProcedure memberGrowth: protectedProcedure
.input(z.object({ id: z.string(), projectId: z.string() })) .input(z.object({ id: z.string(), projectId: z.string() }))
.query(async ({ input: { id, projectId } }) => { .query(({ input: { id, projectId } }) => {
return chQuery<{ date: string; count: number }>(` return chQuery<{ date: string; count: number }>(`
SELECT SELECT
toDate(toStartOfDay(min_date)) AS date, toDate(toStartOfDay(min_date)) AS date,
@@ -228,13 +236,13 @@ export const groupRouter = createTRPCRouter({
properties: protectedProcedure properties: protectedProcedure
.input(z.object({ projectId: z.string() })) .input(z.object({ projectId: z.string() }))
.query(async ({ input: { projectId } }) => { .query(({ input: { projectId } }) => {
return getGroupPropertyKeys(projectId); return getGroupPropertyKeys(projectId);
}), }),
listByIds: protectedProcedure listByIds: protectedProcedure
.input(z.object({ projectId: z.string(), ids: z.array(z.string()) })) .input(z.object({ projectId: z.string(), ids: z.array(z.string()) }))
.query(async ({ input: { projectId, ids } }) => { .query(({ input: { projectId, ids } }) => {
return getGroupsByIds(projectId, ids); return getGroupsByIds(projectId, ids);
}), }),
}); });

View File

@@ -7,6 +7,10 @@ export const zGroupPayload = z.object({
type: z.string().min(1), type: z.string().min(1),
name: z.string().min(1), name: z.string().min(1),
properties: z.record(z.unknown()).optional(), 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(), profileId: z.union([z.string().min(1), z.number()]).optional(),
}); });
@@ -110,6 +114,10 @@ export const zTrackHandlerPayload = z.discriminatedUnion('type', [
type: z.literal('group'), type: z.literal('group'),
payload: zGroupPayload, payload: zGroupPayload,
}), }),
z.object({
type: z.literal('assign_group'),
payload: zAssignGroupPayload,
}),
]); ]);
export type ITrackPayload = z.infer<typeof zTrackPayload>; export type ITrackPayload = z.infer<typeof zTrackPayload>;
@@ -119,6 +127,7 @@ export type IDecrementPayload = z.infer<typeof zDecrementPayload>;
export type IAliasPayload = z.infer<typeof zAliasPayload>; export type IAliasPayload = z.infer<typeof zAliasPayload>;
export type IReplayPayload = z.infer<typeof zReplayPayload>; export type IReplayPayload = z.infer<typeof zReplayPayload>;
export type IGroupPayload = z.infer<typeof zGroupPayload>; export type IGroupPayload = z.infer<typeof zGroupPayload>;
export type IAssignGroupPayload = z.infer<typeof zAssignGroupPayload>;
export type ITrackHandlerPayload = z.infer<typeof zTrackHandlerPayload>; export type ITrackHandlerPayload = z.infer<typeof zTrackHandlerPayload>;
// Deprecated types for beta version of the SDKs // Deprecated types for beta version of the SDKs