Skip to content

Commit 21e51da

Browse files
committed
fix: lookup group members based on profiles table instead of events
1 parent 729722b commit 21e51da

4 files changed

Lines changed: 46 additions & 148 deletions

File tree

apps/start/src/components/groups/group-member-growth.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ export function GroupMemberGrowth({ data }: Props) {
8181
return (
8282
<Widget className="w-full">
8383
<WidgetHead>
84-
<WidgetTitle icon={TrendingUpIcon}>Member growth</WidgetTitle>
84+
<WidgetTitle icon={TrendingUpIcon}>New members last 30 days</WidgetTitle>
8585
</WidgetHead>
8686
<WidgetBody>
8787
{data.length === 0 ? (

apps/start/src/routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.index.tsx

Lines changed: 6 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,18 @@
11
import { useSuspenseQuery } from '@tanstack/react-query';
2-
import { createFileRoute, Link } from '@tanstack/react-router';
3-
import { UsersIcon } from 'lucide-react';
2+
import { createFileRoute } from '@tanstack/react-router';
43
import FullPageLoadingState from '@/components/full-page-loading-state';
54
import { GroupMemberGrowth } from '@/components/groups/group-member-growth';
65
import { OverviewMetricCard } from '@/components/overview/overview-metric-card';
7-
import { WidgetHead, WidgetTitle } from '@/components/overview/overview-widget';
6+
import { WidgetHead } from '@/components/overview/overview-widget';
87
import { MostEvents } from '@/components/profiles/most-events';
98
import { PopularRoutes } from '@/components/profiles/popular-routes';
109
import { ProfileActivity } from '@/components/profiles/profile-activity';
1110
import { KeyValueGrid } from '@/components/ui/key-value-grid';
12-
import { Widget, WidgetBody, WidgetEmptyState } from '@/components/widget';
13-
import { WidgetTable } from '@/components/widget-table';
11+
import { Widget } from '@/components/widget';
1412
import { useTRPC } from '@/integrations/trpc/react';
15-
import { formatDateTime, formatTimeAgoOrDateTime } from '@/utils/date';
13+
import { formatDateTime } from '@/utils/date';
1614
import { createProjectTitle } from '@/utils/title';
1715

18-
const MEMBERS_PREVIEW_LIMIT = 13;
19-
2016
export const Route = createFileRoute(
2117
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/'
2218
)({
@@ -38,7 +34,7 @@ export const Route = createFileRoute(
3834
});
3935

4036
function Component() {
41-
const { projectId, organizationId, groupId } = Route.useParams();
37+
const { projectId, groupId } = Route.useParams();
4238
const trpc = useTRPC();
4339

4440
const group = useSuspenseQuery(
@@ -50,9 +46,6 @@ function Component() {
5046
const activity = useSuspenseQuery(
5147
trpc.group.activity.queryOptions({ id: groupId, projectId })
5248
);
53-
const members = useSuspenseQuery(
54-
trpc.group.members.queryOptions({ id: groupId, projectId })
55-
);
5649
const mostEvents = useSuspenseQuery(
5750
trpc.group.mostEvents.queryOptions({ id: groupId, projectId })
5851
);
@@ -154,7 +147,7 @@ function Component() {
154147
<ProfileActivity data={activity.data} />
155148
</div>
156149

157-
{/* Member growth */}
150+
{/* New members last 30 days */}
158151
<div className="col-span-1">
159152
<GroupMemberGrowth data={memberGrowth.data} />
160153
</div>
@@ -169,65 +162,6 @@ function Component() {
169162
<PopularRoutes data={popularRoutes.data} />
170163
</div>
171164

172-
{/* Members preview */}
173-
<div className="col-span-1 md:col-span-2">
174-
<Widget className="w-full">
175-
<WidgetHead>
176-
<WidgetTitle icon={UsersIcon}>Members</WidgetTitle>
177-
</WidgetHead>
178-
<WidgetBody className="p-0">
179-
{members.data.length === 0 ? (
180-
<WidgetEmptyState icon={UsersIcon} text="No members yet" />
181-
) : (
182-
<WidgetTable
183-
columnClassName="px-2"
184-
columns={[
185-
{
186-
key: 'profile',
187-
name: 'Profile',
188-
width: 'w-full',
189-
render: (member) => (
190-
<Link
191-
className="font-mono text-xs hover:underline"
192-
params={{
193-
organizationId,
194-
projectId,
195-
profileId: member.profileId,
196-
}}
197-
to="/$organizationId/$projectId/profiles/$profileId"
198-
>
199-
{member.profileId}
200-
</Link>
201-
),
202-
},
203-
{
204-
key: 'events',
205-
name: 'Events',
206-
width: '60px',
207-
className: 'text-muted-foreground',
208-
render: (member) => member.eventCount,
209-
},
210-
{
211-
key: 'lastSeen',
212-
name: 'Last Seen',
213-
width: '150px',
214-
className: 'text-muted-foreground',
215-
render: (member) =>
216-
formatTimeAgoOrDateTime(new Date(member.lastSeen)),
217-
},
218-
]}
219-
data={members.data.slice(0, MEMBERS_PREVIEW_LIMIT)}
220-
keyExtractor={(member) => member.profileId}
221-
/>
222-
)}
223-
{members.data.length > MEMBERS_PREVIEW_LIMIT && (
224-
<p className="border-t py-2 text-center text-muted-foreground text-xs">
225-
{`${members.data.length} members found. View all in Members tab`}
226-
</p>
227-
)}
228-
</WidgetBody>
229-
</Widget>
230-
</div>
231165
</div>
232166
);
233167
}

packages/db/src/services/group.service.ts

Lines changed: 7 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -323,32 +323,21 @@ export async function getGroupMemberProfiles({
323323
? `AND (email ILIKE ${sqlstring.escape(`%${search.trim()}%`)} OR first_name ILIKE ${sqlstring.escape(`%${search.trim()}%`)} OR last_name ILIKE ${sqlstring.escape(`%${search.trim()}%`)})`
324324
: '';
325325

326-
// count() OVER () is evaluated after JOINs/WHERE but before LIMIT,
327-
// so we get the total match count and the paginated IDs in one query.
328-
const rows = await chQuery<{ profile_id: string; total_count: number }>(`
326+
const rows = await chQuery<{ id: string; total_count: number }>(`
329327
SELECT
330-
gm.profile_id,
328+
id,
331329
count() OVER () AS total_count
332-
FROM (
333-
SELECT profile_id, max(created_at) AS last_seen
334-
FROM ${TABLE_NAMES.events}
335-
WHERE project_id = ${sqlstring.escape(projectId)}
336-
AND has(groups, ${sqlstring.escape(groupId)})
337-
AND profile_id != device_id
338-
GROUP BY profile_id
339-
) gm
340-
INNER JOIN (
341-
SELECT id FROM ${TABLE_NAMES.profiles} FINAL
342-
WHERE project_id = ${sqlstring.escape(projectId)}
330+
FROM ${TABLE_NAMES.profiles} FINAL
331+
WHERE project_id = ${sqlstring.escape(projectId)}
332+
AND has(groups, ${sqlstring.escape(groupId)})
343333
${searchCondition}
344-
) p ON p.id = gm.profile_id
345-
ORDER BY gm.last_seen DESC
334+
ORDER BY created_at DESC
346335
LIMIT ${take}
347336
OFFSET ${offset}
348337
`);
349338

350339
const count = rows[0]?.total_count ?? 0;
351-
const profileIds = rows.map((r) => r.profile_id);
340+
const profileIds = rows.map((r) => r.id);
352341

353342
if (profileIds.length === 0) {
354343
return { data: [], count };

packages/trpc/src/routers/group.ts

Lines changed: 32 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -82,27 +82,29 @@ export const groupRouter = createTRPCRouter({
8282
metrics: protectedProcedure
8383
.input(z.object({ id: z.string(), projectId: z.string() }))
8484
.query(async ({ input: { id, projectId } }) => {
85-
const data = await chQuery<{
86-
totalEvents: number;
87-
uniqueProfiles: number;
88-
firstSeen: string;
89-
lastSeen: string;
90-
}>(`
91-
SELECT
92-
count() AS totalEvents,
93-
uniqExact(profile_id) AS uniqueProfiles,
94-
min(created_at) AS firstSeen,
95-
max(created_at) AS lastSeen
96-
FROM ${TABLE_NAMES.events}
97-
WHERE project_id = ${sqlstring.escape(projectId)}
98-
AND has(groups, ${sqlstring.escape(id)})
99-
`);
85+
const [eventData, profileData] = await Promise.all([
86+
chQuery<{ totalEvents: number; firstSeen: string; lastSeen: string }>(`
87+
SELECT
88+
count() AS totalEvents,
89+
min(created_at) AS firstSeen,
90+
max(created_at) AS lastSeen
91+
FROM ${TABLE_NAMES.events}
92+
WHERE project_id = ${sqlstring.escape(projectId)}
93+
AND has(groups, ${sqlstring.escape(id)})
94+
`),
95+
chQuery<{ uniqueProfiles: number }>(`
96+
SELECT count() AS uniqueProfiles
97+
FROM ${TABLE_NAMES.profiles} FINAL
98+
WHERE project_id = ${sqlstring.escape(projectId)}
99+
AND has(groups, ${sqlstring.escape(id)})
100+
`),
101+
]);
100102

101103
return {
102-
totalEvents: data[0]?.totalEvents ?? 0,
103-
uniqueProfiles: data[0]?.uniqueProfiles ?? 0,
104-
firstSeen: toNullIfDefaultMinDate(data[0]?.firstSeen),
105-
lastSeen: toNullIfDefaultMinDate(data[0]?.lastSeen),
104+
totalEvents: eventData[0]?.totalEvents ?? 0,
105+
uniqueProfiles: profileData[0]?.uniqueProfiles ?? 0,
106+
firstSeen: toNullIfDefaultMinDate(eventData[0]?.firstSeen),
107+
lastSeen: toNullIfDefaultMinDate(eventData[0]?.lastSeen),
106108
};
107109
}),
108110

@@ -119,25 +121,22 @@ export const groupRouter = createTRPCRouter({
119121
`);
120122
}),
121123

122-
members: protectedProcedure
124+
memberGrowth: protectedProcedure
123125
.input(z.object({ id: z.string(), projectId: z.string() }))
124126
.query(({ input: { id, projectId } }) => {
125-
return chQuery<{
126-
profileId: string;
127-
lastSeen: string;
128-
eventCount: number;
129-
}>(`
127+
return chQuery<{ date: string; count: number }>(`
130128
SELECT
131-
profile_id AS profileId,
132-
max(created_at) AS lastSeen,
133-
count() AS eventCount
134-
FROM ${TABLE_NAMES.events}
129+
toDate(toStartOfDay(created_at)) AS date,
130+
count() AS count
131+
FROM ${TABLE_NAMES.profiles} FINAL
135132
WHERE project_id = ${sqlstring.escape(projectId)}
136133
AND has(groups, ${sqlstring.escape(id)})
137-
AND profile_id != device_id
138-
GROUP BY profile_id
139-
ORDER BY lastSeen DESC, eventCount DESC
140-
LIMIT 50
134+
AND created_at >= now() - INTERVAL 30 DAY
135+
GROUP BY date
136+
ORDER BY date ASC WITH FILL
137+
FROM toDate(now() - INTERVAL 29 DAY)
138+
TO toDate(now() + INTERVAL 1 DAY)
139+
STEP 1
141140
`);
142141
}),
143142

@@ -195,30 +194,6 @@ export const groupRouter = createTRPCRouter({
195194
`);
196195
}),
197196

198-
memberGrowth: protectedProcedure
199-
.input(z.object({ id: z.string(), projectId: z.string() }))
200-
.query(({ input: { id, projectId } }) => {
201-
return chQuery<{ date: string; count: number }>(`
202-
SELECT
203-
toDate(toStartOfDay(min_date)) AS date,
204-
count() AS count
205-
FROM (
206-
SELECT profile_id, min(created_at) AS min_date
207-
FROM ${TABLE_NAMES.events}
208-
WHERE project_id = ${sqlstring.escape(projectId)}
209-
AND has(groups, ${sqlstring.escape(id)})
210-
AND profile_id != device_id
211-
AND created_at >= now() - INTERVAL 30 DAY
212-
GROUP BY profile_id
213-
)
214-
GROUP BY date
215-
ORDER BY date ASC WITH FILL
216-
FROM toDate(now() - INTERVAL 29 DAY)
217-
TO toDate(now() + INTERVAL 1 DAY)
218-
STEP 1
219-
`);
220-
}),
221-
222197
properties: protectedProcedure
223198
.input(z.object({ projectId: z.string() }))
224199
.query(({ input: { projectId } }) => {

0 commit comments

Comments
 (0)