feature(dashboard): refactor overview

fix(lint)
This commit is contained in:
Carl-Gerhard Lindesvärd
2025-03-20 09:28:54 +01:00
committed by Carl-Gerhard Lindesvärd
parent b035c0d586
commit a1eb4a296f
83 changed files with 59167 additions and 32403 deletions

View File

@@ -9,11 +9,12 @@ type Props = {
};
const Conversions = ({ projectId }: Props) => {
const query = api.event.conversions.useQuery(
const query = api.event.conversions.useInfiniteQuery(
{
projectId,
},
{
getNextPageParam: (lastPage) => lastPage.meta.next,
keepPreviousData: true,
},
);

View File

@@ -5,13 +5,13 @@ import EventListener from '@/components/events/event-listener';
import { EventsTable } from '@/components/events/table';
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters';
import { Button } from '@/components/ui/button';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { pushModal } from '@/modals';
import { api } from '@/trpc/client';
import { Loader2Icon } from 'lucide-react';
import { parseAsInteger, useQueryState } from 'nuqs';
import { format } from 'date-fns';
import { CalendarIcon, Loader2Icon } from 'lucide-react';
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
type Props = {
projectId: string;
@@ -20,21 +20,22 @@ type Props = {
const Events = ({ projectId, profileId }: Props) => {
const [filters] = useEventQueryFilters();
const [eventNames] = useEventQueryNamesFilter();
const [cursor, setCursor] = useQueryState(
'cursor',
parseAsInteger.withDefault(0),
const [startDate, setStartDate] = useQueryState(
'startDate',
parseAsIsoDateTime,
);
const query = api.event.events.useQuery(
const [endDate, setEndDate] = useQueryState('endDate', parseAsIsoDateTime);
const query = api.event.events.useInfiniteQuery(
{
cursor,
projectId,
take: 50,
events: eventNames,
filters,
profileId,
startDate: startDate || undefined,
endDate: endDate || undefined,
},
{
getNextPageParam: (lastPage) => lastPage.meta.next,
keepPreviousData: true,
},
);
@@ -43,6 +44,25 @@ const Events = ({ projectId, profileId }: Props) => {
<div>
<TableButtons>
<EventListener onRefresh={() => query.refetch()} />
<Button
variant="outline"
size="sm"
icon={CalendarIcon}
onClick={() => {
pushModal('DateRangerPicker', {
onChange: ({ startDate, endDate }) => {
setStartDate(startDate);
setEndDate(endDate);
},
startDate: startDate || undefined,
endDate: endDate || undefined,
});
}}
>
{startDate && endDate
? `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d')}`
: 'Date range'}
</Button>
<OverviewFiltersDrawer
mode="events"
projectId={projectId}
@@ -58,7 +78,7 @@ const Events = ({ projectId, profileId }: Props) => {
</div>
)}
</TableButtons>
<EventsTable query={query} cursor={cursor} setCursor={setCursor} />
<EventsTable query={query} />
</div>
);
};

View File

@@ -1,7 +1,6 @@
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import ServerLiveCounter from '@/components/overview/live-counter';
import OverviewMetrics from '@/components/overview/overview-metrics';
import OverviewShareServer from '@/components/overview/overview-share';
import OverviewTopDevices from '@/components/overview/overview-top-devices';
import OverviewTopEvents from '@/components/overview/overview-top-events';
@@ -10,6 +9,7 @@ import OverviewTopPages from '@/components/overview/overview-top-pages';
import OverviewTopSources from '@/components/overview/overview-top-sources';
import { OverviewInterval } from '@/components/overview/overview-interval';
import OverviewMetricsV2 from '@/components/overview/overview-metrics-v2';
import { OverviewRange } from '@/components/overview/overview-range';
interface PageProps {
@@ -36,7 +36,8 @@ export default function Page({ params: { projectId } }: PageProps) {
<OverviewFiltersButtons />
</div>
<div className="grid grid-cols-6 gap-4 p-4 pt-0">
<OverviewMetrics projectId={projectId} />
{/* <OverviewMetrics projectId={projectId} /> */}
<OverviewMetricsV2 projectId={projectId} />
<OverviewTopSources projectId={projectId} />
<OverviewTopPages projectId={projectId} />
<OverviewTopDevices projectId={projectId} />

View File

@@ -4,15 +4,9 @@ import { TableButtons } from '@/components/data-table';
import { EventsTable } from '@/components/events/table';
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { api } from '@/trpc/client';
import { Loader2Icon } from 'lucide-react';
import { parseAsInteger, useQueryState } from 'nuqs';
import { GetEventListOptions } from '@openpanel/db';
type Props = {
projectId: string;
@@ -21,21 +15,14 @@ type Props = {
const Events = ({ projectId, profileId }: Props) => {
const [filters] = useEventQueryFilters();
const [eventNames] = useEventQueryNamesFilter();
const [cursor, setCursor] = useQueryState(
'cursor',
parseAsInteger.withDefault(0),
);
const query = api.event.events.useQuery(
const query = api.event.events.useInfiniteQuery(
{
cursor,
projectId,
take: 50,
events: eventNames,
filters,
profileId,
},
{
getNextPageParam: (lastPage) => lastPage.meta.next,
keepPreviousData: true,
},
);
@@ -58,7 +45,7 @@ const Events = ({ projectId, profileId }: Props) => {
</div>
)}
</TableButtons>
<EventsTable query={query} cursor={cursor} setCursor={setCursor} />
<EventsTable query={query} />
</div>
);
};

View File

@@ -49,24 +49,13 @@ const Map = ({ markers }: Props) => {
const boundingBox = getBoundingBox(hull);
const [zoom] = useAnimatedState(
markers.length === 1
? 20
? 1
: determineZoom(boundingBox, size ? size?.height / size?.width : 1),
);
const [long] = useAnimatedState(center.long);
const [lat] = useAnimatedState(center.lat);
useEffect(() => {
requestAnimationFrame(() => {
if (ref.current) {
setSize({
width: ref.current.clientWidth,
height: ref.current.clientHeight,
});
}
});
}, [isFullscreen]);
useEffect(() => {
return bind(window, {
type: 'resize',
@@ -95,20 +84,12 @@ const Map = ({ markers }: Props) => {
const theme = useTheme();
return (
<div
className={cn(
'fixed bottom-0 left-0 right-0 top-0',
!isFullscreen && 'lg:left-72',
)}
ref={ref}
>
<div className={cn('absolute bottom-0 left-0 right-0 top-0')} ref={ref}>
{size === null ? (
<></>
) : (
<>
<ComposableMap
width={size?.width}
height={size?.height}
projection="geoMercator"
projectionConfig={{
rotate: [0, 0, 0],

View File

@@ -0,0 +1,59 @@
import { createContext, useContext as useBaseContext } from 'react';
import { Tooltip as RechartsTooltip, type TooltipProps } from 'recharts';
export function createChartTooltip<
PropsFromTooltip extends Record<string, unknown>,
PropsFromContext extends Record<string, unknown>,
>(
Tooltip: React.ComponentType<
{
context: PropsFromContext;
data: PropsFromTooltip;
} & TooltipProps<number, string>
>,
) {
const context = createContext<PropsFromContext | null>(null);
const useContext = () => {
const value = useBaseContext(context);
if (!value) {
throw new Error('ChartTooltip context not found');
}
return value;
};
const InnerTooltip = (tooltip: TooltipProps<number, string>) => {
const context = useContext();
const data = tooltip.payload?.[0]?.payload;
if (!data || !tooltip.active) {
return null;
}
return (
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-background/80 p-3 shadow-xl backdrop-blur-sm">
<Tooltip data={data} context={context} {...tooltip} />
</div>
);
};
return {
TooltipProvider: ({
children,
...value
}: {
children: React.ReactNode;
} & PropsFromContext) => {
return (
<context.Provider value={value as unknown as PropsFromContext}>
{children}
</context.Provider>
);
},
Tooltip: (props: TooltipProps<number, string>) => {
return (
<RechartsTooltip {...props} content={<InnerTooltip {...props} />} />
);
},
};
}

View File

@@ -56,6 +56,8 @@ export function EventListItem(props: EventListItemProps) {
if (!isMinimal) {
pushModal('EventDetails', {
id: props.id,
projectId,
createdAt,
});
}
}}

View File

@@ -15,6 +15,7 @@ export function useColumns() {
const number = useNumber();
const columns: ColumnDef<IServiceEvent>[] = [
{
size: 300,
accessorKey: 'name',
header: 'Name',
cell({ row }) {
@@ -50,29 +51,29 @@ export function useColumns() {
return (
<div className="flex items-center gap-2">
<TooltipComplete content="Click to edit" side="left">
<button
type="button"
className="transition-transform hover:scale-105"
onClick={() => {
pushModal('EditEvent', {
id: row.original.id,
});
}}
>
<EventIcon
size="sm"
name={row.original.name}
meta={row.original.meta}
/>
</button>
</TooltipComplete>
<button
type="button"
className="transition-transform hover:scale-105"
onClick={() => {
pushModal('EditEvent', {
id: row.original.id,
});
}}
>
<EventIcon
size="sm"
name={row.original.name}
meta={row.original.meta}
/>
</button>
<span className="flex gap-2">
<button
type="button"
onClick={() => {
pushModal('EventDetails', {
id: row.original.id,
createdAt: row.original.createdAt,
projectId: row.original.projectId,
});
}}
className="font-medium"
@@ -86,41 +87,13 @@ export function useColumns() {
},
},
{
accessorKey: 'country',
header: 'Country',
accessorKey: 'createdAt',
header: 'Created at',
size: 170,
cell({ row }) {
const { country, city } = row.original;
const date = row.original.createdAt;
return (
<div className="inline-flex min-w-full flex-none items-center gap-2">
<SerieIcon name={country} />
<span>{city}</span>
</div>
);
},
},
{
accessorKey: 'os',
header: 'OS',
cell({ row }) {
const { os } = row.original;
return (
<div className="flex min-w-full items-center gap-2">
<SerieIcon name={os} />
<span>{os}</span>
</div>
);
},
},
{
accessorKey: 'browser',
header: 'Browser',
cell({ row }) {
const { browser } = row.original;
return (
<div className="inline-flex min-w-full flex-none items-center gap-2">
<SerieIcon name={browser} />
<span>{browser}</span>
</div>
<div>{isToday(date) ? formatTime(date) : formatDateTime(date)}</div>
);
},
},
@@ -134,8 +107,8 @@ export function useColumns() {
}
return (
<ProjectLink
href={`/profiles/${profile?.id}`}
className="max-w-[80px] overflow-hidden text-ellipsis whitespace-nowrap font-medium hover:underline"
href={`/profiles/${profile.id}`}
className="whitespace-nowrap font-medium hover:underline"
>
{getProfileName(profile)}
</ProjectLink>
@@ -143,12 +116,44 @@ export function useColumns() {
},
},
{
accessorKey: 'createdAt',
header: 'Created at',
accessorKey: 'country',
header: 'Country',
size: 150,
cell({ row }) {
const date = row.original.createdAt;
const { country, city } = row.original;
return (
<div>{isToday(date) ? formatTime(date) : formatDateTime(date)}</div>
<div className="row items-center gap-2 min-w-0">
<SerieIcon name={country} />
<span className="truncate">{city}</span>
</div>
);
},
},
{
accessorKey: 'os',
header: 'OS',
size: 130,
cell({ row }) {
const { os } = row.original;
return (
<div className="row items-center gap-2 min-w-0">
<SerieIcon name={os} />
<span className="truncate">{os}</span>
</div>
);
},
},
{
accessorKey: 'browser',
header: 'Browser',
size: 110,
cell({ row }) {
const { browser } = row.original;
return (
<div className="row items-center gap-2 min-w-0">
<SerieIcon name={browser} />
<span className="truncate">{browser}</span>
</div>
);
},
},

View File

@@ -0,0 +1,152 @@
'use client';
import { GridCell } from '@/components/grid-table';
import { cn } from '@/utils/cn';
import {
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
import type { ColumnDef } from '@tanstack/react-table';
import { useVirtualizer, useWindowVirtualizer } from '@tanstack/react-virtual';
import throttle from 'lodash.throttle';
import { useEffect, useRef, useState } from 'react';
interface DataTableProps<TData> {
columns: ColumnDef<TData, any>[];
data: TData[];
}
export function EventsDataTable<TData>({
columns,
data,
}: DataTableProps<TData>) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
const parentRef = useRef<HTMLDivElement>(null);
const [scrollMargin, setScrollMargin] = useState(0);
const { rows } = table.getRowModel();
const virtualizer = useWindowVirtualizer({
count: rows.length,
estimateSize: () => 48,
scrollMargin,
overscan: 10,
});
useEffect(() => {
const updateScrollMargin = throttle(() => {
if (parentRef.current) {
setScrollMargin(
parentRef.current.getBoundingClientRect().top + window.scrollY,
);
}
}, 500);
// Initial calculation
updateScrollMargin();
// Listen for scroll and resize events
window.addEventListener('scroll', updateScrollMargin);
window.addEventListener('resize', updateScrollMargin);
return () => {
window.removeEventListener('scroll', updateScrollMargin);
window.removeEventListener('resize', updateScrollMargin);
};
}, []); // Empty dependency array since we're setting up listeners
const visibleRows = virtualizer.getVirtualItems();
return (
<div className="card">
<div className="relative w-full overflow-auto rounded-md">
<div
className="w-full"
style={{
width: 'max-content',
minWidth: '100%',
}}
>
{table.getHeaderGroups().map((headerGroup) => (
<div className="thead row h-12 sticky top-0" key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<GridCell
key={header.id}
isHeader
style={{
minWidth: header.column.getSize(),
flexShrink: 1,
overflow: 'hidden',
flex: 1,
}}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</GridCell>
);
})}
</div>
))}
<div ref={parentRef} className="w-full">
<div
className="tbody [&>*:last-child]:border-0"
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{visibleRows.map((virtualRow, index) => {
const row = rows[virtualRow.index]!;
if (!row) {
return null;
}
return (
<div
key={row.id}
className={cn('absolute top-0 left-0 w-full h-12 row')}
style={{
transform: `translateY(${
virtualRow.start - virtualizer.options.scrollMargin
}px)`,
}}
>
{row.getVisibleCells().map((cell) => {
return (
<GridCell
key={cell.id}
style={{
minWidth: cell.column.getSize(),
flexShrink: 1,
overflow: 'hidden',
flex: 1,
}}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</GridCell>
);
})}
</div>
);
})}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,64 +1,82 @@
import { DataTable } from '@/components/data-table';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { Pagination } from '@/components/pagination';
import { Button } from '@/components/ui/button';
import { TableSkeleton } from '@/components/ui/table';
import type { UseQueryResult } from '@tanstack/react-query';
import { GanttChartIcon } from 'lucide-react';
import { column } from 'mathjs';
import type { Dispatch, SetStateAction } from 'react';
import type { IServiceEvent } from '@openpanel/db';
import type {
UseInfiniteQueryResult,
UseQueryResult,
} from '@tanstack/react-query';
import { GanttChartIcon, Loader2Icon } from 'lucide-react';
import { useEffect, useRef } from 'react';
import type { RouterOutputs } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { useInViewport } from 'react-in-viewport';
import { useColumns } from './columns';
import { EventsDataTable } from './events-data-table';
type Props =
| {
query: UseQueryResult<IServiceEvent[]>;
query: UseInfiniteQueryResult<RouterOutputs['event']['events']>;
}
| {
query: UseQueryResult<IServiceEvent[]>;
cursor: number;
setCursor: Dispatch<SetStateAction<number>>;
query: UseQueryResult<RouterOutputs['event']['events']>;
};
export const EventsTable = ({ query, ...props }: Props) => {
const columns = useColumns();
const { data, isFetching, isLoading } = query;
const { isLoading } = query;
const ref = useRef<HTMLDivElement>(null);
const { inViewport, enterCount } = useInViewport(ref, undefined, {
disconnectOnLeave: true,
});
const isInfiniteQuery = 'fetchNextPage' in query;
const data =
(isInfiniteQuery
? query.data?.pages?.flatMap((p) => p.items)
: query.data?.items) ?? [];
const hasNextPage = isInfiniteQuery
? query.data?.pages[query.data.pages.length - 1]?.meta.next
: query.data?.meta.next;
useEffect(() => {
if (
hasNextPage &&
isInfiniteQuery &&
data.length > 0 &&
inViewport &&
enterCount > 0 &&
query.isFetchingNextPage === false
) {
query.fetchNextPage();
}
}, [inViewport, enterCount, hasNextPage]);
if (isLoading) {
return <TableSkeleton cols={columns.length} />;
}
if (data?.length === 0) {
if (data.length === 0) {
return (
<FullPageEmptyState title="No events here" icon={GanttChartIcon}>
<p>Could not find any events</p>
{'cursor' in props && props.cursor !== 0 && (
<Button
className="mt-8"
variant="outline"
onClick={() => props.setCursor((p) => p - 1)}
>
Go to previous page
</Button>
)}
</FullPageEmptyState>
);
}
return (
<>
<DataTable data={data ?? []} columns={columns} />
{'cursor' in props && (
<Pagination
className="mt-2"
setCursor={props.setCursor}
cursor={props.cursor}
count={Number.POSITIVE_INFINITY}
take={50}
loading={isFetching}
/>
<EventsDataTable data={data} columns={columns} />
{isInfiniteQuery && (
<div className="w-full h-10 center-center pt-10" ref={ref}>
<div
className={cn(
'size-8 bg-background rounded-full center-center border opacity-0 transition-opacity',
isInfiniteQuery && query.isFetchingNextPage && 'opacity-100',
)}
>
<Loader2Icon className="size-4 animate-spin" />
</div>
</div>
)}
</>
);

View File

@@ -18,7 +18,7 @@ const CopyInput = ({ label, value, className }: Props) => {
onClick={() => clipboard(value)}
>
{!!label && <Label>{label}</Label>}
<div className="font-mono flex items-center justify-between rounded border-input bg-def-200 p-2 px-3 ">
<div className="font-mono flex items-center justify-between rounded border-input bg-card p-2 px-3 ">
{value}
<CopyIcon size={16} />
</div>

View File

@@ -138,7 +138,7 @@ const TagInput = ({
<input
ref={inputRef}
placeholder={`${placeholder}`}
className="min-w-20 flex-1 py-1 focus-visible:outline-none"
className="min-w-20 flex-1 py-1 focus-visible:outline-none bg-card"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}

View File

@@ -66,7 +66,7 @@ export const GridCell: React.FC<
)}
{...props}
>
{children}
<div className="truncate w-full">{children}</div>
</Component>
);

View File

@@ -7,6 +7,7 @@ import {
} from '@/hooks/useEventQueryFilters';
import { getPropertyLabel } from '@/translations/properties';
import { cn } from '@/utils/cn';
import { operators } from '@openpanel/constants';
import { X } from 'lucide-react';
import type { Options as NuqsOptions } from 'nuqs';
@@ -20,7 +21,8 @@ export function OverviewFiltersButtons({
nuqsOptions,
}: OverviewFiltersButtonsProps) {
const [events, setEvents] = useEventQueryNamesFilter(nuqsOptions);
const [filters, setFilter] = useEventQueryFilters(nuqsOptions);
const [filters, setFilter, setFilters, removeFilter] =
useEventQueryFilters(nuqsOptions);
if (filters.length === 0 && events.length === 0) return null;
return (
<div className={cn('flex flex-wrap gap-2', className)}>
@@ -36,20 +38,23 @@ export function OverviewFiltersButtons({
</Button>
))}
{filters.map((filter) => {
if (!filter.value[0]) {
return null;
}
return (
<Button
key={filter.name}
size="sm"
variant="outline"
icon={X}
onClick={() => setFilter(filter.name, [], 'is')}
onClick={() => removeFilter(filter.name)}
>
<span className="mr-1">{getPropertyLabel(filter.name)} is</span>
<strong className="font-semibold">{filter.value.join(', ')}</strong>
<span>{getPropertyLabel(filter.name)}</span>
<span className="opacity-40 ml-2 lowercase">
{operators[filter.operator]}
</span>
{filter.value.length > 0 && (
<strong className="font-semibold ml-2">
{filter.value.join(', ')}
</strong>
)}
</Button>
);
})}

View File

@@ -31,6 +31,10 @@ export interface OverviewFiltersDrawerContentProps {
mode: 'profiles' | 'events';
}
const excludePropertyFilter = (name: string) => {
return ['*', 'duration', 'created_at', 'has_profile'].includes(name);
};
export function OverviewFiltersDrawerContent({
projectId,
nuqsOptions,
@@ -60,10 +64,12 @@ export function OverviewFiltersDrawerContent({
value={event}
onChange={setEvent}
// First items is * which is only used for report editing
items={eventNames.slice(1).map((item) => ({
label: item.name,
value: item.name,
}))}
items={eventNames
.filter((item) => !excludePropertyFilter(item.name))
.map((item) => ({
label: item.name,
value: item.name,
}))}
placeholder="Select event"
/>
)}

View File

@@ -0,0 +1,49 @@
import type { IGetTopGenericInput } from '@openpanel/db';
export const OVERVIEW_COLUMNS_NAME: Record<
IGetTopGenericInput['column'],
string
> = {
country: 'Country',
region: 'Region',
city: 'City',
browser: 'Browser',
brand: 'Brand',
os: 'OS',
device: 'Device',
browser_version: 'Browser version',
os_version: 'OS version',
model: 'Model',
referrer: 'Referrer',
referrer_name: 'Referrer name',
referrer_type: 'Referrer type',
utm_source: 'UTM source',
utm_medium: 'UTM medium',
utm_campaign: 'UTM campaign',
utm_term: 'UTM term',
utm_content: 'UTM content',
};
export const OVERVIEW_COLUMNS_NAME_PLURAL: Record<
IGetTopGenericInput['column'],
string
> = {
country: 'Countries',
region: 'Regions',
city: 'Cities',
browser: 'Browsers',
brand: 'Brands',
os: 'OSs',
device: 'Devices',
browser_version: 'Browser versions',
os_version: 'OS versions',
model: 'Models',
referrer: 'Referrers',
referrer_name: 'Referrer names',
referrer_type: 'Referrer types',
utm_source: 'UTM sources',
utm_medium: 'UTM mediums',
utm_campaign: 'UTM campaigns',
utm_term: 'UTM terms',
utm_content: 'UTM contents',
};

View File

@@ -1,25 +1,12 @@
import { pushModal } from '@/modals';
import { ScanEyeIcon } from 'lucide-react';
import type { IChartProps } from '@openpanel/validation';
import { Button, type ButtonProps } from '../ui/button';
import { Button } from '../ui/button';
type Props = Omit<ButtonProps, 'children'>;
type Props = {
chart: IChartProps;
};
const OverviewDetailsButton = ({ chart }: Props) => {
const OverviewDetailsButton = (props: Props) => {
return (
<Button
size="icon"
variant="ghost"
onClick={() => {
pushModal('OverviewChartDetails', {
chart: chart,
});
}}
>
<Button size="icon" variant="ghost" {...props}>
<ScanEyeIcon size={18} />
</Button>
);

View File

@@ -0,0 +1,192 @@
'use client';
import { fancyMinutes, useNumber } from '@/hooks/useNumerFormatter';
import type { IChartData, RouterOutputs } from '@/trpc/client';
import { cn } from '@/utils/cn';
import AutoSizer from 'react-virtualized-auto-sizer';
import { Area, AreaChart } from 'recharts';
import { average, getPreviousMetric, sum } from '@openpanel/common';
import type { IChartMetric, Metrics } from '@openpanel/validation';
import {
PreviousDiffIndicator,
PreviousDiffIndicatorPure,
getDiffIndicator,
} from '../report-chart/common/previous-diff-indicator';
import { Skeleton } from '../skeleton';
import { Tooltiper } from '../ui/tooltip';
interface MetricCardProps {
id: string;
data: {
current: number;
previous?: number;
}[];
metric: {
current: number;
previous?: number | null;
};
unit?: string;
label: string;
onClick?: () => void;
active?: boolean;
inverted?: boolean;
isLoading?: boolean;
}
export function OverviewMetricCard({
id,
data,
metric,
unit,
label,
onClick,
active,
inverted = false,
isLoading = false,
}: MetricCardProps) {
const number = useNumber();
const { current, previous } = metric;
const renderValue = (value: number, unitClassName?: string, short = true) => {
if (unit === 'min') {
return <>{fancyMinutes(value)}</>;
}
return (
<>
{short ? number.short(value) : number.format(value)}
{unit && <span className={unitClassName}>{unit}</span>}
</>
);
};
const graphColors = getDiffIndicator(
inverted,
getPreviousMetric(current, previous)?.state,
'#6ee7b7', // green
'#fda4af', // red
'#93c5fd', // blue
);
return (
<Tooltiper
content={
<span>
{label}:{' '}
<span className="font-semibold">
{renderValue(current, 'ml-1 font-light text-xl', false)}
</span>
</span>
}
asChild
sideOffset={-20}
>
<button
type="button"
className={cn(
'col-span-2 flex-1 shadow-[0_0_0_0.5px] shadow-border md:col-span-1',
active && 'bg-def-100',
)}
onClick={onClick}
>
<div className={cn('group relative p-4')}>
<div
className={cn(
'pointer-events-none absolute -left-1 -right-1 bottom-0 top-0 z-0 opacity-50 transition-opacity duration-300 group-hover:opacity-100',
)}
>
<AutoSizer>
{({ width, height }) => (
<AreaChart
width={width}
height={height / 4}
data={data}
style={{ marginTop: (height / 4) * 3 }}
>
<defs>
<linearGradient
id={`colorUv${id}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={graphColors}
stopOpacity={0.2}
/>
<stop
offset="100%"
stopColor={graphColors}
stopOpacity={0.05}
/>
</linearGradient>
</defs>
<Area
dataKey={'current'}
type="step"
fill={`url(#colorUv${id})`}
fillOpacity={1}
stroke={graphColors}
strokeWidth={1}
isAnimationActive={false}
/>
</AreaChart>
)}
</AutoSizer>
</div>
<OverviewMetricCardNumber
label={label}
value={renderValue(current, 'ml-1 font-light text-xl')}
enhancer={
<PreviousDiffIndicatorPure
className="text-sm"
size="sm"
inverted={inverted}
{...getPreviousMetric(current, previous)}
/>
}
isLoading={isLoading}
/>
</div>
</button>
</Tooltiper>
);
}
export function OverviewMetricCardNumber({
label,
value,
enhancer,
className,
isLoading,
}: {
label: React.ReactNode;
value: React.ReactNode;
enhancer?: React.ReactNode;
className?: string;
isLoading?: boolean;
}) {
return (
<div className={cn('flex min-w-0 flex-col gap-2', className)}>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2 text-left">
<span className="truncate text-muted-foreground">{label}</span>
</div>
</div>
{isLoading ? (
<div className="flex items-end justify-between gap-4">
<Skeleton className="h-6 w-16" />
<Skeleton className="h-6 w-12" />
</div>
) : (
<div className="flex items-end justify-between gap-4">
<div className="truncate font-mono text-3xl font-bold">{value}</div>
{enhancer}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,258 @@
'use client';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { useNumber } from '@/hooks/useNumerFormatter';
import { type RouterOutputs, api } from '@/trpc/client';
import { getChartColor } from '@/utils/theme';
import { getPreviousMetric } from '@openpanel/common';
import React from 'react';
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
import { createChartTooltip } from '../charts/chart-tooltip';
import { useXAxisProps, useYAxisProps } from '../report-chart/common/axis';
import { PreviousDiffIndicatorPure } from '../report-chart/common/previous-diff-indicator';
import { Skeleton } from '../skeleton';
import { OverviewLiveHistogram } from './overview-live-histogram';
import { OverviewMetricCard } from './overview-metric-card';
interface OverviewMetricsProps {
projectId: string;
}
const TITLES = [
{
title: 'Unique Visitors',
key: 'unique_visitors',
unit: '',
inverted: false,
},
{
title: 'Sessions',
key: 'total_sessions',
unit: '',
inverted: false,
},
{
title: 'Pageviews',
key: 'total_screen_views',
unit: '',
inverted: false,
},
{
title: 'Pages per session',
key: 'views_per_session',
unit: '',
inverted: false,
},
{
title: 'Bounce Rate',
key: 'bounce_rate',
unit: '%',
inverted: true,
},
{
title: 'Session Duration',
key: 'avg_session_duration',
unit: 'min',
inverted: false,
},
] as const;
export default function OverviewMetricsV2({ projectId }: OverviewMetricsProps) {
const { range, interval, metric, setMetric, startDate, endDate } =
useOverviewOptions();
const [filters] = useEventQueryFilters();
const activeMetric = TITLES[metric]!;
const overviewQuery = api.overview.stats.useQuery({
projectId,
range,
interval,
filters,
startDate,
endDate,
});
const data =
overviewQuery.data?.series?.map((item) => ({
...item,
timestamp: new Date(item.date).getTime(),
})) || [];
const xAxisProps = useXAxisProps({ interval: 'day' });
const yAxisProps = useYAxisProps();
return (
<>
<div className="relative -top-0.5 col-span-6 -m-4 mb-0 mt-0 md:m-0">
<div className="card mb-2 grid grid-cols-4 overflow-hidden rounded-md">
{TITLES.map((title, index) => (
<OverviewMetricCard
key={title.key}
id={title.key}
onClick={() => setMetric(index)}
label={title.title}
metric={{
current: overviewQuery.data?.metrics[title.key] ?? 0,
previous: overviewQuery.data?.metrics[`prev_${title.key}`],
}}
unit={title.unit}
data={data.map((item) => ({
current: item[title.key],
previous: item[`prev_${title.key}`],
}))}
active={metric === index}
isLoading={overviewQuery.isLoading}
/>
))}
<div
className={cn(
'col-span-4 min-h-16 flex-1 p-4 pb-0 shadow-[0_0_0_0.5px] shadow-border max-md:row-start-1 md:col-span-2',
)}
>
<OverviewLiveHistogram projectId={projectId} />
</div>
</div>
<div className="card p-4">
<div className="text-center text-muted-foreground mb-2">
{activeMetric.title}
</div>
<div className="w-full h-[150px]">
{overviewQuery.isLoading && <Skeleton className="h-full w-full" />}
<TooltipProvider metric={activeMetric}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data}>
<Tooltip />
<YAxis
{...yAxisProps}
domain={[
0,
activeMetric.key === 'bounce_rate' ? 100 : 'dataMax',
]}
width={25}
/>
<XAxis {...xAxisProps} />
<CartesianGrid
strokeDasharray="3 3"
horizontal={true}
vertical={false}
className="stroke-border"
/>
<Line
key={`prev_${activeMetric.key}`}
type="linear"
dataKey={`prev_${activeMetric.key}`}
stroke={'hsl(var(--foreground) / 0.2)'}
strokeWidth={2}
isAnimationActive={false}
dot={
data.length > 90
? false
: {
stroke: 'hsl(var(--foreground) / 0.2)',
fill: 'hsl(var(--def-100))',
strokeWidth: 1.5,
r: 3,
}
}
activeDot={{
stroke: 'hsl(var(--foreground) / 0.2)',
fill: 'hsl(var(--def-100))',
strokeWidth: 2,
r: 4,
}}
/>
<Line
key={activeMetric.key}
type="linear"
dataKey={activeMetric.key}
stroke={getChartColor(0)}
strokeWidth={2}
isAnimationActive={false}
dot={
data.length > 90
? false
: {
stroke: getChartColor(0),
fill: 'hsl(var(--def-100))',
strokeWidth: 1.5,
r: 3,
}
}
activeDot={{
stroke: getChartColor(0),
fill: 'hsl(var(--def-100))',
strokeWidth: 2,
r: 4,
}}
/>
</LineChart>
</ResponsiveContainer>
</TooltipProvider>
</div>
</div>
</div>
</>
);
}
const { Tooltip, TooltipProvider } = createChartTooltip<
RouterOutputs['overview']['stats']['series'][number],
{
metric: (typeof TITLES)[number];
}
>(({ context: { metric }, data }) => {
const formatDate = useFormatDateInterval('day');
const number = useNumber();
return (
<>
<div className="flex justify-between gap-8 text-muted-foreground">
<div>{formatDate(new Date(data.date))}</div>
</div>
<React.Fragment>
<div className="flex gap-2">
<div
className="w-[3px] rounded-full"
style={{ background: getChartColor(0) }}
/>
<div className="col flex-1 gap-1">
<div className="flex items-center gap-1">{metric.title}</div>
<div className="flex justify-between gap-8 font-mono font-medium">
<div className="row gap-1">
{number.formatWithUnit(data[metric.key])}
{!!data[`prev_${metric.key}`] && (
<span className="text-muted-foreground">
({number.formatWithUnit(data[`prev_${metric.key}`])})
</span>
)}
</div>
<PreviousDiffIndicatorPure
{...getPreviousMetric(
data[metric.key],
data[`prev_${metric.key}`],
)}
/>
</div>
</div>
</div>
</React.Fragment>
</>
);
});

View File

@@ -218,7 +218,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
<OverviewLiveHistogram projectId={projectId} />
</div>
</div>
<div className="card col-span-6 p-4">
{/* <div className="card col-span-6 p-4">
<ReportChart
key={selectedMetric.id}
options={{
@@ -233,7 +233,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
lineType: 'linear',
}}
/>
</div>
</div> */}
</div>
</>
);

View File

@@ -7,11 +7,18 @@ import { useState } from 'react';
import { NOT_SET_VALUE } from '@openpanel/constants';
import type { IChartType } from '@openpanel/validation';
import { ReportChart } from '../report-chart';
import { useNumber } from '@/hooks/useNumerFormatter';
import { pushModal } from '@/modals';
import { api } from '@/trpc/client';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { Widget, WidgetBody } from '../widget';
import { OverviewChartToggle } from './overview-chart-toggle';
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
import OverviewDetailsButton from './overview-details-button';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
import {
OverviewWidgetTableGeneric,
OverviewWidgetTableLoading,
} from './overview-widget-table';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
@@ -27,7 +34,7 @@ export default function OverviewTopDevices({
const [chartType, setChartType] = useState<IChartType>('bar');
const isPageFilter = filters.find((filter) => filter.name === 'path');
const [widget, setWidget, widgets] = useOverviewWidget('tech', {
devices: {
device: {
title: 'Top devices',
btn: 'Devices',
chart: {
@@ -221,7 +228,7 @@ export default function OverviewTopDevices({
},
},
},
brands: {
brand: {
title: 'Top Brands',
btn: 'Brands',
chart: {
@@ -257,7 +264,7 @@ export default function OverviewTopDevices({
},
},
},
models: {
model: {
title: 'Top Models',
btn: 'Models',
chart: {
@@ -302,11 +309,24 @@ export default function OverviewTopDevices({
},
});
const number = useNumber();
const query = api.overview.topGeneric.useQuery({
projectId,
interval,
range,
filters,
column: widget.key,
startDate,
endDate,
});
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
@@ -321,39 +341,44 @@ export default function OverviewTopDevices({
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<ReportChart
options={{
...widget.chart.options,
hideID: true,
onClick: (item) => {
switch (widget.key) {
case 'devices':
setFilter('device', item.names[0]);
break;
case 'browser':
setFilter('browser', item.names[0]);
break;
case 'browser_version':
setFilter('browser_version', item.names[1]);
break;
case 'os':
setFilter('os', item.names[0]);
break;
case 'os_version':
setFilter('os_version', item.names[1]);
break;
}
},
}}
report={{
...widget.chart.report,
previous: false,
}}
/>
{query.isLoading ? (
<OverviewWidgetTableLoading className="-m-4" />
) : (
<OverviewWidgetTableGeneric
className="-m-4"
data={query.data ?? []}
column={{
name: OVERVIEW_COLUMNS_NAME[widget.key],
render(item) {
return (
<div className="row items-center gap-2 min-w-0 relative">
<SerieIcon name={item.name || NOT_SET_VALUE} />
<button
type="button"
className="truncate"
onClick={() => {
setFilter(widget.key, item.name);
}}
>
{item.name || 'Not set'}
</button>
</div>
);
},
}}
/>
)}
</WidgetBody>
<WidgetFooter>
<OverviewDetailsButton chart={widget.chart.report} />
<OverviewChartToggle {...{ chartType, setChartType }} />
<OverviewDetailsButton
onClick={() =>
pushModal('OverviewTopGenericModal', {
projectId,
column: widget.key,
})
}
/>
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
</WidgetFooter>
</Widget>
</>

View File

@@ -9,7 +9,6 @@ import type { IChartType } from '@openpanel/validation';
import { Widget, WidgetBody } from '../../widget';
import { OverviewChartToggle } from '../overview-chart-toggle';
import OverviewDetailsButton from '../overview-details-button';
import { WidgetButtons, WidgetFooter, WidgetHead } from '../overview-widget';
import { useOverviewOptions } from '../useOverviewOptions';
import { useOverviewWidget } from '../useOverviewWidget';
@@ -175,7 +174,6 @@ export default function OverviewTopEvents({
/>
</WidgetBody>
<WidgetFooter>
<OverviewDetailsButton chart={widget.chart.report} />
<OverviewChartToggle {...{ chartType, setChartType }} />
</WidgetFooter>
</Widget>

View File

@@ -0,0 +1,107 @@
'use client';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { ModalContent, ModalHeader } from '@/modals/Modal/Container';
import { api } from '@/trpc/client';
import type { IGetTopGenericInput } from '@openpanel/db';
import { ChevronRightIcon } from 'lucide-react';
import { useEffect } from 'react';
import { toast } from 'sonner';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { Button } from '../ui/button';
import { ScrollArea } from '../ui/scroll-area';
import {
OVERVIEW_COLUMNS_NAME,
OVERVIEW_COLUMNS_NAME_PLURAL,
} from './overview-constants';
import { OverviewWidgetTableGeneric } from './overview-widget-table';
import { useOverviewOptions } from './useOverviewOptions';
interface OverviewTopGenericModalProps {
projectId: string;
column: IGetTopGenericInput['column'];
}
export default function OverviewTopGenericModal({
projectId,
column,
}: OverviewTopGenericModalProps) {
const [filters, setFilter] = useEventQueryFilters();
const { startDate, endDate, range, interval } = useOverviewOptions();
const query = api.overview.topGeneric.useInfiniteQuery(
{
projectId,
filters,
startDate,
endDate,
range,
interval,
limit: 50,
column,
},
{
getNextPageParam: (lastPage, pages) => {
if (lastPage.length === 0) {
return null;
}
return pages.length + 1;
},
},
);
const data = query.data?.pages.flat() || [];
const isEmpty = !query.hasNextPage && !query.isFetching;
const columnNamePlural = OVERVIEW_COLUMNS_NAME_PLURAL[column];
const columnName = OVERVIEW_COLUMNS_NAME[column];
return (
<ModalContent>
<ModalHeader title={`Top ${columnNamePlural}`} />
<ScrollArea className="-mx-6 px-2 max-h-[calc(80vh)]">
<OverviewWidgetTableGeneric
data={data}
column={{
name: columnName,
render(item) {
return (
<div className="row items-center gap-2 min-w-0 relative">
<SerieIcon name={item.prefix || item.name} />
<button
type="button"
className="truncate"
onClick={() => {
setFilter(column, item.name);
}}
>
{item.prefix && (
<span className="mr-1 row inline-flex items-center gap-1">
<span>{item.prefix}</span>
<span>
<ChevronRightIcon className="size-3" />
</span>
</span>
)}
{item.name || 'Not set'}
</button>
</div>
);
},
}}
/>
<div className="row center-center p-4 pb-0">
<Button
variant="outline"
className="w-full"
onClick={() => query.fetchNextPage()}
disabled={isEmpty}
>
Load more
</Button>
</div>
</ScrollArea>
</ModalContent>
);
}

View File

@@ -1,20 +1,28 @@
'use client';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { getCountry } from '@/translations/countries';
import { cn } from '@/utils/cn';
import { useState } from 'react';
import { NOT_SET_VALUE } from '@openpanel/constants';
import type { IChartType } from '@openpanel/validation';
import { useNumber } from '@/hooks/useNumerFormatter';
import { pushModal } from '@/modals';
import { api } from '@/trpc/client';
import { NOT_SET_VALUE } from '@openpanel/constants';
import { ChevronRightIcon } from 'lucide-react';
import { ReportChart } from '../report-chart';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { Widget, WidgetBody } from '../widget';
import { OverviewChartToggle } from './overview-chart-toggle';
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
import OverviewDetailsButton from './overview-details-button';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
import {
OverviewWidgetTableGeneric,
OverviewWidgetTableLoading,
} from './overview-widget-table';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
import { useOverviewWidgetV2 } from './useOverviewWidget';
interface OverviewTopGeoProps {
projectId: string;
@@ -25,139 +33,39 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
const [chartType, setChartType] = useState<IChartType>('bar');
const [filters, setFilter] = useEventQueryFilters();
const isPageFilter = filters.find((filter) => filter.name === 'path');
const [widget, setWidget, widgets] = useOverviewWidget('geo', {
countries: {
const [widget, setWidget, widgets] = useOverviewWidgetV2('geo', {
country: {
title: 'Top countries',
btn: 'Countries',
chart: {
options: {
columns: ['Country', isPageFilter ? 'Views' : 'Sessions'],
renderSerieName(name) {
return getCountry(name[0]) || NOT_SET_VALUE;
},
},
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'country',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top countries',
range: range,
previous: previous,
metric: 'sum',
},
},
},
regions: {
region: {
title: 'Top regions',
btn: 'Regions',
chart: {
options: {
columns: ['Region', isPageFilter ? 'Views' : 'Sessions'],
renderSerieName(name) {
return name[1] || NOT_SET_VALUE;
},
},
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'country',
},
{
id: 'B',
name: 'region',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top regions',
range: range,
previous: previous,
metric: 'sum',
},
},
},
cities: {
city: {
title: 'Top cities',
btn: 'Cities',
chart: {
options: {
columns: ['City', isPageFilter ? 'Views' : 'Sessions'],
renderSerieName(name) {
return name[1] || NOT_SET_VALUE;
},
},
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'country',
},
{
id: 'B',
name: 'city',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top cities',
range: range,
previous: previous,
metric: 'sum',
},
},
},
});
const number = useNumber();
const query = api.overview.topGeneric.useQuery({
projectId,
interval,
range,
filters,
column: widget.key,
startDate,
endDate,
});
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
@@ -172,35 +80,59 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<ReportChart
options={{
hideID: true,
onClick: (item) => {
switch (widget.key) {
case 'countries':
setWidget('regions');
setFilter('country', item.names[0]);
break;
case 'regions':
setWidget('cities');
setFilter('region', item.names[1]);
break;
case 'cities':
setFilter('city', item.names[1]);
break;
}
},
...widget.chart.options,
}}
report={{
...widget.chart.report,
previous: false,
}}
/>
{query.isLoading ? (
<OverviewWidgetTableLoading className="-m-4" />
) : (
<OverviewWidgetTableGeneric
className="-m-4"
data={query.data ?? []}
column={{
name: OVERVIEW_COLUMNS_NAME[widget.key],
render(item) {
return (
<div className="row items-center gap-2 min-w-0 relative">
<SerieIcon
name={item.prefix || item.name || NOT_SET_VALUE}
/>
<button
type="button"
className="truncate"
onClick={() => {
if (widget.key === 'country') {
setWidget('region');
} else if (widget.key === 'region') {
setWidget('city');
}
setFilter(widget.key, item.name);
}}
>
{item.prefix && (
<span className="mr-1 row inline-flex items-center gap-1">
<span>{item.prefix}</span>
<span>
<ChevronRightIcon className="size-3" />
</span>
</span>
)}
{item.name || 'Not set'}
</button>
</div>
);
},
}}
/>
)}
</WidgetBody>
<WidgetFooter>
<OverviewDetailsButton chart={widget.chart.report} />
<OverviewChartToggle {...{ chartType, setChartType }} />
<OverviewDetailsButton
onClick={() =>
pushModal('OverviewTopGenericModal', {
projectId,
column: widget.key,
})
}
/>
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
</WidgetFooter>
</Widget>
<Widget className="col-span-6 md:col-span-3">

View File

@@ -0,0 +1,68 @@
'use client';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { ModalContent, ModalHeader } from '@/modals/Modal/Container';
import { api } from '@/trpc/client';
import { Button } from '../ui/button';
import { ScrollArea } from '../ui/scroll-area';
import { OverviewWidgetTablePages } from './overview-widget-table';
import { useOverviewOptions } from './useOverviewOptions';
interface OverviewTopPagesProps {
projectId: string;
}
function getPath(path: string) {
try {
return new URL(path).pathname;
} catch {
return path;
}
}
export default function OverviewTopPagesModal({
projectId,
}: OverviewTopPagesProps) {
const [filters, setFilter] = useEventQueryFilters();
const { startDate, endDate, range, interval } = useOverviewOptions();
const query = api.overview.topPages.useInfiniteQuery(
{
projectId,
filters,
startDate,
endDate,
mode: 'page',
range,
interval: 'day',
limit: 50,
},
{
getNextPageParam: (_, pages) => pages.length + 1,
},
);
const data = query.data?.pages.flat();
return (
<ModalContent>
<ModalHeader title="Top Pages" />
<ScrollArea className="-mx-6 px-2 max-h-[calc(80vh)]">
<OverviewWidgetTablePages
data={data ?? []}
lastColumnName={'Sessions'}
/>
<div className="row center-center p-4 pb-0">
<Button
variant="outline"
className="w-full"
onClick={() => query.fetchNextPage()}
loading={query.isFetching}
>
Load more
</Button>
</div>
</ScrollArea>
</ModalContent>
);
}

View File

@@ -2,179 +2,81 @@
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { ExternalLinkIcon, FilterIcon, Globe2Icon } from 'lucide-react';
import { Globe2Icon } from 'lucide-react';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { useState } from 'react';
import { NOT_SET_VALUE } from '@openpanel/constants';
import type { IChartType } from '@openpanel/validation';
import { ReportChart } from '../report-chart';
import { pushModal } from '@/modals';
import { api } from '@/trpc/client';
import { Button } from '../ui/button';
import { Tooltiper } from '../ui/tooltip';
import { Widget, WidgetBody } from '../widget';
import { OverviewChartToggle } from './overview-chart-toggle';
import OverviewDetailsButton from './overview-details-button';
import OverviewTopBots from './overview-top-bots';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
import {
OverviewWidgetTableBots,
OverviewWidgetTableLoading,
OverviewWidgetTablePages,
} from './overview-widget-table';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
import { useOverviewWidgetV2 } from './useOverviewWidget';
interface OverviewTopPagesProps {
projectId: string;
}
export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
const { interval, range, previous, startDate, endDate } =
useOverviewOptions();
const [chartType, setChartType] = useState<IChartType>('bar');
const [filters, setFilter] = useEventQueryFilters();
const [filters] = useEventQueryFilters();
const [domain, setDomain] = useQueryState('d', parseAsBoolean);
const renderSerieName = (names: string[]) => {
if (domain) {
if (names[0] === NOT_SET_VALUE) {
return names[1];
}
return names.join('');
}
return (
<Tooltiper content={names.join('')} side="left" className="text-left">
{names[1] || NOT_SET_VALUE}
</Tooltiper>
);
};
const [widget, setWidget, widgets] = useOverviewWidget('pages', {
top: {
const [widget, setWidget, widgets] = useOverviewWidgetV2('pages', {
page: {
title: 'Top pages',
btn: 'Top pages',
chart: {
options: {
renderSerieName,
columns: ['URL', 'Views'],
},
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'screen_view',
},
],
breakdowns: [
{
id: 'A',
name: 'origin',
},
{
id: 'B',
name: 'path',
},
],
chartType,
lineType: 'monotone',
interval,
name: 'Top pages',
range,
previous,
metric: 'sum',
meta: {
columns: {
sessions: 'Sessions',
},
},
},
entries: {
entry: {
title: 'Entry Pages',
btn: 'Entries',
chart: {
options: {
columns: ['URL', 'Sessions'],
renderSerieName,
},
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'origin',
},
{
id: 'B',
name: 'path',
},
],
chartType,
lineType: 'monotone',
interval,
name: 'Entry Pages',
range,
previous,
metric: 'sum',
meta: {
columns: {
sessions: 'Entries',
},
},
},
exits: {
exit: {
title: 'Exit Pages',
btn: 'Exits',
chart: {
options: {
columns: ['URL', 'Sessions'],
renderSerieName,
},
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'session_end',
},
],
breakdowns: [
{
id: 'A',
name: 'origin',
},
{
id: 'B',
name: 'path',
},
],
chartType,
lineType: 'monotone',
interval,
name: 'Exit Pages',
range,
previous,
metric: 'sum',
meta: {
columns: {
sessions: 'Exits',
},
},
},
bot: {
title: 'Bots',
btn: 'Bots',
// @ts-expect-error
chart: null,
},
// bot: {
// title: 'Bots',
// btn: 'Bots',
// },
});
const query = api.overview.topPages.useQuery({
projectId,
filters,
startDate,
endDate,
mode: widget.key,
range,
interval,
});
const data = query.data;
return (
<>
<Widget className="col-span-6 md:col-span-3">
@@ -194,53 +96,36 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
</WidgetButtons>
</WidgetHead>
<WidgetBody>
{widget.key === 'bot' ? (
<OverviewTopBots projectId={projectId} />
{query.isLoading ? (
<OverviewWidgetTableLoading className="-m-4" />
) : (
<ReportChart
options={{
hideID: true,
dropdownMenuContent: (serie) => [
{
title: 'Visit page',
icon: ExternalLinkIcon,
onClick: () => {
window.open(serie.names.join(''), '_blank');
},
},
{
title: 'Set filter',
icon: FilterIcon,
onClick: () => {
setFilter('path', serie.names[1]);
},
},
],
...widget.chart.options,
}}
report={{
...widget.chart.report,
previous: false,
}}
/>
<>
{/*<OverviewWidgetTableBots className="-m-4" data={data ?? []} />*/}
<OverviewWidgetTablePages
className="-m-4"
data={data ?? []}
lastColumnName={widget.meta.columns.sessions}
showDomain={!!domain}
/>
</>
)}
</WidgetBody>
{widget.chart?.report?.name && (
<WidgetFooter>
<OverviewDetailsButton chart={widget.chart.report} />
<OverviewChartToggle {...{ chartType, setChartType }} />
<div className="flex-1" />
<Button
variant={'ghost'}
onClick={() => {
setDomain((p) => !p);
}}
icon={Globe2Icon}
>
{domain ? 'Hide domain' : 'Show domain'}
</Button>
</WidgetFooter>
)}
<WidgetFooter>
<OverviewDetailsButton
onClick={() => pushModal('OverviewTopPagesModal', { projectId })}
/>
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
<div className="flex-1" />
<Button
variant={'ghost'}
onClick={() => {
setDomain((p) => !p);
}}
icon={Globe2Icon}
>
{domain ? 'Hide domain' : 'Show domain'}
</Button>
</WidgetFooter>
</Widget>
</>
);

View File

@@ -2,18 +2,21 @@
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { useState } from 'react';
import type { IChartType } from '@openpanel/validation';
import { pushModal } from '@/modals';
import { api } from '@/trpc/client';
import { NOT_SET_VALUE } from '@openpanel/constants';
import { ReportChart } from '../report-chart';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { Widget, WidgetBody } from '../widget';
import { OverviewChartToggle } from './overview-chart-toggle';
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
import OverviewDetailsButton from './overview-details-button';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
import {
OverviewWidgetTableGeneric,
OverviewWidgetTableLoading,
} from './overview-widget-table';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
import { useOverviewWidgetV2 } from './useOverviewWidget';
interface OverviewTopSourcesProps {
projectId: string;
@@ -21,302 +24,53 @@ interface OverviewTopSourcesProps {
export default function OverviewTopSources({
projectId,
}: OverviewTopSourcesProps) {
const { interval, range, previous, startDate, endDate } =
useOverviewOptions();
const [chartType, setChartType] = useState<IChartType>('bar');
const { interval, range, startDate, endDate } = useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters();
const isPageFilter = filters.find((filter) => filter.name === 'path');
const [widget, setWidget, widgets] = useOverviewWidget('sources', {
all: {
const [widget, setWidget, widgets] = useOverviewWidgetV2('sources', {
referrer_name: {
title: 'Top sources',
btn: 'All',
chart: {
options: {
columns: ['Source', isPageFilter ? 'Views' : 'Sessions'],
},
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters: filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'referrer_name',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
},
domain: {
referrer: {
title: 'Top urls',
btn: 'URLs',
chart: {
options: {
columns: ['URL', isPageFilter ? 'Views' : 'Sessions'],
},
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters: filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'referrer',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top urls',
range: range,
previous: previous,
metric: 'sum',
},
},
},
type: {
referrer_type: {
title: 'Top types',
btn: 'Types',
chart: {
options: {
columns: ['Type', isPageFilter ? 'Views' : 'Sessions'],
},
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters: filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'referrer_type',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top types',
range: range,
previous: previous,
metric: 'sum',
},
},
},
utm_source: {
title: 'UTM Source',
btn: 'Source',
chart: {
options: {
columns: ['Utm Source', isPageFilter ? 'Views' : 'Sessions'],
},
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'properties.__query.utm_source',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'UTM Source',
range: range,
previous: previous,
metric: 'sum',
},
},
},
utm_medium: {
title: 'UTM Medium',
btn: 'Medium',
chart: {
options: {
columns: ['Utm Medium', isPageFilter ? 'Views' : 'Sessions'],
},
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'properties.__query.utm_medium',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'UTM Medium',
range: range,
previous: previous,
metric: 'sum',
},
},
},
utm_campaign: {
title: 'UTM Campaign',
btn: 'Campaign',
chart: {
options: {
columns: ['Utm Campaign', isPageFilter ? 'Views' : 'Sessions'],
},
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'properties.__query.utm_campaign',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'UTM Campaign',
range: range,
previous: previous,
metric: 'sum',
},
},
},
utm_term: {
title: 'UTM Term',
btn: 'Term',
chart: {
options: {
columns: ['Utm Term', isPageFilter ? 'Views' : 'Sessions'],
},
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'properties.__query.utm_term',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'UTM Term',
range: range,
previous: previous,
metric: 'sum',
},
},
},
utm_content: {
title: 'UTM Content',
btn: 'Content',
chart: {
options: {
columns: ['Utm Content', isPageFilter ? 'Views' : 'Sessions'],
},
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'properties.__query.utm_content',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'UTM Content',
range: range,
previous: previous,
metric: 'sum',
},
},
},
});
const query = api.overview.topGeneric.useQuery({
projectId,
interval,
range,
filters,
column: widget.key,
startDate,
endDate,
});
return (
<>
<Widget className="col-span-6 md:col-span-3">
@@ -337,51 +91,53 @@ export default function OverviewTopSources({
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<ReportChart
report={{
...widget.chart.report,
previous: false,
}}
options={{
...widget.chart.options,
renderSerieName: (name) =>
name[0] === NOT_SET_VALUE ? 'Direct / Not set' : name[0],
onClick: (item) => {
switch (widget.key) {
case 'all':
setFilter('referrer_name', item.names[0]);
setWidget('domain');
break;
case 'domain':
setFilter('referrer', item.names[0]);
break;
case 'type':
setFilter('referrer_type', item.names[0]);
setWidget('domain');
break;
case 'utm_source':
setFilter('properties.__query.utm_source', item.names[0]);
break;
case 'utm_medium':
setFilter('properties.__query.utm_medium', item.names[0]);
break;
case 'utm_campaign':
setFilter('properties.__query.utm_campaign', item.names[0]);
break;
case 'utm_term':
setFilter('properties.__query.utm_term', item.names[0]);
break;
case 'utm_content':
setFilter('properties.__query.utm_content', item.names[0]);
break;
}
},
}}
/>
{query.isLoading ? (
<OverviewWidgetTableLoading className="-m-4" />
) : (
<OverviewWidgetTableGeneric
className="-m-4"
data={query.data ?? []}
column={{
name: OVERVIEW_COLUMNS_NAME[widget.key],
render(item) {
return (
<div className="row items-center gap-2 min-w-0 relative">
<SerieIcon name={item.name || NOT_SET_VALUE} />
<button
type="button"
className="truncate"
onClick={() => {
if (widget.key.startsWith('utm_')) {
setFilter(
`properties.__query.${widget.key}`,
item.name,
);
} else {
setFilter(widget.key, item.name);
}
}}
>
{(item.name || 'Direct / Not set')
.replace(/https?:\/\//, '')
.replace('www.', '')}
</button>
</div>
);
},
}}
/>
)}
</WidgetBody>
<WidgetFooter>
<OverviewDetailsButton chart={widget.chart.report} />
<OverviewChartToggle {...{ chartType, setChartType }} />
<OverviewDetailsButton
onClick={() =>
pushModal('OverviewTopGenericModal', {
projectId,
column: widget.key,
})
}
/>
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
</WidgetFooter>
</Widget>
</>

View File

@@ -0,0 +1,330 @@
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { useNumber } from '@/hooks/useNumerFormatter';
import type { RouterOutputs } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { ExternalLinkIcon } from 'lucide-react';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { Skeleton } from '../skeleton';
import { Tooltiper } from '../ui/tooltip';
import { WidgetTable, type Props as WidgetTableProps } from '../widget-table';
type Props<T> = WidgetTableProps<T> & {
getColumnPercentage: (item: T) => number;
};
export const OverviewWidgetTable = <T,>({
data,
keyExtractor,
columns,
getColumnPercentage,
className,
}: Props<T>) => {
return (
<div className={cn(className)}>
<WidgetTable
data={data ?? []}
keyExtractor={keyExtractor}
className={'text-sm min-h-[358px] @container'}
columnClassName="px-2 group/row items-center"
eachRow={(item) => {
return (
<div className="absolute inset-0 !p-0">
<div
className="h-full bg-def-200 group-hover/row:bg-blue-200 dark:group-hover/row:bg-blue-900 transition-colors relative"
style={{
width: `${getColumnPercentage(item) * 100}%`,
}}
/>
</div>
);
}}
columns={columns.map((column, index) => {
return {
...column,
className: cn(
index === 0
? 'w-full flex-1 font-medium min-w-0'
: 'text-right justify-end row w-20 font-mono',
index !== 0 &&
index !== columns.length - 1 &&
'hidden @[310px]:row',
column.className,
),
};
})}
/>
</div>
);
};
export function OverviewWidgetTableLoading({
className,
}: {
className?: string;
}) {
return (
<OverviewWidgetTable
className={className}
data={[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]}
keyExtractor={(item) => item.toString()}
getColumnPercentage={() => 0}
columns={[
{
name: 'Path',
render: () => <Skeleton className="h-4 w-1/3" />,
},
{
name: 'BR',
render: () => <Skeleton className="h-4 w-[30px]" />,
},
// {
// name: 'Duration',
// render: () => <Skeleton className="h-4 w-[30px]" />,
// },
{
name: 'Sessions',
render: () => <Skeleton className="h-4 w-[30px]" />,
},
]}
/>
);
}
function getPath(path: string, showDomain = false) {
try {
const url = new URL(path);
if (showDomain) {
return url.hostname + url.pathname;
}
return url.pathname;
} catch {
return path;
}
}
export function OverviewWidgetTablePages({
data,
lastColumnName,
className,
showDomain = false,
}: {
className?: string;
lastColumnName: string;
data: {
origin: string;
path: string;
avg_duration: number;
bounce_rate: number;
sessions: number;
}[];
showDomain?: boolean;
}) {
const [filters, setFilter] = useEventQueryFilters();
const number = useNumber();
const maxSessions = Math.max(...data.map((item) => item.sessions));
return (
<OverviewWidgetTable
className={className}
data={data ?? []}
keyExtractor={(item) => item.path + item.origin}
getColumnPercentage={(item) => item.sessions / maxSessions}
columns={[
{
name: 'Path',
render(item) {
return (
<Tooltiper asChild content={item.origin + item.path} side="left">
<div className="row items-center gap-2 min-w-0 relative">
<SerieIcon name={item.origin} />
<button
type="button"
className="truncate"
onClick={() => {
setFilter('path', item.path);
setFilter('origin', item.origin);
}}
>
{showDomain ? (
<>
<span className="opacity-40">{item.origin}</span>
<span>{item.path}</span>
</>
) : (
item.path
)}
</button>
<a
href={item.origin + item.path}
target="_blank"
rel="noreferrer"
>
<ExternalLinkIcon className="size-3 group-hover/row:opacity-100 opacity-0 transition-opacity" />
</a>
</div>
</Tooltiper>
);
},
},
{
name: 'BR',
className: 'w-16',
render(item) {
return number.shortWithUnit(item.bounce_rate, '%');
},
},
{
name: 'Duration',
render(item) {
return number.shortWithUnit(item.avg_duration, 'min');
},
},
{
name: lastColumnName,
// className: 'w-28',
render(item) {
return (
<div className="row gap-2 justify-end">
<span className="font-semibold">
{number.short(item.sessions)}
</span>
</div>
);
},
},
]}
/>
);
}
export function OverviewWidgetTableBots({
data,
className,
}: {
className?: string;
data: {
total_sessions: number;
origin: string;
path: string;
sessions: number;
avg_duration: number;
bounce_rate: number;
}[];
}) {
const [filters, setFilter] = useEventQueryFilters();
const number = useNumber();
const maxSessions = Math.max(...data.map((item) => item.sessions));
return (
<OverviewWidgetTable
className={className}
data={data ?? []}
keyExtractor={(item) => item.path + item.origin}
getColumnPercentage={(item) => item.sessions / maxSessions}
columns={[
{
name: 'Path',
render(item) {
return (
<Tooltiper asChild content={item.origin + item.path} side="left">
<div className="row items-center gap-2 min-w-0 relative">
<SerieIcon name={item.origin} />
<button
type="button"
className="truncate"
onClick={() => {
setFilter('path', item.path);
}}
>
{getPath(item.path)}
</button>
<a
href={item.origin + item.path}
target="_blank"
rel="noreferrer"
>
<ExternalLinkIcon className="size-3 group-hover/row:opacity-100 opacity-0 transition-opacity" />
</a>
</div>
</Tooltiper>
);
},
},
{
name: 'Bot',
// className: 'w-28',
render(item) {
return (
<div className="row gap-2 justify-end">
<span className="font-semibold">Google bot</span>
</div>
);
},
},
{
name: 'Date',
// className: 'w-28',
render(item) {
return (
<div className="row gap-2 justify-end">
<span className="font-semibold">Google bot</span>
</div>
);
},
},
]}
/>
);
}
export function OverviewWidgetTableGeneric({
data,
column,
className,
}: {
className?: string;
data: RouterOutputs['overview']['topGeneric'];
column: {
name: string;
render: (
item: RouterOutputs['overview']['topGeneric'][number],
) => React.ReactNode;
};
}) {
const number = useNumber();
const maxSessions = Math.max(...data.map((item) => item.sessions));
return (
<OverviewWidgetTable
className={className}
data={data ?? []}
keyExtractor={(item) => item.name}
getColumnPercentage={(item) => item.sessions / maxSessions}
columns={[
column,
{
name: 'BR',
className: 'w-16',
render(item) {
return number.shortWithUnit(item.bounce_rate, '%');
},
},
// {
// name: 'Duration',
// render(item) {
// return number.shortWithUnit(item.avg_session_duration, 'min');
// },
// },
{
name: 'Sessions',
render(item) {
return (
<div className="row gap-2 justify-end">
<span className="font-semibold">
{number.short(item.sessions)}
</span>
</div>
);
},
},
]}
/>
);
}

View File

@@ -78,6 +78,7 @@ export function useOverviewOptions() {
setStartDate(null);
setEndDate(null);
setStorageItem('range', value);
setInterval(null);
}
setRange(value);
},

View File

@@ -30,3 +30,27 @@ export function useOverviewWidget<T extends string>(
})),
] as const;
}
export function useOverviewWidgetV2<T extends string>(
key: string,
widgets: Record<T, { title: string; btn: string; meta?: any }>,
) {
const keys = Object.keys(widgets) as T[];
const [widget, setWidget] = useQueryState<T>(
key,
parseAsStringEnum(keys)
.withDefault(keys[0]!)
.withOptions({ history: 'push' }),
);
return [
{
...widgets[widget],
key: widget,
},
setWidget,
mapKeys(widgets).map((key) => ({
...widgets[key],
key,
})),
] as const;
}

View File

@@ -52,7 +52,7 @@ function ProjectCard({ id, domain, name, organizationId }: IServiceProject) {
async function ProjectChart({ id }: { id: string }) {
const chart = await chQuery<{ value: number; date: string }>(
`SELECT countDistinct(profile_id) as value, toStartOfDay(created_at) as date FROM ${TABLE_NAMES.events} WHERE project_id = ${escape(id)} AND name = 'session_start' AND created_at >= now() - interval '1 month' GROUP BY date ORDER BY date ASC`,
`SELECT countDistinct(profile_id) as value, toStartOfDay(created_at) as date FROM ${TABLE_NAMES.sessions} WHERE sign = 1 AND project_id = ${escape(id)} AND created_at >= now() - interval '1 month' GROUP BY date ORDER BY date ASC`,
);
return (
@@ -73,27 +73,27 @@ function Metric({ value, label }: { value: React.ReactNode; label: string }) {
async function ProjectMetrics({ id }: { id: string }) {
const [metrics] = await chQuery<{
total: number;
months_3: number;
month: number;
day: number;
}>(
`
SELECT
(
SELECT count(DISTINCT profile_id) as count FROM ${TABLE_NAMES.events} WHERE project_id = ${escape(id)}
) as total,
SELECT uniq(DISTINCT profile_id) as count FROM ${TABLE_NAMES.sessions} WHERE project_id = ${escape(id)} AND created_at >= now() - interval '6 months'
) as months_3,
(
SELECT count(DISTINCT profile_id) as count FROM ${TABLE_NAMES.events} WHERE project_id = ${escape(id)} AND created_at >= now() - interval '1 month'
SELECT uniq(DISTINCT profile_id) as count FROM ${TABLE_NAMES.sessions} WHERE project_id = ${escape(id)} AND created_at >= now() - interval '1 month'
) as month,
(
SELECT count(DISTINCT profile_id) as count FROM ${TABLE_NAMES.events} WHERE project_id = ${escape(id)} AND created_at >= now() - interval '1 day'
SELECT uniq(DISTINCT profile_id) as count FROM ${TABLE_NAMES.sessions} WHERE project_id = ${escape(id)} AND created_at >= now() - interval '1 day'
) as day
`,
);
return (
<FadeIn className="flex gap-4">
<Metric label="Total" value={shortNumber('en')(metrics?.total)} />
<Metric label="3 months" value={shortNumber('en')(metrics?.months_3)} />
<Metric label="Month" value={shortNumber('en')(metrics?.month)} />
<Metric label="24h" value={shortNumber('en')(metrics?.day)} />
</FadeIn>

View File

@@ -91,3 +91,65 @@ export function PreviousDiffIndicator({
</>
);
}
interface PreviousDiffIndicatorPureProps {
diff?: number | null | undefined;
state?: string | null | undefined;
inverted?: boolean;
size?: 'sm' | 'lg' | 'md';
className?: string;
showPrevious?: boolean;
}
export function PreviousDiffIndicatorPure({
diff,
state,
inverted,
size = 'sm',
className,
showPrevious = true,
}: PreviousDiffIndicatorPureProps) {
const variant = getDiffIndicator(
inverted,
state,
'bg-emerald-300',
'bg-rose-300',
undefined,
);
if (diff === null || diff === undefined || !showPrevious) {
return null;
}
const renderIcon = () => {
if (state === 'positive') {
return <ArrowUpIcon strokeWidth={3} size={10} color="#000" />;
}
if (state === 'negative') {
return <ArrowDownIcon strokeWidth={3} size={10} color="#000" />;
}
return null;
};
return (
<div
className={cn(
'flex items-center gap-1 font-mono font-medium',
size === 'lg' && 'gap-2',
className,
)}
>
<div
className={cn(
'flex size-2.5 items-center justify-center rounded-full',
variant,
size === 'lg' && 'size-8',
size === 'md' && 'size-6',
)}
>
{renderIcon()}
</div>
{diff.toFixed(1)}%
</div>
);
}

View File

@@ -21,7 +21,7 @@ const data = {
'samsung internet': 'https://upload.wikimedia.org/wikipedia/commons/thumb/e/e9/Samsung_Internet_logo.svg/1024px-Samsung_Internet_logo.svg.png',
'vstat.info': 'https://vstat.info',
'yahoo!': 'https://yahoo.com',
android: 'https://image.similarpng.com/very-thumbnail/2020/08/Android-icon-on-transparent--background-PNG.png',
android: 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/d7/Android_robot.svg/1745px-Android_robot.svg.png',
'android browser': 'https://image.similarpng.com/very-thumbnail/2020/08/Android-icon-on-transparent--background-PNG.png',
silk: 'https://m.media-amazon.com/images/I/51VCjQCvF0L.png',
kakaotalk: 'https://www.kakaocorp.com/',

View File

@@ -11,6 +11,7 @@ import { last } from 'ramda';
import { getPreviousMetric, round } from '@openpanel/common';
import { alphabetIds } from '@openpanel/constants';
import { useNumber } from '@/hooks/useNumerFormatter';
import { PreviousDiffIndicator } from '../common/previous-diff-indicator';
import { useReportChartContext } from '../context';
import { MetricCardNumber } from '../metric/metric-card';
@@ -36,6 +37,7 @@ export function Chart({
previous,
},
}: Props) {
const number = useNumber();
const { isEditMode } = useReportChartContext();
const mostDropoffs = findMostDropoffs(steps);
const lastStep = last(steps)!;
@@ -50,39 +52,39 @@ export function Chart({
)}
>
<div className="flex items-center gap-8 p-4 px-8">
<div className="flex flex-1 items-center gap-8 min-w-0">
<MetricCardNumber
label="Converted"
value={lastStep.count}
enhancer={
<PreviousDiffIndicator
size="md"
{...getPreviousMetric(lastStep.count, prevLastStep?.count)}
/>
}
/>
<MetricCardNumber
label="Percent"
value={`${totalSessions ? round((lastStep.count / totalSessions) * 100, 2) : 0}%`}
enhancer={
<PreviousDiffIndicator
size="md"
{...getPreviousMetric(lastStep.count, prevLastStep?.count)}
/>
}
/>
<MetricCardNumber
className="flex-1"
label="Most dropoffs"
value={mostDropoffs.event.displayName}
enhancer={
<PreviousDiffIndicator
size="md"
{...getPreviousMetric(lastStep.count, prevLastStep?.count)}
/>
}
/>
</div>
<MetricCardNumber
className="flex-1"
label="Converted"
value={lastStep.count}
enhancer={
<PreviousDiffIndicator
size="md"
{...getPreviousMetric(lastStep.count, prevLastStep?.count)}
/>
}
/>
<MetricCardNumber
className="flex-1"
label="Percent"
value={`${totalSessions ? round((lastStep.count / totalSessions) * 100, 2) : 0}%`}
enhancer={
<PreviousDiffIndicator
size="md"
{...getPreviousMetric(lastStep.count, prevLastStep?.count)}
/>
}
/>
<MetricCardNumber
className="flex-1"
label="Most dropoffs"
value={mostDropoffs.event.displayName}
enhancer={
<PreviousDiffIndicator
size="md"
{...getPreviousMetric(lastStep.count, prevLastStep?.count)}
/>
}
/>
</div>
</div>
<div className="col divide-y divide-def-200">
@@ -109,7 +111,9 @@ export function Chart({
<span>
Last period:{' '}
<span className="font-mono">
{previous?.steps?.[index]?.previousCount}
{number.format(
previous?.steps?.[index]?.previousCount,
)}
</span>
</span>
<PreviousDiffIndicator
@@ -127,7 +131,7 @@ export function Chart({
</span>
<div className="flex items-center gap-4">
<span className="text-lg font-mono">
{step.previousCount}
{number.format(step.previousCount)}
</span>
</div>
</div>
@@ -139,7 +143,9 @@ export function Chart({
<span>
Last period:{' '}
<span className="font-mono">
{previous?.steps?.[index]?.dropoffCount}
{number.format(
previous?.steps?.[index]?.dropoffCount,
)}
</span>
</span>
<PreviousDiffIndicator
@@ -164,7 +170,7 @@ export function Chart({
)}
>
{isMostDropoffs && <AlertCircleIcon size={14} />}
{step.dropoffCount}
{number.format(step.dropoffCount)}
</span>
</div>
</div>
@@ -176,7 +182,7 @@ export function Chart({
<span>
Last period:{' '}
<span className="font-mono">
{previous?.steps?.[index]?.count}
{number.format(previous?.steps?.[index]?.count)}
</span>
</span>
<PreviousDiffIndicator
@@ -193,7 +199,9 @@ export function Chart({
Current:
</span>
<div className="flex items-center gap-4">
<span className="text-lg font-mono">{step.count}</span>
<span className="text-lg font-mono">
{number.format(step.count)}
</span>
</div>
</div>
</TooltipComplete>
@@ -204,7 +212,7 @@ export function Chart({
<span>
Last period:{' '}
<span className="font-mono">
{previous?.steps?.[index]?.count}
{number.format(previous?.steps?.[index]?.count)}
</span>
</span>
<PreviousDiffIndicator

View File

@@ -1,6 +1,6 @@
import { cn } from '@/utils/cn';
interface Props<T> {
export interface Props<T> {
columns: {
name: string;
render: (item: T) => React.ReactNode;
@@ -9,6 +9,8 @@ interface Props<T> {
keyExtractor: (item: T) => string;
data: T[];
className?: string;
eachRow?: (item: T) => React.ReactNode;
columnClassName?: string;
}
export const WidgetTableHead = ({
@@ -21,7 +23,7 @@ export const WidgetTableHead = ({
return (
<thead
className={cn(
'text-def-1000 sticky top-0 z-10 border-b border-border bg-def-100 [&_th:last-child]:text-right [&_th]:whitespace-nowrap [&_th]:p-4 [&_th]:py-2 [&_th]:text-left [&_th]:font-medium',
'text-def-1000 sticky top-0 z-10 border-b border-border bg-def-100 [&_th:last-child]:text-right [&_th]:whitespace-nowrap [&_th]:p-4 [&_th]:py-2 [&_th]:text-right [&_th:first-child]:text-left [&_th]:font-medium',
className,
)}
>
@@ -35,36 +37,69 @@ export function WidgetTable<T>({
columns,
data,
keyExtractor,
eachRow,
columnClassName,
}: Props<T>) {
return (
<div className="w-full overflow-x-auto">
<table className={cn('w-full', className)}>
<WidgetTableHead>
<tr>
{columns.map((column) => (
<th key={column.name} className={cn(column.className)}>
{column.name}
</th>
))}
</tr>
</WidgetTableHead>
<tbody>
{data.map((item) => (
<tr
key={keyExtractor(item)}
className={
'border-b border-border text-right last:border-0 [&_td:first-child]:text-left [&_td]:p-4'
}
<div className={cn('w-full', className)}>
<div
className={cn(
'border-b border-border text-right last:border-0 [&_div:first-child]:text-left grid',
'[&>div]:p-2',
columnClassName,
)}
style={{
gridTemplateColumns:
columns.length > 1
? `1fr ${columns
.slice(1)
.map((col) => 'auto')
.join(' ')}`
: '1fr',
}}
>
{columns.map((column) => (
<div
key={column.name}
className={cn(column.className, 'font-medium font-sans text-sm')}
>
{columns.map((column) => (
<td key={column.name} className={cn(column.className)}>
{column.render(item)}
</td>
))}
</tr>
{column.name}
</div>
))}
</tbody>
</table>
</div>
<div className="col">
{data.map((item) => (
<div
key={keyExtractor(item)}
className={cn(
'border-b border-border text-right last:border-0 [&_div:first-child]:text-left grid relative',
'[&>div]:p-2',
columnClassName,
)}
style={{
gridTemplateColumns:
columns.length > 1
? `1fr ${columns
.slice(1)
.map((col) => 'auto')
.join(' ')}`
: '1fr',
}}
>
{eachRow?.(item)}
{columns.map((column) => (
<div
key={column.name}
className={cn(column.className, 'relative h-8')}
>
{column.render(item)}
</div>
))}
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -80,7 +80,7 @@ export function useEventQueryFilters(options: NuqsOptions = {}) {
if (filter.name === name) {
return {
...filter,
operator,
operator: newValue.length === 0 ? 'isNull' : operator,
value: newValue,
};
}
@@ -93,7 +93,7 @@ export function useEventQueryFilters(options: NuqsOptions = {}) {
{
id: name,
name,
operator,
operator: newValue.length === 0 ? 'isNull' : operator,
value: newValue,
},
];
@@ -102,7 +102,14 @@ export function useEventQueryFilters(options: NuqsOptions = {}) {
[setFilters],
);
return [filters, setFilter, setFilters] as const;
const removeFilter = useCallback(
(name: string) => {
setFilters((prev) => prev.filter((filter) => filter.name !== name));
},
[setFilters],
);
return [filters, setFilter, setFilters, removeFilter] as const;
}
export const eventQueryNamesFilter = parseAsArrayOf(parseAsString).withDefault(

View File

@@ -66,6 +66,9 @@ export function useNumber() {
if (unit === 'min') {
return fancyMinutes(value);
}
if (unit === '%') {
return `${format(round(value * 100, 1))}${unit ? ` ${unit}` : ''}`;
}
return `${format(value)}${unit ? ` ${unit}` : ''}`;
},
};

View File

@@ -32,7 +32,7 @@ export function ModalHeader({
return (
<div
className={cn(
'relative -m-6 mb-6 flex justify-between rounded-t-lg bg-gradient-to-b from-def-300 to-background p-6 pb-0',
'relative -m-6 mb-4 flex justify-between rounded-t-lg bg-gradient-to-b from-def-300 to-background p-6 pb-0',
className,
)}
style={{}}

View File

@@ -13,13 +13,14 @@ import { ModalContent, ModalHeader } from './Modal/Container';
interface Props {
id: string;
createdAt?: Date;
projectId: string;
}
export default function EventDetails({ id }: Props) {
const { projectId } = useAppParams();
export default function EventDetails({ id, createdAt, projectId }: Props) {
const [, setEvents] = useEventQueryNamesFilter();
const [, setFilter] = useEventQueryFilters();
const query = api.event.byId.useQuery({ id, projectId });
const query = api.event.byId.useQuery({ id, projectId, createdAt });
if (query.isLoading || query.isFetching) {
return null;

View File

@@ -1,6 +1,6 @@
'use client';
import { Loader } from 'lucide-react';
import { Loader2Icon } from 'lucide-react';
import dynamic from 'next/dynamic';
import { createPushModal } from 'pushmodal';
@@ -9,11 +9,23 @@ import { ModalContent } from './Modal/Container';
const Loading = () => (
<ModalContent className="flex items-center justify-center p-16">
<Loader className="animate-spin" size={40} />
<Loader2Icon className="animate-spin" size={40} />
</ModalContent>
);
const modals = {
OverviewTopPagesModal: dynamic(
() => import('../components/overview/overview-top-pages-modal'),
{
loading: Loading,
},
),
OverviewTopGenericModal: dynamic(
() => import('../components/overview/overview-top-generic-modal'),
{
loading: Loading,
},
),
RequestPasswordReset: dynamic(() => import('./request-reset-password'), {
loading: Loading,
}),

View File

@@ -8,16 +8,16 @@ export function getProfileName(
return '';
}
if (!profile.isExternal) {
if (short) {
const name =
[profile.firstName, profile.lastName].filter(Boolean).join(' ') ||
profile.email;
if (!name) {
if (short && profile.id.length > 10) {
return `${profile.id.slice(0, 4)}...${profile.id.slice(-4)}`;
}
return profile.id;
}
return (
[profile.firstName, profile.lastName].filter(Boolean).join(' ') ||
profile.email ||
profile.id
);
return name;
}