feat: dashboard v2, esm, upgrades (#211)

* esm

* wip

* wip

* wip

* wip

* wip

* wip

* subscription notice

* wip

* wip

* wip

* fix envs

* fix: update docker build

* fix

* esm/types

* delete dashboard :D

* add patches to dockerfiles

* update packages + catalogs + ts

* wip

* remove native libs

* ts

* improvements

* fix redirects and fetching session

* try fix favicon

* fixes

* fix

* order and resize reportds within a dashboard

* improvements

* wip

* added userjot to dashboard

* fix

* add op

* wip

* different cache key

* improve date picker

* fix table

* event details loading

* redo onboarding completely

* fix login

* fix

* fix

* extend session, billing and improve bars

* fix

* reduce price on 10M
This commit is contained in:
Carl-Gerhard Lindesvärd
2025-10-16 12:27:44 +02:00
committed by GitHub
parent 436e81ecc9
commit 81a7e5d62e
741 changed files with 32695 additions and 16996 deletions

View File

@@ -0,0 +1,49 @@
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/use-app-params';
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/utils/cn';
import { useQuery } from '@tanstack/react-query';
import { GlobeIcon } from 'lucide-react';
export function OriginFilter() {
const { projectId } = useAppParams();
const [filters, setFilter] = useEventQueryFilters();
const originFilter = filters.find((item) => item.name === 'origin');
const trpc = useTRPC();
const { data } = useQuery(
trpc.event.origin.queryOptions(
{
projectId: projectId,
},
{
staleTime: 1000 * 60 * 60,
},
),
);
if (!data || data.length === 0) {
return null;
}
return (
<div className="flex flex-wrap gap-2">
{data?.map((item) => {
return (
<Button
key={item.origin}
variant="outline"
icon={GlobeIcon}
className={cn(
originFilter?.value.includes(item.origin) && 'border-foreground',
)}
onClick={() => setFilter('origin', [item.origin], 'is')}
>
{item.origin}
</Button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,61 @@
import { Button } from '@/components/ui/button';
import {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/use-event-query-filters';
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';
interface OverviewFiltersButtonsProps {
className?: string;
nuqsOptions?: NuqsOptions;
}
export function OverviewFiltersButtons({
className,
nuqsOptions,
}: OverviewFiltersButtonsProps) {
const [events, setEvents] = useEventQueryNamesFilter(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)}>
{events.map((event) => (
<Button
key={event}
size="sm"
variant="outline"
icon={X}
onClick={() => setEvents((p) => p.filter((e) => e !== event))}
>
<strong className="font-semibold">{event}</strong>
</Button>
))}
{filters.map((filter) => {
return (
<Button
key={filter.name}
size="sm"
variant="outline"
icon={X}
onClick={() => removeFilter(filter.name)}
>
<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>
);
})}
</div>
);
}

View File

@@ -0,0 +1,199 @@
import { PureFilterItem } from '@/components/report/sidebar/filters/FilterItem';
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import { SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { useEventNames } from '@/hooks/use-event-names';
import { useEventProperties } from '@/hooks/use-event-properties';
import {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/use-event-query-filters';
import { useProfileProperties } from '@/hooks/use-profile-properties';
import { useProfileValues } from '@/hooks/use-profile-values';
import { usePropertyValues } from '@/hooks/use-property-values';
import { XIcon } from 'lucide-react';
import type { Options as NuqsOptions } from 'nuqs';
import type {
IChartEventFilter,
IChartEventFilterOperator,
IChartEventFilterValue,
} from '@openpanel/validation';
import { ComboboxEvents } from '@/components/ui/combobox-events';
import { OriginFilter } from './origin-filter';
export interface OverviewFiltersDrawerContentProps {
projectId: string;
nuqsOptions?: NuqsOptions;
enableEventsFilter?: boolean;
mode: 'profiles' | 'events';
}
const excludePropertyFilter = (name: string) => {
return ['*', 'duration', 'created_at', 'has_profile'].includes(name);
};
export function OverviewFiltersDrawerContent({
projectId,
nuqsOptions,
enableEventsFilter,
mode,
}: OverviewFiltersDrawerContentProps) {
const [filters, setFilter] = useEventQueryFilters(nuqsOptions);
const [event, setEvent] = useEventQueryNamesFilter(nuqsOptions);
const eventNames = useEventNames({ projectId });
const eventProperties = useEventProperties({ projectId, event: event[0] });
const profileProperties = useProfileProperties(projectId);
const properties = mode === 'events' ? eventProperties : profileProperties;
return (
<div>
<SheetHeader className="mb-8">
<SheetTitle>Overview filters</SheetTitle>
</SheetHeader>
<div className="mt-8 flex flex-col rounded-md border bg-def-100">
<div className="flex flex-col gap-4 p-4">
<OriginFilter />
{enableEventsFilter && (
<ComboboxEvents
className="w-full"
value={event}
onChange={setEvent}
multiple
items={eventNames.filter(
(item) => !excludePropertyFilter(item.name),
)}
placeholder="Select event"
maxDisplayItems={2}
/>
)}
<Combobox
className="w-full"
onChange={(value) => {
setFilter(value, [], 'is');
}}
value=""
placeholder="Filter by property"
label="What do you want to filter by?"
items={properties
.filter((item) => item !== 'name')
.map((item) => ({
label: item,
value: item,
}))}
searchable
size="lg"
/>
</div>
{filters
.filter((filter) => filter.value[0] !== null)
.map((filter) => {
return mode === 'events' ? (
<PureFilterItem
className="border-t p-4 first:border-0"
eventName="screen_view"
key={filter.name}
filter={filter}
onRemove={() => {
setFilter(filter.name, [], filter.operator);
}}
onChangeValue={(value) => {
setFilter(filter.name, value, filter.operator);
}}
onChangeOperator={(operator) => {
setFilter(filter.name, filter.value, operator);
}}
/>
) : /* TODO: Implement profile filters */
null;
})}
</div>
</div>
);
}
export function FilterOptionEvent({
setFilter,
projectId,
...filter
}: IChartEventFilter & {
projectId: string;
setFilter: (
name: string,
value: IChartEventFilterValue,
operator: IChartEventFilterOperator,
) => void;
}) {
const values = usePropertyValues({
projectId,
event: filter.name === 'path' ? 'screen_view' : 'session_start',
property: filter.name,
});
return (
<div className="flex items-center gap-2">
<div>{filter.name}</div>
<Combobox
className="flex-1"
onChange={(value) => setFilter(filter.name, value, filter.operator)}
placeholder={'Select a value'}
items={values.map((value) => ({
value,
label: value,
}))}
value={String(filter.value[0] ?? '')}
/>
<Button
size="icon"
variant="ghost"
onClick={() =>
setFilter(filter.name, filter.value[0] ?? '', filter.operator)
}
>
<XIcon />
</Button>
</div>
);
}
export function FilterOptionProfile({
setFilter,
projectId,
...filter
}: IChartEventFilter & {
projectId: string;
setFilter: (
name: string,
value: IChartEventFilterValue,
operator: IChartEventFilterOperator,
) => void;
}) {
const values = useProfileValues(projectId, filter.name);
return (
<div className="flex items-center gap-2">
<div>{filter.name}</div>
<Combobox
className="flex-1"
onChange={(value) => setFilter(filter.name, value, filter.operator)}
placeholder={'Select a value'}
items={values.map((value) => ({
value,
label: value,
}))}
value={String(filter.value[0] ?? '')}
/>
<Button
size="icon"
variant="ghost"
onClick={() =>
setFilter(filter.name, filter.value[0] ?? '', filter.operator)
}
>
<XIcon />
</Button>
</div>
);
}

View File

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

View File

@@ -0,0 +1,81 @@
import { TooltipComplete } from '@/components/tooltip-complete';
import { useDebounceState } from '@/hooks/use-debounce-state';
import useWS from '@/hooks/use-ws';
import { useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/utils/cn';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef } from 'react';
import { toast } from 'sonner';
import { AnimatedNumber } from '../animated-number';
export interface LiveCounterProps {
projectId: string;
}
const FIFTEEN_SECONDS = 1000 * 30;
export function LiveCounter({ projectId }: LiveCounterProps) {
const trpc = useTRPC();
const client = useQueryClient();
const counter = useDebounceState(0, 1000);
const lastRefresh = useRef(Date.now());
const query = useQuery(
trpc.overview.liveVisitors.queryOptions({
projectId,
}),
);
useEffect(() => {
if (query.data) {
counter.set(query.data);
}
}, [query.data]);
useWS<number>(
`/live/visitors/${projectId}`,
(value) => {
if (!Number.isNaN(value)) {
counter.set(value);
if (Date.now() - lastRefresh.current > FIFTEEN_SECONDS) {
lastRefresh.current = Date.now();
if (!document.hidden) {
toast('Refreshed data');
client.refetchQueries({
type: 'active',
});
}
}
}
},
{
debounce: {
delay: 1000,
maxWait: 5000,
},
},
);
return (
<TooltipComplete
content={`${counter.debounced} unique visitors last 5 minutes`}
>
<div className="flex h-8 items-center gap-2 rounded border border-border px-3 font-medium leading-none">
<div className="relative">
<div
className={cn(
'h-3 w-3 animate-ping rounded-full bg-emerald-500 opacity-100 transition-all',
counter.debounced === 0 && 'bg-destructive opacity-0',
)}
/>
<div
className={cn(
'absolute left-0 top-0 h-3 w-3 rounded-full bg-emerald-500 transition-all',
counter.debounced === 0 && 'bg-destructive',
)}
/>
</div>
<AnimatedNumber value={counter.debounced} />
</div>
</TooltipComplete>
);
}

View File

@@ -0,0 +1,28 @@
import { BarChartIcon, LineChartIcon } from 'lucide-react';
import type { Dispatch, SetStateAction } from 'react';
import type { IChartType } from '@openpanel/validation';
import { Button } from '../ui/button';
interface Props {
chartType: IChartType;
setChartType: Dispatch<SetStateAction<IChartType>>;
}
export function OverviewChartToggle({ chartType, setChartType }: Props) {
return (
<Button
size={'icon'}
variant={'ghost'}
onClick={() => {
setChartType((p) => (p === 'linear' ? 'bar' : 'linear'));
}}
>
{chartType === 'bar' ? (
<LineChartIcon size={16} />
) : (
<BarChartIcon size={16} />
)}
</Button>
);
}

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

@@ -0,0 +1,15 @@
import { ScanEyeIcon } from 'lucide-react';
import { Button, type ButtonProps } from '../ui/button';
type Props = Omit<ButtonProps, 'children'>;
const OverviewDetailsButton = (props: Props) => {
return (
<Button size="icon" variant="ghost" {...props}>
<ScanEyeIcon size={18} />
</Button>
);
};
export default OverviewDetailsButton;

View File

@@ -0,0 +1,49 @@
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import {
isHourIntervalEnabledByRange,
isMinuteIntervalEnabledByRange,
} from '@openpanel/constants';
import { ClockIcon } from 'lucide-react';
import { Combobox } from '../ui/combobox';
export function OverviewInterval() {
const { interval, setInterval, range } = useOverviewOptions();
return (
<Combobox
className="hidden md:flex"
icon={ClockIcon}
placeholder="Interval"
onChange={(value) => {
setInterval(value);
}}
value={interval}
items={[
{
value: 'minute',
label: 'Minute',
disabled: !isMinuteIntervalEnabledByRange(range),
},
{
value: 'hour',
label: 'Hour',
disabled: !isHourIntervalEnabledByRange(range),
},
{
value: 'day',
label: 'Day',
},
{
value: 'week',
label: 'Week',
},
{
value: 'month',
label: 'Month',
disabled:
range === 'today' || range === 'lastHour' || range === '30min',
},
]}
/>
);
}

View File

@@ -0,0 +1,251 @@
import { useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/utils/cn';
import { useQuery } from '@tanstack/react-query';
import type { IChartProps } from '@openpanel/validation';
import { useNumber } from '@/hooks/use-numer-formatter';
import { getChartColor } from '@/utils/theme';
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import {
Bar,
BarChart,
CartesianGrid,
Customized,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { BarShapeBlue } from '../charts/common-bar';
interface OverviewLiveHistogramProps {
projectId: string;
}
export function OverviewLiveHistogram({
projectId,
}: OverviewLiveHistogramProps) {
const report: IChartProps = {
projectId,
events: [
{
segment: 'user',
filters: [
{
id: '1',
name: 'name',
operator: 'is',
value: ['screen_view', 'session_start'],
},
],
id: 'A',
name: '*',
displayName: 'Active users',
},
],
chartType: 'histogram',
interval: 'minute',
range: '30min',
name: '',
metric: 'sum',
breakdowns: [],
lineType: 'monotone',
previous: false,
};
const countReport: IChartProps = {
name: '',
projectId,
events: [
{
segment: 'user',
filters: [],
id: 'A',
name: 'session_start',
},
],
breakdowns: [],
chartType: 'metric',
lineType: 'monotone',
interval: 'minute',
range: '30min',
previous: false,
metric: 'sum',
};
const trpc = useTRPC();
const res = useQuery(trpc.chart.chart.queryOptions(report));
const countRes = useQuery(trpc.chart.chart.queryOptions(countReport));
const metrics = res.data?.series[0]?.metrics;
const minutes = (res.data?.series[0]?.data || []).slice(-30);
const liveCount = countRes.data?.series[0]?.metrics?.sum ?? 0;
// Transform data for Recharts
const chartData = minutes.map((minute) => ({
...minute,
timestamp: new Date(minute.date).getTime(),
time: new Date(minute.date).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
}),
}));
if (res.isInitialLoading || countRes.isInitialLoading) {
return (
<Wrapper count={0}>
<div className="h-full w-full animate-pulse bg-def-200 rounded" />
</Wrapper>
);
}
if (!res.isSuccess && !countRes.isSuccess) {
return null;
}
return (
<Wrapper count={liveCount}>
<div className="h-full w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={chartData}
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
>
<Tooltip
content={CustomTooltip}
cursor={{
fill: 'var(--def-200)',
}}
/>
<XAxis dataKey="time" axisLine={false} tickLine={false} hide />
<YAxis hide />
<Bar
dataKey="count"
fill="rgba(59, 121, 255, 0.2)"
isAnimationActive={false}
shape={BarShapeBlue}
activeBar={BarShapeBlue}
/>
</BarChart>
</ResponsiveContainer>
</div>
</Wrapper>
);
}
interface WrapperProps {
children: React.ReactNode;
count: number;
}
function Wrapper({ children, count }: WrapperProps) {
return (
<div className="flex h-full flex-col">
<div className="relative mb-1 text-sm font-medium text-muted-foreground">
{count} unique vistors last 30 minutes
</div>
<div className="relative flex h-full w-full flex-1 items-end justify-center gap-2">
{children}
</div>
</div>
);
}
// Custom tooltip component that uses portals to escape overflow hidden
const CustomTooltip = ({ active, payload, coordinate }: any) => {
const [tooltipContainer] = useState(() => document.createElement('div'));
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
const number = useNumber();
useEffect(() => {
document.body.appendChild(tooltipContainer);
const handleMouseMove = (e: MouseEvent) => {
setMousePosition({ x: e.clientX, y: e.clientY });
};
document.addEventListener('mousemove', handleMouseMove);
return () => {
if (document.body.contains(tooltipContainer)) {
document.body.removeChild(tooltipContainer);
}
document.removeEventListener('mousemove', handleMouseMove);
};
}, [tooltipContainer]);
if (!active || !payload || !payload.length) {
return null;
}
const data = payload[0].payload;
// Smart positioning to avoid going out of bounds
const tooltipWidth = 180; // min-w-[180px]
const tooltipHeight = 80; // approximate height
const offset = 10;
let left = mousePosition.x + offset;
let top = mousePosition.y - offset;
// Check if tooltip would go off the right edge
if (left + tooltipWidth > window.innerWidth) {
left = mousePosition.x - tooltipWidth - offset;
}
// Check if tooltip would go off the left edge
if (left < 0) {
left = offset;
}
// Check if tooltip would go off the top edge
if (top < 0) {
top = mousePosition.y + offset;
}
// Check if tooltip would go off the bottom edge
if (top + tooltipHeight > window.innerHeight) {
top = window.innerHeight - tooltipHeight - offset;
}
const tooltipContent = (
<div
className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-background/80 p-3 shadow-xl backdrop-blur-sm"
style={{
position: 'fixed',
top,
left,
pointerEvents: 'none',
zIndex: 1000,
}}
>
<div className="flex justify-between gap-8 text-muted-foreground">
<div>
{new Date(data.date).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</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">Active users</div>
<div className="flex justify-between gap-8 font-mono font-medium">
<div className="row gap-1">
{number.formatWithUnit(data.count)}
</div>
</div>
</div>
</div>
</React.Fragment>
</div>
);
return createPortal(tooltipContent, tooltipContainer);
};

View File

@@ -0,0 +1,200 @@
import { fancyMinutes, useNumber } from '@/hooks/use-numer-formatter';
import { cn } from '@/utils/cn';
import AutoSizer from 'react-virtualized-auto-sizer';
import { Area, AreaChart } from 'recharts';
import { formatDate, timeAgo } from '@/utils/date';
import { getPreviousMetric } from '@openpanel/common';
import {
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?: '' | 'date' | 'timeAgo' | 'min' | '%';
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 === 'date') {
return <>{formatDate(new Date(value))}</>;
}
if (unit === 'timeAgo') {
return <>{timeAgo(new Date(value))}</>;
}
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-sm font-medium text-muted-foreground leading-[1.1]">
{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 leading-[1.1] font-bold">
{value}
</div>
{enhancer}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,275 @@
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { cn } from '@/utils/cn';
import { useDashedStroke } from '@/hooks/use-dashed-stroke';
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useTRPC } from '@/integrations/trpc/react';
import type { RouterOutputs } from '@/trpc/client';
import { getChartColor } from '@/utils/theme';
import { getPreviousMetric } from '@openpanel/common';
import type { IInterval } from '@openpanel/validation';
import { useQuery } from '@tanstack/react-query';
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
import { last, omit } from 'ramda';
import React, { useState } from 'react';
import {
Bar,
CartesianGrid,
ComposedChart,
Line,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
import { createChartTooltip } from '../charts/chart-tooltip';
import { BarShapeBlue } from '../charts/common-bar';
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 OverviewMetrics({ projectId }: OverviewMetricsProps) {
const { range, interval, metric, setMetric, startDate, endDate } =
useOverviewOptions();
const [filters] = useEventQueryFilters();
const trpc = useTRPC();
const activeMetric = TITLES[metric]!;
const overviewQuery = useQuery(
trpc.overview.stats.queryOptions({
projectId,
range,
interval,
filters,
startDate,
endDate,
}),
);
const data =
overviewQuery.data?.series?.map((item) => ({
...item,
timestamp: new Date(item.date).getTime(),
})) || [];
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 mb-3 -mt-1 text-sm font-medium text-muted-foreground">
{activeMetric.title}
</div>
<div className="w-full h-[150px]">
{overviewQuery.isLoading && <Skeleton className="h-full w-full" />}
<Chart
activeMetric={activeMetric}
interval={interval}
data={data}
/>
</div>
</div>
</div>
</>
);
}
const { Tooltip, TooltipProvider } = createChartTooltip<
RouterOutputs['overview']['stats']['series'][number],
{
metric: (typeof TITLES)[number];
interval: IInterval;
}
>(({ context: { metric, interval }, data: dataArray }) => {
const data = dataArray[0];
const formatDate = useFormatDateInterval(interval);
const number = useNumber();
if (!data) {
return null;
}
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>
</>
);
});
function Chart({
activeMetric,
interval,
data,
}: {
activeMetric: (typeof TITLES)[number];
interval: IInterval;
data: RouterOutputs['overview']['stats']['series'];
}) {
const xAxisProps = useXAxisProps({ interval });
const yAxisProps = useYAxisProps();
const [activeBar, setActiveBar] = useState(-1);
return (
<TooltipProvider metric={activeMetric} interval={interval}>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={data}
margin={{ top: 0, right: 0, left: 0, bottom: 10 }}
onMouseMove={(e) => {
setActiveBar(e.activeTooltipIndex ?? -1);
}}
barCategoryGap={2}
>
<Tooltip
cursor={{
stroke: 'var(--def-200)',
fill: 'var(--def-200)',
}}
/>
<YAxis
{...yAxisProps}
domain={[0, activeMetric.key === 'bounce_rate' ? 100 : 'auto']}
width={25}
/>
<XAxis {...omit(['scale', 'type'], xAxisProps)} />
<CartesianGrid
strokeDasharray="3 3"
horizontal={true}
vertical={false}
className="stroke-border"
/>
<Line
key={`prev_${activeMetric.key}`}
type="step"
dataKey={`prev_${activeMetric.key}`}
stroke={'var(--border)'}
strokeWidth={2}
isAnimationActive={false}
dot={false}
activeDot={{
stroke: 'var(--foreground)',
fill: 'var(--def-100)',
strokeWidth: 1,
r: 2,
}}
/>
<Bar
key={activeMetric.key}
dataKey={activeMetric.key}
isAnimationActive={false}
shape={(props: any) => (
<BarShapeBlue isActive={activeBar === props.index} {...props} />
)}
/>
</ComposedChart>
</ResponsiveContainer>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,18 @@
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { TimeWindowPicker } from '@/components/time-window-picker';
export function OverviewRange() {
const { range, setRange, setStartDate, setEndDate, endDate, startDate } =
useOverviewOptions();
return (
<TimeWindowPicker
onChange={setRange}
value={range}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
endDate={endDate}
startDate={startDate}
/>
);
}

View File

@@ -0,0 +1,86 @@
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { useMutation, useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { EyeIcon, Globe2Icon, LockIcon } from 'lucide-react';
interface OverviewShareProps {
projectId: string;
}
export function OverviewShare({ projectId }: OverviewShareProps) {
const trpc = useTRPC();
const query = useQuery(
trpc.share.overview.queryOptions(
{
projectId,
},
{
retry(failureCount, error) {
return error.message !== 'Share not found' && failureCount < 3;
},
},
),
);
const data = query.data;
const mutation = useMutation(
trpc.share.createOverview.mutationOptions({
onSuccess() {
query.refetch();
},
}),
);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button icon={data?.public ? Globe2Icon : LockIcon} responsive>
{data?.public ? 'Public' : 'Private'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
{(!data || data.public === false) && (
<DropdownMenuItem onClick={() => pushModal('ShareOverviewModal')}>
<Globe2Icon size={16} className="mr-2" />
Make public
</DropdownMenuItem>
)}
{data?.public && (
<DropdownMenuItem asChild>
<Link
to={'/share/overview/$shareId'}
params={{ shareId: data.id }}
>
<EyeIcon size={16} className="mr-2" />
View
</Link>
</DropdownMenuItem>
)}
{data?.public && (
<DropdownMenuItem
onClick={() => {
mutation.mutate({
...data,
public: false,
password: null,
});
}}
>
<LockIcon size={16} className="mr-2" />
Make private
</DropdownMenuItem>
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,385 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
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/use-numer-formatter';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { useQuery } from '@tanstack/react-query';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { Widget, WidgetBody } from '../widget';
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';
interface OverviewTopDevicesProps {
projectId: string;
}
export default function OverviewTopDevices({
projectId,
}: OverviewTopDevicesProps) {
const { interval, range, previous, startDate, endDate } =
useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters();
const [chartType] = useState<IChartType>('bar');
const isPageFilter = filters.find((filter) => filter.name === 'path');
const [widget, setWidget, widgets] = useOverviewWidget('tech', {
device: {
title: 'Top devices',
btn: 'Devices',
chart: {
options: {
columns: ['Device', isPageFilter ? 'Views' : 'Sessions'],
},
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'user',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'device',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top devices',
range: range,
previous: previous,
metric: 'sum',
},
},
},
browser: {
title: 'Top browser',
btn: 'Browser',
chart: {
options: {
columns: ['Browser', isPageFilter ? 'Views' : 'Sessions'],
},
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'user',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'browser',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top browser',
range: range,
previous: previous,
metric: 'sum',
},
},
},
browser_version: {
title: 'Top Browser Version',
btn: 'Browser Version',
chart: {
options: {
columns: ['Version', isPageFilter ? 'Views' : 'Sessions'],
renderSerieName(name) {
return name[1] || NOT_SET_VALUE;
},
},
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'user',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'browser',
},
{
id: 'B',
name: 'browser_version',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top Browser Version',
range: range,
previous: previous,
metric: 'sum',
},
},
},
os: {
title: 'Top OS',
btn: 'OS',
chart: {
options: {
columns: ['OS', isPageFilter ? 'Views' : 'Sessions'],
},
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'user',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'os',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top OS',
range: range,
previous: previous,
metric: 'sum',
},
},
},
os_version: {
title: 'Top OS version',
btn: 'OS Version',
chart: {
options: {
columns: ['Version', isPageFilter ? 'Views' : 'Sessions'],
renderSerieName(name) {
return name[1] || NOT_SET_VALUE;
},
},
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'user',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'os',
},
{
id: 'B',
name: 'os_version',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top OS version',
range: range,
previous: previous,
metric: 'sum',
},
},
},
brand: {
title: 'Top Brands',
btn: 'Brands',
chart: {
options: {
columns: ['Brand', isPageFilter ? 'Views' : 'Sessions'],
},
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'user',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'brand',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top Brands',
range: range,
previous: previous,
metric: 'sum',
},
},
},
model: {
title: 'Top Models',
btn: 'Models',
chart: {
options: {
columns: ['Model', isPageFilter ? 'Views' : 'Sessions'],
renderSerieName(name) {
return name[1] || NOT_SET_VALUE;
},
},
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'user',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'brand',
},
{
id: 'B',
name: 'model',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top Models',
range: range,
previous: previous,
metric: 'sum',
},
},
},
});
const trpc = useTRPC();
const query = useQuery(
trpc.overview.topGeneric.queryOptions({
projectId,
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
type="button"
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody className="p-0">
{query.isLoading ? (
<OverviewWidgetTableLoading />
) : (
<OverviewWidgetTableGeneric
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
onClick={() =>
pushModal('OverviewTopGenericModal', {
projectId,
column: widget.key,
})
}
/>
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
</WidgetFooter>
</Widget>
</>
);
}

View File

@@ -0,0 +1,184 @@
import { ReportChart } from '@/components/report-chart';
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { cn } from '@/utils/cn';
import { useState } from 'react';
import type { IChartType } from '@openpanel/validation';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import { Widget, WidgetBody } from '../widget';
import { OverviewChartToggle } from './overview-chart-toggle';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
export interface OverviewTopEventsProps {
projectId: string;
}
export default function OverviewTopEvents({
projectId,
}: OverviewTopEventsProps) {
const { interval, range, previous, startDate, endDate } =
useOverviewOptions();
const [filters] = useEventQueryFilters();
const trpc = useTRPC();
const { data: conversions } = useQuery(
trpc.event.conversionNames.queryOptions({ projectId }),
);
const [chartType, setChartType] = useState<IChartType>('bar');
const [widget, setWidget, widgets] = useOverviewWidget('ev', {
your: {
title: 'Top events',
btn: 'Your',
chart: {
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters: [
...filters,
{
id: 'ex_session',
name: 'name',
operator: 'isNot',
value: ['session_start', 'session_end', 'screen_view'],
},
],
id: 'A',
name: '*',
},
],
breakdowns: [
{
id: 'A',
name: 'name',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Your top events',
range: range,
previous: previous,
metric: 'sum',
},
},
},
all: {
title: 'Top events',
btn: 'All',
chart: {
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters: [...filters],
id: 'A',
name: '*',
},
],
breakdowns: [
{
id: 'A',
name: 'name',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'All top events',
range: range,
previous: previous,
metric: 'sum',
},
},
},
conversions: {
title: 'Conversions',
btn: 'Conversions',
hide: !conversions || conversions.length === 0,
chart: {
report: {
limit: 10,
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters: [
...filters,
{
id: 'conversion',
name: 'name',
operator: 'is',
value: conversions?.map((c) => c.name) ?? [],
},
],
id: 'A',
name: '*',
},
],
breakdowns: [
{
id: 'A',
name: 'name',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Conversions',
range: range,
previous: previous,
metric: 'sum',
},
},
},
});
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets
.filter((item) => item.hide !== true)
.map((w) => (
<button
type="button"
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody className="p-3">
<ReportChart
options={{ hideID: true, columns: ['Event', 'Count'] }}
report={{
...widget.chart.report,
previous: false,
}}
/>
</WidgetBody>
<WidgetFooter>
<OverviewChartToggle {...{ chartType, setChartType }} />
</WidgetFooter>
</Widget>
</>
);
}

View File

@@ -0,0 +1,106 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useTRPC } from '@/integrations/trpc/react';
import { ModalContent, ModalHeader } from '@/modals/Modal/Container';
import type { IGetTopGenericInput } from '@openpanel/db';
import { useInfiniteQuery } from '@tanstack/react-query';
import { ChevronRightIcon } from 'lucide-react';
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 } = useOverviewOptions();
const trpc = useTRPC();
const query = useInfiniteQuery(
trpc.overview.topGeneric.infiniteQueryOptions(
{
projectId,
filters,
startDate,
endDate,
range,
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">
<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

@@ -0,0 +1,176 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { cn } from '@/utils/cn';
import { useState } from 'react';
import type { IChartType } from '@openpanel/validation';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { NOT_SET_VALUE } from '@openpanel/constants';
import { useQuery } from '@tanstack/react-query';
import { ChevronRightIcon } from 'lucide-react';
import { ReportChart } from '../report-chart';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { Widget, WidgetBody } from '../widget';
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 { useOverviewWidgetV2 } from './useOverviewWidget';
interface OverviewTopGeoProps {
projectId: string;
}
export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
const { interval, range, previous, startDate, endDate } =
useOverviewOptions();
const [chartType, setChartType] = useState<IChartType>('bar');
const [filters, setFilter] = useEventQueryFilters();
const isPageFilter = filters.find((filter) => filter.name === 'path');
const [widget, setWidget, widgets] = useOverviewWidgetV2('geo', {
country: {
title: 'Top countries',
btn: 'Countries',
},
region: {
title: 'Top regions',
btn: 'Regions',
},
city: {
title: 'Top cities',
btn: 'Cities',
},
});
const number = useNumber();
const trpc = useTRPC();
const query = useQuery(
trpc.overview.topGeneric.queryOptions({
projectId,
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
type="button"
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody className="p-0">
{query.isLoading ? (
<OverviewWidgetTableLoading />
) : (
<OverviewWidgetTableGeneric
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
onClick={() =>
pushModal('OverviewTopGenericModal', {
projectId,
column: widget.key,
})
}
/>
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
</WidgetFooter>
</Widget>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">Map</div>
</WidgetHead>
<WidgetBody>
<ReportChart
options={{ hideID: true }}
report={{
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'country',
},
],
chartType: 'map',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
}}
/>
</WidgetBody>
</Widget>
</>
);
}

View File

@@ -0,0 +1,61 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useTRPC } from '@/integrations/trpc/react';
import { ModalContent, ModalHeader } from '@/modals/Modal/Container';
import { useInfiniteQuery } from '@tanstack/react-query';
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;
}
export default function OverviewTopPagesModal({
projectId,
}: OverviewTopPagesProps) {
const [filters, setFilter] = useEventQueryFilters();
const { startDate, endDate, range } = useOverviewOptions();
const trpc = useTRPC();
const query = useInfiniteQuery(
trpc.overview.topPages.infiniteQueryOptions(
{
projectId,
filters,
startDate,
endDate,
mode: 'page',
range,
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">
<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

@@ -0,0 +1,127 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { cn } from '@/utils/cn';
import { Globe2Icon } from 'lucide-react';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { useQuery } from '@tanstack/react-query';
import { Button } from '../ui/button';
import { Widget, WidgetBody } from '../widget';
import OverviewDetailsButton from './overview-details-button';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
import {
OverviewWidgetTableLoading,
OverviewWidgetTablePages,
} from './overview-widget-table';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidgetV2 } from './useOverviewWidget';
interface OverviewTopPagesProps {
projectId: string;
}
export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
const { interval, range, startDate, endDate } = useOverviewOptions();
const [filters] = useEventQueryFilters();
const [domain, setDomain] = useQueryState('d', parseAsBoolean);
const [widget, setWidget, widgets] = useOverviewWidgetV2('pages', {
page: {
title: 'Top pages',
btn: 'Top pages',
meta: {
columns: {
sessions: 'Sessions',
},
},
},
entry: {
title: 'Entry Pages',
btn: 'Entries',
meta: {
columns: {
sessions: 'Entries',
},
},
},
exit: {
title: 'Exit Pages',
btn: 'Exits',
meta: {
columns: {
sessions: 'Exits',
},
},
},
// bot: {
// title: 'Bots',
// btn: 'Bots',
// },
});
const trpc = useTRPC();
const query = useQuery(
trpc.overview.topPages.queryOptions({
projectId,
filters,
startDate,
endDate,
mode: widget.key,
range,
}),
);
const data = query.data;
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
type="button"
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody className="p-0">
{query.isLoading ? (
<OverviewWidgetTableLoading />
) : (
<>
{/*<OverviewWidgetTableBots data={data ?? []} />*/}
<OverviewWidgetTablePages
data={data ?? []}
lastColumnName={widget.meta.columns.sessions}
showDomain={!!domain}
/>
</>
)}
</WidgetBody>
<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

@@ -0,0 +1,145 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { cn } from '@/utils/cn';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { NOT_SET_VALUE } from '@openpanel/constants';
import { useQuery } from '@tanstack/react-query';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { Widget, WidgetBody } from '../widget';
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 { useOverviewWidgetV2 } from './useOverviewWidget';
interface OverviewTopSourcesProps {
projectId: string;
}
export default function OverviewTopSources({
projectId,
}: OverviewTopSourcesProps) {
const { range, startDate, endDate } = useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters();
const [widget, setWidget, widgets] = useOverviewWidgetV2('sources', {
referrer_name: {
title: 'Top sources',
btn: 'All',
},
referrer: {
title: 'Top urls',
btn: 'URLs',
},
referrer_type: {
title: 'Top types',
btn: 'Types',
},
utm_source: {
title: 'UTM Source',
btn: 'Source',
},
utm_medium: {
title: 'UTM Medium',
btn: 'Medium',
},
utm_campaign: {
title: 'UTM Campaign',
btn: 'Campaign',
},
utm_term: {
title: 'UTM Term',
btn: 'Term',
},
utm_content: {
title: 'UTM Content',
btn: 'Content',
},
});
const trpc = useTRPC();
const query = useQuery(
trpc.overview.topGeneric.queryOptions({
projectId,
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
type="button"
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody className="p-0">
{query.isLoading ? (
<OverviewWidgetTableLoading />
) : (
<OverviewWidgetTableGeneric
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
onClick={() =>
pushModal('OverviewTopGenericModal', {
projectId,
column: widget.key,
})
}
/>
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
</WidgetFooter>
</Widget>
</>
);
}

View File

@@ -0,0 +1,346 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useNumber } from '@/hooks/use-numer-formatter';
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 [&_.head]:pt-3'}
columnClassName="[&_.cell:first-child]:pl-4 [&_.cell:last-child]:pr-4"
eachRow={(item) => {
return (
<div className="absolute top-0 left-0 !p-0 w-full h-full">
<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
? 'text-left w-full font-medium min-w-0'
: 'text-right font-mono',
index !== 0 &&
index !== columns.length - 1 &&
'hidden @[310px]:table-cell',
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" />,
width: 'w-full',
},
{
name: 'BR',
render: () => <Skeleton className="h-4 w-[30px]" />,
width: '60px',
},
// {
// name: 'Duration',
// render: () => <Skeleton className="h-4 w-[30px]" />,
// },
{
name: 'Sessions',
render: () => <Skeleton className="h-4 w-[30px]" />,
width: '84px',
},
]}
/>
);
}
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',
width: 'w-full',
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);
}}
>
{item.path ? (
<>
{showDomain ? (
<>
<span className="opacity-40">{item.origin}</span>
<span>{item.path}</span>
</>
) : (
item.path
)}
</>
) : (
<span className="opacity-40">Not set</span>
)}
</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',
width: '60px',
render(item) {
return number.shortWithUnit(item.bounce_rate, '%');
},
},
{
name: 'Duration',
width: '75px',
render(item) {
return number.shortWithUnit(item.avg_duration, 'min');
},
},
{
name: lastColumnName,
width: '84px',
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',
width: 'w-full',
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',
width: '60px',
render(item) {
return (
<div className="row gap-2 justify-end">
<span className="font-semibold">Google bot</span>
</div>
);
},
},
{
name: 'Date',
width: '60px',
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,
width: 'w-full',
},
{
name: 'BR',
width: '60px',
render(item) {
return number.shortWithUnit(item.bounce_rate, '%');
},
},
// {
// name: 'Duration',
// render(item) {
// return number.shortWithUnit(item.avg_session_duration, 'min');
// },
// },
{
name: 'Sessions',
width: '84px',
render(item) {
return (
<div className="row gap-2 justify-end">
<span className="font-semibold">
{number.short(item.sessions)}
</span>
</div>
);
},
},
]}
/>
);
}

View File

@@ -0,0 +1,188 @@
import { useThrottle } from '@/hooks/use-throttle';
import { cn } from '@/utils/cn';
import { ChevronsUpDownIcon, Icon, type LucideIcon } from 'lucide-react';
import { last } from 'ramda';
import { Children, useEffect, useRef, useState } from 'react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import type { WidgetHeadProps, WidgetTitleProps } from '../widget';
import { WidgetHead as WidgetHeadBase } from '../widget';
export function WidgetHead({ className, ...props }: WidgetHeadProps) {
return (
<WidgetHeadBase
className={cn(
'relative flex flex-col rounded-t-xl p-0 [&_.title]:flex [&_.title]:items-center [&_.title]:p-4 [&_.title]:font-semibold',
className,
)}
{...props}
/>
);
}
export function WidgetTitle({
children,
className,
icon: Icon,
...props
}: WidgetTitleProps & {
icon?: LucideIcon;
}) {
return (
<div
className={cn('title text-left row justify-start', className)}
{...props}
>
{Icon && (
<div className="rounded-lg bg-def-200 p-1 mr-2">
<Icon size={16} />
</div>
)}
{children}
</div>
);
}
export function WidgetAbsoluteButtons({
className,
children,
...props
}: WidgetHeadProps) {
return (
<div
className={cn(
'row gap-1 absolute right-4 top-1/2 -translate-y-1/2',
className,
)}
{...props}
>
{children}
</div>
);
}
export function WidgetButtons({
className,
children,
...props
}: WidgetHeadProps) {
const container = useRef<HTMLDivElement>(null);
const sizes = useRef<number[]>([]);
const [slice, setSlice] = useState(3); // Show 3 buttons by default
const gap = 16;
const handleResize = useThrottle(() => {
if (container.current) {
if (sizes.current.length === 0) {
// Get buttons
const buttons: HTMLButtonElement[] = Array.from(
container.current.querySelectorAll('button'),
);
// Get sizes and cache them
sizes.current = buttons.map(
(button) => Math.ceil(button.offsetWidth) + gap,
);
}
const containerWidth = container.current.offsetWidth;
const buttonsWidth = sizes.current.reduce((acc, size) => acc + size, 0);
const moreWidth = (last(sizes.current) ?? 0) + gap;
if (buttonsWidth > containerWidth) {
const res = sizes.current.reduce(
(acc, size, index) => {
if (acc.size + size + moreWidth > containerWidth) {
return { index: acc.index, size: acc.size + size };
}
return { index, size: acc.size + size };
},
{ index: 0, size: 0 },
);
setSlice(res.index);
} else {
setSlice(sizes.current.length - 1);
}
}
}, 30);
useEffect(() => {
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [handleResize, children]);
const hidden = '!opacity-0 absolute pointer-events-none';
return (
<div
ref={container}
className={cn(
'-mb-px -mt-2 flex flex-wrap justify-start self-stretch px-4 transition-opacity [&_button.active]:border-b-2 [&_button.active]:border-black [&_button.active]:opacity-100 dark:[&_button.active]:border-white [&_button]:whitespace-nowrap [&_button]:py-1 [&_button]:text-sm [&_button]:opacity-50',
className,
)}
style={{ gap }}
{...props}
>
{Children.map(children, (child, index) => {
return (
<div
className={cn(
'flex [&_button]:leading-normal',
slice < index ? hidden : 'opacity-100',
)}
>
{child}
</div>
);
})}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
'flex select-none items-center gap-1',
sizes.current.length - 1 === slice ? hidden : 'opacity-50',
)}
>
More <ChevronsUpDownIcon size={12} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="[&_button]:w-full">
<DropdownMenuGroup>
{Children.map(children, (child, index) => {
if (index <= slice) {
return null;
}
return <DropdownMenuItem asChild>{child}</DropdownMenuItem>;
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
export function WidgetFooter({
className,
children,
...props
}: WidgetHeadProps) {
return (
<div
className={cn(
'flex rounded-b-md border-t bg-def-100 p-2 py-1',
className,
)}
{...props}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,86 @@
import { differenceInCalendarMonths } from 'date-fns';
import {
parseAsInteger,
parseAsString,
parseAsStringEnum,
useQueryState,
} from 'nuqs';
import { getStorageItem, setStorageItem } from '@/utils/storage';
import {
getDefaultIntervalByDates,
getDefaultIntervalByRange,
intervals,
timeWindows,
} from '@openpanel/constants';
import type { IChartRange } from '@openpanel/validation';
import { mapKeys } from '@openpanel/validation';
const nuqsOptions = { history: 'push' } as const;
export function useOverviewOptions() {
const [startDate, setStartDate] = useQueryState(
'start',
parseAsString.withOptions(nuqsOptions),
);
const [endDate, setEndDate] = useQueryState(
'end',
parseAsString.withOptions(nuqsOptions),
);
const [range, setRange] = useQueryState(
'range',
parseAsStringEnum(mapKeys(timeWindows))
.withDefault(getStorageItem('range', '7d'))
.withOptions({
...nuqsOptions,
clearOnDefault: false,
}),
);
const [overrideInterval, setInterval] = useQueryState(
'overrideInterval',
parseAsStringEnum(mapKeys(intervals)).withOptions({
...nuqsOptions,
clearOnDefault: false,
}),
);
const interval =
overrideInterval ||
getDefaultIntervalByDates(startDate, endDate) ||
getDefaultIntervalByRange(range);
const [metric, setMetric] = useQueryState(
'metric',
parseAsInteger.withDefault(0).withOptions(nuqsOptions),
);
return {
// Skip previous for ranges over 6 months (for performance reasons)
previous: !(
range === 'yearToDate' ||
range === 'lastYear' ||
(range === 'custom' &&
startDate &&
endDate &&
differenceInCalendarMonths(startDate, endDate) > 6)
),
range,
setRange: (value: IChartRange | null) => {
if (value !== 'custom') {
setStartDate(null);
setEndDate(null);
setStorageItem('range', value);
setInterval(null);
}
setRange(value);
},
metric,
setMetric,
startDate,
setStartDate,
endDate,
setEndDate,
interval,
setInterval,
};
}

View File

@@ -0,0 +1,56 @@
import { parseAsStringEnum, useQueryState } from 'nuqs';
import { mapKeys } from '@openpanel/validation';
import type { ReportChartProps } from '../report-chart/context';
export function useOverviewWidget<T extends string>(
key: string,
widgets: Record<
T,
{ title: string; btn: string; chart: ReportChartProps; hide?: boolean }
>,
) {
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;
}
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;
}