wip event list

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-02-15 21:29:45 +01:00
parent 1328825e7c
commit a74acda707
29 changed files with 995 additions and 883 deletions

View File

@@ -10,7 +10,7 @@ import { JoinWaitlist } from './join-waitlist';
import { Sections } from './section'; import { Sections } from './section';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export const revalidate = 60 * 60; export const revalidate = 3600;
export default async function Page() { export default async function Page() {
const waitlistCount = await db.waitlist.count(); const waitlistCount = await db.waitlist.count();

View File

@@ -173,7 +173,6 @@ export function EventListItem({
{profile && ( {profile && (
<KeyValueSubtle <KeyValueSubtle
name="Profile" name="Profile"
// icon={<ProfileAvatar size="xs" {...(profile ?? {})} />}
value={getProfileName(profile)} value={getProfileName(profile)}
href={`/${params.organizationId}/${params.projectId}/profiles/${profile.id}`} href={`/${params.organizationId}/${params.projectId}/profiles/${profile.id}`}
/> />
@@ -191,12 +190,18 @@ export function EventListItem({
} }
image={<EventIcon name={name} />} image={<EventIcon name={name} />}
> >
<div className="p-2">
<div className="bg-gradient-to-tr from-slate-100 to-white rounded-md">
{propertiesList.length > 0 && ( {propertiesList.length > 0 && (
<div className="p-4 flex flex-col gap-4"> <div className="p-4 flex flex-col gap-4">
<div className="font-medium">Your properties</div> <div className="font-medium">Your properties</div>
<div className="flex flex-wrap gap-x-4 gap-y-2"> <div className="flex flex-wrap gap-x-4 gap-y-2">
{propertiesList.map((item) => ( {propertiesList.map((item) => (
<KeyValue key={item.name} name={item.name} value={item.value} /> <KeyValue
key={item.name}
name={item.name}
value={item.value}
/>
))} ))}
</div> </div>
</div> </div>
@@ -206,7 +211,7 @@ export function EventListItem({
<div className="flex flex-wrap gap-x-4 gap-y-2"> <div className="flex flex-wrap gap-x-4 gap-y-2">
{keyValueList.map((item) => ( {keyValueList.map((item) => (
<KeyValue <KeyValue
onClick={item.onClick} onClick={() => item.onClick?.()}
key={item.name} key={item.name}
name={item.name} name={item.name}
value={item.value} value={item.value}
@@ -214,6 +219,8 @@ export function EventListItem({
))} ))}
</div> </div>
</div> </div>
</div>
</div>
</ExpandableListItem> </ExpandableListItem>
); );
} }

View File

@@ -0,0 +1,53 @@
'use client';
import { FullPageEmptyState } from '@/components/FullPageEmptyState';
import { Pagination } from '@/components/Pagination';
import { Button } from '@/components/ui/button';
import { useCursor } from '@/hooks/useCursor';
import { GanttChartIcon } from 'lucide-react';
import { last } from 'ramda';
import { IServiceCreateEventPayload } from '@mixan/db';
import { EventListItem } from './event-list-item';
interface EventListProps {
data: IServiceCreateEventPayload[];
}
export function EventList({ data }: EventListProps) {
const { cursor, setCursor } = useCursor();
return (
<>
<div className="p-4">
{data.length === 0 ? (
<FullPageEmptyState title="No events here" icon={GanttChartIcon}>
{/* {filterEvents.length ? (
<p>Could not find any events with your filter</p>
) : (
<p>We have not recieved any events yet</p>
)} */}
<p>We have not recieved any events yet</p>
</FullPageEmptyState>
) : (
<>
<div className="flex flex-col gap-4">
{data.map((item) => (
<EventListItem
key={item.createdAt.toString() + item.name + item.profileId}
{...item}
/>
))}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCursor(last(data)?.createdAt ?? null)}
>
Next
</Button>
</>
)}
</div>
</>
);
}

View File

@@ -1,75 +0,0 @@
'use client';
import { useMemo, useState } 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 { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import { GanttChartIcon } from 'lucide-react';
import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs';
import { EventListItem } from './event-list-item';
interface ListEventsProps {
projectId: string;
}
export function ListEvents({ projectId }: ListEventsProps) {
const pagination = usePagination();
const [eventFilters, setEventFilters] = useQueryState(
'events',
parseAsArrayOf(parseAsString).withDefault([])
);
const eventsQuery = api.event.list.useQuery({
events: eventFilters,
projectId: projectId,
...pagination,
});
const events = useMemo(() => eventsQuery.data ?? [], [eventsQuery]);
const filterEventsQuery = api.chart.events.useQuery({
projectId: projectId,
});
const filterEvents = (filterEventsQuery.data ?? []).map((item) => ({
value: item.name,
label: item.name,
}));
return (
<>
<StickyBelowHeader className="p-4 flex justify-between">
<div>
<ComboboxAdvanced
items={filterEvents}
value={eventFilters}
onChange={setEventFilters}
placeholder="Filter by event"
/>
</div>
</StickyBelowHeader>
<div className="p-4">
{events.length === 0 ? (
<FullPageEmptyState title="No events here" icon={GanttChartIcon}>
{eventFilters.length ? (
<p>Could not find any events with your filter</p>
) : (
<p>We have not recieved any events yet</p>
)}
</FullPageEmptyState>
) : (
<>
<div className="flex flex-col gap-4">
{events.map((item) => (
<EventListItem key={item.createdAt.toString()} {...item} />
))}
</div>
<div className="mt-2">
<Pagination {...pagination} />
</div>
</>
)}
</div>
</>
);
}

View File

@@ -1,22 +1,39 @@
import { Suspense } from 'react';
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout'; import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import { getExists } from '@/server/pageExists'; import { getExists } from '@/server/pageExists';
import { ListEvents } from './list-events'; import { getEventList, getEvents } from '@mixan/db';
import { StickyBelowHeader } from '../layout-sticky-below-header';
import { EventList } from './event-list';
interface PageProps { interface PageProps {
params: { params: {
projectId: string; projectId: string;
organizationId: string; organizationId: string;
}; };
searchParams: {
cursor?: string;
};
} }
export default async function Page({ export default async function Page({
params: { projectId, organizationId }, params: { projectId, organizationId },
searchParams: { cursor },
}: PageProps) { }: PageProps) {
await getExists(organizationId, projectId); await getExists(organizationId, projectId);
const events = await getEventList({
cursor,
projectId,
take: 50,
});
return ( return (
<PageLayout title="Events" organizationSlug={organizationId}> <PageLayout title="Events" organizationSlug={organizationId}>
<ListEvents projectId={projectId} /> <StickyBelowHeader className="p-4 flex justify-between">
<OverviewFiltersDrawer projectId={projectId} />
</StickyBelowHeader>
<EventList data={events} />
</PageLayout> </PageLayout>
); );
} }

View File

@@ -1,12 +1,10 @@
'use client'; 'use client';
import { Suspense } from 'react';
import { WidgetHead } from '@/components/overview/overview-widget'; import { WidgetHead } from '@/components/overview/overview-widget';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { Chart } from '@/components/report/chart'; import { Chart } from '@/components/report/chart';
import { ChartLoading } from '@/components/report/chart/ChartLoading';
import { MetricCardLoading } from '@/components/report/chart/MetricCard';
import { Widget, WidgetBody } from '@/components/Widget'; import { Widget, WidgetBody } from '@/components/Widget';
import { useEventFilters } from '@/hooks/useEventQueryFilters';
import type { IChartInput } from '@/types'; import type { IChartInput } from '@/types';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
@@ -15,8 +13,8 @@ interface OverviewMetricsProps {
} }
export default function OverviewMetrics({ projectId }: OverviewMetricsProps) { export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
const { previous, range, interval, metric, setMetric, filters } = const { previous, range, interval, metric, setMetric } = useOverviewOptions();
useOverviewOptions(); const filters = useEventFilters();
const reports = [ const reports = [
{ {

View File

@@ -2,26 +2,8 @@
import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { ReportRange } from '@/components/report/ReportRange'; import { ReportRange } from '@/components/report/ReportRange';
import { Button } from '@/components/ui/button';
import { SheetTrigger } from '@/components/ui/sheet';
import { FilterIcon } from 'lucide-react';
export function OverviewReportRange() { export function OverviewReportRange() {
const { previous, range, setRange, interval, metric, setMetric, filters } = const { range, setRange } = useOverviewOptions();
useOverviewOptions();
return <ReportRange value={range} onChange={(value) => setRange(value)} />; return <ReportRange value={range} onChange={(value) => setRange(value)} />;
} }
export function OverviewFilterSheetTrigger() {
const { previous, range, setRange, interval, metric, setMetric, filters } =
useOverviewOptions();
return (
<SheetTrigger asChild>
<Button variant="outline" responsive icon={FilterIcon}>
Filters
</Button>
</SheetTrigger>
);
}

View File

@@ -1,7 +1,7 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout'; 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 ServerLiveCounter from '@/components/overview/live-counter'; import ServerLiveCounter from '@/components/overview/live-counter';
import { OverviewFilters } from '@/components/overview/overview-filters';
import { OverviewFiltersButtons } from '@/components/overview/overview-filters-buttons';
import { OverviewLiveHistogram } from '@/components/overview/overview-live-histogram'; import { OverviewLiveHistogram } from '@/components/overview/overview-live-histogram';
import { OverviewShare } from '@/components/overview/overview-share'; import { OverviewShare } from '@/components/overview/overview-share';
import OverviewTopDevices from '@/components/overview/overview-top-devices'; import OverviewTopDevices from '@/components/overview/overview-top-devices';
@@ -9,17 +9,13 @@ import OverviewTopEvents from '@/components/overview/overview-top-events';
import OverviewTopGeo from '@/components/overview/overview-top-geo'; import OverviewTopGeo from '@/components/overview/overview-top-geo';
import OverviewTopPages from '@/components/overview/overview-top-pages'; import OverviewTopPages from '@/components/overview/overview-top-pages';
import OverviewTopSources from '@/components/overview/overview-top-sources'; import OverviewTopSources from '@/components/overview/overview-top-sources';
import { Sheet, SheetContent } from '@/components/ui/sheet';
import { getExists } from '@/server/pageExists'; import { getExists } from '@/server/pageExists';
import { db } from '@mixan/db'; import { db } from '@mixan/db';
import { StickyBelowHeader } from './layout-sticky-below-header'; import { StickyBelowHeader } from './layout-sticky-below-header';
import OverviewMetrics from './overview-metrics'; import OverviewMetrics from './overview-metrics';
import { import { OverviewReportRange } from './overview-sticky-header';
OverviewFilterSheetTrigger,
OverviewReportRange,
} from './overview-sticky-header';
interface PageProps { interface PageProps {
params: { params: {
@@ -42,12 +38,11 @@ export default async function Page({
return ( return (
<PageLayout title="Overview" organizationSlug={organizationId}> <PageLayout title="Overview" organizationSlug={organizationId}>
<Sheet>
<StickyBelowHeader> <StickyBelowHeader>
<div className="p-4 flex gap-2 justify-between"> <div className="p-4 flex gap-2 justify-between">
<div className="flex gap-2"> <div className="flex gap-2">
<OverviewReportRange /> <OverviewReportRange />
<OverviewFilterSheetTrigger /> <OverviewFiltersDrawer projectId={projectId} />
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<ServerLiveCounter projectId={projectId} /> <ServerLiveCounter projectId={projectId} />
@@ -69,10 +64,6 @@ export default async function Page({
<OverviewTopGeo projectId={projectId} /> <OverviewTopGeo projectId={projectId} />
</div> </div>
</div> </div>
<SheetContent className="!max-w-lg w-full" side="right">
<OverviewFilters projectId={projectId} />
</SheetContent>
</Sheet>
</PageLayout> </PageLayout>
); );
} }

View File

@@ -1,20 +1,16 @@
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header'; import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
import OverviewMetrics from '@/app/(app)/[organizationId]/[projectId]/overview-metrics'; import OverviewMetrics from '@/app/(app)/[organizationId]/[projectId]/overview-metrics';
import { import { OverviewReportRange } from '@/app/(app)/[organizationId]/[projectId]/overview-sticky-header';
OverviewFilterSheetTrigger,
OverviewReportRange,
} from '@/app/(app)/[organizationId]/[projectId]/overview-sticky-header';
import { Logo } from '@/components/Logo'; import { Logo } from '@/components/Logo';
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import ServerLiveCounter from '@/components/overview/live-counter'; import ServerLiveCounter from '@/components/overview/live-counter';
import { OverviewFilters } from '@/components/overview/overview-filters';
import { OverviewFiltersButtons } from '@/components/overview/overview-filters-buttons';
import { OverviewLiveHistogram } from '@/components/overview/overview-live-histogram'; import { OverviewLiveHistogram } from '@/components/overview/overview-live-histogram';
import OverviewTopDevices from '@/components/overview/overview-top-devices'; import OverviewTopDevices from '@/components/overview/overview-top-devices';
import OverviewTopEvents from '@/components/overview/overview-top-events'; import OverviewTopEvents from '@/components/overview/overview-top-events';
import OverviewTopGeo from '@/components/overview/overview-top-geo'; import OverviewTopGeo from '@/components/overview/overview-top-geo';
import OverviewTopPages from '@/components/overview/overview-top-pages'; import OverviewTopPages from '@/components/overview/overview-top-pages';
import OverviewTopSources from '@/components/overview/overview-top-sources'; import OverviewTopSources from '@/components/overview/overview-top-sources';
import { Sheet, SheetContent } from '@/components/ui/sheet';
import { getOrganizationBySlug } from '@/server/services/organization.service'; import { getOrganizationBySlug } from '@/server/services/organization.service';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
@@ -49,12 +45,11 @@ export default async function Page({ params: { id } }: PageProps) {
</a> </a>
</div> </div>
<div className="bg-white rounded-lg shadow ring-8 ring-blue-600/50"> <div className="bg-white rounded-lg shadow ring-8 ring-blue-600/50">
<Sheet>
<StickyBelowHeader> <StickyBelowHeader>
<div className="p-4 flex gap-2 justify-between"> <div className="p-4 flex gap-2 justify-between">
<div className="flex gap-2"> <div className="flex gap-2">
<OverviewReportRange /> <OverviewReportRange />
<OverviewFilterSheetTrigger /> <OverviewFiltersDrawer projectId={projectId} />
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<ServerLiveCounter projectId={projectId} /> <ServerLiveCounter projectId={projectId} />
@@ -75,10 +70,6 @@ export default async function Page({ params: { id } }: PageProps) {
<OverviewTopGeo projectId={projectId} /> <OverviewTopGeo projectId={projectId} />
</div> </div>
</div> </div>
<SheetContent className="!max-w-lg w-full" side="right">
<OverviewFilters projectId={projectId} />
</SheetContent>
</Sheet>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,34 @@
'use client';
import { Button } from '@/components/ui/button';
import {
useEventFilters,
useEventQueryFilters,
} from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { X } from 'lucide-react';
export function OverviewFiltersButtons() {
const eventQueryFilters = useEventQueryFilters();
const filters = Object.entries(eventQueryFilters).filter(
([, filter]) => filter.get !== null
);
return (
<div
className={cn('flex flex-wrap gap-2', filters.length > 0 && 'px-4 pb-4')}
>
{filters.map(([key, filter]) => (
<Button
key={key}
size="sm"
variant="outline"
icon={X}
onClick={() => filter.set(null)}
>
<span className="mr-1">{key} is</span>
<strong>{filter.get}</strong>
</Button>
))}
</div>
);
}

View File

@@ -0,0 +1,92 @@
'use client';
import { api } from '@/app/_trpc/client';
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { XIcon } from 'lucide-react';
interface OverviewFiltersProps {
projectId: string;
}
export function OverviewFiltersDrawerContent({
projectId,
}: OverviewFiltersProps) {
const eventQueryFilters = useEventQueryFilters();
return (
<div>
<h2 className="text-xl font-medium mb-8">Overview filters</h2>
<Combobox
className="w-full"
onChange={(value) => {
// @ts-expect-error
eventQueryFilters[value].set('');
}}
value=""
placeholder="Filter by..."
label="What do you want to filter by?"
items={Object.entries(eventQueryFilters)
.filter(([, filter]) => filter.get === null)
.map(([name]) => ({
label: name,
value: name,
}))}
searchable
/>
<div className="flex flex-col gap-4 mt-8">
{Object.entries(eventQueryFilters)
.filter(([, filter]) => filter.get !== null)
.map(([name, filter]) => (
<FilterOption
key={name}
projectId={projectId}
name={name}
{...filter}
/>
))}
</div>
</div>
);
}
export function FilterOption({
name,
get,
set,
projectId,
}: {
name: string;
get: string | null;
set: (value: string | null) => void;
projectId: string;
}) {
const { data } = api.chart.values.useQuery({
projectId,
event: name === 'path' ? 'screen_view' : 'session_start',
property: name,
});
return (
<div className="flex gap-2 items-center">
<div>{name}</div>
<Combobox
className="flex-1"
onChange={(value) => set(value)}
placeholder={'Select a value'}
items={
data?.values.filter(Boolean).map((value) => ({
value,
label: value,
})) ?? []
}
value={get}
/>
<Button size="icon" variant="ghost" onClick={() => set(null)}>
<XIcon />
</Button>
</div>
);
}

View File

@@ -0,0 +1,28 @@
'use client';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { FilterIcon } from 'lucide-react';
import { OverviewFiltersDrawerContent } from './overview-filters-drawer-content';
interface OverviewFiltersDrawerProps {
projectId: string;
}
export function OverviewFiltersDrawer({
projectId,
}: OverviewFiltersDrawerProps) {
return (
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" responsive icon={FilterIcon}>
Filters
</Button>
</SheetTrigger>
<SheetContent className="!max-w-lg w-full" side="right">
<OverviewFiltersDrawerContent projectId={projectId} />
</SheetContent>
</Sheet>
);
}

View File

@@ -1,204 +0,0 @@
'use client';
import { cn } from '@/utils/cn';
import { X } from 'lucide-react';
import { Button } from '../ui/button';
import { useOverviewOptions } from './useOverviewOptions';
export function OverviewFiltersButtons() {
const options = useOverviewOptions();
const activeFilter = options.filters.length > 0;
return (
<div className={cn('flex flex-wrap gap-2', activeFilter && 'px-4 pb-4')}>
{options.referrer && (
<Button
size="sm"
variant="outline"
icon={X}
onClick={() => options.setReferrer(null)}
>
<span className="mr-1">Referrer is</span>
<strong>{options.referrer}</strong>
</Button>
)}
{options.referrerName && (
<Button
size="sm"
variant="outline"
icon={X}
onClick={() => options.setReferrerName(null)}
>
<span className="mr-1">Referrer name is</span>
<strong>{options.referrerName}</strong>
</Button>
)}
{options.referrerType && (
<Button
size="sm"
variant="outline"
icon={X}
onClick={() => options.setReferrerType(null)}
>
<span className="mr-1">Referrer type is</span>
<strong>{options.referrerType}</strong>
</Button>
)}
{options.device && (
<Button
size="sm"
variant="outline"
icon={X}
onClick={() => options.setDevice(null)}
>
<span className="mr-1">Device is</span>
<strong>{options.device}</strong>
</Button>
)}
{options.page && (
<Button
size="sm"
variant="outline"
icon={X}
onClick={() => options.setPage(null)}
>
<span className="mr-1">Page is</span>
<strong>{options.page}</strong>
</Button>
)}
{options.utmSource && (
<Button
size="sm"
variant="outline"
icon={X}
onClick={() => options.setUtmSource(null)}
>
<span className="mr-1">Utm Source is</span>
<strong>{options.utmSource}</strong>
</Button>
)}
{options.utmMedium && (
<Button
size="sm"
variant="outline"
icon={X}
onClick={() => options.setUtmMedium(null)}
>
<span className="mr-1">Utm Medium is</span>
<strong>{options.utmMedium}</strong>
</Button>
)}
{options.utmCampaign && (
<Button
size="sm"
variant="outline"
icon={X}
onClick={() => options.setUtmCampaign(null)}
>
<span className="mr-1">Utm Campaign is</span>
<strong>{options.utmCampaign}</strong>
</Button>
)}
{options.utmTerm && (
<Button
size="sm"
variant="outline"
icon={X}
onClick={() => options.setUtmTerm(null)}
>
<span className="mr-1">Utm Term is</span>
<strong>{options.utmTerm}</strong>
</Button>
)}
{options.utmContent && (
<Button
size="sm"
variant="outline"
icon={X}
onClick={() => options.setUtmContent(null)}
>
<span className="mr-1">Utm Content is</span>
<strong>{options.utmContent}</strong>
</Button>
)}
{options.country && (
<Button
size="sm"
variant="outline"
icon={X}
onClick={() => options.setCountry(null)}
>
<span className="mr-1">Country is</span>
<strong>{options.country}</strong>
</Button>
)}
{options.region && (
<Button
size="sm"
variant="outline"
icon={X}
onClick={() => options.setRegion(null)}
>
<span className="mr-1">Region is</span>
<strong>{options.region}</strong>
</Button>
)}
{options.city && (
<Button
size="sm"
variant="outline"
icon={X}
onClick={() => options.setCity(null)}
>
<span className="mr-1">City is</span>
<strong>{options.city}</strong>
</Button>
)}
{options.browser && (
<Button
size="sm"
variant="outline"
icon={X}
onClick={() => options.setBrowser(null)}
>
<span className="mr-1">Browser is</span>
<strong>{options.browser}</strong>
</Button>
)}
{options.browserVersion && (
<Button
size="sm"
variant="outline"
icon={X}
onClick={() => options.setBrowserVersion(null)}
>
<span className="mr-1">Browser Version is</span>
<strong>{options.browserVersion}</strong>
</Button>
)}
{options.os && (
<Button
size="sm"
variant="outline"
icon={X}
onClick={() => options.setOS(null)}
>
<span className="mr-1">OS is</span>
<strong>{options.os}</strong>
</Button>
)}
{options.osVersion && (
<Button
size="sm"
variant="outline"
icon={X}
onClick={() => options.setOSVersion(null)}
>
<span className="mr-1">OS Version is</span>
<strong>{options.osVersion}</strong>
</Button>
)}
</div>
);
}

View File

@@ -1,123 +0,0 @@
'use client';
import { api } from '@/app/_trpc/client';
import { useAppParams } from '@/hooks/useAppParams';
import { cn } from '@/utils/cn';
import { Combobox } from '../ui/combobox';
import { Label } from '../ui/label';
import { useOverviewOptions } from './useOverviewOptions';
interface OverviewFiltersProps {
projectId: string;
}
export function OverviewFilters({ projectId }: OverviewFiltersProps) {
const options = useOverviewOptions();
const { data: referrers } = api.chart.values.useQuery({
projectId,
property: 'referrer',
event: 'session_start',
});
const { data: devices } = api.chart.values.useQuery({
projectId,
property: 'device',
event: 'session_start',
});
const { data: pages } = api.chart.values.useQuery({
projectId,
property: 'path',
event: 'screen_view',
});
return (
<div>
<h2 className="text-xl font-medium mb-8">Overview filters</h2>
<div className="flex flex-col gap-4">
<div>
<Label className="flex justify-between">
Referrer
<button
className={cn(
'text-slate-500 transition-opacity opacity-100',
options.referrer === null && 'opacity-0'
)}
onClick={() => options.setReferrer(null)}
>
Reset
</button>
</Label>
<Combobox
className="w-full"
onChange={(value) => options.setReferrer(value)}
label="Referrer"
placeholder="Referrer"
items={
referrers?.values?.filter(Boolean)?.map((value) => ({
value,
label: value,
})) ?? []
}
value={options.referrer}
/>
</div>
<div>
<Label className="flex justify-between">
Device
<button
className={cn(
'opacity-100 text-slate-500 transition-opacity',
options.device === null && 'opacity-0'
)}
onClick={() => options.setDevice(null)}
>
Reset
</button>
</Label>
<Combobox
className="w-full"
onChange={(value) => options.setDevice(value)}
label="Device"
placeholder="Device"
items={
devices?.values?.filter(Boolean)?.map((value) => ({
value,
label: value,
})) ?? []
}
value={options.device}
/>
</div>
<div>
<Label className="flex justify-between">
Page
<button
className={cn(
'opacity-100 text-slate-500 transition-opacity',
options.page === null && 'opacity-0'
)}
onClick={() => options.setPage(null)}
>
Reset
</button>
</Label>
<Combobox
className="w-full"
onChange={(value) => options.setPage(value)}
label="Page"
placeholder="Page"
items={
pages?.values?.filter(Boolean)?.map((value) => ({
value,
label: value,
})) ?? []
}
value={options.page}
/>
</div>
</div>
</div>
);
}

View File

@@ -1,6 +1,10 @@
'use client'; 'use client';
import { Chart } from '@/components/report/chart'; import { Chart } from '@/components/report/chart';
import {
useEventFilters,
useEventQueryFilters,
} from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget'; import { Widget, WidgetBody } from '../Widget';
@@ -14,17 +18,10 @@ interface OverviewTopDevicesProps {
export default function OverviewTopDevices({ export default function OverviewTopDevices({
projectId, projectId,
}: OverviewTopDevicesProps) { }: OverviewTopDevicesProps) {
const { const { interval, range, previous } = useOverviewOptions();
filters, const filters = useEventFilters();
interval, const { device, browser, browserVersion, os, osVersion } =
range, useEventQueryFilters();
previous,
setBrowser,
setBrowserVersion,
setOS,
setOSVersion,
setDevice,
} = useOverviewOptions();
const [widget, setWidget, widgets] = useOverviewWidget('tech', { const [widget, setWidget, widgets] = useOverviewWidget('tech', {
devices: { devices: {
title: 'Top devices', title: 'Top devices',
@@ -193,21 +190,21 @@ export default function OverviewTopDevices({
onClick={(item) => { onClick={(item) => {
switch (widget.key) { switch (widget.key) {
case 'devices': case 'devices':
setDevice(item.name); device.set(item.name);
break; break;
case 'browser': case 'browser':
setWidget('browser_version'); setWidget('browser_version');
setBrowser(item.name); browser.set(item.name);
break; break;
case 'browser_version': case 'browser_version':
setBrowserVersion(item.name); browserVersion.set(item.name);
break; break;
case 'os': case 'os':
setWidget('os_version'); setWidget('os_version');
setOS(item.name); os.set(item.name);
break; break;
case 'os_version': case 'os_version':
setOSVersion(item.name); osVersion.set(item.name);
break; break;
} }
}} }}

View File

@@ -1,8 +1,7 @@
'use client'; 'use client';
import { Suspense } from 'react';
import { Chart } from '@/components/report/chart'; import { Chart } from '@/components/report/chart';
import { ChartLoading } from '@/components/report/chart/ChartLoading'; import { useEventFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget'; import { Widget, WidgetBody } from '../Widget';
@@ -16,7 +15,8 @@ interface OverviewTopEventsProps {
export default function OverviewTopEvents({ export default function OverviewTopEvents({
projectId, projectId,
}: OverviewTopEventsProps) { }: OverviewTopEventsProps) {
const { filters, interval, range, previous } = useOverviewOptions(); const { interval, range, previous } = useOverviewOptions();
const filters = useEventFilters();
const [widget, setWidget, widgets] = useOverviewWidget('ev', { const [widget, setWidget, widgets] = useOverviewWidget('ev', {
all: { all: {
title: 'Top events', title: 'Top events',

View File

@@ -1,8 +1,10 @@
'use client'; 'use client';
import { Suspense } from 'react';
import { Chart } from '@/components/report/chart'; import { Chart } from '@/components/report/chart';
import { ChartLoading } from '@/components/report/chart/ChartLoading'; import {
useEventFilters,
useEventQueryFilters,
} from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget'; import { Widget, WidgetBody } from '../Widget';
@@ -14,8 +16,9 @@ interface OverviewTopGeoProps {
projectId: string; projectId: string;
} }
export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) { export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
const { filters, interval, range, previous, setCountry, setRegion, setCity } = const { interval, range, previous } = useOverviewOptions();
useOverviewOptions(); const filters = useEventFilters();
const { region, country, city } = useEventQueryFilters();
const [widget, setWidget, widgets] = useOverviewWidget('geo', { const [widget, setWidget, widgets] = useOverviewWidget('geo', {
map: { map: {
title: 'Map', title: 'Map',
@@ -157,14 +160,14 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
switch (widget.key) { switch (widget.key) {
case 'countries': case 'countries':
setWidget('regions'); setWidget('regions');
setCountry(item.name); country.set(item.name);
break; break;
case 'regions': case 'regions':
setWidget('cities'); setWidget('cities');
setRegion(item.name); region.set(item.name);
break; break;
case 'cities': case 'cities':
setCity(item.name); city.set(item.name);
break; break;
} }
}} }}

View File

@@ -1,8 +1,10 @@
'use client'; 'use client';
import { Suspense } from 'react';
import { Chart } from '@/components/report/chart'; import { Chart } from '@/components/report/chart';
import { ChartLoading } from '@/components/report/chart/ChartLoading'; import {
useEventFilters,
useEventQueryFilters,
} from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget'; import { Widget, WidgetBody } from '../Widget';
@@ -14,7 +16,9 @@ interface OverviewTopPagesProps {
projectId: string; projectId: string;
} }
export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) { export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
const { filters, interval, range, previous, setPage } = useOverviewOptions(); const { interval, range, previous } = useOverviewOptions();
const filters = useEventFilters();
const { path } = useEventQueryFilters();
const [widget, setWidget, widgets] = useOverviewWidget('pages', { const [widget, setWidget, widgets] = useOverviewWidget('pages', {
top: { top: {
title: 'Top pages', title: 'Top pages',
@@ -125,7 +129,7 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
{...widget.chart} {...widget.chart}
previous={false} previous={false}
onClick={(item) => { onClick={(item) => {
setPage(item.name); path.set(item.name);
}} }}
/> />
</WidgetBody> </WidgetBody>

View File

@@ -1,8 +1,10 @@
'use client'; 'use client';
import { Suspense } from 'react';
import { Chart } from '@/components/report/chart'; import { Chart } from '@/components/report/chart';
import { ChartLoading } from '@/components/report/chart/ChartLoading'; import {
useEventFilters,
useEventQueryFilters,
} from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget'; import { Widget, WidgetBody } from '../Widget';
@@ -16,20 +18,18 @@ interface OverviewTopSourcesProps {
export default function OverviewTopSources({ export default function OverviewTopSources({
projectId, projectId,
}: OverviewTopSourcesProps) { }: OverviewTopSourcesProps) {
const { interval, range, previous } = useOverviewOptions();
const { const {
filters, referrer,
interval, referrerName,
range, referrerType,
previous, utmCampaign,
setReferrer, utmContent,
setUtmSource, utmMedium,
setUtmMedium, utmSource,
setUtmCampaign, utmTerm,
setUtmTerm, } = useEventQueryFilters();
setUtmContent, const filters = useEventFilters();
setReferrerName,
setReferrerType,
} = useOverviewOptions();
const [widget, setWidget, widgets] = useOverviewWidget('sources', { const [widget, setWidget, widgets] = useOverviewWidget('sources', {
all: { all: {
title: 'Top sources', title: 'Top sources',
@@ -282,30 +282,30 @@ export default function OverviewTopSources({
onClick={(item) => { onClick={(item) => {
switch (widget.key) { switch (widget.key) {
case 'all': case 'all':
setReferrerName(item.name); referrerName.set(item.name);
setWidget('domain'); setWidget('domain');
break; break;
case 'domain': case 'domain':
setReferrer(item.name); referrer.set(item.name);
break; break;
case 'type': case 'type':
setReferrerType(item.name); referrerType.set(item.name);
setWidget('domain'); setWidget('domain');
break; break;
case 'utm_source': case 'utm_source':
setUtmSource(item.name); utmSource.set(item.name);
break; break;
case 'utm_medium': case 'utm_medium':
setUtmMedium(item.name); utmMedium.set(item.name);
break; break;
case 'utm_campaign': case 'utm_campaign':
setUtmCampaign(item.name); utmCampaign.set(item.name);
break; break;
case 'utm_term': case 'utm_term':
setUtmTerm(item.name); utmTerm.set(item.name);
break; break;
case 'utm_content': case 'utm_content':
setUtmContent(item.name); utmContent.set(item.name);
break; break;
} }
}} }}

View File

@@ -1,11 +1,9 @@
import { useMemo } from 'react'; import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import type { IChartInput } from '@/types';
import { getDefaultIntervalByRange, timeRanges } from '@/utils/constants'; import { getDefaultIntervalByRange, timeRanges } from '@/utils/constants';
import { mapKeys } from '@/utils/validation'; import { mapKeys } from '@/utils/validation';
import { import {
parseAsBoolean, parseAsBoolean,
parseAsInteger, parseAsInteger,
parseAsString,
parseAsStringEnum, parseAsStringEnum,
useQueryState, useQueryState,
} from 'nuqs'; } from 'nuqs';
@@ -29,267 +27,12 @@ export function useOverviewOptions() {
parseAsInteger.withDefault(0).withOptions(nuqsOptions) parseAsInteger.withDefault(0).withOptions(nuqsOptions)
); );
// Filters
const [page, setPage] = useQueryState(
'page',
parseAsString.withOptions(nuqsOptions)
);
// Referrer
const [referrer, setReferrer] = useQueryState(
'referrer',
parseAsString.withOptions(nuqsOptions)
);
const [referrerName, setReferrerName] = useQueryState(
'referrer_name',
parseAsString.withOptions(nuqsOptions)
);
const [referrerType, setReferrerType] = useQueryState(
'referrer_type',
parseAsString.withOptions(nuqsOptions)
);
// Sources
const [utmSource, setUtmSource] = useQueryState(
'utm_source',
parseAsString.withOptions(nuqsOptions)
);
const [utmMedium, setUtmMedium] = useQueryState(
'utm_medium',
parseAsString.withOptions(nuqsOptions)
);
const [utmCampaign, setUtmCampaign] = useQueryState(
'utm_campaign',
parseAsString.withOptions(nuqsOptions)
);
const [utmContent, setUtmContent] = useQueryState(
'utm_content',
parseAsString.withOptions(nuqsOptions)
);
const [utmTerm, setUtmTerm] = useQueryState(
'utm_term',
parseAsString.withOptions(nuqsOptions)
);
// Geo
const [country, setCountry] = useQueryState(
'country',
parseAsString.withOptions(nuqsOptions)
);
const [region, setRegion] = useQueryState(
'region',
parseAsString.withOptions(nuqsOptions)
);
const [city, setCity] = useQueryState(
'city',
parseAsString.withOptions(nuqsOptions)
);
//
const [device, setDevice] = useQueryState(
'device',
parseAsString.withOptions(nuqsOptions)
);
const [browser, setBrowser] = useQueryState(
'browser',
parseAsString.withOptions(nuqsOptions)
);
const [browserVersion, setBrowserVersion] = useQueryState(
'browser_version',
parseAsString.withOptions(nuqsOptions)
);
const [os, setOS] = useQueryState(
'os',
parseAsString.withOptions(nuqsOptions)
);
const [osVersion, setOSVersion] = useQueryState(
'os_version',
parseAsString.withOptions(nuqsOptions)
);
// Toggles // Toggles
const [liveHistogram, setLiveHistogram] = useQueryState( const [liveHistogram, setLiveHistogram] = useQueryState(
'live', 'live',
parseAsBoolean.withDefault(false).withOptions(nuqsOptions) parseAsBoolean.withDefault(false).withOptions(nuqsOptions)
); );
const filters = useMemo(() => {
const filters: IChartInput['events'][number]['filters'] = [];
if (page) {
filters.push({
id: 'path',
operator: 'is',
name: 'path',
value: [page],
});
}
if (device) {
filters.push({
id: 'device',
operator: 'is',
name: 'device',
value: [device],
});
}
if (referrer) {
filters.push({
id: 'referrer',
operator: 'is',
name: 'referrer',
value: [referrer],
});
}
if (referrerName) {
filters.push({
id: 'referrer_name',
operator: 'is',
name: 'referrer_name',
value: [referrerName],
});
}
if (referrerType) {
filters.push({
id: 'referrer_type',
operator: 'is',
name: 'referrer_type',
value: [referrerType],
});
}
if (utmSource) {
filters.push({
id: 'utm_source',
operator: 'is',
name: 'properties.query.utm_source',
value: [utmSource],
});
}
if (utmMedium) {
filters.push({
id: 'utm_medium',
operator: 'is',
name: 'properties.query.utm_medium',
value: [utmMedium],
});
}
if (utmCampaign) {
filters.push({
id: 'utm_campaign',
operator: 'is',
name: 'properties.query.utm_campaign',
value: [utmCampaign],
});
}
if (utmContent) {
filters.push({
id: 'utm_content',
operator: 'is',
name: 'properties.query.utm_content',
value: [utmContent],
});
}
if (utmTerm) {
filters.push({
id: 'utm_term',
operator: 'is',
name: 'properties.query.utm_term',
value: [utmTerm],
});
}
if (country) {
filters.push({
id: 'country',
operator: 'is',
name: 'country',
value: [country],
});
}
if (region) {
filters.push({
id: 'region',
operator: 'is',
name: 'region',
value: [region],
});
}
if (city) {
filters.push({
id: 'city',
operator: 'is',
name: 'city',
value: [city],
});
}
if (browser) {
filters.push({
id: 'browser',
operator: 'is',
name: 'browser',
value: [browser],
});
}
if (browserVersion) {
filters.push({
id: 'browser_version',
operator: 'is',
name: 'browser_version',
value: [browserVersion],
});
}
if (os) {
filters.push({
id: 'os',
operator: 'is',
name: 'os',
value: [os],
});
}
if (osVersion) {
filters.push({
id: 'os_version',
operator: 'is',
name: 'os_version',
value: [osVersion],
});
}
return filters;
}, [
page,
device,
referrer,
referrerName,
referrerType,
utmSource,
utmMedium,
utmCampaign,
utmContent,
utmTerm,
country,
region,
city,
browser,
browserVersion,
os,
osVersion,
]);
return { return {
previous, previous,
setPrevious, setPrevious,
@@ -297,52 +40,9 @@ export function useOverviewOptions() {
setRange, setRange,
metric, metric,
setMetric, setMetric,
page,
setPage,
// Computed // Computed
interval, interval,
filters,
// Refs
referrer,
setReferrer,
referrerName,
setReferrerName,
referrerType,
setReferrerType,
// UTM
utmSource,
setUtmSource,
utmMedium,
setUtmMedium,
utmCampaign,
setUtmCampaign,
utmContent,
setUtmContent,
utmTerm,
setUtmTerm,
// GEO
country,
setCountry,
region,
setRegion,
city,
setCity,
// Tech
device,
setDevice,
browser,
setBrowser,
browserVersion,
setBrowserVersion,
os,
setOS,
osVersion,
setOSVersion,
// Toggles // Toggles
liveHistogram, liveHistogram,

View File

@@ -13,13 +13,16 @@ export function KeyValue({ href, onClick, name, value }: KeyValueProps) {
const Component = href ? (Link as any) : onClick ? 'button' : 'div'; const Component = href ? (Link as any) : onClick ? 'button' : 'div';
return ( return (
<Component <Component
className="group flex border border-border rounded-md text-xs divide-x font-medium self-start min-w-0 max-w-full" className={cn(
'group overflow-hidden flex border border-border rounded-md text-xs divide-x font-medium self-start min-w-0 max-w-full transition-transform',
clickable && 'hover:-translate-y-0.5'
)}
{...{ href, onClick }} {...{ href, onClick }}
> >
<div className="p-1 px-2">{name}</div> <div className="p-1 px-2 bg-slate-50">{name}</div>
<div <div
className={cn( className={cn(
'p-1 px-2 font-mono text-blue-700 bg-slate-50 whitespace-nowrap overflow-hidden text-ellipsis', 'p-1 px-2 font-mono text-blue-700 bg-white whitespace-nowrap overflow-hidden text-ellipsis shadow-[inset_0_0_0_1px_#fff]',
clickable && 'group-hover:underline' clickable && 'group-hover:underline'
)} )}
> >

View File

@@ -0,0 +1,12 @@
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
export function useCursor() {
const [cursor, setCursor] = useQueryState(
'cursor',
parseAsIsoDateTime.withOptions({ shallow: false })
);
return {
cursor,
setCursor,
};
}

View File

@@ -0,0 +1,311 @@
import { useMemo } from 'react';
import type { IChartInput } from '@/types';
import { parseAsString, useQueryState } from 'nuqs';
const nuqsOptions = { history: 'push' } as const;
export function useEventQueryFiltersqweqweqweqweqwe() {
// Path
const [path, setPath] = useQueryState(
'path',
parseAsString.withOptions(nuqsOptions)
);
// Referrer
const [referrer, setReferrer] = useQueryState(
'referrer',
parseAsString.withOptions(nuqsOptions)
);
const [referrerName, setReferrerName] = useQueryState(
'referrer_name',
parseAsString.withOptions(nuqsOptions)
);
const [referrerType, setReferrerType] = useQueryState(
'referrer_type',
parseAsString.withOptions(nuqsOptions)
);
// Sources
const [utmSource, setUtmSource] = useQueryState(
'utm_source',
parseAsString.withOptions(nuqsOptions)
);
const [utmMedium, setUtmMedium] = useQueryState(
'utm_medium',
parseAsString.withOptions(nuqsOptions)
);
const [utmCampaign, setUtmCampaign] = useQueryState(
'utm_campaign',
parseAsString.withOptions(nuqsOptions)
);
const [utmContent, setUtmContent] = useQueryState(
'utm_content',
parseAsString.withOptions(nuqsOptions)
);
const [utmTerm, setUtmTerm] = useQueryState(
'utm_term',
parseAsString.withOptions(nuqsOptions)
);
// Geo
const [country, setCountry] = useQueryState(
'country',
parseAsString.withOptions(nuqsOptions)
);
const [region, setRegion] = useQueryState(
'region',
parseAsString.withOptions(nuqsOptions)
);
const [city, setCity] = useQueryState(
'city',
parseAsString.withOptions(nuqsOptions)
);
// tech
const [device, setDevice] = useQueryState(
'device',
parseAsString.withOptions(nuqsOptions)
);
const [browser, setBrowser] = useQueryState(
'browser',
parseAsString.withOptions(nuqsOptions)
);
const [browserVersion, setBrowserVersion] = useQueryState(
'browser_version',
parseAsString.withOptions(nuqsOptions)
);
const [os, setOS] = useQueryState(
'os',
parseAsString.withOptions(nuqsOptions)
);
const [osVersion, setOSVersion] = useQueryState(
'os_version',
parseAsString.withOptions(nuqsOptions)
);
const filters = useMemo(() => {
const filters: IChartInput['events'][number]['filters'] = [];
if (path) {
filters.push({
id: 'path',
operator: 'is',
name: 'path',
value: [path],
});
}
if (device) {
filters.push({
id: 'device',
operator: 'is',
name: 'device',
value: [device],
});
}
if (referrer) {
filters.push({
id: 'referrer',
operator: 'is',
name: 'referrer',
value: [referrer],
});
}
if (referrerName) {
filters.push({
id: 'referrer_name',
operator: 'is',
name: 'referrer_name',
value: [referrerName],
});
}
if (referrerType) {
filters.push({
id: 'referrer_type',
operator: 'is',
name: 'referrer_type',
value: [referrerType],
});
}
if (utmSource) {
filters.push({
id: 'utm_source',
operator: 'is',
name: 'properties.query.utm_source',
value: [utmSource],
});
}
if (utmMedium) {
filters.push({
id: 'utm_medium',
operator: 'is',
name: 'properties.query.utm_medium',
value: [utmMedium],
});
}
if (utmCampaign) {
filters.push({
id: 'utm_campaign',
operator: 'is',
name: 'properties.query.utm_campaign',
value: [utmCampaign],
});
}
if (utmContent) {
filters.push({
id: 'utm_content',
operator: 'is',
name: 'properties.query.utm_content',
value: [utmContent],
});
}
if (utmTerm) {
filters.push({
id: 'utm_term',
operator: 'is',
name: 'properties.query.utm_term',
value: [utmTerm],
});
}
if (country) {
filters.push({
id: 'country',
operator: 'is',
name: 'country',
value: [country],
});
}
if (region) {
filters.push({
id: 'region',
operator: 'is',
name: 'region',
value: [region],
});
}
if (city) {
filters.push({
id: 'city',
operator: 'is',
name: 'city',
value: [city],
});
}
if (browser) {
filters.push({
id: 'browser',
operator: 'is',
name: 'browser',
value: [browser],
});
}
if (browserVersion) {
filters.push({
id: 'browser_version',
operator: 'is',
name: 'browser_version',
value: [browserVersion],
});
}
if (os) {
filters.push({
id: 'os',
operator: 'is',
name: 'os',
value: [os],
});
}
if (osVersion) {
filters.push({
id: 'os_version',
operator: 'is',
name: 'os_version',
value: [osVersion],
});
}
return filters;
}, [
path,
device,
referrer,
referrerName,
referrerType,
utmSource,
utmMedium,
utmCampaign,
utmContent,
utmTerm,
country,
region,
city,
browser,
browserVersion,
os,
osVersion,
]);
return {
// Computed
filters,
// Path
path,
setPath,
// Refs
referrer,
setReferrer,
referrerName,
setReferrerName,
referrerType,
setReferrerType,
// UTM
utmSource,
setUtmSource,
utmMedium,
setUtmMedium,
utmCampaign,
setUtmCampaign,
utmContent,
setUtmContent,
utmTerm,
setUtmTerm,
// GEO
country,
setCountry,
region,
setRegion,
city,
setCity,
// Tech
device,
setDevice,
browser,
setBrowser,
browserVersion,
setBrowserVersion,
os,
setOS,
osVersion,
setOSVersion,
};
}

View File

@@ -0,0 +1,227 @@
import { useMemo } from 'react';
import type { IChartInput } from '@/types';
// prettier-ignore
import type { UseQueryStateReturn } from 'nuqs';
import { parseAsString, useQueryState } from 'nuqs';
const nuqsOptions = { history: 'push' } as const;
function useFix<T>(hook: UseQueryStateReturn<T, undefined>) {
return useMemo(
() => ({
get: hook[0],
set: hook[1],
}),
[hook]
);
}
export function useEventQueryFilters() {
// Ignore prettier so that we have all one same line
// prettier-ignore
return {
path: useFix(useQueryState('path', parseAsString.withOptions(nuqsOptions))),
referrer: useFix(useQueryState('referrer', parseAsString.withOptions(nuqsOptions))),
referrerName: useFix(useQueryState('referrerName',parseAsString.withOptions(nuqsOptions))),
referrerType: useFix(useQueryState('referrerType',parseAsString.withOptions(nuqsOptions))),
utmSource: useFix(useQueryState('utmSource',parseAsString.withOptions(nuqsOptions))),
utmMedium: useFix(useQueryState('utmMedium',parseAsString.withOptions(nuqsOptions))),
utmCampaign: useFix(useQueryState('utmCampaign',parseAsString.withOptions(nuqsOptions))),
utmContent: useFix(useQueryState('utmContent',parseAsString.withOptions(nuqsOptions))),
utmTerm: useFix(useQueryState('utmTerm', parseAsString.withOptions(nuqsOptions))),
country: useFix(useQueryState('country', parseAsString.withOptions(nuqsOptions))),
region: useFix(useQueryState('region', parseAsString.withOptions(nuqsOptions))),
city: useFix(useQueryState('city', parseAsString.withOptions(nuqsOptions))),
device: useFix(useQueryState('device', parseAsString.withOptions(nuqsOptions))),
browser: useFix(useQueryState('browser', parseAsString.withOptions(nuqsOptions))),
browserVersion: useFix(useQueryState('browserVersion',parseAsString.withOptions(nuqsOptions))),
os: useFix(useQueryState('os', parseAsString.withOptions(nuqsOptions))),
osVersion: useFix(useQueryState('osVersion',parseAsString.withOptions(nuqsOptions))),
} as const;
}
export function useEventFilters() {
const hej = useEventQueryFilters();
const filters = useMemo(() => {
const filters: IChartInput['events'][number]['filters'] = [];
if (hej.path.get) {
filters.push({
id: 'path',
operator: 'is',
name: 'path' as const,
value: [hej.path.get],
});
}
if (hej.device.get) {
filters.push({
id: 'device',
operator: 'is',
name: 'device' as const,
value: [hej.device.get],
});
}
if (hej.referrer.get) {
filters.push({
id: 'referrer',
operator: 'is',
name: 'referrer' as const,
value: [hej.referrer.get],
});
}
console.log('hej.referrerName.get', hej.referrerName.get);
if (hej.referrerName.get) {
filters.push({
id: 'referrerName',
operator: 'is',
name: 'referrer_name' as const,
value: [hej.referrerName.get],
});
}
if (hej.referrerType.get) {
filters.push({
id: 'referrerType',
operator: 'is',
name: 'referrer_type' as const,
value: [hej.referrerType.get],
});
}
if (hej.utmSource.get) {
filters.push({
id: 'utmSource',
operator: 'is',
name: 'properties.query.utm_source' as const,
value: [hej.utmSource.get],
});
}
if (hej.utmMedium.get) {
filters.push({
id: 'utmMedium',
operator: 'is',
name: 'properties.query.utm_medium' as const,
value: [hej.utmMedium.get],
});
}
if (hej.utmCampaign.get) {
filters.push({
id: 'utmCampaign',
operator: 'is',
name: 'properties.query.utm_campaign' as const,
value: [hej.utmCampaign.get],
});
}
if (hej.utmContent.get) {
filters.push({
id: 'utmContent',
operator: 'is',
name: 'properties.query.utm_content' as const,
value: [hej.utmContent.get],
});
}
if (hej.utmTerm.get) {
filters.push({
id: 'utmTerm',
operator: 'is',
name: 'properties.query.utm_term' as const,
value: [hej.utmTerm.get],
});
}
if (hej.country.get) {
filters.push({
id: 'country',
operator: 'is',
name: 'country' as const,
value: [hej.country.get],
});
}
if (hej.region.get) {
filters.push({
id: 'region',
operator: 'is',
name: 'region' as const,
value: [hej.region.get],
});
}
if (hej.city.get) {
filters.push({
id: 'city',
operator: 'is',
name: 'city' as const,
value: [hej.city.get],
});
}
if (hej.browser.get) {
filters.push({
id: 'browser',
operator: 'is',
name: 'browser' as const,
value: [hej.browser.get],
});
}
if (hej.browserVersion.get) {
filters.push({
id: 'browserVersion',
operator: 'is',
name: 'browser_version' as const,
value: [hej.browserVersion.get],
});
}
if (hej.os.get) {
filters.push({
id: 'os',
operator: 'is',
name: 'os' as const,
value: [hej.os.get],
});
}
if (hej.osVersion.get) {
filters.push({
id: 'osVersion',
operator: 'is',
name: 'os_version' as const,
value: [hej.osVersion.get],
});
}
return filters;
}, [
hej.path,
hej.device,
hej.referrer,
hej.referrerName,
hej.referrerType,
hej.utmSource,
hej.utmMedium,
hej.utmCampaign,
hej.utmContent,
hej.utmTerm,
hej.country,
hej.region,
hej.city,
hej.browser,
hej.browserVersion,
hej.os,
hej.osVersion,
]);
return filters;
}

View File

@@ -1,6 +1,6 @@
import type { Profile } from '@mixan/db'; import type { IDBProfile } from '@mixan/db';
export function getProfileName(profile: Profile | undefined | null) { export function getProfileName(profile: IDBProfile | undefined | null) {
if (!profile) return 'No profile'; if (!profile) return 'No profile';
return [profile.first_name, profile.last_name].filter(Boolean).join(' '); return [profile.first_name, profile.last_name].filter(Boolean).join(' ');
} }

View File

@@ -1,4 +1,10 @@
ALTER TABLE
events
ADD
COLUMN id UUID;
CREATE TABLE openpanel.events ( CREATE TABLE openpanel.events (
`id` UUID,
`name` String, `name` String,
`profile_id` String, `profile_id` String,
`project_id` String, `project_id` String,

View File

@@ -12,11 +12,12 @@
"with-env": "dotenv -e ../../.env -c --" "with-env": "dotenv -e ../../.env -c --"
}, },
"dependencies": { "dependencies": {
"@clickhouse/client": "^0.2.9",
"@mixan/common": "workspace:*", "@mixan/common": "workspace:*",
"@mixan/redis": "workspace:*", "@mixan/redis": "workspace:*",
"@clickhouse/client": "^0.2.9",
"@prisma/client": "^5.1.1", "@prisma/client": "^5.1.1",
"ramda": "^0.29.1" "ramda": "^0.29.1",
"uuid": "^9.0.1"
}, },
"devDependencies": { "devDependencies": {
"@mixan/eslint-config": "workspace:*", "@mixan/eslint-config": "workspace:*",
@@ -25,6 +26,7 @@
"@mixan/types": "workspace:*", "@mixan/types": "workspace:*",
"@types/node": "^18.16.0", "@types/node": "^18.16.0",
"@types/ramda": "^0.29.6", "@types/ramda": "^0.29.6",
"@types/uuid": "^9.0.8",
"eslint": "^8.48.0", "eslint": "^8.48.0",
"prettier": "^3.0.3", "prettier": "^3.0.3",
"prisma": "^5.1.1", "prisma": "^5.1.1",

View File

@@ -1,5 +1,5 @@
import type { IDBProfile } from '@/prisma-types';
import { omit } from 'ramda'; import { omit } from 'ramda';
import { v4 as uuid } from 'uuid';
import { randomSplitName, toDots } from '@mixan/common'; import { randomSplitName, toDots } from '@mixan/common';
import { redis, redisPub } from '@mixan/redis'; import { redis, redisPub } from '@mixan/redis';
@@ -12,8 +12,11 @@ import {
} from '../clickhouse-client'; } from '../clickhouse-client';
import type { Prisma } from '../prisma-client'; import type { Prisma } from '../prisma-client';
import { db } from '../prisma-client'; import { db } from '../prisma-client';
import type { IDBProfile } from '../prisma-types';
import { createSqlBuilder } from '../sql-builder';
export interface IClickhouseEvent { export interface IClickhouseEvent {
id: string;
name: string; name: string;
profile_id: string; profile_id: string;
project_id: string; project_id: string;
@@ -41,6 +44,7 @@ export function transformEvent(
event: IClickhouseEvent event: IClickhouseEvent
): IServiceCreateEventPayload { ): IServiceCreateEventPayload {
return { return {
id: event.id,
name: event.name, name: event.name,
profileId: event.profile_id, profileId: event.profile_id,
projectId: event.project_id, projectId: event.project_id,
@@ -66,6 +70,7 @@ export function transformEvent(
} }
export interface IServiceCreateEventPayload { export interface IServiceCreateEventPayload {
id: string;
name: string; name: string;
profileId: string; profileId: string;
projectId: string; projectId: string;
@@ -102,7 +107,10 @@ export async function getLiveVisitors(projectId: string) {
return keys.length; return keys.length;
} }
export async function getEvents(sql: string, options: GetEventsOptions = {}) { export async function getEvents(
sql: string,
options: GetEventsOptions = {}
): Promise<IServiceCreateEventPayload[]> {
const events = await chQuery<IClickhouseEvent>(sql); const events = await chQuery<IClickhouseEvent>(sql);
if (options.profile) { if (options.profile) {
const profileIds = events.map((e) => e.profile_id); const profileIds = events.map((e) => e.profile_id);
@@ -124,7 +132,9 @@ export async function getEvents(sql: string, options: GetEventsOptions = {}) {
return events.map(transformEvent); return events.map(transformEvent);
} }
export async function createEvent(payload: IServiceCreateEventPayload) { export async function createEvent(
payload: Omit<IServiceCreateEventPayload, 'id'>
) {
console.log(`create event ${payload.name} for ${payload.profileId}`); console.log(`create event ${payload.name} for ${payload.profileId}`);
if (payload.name === 'session_start') { if (payload.name === 'session_start') {
@@ -167,6 +177,7 @@ export async function createEvent(payload: IServiceCreateEventPayload) {
} }
const event: IClickhouseEvent = { const event: IClickhouseEvent = {
id: uuid(),
name: payload.name, name: payload.name,
profile_id: payload.profileId, profile_id: payload.profileId,
project_id: payload.projectId, project_id: payload.projectId,
@@ -193,6 +204,9 @@ export async function createEvent(payload: IServiceCreateEventPayload) {
table: 'events', table: 'events',
values: [event], values: [event],
format: 'JSONEachRow', format: 'JSONEachRow',
clickhouse_settings: {
date_time_input_format: 'best_effort',
},
}); });
redisPub.publish('event', JSON.stringify(transformEvent(event))); redisPub.publish('event', JSON.stringify(transformEvent(event)));
@@ -208,3 +222,35 @@ export async function createEvent(payload: IServiceCreateEventPayload) {
document: event, document: event,
}; };
} }
interface GetEventListOptions {
projectId: string;
profileId?: string;
take: number;
cursor?: string;
}
export async function getEventList({
cursor,
take,
projectId,
profileId,
}: GetEventListOptions) {
const { sb, getSql } = createSqlBuilder();
sb.limit = take;
sb.where.projectId = `project_id = '${projectId}'`;
if (profileId) {
sb.where.profileId = `profile_id = '${profileId}'`;
}
if (cursor) {
sb.where.cursor = `created_at <= '${formatClickhouseDate(cursor)}'`;
}
sb.orderBy.created_at = 'created_at DESC';
const res = await getEvents(getSql(), { profile: true });
return res;
}

10
pnpm-lock.yaml generated
View File

@@ -713,6 +713,9 @@ importers:
ramda: ramda:
specifier: ^0.29.1 specifier: ^0.29.1
version: 0.29.1 version: 0.29.1
uuid:
specifier: ^9.0.1
version: 9.0.1
devDependencies: devDependencies:
'@mixan/eslint-config': '@mixan/eslint-config':
specifier: workspace:* specifier: workspace:*
@@ -732,6 +735,9 @@ importers:
'@types/ramda': '@types/ramda':
specifier: ^0.29.6 specifier: ^0.29.6
version: 0.29.7 version: 0.29.7
'@types/uuid':
specifier: ^9.0.8
version: 9.0.8
eslint: eslint:
specifier: ^8.48.0 specifier: ^8.48.0
version: 8.52.0 version: 8.52.0
@@ -7645,6 +7651,10 @@ packages:
resolution: {integrity: sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==} resolution: {integrity: sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==}
dev: false dev: false
/@types/uuid@9.0.8:
resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==}
dev: true
/@types/ws@8.5.10: /@types/ws@8.5.10:
resolution: {integrity: sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==} resolution: {integrity: sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==}
dependencies: dependencies: