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