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';
import { getRedisCache } from '@openpanel/redis';
import {
type IAssignGroupPayload,
type IDecrementPayload,
type IGroupPayload,
type IIdentifyPayload,
@@ -341,31 +342,29 @@ async function handleGroup(
context: TrackContext
): Promise<void> {
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 promises: Promise<unknown>[] = [];
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,

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 {
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))}
</div>
<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="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)}
</div>
</div>
@@ -87,7 +93,7 @@ export function GroupMemberGrowth({ data }: Props) {
<ResponsiveContainer>
<AreaChart data={chartData}>
<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="95%" stopColor={color} stopOpacity={0.02} />
</linearGradient>
@@ -97,17 +103,18 @@ export function GroupMemberGrowth({ data }: Props) {
cursor={{ stroke: color, strokeOpacity: 0.3 }}
/>
<Area
type="monotone"
dataKey="cumulative"
dot={false}
fill={`url(#${gradientId})`}
isAnimationActive={false}
stroke={color}
strokeWidth={2}
fill={`url(#${gradientId})`}
dot={false}
isAnimationActive={false}
type="monotone"
/>
<XAxis {...xAxisProps} />
<YAxis {...yAxisProps} domain={[0, 'dataMax']} />
<YAxis {...yAxisProps} />
<CartesianGrid
className="stroke-border"
horizontal={true}
strokeDasharray="3 3"
strokeOpacity={0.5}

View File

@@ -242,6 +242,7 @@ export default function BillingUsage({ organization }: Props) {
<XAxis {...xAxisProps} dataKey="date" />
<YAxis {...yAxisProps} domain={[0, 'dataMax']} />
<CartesianGrid
className="stroke-border"
horizontal={true}
strokeDasharray="3 3"
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';
type Props = {
@@ -6,28 +7,32 @@ type 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 (
<Widget className="w-full">
<WidgetHead>
<WidgetTitle>Popular events</WidgetTitle>
</WidgetHead>
<div className="flex flex-col gap-1 p-1">
{data.slice(0, 5).map((item) => (
<div key={item.name} className="relative px-3 py-2">
<div
className="absolute bottom-0 left-0 top-0 rounded bg-def-200"
style={{
width: `${(item.count / max) * 100}%`,
}}
/>
<div className="relative flex justify-between ">
<div>{item.name}</div>
<div>{item.count}</div>
{data.length === 0 ? (
<WidgetEmptyState icon={ZapIcon} text="No events yet" />
) : (
<div className="flex flex-col gap-1 p-1">
{data.slice(0, 5).map((item) => (
<div key={item.name} className="relative px-3 py-2">
<div
className="absolute bottom-0 left-0 top-0 rounded bg-def-200"
style={{
width: `${(item.count / max) * 100}%`,
}}
/>
<div className="relative flex justify-between ">
<div>{item.name}</div>
<div>{item.count}</div>
</div>
</div>
</div>
))}
</div>
))}
</div>
)}
</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';
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 (
<Widget className="w-full">
<WidgetHead>
<WidgetTitle>Most visted pages</WidgetTitle>
</WidgetHead>
<div className="flex flex-col gap-1 p-1">
{data.slice(0, 5).map((item) => (
<div key={item.path} className="relative px-3 py-2">
<div
className="absolute bottom-0 left-0 top-0 rounded bg-def-200"
style={{
width: `${(item.count / max) * 100}%`,
}}
/>
<div className="relative flex justify-between ">
<div>{item.path}</div>
<div>{item.count}</div>
{data.length === 0 ? (
<WidgetEmptyState icon={RouteIcon} text="No pages visited yet" />
) : (
<div className="flex flex-col gap-1 p-1">
{data.slice(0, 5).map((item) => (
<div key={item.path} className="relative px-3 py-2">
<div
className="absolute bottom-0 left-0 top-0 rounded bg-def-200"
style={{
width: `${(item.count / max) * 100}%`,
}}
/>
<div className="relative flex justify-between ">
<div>{item.path}</div>
<div>{item.count}</div>
</div>
</div>
</div>
))}
</div>
))}
</div>
)}
</Widget>
);
};

View File

@@ -54,6 +54,19 @@ export function WidgetBody({ children, className }: WidgetBodyProps) {
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 {
children: React.ReactNode;
className?: string;

View File

@@ -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() {
</WidgetHead>
<WidgetBody className="p-0">
{members.data.length === 0 ? (
<p className="py-4 text-center text-muted-foreground text-sm">
No members found
</p>
<WidgetEmptyState icon={UsersIcon} text="No members yet" />
) : (
<WidgetTable
columnClassName="px-2"

View File

@@ -42,7 +42,7 @@ export default function App() {
for (const id of u.groupIds) {
const meta = PRESET_GROUPS.find((g) => g.id === id);
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 {
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 {
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<GroupPayload, 'id'>;
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,

View File

@@ -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);
}),
});

View File

@@ -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<typeof zTrackPayload>;
@@ -119,6 +127,7 @@ export type IDecrementPayload = z.infer<typeof zDecrementPayload>;
export type IAliasPayload = z.infer<typeof zAliasPayload>;
export type IReplayPayload = z.infer<typeof zReplayPayload>;
export type IGroupPayload = z.infer<typeof zGroupPayload>;
export type IAssignGroupPayload = z.infer<typeof zAssignGroupPayload>;
export type ITrackHandlerPayload = z.infer<typeof zTrackHandlerPayload>;
// Deprecated types for beta version of the SDKs