From 3a8404f7046862c1dc0c2cf40c83b4baf89f4de7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Mon, 18 Mar 2024 09:08:02 +0100 Subject: [PATCH] dashboard: update event and profile list --- .../events-per-day-chart.tsx} | 2 +- .../event-conversions-list.tsx | 43 +++++ .../events/event-conversions-list/index.tsx | 32 ++++ .../[projectId]/events/event-details.tsx | 136 ++++++++------- .../[projectId]/events/event-edit.tsx | 3 +- .../[projectId]/events/event-icon.tsx | 4 +- .../[projectId]/events/event-list-item.tsx | 160 +++++++----------- .../[projectId]/events/event-list.tsx | 36 ++-- .../[projectId]/events/event-listener.tsx | 2 +- .../[projectId]/events/page.tsx | 22 ++- .../[projectId]/profiles/[profileId]/page.tsx | 89 +++++++--- .../{profile-list.tsx => _profile-list.tsx} | 0 .../[projectId]/profiles/page.tsx | 37 ++-- .../profiles/profile-last-seen/index.tsx | 76 +++++++++ .../profiles/profile-list/index.tsx | 30 ++++ .../profiles/profile-list/profile-list.tsx | 110 ++++++++++++ .../profiles/profile-top/index.tsx | 70 ++++++++ apps/dashboard/src/components/Pagination.tsx | 69 ++++++-- .../src/components/events/ListProperties.tsx | 35 ---- .../components/events/ListPropertiesIcon.tsx | 69 ++++++++ .../overview/overview-chart-toggle.tsx | 23 +++ .../overview/overview-top-devices.tsx | 18 +- .../overview-top-events.tsx | 23 ++- .../components/overview/overview-top-geo.tsx | 14 +- .../overview/overview-top-pages.tsx | 14 +- .../overview/overview-top-sources.tsx | 25 +-- .../components/overview/overview-widget.tsx | 5 +- .../components/overview/useOverviewOptions.ts | 10 ++ .../src/components/profiles/ProfileAvatar.tsx | 8 +- .../src/components/report/chart/SerieIcon.tsx | 28 ++- apps/dashboard/src/components/ui/sheet.tsx | 2 +- apps/dashboard/src/components/ui/tooltip.tsx | 14 ++ .../dashboard/src/components/widget-table.tsx | 34 ++++ packages/db/src/services/profile.service.ts | 17 +- 34 files changed, 942 insertions(+), 318 deletions(-) rename apps/dashboard/src/app/(app)/[organizationId]/[projectId]/events/{event-chart.tsx => charts/events-per-day-chart.tsx} (91%) create mode 100644 apps/dashboard/src/app/(app)/[organizationId]/[projectId]/events/event-conversions-list/event-conversions-list.tsx create mode 100644 apps/dashboard/src/app/(app)/[organizationId]/[projectId]/events/event-conversions-list/index.tsx rename apps/dashboard/src/app/(app)/[organizationId]/[projectId]/profiles/{profile-list.tsx => _profile-list.tsx} (100%) create mode 100644 apps/dashboard/src/app/(app)/[organizationId]/[projectId]/profiles/profile-last-seen/index.tsx create mode 100644 apps/dashboard/src/app/(app)/[organizationId]/[projectId]/profiles/profile-list/index.tsx create mode 100644 apps/dashboard/src/app/(app)/[organizationId]/[projectId]/profiles/profile-list/profile-list.tsx create mode 100644 apps/dashboard/src/app/(app)/[organizationId]/[projectId]/profiles/profile-top/index.tsx delete mode 100644 apps/dashboard/src/components/events/ListProperties.tsx create mode 100644 apps/dashboard/src/components/events/ListPropertiesIcon.tsx create mode 100644 apps/dashboard/src/components/overview/overview-chart-toggle.tsx create mode 100644 apps/dashboard/src/components/widget-table.tsx diff --git a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/events/event-chart.tsx b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/events/charts/events-per-day-chart.tsx similarity index 91% rename from apps/dashboard/src/app/(app)/[organizationId]/[projectId]/events/event-chart.tsx rename to apps/dashboard/src/app/(app)/[organizationId]/[projectId]/events/charts/events-per-day-chart.tsx index 99292a96..4a61f20a 100644 --- a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/events/event-chart.tsx +++ b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/events/charts/events-per-day-chart.tsx @@ -8,7 +8,7 @@ interface Props { filters?: any[]; } -export function EventChart({ projectId, filters, events }: Props) { +export function EventsPerDayChart({ projectId, filters, events }: Props) { const fallback: IChartEvent[] = [ { id: 'A', diff --git a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/events/event-conversions-list/event-conversions-list.tsx b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/events/event-conversions-list/event-conversions-list.tsx new file mode 100644 index 00000000..54c015b7 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/events/event-conversions-list/event-conversions-list.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { Fragment } from 'react'; +import { Widget, WidgetHead } from '@/components/Widget'; +import { isSameDay } from 'date-fns'; + +import type { IServiceCreateEventPayload } from '@openpanel/db'; + +import { EventListItem } from '../event-list-item'; + +function showDateHeader(a: Date, b?: Date) { + if (!b) return true; + return !isSameDay(a, b); +} + +interface EventListProps { + data: IServiceCreateEventPayload[]; +} +export function EventConversionsList({ data }: EventListProps) { + return ( + + +
Conversions
+
+
+ {data.map((item, index, list) => ( + + {showDateHeader(item.createdAt, list[index - 1]?.createdAt) && ( +
+
+
+ {item.createdAt.toLocaleDateString()} +
+
+
+ )} + +
+ ))} +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/events/event-conversions-list/index.tsx b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/events/event-conversions-list/index.tsx new file mode 100644 index 00000000..9dfc78a8 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/events/event-conversions-list/index.tsx @@ -0,0 +1,32 @@ +import { Widget } from '@/components/Widget'; + +import { db, getEvents } from '@openpanel/db'; + +import { EventConversionsList } from './event-conversions-list'; + +interface Props { + projectId: string; +} + +export default async function EventConversionsListServer({ projectId }: Props) { + const conversions = await db.eventMeta.findMany({ + where: { + project_id: projectId, + conversion: true, + }, + }); + + if (conversions.length === 0) { + return null; + } + + const events = await getEvents( + `SELECT * FROM events WHERE project_id = '${projectId}' AND name IN (${conversions.map((c) => `'${c.name}'`).join(', ')}) ORDER BY created_at DESC LIMIT 20;`, + { + profile: true, + meta: true, + } + ); + + return ; +} diff --git a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/events/event-details.tsx b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/events/event-details.tsx index 6eb32fcc..d180e201 100644 --- a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/events/event-details.tsx +++ b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/events/event-details.tsx @@ -1,11 +1,14 @@ 'use client'; +import { useState } from 'react'; import type { Dispatch, SetStateAction } from 'react'; import { ChartSwitchShortcut } from '@/components/report/chart'; +import { Button } from '@/components/ui/button'; import { KeyValue } from '@/components/ui/key-value'; import { Sheet, SheetContent, + SheetFooter, SheetHeader, SheetTitle, } from '@/components/ui/sheet'; @@ -17,6 +20,8 @@ import { round } from 'mathjs'; import type { IServiceCreateEventPayload } from '@openpanel/db'; +import { EventEdit } from './event-edit'; + interface Props { event: IServiceCreateEventPayload; open: boolean; @@ -24,6 +29,7 @@ interface Props { } export function EventDetails({ event, open, setOpen }: Props) { const { name } = event; + const [isEditOpen, setIsEditOpen] = useState(false); const [, setFilter] = useEventQueryFilters({ shallow: false }); const [, setEvents] = useEventQueryNamesFilter({ shallow: false }); @@ -140,79 +146,91 @@ export function EventDetails({ event, open, setOpen }: Props) { .filter((item) => typeof item.value === 'string' && item.value); return ( - - -
-
- - {name.replace('_', ' ')} - + <> + + +
+
+ + {name.replace('_', ' ')} + - {properties.length > 0 && ( + {properties.length > 0 && ( +
+
Params
+
+ {properties.map((item) => ( + { + setFilter( + `properties.${item.name}`, + item.value ? String(item.value) : '', + 'is' + ); + }} + /> + ))} +
+
+ )}
-
Params
+
Common
- {properties.map((item) => ( + {common.map((item) => ( { - setFilter( - `properties.${item.name}`, - item.value ? String(item.value) : '', - 'is' - ); - }} + onClick={item.onClick} /> ))}
- )} -
-
Common
-
- {common.map((item) => ( - - ))} -
-
-
-
-
Similar events
- +
+
+
Similar events
+ +
+
-
-
- - + + + + + + + ); } diff --git a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/events/event-edit.tsx b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/events/event-edit.tsx index 684df1e8..659b6c82 100644 --- a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/events/event-edit.tsx +++ b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/events/event-edit.tsx @@ -66,8 +66,7 @@ export function EventEdit({ event, open, setOpen }: Props) { const mutation = api.event.updateEventMeta.useMutation({ onSuccess() { - // @ts-expect-error - document.querySelector('#close-sheet')?.click(); + setOpen(false); toast('Event updated'); router.refresh(); }, diff --git a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/events/event-icon.tsx b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/events/event-icon.tsx index aedb3172..b3c7407a 100644 --- a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/events/event-icon.tsx +++ b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/events/event-icon.tsx @@ -11,7 +11,9 @@ import { SheetTitle, SheetTrigger, } from '@/components/ui/sheet'; +import { Tooltip, TooltipContent } from '@/components/ui/tooltip'; import { cn } from '@/utils/cn'; +import { TooltipTrigger } from '@radix-ui/react-tooltip'; import type { VariantProps } from 'class-variance-authority'; import { cva } from 'class-variance-authority'; import type { LucideIcon } from 'lucide-react'; @@ -155,7 +157,7 @@ export function EventIcon({ className, name, size, meta }: EventIconProps) { return (
- +
); } diff --git a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/events/event-list-item.tsx b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/events/event-list-item.tsx index 1f295f9d..7f553476 100644 --- a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/events/event-list-item.tsx +++ b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/events/event-list-item.tsx @@ -1,45 +1,50 @@ 'use client'; import { useState } from 'react'; -import { ProfileAvatar } from '@/components/profiles/ProfileAvatar'; -import { SerieIcon } from '@/components/report/chart/SerieIcon'; -import { KeyValueSubtle } from '@/components/ui/key-value'; +import { Tooltiper } from '@/components/ui/tooltip'; import { useAppParams } from '@/hooks/useAppParams'; -import { useEventQueryFilters } from '@/hooks/useEventQueryFilters'; import { useNumber } from '@/hooks/useNumerFormatter'; import { cn } from '@/utils/cn'; -import { getProfileName } from '@/utils/getters'; +import Link from 'next/link'; import type { IServiceCreateEventPayload } from '@openpanel/db'; import { EventDetails } from './event-details'; -import { EventEdit } from './event-edit'; import { EventIcon } from './event-icon'; type EventListItemProps = IServiceCreateEventPayload; export function EventListItem(props: EventListItemProps) { - const { - profile, - createdAt, - name, - path, - duration, - brand, - browser, - city, - country, - device, - os, - projectId, - meta, - } = props; - const params = useAppParams(); - const [, setFilter] = useEventQueryFilters({ shallow: false }); + const { organizationId, projectId } = useAppParams(); + const { createdAt, name, path, duration, meta, profile } = props; const [isDetailsOpen, setIsDetailsOpen] = useState(false); - const [isEditOpen, setIsEditOpen] = useState(false); + const number = useNumber(); + const renderName = () => { + if (name === 'screen_view') { + if (path.includes('/')) { + return path; + } + + return `Route: ${path}`; + } + + return name.replace(/_/g, ' '); + }; + + const renderDuration = () => { + if (name === 'screen_view') { + return ( + + {number.shortWithUnit(duration / 1000, 'min')} + + ); + } + + return null; + }; + return ( <> - -
setIsDetailsOpen(true)} className={cn( - 'p-4 flex flex-col gap-2 hover:bg-slate-50 rounded-lg transition-colors', + 'w-full card p-4 flex hover:bg-slate-50 rounded-lg transition-colors justify-between items-center', meta?.conversion && `bg-${meta.color}-50 hover:bg-${meta.color}-100` )} > -
-
- - -
-
- {createdAt.toLocaleTimeString()} -
+ {profile?.firstName} {profile?.lastName} + + + + +
+ {createdAt.toLocaleTimeString()} +
+
-
- {path && ( - - )} - {profile && ( - - {profile.avatar && } - {getProfileName(profile)} - - } - href={`/${params.organizationId}/${params.projectId}/profiles/${profile.id}`} - /> - )} - setFilter('city', city)} - value={ - <> - {country && } - {city} - - } - /> - setFilter('device', device)} - value={ - <> - {device && } - {brand || os} - - } - /> - {browser !== 'WebKit' && browser !== '' && ( - setFilter('browser', browser)} - value={ - <> - {browser && } - {browser} - - } - /> - )} -
-
+ ); } diff --git a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/events/event-list.tsx b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/events/event-list.tsx index 3e847f98..a6ecf777 100644 --- a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/events/event-list.tsx +++ b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/events/event-list.tsx @@ -9,7 +9,11 @@ import { useAppParams } from '@/hooks/useAppParams'; import { useCursor } from '@/hooks/useCursor'; import { useEventQueryFilters } from '@/hooks/useEventQueryFilters'; import { isSameDay } from 'date-fns'; -import { GanttChartIcon } from 'lucide-react'; +import { + ChevronLeftIcon, + ChevronRightIcon, + GanttChartIcon, +} from 'lucide-react'; import type { IServiceCreateEventPayload } from '@openpanel/db'; @@ -56,21 +60,26 @@ export function EventList({ data, count }: EventListProps) { ) : ( <> -
- - -
-
+
{data.map((item, index, list) => ( {showDateHeader(item.createdAt, list[index - 1]?.createdAt) && ( -
- {item.createdAt.toLocaleDateString()} +
+ {index === 0 ? :
} +
+
+ {item.createdAt.toLocaleDateString()} +
+ {index === 0 && ( + + )} +
)} @@ -78,6 +87,7 @@ export function EventList({ data, count }: EventListProps) { ))}
{counter === 0 ? ( - 'Listening to events' + 'Listening' ) : ( <> -
- - +
+
+ +
+
+ + +
); diff --git a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/profiles/[profileId]/page.tsx b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/profiles/[profileId]/page.tsx index a84cc38d..7a1132ea 100644 --- a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/profiles/[profileId]/page.tsx +++ b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/profiles/[profileId]/page.tsx @@ -3,14 +3,13 @@ import { OverviewFiltersButtons } from '@/components/overview/filters/overview-f import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer'; import { ProfileAvatar } from '@/components/profiles/ProfileAvatar'; import { ChartSwitch } from '@/components/report/chart'; -import { KeyValue } from '@/components/ui/key-value'; +import { SerieIcon } from '@/components/report/chart/SerieIcon'; import { Widget, WidgetBody, WidgetHead } from '@/components/Widget'; import { eventQueryFiltersParser, eventQueryNamesFilter, } from '@/hooks/useEventQueryFilters'; import { getExists } from '@/server/pageExists'; -import { cn } from '@/utils/cn'; import { getProfileName } from '@/utils/getters'; import { notFound } from 'next/navigation'; import { parseAsInteger, parseAsString } from 'nuqs'; @@ -117,7 +116,7 @@ export default async function Page({ lineType: 'monotone', interval: 'day', name: 'Events', - range: '7d', + range: '1m', previous: false, metric: 'sum', }; @@ -149,29 +148,71 @@ export default async function Page({
- - - Properties - - - {Object.entries(profile.properties) - .filter(([, value]) => !!value) - .map(([key, value]) => ( - - ))} - - - - - Events per day - - - - - +
+ +
+
+ + + Events per day + + + + + + + + Profile + + +
+ + + + + +
+
+ + + Properties + +
+ {Object.entries(profile.properties) + .filter(([, value]) => !!value) + .map(([key, value]) => ( + + ))} +
+
+
-
); } + +function ValueRow({ name, value }: { name: string; value?: unknown }) { + if (!value) { + return null; + } + return ( +
+
+ {name.replace('_', ' ')} +
+
+ {typeof value === 'string' ? ( + <> + {value} + + ) : ( + <>{value} + )} +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/profiles/profile-list.tsx b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/profiles/_profile-list.tsx similarity index 100% rename from apps/dashboard/src/app/(app)/[organizationId]/[projectId]/profiles/profile-list.tsx rename to apps/dashboard/src/app/(app)/[organizationId]/[projectId]/profiles/_profile-list.tsx diff --git a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/profiles/page.tsx b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/profiles/page.tsx index 54a24563..278ed942 100644 --- a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/profiles/page.tsx +++ b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/profiles/page.tsx @@ -5,10 +5,10 @@ import { eventQueryFiltersParser } from '@/hooks/useEventQueryFilters'; import { getExists } from '@/server/pageExists'; import { parseAsInteger } from 'nuqs'; -import { getProfileList, getProfileListCount } from '@openpanel/db'; - import { StickyBelowHeader } from '../layout-sticky-below-header'; -import { ProfileList } from './profile-list'; +import ProfileLastSeenServer from './profile-last-seen'; +import ProfileListServer from './profile-list'; +import ProfileTopServer from './profile-top'; interface PageProps { params: { @@ -29,19 +29,7 @@ export default async function Page({ params: { organizationId, projectId }, searchParams: { cursor, f }, }: PageProps) { - const [profiles, count] = await Promise.all([ - getProfileList({ - projectId, - take: 50, - cursor: parseAsInteger.parseServerSide(cursor ?? '') ?? undefined, - filters: eventQueryFiltersParser.parseServerSide(f ?? '') ?? undefined, - }), - getProfileListCount({ - projectId, - filters: eventQueryFiltersParser.parseServerSide(f ?? '') ?? undefined, - }), - getExists(organizationId, projectId), - ]); + await getExists(organizationId, projectId); return ( @@ -56,7 +44,22 @@ export default async function Page({ nuqsOptions={nuqsOptions} /> - +
+ +
+ + +
+
); } diff --git a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/profiles/profile-last-seen/index.tsx b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/profiles/profile-last-seen/index.tsx new file mode 100644 index 00000000..5b48b3ab --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/profiles/profile-last-seen/index.tsx @@ -0,0 +1,76 @@ +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { Widget, WidgetBody, WidgetHead } from '@/components/Widget'; +import { cn } from '@/utils/cn'; + +import { chQuery } from '@openpanel/db'; + +interface Props { + projectId: string; +} + +export default async function ProfileLastSeenServer({ projectId }: Props) { + interface Row { + days: number; + count: number; + } + // Days since last event from users + // group by days + const res = await chQuery( + `SELECT age('days',created_at, now()) as days, count(distinct profile_id) as count FROM events where project_id = '${projectId}' group by days order by days ASC` + ); + + const take = 18; + const split = take / 2; + const max = Math.max(...res.map((item) => item.count)); + const renderItem = (item: Row) => ( +
+ + +
+
+
+ + + {item.count} profiles last seen{' '} + {item.days === 0 ? 'today' : `${item.days} days ago`} + + +
{item.days}
+
+ ); + return ( + + +
Last seen
+
+ +
+ {res.length >= 18 ? ( + <> + {res.slice(0, split).map(renderItem)} + {res.slice(-split).map(renderItem)} + + ) : ( + res.map(renderItem) + )} +
+
DAYS
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/profiles/profile-list/index.tsx b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/profiles/profile-list/index.tsx new file mode 100644 index 00000000..d0152941 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/profiles/profile-list/index.tsx @@ -0,0 +1,30 @@ +import { getProfileList, getProfileListCount } from '@openpanel/db'; +import type { IChartEventFilter } from '@openpanel/validation'; + +import { ProfileList } from './profile-list'; + +interface Props { + projectId: string; + cursor?: number; + filters?: IChartEventFilter[]; +} + +export default async function ProfileListServer({ + projectId, + cursor, + filters, +}: Props) { + const [profiles, count] = await Promise.all([ + getProfileList({ + projectId, + take: 10, + cursor, + filters, + }), + getProfileListCount({ + projectId, + filters, + }), + ]); + return ; +} diff --git a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/profiles/profile-list/profile-list.tsx b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/profiles/profile-list/profile-list.tsx new file mode 100644 index 00000000..f803dd9f --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/profiles/profile-list/profile-list.tsx @@ -0,0 +1,110 @@ +'use client'; + +import { ListPropertiesIcon } from '@/components/events/ListPropertiesIcon'; +import { FullPageEmptyState } from '@/components/FullPageEmptyState'; +import { Pagination } from '@/components/Pagination'; +import { ProfileAvatar } from '@/components/profiles/ProfileAvatar'; +import { Button } from '@/components/ui/button'; +import { Tooltiper } from '@/components/ui/tooltip'; +import { Widget, WidgetHead } from '@/components/Widget'; +import { WidgetTable } from '@/components/widget-table'; +import { useAppParams } from '@/hooks/useAppParams'; +import { useCursor } from '@/hooks/useCursor'; +import { UsersIcon } from 'lucide-react'; +import Link from 'next/link'; + +import type { IServiceProfile } from '@openpanel/db'; + +interface ProfileListProps { + data: IServiceProfile[]; + count: number; +} +export function ProfileList({ data, count }: ProfileListProps) { + const { organizationId, projectId } = useAppParams(); + const { cursor, setCursor } = useCursor(); + return ( + + +
Profiles
+ +
+ {data.length ? ( + <> + item.id} + columns={[ + { + name: 'Name', + render(profile) { + return ( + + + {profile.firstName} {profile.lastName} + + ); + }, + }, + { + name: '', + render(profile) { + return ; + }, + }, + { + name: 'Last seen', + render(profile) { + return ( + +
+ {profile.createdAt.toLocaleTimeString()} +
+
+ ); + }, + }, + ]} + /> +
+ +
+ + ) : ( + + {cursor !== 0 ? ( + <> +

Looks like you have reached the end of the list

+ + + ) : ( +

Looks like there is no profiles here

+ )} +
+ )} +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/profiles/profile-top/index.tsx b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/profiles/profile-top/index.tsx new file mode 100644 index 00000000..6870e9bb --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationId]/[projectId]/profiles/profile-top/index.tsx @@ -0,0 +1,70 @@ +import { ListPropertiesIcon } from '@/components/events/ListPropertiesIcon'; +import { ProfileAvatar } from '@/components/profiles/ProfileAvatar'; +import { Widget, WidgetHead } from '@/components/Widget'; +import { WidgetTable } from '@/components/widget-table'; +import Link from 'next/link'; + +import { chQuery, getProfiles } from '@openpanel/db'; + +interface Props { + projectId: string; + organizationId: string; +} + +export default async function ProfileTopServer({ + organizationId, + projectId, +}: Props) { + // Days since last event from users + // group by days + const res = await chQuery<{ profile_id: string; count: number }>( + `SELECT profile_id, count(*) as count from events where profile_id != '' and project_id = '${projectId}' group by profile_id order by count() DESC LIMIT 10` + ); + const profiles = await getProfiles({ ids: res.map((r) => r.profile_id) }); + const list = res.map((item) => { + return { + count: item.count, + ...(profiles.find((p) => p.id === item.profile_id) ?? {}), + }; + }); + + return ( + + +
Power users
+
+ !!item.id)} + keyExtractor={(item) => item.id!} + columns={[ + { + name: 'Name', + render(profile) { + return ( + + + {profile.firstName} {profile.lastName} + + ); + }, + }, + { + name: '', + render(profile) { + return ; + }, + }, + { + name: 'Events', + render(profile) { + return profile.count; + }, + }, + ]} + /> +
+ ); +} diff --git a/apps/dashboard/src/components/Pagination.tsx b/apps/dashboard/src/components/Pagination.tsx index e5ecab97..b9fb37eb 100644 --- a/apps/dashboard/src/components/Pagination.tsx +++ b/apps/dashboard/src/components/Pagination.tsx @@ -1,5 +1,12 @@ import type { Dispatch, SetStateAction } from 'react'; import { useState } from 'react'; +import { cn } from '@/utils/cn'; +import { + ChevronLeftIcon, + ChevronRightIcon, + ChevronsLeftIcon, + ChevronsRightIcon, +} from 'lucide-react'; import { Button } from './ui/button'; @@ -20,38 +27,72 @@ export function Pagination({ count, cursor, setCursor, + className, + size = 'base', }: { - take?: number; - count?: number; + take: number; + count: number; cursor: number; setCursor: Dispatch>; + className?: string; + size?: 'sm' | 'base'; }) { - const isNextDisabled = - count !== undefined && take !== undefined && cursor * take + take >= count; - + const lastCursor = Math.floor(count / take) - 1; + const isNextDisabled = count === 0 || lastCursor === cursor; return ( -
-
Page: {cursor + 1}
- {typeof count === 'number' && ( -
Total rows: {count}
+
+ {size === 'base' && ( + <> +
Page: {cursor + 1}
+ {typeof count === 'number' && ( +
Total rows: {count}
+ )} + + )} + {size === 'base' && ( + )} + {size === 'base' && ( + + )}
); } diff --git a/apps/dashboard/src/components/events/ListProperties.tsx b/apps/dashboard/src/components/events/ListProperties.tsx deleted file mode 100644 index 93aa80eb..00000000 --- a/apps/dashboard/src/components/events/ListProperties.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { toDots } from '@openpanel/common'; - -import { Table, TableBody, TableCell, TableRow } from '../ui/table'; - -interface ListPropertiesProps { - data: any; - className?: string; -} - -export function ListProperties({ - data, - className = 'mini', -}: ListPropertiesProps) { - const dots = toDots(data); - return ( - - - {Object.keys(dots).map((key) => { - return ( - - {key} - - {typeof dots[key] === 'boolean' - ? dots[key] - ? 'true' - : 'false' - : dots[key]} - - - ); - })} - -
- ); -} diff --git a/apps/dashboard/src/components/events/ListPropertiesIcon.tsx b/apps/dashboard/src/components/events/ListPropertiesIcon.tsx new file mode 100644 index 00000000..6e9fa9c3 --- /dev/null +++ b/apps/dashboard/src/components/events/ListPropertiesIcon.tsx @@ -0,0 +1,69 @@ +import { SerieIcon } from '../report/chart/SerieIcon'; +import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; + +interface Props { + country?: string; + city?: string; + os?: string; + os_version?: string; + browser?: string; + browser_version?: string; + referrer_name?: string; + referrer_type?: string; +} + +export function ListPropertiesIcon({ + country, + city, + os, + os_version, + browser, + browser_version, + referrer_name, + referrer_type, +}: Props) { + return ( +
+ {country && ( + + + + + + {country}, {city} + + + )} + {os && ( + + + + + + {os} ({os_version}) + + + )} + {browser && ( + + + + + + {browser} ({browser_version}) + + + )} + {referrer_name && ( + + + + + + {referrer_name} ({referrer_type}) + + + )} +
+ ); +} diff --git a/apps/dashboard/src/components/overview/overview-chart-toggle.tsx b/apps/dashboard/src/components/overview/overview-chart-toggle.tsx new file mode 100644 index 00000000..25cc0899 --- /dev/null +++ b/apps/dashboard/src/components/overview/overview-chart-toggle.tsx @@ -0,0 +1,23 @@ +import { BarChartIcon, LineChartIcon } from 'lucide-react'; + +import { Button } from '../ui/button'; +import { useOverviewOptions } from './useOverviewOptions'; + +export function OverviewChartToggle() { + const { chartType, setChartType } = useOverviewOptions(); + return ( + + ); +} diff --git a/apps/dashboard/src/components/overview/overview-top-devices.tsx b/apps/dashboard/src/components/overview/overview-top-devices.tsx index 04d2cbb1..c4c6557e 100644 --- a/apps/dashboard/src/components/overview/overview-top-devices.tsx +++ b/apps/dashboard/src/components/overview/overview-top-devices.tsx @@ -5,6 +5,7 @@ import { useEventQueryFilters } from '@/hooks/useEventQueryFilters'; import { cn } from '@/utils/cn'; import { Widget, WidgetBody } from '../Widget'; +import { OverviewChartToggle } from './overview-chart-toggle'; import { WidgetButtons, WidgetHead } from './overview-widget'; import { useOverviewOptions } from './useOverviewOptions'; import { useOverviewWidget } from './useOverviewWidget'; @@ -15,7 +16,7 @@ interface OverviewTopDevicesProps { export default function OverviewTopDevices({ projectId, }: OverviewTopDevicesProps) { - const { interval, range, previous, startDate, endDate } = + const { interval, range, previous, startDate, endDate, chartType } = useOverviewOptions(); const [filters, setFilter] = useEventQueryFilters(); const isPageFilter = filters.find((filter) => filter.name === 'path'); @@ -41,7 +42,7 @@ export default function OverviewTopDevices({ name: 'device', }, ], - chartType: 'bar', + chartType, lineType: 'monotone', interval: interval, name: 'Top sources', @@ -71,7 +72,7 @@ export default function OverviewTopDevices({ name: 'browser', }, ], - chartType: 'bar', + chartType, lineType: 'monotone', interval: interval, name: 'Top sources', @@ -101,7 +102,7 @@ export default function OverviewTopDevices({ name: 'browser_version', }, ], - chartType: 'bar', + chartType, lineType: 'monotone', interval: interval, name: 'Top sources', @@ -131,7 +132,7 @@ export default function OverviewTopDevices({ name: 'os', }, ], - chartType: 'bar', + chartType, lineType: 'monotone', interval: interval, name: 'Top sources', @@ -161,7 +162,7 @@ export default function OverviewTopDevices({ name: 'os_version', }, ], - chartType: 'bar', + chartType, lineType: 'monotone', interval: interval, name: 'Top sources', @@ -176,7 +177,10 @@ export default function OverviewTopDevices({ <> -
{widget.title}
+
+ {widget.title} + +
{widgets.map((w) => (