wip event list

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-02-16 23:06:36 +01:00
parent a74acda707
commit 02d52d5da8
27 changed files with 1178 additions and 465 deletions

View File

@@ -1,4 +1,4 @@
import type { FastifyRequest, RawRequestDefaultExpression } from 'fastify'; import type { FastifyRequest } from 'fastify';
interface RemoteIpLookupResponse { interface RemoteIpLookupResponse {
country: string | undefined; country: string | undefined;

View File

@@ -1,7 +1,28 @@
import { useEffect, useState } from 'react';
import { api } from '@/app/_trpc/client';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import type { VariantProps } from 'class-variance-authority'; import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority'; import { cva } from 'class-variance-authority';
import { ActivityIcon, BotIcon, MonitorPlayIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import { ActivityIcon, BotIcon, DotIcon, MonitorPlayIcon } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import type { EventMeta } from '@mixan/db';
const variants = cva('flex items-center justify-center shrink-0', { const variants = cva('flex items-center justify-center shrink-0', {
variants: { variants: {
@@ -17,30 +38,208 @@ const variants = cva('flex items-center justify-center shrink-0', {
type EventIconProps = VariantProps<typeof variants> & { type EventIconProps = VariantProps<typeof variants> & {
name: string; name: string;
meta?: EventMeta;
projectId: string;
className?: string; className?: string;
}; };
const records = { const records: Record<
default: { Icon: BotIcon, text: 'text-chart-0', bg: 'bg-chart-0/10' }, string,
{
icon: string;
color: string;
}
> = {
default: {
icon: 'BotIcon',
color: 'slate',
},
screen_view: { screen_view: {
Icon: MonitorPlayIcon, icon: 'MonitorPlayIcon',
text: 'text-chart-3', color: 'blue',
bg: 'bg-chart-3/10',
}, },
session_start: { session_start: {
Icon: ActivityIcon, icon: 'ActivityIcon',
text: 'text-chart-2', color: 'teal',
bg: 'bg-chart-2/10',
}, },
}; };
export function EventIcon({ className, name, size }: EventIconProps) { const icons: Record<string, LucideIcon> = {
const { Icon, text, bg } = BotIcon,
name in records ? records[name as keyof typeof records] : records.default; MonitorPlayIcon,
ActivityIcon,
};
const colors = [
'rose',
'pink',
'fuchsia',
'purple',
'violet',
'indigo',
'blue',
'sky',
'cyan',
'teal',
'emerald',
'green',
'lime',
'yellow',
'amber',
'orange',
'red',
'stone',
'neutral',
'zinc',
'grey',
'slate',
];
export function EventIcon({
className,
name,
size,
meta,
projectId,
}: EventIconProps) {
const router = useRouter();
const [selectedIcon, setIcon] = useState(
meta?.icon ?? records[name]?.icon ?? records.default?.icon ?? ''
);
const [selectedColor, setColor] = useState(
meta?.color ?? records[name]?.color ?? records.default?.color ?? ''
);
const [conversion, setConversion] = useState(!!meta?.conversion);
useEffect(() => {
if (meta?.icon) {
setIcon(meta.icon);
}
}, [meta?.icon]);
useEffect(() => {
if (meta?.color) {
setColor(meta.color);
}
}, [meta?.color]);
useEffect(() => {
setConversion(meta?.conversion ?? false);
}, [meta?.conversion]);
const SelectedIcon = icons[selectedIcon]!;
const Icon =
icons[meta?.icon ?? records[name]?.icon ?? records.default?.icon ?? '']!;
const color =
meta?.color ?? records[name]?.color ?? records.default?.color ?? '';
const mutation = api.event.updateEventMeta.useMutation({
onSuccess() {
document.querySelector('#close-sheet')?.click();
toast('Event updated');
router.refresh();
},
});
const getBg = (color: string) => `bg-${color}-200`;
const getText = (color: string) => `text-${color}-700`;
return ( return (
<div className={cn(variants({ size }), bg, className)}> <Sheet>
<Icon size={20} className={text} /> <SheetTrigger className={cn(getBg(color), variants({ size }), className)}>
<Icon size={20} className={getText(color)} />
</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Edit "{name}"</SheetTitle>
</SheetHeader>
<div className="flex flex-col gap-8 my-8">
<div>
<Label className="mb-4 block">Conversion</Label>
<label className="cursor-pointer flex items-center select-none border border-border rounded-md p-4 gap-4">
<Checkbox
checked={conversion}
onCheckedChange={(checked) => {
if (checked === 'indeterminate') return;
setConversion(checked);
}}
/>
<div>
<span>Yes, this event is important!</span>
</div> </div>
</label>
</div>
<div>
<Label className="mb-4 block">Pick a icon</Label>
<div className="flex flex-wrap gap-4">
{Object.entries(icons).map(([name, Icon]) => (
<button
key={name}
onClick={() => {
setIcon(name);
}}
className={cn(
'flex-shrink-0 rounded-md w-8 h-8 cursor-pointer inline-flex transition-all bg-slate-100 flex items-center justify-center',
name === selectedIcon
? 'scale-110 ring-1 ring-black'
: '[&_svg]:opacity-50'
)}
>
<Icon size={16} />
</button>
))}
</div>
</div>
<div>
<Label className="mb-4 block">Pick a color</Label>
<div className="flex flex-wrap gap-4">
{colors.map((color) => (
<button
key={color}
onClick={() => {
setColor(color);
}}
className={cn(
'flex-shrink-0 rounded-md w-8 h-8 cursor-pointer transition-all flex justify-center items-center',
color === selectedColor ? 'ring-1 ring-black' : '',
getBg(color)
)}
>
{SelectedIcon ? (
<SelectedIcon size={16} />
) : (
<svg
className={`${getText(color)} opacity-70`}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12.1" cy="12.1" r="4" />
</svg>
)}
</button>
))}
</div>
</div>
</div>
<SheetFooter>
<Button
onClick={() =>
mutation.mutate({
projectId,
name,
icon: selectedIcon,
color: selectedColor,
conversion,
})
}
>
Update event
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
); );
} }

View File

@@ -4,13 +4,16 @@ import type { RouterOutputs } from '@/app/_trpc/client';
import { ExpandableListItem } from '@/components/general/ExpandableListItem'; import { ExpandableListItem } from '@/components/general/ExpandableListItem';
import { KeyValue, KeyValueSubtle } from '@/components/ui/key-value'; import { KeyValue, KeyValueSubtle } from '@/components/ui/key-value';
import { useAppParams } from '@/hooks/useAppParams'; import { useAppParams } from '@/hooks/useAppParams';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { getProfileName } from '@/utils/getters'; import { getProfileName } from '@/utils/getters';
import { round } from '@/utils/math'; import { round } from '@/utils/math';
import { useQueryState } from 'nuqs';
import type { IServiceCreateEventPayload } from '@mixan/db';
import { EventIcon } from './event-icon'; import { EventIcon } from './event-icon';
type EventListItemProps = RouterOutputs['event']['list'][number]; type EventListItemProps = IServiceCreateEventPayload;
export function EventListItem({ export function EventListItem({
profile, profile,
@@ -33,25 +36,11 @@ export function EventListItem({
country, country,
continent, continent,
device, device,
projectId,
meta,
}: EventListItemProps) { }: EventListItemProps) {
const params = useAppParams(); const params = useAppParams();
const eventQueryFilters = useEventQueryFilters({ shallow: false });
const [, setPath] = useQueryState('path');
const [, setReferrer] = useQueryState('referrer');
const [, setReferrerName] = useQueryState('referrerName');
const [, setReferrerType] = useQueryState('referrerType');
const [, setBrand] = useQueryState('brand');
const [, setModel] = useQueryState('model');
const [, setBrowser] = useQueryState('browser');
const [, setBrowserVersion] = useQueryState('browserVersion');
const [, setOs] = useQueryState('os');
const [, setOsVersion] = useQueryState('osVersion');
const [, setCity] = useQueryState('city');
const [, setRegion] = useQueryState('region');
const [, setCountry] = useQueryState('country');
const [, setContinent] = useQueryState('continent');
const [, setDevice] = useQueryState('device');
const keyValueList = [ const keyValueList = [
{ {
name: 'Duration', name: 'Duration',
@@ -61,98 +50,98 @@ export function EventListItem({
name: 'Referrer', name: 'Referrer',
value: referrer, value: referrer,
onClick() { onClick() {
setReferrer(referrer ?? null); eventQueryFilters.referrer.set(referrer ?? null);
}, },
}, },
{ {
name: 'Referrer name', name: 'Referrer name',
value: referrerName, value: referrerName,
onClick() { onClick() {
setReferrerName(referrerName ?? null); eventQueryFilters.referrerName.set(referrerName ?? null);
}, },
}, },
{ {
name: 'Referrer type', name: 'Referrer type',
value: referrerType, value: referrerType,
onClick() { onClick() {
setReferrerType(referrerType ?? null); eventQueryFilters.referrerType.set(referrerType ?? null);
}, },
}, },
{ {
name: 'Brand', name: 'Brand',
value: brand, value: brand,
onClick() { onClick() {
setBrand(brand ?? null); eventQueryFilters.brand.set(brand ?? null);
}, },
}, },
{ {
name: 'Model', name: 'Model',
value: model, value: model,
onClick() { onClick() {
setModel(model ?? null); eventQueryFilters.model.set(model ?? null);
}, },
}, },
{ {
name: 'Browser', name: 'Browser',
value: browser, value: browser,
onClick() { onClick() {
setBrowser(browser ?? null); eventQueryFilters.browser.set(browser ?? null);
}, },
}, },
{ {
name: 'Browser version', name: 'Browser version',
value: browserVersion, value: browserVersion,
onClick() { onClick() {
setBrowserVersion(browserVersion ?? null); eventQueryFilters.browserVersion.set(browserVersion ?? null);
}, },
}, },
{ {
name: 'OS', name: 'OS',
value: os, value: os,
onClick() { onClick() {
setOs(os ?? null); eventQueryFilters.os.set(os ?? null);
}, },
}, },
{ {
name: 'OS cersion', name: 'OS cersion',
value: osVersion, value: osVersion,
onClick() { onClick() {
setOsVersion(osVersion ?? null); eventQueryFilters.osVersion.set(osVersion ?? null);
}, },
}, },
{ {
name: 'City', name: 'City',
value: city, value: city,
onClick() { onClick() {
setCity(city ?? null); eventQueryFilters.city.set(city ?? null);
}, },
}, },
{ {
name: 'Region', name: 'Region',
value: region, value: region,
onClick() { onClick() {
setRegion(region ?? null); eventQueryFilters.region.set(region ?? null);
}, },
}, },
{ {
name: 'Country', name: 'Country',
value: country, value: country,
onClick() { onClick() {
setCountry(country ?? null); eventQueryFilters.country.set(country ?? null);
}, },
}, },
{ {
name: 'Continent', name: 'Continent',
value: continent, value: continent,
onClick() { onClick() {
setContinent(continent ?? null); eventQueryFilters.continent.set(continent ?? null);
}, },
}, },
{ {
name: 'Device', name: 'Device',
value: device, value: device,
onClick() { onClick() {
setDevice(device ?? null); eventQueryFilters.device.set(device ?? null);
}, },
}, },
].filter((item) => typeof item.value === 'string' && item.value); ].filter((item) => typeof item.value === 'string' && item.value);
@@ -166,6 +155,7 @@ export function EventListItem({
return ( return (
<ExpandableListItem <ExpandableListItem
className={cn(meta?.conversion && 'ring-2 ring-primary-500')}
title={name.split('_').join(' ')} title={name.split('_').join(' ')}
content={ content={
<> <>
@@ -182,13 +172,13 @@ export function EventListItem({
name="Path" name="Path"
value={path} value={path}
onClick={() => { onClick={() => {
setPath(path); eventQueryFilters.path.set(path);
}} }}
/> />
)} )}
</> </>
} }
image={<EventIcon name={name} />} image={<EventIcon name={name} meta={meta} projectId={projectId} />}
> >
<div className="p-2"> <div className="p-2">
<div className="bg-gradient-to-tr from-slate-100 to-white rounded-md"> <div className="bg-gradient-to-tr from-slate-100 to-white rounded-md">

View File

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

View File

@@ -1,9 +1,10 @@
import { Suspense } from 'react';
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 { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import { getEventFilters } from '@/hooks/useEventQueryFilters';
import { getExists } from '@/server/pageExists'; import { getExists } from '@/server/pageExists';
import { getEventList, getEvents } from '@mixan/db'; import { getEventList, getEventsCount } from '@mixan/db';
import { StickyBelowHeader } from '../layout-sticky-below-header'; import { StickyBelowHeader } from '../layout-sticky-below-header';
import { EventList } from './event-list'; import { EventList } from './event-list';
@@ -15,25 +16,114 @@ interface PageProps {
}; };
searchParams: { searchParams: {
cursor?: string; cursor?: string;
path?: string;
device?: string;
referrer?: string;
referrerName?: string;
referrerType?: string;
utmSource?: string;
utmMedium?: string;
utmCampaign?: string;
utmContent?: string;
utmTerm?: string;
continent?: string;
country?: string;
region?: string;
city?: string;
browser?: string;
browserVersion?: string;
os?: string;
osVersion?: string;
brand?: string;
model?: string;
}; };
} }
const nuqsOptions = {
shallow: false,
};
function parseQueryAsNumber(value: string | undefined) {
if (typeof value === 'string') {
return parseInt(value, 10);
}
return undefined;
}
export default async function Page({ export default async function Page({
params: { projectId, organizationId }, params: { projectId, organizationId },
searchParams: { cursor }, searchParams,
}: PageProps) { }: PageProps) {
await getExists(organizationId, projectId); const [events, count] = await Promise.all([
const events = await getEventList({ getEventList({
cursor, cursor: parseQueryAsNumber(searchParams.cursor),
projectId, projectId,
take: 50, take: 50,
}); filters: getEventFilters({
path: searchParams.path ?? null,
device: searchParams.device ?? null,
referrer: searchParams.referrer ?? null,
referrerName: searchParams.referrerName ?? null,
referrerType: searchParams.referrerType ?? null,
utmSource: searchParams.utmSource ?? null,
utmMedium: searchParams.utmMedium ?? null,
utmCampaign: searchParams.utmCampaign ?? null,
utmContent: searchParams.utmContent ?? null,
utmTerm: searchParams.utmTerm ?? null,
continent: searchParams.continent ?? null,
country: searchParams.country ?? null,
region: searchParams.region ?? null,
city: searchParams.city ?? null,
browser: searchParams.browser ?? null,
browserVersion: searchParams.browserVersion ?? null,
os: searchParams.os ?? null,
osVersion: searchParams.osVersion ?? null,
brand: searchParams.brand ?? null,
model: searchParams.model ?? null,
}),
}),
getEventsCount({
projectId,
filters: getEventFilters({
path: searchParams.path ?? null,
device: searchParams.device ?? null,
referrer: searchParams.referrer ?? null,
referrerName: searchParams.referrerName ?? null,
referrerType: searchParams.referrerType ?? null,
utmSource: searchParams.utmSource ?? null,
utmMedium: searchParams.utmMedium ?? null,
utmCampaign: searchParams.utmCampaign ?? null,
utmContent: searchParams.utmContent ?? null,
utmTerm: searchParams.utmTerm ?? null,
continent: searchParams.continent ?? null,
country: searchParams.country ?? null,
region: searchParams.region ?? null,
city: searchParams.city ?? null,
browser: searchParams.browser ?? null,
browserVersion: searchParams.browserVersion ?? null,
os: searchParams.os ?? null,
osVersion: searchParams.osVersion ?? null,
brand: searchParams.brand ?? null,
model: searchParams.model ?? null,
}),
}),
getExists(organizationId, projectId),
]);
console.log(events[0]);
return ( return (
<PageLayout title="Events" organizationSlug={organizationId}> <PageLayout title="Events" organizationSlug={organizationId}>
<StickyBelowHeader className="p-4 flex justify-between"> <StickyBelowHeader className="p-4 flex justify-between">
<OverviewFiltersDrawer projectId={projectId} /> <OverviewFiltersDrawer
projectId={projectId}
nuqsOptions={nuqsOptions}
/>
<OverviewFiltersButtons
className="p-0 justify-end"
nuqsOptions={nuqsOptions}
/>
</StickyBelowHeader> </StickyBelowHeader>
<EventList data={events} /> <EventList data={events} count={count} />
</PageLayout> </PageLayout>
); );
} }

View File

@@ -1,42 +1,54 @@
import { useMemo, useState } from 'react'; import type { Dispatch, SetStateAction } from 'react';
import { useState } from 'react';
import { Button } from './ui/button'; import { Button } from './ui/button';
export function usePagination(take = 100) { export function usePagination(take: number) {
const [skip, setSkip] = useState(0); const [page, setPage] = useState(0);
return useMemo( return {
() => ({
skip,
next: () => setSkip((p) => p + take),
prev: () => setSkip((p) => Math.max(p - take)),
take, take,
canPrev: skip > 0, skip: page * take,
canNext: true, setPage,
page: skip / take + 1, page,
}), paginate: <T,>(data: T[]): T[] =>
[skip, setSkip, take] data.slice(page * take, (page + 1) * take),
); };
} }
export type PaginationProps = ReturnType<typeof usePagination>; export function Pagination({
take,
count,
cursor,
setCursor,
}: {
take?: number;
count?: number;
cursor: number;
setCursor: Dispatch<SetStateAction<number>>;
}) {
const isNextDisabled =
count !== undefined && take !== undefined && cursor * take + take >= count;
export function Pagination(props: PaginationProps) {
return ( return (
<div className="flex select-none items-center justify-end gap-2"> <div className="flex select-none items-center justify-end gap-2">
<div className="font-medium text-xs">Page: {props.page}</div> <div className="font-medium text-xs">Page: {cursor + 1}</div>
{typeof count === 'number' && (
<div className="font-medium text-xs">Total rows: {count}</div>
)}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => props.prev()} onClick={() => setCursor((p) => Math.max(0, p - 1))}
disabled={!props.canPrev} disabled={cursor === 0}
> >
Previous Previous
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => props.next()} onClick={() => setCursor((p) => p + 1)}
disabled={!props.canNext} disabled={isNextDisabled}
> >
Next Next
</Button> </Button>

View File

@@ -11,6 +11,7 @@ interface ExpandableListItemProps {
title: string; title: string;
image?: React.ReactNode; image?: React.ReactNode;
initialOpen?: boolean; initialOpen?: boolean;
className?: string;
} }
export function ExpandableListItem({ export function ExpandableListItem({
title, title,
@@ -18,14 +19,17 @@ export function ExpandableListItem({
image, image,
initialOpen = false, initialOpen = false,
children, children,
className,
}: ExpandableListItemProps) { }: ExpandableListItemProps) {
const [open, setOpen] = useState(initialOpen ?? false); const [open, setOpen] = useState(initialOpen ?? false);
return ( return (
<div className="bg-white shadow rounded-xl overflow-hidden"> <div
<div className="p-3 sm:p-6 flex gap-4 items-center"> className={cn('bg-white shadow rounded-xl overflow-hidden', className)}
>
<div className="p-2 sm:p-4 flex gap-4">
<div className="flex gap-1">{image}</div> <div className="flex gap-1">{image}</div>
<div className="flex flex-col flex-1 gap-1 min-w-0"> <div className="flex flex-col flex-1 gap-1 min-w-0">
<span className="text-lg font-medium leading-none mb-1">{title}</span> <span className="text-md font-medium leading-none mb-1">{title}</span>
{!!content && ( {!!content && (
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-4"> <div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-4">
{content} {content}

View File

@@ -1,22 +1,27 @@
'use client'; 'use client';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
useEventFilters,
useEventQueryFilters,
} from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { Options as NuqsOptions } from 'nuqs';
export function OverviewFiltersButtons() { interface OverviewFiltersButtonsProps {
const eventQueryFilters = useEventQueryFilters(); className?: string;
nuqsOptions?: NuqsOptions;
}
export function OverviewFiltersButtons({
className,
nuqsOptions,
}: OverviewFiltersButtonsProps) {
const eventQueryFilters = useEventQueryFilters(nuqsOptions);
const filters = Object.entries(eventQueryFilters).filter( const filters = Object.entries(eventQueryFilters).filter(
([, filter]) => filter.get !== null ([, filter]) => filter.get !== null
); );
if (filters.length === 0) return null;
return ( return (
<div <div className={cn('flex flex-wrap gap-2 px-4 pb-4', className)}>
className={cn('flex flex-wrap gap-2', filters.length > 0 && 'px-4 pb-4')}
>
{filters.map(([key, filter]) => ( {filters.map(([key, filter]) => (
<Button <Button
key={key} key={key}

View File

@@ -5,15 +5,18 @@ import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox'; import { Combobox } from '@/components/ui/combobox';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters'; import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { XIcon } from 'lucide-react'; import { XIcon } from 'lucide-react';
import { Options as NuqsOptions } from 'nuqs';
interface OverviewFiltersProps { interface OverviewFiltersProps {
projectId: string; projectId: string;
nuqsOptions?: NuqsOptions;
} }
export function OverviewFiltersDrawerContent({ export function OverviewFiltersDrawerContent({
projectId, projectId,
nuqsOptions,
}: OverviewFiltersProps) { }: OverviewFiltersProps) {
const eventQueryFilters = useEventQueryFilters(); const eventQueryFilters = useEventQueryFilters(nuqsOptions);
return ( return (
<div> <div>

View File

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

View File

@@ -1,5 +1,3 @@
'use client';
import * as React from 'react'; import * as React from 'react';
import type { IChartData } from '@/app/_trpc/client'; import type { IChartData } from '@/app/_trpc/client';
import { Pagination, usePagination } from '@/components/Pagination'; import { Pagination, usePagination } from '@/components/Pagination';
@@ -37,7 +35,7 @@ export function ReportTable({
visibleSeries, visibleSeries,
setVisibleSeries, setVisibleSeries,
}: ReportTableProps) { }: ReportTableProps) {
const pagination = usePagination(50); const { setPage, paginate, page } = usePagination(50);
const number = useNumber(); const number = useNumber();
const interval = useSelector((state) => state.report.interval); const interval = useSelector((state) => state.report.interval);
const formatDate = useFormatDateInterval(interval); const formatDate = useFormatDateInterval(interval);
@@ -63,9 +61,7 @@ export function ReportTable({
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{data.series {paginate(data.series).map((serie, index) => {
.slice(pagination.skip, pagination.skip + pagination.take)
.map((serie, index) => {
const checked = !!visibleSeries.find( const checked = !!visibleSeries.find(
(item) => item.name === serie.name (item) => item.name === serie.name
); );
@@ -122,9 +118,7 @@ export function ReportTable({
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{data.series {paginate(data.series).map((serie) => {
.slice(pagination.skip, pagination.skip + pagination.take)
.map((serie) => {
return ( return (
<TableRow key={serie.name}> <TableRow key={serie.name}>
<TableCell className="h-10"> <TableCell className="h-10">
@@ -146,10 +140,7 @@ export function ReportTable({
{serie.data.map((item) => { {serie.data.map((item) => {
return ( return (
<TableCell <TableCell className="h-10" key={item.date.toString()}>
className="h-10"
key={item.date.toString()}
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{number.format(item.count)} {number.format(item.count)}
<PreviousDiffIndicator {...item.previous} /> <PreviousDiffIndicator {...item.previous} />
@@ -171,7 +162,7 @@ export function ReportTable({
<Badge>Min: {number.format(data.metrics.min)}</Badge> <Badge>Min: {number.format(data.metrics.min)}</Badge>
<Badge>Max: {number.format(data.metrics.max)}</Badge> <Badge>Max: {number.format(data.metrics.max)}</Badge>
</div> </div>
<Pagination {...pagination} /> <Pagination cursor={page} setCursor={setPage} />
</div> </div>
</> </>
); );

View File

@@ -37,13 +37,13 @@ export function KeyValueSubtle({ 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 text-xs gap-2 font-medium self-start min-w-0 max-w-full items-center" className="group flex text-[10px] sm:text-xs gap-2 font-medium self-start min-w-0 max-w-full items-center"
{...{ href, onClick }} {...{ href, onClick }}
> >
<div className="text-gray-400">{name}</div> <div className="text-gray-400">{name}</div>
<div <div
className={cn( className={cn(
'bg-slate-100 rounded p-1 px-2 text-gray-600 whitespace-nowrap overflow-hidden text-ellipsis', 'bg-slate-100 rounded p-0.5 px-1 sm:p-1 sm:px-2 text-gray-600 whitespace-nowrap overflow-hidden text-ellipsis',
clickable && 'group-hover:underline' clickable && 'group-hover:underline'
)} )}
> >

View File

@@ -3,10 +3,9 @@
import * as React from 'react'; import * as React from 'react';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import * as SheetPrimitive from '@radix-ui/react-dialog'; import * as SheetPrimitive from '@radix-ui/react-dialog';
import { ScrollArea } from '@radix-ui/react-scroll-area';
import { cva } from 'class-variance-authority'; import { cva } from 'class-variance-authority';
import type { VariantProps } from 'class-variance-authority'; import type { VariantProps } from 'class-variance-authority';
import { X } from 'lucide-react'; import { XIcon } from 'lucide-react';
const Sheet = SheetPrimitive.Root; const Sheet = SheetPrimitive.Root;
@@ -22,7 +21,7 @@ const SheetOverlay = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay <SheetPrimitive.Overlay
className={cn( className={cn(
'fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', 'fixed inset-0 z-50 bg-black/20 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className className
)} )}
{...props} {...props}
@@ -32,7 +31,7 @@ const SheetOverlay = React.forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva( const sheetVariants = cva(
'fixed z-50 gap-4 bg-background shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-150 data-[state=open]:duration-150', 'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
{ {
variants: { variants: {
side: { side: {
@@ -59,17 +58,16 @@ const SheetContent = React.forwardRef<
SheetContentProps SheetContentProps
>(({ side = 'right', className, children, ...props }, ref) => ( >(({ side = 'right', className, children, ...props }, ref) => (
<SheetPortal> <SheetPortal>
<SheetOverlay className="backdrop-blur-none bg-transparent" /> <SheetOverlay />
<SheetPrimitive.Content <SheetPrimitive.Content
ref={ref} ref={ref}
className={cn(sheetVariants({ side }), className)} className={cn(sheetVariants({ side }), className)}
{...props} {...props}
> >
<div className="h-screen p-6 overflow-y-auto overflow-x-hidden">
{children} {children}
</div> <SheetPrimitive.Close id="close-sheet" className="hidden" />
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"> <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" /> <XIcon className="h-4 w-4" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</SheetPrimitive.Close> </SheetPrimitive.Close>
</SheetPrimitive.Content> </SheetPrimitive.Content>

View File

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

View File

@@ -2,7 +2,7 @@ import { useMemo } from 'react';
import type { IChartInput } from '@/types'; import type { IChartInput } from '@/types';
// prettier-ignore // prettier-ignore
import type { UseQueryStateReturn } from 'nuqs'; import type { Options as NuqsOptions, UseQueryStateReturn } from 'nuqs';
import { parseAsString, useQueryState } from 'nuqs'; import { parseAsString, useQueryState } from 'nuqs';
@@ -18,210 +18,309 @@ function useFix<T>(hook: UseQueryStateReturn<T, undefined>) {
); );
} }
export function useEventQueryFilters() { export function useEventQueryFilters(options: NuqsOptions = {}) {
// Ignore prettier so that we have all one same line // Ignore prettier so that we have all one same line
// prettier-ignore // prettier-ignore
return { return {
path: useFix(useQueryState('path', parseAsString.withOptions(nuqsOptions))), path: useFix(useQueryState('path', parseAsString.withOptions({...nuqsOptions, ...options}))),
referrer: useFix(useQueryState('referrer', parseAsString.withOptions(nuqsOptions))), referrer: useFix(useQueryState('referrer', parseAsString.withOptions({...nuqsOptions, ...options}))),
referrerName: useFix(useQueryState('referrerName',parseAsString.withOptions(nuqsOptions))), referrerName: useFix(useQueryState('referrerName',parseAsString.withOptions({...nuqsOptions, ...options}))),
referrerType: useFix(useQueryState('referrerType',parseAsString.withOptions(nuqsOptions))), referrerType: useFix(useQueryState('referrerType',parseAsString.withOptions({...nuqsOptions, ...options}))),
utmSource: useFix(useQueryState('utmSource',parseAsString.withOptions(nuqsOptions))), utmSource: useFix(useQueryState('utmSource',parseAsString.withOptions({...nuqsOptions, ...options}))),
utmMedium: useFix(useQueryState('utmMedium',parseAsString.withOptions(nuqsOptions))), utmMedium: useFix(useQueryState('utmMedium',parseAsString.withOptions({...nuqsOptions, ...options}))),
utmCampaign: useFix(useQueryState('utmCampaign',parseAsString.withOptions(nuqsOptions))), utmCampaign: useFix(useQueryState('utmCampaign',parseAsString.withOptions({...nuqsOptions, ...options}))),
utmContent: useFix(useQueryState('utmContent',parseAsString.withOptions(nuqsOptions))), utmContent: useFix(useQueryState('utmContent',parseAsString.withOptions({...nuqsOptions, ...options}))),
utmTerm: useFix(useQueryState('utmTerm', parseAsString.withOptions(nuqsOptions))), utmTerm: useFix(useQueryState('utmTerm', parseAsString.withOptions({...nuqsOptions, ...options}))),
country: useFix(useQueryState('country', parseAsString.withOptions(nuqsOptions))), continent: useFix(useQueryState('continent', parseAsString.withOptions({...nuqsOptions, ...options}))),
region: useFix(useQueryState('region', parseAsString.withOptions(nuqsOptions))), country: useFix(useQueryState('country', parseAsString.withOptions({...nuqsOptions, ...options}))),
city: useFix(useQueryState('city', parseAsString.withOptions(nuqsOptions))), region: useFix(useQueryState('region', parseAsString.withOptions({...nuqsOptions, ...options}))),
device: useFix(useQueryState('device', parseAsString.withOptions(nuqsOptions))), city: useFix(useQueryState('city', parseAsString.withOptions({...nuqsOptions, ...options}))),
browser: useFix(useQueryState('browser', parseAsString.withOptions(nuqsOptions))), device: useFix(useQueryState('device', parseAsString.withOptions({...nuqsOptions, ...options}))),
browserVersion: useFix(useQueryState('browserVersion',parseAsString.withOptions(nuqsOptions))), browser: useFix(useQueryState('browser', parseAsString.withOptions({...nuqsOptions, ...options}))),
os: useFix(useQueryState('os', parseAsString.withOptions(nuqsOptions))), browserVersion: useFix(useQueryState('browserVersion',parseAsString.withOptions({...nuqsOptions, ...options}))),
osVersion: useFix(useQueryState('osVersion',parseAsString.withOptions(nuqsOptions))), os: useFix(useQueryState('os', parseAsString.withOptions({...nuqsOptions, ...options}))),
osVersion: useFix(useQueryState('osVersion',parseAsString.withOptions({...nuqsOptions, ...options}))),
brand: useFix(useQueryState('brand',parseAsString.withOptions({...nuqsOptions, ...options}))),
model: useFix(useQueryState('model',parseAsString.withOptions({...nuqsOptions, ...options}))),
} as const; } as const;
} }
export function useEventFilters() { export function useEventFilters() {
const hej = useEventQueryFilters(); const eventQueryFilters = useEventQueryFilters();
const filters = useMemo(() => { const filters = useMemo(() => {
const filters: IChartInput['events'][number]['filters'] = []; return getEventFilters({
path: eventQueryFilters.path.get,
if (hej.path.get) { device: eventQueryFilters.device.get,
filters.push({ referrer: eventQueryFilters.referrer.get,
id: 'path', referrerName: eventQueryFilters.referrerName.get,
operator: 'is', referrerType: eventQueryFilters.referrerType.get,
name: 'path' as const, utmSource: eventQueryFilters.utmSource.get,
value: [hej.path.get], utmMedium: eventQueryFilters.utmMedium.get,
utmCampaign: eventQueryFilters.utmCampaign.get,
utmContent: eventQueryFilters.utmContent.get,
utmTerm: eventQueryFilters.utmTerm.get,
continent: eventQueryFilters.continent.get,
country: eventQueryFilters.country.get,
region: eventQueryFilters.region.get,
city: eventQueryFilters.city.get,
browser: eventQueryFilters.browser.get,
browserVersion: eventQueryFilters.browserVersion.get,
os: eventQueryFilters.os.get,
osVersion: eventQueryFilters.osVersion.get,
brand: eventQueryFilters.brand.get,
model: eventQueryFilters.model.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, eventQueryFilters.path.get,
hej.device, eventQueryFilters.device.get,
hej.referrer, eventQueryFilters.referrer.get,
hej.referrerName, eventQueryFilters.referrerName.get,
hej.referrerType, eventQueryFilters.referrerType.get,
hej.utmSource, eventQueryFilters.utmSource.get,
hej.utmMedium, eventQueryFilters.utmMedium.get,
hej.utmCampaign, eventQueryFilters.utmCampaign.get,
hej.utmContent, eventQueryFilters.utmContent.get,
hej.utmTerm, eventQueryFilters.utmTerm.get,
hej.country, eventQueryFilters.continent.get,
hej.region, eventQueryFilters.country.get,
hej.city, eventQueryFilters.region.get,
hej.browser, eventQueryFilters.city.get,
hej.browserVersion, eventQueryFilters.browser.get,
hej.os, eventQueryFilters.browserVersion.get,
hej.osVersion, eventQueryFilters.os.get,
eventQueryFilters.osVersion.get,
eventQueryFilters.model.get,
eventQueryFilters.brand.get,
]); ]);
return filters; return filters;
} }
export function getEventFilters({
path,
device,
referrer,
referrerName,
referrerType,
utmSource,
utmMedium,
utmCampaign,
utmContent,
utmTerm,
continent,
country,
region,
city,
browser,
browserVersion,
os,
osVersion,
brand,
model,
}: {
path: string | null;
device: string | null;
referrer: string | null;
referrerName: string | null;
referrerType: string | null;
utmSource: string | null;
utmMedium: string | null;
utmCampaign: string | null;
utmContent: string | null;
utmTerm: string | null;
continent: string | null;
country: string | null;
region: string | null;
city: string | null;
browser: string | null;
browserVersion: string | null;
os: string | null;
osVersion: string | null;
brand: string | null;
model: string | null;
}) {
const filters: IChartInput['events'][number]['filters'] = [];
if (path) {
filters.push({
id: 'path',
operator: 'is',
name: 'path' as const,
value: [path],
});
}
if (device) {
filters.push({
id: 'device',
operator: 'is',
name: 'device' as const,
value: [device],
});
}
if (referrer) {
filters.push({
id: 'referrer',
operator: 'is',
name: 'referrer' as const,
value: [referrer],
});
}
if (referrerName) {
filters.push({
id: 'referrerName',
operator: 'is',
name: 'referrer_name' as const,
value: [referrerName],
});
}
if (referrerType) {
filters.push({
id: 'referrerType',
operator: 'is',
name: 'referrer_type' as const,
value: [referrerType],
});
}
if (utmSource) {
filters.push({
id: 'utmSource',
operator: 'is',
name: 'properties.query.utm_source' as const,
value: [utmSource],
});
}
if (utmMedium) {
filters.push({
id: 'utmMedium',
operator: 'is',
name: 'properties.query.utm_medium' as const,
value: [utmMedium],
});
}
if (utmCampaign) {
filters.push({
id: 'utmCampaign',
operator: 'is',
name: 'properties.query.utm_campaign' as const,
value: [utmCampaign],
});
}
if (utmContent) {
filters.push({
id: 'utmContent',
operator: 'is',
name: 'properties.query.utm_content' as const,
value: [utmContent],
});
}
if (utmTerm) {
filters.push({
id: 'utmTerm',
operator: 'is',
name: 'properties.query.utm_term' as const,
value: [utmTerm],
});
}
if (continent) {
filters.push({
id: 'continent',
operator: 'is',
name: 'continent' as const,
value: [continent],
});
}
if (country) {
filters.push({
id: 'country',
operator: 'is',
name: 'country' as const,
value: [country],
});
}
if (region) {
filters.push({
id: 'region',
operator: 'is',
name: 'region' as const,
value: [region],
});
}
if (city) {
filters.push({
id: 'city',
operator: 'is',
name: 'city' as const,
value: [city],
});
}
if (browser) {
filters.push({
id: 'browser',
operator: 'is',
name: 'browser' as const,
value: [browser],
});
}
if (browserVersion) {
filters.push({
id: 'browserVersion',
operator: 'is',
name: 'browser_version' as const,
value: [browserVersion],
});
}
if (os) {
filters.push({
id: 'os',
operator: 'is',
name: 'os' as const,
value: [os],
});
}
if (osVersion) {
filters.push({
id: 'osVersion',
operator: 'is',
name: 'os_version' as const,
value: [osVersion],
});
}
if (brand) {
filters.push({
id: 'brand',
operator: 'is',
name: 'brand' as const,
value: [brand],
});
}
if (model) {
filters.push({
id: 'model',
operator: 'is',
name: 'model' as const,
value: [model],
});
}
return filters;
}

View File

@@ -1,38 +1,29 @@
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc'; import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { transformEvent } from '@/server/services/event.service';
import { z } from 'zod'; import { z } from 'zod';
import type { IDBEvent } from '@mixan/db'; import { db } from '@mixan/db';
import { chQuery, createSqlBuilder, getEvents } from '@mixan/db';
export const eventRouter = createTRPCRouter({ export const eventRouter = createTRPCRouter({
list: protectedProcedure updateEventMeta: protectedProcedure
.input( .input(
z.object({ z.object({
projectId: z.string(), projectId: z.string(),
take: z.number().default(100), name: z.string(),
skip: z.number().default(0), icon: z.string().optional(),
profileId: z.string().optional(), color: z.string().optional(),
events: z.array(z.string()).optional(), conversion: z.boolean().optional(),
}) })
) )
.query(async ({ input: { take, skip, projectId, profileId, events } }) => { .mutation(({ input: { projectId, name, icon, color, conversion } }) => {
const { sb, getSql } = createSqlBuilder(); return db.eventMeta.upsert({
where: {
sb.limit = take; name_project_id: {
sb.offset = skip; name,
sb.where.projectId = `project_id = '${projectId}'`; project_id: projectId,
if (profileId) { },
sb.where.profileId = `profile_id = '${profileId}'`; },
} create: { project_id: projectId, name, icon, color, conversion },
if (events?.length) { update: { icon, color, conversion },
sb.where.name = `name IN (${events.map((e) => `'${e}'`).join(',')})`; });
}
sb.orderBy.created_at = 'created_at DESC';
const res = await getEvents(getSql(), { profile: true });
return res;
}), }),
}); });

View File

@@ -20,6 +20,50 @@ const config = {
...colors.flatMap((color) => ...colors.flatMap((color) =>
['text', 'bg'].map((prefix) => `${prefix}-chart-${color}`) ['text', 'bg'].map((prefix) => `${prefix}-chart-${color}`)
), ),
'bg-rose-200',
'text-rose-700',
'bg-pink-200',
'text-pink-700',
'bg-fuchsia-200',
'text-fuchsia-700',
'bg-purple-200',
'text-purple-700',
'bg-violet-200',
'text-violet-700',
'bg-indigo-200',
'text-indigo-700',
'bg-blue-200',
'text-blue-700',
'bg-sky-200',
'text-sky-700',
'bg-cyan-200',
'text-cyan-700',
'bg-teal-200',
'text-teal-700',
'bg-emerald-200',
'text-emerald-700',
'bg-green-200',
'text-green-700',
'bg-lime-200',
'text-lime-700',
'bg-yellow-200',
'text-yellow-700',
'bg-amber-200',
'text-amber-700',
'bg-orange-200',
'text-orange-700',
'bg-red-200',
'text-red-700',
'bg-stone-200',
'text-stone-700',
'bg-neutral-200',
'text-neutral-700',
'bg-zinc-200',
'text-zinc-700',
'bg-grey-200',
'text-grey-700',
'bg-slate-200',
'text-slate-700',
], ],
content: [ content: [
'./pages/**/*.{ts,tsx}', './pages/**/*.{ts,tsx}',

View File

@@ -1,10 +1,5 @@
ALTER TABLE
events
ADD
COLUMN id UUID;
CREATE TABLE openpanel.events ( CREATE TABLE openpanel.events (
`id` UUID, `id` UUID DEFAULT generateUUIDv4(),
`name` String, `name` String,
`profile_id` String, `profile_id` String,
`project_id` String, `project_id` String,
@@ -18,6 +13,7 @@ CREATE TABLE openpanel.events (
`country` String, `country` String,
`city` String, `city` String,
`region` String, `region` String,
`continent` String,
`os` String, `os` String,
`os_version` String, `os_version` String,
`browser` String, `browser` String,
@@ -45,3 +41,13 @@ CREATE TABLE test.profiles (
) ENGINE = ReplacingMergeTree ) ENGINE = ReplacingMergeTree
ORDER BY ORDER BY
(id) SETTINGS index_granularity = 8192; (id) SETTINGS index_granularity = 8192;
ALTER TABLE
events
ADD
COLUMN continent String
AFTER
region;
ALTER TABLE
events DROP COLUMN id;

View File

@@ -5,3 +5,4 @@ export * from './src/sql-builder';
export * from './src/services/salt'; export * from './src/services/salt';
export * from './src/services/event.service'; export * from './src/services/event.service';
export * from './src/services/share.service'; export * from './src/services/share.service';
export * from './src/services/chart.service';

View File

@@ -0,0 +1,11 @@
/*
Warnings:
- Added the required column `project_id` to the `event_meta` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "event_meta" ADD COLUMN "project_id" TEXT NOT NULL;
-- AddForeignKey
ALTER TABLE "event_meta" ADD CONSTRAINT "event_meta_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "event_meta" ADD COLUMN "color" TEXT,
ADD COLUMN "icon" TEXT,
ALTER COLUMN "conversion" DROP NOT NULL;

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- A unique constraint covering the columns `[name,project_id]` on the table `event_meta` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "event_meta_name_project_id_key" ON "event_meta"("name", "project_id");

View File

@@ -24,6 +24,7 @@ model Project {
reports Report[] reports Report[]
dashboards Dashboard[] dashboards Dashboard[]
share ShareOverview? share ShareOverview?
EventMeta EventMeta[]
@@map("projects") @@map("projects")
} }
@@ -169,9 +170,15 @@ model ShareOverview {
model EventMeta { model EventMeta {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String name String
conversion Boolean conversion Boolean?
color String?
icon String?
project_id String
project Project @relation(fields: [project_id], references: [id])
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
@@unique([name, project_id])
@@map("event_meta") @@map("event_meta")
} }

View File

@@ -14,7 +14,7 @@ interface ClickhouseJsonResponse<T> {
meta: { name: string; type: string }[]; meta: { name: string; type: string }[];
} }
export async function chQueryAll<T extends Record<string, any>>( export async function chQueryWithMeta<T extends Record<string, any>>(
query: string query: string
): Promise<ClickhouseJsonResponse<T>> { ): Promise<ClickhouseJsonResponse<T>> {
const res = await ch.query({ const res = await ch.query({
@@ -42,7 +42,7 @@ export async function chQueryAll<T extends Record<string, any>>(
export async function chQuery<T extends Record<string, any>>( export async function chQuery<T extends Record<string, any>>(
query: string query: string
): Promise<T[]> { ): Promise<T[]> {
return (await chQueryAll<T>(query)).data; return (await chQueryWithMeta<T>(query)).data;
} }
export function formatClickhouseDate(_date: Date | string) { export function formatClickhouseDate(_date: Date | string) {

View File

@@ -0,0 +1,178 @@
import { formatClickhouseDate } from '../clickhouse-client';
import type { SqlBuilderObject } from '../sql-builder';
import { createSqlBuilder } from '../sql-builder';
function log(sql: string) {
const logs = ['--- START', sql, '--- END'];
console.log(logs.join('\n'));
return sql;
}
type IGetChartDataInput = any;
export function getChartSql({
event,
breakdowns,
interval,
startDate,
endDate,
projectId,
}: IGetChartDataInput) {
const { sb, join, getWhere, getFrom, getSelect, getOrderBy, getGroupBy } =
createSqlBuilder();
sb.where.projectId = `project_id = '${projectId}'`;
if (event.name !== '*') {
sb.select.label = `'${event.name}' as label`;
sb.where.eventName = `name = '${event.name}'`;
}
getEventFiltersWhereClause(sb, event.filters);
sb.select.count = `count(*) as count`;
switch (interval) {
case 'minute': {
sb.select.date = `toStartOfMinute(created_at) as date`;
break;
}
case 'hour': {
sb.select.date = `toStartOfHour(created_at) as date`;
break;
}
case 'day': {
sb.select.date = `toStartOfDay(created_at) as date`;
break;
}
case 'month': {
sb.select.date = `toStartOfMonth(created_at) as date`;
break;
}
}
sb.groupBy.date = 'date';
sb.orderBy.date = 'date ASC';
if (startDate) {
sb.where.startDate = `created_at >= '${formatClickhouseDate(startDate)}'`;
}
if (endDate) {
sb.where.endDate = `created_at <= '${formatClickhouseDate(endDate)}'`;
}
const breakdown = breakdowns[0]!;
if (breakdown) {
const value = breakdown.name.startsWith('properties.')
? `mapValues(mapExtractKeyLike(properties, '${breakdown.name
.replace(/^properties\./, '')
.replace('.*.', '.%.')}'))`
: breakdown.name;
sb.select.label = breakdown.name.startsWith('properties.')
? `arrayElement(${value}, 1) as label`
: `${breakdown.name} as label`;
sb.groupBy.label = `label`;
}
if (event.segment === 'user') {
sb.select.count = `countDistinct(profile_id) as count`;
}
if (event.segment === 'user_average') {
sb.select.count = `COUNT(*)::float / COUNT(DISTINCT profile_id)::float as count`;
}
if (event.segment === 'property_sum' && event.property) {
sb.select.count = `sum(${event.property}) as count`;
}
if (event.segment === 'property_average' && event.property) {
sb.select.count = `avg(${event.property}) as count`;
}
if (event.segment === 'one_event_per_user') {
sb.from = `(
SELECT DISTINCT ON (profile_id) * from events WHERE ${join(
sb.where,
' AND '
)}
ORDER BY profile_id, created_at DESC
) as subQuery`;
return log(`${getSelect()} ${getFrom()} ${getGroupBy()} ${getOrderBy()}`);
}
return log(
`${getSelect()} ${getFrom()} ${getWhere()} ${getGroupBy()} ${getOrderBy()}`
);
}
export function getEventFiltersWhereClause(
sb: SqlBuilderObject,
filters: any[]
) {
filters.forEach((filter, index) => {
const id = `f${index}`;
const { name, value, operator } = filter;
if (name.startsWith('properties.')) {
const whereFrom = `mapValues(mapExtractKeyLike(properties, '${name
.replace(/^properties\./, '')
.replace('.*.', '.%.')}'))`;
switch (operator) {
case 'is': {
sb.where[id] = `arrayExists(x -> ${value
.map((val) => `x = '${String(val).trim()}'`)
.join(' OR ')}, ${whereFrom})`;
break;
}
case 'isNot': {
sb.where[id] = `arrayExists(x -> ${value
.map((val) => `x != '${String(val).trim()}'`)
.join(' OR ')}, ${whereFrom})`;
break;
}
case 'contains': {
sb.where[id] = `arrayExists(x -> ${value
.map((val) => `x LIKE '%${String(val).trim()}%'`)
.join(' OR ')}, ${whereFrom})`;
break;
}
case 'doesNotContain': {
sb.where[id] = `arrayExists(x -> ${value
.map((val) => `x NOT LIKE '%${String(val).trim()}%'`)
.join(' OR ')}, ${whereFrom})`;
break;
}
}
} else {
switch (operator) {
case 'is': {
sb.where[id] = `${name} IN (${value
.map((val) => `'${String(val).trim()}'`)
.join(', ')})`;
break;
}
case 'isNot': {
sb.where[id] = `${name} NOT IN (${value
.map((val) => `'${String(val).trim()}'`)
.join(', ')})`;
break;
}
case 'contains': {
sb.where[id] = value
.map((val) => `${name} LIKE '%${String(val).trim()}%'`)
.join(' OR ');
break;
}
case 'doesNotContain': {
sb.where[id] = value
.map((val) => `${name} NOT LIKE '%${String(val).trim()}%'`)
.join(' OR ');
break;
}
}
}
});
return sb;
}

View File

@@ -1,4 +1,4 @@
import { omit } from 'ramda'; import { omit, uniq } from 'ramda';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { randomSplitName, toDots } from '@mixan/common'; import { randomSplitName, toDots } from '@mixan/common';
@@ -10,10 +10,11 @@ import {
convertClickhouseDateToJs, convertClickhouseDateToJs,
formatClickhouseDate, formatClickhouseDate,
} from '../clickhouse-client'; } from '../clickhouse-client';
import type { Prisma } from '../prisma-client'; import type { EventMeta, Prisma } from '../prisma-client';
import { db } from '../prisma-client'; import { db } from '../prisma-client';
import type { IDBProfile } from '../prisma-types'; import type { IDBProfile } from '../prisma-types';
import { createSqlBuilder } from '../sql-builder'; import { createSqlBuilder } from '../sql-builder';
import { getEventFiltersWhereClause } from './chart.service';
export interface IClickhouseEvent { export interface IClickhouseEvent {
id: string; id: string;
@@ -37,7 +38,10 @@ export interface IClickhouseEvent {
device: string; device: string;
brand: string; brand: string;
model: string; model: string;
// They do not exist here. Just make ts happy for now
profile?: IDBProfile; profile?: IDBProfile;
meta?: EventMeta;
} }
export function transformEvent( export function transformEvent(
@@ -66,6 +70,7 @@ export function transformEvent(
referrerName: event.referrer_name, referrerName: event.referrer_name,
referrerType: event.referrer_type, referrerType: event.referrer_type,
profile: event.profile, profile: event.profile,
meta: event.meta,
}; };
} }
@@ -95,11 +100,13 @@ export interface IServiceCreateEventPayload {
referrer: string | undefined; referrer: string | undefined;
referrerName: string | undefined; referrerName: string | undefined;
referrerType: string | undefined; referrerType: string | undefined;
profile?: IDBProfile; profile: IDBProfile | undefined;
meta: EventMeta | undefined;
} }
interface GetEventsOptions { interface GetEventsOptions {
profile?: boolean | Prisma.ProfileSelect; profile?: boolean | Prisma.ProfileSelect;
meta?: boolean | Prisma.EventMetaSelect;
} }
export async function getLiveVisitors(projectId: string) { export async function getLiveVisitors(projectId: string) {
@@ -129,6 +136,22 @@ export async function getEvents(
| undefined; | undefined;
} }
} }
if (options.meta) {
const names = uniq(events.map((e) => e.name));
const metas = await db.eventMeta.findMany({
where: {
name: {
in: names,
},
project_id: events[0]?.project_id,
},
select: options.meta === true ? undefined : options.meta,
});
for (const event of events) {
event.meta = metas.find((m) => m.name === event.name);
}
}
return events.map(transformEvent); return events.map(transformEvent);
} }
@@ -227,7 +250,8 @@ interface GetEventListOptions {
projectId: string; projectId: string;
profileId?: string; profileId?: string;
take: number; take: number;
cursor?: string; cursor?: number;
filters: any[];
} }
export async function getEventList({ export async function getEventList({
@@ -235,22 +259,44 @@ export async function getEventList({
take, take,
projectId, projectId,
profileId, profileId,
filters,
}: GetEventListOptions) { }: GetEventListOptions) {
const { sb, getSql } = createSqlBuilder(); const { sb, getSql } = createSqlBuilder();
sb.limit = take; sb.limit = take;
sb.offset = (cursor ?? 0) * take;
sb.where.projectId = `project_id = '${projectId}'`; sb.where.projectId = `project_id = '${projectId}'`;
if (profileId) { if (profileId) {
sb.where.profileId = `profile_id = '${profileId}'`; sb.where.profileId = `profile_id = '${profileId}'`;
} }
if (cursor) { getEventFiltersWhereClause(sb, filters);
sb.where.cursor = `created_at <= '${formatClickhouseDate(cursor)}'`;
} // if (cursor) {
// sb.where.cursor = `created_at <= '${formatClickhouseDate(cursor)}'`;
// }
sb.orderBy.created_at = 'created_at DESC'; sb.orderBy.created_at = 'created_at DESC';
const res = await getEvents(getSql(), { profile: true }); return getEvents(getSql(), { profile: true, meta: true });
}
return res;
export async function getEventsCount({
projectId,
profileId,
filters,
}: Omit<GetEventListOptions, 'cursor' | 'take'>) {
const { sb, getSql } = createSqlBuilder();
sb.where.projectId = `project_id = '${projectId}'`;
if (profileId) {
sb.where.profileId = `profile_id = '${profileId}'`;
}
getEventFiltersWhereClause(sb, filters);
const res = await chQuery<{ count: number }>(
getSql().replace('*', 'count(*) as count')
);
return res[0]?.count ?? 0;
} }

View File

@@ -1,8 +1,4 @@
export function createSqlBuilder() { export interface SqlBuilderObject {
const join = (obj: Record<string, string> | string[], joiner: string) =>
Object.values(obj).filter(Boolean).join(joiner);
const sb: {
where: Record<string, string>; where: Record<string, string>;
select: Record<string, string>; select: Record<string, string>;
groupBy: Record<string, string>; groupBy: Record<string, string>;
@@ -10,7 +6,13 @@ export function createSqlBuilder() {
from: string; from: string;
limit: number | undefined; limit: number | undefined;
offset: number | undefined; offset: number | undefined;
} = { }
export function createSqlBuilder() {
const join = (obj: Record<string, string> | string[], joiner: string) =>
Object.values(obj).filter(Boolean).join(joiner);
const sb: SqlBuilderObject = {
where: {}, where: {},
from: 'openpanel.events', from: 'openpanel.events',
select: {}, select: {},