diff --git a/apps/start/src/components/groups/group-member-growth.tsx b/apps/start/src/components/groups/group-member-growth.tsx new file mode 100644 index 00000000..5c1fd75c --- /dev/null +++ b/apps/start/src/components/groups/group-member-growth.tsx @@ -0,0 +1,123 @@ +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, + AreaChart, + CartesianGrid, + Tooltip as RechartTooltip, + ResponsiveContainer, + XAxis, + YAxis, +} from 'recharts'; +import { getChartColor } from '@/utils/theme'; + +type Props = { + data: { date: string; count: number }[]; +}; + +function Tooltip(props: any) { + const number = useNumber(); + const formatDate = useFormatDateInterval({ interval: 'day', short: false }); + const payload = props.payload?.[0]?.payload; + + if (!payload) { + return null; + } + + return ( +
+
+ {formatDate(new Date(payload.timestamp))} +
+
+
+
+
Total members
+
+ {number.format(payload.cumulative)} +
+
+
+ {payload.count > 0 && ( +
+ +{number.format(payload.count)} new +
+ )} +
+ ); +} + +export function GroupMemberGrowth({ data }: Props) { + const xAxisProps = useXAxisProps({ interval: 'day' }); + const yAxisProps = useYAxisProps({}); + const color = getChartColor(0); + + let cumulative = 0; + const chartData = data.map((item) => { + cumulative += item.count; + return { + date: item.date, + timestamp: new Date(item.date).getTime(), + count: item.count, + cumulative, + }; + }); + + const gradientId = 'memberGrowthGradient'; + + return ( + + + Member growth + + + {data.length === 0 ? ( +

+ No data yet +

+ ) : ( +
+ + + + + + + + + } + cursor={{ stroke: color, strokeOpacity: 0.3 }} + /> + + + + + + +
+ )} +
+
+ ); +} diff --git a/apps/start/src/components/groups/table/columns.tsx b/apps/start/src/components/groups/table/columns.tsx index 8322429d..bdf531ca 100644 --- a/apps/start/src/components/groups/table/columns.tsx +++ b/apps/start/src/components/groups/table/columns.tsx @@ -5,7 +5,12 @@ import { Link } from '@tanstack/react-router'; import type { ColumnDef } from '@tanstack/react-table'; import type { IServiceGroup } from '@openpanel/db'; -export function useGroupColumns(): ColumnDef[] { +type IServiceGroupWithStats = IServiceGroup & { + memberCount: number; + lastActiveAt: Date | null; +}; + +export function useGroupColumns(): ColumnDef[] { const { organizationId, projectId } = useAppParams(); return [ @@ -41,6 +46,24 @@ export function useGroupColumns(): ColumnDef[] { {row.original.type} ), }, + { + accessorKey: 'memberCount', + header: 'Members', + cell: ({ row }) => ( + {row.original.memberCount} + ), + }, + { + accessorKey: 'lastActiveAt', + header: 'Last active', + size: ColumnCreatedAt.size, + cell: ({ row }) => + row.original.lastActiveAt ? ( + {row.original.lastActiveAt} + ) : ( + + ), + }, { accessorKey: 'createdAt', header: 'Created', diff --git a/apps/start/src/components/profiles/profile-groups.tsx b/apps/start/src/components/profiles/profile-groups.tsx index 6fe4404a..b15f9ccf 100644 --- a/apps/start/src/components/profiles/profile-groups.tsx +++ b/apps/start/src/components/profiles/profile-groups.tsx @@ -1,15 +1,13 @@ import { ProjectLink } from '@/components/links'; -import { Widget } from '@/components/widget'; import { useTRPC } from '@/integrations/trpc/react'; -import { WidgetHead } from '../overview/overview-widget'; import { useQuery } from '@tanstack/react-query'; -import { FullPageEmptyState } from '../full-page-empty-state'; +import { UsersIcon } from 'lucide-react'; -type Props = { +interface Props { profileId: string; projectId: string; groups: string[]; -}; +} export const ProfileGroups = ({ projectId, groups }: Props) => { const trpc = useTRPC(); @@ -20,33 +18,26 @@ export const ProfileGroups = ({ projectId, groups }: Props) => { }), ); - if (!groups.length) return null; + if (groups.length === 0 || !query.data?.length) { + return null; + } return ( - - -
Groups
-
- {query.data?.length ? ( -
- {query.data.map((group) => ( - -
-
{group.name}
-
- {group.type} · {group.id} -
-
-
- ))} -
- ) : query.isLoading ? null : ( - - )} -
+
+ + + Groups + + {query.data.map((group) => ( + + {group.name} + {group.type} + + ))} +
); }; 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 e4ddbd4c..88c5d054 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 @@ -2,8 +2,11 @@ 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 { GroupMemberGrowth } from '@/components/groups/group-member-growth'; import { OverviewMetricCard } from '@/components/overview/overview-metric-card'; import { WidgetHead, WidgetTitle } from '@/components/overview/overview-widget'; +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'; @@ -50,6 +53,15 @@ function Component() { const members = useSuspenseQuery( trpc.group.members.queryOptions({ id: groupId, projectId }) ); + const mostEvents = useSuspenseQuery( + trpc.group.mostEvents.queryOptions({ id: groupId, projectId }) + ); + const popularRoutes = useSuspenseQuery( + trpc.group.popularRoutes.queryOptions({ id: groupId, projectId }) + ); + const memberGrowth = useSuspenseQuery( + trpc.group.memberGrowth.queryOptions({ id: groupId, projectId }) + ); const g = group.data; const m = metrics.data?.[0]; @@ -142,8 +154,23 @@ function Component() {
- {/* Members preview */} + {/* Member growth */}
+ +
+ + {/* Top events */} +
+ +
+ + {/* Popular routes */} +
+ +
+ + {/* Members preview */} +
Members 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 44fca842..f3bd8b95 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 @@ -104,20 +104,16 @@ function Component() {
{/* Profile properties - full width */} -
+
-
- - {/* Groups - full width, only if profile belongs to groups */} - {profile.data?.groups?.length ? ( -
+ {profile.data?.groups?.length ? ( -
- ) : null} + ) : null} +
{/* Heatmap / Activity */}
diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.sessions_.$sessionId.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.sessions_.$sessionId.tsx index 32b420db..ae1b6dad 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.sessions_.$sessionId.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.sessions_.$sessionId.tsx @@ -1,5 +1,5 @@ import type { IServiceEvent, IServiceSession } from '@openpanel/db'; -import { useSuspenseQuery } from '@tanstack/react-query'; +import { useSuspenseQuery, useQuery } from '@tanstack/react-query'; import { createFileRoute, Link } from '@tanstack/react-router'; import { EventIcon } from '@/components/events/event-icon'; import FullPageLoadingState from '@/components/full-page-loading-state'; @@ -165,6 +165,14 @@ function Component() { }) ); + const { data: sessionGroups } = useQuery({ + ...trpc.group.listByIds.queryOptions({ + projectId, + ids: session.groups ?? [], + }), + enabled: (session.groups?.length ?? 0) > 0, + }); + const fakeEvent = sessionToFakeEvent(session); return ( @@ -324,6 +332,35 @@ function Component() { )} + {/* Group cards */} + {sessionGroups && sessionGroups.length > 0 && ( + + + Groups + + + {sessionGroups.map((group) => ( + +
+ {group.name} + + {group.id} + +
+ + {group.type} + + + ))} +
+
+ )} + {/* Visited pages */} { // Handle subscription end date limit const endDate = await getOrganizationSubscriptionChartEndDate( input.projectId, - normalized.endDate, + normalized.endDate ); if (endDate) { normalized.endDate = endDate; @@ -73,6 +73,7 @@ export async function executeChart(input: IReportInput): Promise { executionPlan.definitions, includeAlphaIds, previousSeries, + normalized.limit ); return response; @@ -83,7 +84,7 @@ export async function executeChart(input: IReportInput): Promise { * Executes a simplified pipeline: normalize -> fetch aggregate -> format */ export async function executeAggregateChart( - input: IReportInput, + input: IReportInput ): Promise { // Stage 1: Normalize input const normalized = await normalize(input); @@ -91,7 +92,7 @@ export async function executeAggregateChart( // Handle subscription end date limit const endDate = await getOrganizationSubscriptionChartEndDate( input.projectId, - normalized.endDate, + normalized.endDate ); if (endDate) { normalized.endDate = endDate; @@ -137,7 +138,7 @@ export async function executeAggregateChart( getAggregateChartSql(queryInput), { session_timezone: timezone, - }, + } ); // Fallback: if no results with breakdowns, try without breakdowns @@ -149,7 +150,7 @@ export async function executeAggregateChart( }), { session_timezone: timezone, - }, + } ); } @@ -262,7 +263,7 @@ export async function executeAggregateChart( getAggregateChartSql(queryInput), { session_timezone: timezone, - }, + } ); if (queryResult.length === 0 && normalized.breakdowns.length > 0) { @@ -273,7 +274,7 @@ export async function executeAggregateChart( }), { session_timezone: timezone, - }, + } ); } @@ -344,7 +345,7 @@ export async function executeAggregateChart( normalized.series, includeAlphaIds, previousSeries, - normalized.limit, + normalized.limit ); return response; diff --git a/packages/db/src/services/group.service.ts b/packages/db/src/services/group.service.ts index e966cabc..145e68e1 100644 --- a/packages/db/src/services/group.service.ts +++ b/packages/db/src/services/group.service.ts @@ -181,16 +181,8 @@ export async function getGroupTypes(projectId: string): Promise { } 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 getGroupById(id, projectId); + await upsertGroup(input); + return getGroupById(input.id, input.projectId); } export async function updateGroup( @@ -248,6 +240,49 @@ export async function getGroupPropertyKeys( return rows.map((r) => r.key).sort(); } +export type IServiceGroupStats = { + groupId: string; + memberCount: number; + lastActiveAt: Date | null; +}; + +export async function getGroupStats( + projectId: string, + groupIds: string[] +): Promise> { + if (groupIds.length === 0) { + return new Map(); + } + + const rows = await chQuery<{ + group_id: string; + member_count: number; + last_active_at: string; + }>(` + SELECT + g AS group_id, + uniqExact(profile_id) AS member_count, + max(created_at) AS last_active_at + FROM ${TABLE_NAMES.events} + ARRAY JOIN groups AS g + WHERE project_id = ${sqlstring.escape(projectId)} + AND g IN (${groupIds.map((id) => sqlstring.escape(id)).join(',')}) + AND profile_id != device_id + GROUP BY g + `); + + return new Map( + rows.map((r) => [ + r.group_id, + { + groupId: r.group_id, + memberCount: r.member_count, + lastActiveAt: r.last_active_at ? new Date(r.last_active_at) : null, + }, + ]) + ); +} + export async function getGroupsByIds( projectId: string, ids: string[] @@ -284,26 +319,12 @@ export async function getGroupMemberProfiles({ ? `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 + // count() OVER () is evaluated after JOINs/WHERE but before LIMIT, + // so we get the total match count and the paginated IDs in one query. + const rows = await chQuery<{ profile_id: string; total_count: number }>(` + SELECT + gm.profile_id, + count() OVER () AS total_count FROM ( SELECT profile_id, max(created_at) AS last_seen FROM ${TABLE_NAMES.events} @@ -311,7 +332,6 @@ export async function getGroupMemberProfiles({ 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 @@ -322,10 +342,14 @@ export async function getGroupMemberProfiles({ LIMIT ${take} OFFSET ${offset} `); - const profileIds = idRows.map((r) => r.profile_id); + + const count = rows[0]?.total_count ?? 0; + const profileIds = rows.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 diff --git a/packages/trpc/src/routers/group.ts b/packages/trpc/src/routers/group.ts index 23377db9..79956e06 100644 --- a/packages/trpc/src/routers/group.ts +++ b/packages/trpc/src/routers/group.ts @@ -7,6 +7,7 @@ import { getGroupListCount, getGroupMemberProfiles, getGroupPropertyKeys, + getGroupStats, getGroupsByIds, getGroupTypes, TABLE_NAMES, @@ -32,7 +33,18 @@ export const groupRouter = createTRPCRouter({ getGroupList(input), getGroupListCount(input), ]); - return { data, meta: { count, take: input.take } }; + const stats = await getGroupStats( + input.projectId, + data.map((g) => g.id) + ); + return { + data: data.map((g) => ({ + ...g, + memberCount: stats.get(g.id)?.memberCount ?? 0, + lastActiveAt: stats.get(g.id)?.lastActiveAt ?? null, + })), + meta: { count, take: input.take }, + }; }), byId: protectedProcedure @@ -160,6 +172,60 @@ export const groupRouter = createTRPCRouter({ }; }), + mostEvents: protectedProcedure + .input(z.object({ id: z.string(), projectId: z.string() })) + .query(async ({ input: { id, projectId } }) => { + return chQuery<{ count: number; name: string }>(` + SELECT count() as count, name + FROM ${TABLE_NAMES.events} + WHERE project_id = ${sqlstring.escape(projectId)} + AND has(groups, ${sqlstring.escape(id)}) + AND name NOT IN ('screen_view', 'session_start', 'session_end') + GROUP BY name + ORDER BY count DESC + LIMIT 10 + `); + }), + + popularRoutes: protectedProcedure + .input(z.object({ id: z.string(), projectId: z.string() })) + .query(async ({ input: { id, projectId } }) => { + return chQuery<{ count: number; path: string }>(` + SELECT count() as count, path + FROM ${TABLE_NAMES.events} + WHERE project_id = ${sqlstring.escape(projectId)} + AND has(groups, ${sqlstring.escape(id)}) + AND name = 'screen_view' + GROUP BY path + ORDER BY count DESC + LIMIT 10 + `); + }), + + memberGrowth: protectedProcedure + .input(z.object({ id: z.string(), projectId: z.string() })) + .query(async ({ input: { id, projectId } }) => { + return chQuery<{ date: string; count: number }>(` + SELECT + toDate(toStartOfDay(min_date)) AS date, + count() AS count + FROM ( + SELECT profile_id, min(created_at) AS min_date + FROM ${TABLE_NAMES.events} + WHERE project_id = ${sqlstring.escape(projectId)} + AND has(groups, ${sqlstring.escape(id)}) + AND profile_id != device_id + AND created_at >= now() - INTERVAL 30 DAY + GROUP BY profile_id + ) + GROUP BY date + ORDER BY date ASC WITH FILL + FROM toDate(now() - INTERVAL 29 DAY) + TO toDate(now() + INTERVAL 1 DAY) + STEP 1 + `); + }), + properties: protectedProcedure .input(z.object({ projectId: z.string() })) .query(async ({ input: { projectId } }) => {