improve event list / event details

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-03-08 22:55:48 +01:00
parent 8b346f8466
commit 22a37b698d
19 changed files with 834 additions and 790 deletions

View File

@@ -249,6 +249,7 @@ export async function postEvent(
if (duration < 0) {
contextLogger.send('duration is wrong', {
payload,
duration,
});
} else {
// Skip update duration if it's wrong
@@ -270,6 +271,7 @@ export async function postEvent(
} else if (payload.name !== 'screen_view') {
contextLogger.send('no previous job', {
prevEventJob,
payload,
});
}

View File

@@ -78,3 +78,32 @@ export function wsVisitors(
redisSub.off('pmessage', pmessage);
});
}
export function wsEvents(
connection: {
socket: WebSocket;
},
req: FastifyRequest<{
Params: {
projectId: string;
};
}>
) {
const { params } = req;
redisSub.subscribe('event');
const message = (channel: string, message: string) => {
const event = getSafeJson<IServiceCreateEventPayload>(message);
if (event?.projectId === params.projectId) {
connection.socket.send(JSON.stringify(event));
}
};
redisSub.on('message', message);
connection.socket.on('close', () => {
redisSub.unsubscribe('event');
redisSub.off('message', message);
});
}

View File

@@ -17,6 +17,7 @@ const liveRouter: FastifyPluginCallback = (fastify, opts, done) => {
{ websocket: true },
controller.wsVisitors
);
fastify.get('/events/:projectId', { websocket: true }, controller.wsEvents);
done();
});

View File

@@ -0,0 +1,42 @@
import { ChartSwitchShortcut } from '@/components/report/chart';
import type { IChartEvent } from '@mixan/validation';
interface Props {
projectId: string;
events?: string[];
filters?: any[];
}
export function EventChart({ projectId, filters, events }: Props) {
const fallback: IChartEvent[] = [
{
id: 'A',
name: '*',
displayName: 'All events',
segment: 'event',
filters: filters ?? [],
},
];
return (
<div className="card p-4 mb-8">
<ChartSwitchShortcut
projectId={projectId}
range="1m"
chartType="histogram"
events={
events && events.length > 0
? events.map((name) => ({
id: name,
name,
displayName: name,
segment: 'event',
filters: filters ?? [],
}))
: fallback
}
/>
</div>
);
}

View File

@@ -0,0 +1,204 @@
'use client';
import type { Dispatch, SetStateAction } from 'react';
import { ChartSwitch, ChartSwitchShortcut } from '@/components/report/chart';
import { Chart } from '@/components/report/chart/Chart';
import { KeyValue } from '@/components/ui/key-value';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { round } from 'mathjs';
import type { IServiceCreateEventPayload } from '@mixan/db';
interface Props {
event: IServiceCreateEventPayload;
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
}
export function EventDetails({ event, open, setOpen }: Props) {
const { name } = event;
const [, setFilter] = useEventQueryFilters({ shallow: false });
const common = [
{
name: 'Duration',
value: event.duration ? round(event.duration / 1000, 1) : undefined,
},
{
name: 'Referrer',
value: event.referrer,
onClick() {
setFilter('referrer', event.referrer ?? '');
},
},
{
name: 'Referrer name',
value: event.referrerName,
onClick() {
setFilter('referrer_name', event.referrerName ?? '');
},
},
{
name: 'Referrer type',
value: event.referrerType,
onClick() {
setFilter('referrer_type', event.referrerType ?? '');
},
},
{
name: 'Brand',
value: event.brand,
onClick() {
setFilter('brand', event.brand ?? '');
},
},
{
name: 'Model',
value: event.model,
onClick() {
setFilter('model', event.model ?? '');
},
},
{
name: 'Browser',
value: event.browser,
onClick() {
setFilter('browser', event.browser ?? '');
},
},
{
name: 'Browser version',
value: event.browserVersion,
onClick() {
setFilter('browser_version', event.browserVersion ?? '');
},
},
{
name: 'OS',
value: event.os,
onClick() {
setFilter('os', event.os ?? '');
},
},
{
name: 'OS version',
value: event.osVersion,
onClick() {
setFilter('os_version', event.osVersion ?? '');
},
},
{
name: 'City',
value: event.city,
onClick() {
setFilter('city', event.city ?? '');
},
},
{
name: 'Region',
value: event.region,
onClick() {
setFilter('region', event.region ?? '');
},
},
{
name: 'Country',
value: event.country,
onClick() {
setFilter('country', event.country ?? '');
},
},
{
name: 'Continent',
value: event.continent,
onClick() {
setFilter('continent', event.continent ?? '');
},
},
{
name: 'Device',
value: event.device,
onClick() {
setFilter('device', event.device ?? '');
},
},
].filter((item) => typeof item.value === 'string' && item.value);
const properties = Object.entries(event.properties)
.map(([name, value]) => ({
name,
value: value as string | number | undefined,
}))
.filter((item) => typeof item.value === 'string' && item.value);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent className="overflow-y-scroll">
<div className="overflow-y-scroll">
<div className="flex flex-col gap-8">
<SheetHeader>
<SheetTitle>{name.replace('_', ' ')}</SheetTitle>
</SheetHeader>
{properties.length > 0 && (
<div>
<div className="text-sm font-medium mb-2">Params</div>
<div className="flex gap-2 flex-wrap">
{properties.map((item) => (
<KeyValue
key={item.name}
name={item.name}
value={item.value}
onClick={() => {
setFilter(
`properties.${item.name}`,
item.value ? String(item.value) : '',
'is'
);
}}
/>
))}
</div>
</div>
)}
<div>
<div className="text-sm font-medium mb-2">Common</div>
<div className="flex gap-2 flex-wrap">
{common.map((item) => (
<KeyValue
key={item.name}
name={item.name}
value={item.value}
onClick={item.onClick}
/>
))}
</div>
</div>
<div>
<div className="text-sm font-medium mb-2">Similar events</div>
<ChartSwitchShortcut
projectId={event.projectId}
chartType="histogram"
events={[
{
id: 'A',
name: event.name,
displayName: 'Similar events',
segment: 'event',
filters: [],
},
]}
/>
</div>
</div>
</div>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,176 @@
import type { Dispatch, SetStateAction } from 'react';
import { useEffect, useState } from 'react';
import { api } from '@/app/_trpc/client';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { cn } from '@/utils/cn';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import type { IServiceCreateEventPayload } from '@mixan/db';
import {
EventIconColors,
EventIconMapper,
EventIconRecords,
} from './event-icon';
interface Props {
event: IServiceCreateEventPayload;
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
}
export function EventEdit({ event, open, setOpen }: Props) {
const router = useRouter();
const { name, meta, projectId } = event;
const [selectedIcon, setIcon] = useState(
meta?.icon ??
EventIconRecords[name]?.icon ??
EventIconRecords.default?.icon ??
''
);
const [selectedColor, setColor] = useState(
meta?.color ??
EventIconRecords[name]?.color ??
EventIconRecords.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 = EventIconMapper[selectedIcon]!;
const mutation = api.event.updateEventMeta.useMutation({
onSuccess() {
// @ts-expect-error
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 (
<Sheet open={open} onOpenChange={setOpen}>
<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>
</label>
</div>
<div>
<Label className="mb-4 block">Pick a icon</Label>
<div className="flex flex-wrap gap-4">
{Object.entries(EventIconMapper).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">
{EventIconColors.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

@@ -40,7 +40,7 @@ type EventIconProps = VariantProps<typeof variants> & {
className?: string;
};
const records: Record<
export const EventIconRecords: Record<
string,
{
icon: string;
@@ -59,9 +59,13 @@ const records: Record<
icon: 'ActivityIcon',
color: 'teal',
},
link_out: {
icon: 'ExternalLinkIcon',
color: 'indigo',
},
};
const icons: Record<string, LucideIcon> = {
export const EventIconMapper: Record<string, LucideIcon> = {
DownloadIcon: Icons.DownloadIcon,
BotIcon: Icons.BotIcon,
BoxIcon: Icons.BoxIcon,
@@ -76,9 +80,41 @@ const icons: Record<string, LucideIcon> = {
ConeIcon: Icons.ConeIcon,
MonitorPlayIcon: Icons.MonitorPlayIcon,
PizzaIcon: Icons.PizzaIcon,
SearchIcon: Icons.SearchIcon,
HomeIcon: Icons.HomeIcon,
MailIcon: Icons.MailIcon,
AngryIcon: Icons.AngryIcon,
AnnoyedIcon: Icons.AnnoyedIcon,
ArchiveIcon: Icons.ArchiveIcon,
AwardIcon: Icons.AwardIcon,
BadgeCheckIcon: Icons.BadgeCheckIcon,
BeerIcon: Icons.BeerIcon,
BluetoothIcon: Icons.BluetoothIcon,
BookIcon: Icons.BookIcon,
BookmarkIcon: Icons.BookmarkIcon,
BookCheckIcon: Icons.BookCheckIcon,
BookMinusIcon: Icons.BookMinusIcon,
BookPlusIcon: Icons.BookPlusIcon,
CalendarIcon: Icons.CalendarIcon,
ClockIcon: Icons.ClockIcon,
CogIcon: Icons.CogIcon,
LoaderIcon: Icons.LoaderIcon,
CrownIcon: Icons.CrownIcon,
FileIcon: Icons.FileIcon,
KeyRoundIcon: Icons.KeyRoundIcon,
GemIcon: Icons.GemIcon,
GlobeIcon: Icons.GlobeIcon,
LightbulbIcon: Icons.LightbulbIcon,
LightbulbOffIcon: Icons.LightbulbOffIcon,
LockIcon: Icons.LockIcon,
MessageCircleIcon: Icons.MessageCircleIcon,
RadioIcon: Icons.RadioIcon,
RepeatIcon: Icons.RepeatIcon,
ShareIcon: Icons.ShareIcon,
ExternalLinkIcon: Icons.ExternalLinkIcon,
};
const colors = [
export const EventIconColors = [
'rose',
'pink',
'fuchsia',
@@ -103,152 +139,23 @@ const colors = [
'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]!;
export function EventIcon({ className, name, size, meta }: EventIconProps) {
const Icon =
icons[meta?.icon ?? records[name]?.icon ?? records.default?.icon ?? '']!;
EventIconMapper[
meta?.icon ??
EventIconRecords[name]?.icon ??
EventIconRecords.default?.icon ??
''
]!;
const color =
meta?.color ?? records[name]?.color ?? records.default?.color ?? '';
const mutation = api.event.updateEventMeta.useMutation({
onSuccess() {
// @ts-expect-error
document.querySelector('#close-sheet')?.click();
toast('Event updated');
router.refresh();
},
});
const getBg = (color: string) => `bg-${color}-200`;
const getText = (color: string) => `text-${color}-700`;
meta?.color ??
EventIconRecords[name]?.color ??
EventIconRecords.default?.color ??
'';
return (
<Sheet>
<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>
</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>
<div className={cn(`bg-${color}-200`, variants({ size }), className)}>
<Icon size={20} className={`text-${color}-700`} />
</div>
);
}

View File

@@ -1,275 +0,0 @@
'use client';
import { ExpandableListItem } from '@/components/general/ExpandableListItem';
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
import { SerieIcon } from '@/components/report/chart/SerieIcon';
import { KeyValue, KeyValueSubtle } from '@/components/ui/key-value';
import { useAppParams } from '@/hooks/useAppParams';
import {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { getProfileName } from '@/utils/getters';
import { round } from '@/utils/math';
import Link from 'next/link';
import { uniq } from 'ramda';
import type { IServiceCreateEventPayload } from '@mixan/db';
import { EventIcon } from './event-icon';
type EventListItemProps = IServiceCreateEventPayload;
export function EventListItem(props: EventListItemProps) {
const {
profile,
createdAt,
name,
properties,
path,
duration,
referrer,
referrerName,
referrerType,
brand,
model,
browser,
browserVersion,
os,
osVersion,
city,
region,
country,
continent,
device,
projectId,
meta,
} = props;
const params = useAppParams();
const [, setEvents] = useEventQueryNamesFilter({ shallow: false });
const [, setFilter] = useEventQueryFilters({ shallow: false });
const keyValueList = [
{
name: 'Duration',
value: duration ? round(duration / 1000, 1) : undefined,
},
{
name: 'Referrer',
value: referrer,
onClick() {
setFilter('referrer', referrer ?? '');
},
},
{
name: 'Referrer name',
value: referrerName,
onClick() {
setFilter('referrer_name', referrerName ?? '');
},
},
{
name: 'Referrer type',
value: referrerType,
onClick() {
setFilter('referrer_type', referrerType ?? '');
},
},
{
name: 'Brand',
value: brand,
onClick() {
setFilter('brand', brand ?? '');
},
},
{
name: 'Model',
value: model,
onClick() {
setFilter('model', model ?? '');
},
},
{
name: 'Browser',
value: browser,
onClick() {
setFilter('browser', browser ?? '');
},
},
{
name: 'Browser version',
value: browserVersion,
onClick() {
setFilter('browser_version', browserVersion ?? '');
},
},
{
name: 'OS',
value: os,
onClick() {
setFilter('os', os ?? '');
},
},
{
name: 'OS version',
value: osVersion,
onClick() {
setFilter('os_version', osVersion ?? '');
},
},
{
name: 'City',
value: city,
onClick() {
setFilter('city', city ?? '');
},
},
{
name: 'Region',
value: region,
onClick() {
setFilter('region', region ?? '');
},
},
{
name: 'Country',
value: country,
onClick() {
setFilter('country', country ?? '');
},
},
{
name: 'Continent',
value: continent,
onClick() {
setFilter('continent', continent ?? '');
},
},
{
name: 'Device',
value: device,
onClick() {
setFilter('device', device ?? '');
},
},
].filter((item) => typeof item.value === 'string' && item.value);
const propertiesList = Object.entries(properties)
.map(([name, value]) => ({
name,
value: value as string | number | undefined,
}))
.filter((item) => typeof item.value === 'string' && item.value);
return (
<div className="p-4 flex gap-4">
<EventIcon name={name} meta={meta} projectId={projectId} />
<div>
{!!profile && (
<div className="flex gap-2 items-center mb-1">
<ProfileAvatar size="xs" {...profile} />
<Link
className="font-medium text-sm text-muted-foreground"
href={`/${params.organizationId}/${params.projectId}/profiles/${profile.id}`}
>
{getProfileName(profile)}
</Link>
</div>
)}
<div className="[&>span]:inline text-slate-700">
<span
className={cn(
'font-medium bg-muted p-0.5 px-1 leading-none rounded-md',
meta?.conversion && `bg-${meta.color}-100 text-${meta.color}-800`
)}
>
{meta?.conversion && '⭐️ '}
{name}
</span>
<span className="text-muted-foreground">{' at '}</span>
<span>{path}</span>
<span className="text-muted-foreground">{' from '}</span>
<span>
{city || 'Unknown'}, {country}
</span>
<span className="text-muted-foreground">{' using '}</span>
<span className="!inline-flex items-center gap-1">
{brand || device} <SerieIcon name={device} />
</span>
</div>
</div>
</div>
);
// return (
// <ExpandableListItem
// className={cn(meta?.conversion && `bg-${meta.color}-50`)}
// title={
// <button onClick={() => setEvents((p) => uniq([...p, name]))}>
// {name.split('_').join(' ')}
// </button>
// }
// content={
// <>
// <KeyValueSubtle name="Time" value={createdAt.toLocaleString()} />
// {profile?.id === props.deviceId && (
// <KeyValueSubtle name="Anonymous" value={'Yes'} />
// )}
// {profile && (
// <KeyValueSubtle
// name="Profile"
// value={getProfileName(profile)}
// href={`/${params.organizationId}/${params.projectId}/profiles/${profile.id}`}
// />
// )}
// {path && (
// <KeyValueSubtle
// name="Path"
// value={path}
// onClick={() => {
// setFilter('path', path);
// }}
// />
// )}
// </>
// }
// image={<EventIcon name={name} meta={meta} projectId={projectId} />}
// >
// <div className="bg-white p-4">
// {propertiesList.length > 0 && (
// <div className="flex flex-col gap-4 mb-6">
// <div className="font-medium">Your properties</div>
// <div className="flex flex-wrap gap-x-4 gap-y-2">
// {propertiesList.map((item) => (
// <KeyValue
// key={item.name}
// name={item.name}
// value={item.value}
// onClick={() => {
// setFilter(
// `properties.${item.name}`,
// item.value ? String(item.value) : '',
// 'is'
// );
// }}
// />
// ))}
// </div>
// </div>
// )}
// <div className="flex flex-col gap-4">
// <div className="font-medium">Properties</div>
// <div className="flex flex-wrap gap-x-4 gap-y-2">
// {keyValueList.map((item) => (
// <KeyValue
// onClick={() => item.onClick?.()}
// key={item.name}
// name={item.name}
// value={item.value}
// />
// ))}
// </div>
// </div>
// </div>
// </ExpandableListItem>
// );
}

View File

@@ -1,22 +1,19 @@
'use client';
import { ExpandableListItem } from '@/components/general/ExpandableListItem';
import { useState } from 'react';
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
import { SerieIcon } from '@/components/report/chart/SerieIcon';
import { KeyValue, KeyValueSubtle } from '@/components/ui/key-value';
import { KeyValueSubtle } from '@/components/ui/key-value';
import { useAppParams } from '@/hooks/useAppParams';
import {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { useNumber } from '@/hooks/useNumerFormatter';
import { cn } from '@/utils/cn';
import { getProfileName } from '@/utils/getters';
import { round } from '@/utils/math';
import Link from 'next/link';
import { uniq } from 'ramda';
import type { IServiceCreateEventPayload } from '@mixan/db';
import { EventDetails } from './event-details';
import { EventEdit } from './event-edit';
import { EventIcon } from './event-icon';
type EventListItemProps = IServiceCreateEventPayload;
@@ -26,300 +23,111 @@ export function EventListItem(props: EventListItemProps) {
profile,
createdAt,
name,
properties,
path,
duration,
referrer,
referrerName,
referrerType,
brand,
model,
browser,
browserVersion,
os,
osVersion,
city,
region,
country,
continent,
device,
os,
projectId,
meta,
} = props;
const params = useAppParams();
const [, setEvents] = useEventQueryNamesFilter({ shallow: false });
const [, setFilter] = useEventQueryFilters({ shallow: false });
const keyValueList = [
{
name: 'Duration',
value: duration ? round(duration / 1000, 1) : undefined,
},
{
name: 'Referrer',
value: referrer,
onClick() {
setFilter('referrer', referrer ?? '');
},
},
{
name: 'Referrer name',
value: referrerName,
onClick() {
setFilter('referrer_name', referrerName ?? '');
},
},
{
name: 'Referrer type',
value: referrerType,
onClick() {
setFilter('referrer_type', referrerType ?? '');
},
},
{
name: 'Brand',
value: brand,
onClick() {
setFilter('brand', brand ?? '');
},
},
{
name: 'Model',
value: model,
onClick() {
setFilter('model', model ?? '');
},
},
{
name: 'Browser',
value: browser,
onClick() {
setFilter('browser', browser ?? '');
},
},
{
name: 'Browser version',
value: browserVersion,
onClick() {
setFilter('browser_version', browserVersion ?? '');
},
},
{
name: 'OS',
value: os,
onClick() {
setFilter('os', os ?? '');
},
},
{
name: 'OS version',
value: osVersion,
onClick() {
setFilter('os_version', osVersion ?? '');
},
},
{
name: 'City',
value: city,
onClick() {
setFilter('city', city ?? '');
},
},
{
name: 'Region',
value: region,
onClick() {
setFilter('region', region ?? '');
},
},
{
name: 'Country',
value: country,
onClick() {
setFilter('country', country ?? '');
},
},
{
name: 'Continent',
value: continent,
onClick() {
setFilter('continent', continent ?? '');
},
},
{
name: 'Device',
value: device,
onClick() {
setFilter('device', device ?? '');
},
},
].filter((item) => typeof item.value === 'string' && item.value);
const propertiesList = Object.entries(properties)
.map(([name, value]) => ({
name,
value: value as string | number | undefined,
}))
.filter((item) => typeof item.value === 'string' && item.value);
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
const [isEditOpen, setIsEditOpen] = useState(false);
const number = useNumber();
return (
<div className="p-4 flex flex-col gap-2 hover:bg-slate-50 rounded-lg transition-colors">
<div className="flex justify-between items-center">
<div className="flex gap-4 items-center">
<EventIcon name={name} meta={meta} projectId={projectId} />
<div className="font-semibold">{name.replace(/_/g, ' ')}</div>
</div>
<div className="text-muted-foreground">
{createdAt.toLocaleTimeString()}
</div>
</div>
<div className="flex flex-wrap gap-2">
{path && <KeyValueSubtle name={'Path'} value={path} />}
{profile && (
<KeyValueSubtle
name={'Profile'}
value={
<>
{profile.avatar && <ProfileAvatar size="xs" {...profile} />}
{getProfileName(profile)}
</>
}
href={`/${params.organizationId}/${params.projectId}/profiles/${profile.id}`}
/>
<>
<EventDetails
event={props}
open={isDetailsOpen}
setOpen={setIsDetailsOpen}
/>
<EventEdit event={props} open={isEditOpen} setOpen={setIsEditOpen} />
<div
className={cn(
'p-4 flex flex-col gap-2 hover:bg-slate-50 rounded-lg transition-colors',
meta?.conversion && `bg-${meta.color}-50 hover:bg-${meta.color}-100`
)}
<KeyValueSubtle
name={'From'}
value={
<>
<SerieIcon name={country} />
{city}
</>
}
/>
<KeyValueSubtle
name={'Device'}
value={
<>
<SerieIcon name={device} />
{brand}
</>
}
/>
{browser !== 'WebKit' && browser !== '' && (
<KeyValueSubtle
name={'Browser'}
value={
<>
<SerieIcon name={browser} />
{browser}
</>
}
/>
)}
{/* {!!profile && (
<div className="flex gap-2 items-center mb-1">
<ProfileAvatar size="xs" {...profile} />
<Link
className="font-medium text-sm text-muted-foreground"
href={`/${params.organizationId}/${params.projectId}/profiles/${profile.id}`}
>
<div className="flex justify-between items-center">
<div className="flex gap-4 items-center">
<button onClick={() => setIsEditOpen(true)}>
<EventIcon name={name} meta={meta} projectId={projectId} />
</button>
<button
onClick={() => setIsDetailsOpen(true)}
className="font-semibold hover:underline"
>
{getProfileName(profile)}
</Link>
{name.replace(/_/g, ' ')}
</button>
</div>
)}
<div className="[&>span]:inline text-slate-700">
<span
className={cn(
'font-medium bg-muted p-0.5 px-1 leading-none rounded-md',
meta?.conversion && `bg-${meta.color}-100 text-${meta.color}-800`
)}
>
{meta?.conversion && '⭐️ '}
{name}
</span>
<span className="text-muted-foreground">{' at '}</span>
<span>{path}</span>
<span className="text-muted-foreground">{' from '}</span>
<span>
{city || 'Unknown'}, {country}
</span>
<span className="text-muted-foreground">{' using '}</span>
<span className="!inline-flex items-center gap-1">
{brand || device} <SerieIcon name={device} />
</span>
</div> */}
<div className="text-muted-foreground text-sm">
{createdAt.toLocaleTimeString()}
</div>
</div>
<div className="flex flex-wrap gap-2">
{path && (
<KeyValueSubtle
name={'Path'}
value={
path +
(duration
? ` (${number.shortWithUnit(duration / 1000, 'min')})`
: '')
}
/>
)}
{profile && (
<KeyValueSubtle
name={'Profile'}
value={
<>
{profile.avatar && <ProfileAvatar size="xs" {...profile} />}
{getProfileName(profile)}
</>
}
href={`/${params.organizationId}/${params.projectId}/profiles/${profile.id}`}
/>
)}
<KeyValueSubtle
name={'From'}
onClick={() => setFilter('city', city)}
value={
<>
{country && <SerieIcon name={country} />}
{city}
</>
}
/>
<KeyValueSubtle
name={'Device'}
onClick={() => setFilter('device', device)}
value={
<>
{device && <SerieIcon name={device} />}
{brand || os}
</>
}
/>
{browser !== 'WebKit' && browser !== '' && (
<KeyValueSubtle
name={'Browser'}
onClick={() => setFilter('browser', browser)}
value={
<>
{browser && <SerieIcon name={browser} />}
{browser}
</>
}
/>
)}
</div>
</div>
</div>
</>
);
// return (
// <ExpandableListItem
// className={cn(meta?.conversion && `bg-${meta.color}-50`)}
// title={
// <button onClick={() => setEvents((p) => uniq([...p, name]))}>
// {name.split('_').join(' ')}
// </button>
// }
// content={
// <>
// <KeyValueSubtle name="Time" value={createdAt.toLocaleString()} />
// {profile?.id === props.deviceId && (
// <KeyValueSubtle name="Anonymous" value={'Yes'} />
// )}
// {profile && (
// <KeyValueSubtle
// name="Profile"
// value={getProfileName(profile)}
// href={`/${params.organizationId}/${params.projectId}/profiles/${profile.id}`}
// />
// )}
// {path && (
// <KeyValueSubtle
// name="Path"
// value={path}
// onClick={() => {
// setFilter('path', path);
// }}
// />
// )}
// </>
// }
// image={<EventIcon name={name} meta={meta} projectId={projectId} />}
// >
// <div className="bg-white p-4">
// {propertiesList.length > 0 && (
// <div className="flex flex-col gap-4 mb-6">
// <div className="font-medium">Your properties</div>
// <div className="flex flex-wrap gap-x-4 gap-y-2">
// {propertiesList.map((item) => (
// <KeyValue
// key={item.name}
// name={item.name}
// value={item.value}
// onClick={() => {
// setFilter(
// `properties.${item.name}`,
// item.value ? String(item.value) : '',
// 'is'
// );
// }}
// />
// ))}
// </div>
// </div>
// )}
// <div className="flex flex-col gap-4">
// <div className="font-medium">Properties</div>
// <div className="flex flex-wrap gap-x-4 gap-y-2">
// {keyValueList.map((item) => (
// <KeyValue
// onClick={() => item.onClick?.()}
// key={item.name}
// name={item.name}
// value={item.value}
// />
// ))}
// </div>
// </div>
// </div>
// </ExpandableListItem>
// );
}

View File

@@ -3,7 +3,9 @@
import { Fragment, Suspense } from 'react';
import { FullPageEmptyState } from '@/components/FullPageEmptyState';
import { Pagination } from '@/components/Pagination';
import { ChartSwitch, ChartSwitchShortcut } from '@/components/report/chart';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/useAppParams';
import { useCursor } from '@/hooks/useCursor';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { isSameDay } from 'date-fns';
@@ -12,6 +14,7 @@ import { GanttChartIcon } from 'lucide-react';
import type { IServiceCreateEventPayload } from '@mixan/db';
import { EventListItem } from './event-list-item';
import EventListener from './event-listener';
function showDateHeader(a: Date, b?: Date) {
if (!b) return true;
@@ -26,64 +29,62 @@ export function EventList({ data, count }: EventListProps) {
const { cursor, setCursor } = useCursor();
const [filters] = useEventQueryFilters();
return (
<Suspense>
<div className="p-4">
{data.length === 0 ? (
<FullPageEmptyState title="No events here" icon={GanttChartIcon}>
{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>We have not recieved any events yet</p>
<>
{data.length === 0 ? (
<FullPageEmptyState title="No events here" icon={GanttChartIcon}>
{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>We have not recieved any events yet</p>
)}
</>
)}
</FullPageEmptyState>
) : (
<>
<div className="flex justify-between">
<EventListener />
<Pagination
cursor={cursor}
setCursor={setCursor}
count={count}
take={50}
/>
</div>
<div className="flex flex-col my-4 card p-4">
{data.map((item, index, list) => (
<Fragment key={item.id}>
{showDateHeader(item.createdAt, list[index - 1]?.createdAt) && (
<div className="text-muted-foreground font-medium text-sm [&:not(:first-child)]:mt-12 text-center">
{item.createdAt.toLocaleDateString()}
</div>
)}
</>
)}
</FullPageEmptyState>
) : (
<>
<Pagination
cursor={cursor}
setCursor={setCursor}
count={count}
take={50}
/>
<div className="flex flex-col my-4 card p-4">
{data.map((item, index, list) => (
<Fragment key={item.id}>
{showDateHeader(
item.createdAt,
list[index - 1]?.createdAt
) && (
<div className="text-muted-foreground font-medium text-sm [&:not(:first-child)]:mt-12 text-center">
{item.createdAt.toLocaleDateString()}
</div>
)}
<EventListItem {...item} />
</Fragment>
))}
</div>
<Pagination
cursor={cursor}
setCursor={setCursor}
count={count}
take={50}
/>
</>
)}
</div>
</Suspense>
<EventListItem {...item} />
</Fragment>
))}
</div>
<Pagination
cursor={cursor}
setCursor={setCursor}
count={count}
take={50}
/>
</>
)}
</>
);
}

View File

@@ -0,0 +1,92 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useAppParams } from '@/hooks/useAppParams';
import { cn } from '@/utils/cn';
import { useQueryClient } from '@tanstack/react-query';
import dynamic from 'next/dynamic';
import { useRouter } from 'next/navigation';
import useWebSocket from 'react-use-websocket';
import { toast } from 'sonner';
import type { IServiceCreateEventPayload } from '@mixan/db';
const AnimatedNumbers = dynamic(() => import('react-animated-numbers'), {
ssr: false,
loading: () => <div>0</div>,
});
export default function EventListener() {
const router = useRouter();
const { projectId } = useAppParams();
const ws = String(process.env.NEXT_PUBLIC_API_URL)
.replace(/^https/, 'wss')
.replace(/^http/, 'ws');
const [counter, setCounter] = useState(0);
const [socketUrl] = useState(`${ws}/live/events/${projectId}`);
useWebSocket(socketUrl, {
shouldReconnect: () => true,
onMessage(payload) {
const event = JSON.parse(payload.data) as IServiceCreateEventPayload;
if (event?.name) {
setCounter((prev) => prev + 1);
toast(`New event ${event.name} from ${event.country}!`);
}
},
});
return (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => {
setCounter(0);
router.refresh();
}}
className="bg-white border border-border rounded h-8 px-3 leading-none flex items-center text-sm font-medium gap-2"
>
<div className="relative">
<div
className={cn(
'bg-emerald-500 h-3 w-3 rounded-full animate-ping opacity-100 transition-all'
)}
></div>
<div
className={cn(
'bg-emerald-500 h-3 w-3 rounded-full absolute top-0 left-0 transition-all'
)}
></div>
</div>
{counter === 0 ? (
'Listening to events'
) : (
<>
<AnimatedNumbers
includeComma
transitions={(index) => ({
type: 'spring',
duration: index + 0.3,
damping: 10,
stiffness: 200,
})}
animateToNumber={counter}
locale="en"
/>
new events
</>
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{counter === 0 ? 'Listening to new events' : 'Click to refresh'}
</TooltipContent>
</Tooltip>
);
}

View File

@@ -11,6 +11,7 @@ import { parseAsInteger } from 'nuqs';
import { getEventList, getEventsCount } from '@mixan/db';
import { StickyBelowHeader } from '../layout-sticky-below-header';
import { EventChart } from './event-chart';
import { EventList } from './event-list';
interface PageProps {
@@ -33,18 +34,24 @@ export default async function Page({
params: { projectId, organizationId },
searchParams,
}: PageProps) {
const filters =
eventQueryFiltersParser.parseServerSide(searchParams.f ?? '') ?? undefined;
const eventsFilter = eventQueryNamesFilter.parseServerSide(
searchParams.events ?? ''
);
const [events, count] = await Promise.all([
getEventList({
cursor: parseAsInteger.parse(searchParams.cursor ?? '') ?? undefined,
cursor:
parseAsInteger.parseServerSide(searchParams.cursor ?? '') ?? undefined,
projectId,
take: 50,
events: eventQueryNamesFilter.parse(searchParams.events ?? ''),
filters: eventQueryFiltersParser.parse(searchParams.f ?? '') ?? undefined,
events: eventsFilter,
filters,
}),
getEventsCount({
projectId,
events: eventQueryNamesFilter.parse(searchParams.events ?? ''),
filters: eventQueryFiltersParser.parse(searchParams.f ?? '') ?? undefined,
events: eventsFilter,
filters,
}),
getExists(organizationId, projectId),
]);
@@ -63,7 +70,14 @@ export default async function Page({
nuqsOptions={nuqsOptions}
/>
</StickyBelowHeader>
<EventList data={events} count={count} />
<div className="p-4">
<EventChart
projectId={projectId}
events={eventsFilter}
filters={filters}
/>
<EventList data={events} count={count} />
</div>
</PageLayout>
);
}

View File

@@ -50,12 +50,15 @@ export default async function Page({
projectId,
profileId,
take: 50,
cursor: parseAsInteger.parse(searchParams.cursor ?? '') ?? undefined,
events: eventQueryNamesFilter.parse(searchParams.events ?? ''),
filters: eventQueryFiltersParser.parse(searchParams.f ?? '') ?? undefined,
cursor:
parseAsInteger.parseServerSide(searchParams.cursor ?? '') ?? undefined,
events: eventQueryNamesFilter.parseServerSide(searchParams.events ?? ''),
filters:
eventQueryFiltersParser.parseServerSide(searchParams.f ?? '') ??
undefined,
};
const startDate = parseAsString.parse(searchParams.startDate);
const endDate = parseAsString.parse(searchParams.endDate);
const startDate = parseAsString.parseServerSide(searchParams.startDate);
const endDate = parseAsString.parseServerSide(searchParams.endDate);
const [profile, events, count, conversions] = await Promise.all([
getProfileById(profileId),
getEventList(eventListOptions),

View File

@@ -33,12 +33,12 @@ export default async function Page({
getProfileList({
projectId,
take: 50,
cursor: parseAsInteger.parse(cursor ?? '') ?? undefined,
filters: eventQueryFiltersParser.parse(f ?? '') ?? undefined,
cursor: parseAsInteger.parseServerSide(cursor ?? '') ?? undefined,
filters: eventQueryFiltersParser.parseServerSide(f ?? '') ?? undefined,
}),
getProfileListCount({
projectId,
filters: eventQueryFiltersParser.parse(f ?? '') ?? undefined,
filters: eventQueryFiltersParser.parseServerSide(f ?? '') ?? undefined,
}),
getExists(organizationId, projectId),
]);

View File

@@ -17,3 +17,36 @@ export const ChartSwitch = withChartProivder(function ChartSwitch(
return <Chart {...props} />;
});
interface ChartSwitchShortcutProps {
projectId: ReportChartProps['projectId'];
range?: ReportChartProps['range'];
previous?: ReportChartProps['previous'];
chartType?: ReportChartProps['chartType'];
interval?: ReportChartProps['interval'];
events: ReportChartProps['events'];
}
export const ChartSwitchShortcut = ({
projectId,
range = '7d',
previous = false,
chartType = 'linear',
interval = 'day',
events,
}: ChartSwitchShortcutProps) => {
return (
<ChartSwitch
projectId={projectId}
range={range}
breakdowns={[]}
previous={previous}
chartType={chartType}
interval={interval}
name="Random"
lineType="bump"
metric="sum"
events={events}
/>
);
};

View File

@@ -31,16 +31,16 @@ const SheetOverlay = React.forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
'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',
'overflow-y-auto fixed z-50 gap-4 bg-background p-6 rounded-lg 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: {
side: {
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
top: 'inset-x-4 top-4 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
bottom:
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
'inset-x-4 bottom-4 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
left: 'top-4 bottom-4 left-4 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
right:
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
'top-4 bottom-4 right-4 w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
},
},
defaultVariants: {
@@ -55,10 +55,12 @@ interface SheetContentProps
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = 'right', className, children, ...props }, ref) => (
SheetContentProps & {
onClose?: () => void;
}
>(({ side = 'right', className, children, onClose, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetOverlay onClick={onClose} />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
@@ -66,7 +68,10 @@ const SheetContent = React.forwardRef<
>
{children}
<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
onClick={onClose}
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"
>
<XIcon className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>

View File

@@ -4,6 +4,7 @@ import { isNil } from 'ramda';
export function fancyMinutes(time: number) {
const minutes = Math.floor(time / 60);
const seconds = round(time - minutes * 60, 0);
if (minutes === 0) return `${seconds}s`;
return `${minutes}m ${seconds}s`;
}

View File

@@ -51,6 +51,7 @@ const config = {
return [
`text-${color}-${variant}`,
`bg-${color}-${variant}`,
`hover:bg-${color}-${variant}`,
`border-${color}-${variant}`,
];
});

View File

@@ -21,6 +21,6 @@ const config = {
trailingComma: 'es5',
printWidth: 80,
tabWidth: 2,
}
};
export default config
export default config;