This commit is contained in:
Carl-Gerhard Lindesvärd
2026-03-06 10:13:57 +01:00
parent 289ffb7d6d
commit 0cfccd549b
9 changed files with 372 additions and 84 deletions

View File

@@ -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 (
<div className="flex min-w-[160px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
<div className="text-muted-foreground text-sm">
{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="col gap-1">
<div className="text-muted-foreground text-sm">Total members</div>
<div className="font-semibold text-lg" style={{ color: getChartColor(0) }}>
{number.format(payload.cumulative)}
</div>
</div>
</div>
{payload.count > 0 && (
<div className="text-muted-foreground text-xs">
+{number.format(payload.count)} new
</div>
)}
</div>
);
}
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 (
<Widget className="w-full">
<WidgetHead>
<WidgetTitle icon={TrendingUpIcon}>Member growth</WidgetTitle>
</WidgetHead>
<WidgetBody>
{data.length === 0 ? (
<p className="py-4 text-center text-muted-foreground text-sm">
No data yet
</p>
) : (
<div className="h-[200px] w-full">
<ResponsiveContainer>
<AreaChart data={chartData}>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
<stop offset="95%" stopColor={color} stopOpacity={0.02} />
</linearGradient>
</defs>
<RechartTooltip
content={<Tooltip />}
cursor={{ stroke: color, strokeOpacity: 0.3 }}
/>
<Area
type="monotone"
dataKey="cumulative"
stroke={color}
strokeWidth={2}
fill={`url(#${gradientId})`}
dot={false}
isAnimationActive={false}
/>
<XAxis {...xAxisProps} />
<YAxis {...yAxisProps} domain={[0, 'dataMax']} />
<CartesianGrid
horizontal={true}
strokeDasharray="3 3"
strokeOpacity={0.5}
vertical={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
)}
</WidgetBody>
</Widget>
);
}

View File

@@ -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<IServiceGroup>[] {
type IServiceGroupWithStats = IServiceGroup & {
memberCount: number;
lastActiveAt: Date | null;
};
export function useGroupColumns(): ColumnDef<IServiceGroupWithStats>[] {
const { organizationId, projectId } = useAppParams();
return [
@@ -41,6 +46,24 @@ export function useGroupColumns(): ColumnDef<IServiceGroup>[] {
<Badge variant="outline">{row.original.type}</Badge>
),
},
{
accessorKey: 'memberCount',
header: 'Members',
cell: ({ row }) => (
<span className="tabular-nums">{row.original.memberCount}</span>
),
},
{
accessorKey: 'lastActiveAt',
header: 'Last active',
size: ColumnCreatedAt.size,
cell: ({ row }) =>
row.original.lastActiveAt ? (
<ColumnCreatedAt>{row.original.lastActiveAt}</ColumnCreatedAt>
) : (
<span className="text-muted-foreground"></span>
),
},
{
accessorKey: 'createdAt',
header: 'Created',

View File

@@ -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 (
<Widget className="w-full">
<WidgetHead>
<div className="title">Groups</div>
</WidgetHead>
{query.data?.length ? (
<div className="flex flex-wrap gap-2 p-4">
{query.data.map((group) => (
<ProjectLink
key={group.id}
href={`/groups/${encodeURIComponent(group.id)}`}
className="flex items-center gap-2 rounded-md border bg-muted/50 px-3 py-2 hover:bg-muted transition-colors"
>
<div>
<div className="text-sm font-medium">{group.name}</div>
<div className="text-xs text-muted-foreground">
{group.type} · {group.id}
</div>
</div>
</ProjectLink>
))}
</div>
) : query.isLoading ? null : (
<FullPageEmptyState title="No groups found" className="p-4" />
)}
</Widget>
<div className="flex flex-wrap items-center gap-2">
<span className="flex shrink-0 items-center gap-1.5 text-muted-foreground text-xs">
<UsersIcon className="size-3.5" />
Groups
</span>
{query.data.map((group) => (
<ProjectLink
key={group.id}
href={`/groups/${encodeURIComponent(group.id)}`}
className="inline-flex items-center gap-1.5 rounded-full border bg-muted/50 px-2.5 py-1 text-xs transition-colors hover:bg-muted"
>
<span className="font-medium">{group.name}</span>
<span className="text-muted-foreground">{group.type}</span>
</ProjectLink>
))}
</div>
);
};

View File

@@ -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() {
<ProfileActivity data={activity.data} />
</div>
{/* Members preview */}
{/* Member growth */}
<div className="col-span-1">
<GroupMemberGrowth data={memberGrowth.data} />
</div>
{/* Top events */}
<div className="col-span-1">
<MostEvents data={mostEvents.data} />
</div>
{/* Popular routes */}
<div className="col-span-1">
<PopularRoutes data={popularRoutes.data} />
</div>
{/* Members preview */}
<div className="col-span-1 md:col-span-2">
<Widget className="w-full">
<WidgetHead>
<WidgetTitle icon={UsersIcon}>Members</WidgetTitle>

View File

@@ -104,20 +104,16 @@ function Component() {
<ProfileMetrics data={metrics.data} />
</div>
{/* Profile properties - full width */}
<div className="col-span-1 md:col-span-2">
<div className="col-span-1 flex flex-col gap-3 md:col-span-2">
<ProfileProperties profile={profile.data!} />
</div>
{/* Groups - full width, only if profile belongs to groups */}
{profile.data?.groups?.length ? (
<div className="col-span-1 md:col-span-2">
{profile.data?.groups?.length ? (
<ProfileGroups
profileId={profileId}
projectId={projectId}
groups={profile.data.groups}
/>
</div>
) : null}
) : null}
</div>
{/* Heatmap / Activity */}
<div className="col-span-1">

View File

@@ -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() {
</Widget>
)}
{/* Group cards */}
{sessionGroups && sessionGroups.length > 0 && (
<Widget className="w-full">
<WidgetHead>
<WidgetTitle>Groups</WidgetTitle>
</WidgetHead>
<WidgetBody className="p-0">
{sessionGroups.map((group) => (
<Link
key={group.id}
className="row items-center gap-3 p-4 transition-colors hover:bg-accent"
params={{ organizationId, projectId, groupId: group.id }}
to="/$organizationId/$projectId/groups/$groupId"
>
<div className="col min-w-0 flex-1 gap-0.5">
<span className="truncate font-medium">{group.name}</span>
<span className="truncate text-muted-foreground text-sm font-mono">
{group.id}
</span>
</div>
<span className="shrink-0 rounded border px-1.5 py-0.5 text-muted-foreground text-xs">
{group.type}
</span>
</Link>
))}
</WidgetBody>
</Widget>
)}
{/* Visited pages */}
<VisitedRoutes
paths={events

View File

@@ -1,5 +1,5 @@
import { getPreviousMetric, groupByLabels } from '@openpanel/common';
import type { ISerieDataItem } from '@openpanel/common';
import { groupByLabels } from '@openpanel/common';
import { alphabetIds } from '@openpanel/constants';
import type {
FinalChart,
@@ -33,7 +33,7 @@ export async function executeChart(input: IReportInput): Promise<FinalChart> {
// 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<FinalChart> {
executionPlan.definitions,
includeAlphaIds,
previousSeries,
normalized.limit
);
return response;
@@ -83,7 +84,7 @@ export async function executeChart(input: IReportInput): Promise<FinalChart> {
* Executes a simplified pipeline: normalize -> fetch aggregate -> format
*/
export async function executeAggregateChart(
input: IReportInput,
input: IReportInput
): Promise<FinalChart> {
// 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;

View File

@@ -181,16 +181,8 @@ export async function getGroupTypes(projectId: string): Promise<string[]> {
}
export async function createGroup(input: IServiceUpsertGroup) {
const { id, projectId, type, name, properties = {} } = input;
await writeGroupToCh({
id,
projectId,
type,
name,
properties: properties as Record<string, string>,
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<Map<string, IServiceGroupStats>> {
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

View File

@@ -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 } }) => {