fix: lookup group members based on profiles table instead of events
This commit is contained in:
@@ -81,7 +81,7 @@ export function GroupMemberGrowth({ data }: Props) {
|
||||
return (
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<WidgetTitle icon={TrendingUpIcon}>Member growth</WidgetTitle>
|
||||
<WidgetTitle icon={TrendingUpIcon}>New members last 30 days</WidgetTitle>
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
{data.length === 0 ? (
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute, Link } from '@tanstack/react-router';
|
||||
import { UsersIcon } from 'lucide-react';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
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 { WidgetHead } 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, WidgetEmptyState } from '@/components/widget';
|
||||
import { WidgetTable } from '@/components/widget-table';
|
||||
import { Widget } from '@/components/widget';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { formatDateTime, formatTimeAgoOrDateTime } from '@/utils/date';
|
||||
import { formatDateTime } from '@/utils/date';
|
||||
import { createProjectTitle } from '@/utils/title';
|
||||
|
||||
const MEMBERS_PREVIEW_LIMIT = 13;
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/'
|
||||
)({
|
||||
@@ -38,7 +34,7 @@ export const Route = createFileRoute(
|
||||
});
|
||||
|
||||
function Component() {
|
||||
const { projectId, organizationId, groupId } = Route.useParams();
|
||||
const { projectId, groupId } = Route.useParams();
|
||||
const trpc = useTRPC();
|
||||
|
||||
const group = useSuspenseQuery(
|
||||
@@ -50,9 +46,6 @@ function Component() {
|
||||
const activity = useSuspenseQuery(
|
||||
trpc.group.activity.queryOptions({ id: groupId, projectId })
|
||||
);
|
||||
const members = useSuspenseQuery(
|
||||
trpc.group.members.queryOptions({ id: groupId, projectId })
|
||||
);
|
||||
const mostEvents = useSuspenseQuery(
|
||||
trpc.group.mostEvents.queryOptions({ id: groupId, projectId })
|
||||
);
|
||||
@@ -154,7 +147,7 @@ function Component() {
|
||||
<ProfileActivity data={activity.data} />
|
||||
</div>
|
||||
|
||||
{/* Member growth */}
|
||||
{/* New members last 30 days */}
|
||||
<div className="col-span-1">
|
||||
<GroupMemberGrowth data={memberGrowth.data} />
|
||||
</div>
|
||||
@@ -169,65 +162,6 @@ function Component() {
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()}%`)})`
|
||||
: '';
|
||||
|
||||
// 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 }>(`
|
||||
const rows = await chQuery<{ id: string; total_count: number }>(`
|
||||
SELECT
|
||||
gm.profile_id,
|
||||
id,
|
||||
count() OVER () AS total_count
|
||||
FROM (
|
||||
SELECT profile_id, max(created_at) AS last_seen
|
||||
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)}
|
||||
FROM ${TABLE_NAMES.profiles} FINAL
|
||||
WHERE project_id = ${sqlstring.escape(projectId)}
|
||||
AND has(groups, ${sqlstring.escape(groupId)})
|
||||
${searchCondition}
|
||||
) p ON p.id = gm.profile_id
|
||||
ORDER BY gm.last_seen DESC
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ${take}
|
||||
OFFSET ${offset}
|
||||
`);
|
||||
|
||||
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) {
|
||||
return { data: [], count };
|
||||
|
||||
@@ -82,27 +82,29 @@ export const groupRouter = createTRPCRouter({
|
||||
metrics: protectedProcedure
|
||||
.input(z.object({ id: z.string(), projectId: z.string() }))
|
||||
.query(async ({ input: { id, projectId } }) => {
|
||||
const data = await chQuery<{
|
||||
totalEvents: number;
|
||||
uniqueProfiles: number;
|
||||
firstSeen: string;
|
||||
lastSeen: string;
|
||||
}>(`
|
||||
SELECT
|
||||
count() AS totalEvents,
|
||||
uniqExact(profile_id) AS uniqueProfiles,
|
||||
min(created_at) AS firstSeen,
|
||||
max(created_at) AS lastSeen
|
||||
FROM ${TABLE_NAMES.events}
|
||||
WHERE project_id = ${sqlstring.escape(projectId)}
|
||||
AND has(groups, ${sqlstring.escape(id)})
|
||||
`);
|
||||
const [eventData, profileData] = await Promise.all([
|
||||
chQuery<{ totalEvents: number; firstSeen: string; lastSeen: string }>(`
|
||||
SELECT
|
||||
count() AS totalEvents,
|
||||
min(created_at) AS firstSeen,
|
||||
max(created_at) AS lastSeen
|
||||
FROM ${TABLE_NAMES.events}
|
||||
WHERE project_id = ${sqlstring.escape(projectId)}
|
||||
AND has(groups, ${sqlstring.escape(id)})
|
||||
`),
|
||||
chQuery<{ uniqueProfiles: number }>(`
|
||||
SELECT count() AS uniqueProfiles
|
||||
FROM ${TABLE_NAMES.profiles} FINAL
|
||||
WHERE project_id = ${sqlstring.escape(projectId)}
|
||||
AND has(groups, ${sqlstring.escape(id)})
|
||||
`),
|
||||
]);
|
||||
|
||||
return {
|
||||
totalEvents: data[0]?.totalEvents ?? 0,
|
||||
uniqueProfiles: data[0]?.uniqueProfiles ?? 0,
|
||||
firstSeen: toNullIfDefaultMinDate(data[0]?.firstSeen),
|
||||
lastSeen: toNullIfDefaultMinDate(data[0]?.lastSeen),
|
||||
totalEvents: eventData[0]?.totalEvents ?? 0,
|
||||
uniqueProfiles: profileData[0]?.uniqueProfiles ?? 0,
|
||||
firstSeen: toNullIfDefaultMinDate(eventData[0]?.firstSeen),
|
||||
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() }))
|
||||
.query(({ input: { id, projectId } }) => {
|
||||
return chQuery<{
|
||||
profileId: string;
|
||||
lastSeen: string;
|
||||
eventCount: number;
|
||||
}>(`
|
||||
return chQuery<{ date: string; count: number }>(`
|
||||
SELECT
|
||||
profile_id AS profileId,
|
||||
max(created_at) AS lastSeen,
|
||||
count() AS eventCount
|
||||
FROM ${TABLE_NAMES.events}
|
||||
toDate(toStartOfDay(created_at)) AS date,
|
||||
count() AS count
|
||||
FROM ${TABLE_NAMES.profiles} FINAL
|
||||
WHERE project_id = ${sqlstring.escape(projectId)}
|
||||
AND has(groups, ${sqlstring.escape(id)})
|
||||
AND profile_id != device_id
|
||||
GROUP BY profile_id
|
||||
ORDER BY lastSeen DESC, eventCount DESC
|
||||
LIMIT 50
|
||||
AND created_at >= now() - INTERVAL 30 DAY
|
||||
GROUP BY date
|
||||
ORDER BY date ASC WITH FILL
|
||||
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
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(({ input: { projectId } }) => {
|
||||
|
||||
Reference in New Issue
Block a user