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