fix: lookup group members based on profiles table instead of events

This commit is contained in:
Carl-Gerhard Lindesvärd
2026-03-22 20:48:27 +01:00
parent 729722bf85
commit 21e51daa5f
4 changed files with 46 additions and 148 deletions

View File

@@ -81,7 +81,7 @@ export function GroupMemberGrowth({ data }: Props) {
return ( return (
<Widget className="w-full"> <Widget className="w-full">
<WidgetHead> <WidgetHead>
<WidgetTitle icon={TrendingUpIcon}>Member growth</WidgetTitle> <WidgetTitle icon={TrendingUpIcon}>New members last 30 days</WidgetTitle>
</WidgetHead> </WidgetHead>
<WidgetBody> <WidgetBody>
{data.length === 0 ? ( {data.length === 0 ? (

View File

@@ -1,22 +1,18 @@
import { useSuspenseQuery } from '@tanstack/react-query'; import { useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute, Link } from '@tanstack/react-router'; import { createFileRoute } from '@tanstack/react-router';
import { UsersIcon } from 'lucide-react';
import FullPageLoadingState from '@/components/full-page-loading-state'; import FullPageLoadingState from '@/components/full-page-loading-state';
import { GroupMemberGrowth } from '@/components/groups/group-member-growth'; import { GroupMemberGrowth } from '@/components/groups/group-member-growth';
import { OverviewMetricCard } from '@/components/overview/overview-metric-card'; import { OverviewMetricCard } from '@/components/overview/overview-metric-card';
import { WidgetHead, WidgetTitle } from '@/components/overview/overview-widget'; import { WidgetHead } from '@/components/overview/overview-widget';
import { MostEvents } from '@/components/profiles/most-events'; import { MostEvents } from '@/components/profiles/most-events';
import { PopularRoutes } from '@/components/profiles/popular-routes'; import { PopularRoutes } from '@/components/profiles/popular-routes';
import { ProfileActivity } from '@/components/profiles/profile-activity'; import { ProfileActivity } from '@/components/profiles/profile-activity';
import { KeyValueGrid } from '@/components/ui/key-value-grid'; import { KeyValueGrid } from '@/components/ui/key-value-grid';
import { Widget, WidgetBody, WidgetEmptyState } from '@/components/widget'; import { Widget } from '@/components/widget';
import { WidgetTable } from '@/components/widget-table';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { formatDateTime, formatTimeAgoOrDateTime } from '@/utils/date'; import { formatDateTime } from '@/utils/date';
import { createProjectTitle } from '@/utils/title'; import { createProjectTitle } from '@/utils/title';
const MEMBERS_PREVIEW_LIMIT = 13;
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/' '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/'
)({ )({
@@ -38,7 +34,7 @@ export const Route = createFileRoute(
}); });
function Component() { function Component() {
const { projectId, organizationId, groupId } = Route.useParams(); const { projectId, groupId } = Route.useParams();
const trpc = useTRPC(); const trpc = useTRPC();
const group = useSuspenseQuery( const group = useSuspenseQuery(
@@ -50,9 +46,6 @@ function Component() {
const activity = useSuspenseQuery( const activity = useSuspenseQuery(
trpc.group.activity.queryOptions({ id: groupId, projectId }) trpc.group.activity.queryOptions({ id: groupId, projectId })
); );
const members = useSuspenseQuery(
trpc.group.members.queryOptions({ id: groupId, projectId })
);
const mostEvents = useSuspenseQuery( const mostEvents = useSuspenseQuery(
trpc.group.mostEvents.queryOptions({ id: groupId, projectId }) trpc.group.mostEvents.queryOptions({ id: groupId, projectId })
); );
@@ -154,7 +147,7 @@ function Component() {
<ProfileActivity data={activity.data} /> <ProfileActivity data={activity.data} />
</div> </div>
{/* Member growth */} {/* New members last 30 days */}
<div className="col-span-1"> <div className="col-span-1">
<GroupMemberGrowth data={memberGrowth.data} /> <GroupMemberGrowth data={memberGrowth.data} />
</div> </div>
@@ -169,65 +162,6 @@ function Component() {
<PopularRoutes data={popularRoutes.data} /> <PopularRoutes data={popularRoutes.data} />
</div> </div>
{/* Members preview */}
<div className="col-span-1 md:col-span-2">
<Widget className="w-full">
<WidgetHead>
<WidgetTitle icon={UsersIcon}>Members</WidgetTitle>
</WidgetHead>
<WidgetBody className="p-0">
{members.data.length === 0 ? (
<WidgetEmptyState icon={UsersIcon} text="No members yet" />
) : (
<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> </div>
); );
} }

View File

@@ -323,32 +323,21 @@ 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()}%`)})` ? `AND (email ILIKE ${sqlstring.escape(`%${search.trim()}%`)} OR first_name ILIKE ${sqlstring.escape(`%${search.trim()}%`)} OR last_name ILIKE ${sqlstring.escape(`%${search.trim()}%`)})`
: ''; : '';
// count() OVER () is evaluated after JOINs/WHERE but before LIMIT, const rows = await chQuery<{ id: string; total_count: number }>(`
// 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 SELECT
gm.profile_id, id,
count() OVER () AS total_count count() OVER () AS total_count
FROM ( FROM ${TABLE_NAMES.profiles} FINAL
SELECT profile_id, max(created_at) AS last_seen WHERE project_id = ${sqlstring.escape(projectId)}
FROM ${TABLE_NAMES.events} AND has(groups, ${sqlstring.escape(groupId)})
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} ${searchCondition}
) p ON p.id = gm.profile_id ORDER BY created_at DESC
ORDER BY gm.last_seen DESC
LIMIT ${take} LIMIT ${take}
OFFSET ${offset} OFFSET ${offset}
`); `);
const count = rows[0]?.total_count ?? 0; const count = rows[0]?.total_count ?? 0;
const profileIds = rows.map((r) => r.profile_id); const profileIds = rows.map((r) => r.id);
if (profileIds.length === 0) { if (profileIds.length === 0) {
return { data: [], count }; return { data: [], count };

View File

@@ -82,27 +82,29 @@ export const groupRouter = createTRPCRouter({
metrics: protectedProcedure metrics: protectedProcedure
.input(z.object({ id: z.string(), projectId: z.string() })) .input(z.object({ id: z.string(), projectId: z.string() }))
.query(async ({ input: { id, projectId } }) => { .query(async ({ input: { id, projectId } }) => {
const data = await chQuery<{ const [eventData, profileData] = await Promise.all([
totalEvents: number; chQuery<{ totalEvents: number; firstSeen: string; lastSeen: string }>(`
uniqueProfiles: number; SELECT
firstSeen: string; count() AS totalEvents,
lastSeen: string; min(created_at) AS firstSeen,
}>(` max(created_at) AS lastSeen
SELECT FROM ${TABLE_NAMES.events}
count() AS totalEvents, WHERE project_id = ${sqlstring.escape(projectId)}
uniqExact(profile_id) AS uniqueProfiles, AND has(groups, ${sqlstring.escape(id)})
min(created_at) AS firstSeen, `),
max(created_at) AS lastSeen chQuery<{ uniqueProfiles: number }>(`
FROM ${TABLE_NAMES.events} SELECT count() AS uniqueProfiles
WHERE project_id = ${sqlstring.escape(projectId)} FROM ${TABLE_NAMES.profiles} FINAL
AND has(groups, ${sqlstring.escape(id)}) WHERE project_id = ${sqlstring.escape(projectId)}
`); AND has(groups, ${sqlstring.escape(id)})
`),
]);
return { return {
totalEvents: data[0]?.totalEvents ?? 0, totalEvents: eventData[0]?.totalEvents ?? 0,
uniqueProfiles: data[0]?.uniqueProfiles ?? 0, uniqueProfiles: profileData[0]?.uniqueProfiles ?? 0,
firstSeen: toNullIfDefaultMinDate(data[0]?.firstSeen), firstSeen: toNullIfDefaultMinDate(eventData[0]?.firstSeen),
lastSeen: toNullIfDefaultMinDate(data[0]?.lastSeen), lastSeen: toNullIfDefaultMinDate(eventData[0]?.lastSeen),
}; };
}), }),
@@ -119,25 +121,22 @@ export const groupRouter = createTRPCRouter({
`); `);
}), }),
members: protectedProcedure memberGrowth: protectedProcedure
.input(z.object({ id: z.string(), projectId: z.string() })) .input(z.object({ id: z.string(), projectId: z.string() }))
.query(({ input: { id, projectId } }) => { .query(({ input: { id, projectId } }) => {
return chQuery<{ return chQuery<{ date: string; count: number }>(`
profileId: string;
lastSeen: string;
eventCount: number;
}>(`
SELECT SELECT
profile_id AS profileId, toDate(toStartOfDay(created_at)) AS date,
max(created_at) AS lastSeen, count() AS count
count() AS eventCount FROM ${TABLE_NAMES.profiles} FINAL
FROM ${TABLE_NAMES.events}
WHERE project_id = ${sqlstring.escape(projectId)} WHERE project_id = ${sqlstring.escape(projectId)}
AND has(groups, ${sqlstring.escape(id)}) 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 lastSeen DESC, eventCount DESC ORDER BY date ASC WITH FILL
LIMIT 50 FROM toDate(now() - INTERVAL 29 DAY)
TO toDate(now() + INTERVAL 1 DAY)
STEP 1
`); `);
}), }),
@@ -195,30 +194,6 @@ export const groupRouter = createTRPCRouter({
`); `);
}), }),
memberGrowth: protectedProcedure
.input(z.object({ id: z.string(), projectId: z.string() }))
.query(({ 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 properties: protectedProcedure
.input(z.object({ projectId: z.string() })) .input(z.object({ projectId: z.string() }))
.query(({ input: { projectId } }) => { .query(({ input: { projectId } }) => {