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 (
|
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 ? (
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 } }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user