a looooot

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-02-22 21:50:30 +01:00
parent 1d800835b8
commit 9c92803c4c
61 changed files with 2689 additions and 681 deletions

View File

@@ -59,7 +59,12 @@ export function EventList({ data, count }: EventListProps) {
<EventListItem key={item.id} {...item} />
))}
</div>
<Pagination cursor={cursor} setCursor={setCursor} />
<Pagination
cursor={cursor}
setCursor={setCursor}
count={count}
take={50}
/>
</>
)}
</div>

View File

@@ -6,6 +6,7 @@ import {
eventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters';
import { getExists } from '@/server/pageExists';
import { parseAsInteger } from 'nuqs';
import { getEventList, getEventsCount } from '@mixan/db';
@@ -28,20 +29,13 @@ const nuqsOptions = {
shallow: false,
};
function parseQueryAsNumber(value: string | undefined) {
if (typeof value === 'string') {
return parseInt(value, 10);
}
return undefined;
}
export default async function Page({
params: { projectId, organizationId },
searchParams,
}: PageProps) {
const [events, count] = await Promise.all([
getEventList({
cursor: parseQueryAsNumber(searchParams.cursor),
cursor: parseAsInteger.parse(searchParams.cursor ?? '') ?? undefined,
projectId,
take: 50,
events: eventQueryNamesFilter.parse(searchParams.events ?? ''),
@@ -59,6 +53,7 @@ export default async function Page({
<PageLayout title="Events" organizationSlug={organizationId}>
<StickyBelowHeader className="p-4 flex justify-between">
<OverviewFiltersDrawer
mode="events"
projectId={projectId}
nuqsOptions={nuqsOptions}
enableEventsFilter

View File

@@ -2,8 +2,10 @@
import { useEffect, useState } from 'react';
import { Logo } from '@/components/Logo';
import { buttonVariants } from '@/components/ui/button';
import { cn } from '@/utils/cn';
import { Rotate as Hamburger } from 'hamburger-react';
import { PlusIcon } from 'lucide-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
@@ -15,10 +17,14 @@ import LayoutOrganizationSelector from './layout-organization-selector';
interface LayoutSidebarProps {
organizations: IServiceOrganization[];
dashboards: IServiceDashboards;
organizationId: string;
projectId: string;
}
export function LayoutSidebar({
organizations,
dashboards,
organizationId,
projectId,
}: LayoutSidebarProps) {
const [active, setActive] = useState(false);
const pathname = usePathname();
@@ -56,11 +62,18 @@ export function LayoutSidebar({
<div className="flex flex-col p-4 gap-2 flex-grow overflow-auto">
<LayoutMenu dashboards={dashboards} />
{/* Placeholder for LayoutOrganizationSelector */}
<div className="h-16 block shrink-0"></div>
<div className="h-32 block shrink-0"></div>
</div>
<div className="fixed bottom-0 left-0 right-0">
<div className="bg-gradient-to-t from-white to-white/0 h-8 w-full"></div>
<div className="bg-white p-4 pt-0">
<div className="bg-white p-4 pt-0 flex flex-col gap-2">
<Link
className={cn('flex gap-2', buttonVariants())}
href={`/${organizationId}/${projectId}/reports`}
>
<PlusIcon size={16} />
Create a report
</Link>
<LayoutOrganizationSelector organizations={organizations} />
</div>
</div>

View File

@@ -9,12 +9,13 @@ interface AppLayoutProps {
children: React.ReactNode;
params: {
organizationId: string;
projectId: string;
};
}
export default async function AppLayout({
children,
params: { organizationId },
params: { organizationId, projectId },
}: AppLayoutProps) {
const [organizations, dashboards] = await Promise.all([
getCurrentOrganizations(),
@@ -23,7 +24,9 @@ export default async function AppLayout({
return (
<div id="dashboard">
<LayoutSidebar {...{ organizations, dashboards }} />
<LayoutSidebar
{...{ organizationId, projectId, organizations, dashboards }}
/>
<div className="lg:pl-72 transition-all">{children}</div>
</div>
);

View File

@@ -19,7 +19,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
const reports = [
{
id: 'Unique visitors',
id: 'Visitors',
projectId,
events: [
{
@@ -27,20 +27,20 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
filters,
id: 'A',
name: 'session_start',
displayName: 'Unique visitors',
displayName: 'Visitors',
},
],
breakdowns: [],
chartType: 'metric',
lineType: 'monotone',
interval,
name: 'Unique visitors',
name: 'Visitors',
range,
previous,
metric: 'sum',
},
{
id: 'Total sessions',
id: 'Sessions',
projectId,
events: [
{
@@ -48,20 +48,20 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
filters,
id: 'A',
name: 'session_start',
displayName: 'Total sessions',
displayName: 'Sessions',
},
],
breakdowns: [],
chartType: 'metric',
lineType: 'monotone',
interval,
name: 'Total sessions',
name: 'Sessions',
range,
previous,
metric: 'sum',
},
{
id: 'Total pageviews',
id: 'Pageviews',
projectId,
events: [
{
@@ -69,14 +69,14 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
filters,
id: 'A',
name: 'screen_view',
displayName: 'Total pageviews',
displayName: 'Pageviews',
},
],
breakdowns: [],
chartType: 'metric',
lineType: 'monotone',
interval,
name: 'Total pageviews',
name: 'Pageviews',
range,
previous,
metric: 'sum',

View File

@@ -42,7 +42,7 @@ export default async function Page({
<div className="p-4 flex gap-2 justify-between">
<div className="flex gap-2">
<OverviewReportRange />
<OverviewFiltersDrawer projectId={projectId} />
<OverviewFiltersDrawer projectId={projectId} mode="events" />
</div>
<div className="flex gap-2">
<ServerLiveCounter projectId={projectId} />

View File

@@ -1,13 +1,35 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { ListProperties } from '@/components/events/ListProperties';
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
import { Chart } from '@/components/report/chart';
import { GradientBackground } from '@/components/ui/gradient-background';
import { KeyValue } from '@/components/ui/key-value';
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
import {
eventQueryFiltersParser,
eventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters';
import { getExists } from '@/server/pageExists';
import { formatDateTime } from '@/utils/date';
import { getProfileName } from '@/utils/getters';
import { notFound } from 'next/navigation';
import { parseAsInteger } from 'nuqs';
import { getProfileById, getProfilesByExternalId } from '@mixan/db';
import type { GetEventListOptions } from '@mixan/db';
import {
getConversionEventNames,
getEventList,
getEventMeta,
getEventsCount,
getProfileById,
getProfilesByExternalId,
} from '@mixan/db';
import type { IChartInput } from '@mixan/validation';
import { EventList } from '../../events/event-list';
import { StickyBelowHeader } from '../../layout-sticky-below-header';
import ListProfileEvents from './list-profile-events';
interface PageProps {
@@ -16,18 +38,90 @@ interface PageProps {
profileId: string;
organizationId: string;
};
searchParams: {
events?: string;
cursor?: string;
f?: string;
};
}
export default async function Page({
params: { projectId, profileId, organizationId },
searchParams,
}: PageProps) {
const [profile] = await Promise.all([
const eventListOptions: GetEventListOptions = {
projectId,
profileId,
take: 50,
cursor: parseAsInteger.parse(searchParams.cursor ?? '') ?? undefined,
events: eventQueryNamesFilter.parse(searchParams.events ?? ''),
filters: eventQueryFiltersParser.parse(searchParams.f ?? '') ?? undefined,
};
const [profile, events, count, conversions] = await Promise.all([
getProfileById(profileId),
getEventList(eventListOptions),
getEventsCount(eventListOptions),
getConversionEventNames(projectId),
getExists(organizationId, projectId),
]);
const profiles = (
await getProfilesByExternalId(profile.external_id, profile.project_id)
).filter((item) => item.id !== profile.id);
const chartSelectedEvents = [
{
segment: 'event',
filters: [
{
id: 'profile_id',
name: 'profile_id',
operator: 'is',
value: [profileId],
},
],
id: 'A',
name: '*',
displayName: 'Events',
},
];
if (conversions.length) {
chartSelectedEvents.push({
segment: 'event',
filters: [
{
id: 'profile_id',
name: 'profile_id',
operator: 'is',
value: [profileId],
},
{
id: 'name',
name: 'name',
operator: 'is',
value: conversions.map((c) => c.name),
},
],
id: 'B',
name: '*',
displayName: 'Conversions',
});
}
const profileChart: IChartInput = {
projectId,
chartType: 'histogram',
events: chartSelectedEvents,
breakdowns: [],
lineType: 'monotone',
interval: 'day',
name: 'Events',
range: '7d',
previous: false,
metric: 'sum',
};
if (!profile) {
return notFound();
}
return (
<PageLayout
organizationSlug={organizationId}
@@ -38,50 +132,41 @@ export default async function Page({
</div>
}
>
<StickyBelowHeader className="p-4 flex justify-between">
<OverviewFiltersDrawer
projectId={projectId}
mode="events"
nuqsOptions={{ shallow: false }}
/>
<OverviewFiltersButtons
nuqsOptions={{ shallow: false }}
className="p-0 justify-end"
/>
</StickyBelowHeader>
<div className="p-4">
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 mb-8">
<Widget>
<WidgetHead>
<span className="title">Properties</span>
</WidgetHead>
<ListProperties
data={profile.properties}
className="rounded-none border-none"
/>
<WidgetBody className="flex gap-2 flex-wrap">
{Object.entries(profile.properties)
.filter(([, value]) => !!value)
.map(([key, value]) => (
<KeyValue key={key} name={key} value={value} />
))}
</WidgetBody>
</Widget>
<Widget>
<WidgetHead>
<span className="title">Linked profile</span>
<span className="title">Events per day</span>
</WidgetHead>
{profiles.length > 0 ? (
<div className="flex flex-col gap-4">
{profiles.map((profile) => (
<div key={profile.id} className="border-b border-border">
<WidgetBody className="flex gap-4">
<ProfileAvatar {...profile} />
<div>
<div className="font-medium mt-1">
{getProfileName(profile)}
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2 text-muted-foreground text-xs">
<span>{profile.id}</span>
<span>{formatDateTime(profile.createdAt)}</span>
</div>
</div>
</WidgetBody>
<ListProperties
data={profile.properties}
className="rounded-none border-none"
/>
</div>
))}
</div>
) : (
<div className="p-4">No linked profiles</div>
)}
<WidgetBody className="flex gap-2">
<Chart {...profileChart} />
</WidgetBody>
</Widget>
</div>
<ListProfileEvents projectId={projectId} profileId={profileId} />
<EventList data={events} count={count} />
</div>
</PageLayout>
);

View File

@@ -1,67 +0,0 @@
'use client';
import { useMemo } from 'react';
import { api } from '@/app/_trpc/client';
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
import { FullPageEmptyState } from '@/components/FullPageEmptyState';
import { Pagination, usePagination } from '@/components/Pagination';
import { Input } from '@/components/ui/input';
import { UsersIcon } from 'lucide-react';
import { useQueryState } from 'nuqs';
import { ProfileListItem } from './profile-list-item';
interface ListProfilesProps {
projectId: string;
}
export function ListProfiles({ projectId }: ListProfilesProps) {
const [query, setQuery] = useQueryState('q');
const pagination = usePagination();
const profilesQuery = api.profile.list.useQuery(
{
projectId,
query,
...pagination,
},
{
keepPreviousData: true,
}
);
const profiles = useMemo(() => profilesQuery.data ?? [], [profilesQuery]);
return (
<>
<StickyBelowHeader className="p-4 flex justify-between">
<Input
placeholder="Search by name"
value={query ?? ''}
onChange={(event) => setQuery(event.target.value || null)}
/>
</StickyBelowHeader>
<div className="p-4">
{profiles.length === 0 ? (
<FullPageEmptyState title="No profiles" icon={UsersIcon}>
{query ? (
<p>
No match for <strong>"{query}"</strong>
</p>
) : (
<p>We could not find any profiles on this project</p>
)}
</FullPageEmptyState>
) : (
<>
<div className="flex flex-col gap-4">
{profiles.map((item) => (
<ProfileListItem key={item.id} {...item} />
))}
</div>
<div className="mt-2">
<Pagination {...pagination} />
</div>
</>
)}
</div>
</>
);
}

View File

@@ -1,21 +1,62 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import { eventQueryFiltersParser } from '@/hooks/useEventQueryFilters';
import { getExists } from '@/server/pageExists';
import { parseAsInteger } from 'nuqs';
import { ListProfiles } from './list-profiles';
import { getProfileList, getProfileListCount } from '@mixan/db';
import { StickyBelowHeader } from '../layout-sticky-below-header';
import { ProfileList } from './profile-list';
interface PageProps {
params: {
organizationId: string;
projectId: string;
};
searchParams: {
f?: string;
cursor?: string;
};
}
const nuqsOptions = {
shallow: false,
};
export default async function Page({
params: { organizationId, projectId },
searchParams: { cursor, f },
}: PageProps) {
await getExists(organizationId, projectId);
const [profiles, count] = await Promise.all([
getProfileList({
projectId,
take: 50,
cursor: parseAsInteger.parse(cursor ?? '') ?? undefined,
filters: eventQueryFiltersParser.parse(f ?? '') ?? undefined,
}),
getProfileListCount({
projectId,
filters: eventQueryFiltersParser.parse(f ?? '') ?? undefined,
}),
getExists(organizationId, projectId),
]);
return (
<PageLayout title="Events" organizationSlug={organizationId}>
<ListProfiles projectId={projectId} />
<PageLayout title="Profiles" organizationSlug={organizationId}>
<StickyBelowHeader className="p-4 flex justify-between">
<OverviewFiltersDrawer
projectId={projectId}
nuqsOptions={nuqsOptions}
mode="events"
/>
<OverviewFiltersButtons
className="p-0 justify-end"
nuqsOptions={nuqsOptions}
/>
</StickyBelowHeader>
<ProfileList data={profiles} count={count} />
</PageLayout>
);
}

View File

@@ -1,30 +1,30 @@
'use client';
import type { RouterOutputs } from '@/app/_trpc/client';
import { ListProperties } from '@/components/events/ListProperties';
import { ExpandableListItem } from '@/components/general/ExpandableListItem';
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
import { KeyValue, KeyValueSubtle } from '@/components/ui/key-value';
import { useAppParams } from '@/hooks/useAppParams';
import { formatDateTime } from '@/utils/date';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { getProfileName } from '@/utils/getters';
import Link from 'next/link';
type ProfileListItemProps = RouterOutputs['profile']['list'][number];
import type { IServiceProfile } from '@mixan/db';
type ProfileListItemProps = IServiceProfile;
export function ProfileListItem(props: ProfileListItemProps) {
const { id, properties, createdAt } = props;
const params = useAppParams();
const [, setFilter] = useEventQueryFilters({ shallow: false });
const renderContent = () => {
return (
<>
<span>{formatDateTime(createdAt)}</span>
<Link
<KeyValueSubtle name="Time" value={createdAt.toLocaleString()} />
<KeyValueSubtle
href={`/${params.organizationId}/${params.projectId}/profiles/${id}`}
className="text-black font-medium hover:underline"
>
See profile
</Link>
name="Details"
value={'See profile'}
/>
</>
);
};
@@ -35,7 +35,29 @@ export function ProfileListItem(props: ProfileListItemProps) {
content={renderContent()}
image={<ProfileAvatar {...props} />}
>
<ListProperties data={properties} className="rounded-none border-none" />
<>
{properties && (
<div className="p-2">
<div className="bg-gradient-to-tr from-slate-100 to-white rounded-md">
<div className="p-4 flex flex-col gap-4">
<div className="font-medium">Properties</div>
<div className="flex flex-wrap gap-x-4 gap-y-2">
{Object.entries(properties)
.filter(([, value]) => !!value)
.map(([key, value]) => (
<KeyValue
onClick={() => setFilter(`properties.${key}`, value)}
key={key}
name={key}
value={value}
/>
))}
</div>
</div>
</div>
</div>
)}
</>
</ExpandableListItem>
);
}

View File

@@ -0,0 +1,74 @@
'use client';
import { Suspense } from 'react';
import { FullPageEmptyState } from '@/components/FullPageEmptyState';
import { Pagination } from '@/components/Pagination';
import { Button } from '@/components/ui/button';
import { useCursor } from '@/hooks/useCursor';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { UsersIcon } from 'lucide-react';
import type { IServiceProfile } from '@mixan/db';
import { ProfileListItem } from './profile-list-item';
interface ProfileListProps {
data: IServiceProfile[];
count: number;
}
export function ProfileList({ data, count }: ProfileListProps) {
const { cursor, setCursor } = useCursor();
const [filters] = useEventQueryFilters();
return (
<Suspense>
<div className="p-4">
{data.length === 0 ? (
<FullPageEmptyState title="No profiles here" icon={UsersIcon}>
{cursor !== 0 ? (
<>
<p>Looks like you have reached the end of the list</p>
<Button
className="mt-4"
variant="outline"
size="sm"
onClick={() => setCursor((p) => Math.max(0, p - 1))}
>
Go back
</Button>
</>
) : (
<>
{filters.length ? (
<p>Could not find any profiles with your filter</p>
) : (
<p>No profiles have been created yet</p>
)}
</>
)}
</FullPageEmptyState>
) : (
<>
<Pagination
cursor={cursor}
setCursor={setCursor}
count={count}
take={50}
/>
<div className="flex flex-col gap-4 my-4">
{data.map((item) => (
<ProfileListItem key={item.id} {...item} />
))}
</div>
<Pagination
cursor={cursor}
setCursor={setCursor}
count={count}
take={50}
/>
</>
)}
</div>
</Suspense>
);
}

View File

@@ -48,7 +48,7 @@ export default async function Page({ params: { id } }: PageProps) {
<div className="p-4 flex gap-2 justify-between">
<div className="flex gap-2">
<OverviewReportRange />
<OverviewFiltersDrawer projectId={projectId} />
<OverviewFiltersDrawer projectId={projectId} mode="events" />
</div>
<div className="flex gap-2">
<ServerLiveCounter projectId={projectId} />

View File

@@ -9,6 +9,8 @@ import {
useEventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters';
import { useEventValues } from '@/hooks/useEventValues';
import { useProfileProperties } from '@/hooks/useProfileProperties';
import { useProfileValues } from '@/hooks/useProfileValues';
import { XIcon } from 'lucide-react';
import type { Options as NuqsOptions } from 'nuqs';
@@ -18,21 +20,25 @@ import type {
IChartEventFilterValue,
} from '@mixan/validation';
interface OverviewFiltersProps {
export interface OverviewFiltersDrawerContentProps {
projectId: string;
nuqsOptions?: NuqsOptions;
enableEventsFilter?: boolean;
mode: 'profiles' | 'events';
}
export function OverviewFiltersDrawerContent({
projectId,
nuqsOptions,
enableEventsFilter,
}: OverviewFiltersProps) {
mode,
}: OverviewFiltersDrawerContentProps) {
const [filters, setFilter] = useEventQueryFilters(nuqsOptions);
const [event, setEvent] = useEventQueryNamesFilter(nuqsOptions);
const eventNames = useEventNames(projectId);
const eventProperties = useEventProperties(projectId);
const profileProperties = useProfileProperties(projectId);
const properties = mode === 'events' ? eventProperties : profileProperties;
return (
<div>
@@ -62,7 +68,7 @@ export function OverviewFiltersDrawerContent({
value=""
placeholder="Filter by property"
label="What do you want to filter by?"
items={eventProperties.map((item) => ({
items={properties.map((item) => ({
label: item,
value: item,
}))}
@@ -74,8 +80,15 @@ export function OverviewFiltersDrawerContent({
{filters
.filter((filter) => filter.value[0] !== null)
.map((filter) => {
return (
<FilterOption
return mode === 'events' ? (
<FilterOptionEvent
key={filter.name}
projectId={projectId}
setFilter={setFilter}
{...filter}
/>
) : (
<FilterOptionProfile
key={filter.name}
projectId={projectId}
setFilter={setFilter}
@@ -88,7 +101,7 @@ export function OverviewFiltersDrawerContent({
);
}
export function FilterOption({
export function FilterOptionEvent({
setFilter,
projectId,
...filter
@@ -131,3 +144,43 @@ export function FilterOption({
</div>
);
}
export function FilterOptionProfile({
setFilter,
projectId,
...filter
}: IChartEventFilter & {
projectId: string;
setFilter: (
name: string,
value: IChartEventFilterValue,
operator: IChartEventFilterOperator
) => void;
}) {
const values = useProfileValues(projectId, filter.name);
return (
<div className="flex gap-2 items-center">
<div>{filter.name}</div>
<Combobox
className="flex-1"
onChange={(value) => setFilter(filter.name, value, filter.operator)}
placeholder={'Select a value'}
items={values.map((value) => ({
value,
label: value,
}))}
value={String(filter.value[0] ?? '')}
/>
<Button
size="icon"
variant="ghost"
onClick={() =>
setFilter(filter.name, filter.value[0] ?? '', filter.operator)
}
>
<XIcon />
</Button>
</div>
);
}

View File

@@ -3,21 +3,13 @@
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { FilterIcon } from 'lucide-react';
import type { Options as NuqsOptions } from 'nuqs';
import type { OverviewFiltersDrawerContentProps } from './overview-filters-drawer-content';
import { OverviewFiltersDrawerContent } from './overview-filters-drawer-content';
interface OverviewFiltersDrawerProps {
projectId: string;
nuqsOptions?: NuqsOptions;
enableEventsFilter?: boolean;
}
export function OverviewFiltersDrawer({
projectId,
nuqsOptions,
enableEventsFilter,
}: OverviewFiltersDrawerProps) {
export function OverviewFiltersDrawer(
props: OverviewFiltersDrawerContentProps
) {
return (
<Sheet>
<SheetTrigger asChild>
@@ -26,11 +18,7 @@ export function OverviewFiltersDrawer({
</Button>
</SheetTrigger>
<SheetContent className="!max-w-lg w-full" side="right">
<OverviewFiltersDrawerContent
projectId={projectId}
nuqsOptions={nuqsOptions}
enableEventsFilter={enableEventsFilter}
/>
<OverviewFiltersDrawerContent {...props} />
</SheetContent>
</Sheet>
);

View File

@@ -0,0 +1,24 @@
import { cn } from '@/utils/cn';
interface GradientBackgroundProps {
children: React.ReactNode;
className?: string;
}
export function GradientBackground({
children,
className,
...props
}: GradientBackgroundProps) {
return (
<div
className={cn(
'bg-gradient-to-tr from-slate-100 to-white rounded-md',
className
)}
{...props}
>
<div className="p-4 flex flex-col gap-4">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { api } from '@/app/_trpc/client';
export function useProfileProperties(projectId: string, event?: string) {
const query = api.profile.properties.useQuery({
projectId: projectId,
event,
});
return query.data ?? [];
}

View File

@@ -0,0 +1,10 @@
import { api } from '@/app/_trpc/client';
export function useProfileValues(projectId: string, property: string) {
const query = api.profile.values.useQuery({
projectId: projectId,
property,
});
return query.data?.values ?? [];
}

View File

@@ -1,7 +1,14 @@
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import {
createTRPCRouter,
protectedProcedure,
publicProcedure,
} from '@/server/api/trpc';
import { db } from '@/server/db';
import { flatten, map, pipe, prop, sort, uniq } from 'ramda';
import { z } from 'zod';
import { chQuery, createSqlBuilder } from '@mixan/db';
export const profileRouter = createTRPCRouter({
list: protectedProcedure
.input(
@@ -61,4 +68,56 @@ export const profileRouter = createTRPCRouter({
},
});
}),
properties: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ input: { projectId } }) => {
const events = await chQuery<{ keys: string[] }>(
`SELECT distinct mapKeys(properties) as keys from profiles where project_id = '${projectId}';`
);
const properties = events
.flatMap((event) => event.keys)
.map((item) => item.replace(/\.([0-9]+)\./g, '.*.'))
.map((item) => item.replace(/\.([0-9]+)/g, '[*]'))
.map((item) => `properties.${item}`);
properties.push('external_id', 'first_name', 'last_name', 'email');
return pipe(
sort<string>((a, b) => a.length - b.length),
uniq
)(properties);
}),
values: publicProcedure
.input(
z.object({
property: z.string(),
projectId: z.string(),
})
)
.query(async ({ input: { property, projectId } }) => {
const { sb, getSql } = createSqlBuilder();
sb.from = 'profiles';
sb.where.project_id = `project_id = '${projectId}'`;
if (property.startsWith('properties.')) {
sb.select.values = `distinct mapValues(mapExtractKeyLike(properties, '${property
.replace(/^properties\./, '')
.replace('.*.', '.%.')}')) as values`;
} else {
sb.select.values = `${property} as values`;
}
const profiles = await chQuery<{ values: string[] }>(getSql());
const values = pipe(
(data: typeof profiles) => map(prop('values'), data),
flatten,
uniq,
sort((a, b) => a.length - b.length)
)(profiles);
return {
values,
};
}),
});