This commit is contained in:
Carl-Gerhard Lindesvärd
2026-03-06 09:00:10 +01:00
parent 765e4aa107
commit 90881e5ffb
68 changed files with 4092 additions and 1694 deletions

View File

@@ -1,11 +1,12 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { createFileRoute, Link } from '@tanstack/react-router';
import { Building2Icon } from 'lucide-react';
import { createFileRoute } from '@tanstack/react-router';
import { PlusIcon } from 'lucide-react';
import { parseAsString, useQueryState } from 'nuqs';
import { GroupsTable } from '@/components/groups/table';
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { useDataTablePagination } from '@/components/ui/data-table/data-table-hooks';
import {
Select,
SelectContent,
@@ -15,9 +16,11 @@ import {
} from '@/components/ui/select';
import { useSearchQueryState } from '@/hooks/use-search-query-state';
import { useTRPC } from '@/integrations/trpc/react';
import { formatDateTime } from '@/utils/date';
import { pushModal } from '@/modals';
import { createProjectTitle } from '@/utils/title';
const PAGE_SIZE = 50;
export const Route = createFileRoute('/_app/$organizationId/$projectId/groups')(
{
component: Component,
@@ -28,13 +31,14 @@ export const Route = createFileRoute('/_app/$organizationId/$projectId/groups')(
);
function Component() {
const { projectId, organizationId } = Route.useParams();
const { projectId } = Route.useParams();
const trpc = useTRPC();
const { search, setSearch, debouncedSearch } = useSearchQueryState();
const { debouncedSearch } = useSearchQueryState();
const [typeFilter, setTypeFilter] = useQueryState(
'type',
parseAsString.withDefault('')
);
const { page } = useDataTablePagination(PAGE_SIZE);
const typesQuery = useQuery(trpc.group.types.queryOptions({ projectId }));
@@ -44,103 +48,53 @@ function Component() {
projectId,
search: debouncedSearch || undefined,
type: typeFilter || undefined,
take: 100,
take: PAGE_SIZE,
cursor: (page - 1) * PAGE_SIZE,
},
{ placeholderData: keepPreviousData }
)
);
const groups = groupsQuery.data?.data ?? [];
const types = typesQuery.data ?? [];
return (
<PageContainer>
<PageHeader
actions={
<Button onClick={() => pushModal('AddGroup')}>
<PlusIcon className="mr-2 size-4" />
Add group
</Button>
}
className="mb-8"
description="Groups represent companies, teams, or other entities that events belong to."
title="Groups"
/>
<div className="mb-6 flex flex-wrap gap-3">
<Input
className="w-64"
onChange={(e) => setSearch(e.target.value)}
placeholder="Search groups..."
value={search}
/>
{types.length > 0 && (
<Select
onValueChange={(v) => setTypeFilter(v === 'all' ? '' : v)}
value={typeFilter || 'all'}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="All types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
{types.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{groups.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 py-24 text-muted-foreground">
<Building2Icon className="size-10 opacity-30" />
<p className="text-sm">No groups found</p>
</div>
) : (
<div className="card overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-def-100">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
Name
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
ID
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
Type
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
Created
</th>
</tr>
</thead>
<tbody>
{groups.map((group) => (
<tr
className="border-b transition-colors last:border-0 hover:bg-def-100"
key={`${group.projectId}-${group.id}`}
>
<td className="px-4 py-3">
<Link
className="font-medium hover:underline"
params={{ organizationId, projectId, groupId: group.id }}
to="/$organizationId/$projectId/groups/$groupId"
>
{group.name}
</Link>
</td>
<td className="px-4 py-3 font-mono text-muted-foreground text-xs">
{group.id}
</td>
<td className="px-4 py-3">
<Badge variant="outline">{group.type}</Badge>
</td>
<td className="px-4 py-3 text-muted-foreground">
{formatDateTime(new Date(group.createdAt))}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<GroupsTable
pageSize={PAGE_SIZE}
query={groupsQuery}
toolbarLeft={
types.length > 0 ? (
<Select
onValueChange={(v) => setTypeFilter(v === 'all' ? '' : v)}
value={typeFilter || 'all'}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="All types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
{types.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectContent>
</Select>
) : null
}
/>
</PageContainer>
);
}

View File

@@ -0,0 +1,46 @@
import { EventsTable } from '@/components/events/table';
import { useReadColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
import { useEventQueryNamesFilter } from '@/hooks/use-event-query-filters';
import { useTRPC } from '@/integrations/trpc/react';
import { createProjectTitle } from '@/utils/title';
import { useInfiniteQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/events'
)({
component: Component,
head: () => ({
meta: [{ title: createProjectTitle('Group events') }],
}),
});
function Component() {
const { projectId, groupId } = Route.useParams();
const trpc = useTRPC();
const [startDate] = useQueryState('startDate', parseAsIsoDateTime);
const [endDate] = useQueryState('endDate', parseAsIsoDateTime);
const [eventNames] = useEventQueryNamesFilter();
const columnVisibility = useReadColumnVisibility('events');
const query = useInfiniteQuery(
trpc.event.events.infiniteQueryOptions(
{
projectId,
groupId,
filters: [], // Always scope to group only; date + event names from toolbar still apply
startDate: startDate || undefined,
endDate: endDate || undefined,
events: eventNames,
columnVisibility: columnVisibility ?? {},
},
{
enabled: columnVisibility !== null,
getNextPageParam: (lastPage) => lastPage.meta.next,
}
)
);
return <EventsTable query={query} />;
}

View File

@@ -0,0 +1,208 @@
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 { OverviewMetricCard } from '@/components/overview/overview-metric-card';
import { WidgetHead, WidgetTitle } from '@/components/overview/overview-widget';
import { ProfileActivity } from '@/components/profiles/profile-activity';
import { KeyValueGrid } from '@/components/ui/key-value-grid';
import { Widget, WidgetBody } from '@/components/widget';
import { WidgetTable } from '@/components/widget-table';
import { useTRPC } from '@/integrations/trpc/react';
import { formatDateTime, formatTimeAgoOrDateTime } from '@/utils/date';
import { createProjectTitle } from '@/utils/title';
const MEMBERS_PREVIEW_LIMIT = 13;
export const Route = createFileRoute(
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/'
)({
component: Component,
loader: async ({ context, params }) => {
await Promise.all([
context.queryClient.prefetchQuery(
context.trpc.group.activity.queryOptions({
id: params.groupId,
projectId: params.projectId,
})
),
]);
},
pendingComponent: FullPageLoadingState,
head: () => ({
meta: [{ title: createProjectTitle('Group') }],
}),
});
function Component() {
const { projectId, organizationId, groupId } = Route.useParams();
const trpc = useTRPC();
const group = useSuspenseQuery(
trpc.group.byId.queryOptions({ id: groupId, projectId })
);
const metrics = useSuspenseQuery(
trpc.group.metrics.queryOptions({ id: groupId, projectId })
);
const activity = useSuspenseQuery(
trpc.group.activity.queryOptions({ id: groupId, projectId })
);
const members = useSuspenseQuery(
trpc.group.members.queryOptions({ id: groupId, projectId })
);
const g = group.data;
const m = metrics.data?.[0];
if (!g) {
return null;
}
const properties = g.properties as Record<string, unknown>;
return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{/* Metrics */}
{m && (
<div className="col-span-1 md:col-span-2">
<div className="card grid grid-cols-2 overflow-hidden rounded-md md:grid-cols-4">
<OverviewMetricCard
data={[]}
id="totalEvents"
isLoading={false}
label="Total Events"
metric={{ current: m.totalEvents, previous: null }}
unit=""
/>
<OverviewMetricCard
data={[]}
id="uniqueMembers"
isLoading={false}
label="Unique Members"
metric={{ current: m.uniqueProfiles, previous: null }}
unit=""
/>
<OverviewMetricCard
data={[]}
id="firstSeen"
isLoading={false}
label="First Seen"
metric={{
current: m.firstSeen ? new Date(m.firstSeen).getTime() : 0,
previous: null,
}}
unit="timeAgo"
/>
<OverviewMetricCard
data={[]}
id="lastSeen"
isLoading={false}
label="Last Seen"
metric={{
current: m.lastSeen ? new Date(m.lastSeen).getTime() : 0,
previous: null,
}}
unit="timeAgo"
/>
</div>
</div>
)}
{/* Properties */}
<div className="col-span-1 md:col-span-2">
<Widget className="w-full">
<WidgetHead>
<div className="title">Group Information</div>
</WidgetHead>
<KeyValueGrid
className="border-0"
columns={3}
copyable
data={[
{ name: 'id', value: g.id },
{ name: 'name', value: g.name },
{ name: 'type', value: g.type },
{
name: 'createdAt',
value: formatDateTime(new Date(g.createdAt)),
},
...Object.entries(properties)
.filter(([, v]) => v !== undefined && v !== '')
.map(([k, v]) => ({
name: k,
value: String(v),
})),
]}
/>
</Widget>
</div>
{/* Activity heatmap */}
<div className="col-span-1">
<ProfileActivity data={activity.data} />
</div>
{/* Members preview */}
<div className="col-span-1">
<Widget className="w-full">
<WidgetHead>
<WidgetTitle icon={UsersIcon}>Members</WidgetTitle>
</WidgetHead>
<WidgetBody className="p-0">
{members.data.length === 0 ? (
<p className="py-4 text-center text-muted-foreground text-sm">
No members found
</p>
) : (
<WidgetTable
columnClassName="px-2"
columns={[
{
key: 'profile',
name: 'Profile',
width: 'w-full',
render: (member) => (
<Link
className="font-mono text-xs hover:underline"
params={{
organizationId,
projectId,
profileId: member.profileId,
}}
to="/$organizationId/$projectId/profiles/$profileId"
>
{member.profileId}
</Link>
),
},
{
key: 'events',
name: 'Events',
width: '60px',
className: 'text-muted-foreground',
render: (member) => member.eventCount,
},
{
key: 'lastSeen',
name: 'Last Seen',
width: '150px',
className: 'text-muted-foreground',
render: (member) =>
formatTimeAgoOrDateTime(new Date(member.lastSeen)),
},
]}
data={members.data.slice(0, MEMBERS_PREVIEW_LIMIT)}
keyExtractor={(member) => member.profileId}
/>
)}
{members.data.length > MEMBERS_PREVIEW_LIMIT && (
<p className="border-t py-2 text-center text-muted-foreground text-xs">
{`${members.data.length} members found. View all in Members tab`}
</p>
)}
</WidgetBody>
</Widget>
</div>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { ProfilesTable } from '@/components/profiles/table';
import { useDataTablePagination } from '@/components/ui/data-table/data-table-hooks';
import { useSearchQueryState } from '@/hooks/use-search-query-state';
import { useTRPC } from '@/integrations/trpc/react';
import { createProjectTitle } from '@/utils/title';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/members'
)({
component: Component,
head: () => ({
meta: [{ title: createProjectTitle('Group members') }],
}),
});
function Component() {
const { projectId, groupId } = Route.useParams();
const trpc = useTRPC();
const { debouncedSearch } = useSearchQueryState();
const { page } = useDataTablePagination(50);
const query = useQuery({
...trpc.group.listProfiles.queryOptions({
projectId,
groupId,
cursor: page - 1,
take: 50,
search: debouncedSearch || undefined,
}),
placeholderData: keepPreviousData,
});
return (
<ProfilesTable
pageSize={50}
query={query as Parameters<typeof ProfilesTable>[0]['query']}
type="profiles"
/>
);
}

View File

@@ -0,0 +1,161 @@
import {
useMutation,
useQueryClient,
useSuspenseQuery,
} from '@tanstack/react-query';
import {
createFileRoute,
Outlet,
useNavigate,
useRouter,
} from '@tanstack/react-router';
import { Building2Icon, PencilIcon, Trash2Icon } from 'lucide-react';
import FullPageLoadingState from '@/components/full-page-loading-state';
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { usePageTabs } from '@/hooks/use-page-tabs';
import { handleError, useTRPC } from '@/integrations/trpc/react';
import { pushModal, showConfirm } from '@/modals';
import { createProjectTitle } from '@/utils/title';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs'
)({
component: Component,
loader: async ({ context, params }) => {
await Promise.all([
context.queryClient.prefetchQuery(
context.trpc.group.byId.queryOptions({
id: params.groupId,
projectId: params.projectId,
})
),
context.queryClient.prefetchQuery(
context.trpc.group.metrics.queryOptions({
id: params.groupId,
projectId: params.projectId,
})
),
]);
},
pendingComponent: FullPageLoadingState,
head: () => ({
meta: [{ title: createProjectTitle('Group') }],
}),
});
function Component() {
const router = useRouter();
const { projectId, organizationId, groupId } = Route.useParams();
const trpc = useTRPC();
const queryClient = useQueryClient();
const navigate = useNavigate();
const group = useSuspenseQuery(
trpc.group.byId.queryOptions({ id: groupId, projectId })
);
const deleteMutation = useMutation(
trpc.group.delete.mutationOptions({
onSuccess() {
queryClient.invalidateQueries(trpc.group.list.pathFilter());
navigate({
to: '/$organizationId/$projectId/groups',
params: { organizationId, projectId },
});
},
onError: handleError,
})
);
const { activeTab, tabs } = usePageTabs([
{ id: '/$organizationId/$projectId/groups/$groupId', label: 'Overview' },
{ id: 'members', label: 'Members' },
{ id: 'events', label: 'Events' },
]);
const handleTabChange = (tabId: string) => {
router.navigate({
from: Route.fullPath,
to: tabId,
});
};
const g = group.data;
if (!g) {
return (
<PageContainer>
<div className="flex flex-col items-center justify-center gap-3 py-24 text-muted-foreground">
<Building2Icon className="size-10 opacity-30" />
<p className="text-sm">Group not found</p>
</div>
</PageContainer>
);
}
return (
<PageContainer className="col">
<PageHeader
actions={
<div className="row gap-2">
<Button
onClick={() =>
pushModal('EditGroup', {
id: g.id,
projectId: g.projectId,
name: g.name,
type: g.type,
properties: g.properties,
})
}
size="sm"
variant="outline"
>
<PencilIcon className="mr-2 size-4" />
Edit
</Button>
<Button
onClick={() =>
showConfirm({
title: 'Delete group',
text: `Are you sure you want to delete "${g.name}"? This action cannot be undone.`,
onConfirm: () =>
deleteMutation.mutate({ id: g.id, projectId }),
})
}
size="sm"
variant="outline"
>
<Trash2Icon className="mr-2 size-4" />
Delete
</Button>
</div>
}
title={
<div className="row min-w-0 items-center gap-3">
<Building2Icon className="size-6 shrink-0" />
<span className="truncate">{g.name}</span>
</div>
}
/>
<Tabs
className="mt-2 mb-8"
onValueChange={handleTabChange}
value={activeTab}
>
<TabsList>
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Outlet />
</PageContainer>
);
}

View File

@@ -1,244 +0,0 @@
import { useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute, Link } from '@tanstack/react-router';
import { Building2Icon, UsersIcon } from 'lucide-react';
import FullPageLoadingState from '@/components/full-page-loading-state';
import { OverviewMetricCard } from '@/components/overview/overview-metric-card';
import { WidgetHead, WidgetTitle } from '@/components/overview/overview-widget';
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { ProfileActivity } from '@/components/profiles/profile-activity';
import { Badge } from '@/components/ui/badge';
import { KeyValueGrid } from '@/components/ui/key-value-grid';
import { Widget, WidgetBody } from '@/components/widget';
import { useTRPC } from '@/integrations/trpc/react';
import { formatDateTime } from '@/utils/date';
import { createProjectTitle } from '@/utils/title';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId/groups_/$groupId'
)({
component: Component,
loader: async ({ context, params }) => {
await Promise.all([
context.queryClient.prefetchQuery(
context.trpc.group.byId.queryOptions({
id: params.groupId,
projectId: params.projectId,
})
),
context.queryClient.prefetchQuery(
context.trpc.group.metrics.queryOptions({
id: params.groupId,
projectId: params.projectId,
})
),
context.queryClient.prefetchQuery(
context.trpc.group.activity.queryOptions({
id: params.groupId,
projectId: params.projectId,
})
),
context.queryClient.prefetchQuery(
context.trpc.group.members.queryOptions({
id: params.groupId,
projectId: params.projectId,
})
),
]);
},
pendingComponent: FullPageLoadingState,
head: () => ({
meta: [{ title: createProjectTitle('Group') }],
}),
});
function Component() {
const { projectId, organizationId, groupId } = Route.useParams();
const trpc = useTRPC();
const group = useSuspenseQuery(
trpc.group.byId.queryOptions({ id: groupId, projectId })
);
const metrics = useSuspenseQuery(
trpc.group.metrics.queryOptions({ id: groupId, projectId })
);
const activity = useSuspenseQuery(
trpc.group.activity.queryOptions({ id: groupId, projectId })
);
const members = useSuspenseQuery(
trpc.group.members.queryOptions({ id: groupId, projectId })
);
const g = group.data;
const m = metrics.data?.[0];
if (!g) {
return (
<PageContainer>
<div className="flex flex-col items-center justify-center gap-3 py-24 text-muted-foreground">
<Building2Icon className="size-10 opacity-30" />
<p className="text-sm">Group not found</p>
</div>
</PageContainer>
);
}
const properties = g.properties as Record<string, unknown>;
return (
<PageContainer>
<PageHeader
title={
<div className="row min-w-0 items-center gap-3">
<Building2Icon className="size-6 shrink-0" />
<span className="truncate">{g.name}</span>
<Badge className="shrink-0" variant="outline">
{g.type}
</Badge>
</div>
}
>
<p className="font-mono text-muted-foreground text-sm">{g.id}</p>
</PageHeader>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{/* Metrics */}
{m && (
<div className="col-span-1 md:col-span-2">
<div className="card grid grid-cols-2 overflow-hidden rounded-md md:grid-cols-4">
<OverviewMetricCard
data={[]}
id="totalEvents"
isLoading={false}
label="Total Events"
metric={{ current: m.totalEvents, previous: null }}
unit=""
/>
<OverviewMetricCard
data={[]}
id="uniqueMembers"
isLoading={false}
label="Unique Members"
metric={{ current: m.uniqueProfiles, previous: null }}
unit=""
/>
<OverviewMetricCard
data={[]}
id="firstSeen"
isLoading={false}
label="First Seen"
metric={{
current: m.firstSeen ? new Date(m.firstSeen).getTime() : 0,
previous: null,
}}
unit="timeAgo"
/>
<OverviewMetricCard
data={[]}
id="lastSeen"
isLoading={false}
label="Last Seen"
metric={{
current: m.lastSeen ? new Date(m.lastSeen).getTime() : 0,
previous: null,
}}
unit="timeAgo"
/>
</div>
</div>
)}
{/* Properties */}
<div className="col-span-1 md:col-span-2">
<Widget className="w-full">
<WidgetHead>
<div className="title">Group Information</div>
</WidgetHead>
<KeyValueGrid
className="border-0"
columns={3}
copyable
data={[
{ name: 'id', value: g.id },
{ name: 'name', value: g.name },
{ name: 'type', value: g.type },
{
name: 'createdAt',
value: formatDateTime(new Date(g.createdAt)),
},
...Object.entries(properties)
.filter(([, v]) => v !== undefined && v !== '')
.map(([k, v]) => ({
name: k,
value: String(v),
})),
]}
/>
</Widget>
</div>
{/* Activity heatmap */}
<div className="col-span-1">
<ProfileActivity data={activity.data} />
</div>
{/* Members */}
<div className="col-span-1">
<Widget className="w-full">
<WidgetHead>
<WidgetTitle icon={UsersIcon}>Members</WidgetTitle>
</WidgetHead>
<WidgetBody>
{members.data.length === 0 ? (
<p className="py-4 text-center text-muted-foreground text-sm">
No members found
</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="py-2 text-left font-medium text-muted-foreground">
Profile
</th>
<th className="py-2 text-right font-medium text-muted-foreground">
Events
</th>
</tr>
</thead>
<tbody>
{members.data.map((member) => (
<tr
className="border-b last:border-0"
key={member.profileId}
>
<td className="py-2">
<Link
className="font-mono text-xs hover:underline"
params={{
organizationId,
projectId,
profileId: member.profileId,
}}
to="/$organizationId/$projectId/profiles/$profileId"
>
{member.profileId}
</Link>
</td>
<td className="py-2 text-right text-muted-foreground">
{member.eventCount}
</td>
</tr>
))}
</tbody>
</table>
)}
</WidgetBody>
</Widget>
</div>
</div>
</PageContainer>
);
}

View File

@@ -4,6 +4,7 @@ import { MostEvents } from '@/components/profiles/most-events';
import { PopularRoutes } from '@/components/profiles/popular-routes';
import { ProfileActivity } from '@/components/profiles/profile-activity';
import { ProfileCharts } from '@/components/profiles/profile-charts';
import { ProfileGroups } from '@/components/profiles/profile-groups';
import { ProfileMetrics } from '@/components/profiles/profile-metrics';
import { ProfileProperties } from '@/components/profiles/profile-properties';
import { useTRPC } from '@/integrations/trpc/react';
@@ -107,6 +108,17 @@ function Component() {
<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">
<ProfileGroups
profileId={profileId}
projectId={projectId}
groups={profile.data.groups}
/>
</div>
) : null}
{/* Heatmap / Activity */}
<div className="col-span-1">
<ProfileActivity data={activity.data} />