fix: improvements for frontend

This commit is contained in:
Carl-Gerhard Lindesvärd
2025-11-04 11:03:32 +01:00
parent 3474fbd12d
commit b51bc8f3f6
38 changed files with 487 additions and 415 deletions

View File

@@ -3,7 +3,6 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"testing": "pnpm dev",
"dev": "pnpm with-env vite dev --port 3000", "dev": "pnpm with-env vite dev --port 3000",
"start_deprecated": "pnpm with-env node .output/server/index.mjs", "start_deprecated": "pnpm with-env node .output/server/index.mjs",
"preview": "vite preview", "preview": "vite preview",

View File

@@ -1,3 +1,4 @@
import { getChartColor, getChartTranslucentColor } from '@/utils/theme';
import { Bar } from 'recharts'; import { Bar } from 'recharts';
type Options = { type Options = {
@@ -11,6 +12,26 @@ export const BarWithBorder = (options: Options) => {
return (props: any) => { return (props: any) => {
const { x, y, width, height, value, isActive } = props; const { x, y, width, height, value, isActive } = props;
const fill =
options.fill === 'props'
? props.fill
: isActive
? options.active.fill
: options.fill;
const border =
options.border === 'props'
? props.stroke
: isActive
? options.active.border
: options.border;
const withActive = (color: string) => {
if (color.startsWith('rgba')) {
return isActive ? color.replace(/, 0.\d+\)$/, ', 0.4)') : color;
}
return color;
};
return ( return (
<g> <g>
<rect <rect
@@ -19,16 +40,18 @@ export const BarWithBorder = (options: Options) => {
width={width} width={width}
height={height} height={height}
stroke="none" stroke="none"
fill={isActive ? options.active.fill : options.fill} fill={withActive(fill)}
rx={3}
/> />
{value > 0 && ( {value > 0 && (
<rect <rect
x={x} x={x}
y={y - options.borderHeight - 2} y={y - options.borderHeight - 1}
width={width} width={width}
height={options.borderHeight} height={options.borderHeight}
stroke="none" stroke="none"
fill={isActive ? options.active.border : options.border} fill={withActive(border)}
rx={2}
/> />
)} )}
</g> </g>
@@ -54,3 +77,24 @@ export const BarShapeBlue = BarWithBorder({
fill: 'rgba(59, 121, 255, 0.4)', fill: 'rgba(59, 121, 255, 0.4)',
}, },
}); });
export const BarShapeProps = BarWithBorder({
borderHeight: 2,
border: 'props',
fill: 'props',
active: {
border: 'props',
fill: 'props',
},
});
const BarShapes = [...new Array(13)].map((_, index) =>
BarWithBorder({
borderHeight: 2,
border: getChartColor(index),
fill: getChartTranslucentColor(index),
active: {
border: getChartColor(index),
fill: getChartTranslucentColor(index),
},
}),
);

View File

@@ -2,6 +2,7 @@ import { formatDateTime, formatTime } from '@/utils/date';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { isToday } from 'date-fns'; import { isToday } from 'date-fns';
import { ColumnCreatedAt } from '@/components/column-created-at';
import CopyInput from '@/components/forms/copy-input'; import CopyInput from '@/components/forms/copy-input';
import { createActionColumn } from '@/components/ui/data-table/data-table-helpers'; import { createActionColumn } from '@/components/ui/data-table/data-table-helpers';
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'; import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
@@ -30,11 +31,10 @@ export function useColumns() {
{ {
accessorKey: 'createdAt', accessorKey: 'createdAt',
header: 'Created at', header: 'Created at',
cell({ row }) { size: ColumnCreatedAt.size,
const date = row.original.createdAt; cell: ({ row }) => {
return ( const item = row.original;
<div>{isToday(date) ? formatTime(date) : formatDateTime(date)}</div> return <ColumnCreatedAt>{item.createdAt}</ColumnCreatedAt>;
);
}, },
}, },
createActionColumn(({ row }) => { createActionColumn(({ row }) => {

View File

@@ -0,0 +1,18 @@
import { formatDateTime, timeAgo } from '@/utils/date';
export function ColumnCreatedAt({ children }: { children: Date | string }) {
return (
<div className="relative">
<div className="absolute inset-0 opacity-0 group-hover/row:opacity-100 transition-opacity duration-100">
{formatDateTime(
typeof children === 'string' ? new Date(children) : children,
)}
</div>
<div className="text-muted-foreground group-hover/row:opacity-0 transition-opacity duration-100">
{timeAgo(typeof children === 'string' ? new Date(children) : children)}
</div>
</div>
);
}
ColumnCreatedAt.size = 150;

View File

@@ -8,7 +8,8 @@ import { useDebounceState } from '@/hooks/use-debounce-state';
import useWS from '@/hooks/use-ws'; import useWS from '@/hooks/use-ws';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import type { IServiceEventMinimal } from '@openpanel/db'; import type { IServiceEvent, IServiceEventMinimal } from '@openpanel/db';
import { useParams } from '@tanstack/react-router';
import { AnimatedNumber } from '../animated-number'; import { AnimatedNumber } from '../animated-number';
export default function EventListener({ export default function EventListener({
@@ -16,13 +17,24 @@ export default function EventListener({
}: { }: {
onRefresh: () => void; onRefresh: () => void;
}) { }) {
const params = useParams({
strict: false,
});
const { projectId } = useAppParams(); const { projectId } = useAppParams();
const counter = useDebounceState(0, 1000); const counter = useDebounceState(0, 1000);
useWS<IServiceEventMinimal | IServiceEvent>(
useWS<IServiceEventMinimal>(
`/live/events/${projectId}`, `/live/events/${projectId}`,
(event) => { (event) => {
if (event?.name) { if (event) {
const isProfilePage = !!params?.profileId;
if (isProfilePage) {
const profile = 'profile' in event ? event.profile : null;
if (profile?.id === params?.profileId) {
counter.set((prev) => prev + 1);
}
return;
}
counter.set((prev) => prev + 1); counter.set((prev) => prev + 1);
} }
}, },

View File

@@ -3,10 +3,11 @@ import { ProjectLink } from '@/components/links';
import { SerieIcon } from '@/components/report-chart/common/serie-icon'; import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { useNumber } from '@/hooks/use-numer-formatter'; import { useNumber } from '@/hooks/use-numer-formatter';
import { pushModal } from '@/modals'; import { pushModal } from '@/modals';
import { formatDateTime, formatTimeAgoOrDateTime } from '@/utils/date'; import { formatDateTime, formatTimeAgoOrDateTime, timeAgo } from '@/utils/date';
import { getProfileName } from '@/utils/getters'; import { getProfileName } from '@/utils/getters';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { ColumnCreatedAt } from '@/components/column-created-at';
import { KeyValueGrid } from '@/components/ui/key-value-grid'; import { KeyValueGrid } from '@/components/ui/key-value-grid';
import type { IServiceEvent } from '@openpanel/db'; import type { IServiceEvent } from '@openpanel/db';
@@ -16,19 +17,10 @@ export function useColumns() {
{ {
accessorKey: 'createdAt', accessorKey: 'createdAt',
header: 'Created at', header: 'Created at',
size: 140, size: ColumnCreatedAt.size,
cell: ({ row }) => { cell: ({ row }) => {
const session = row.original; const session = row.original;
return ( return <ColumnCreatedAt>{session.createdAt}</ColumnCreatedAt>;
<div className="relative">
<div className="absolute inset-0 opacity-0 group-hover/row:opacity-100 transition-opacity duration-100">
{formatDateTime(session.createdAt)}
</div>
<div className="text-muted-foreground group-hover/row:opacity-0 transition-opacity duration-100">
{formatTimeAgoOrDateTime(session.createdAt)}
</div>
</div>
);
}, },
}, },
{ {

View File

@@ -1,6 +1,8 @@
import { FullPageEmptyState } from '@/components/full-page-empty-state'; import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons'; import {
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer'; OverviewFilterButton,
OverviewFiltersButtons,
} from '@/components/overview/filters/overview-filters-buttons';
import { Skeleton } from '@/components/skeleton'; import { Skeleton } from '@/components/skeleton';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useDataTableColumnVisibility } from '@/components/ui/data-table/data-table-hooks'; import { useDataTableColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
@@ -9,24 +11,19 @@ import { DataTableViewOptions } from '@/components/ui/data-table/data-table-view
import { useAppParams } from '@/hooks/use-app-params'; import { useAppParams } from '@/hooks/use-app-params';
import { pushModal } from '@/modals'; import { pushModal } from '@/modals';
import type { RouterInputs, RouterOutputs } from '@/trpc/client'; import type { RouterInputs, RouterOutputs } from '@/trpc/client';
import { arePropsEqual } from '@/utils/are-props-equal';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import type { IServiceEvent } from '@openpanel/db'; import type { IServiceEvent } from '@openpanel/db';
import type { UseInfiniteQueryResult } from '@tanstack/react-query'; import type { UseInfiniteQueryResult } from '@tanstack/react-query';
import type { Table } from '@tanstack/react-table'; import type { Table } from '@tanstack/react-table';
import { getCoreRowModel, useReactTable } from '@tanstack/react-table'; import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { Updater } from '@tanstack/react-table';
import { ColumnOrderState } from '@tanstack/react-table';
import { useWindowVirtualizer } from '@tanstack/react-virtual'; import { useWindowVirtualizer } from '@tanstack/react-virtual';
import type { TRPCInfiniteData } from '@trpc/tanstack-react-query'; import type { TRPCInfiniteData } from '@trpc/tanstack-react-query';
import { format } from 'date-fns'; import { format } from 'date-fns';
import throttle from 'lodash.throttle'; import { CalendarIcon, FilterIcon, Loader2Icon } from 'lucide-react';
import { CalendarIcon, Loader2Icon } from 'lucide-react';
import { parseAsIsoDateTime, useQueryState } from 'nuqs'; import { parseAsIsoDateTime, useQueryState } from 'nuqs';
import { last } from 'ramda'; import { last } from 'ramda';
import { memo, useEffect, useMemo, useRef, useState } from 'react'; import { memo, useEffect, useMemo, useRef } from 'react';
import { useInViewport } from 'react-in-viewport'; import { useInViewport } from 'react-in-viewport';
import { useLocalStorage } from 'usehooks-ts';
import EventListener from '../event-listener'; import EventListener from '../event-listener';
import { useColumns } from './columns'; import { useColumns } from './columns';
@@ -328,11 +325,7 @@ function EventsTableToolbar({
? `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d')}` ? `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d')}`
: 'Date range'} : 'Date range'}
</Button> </Button>
<OverviewFiltersDrawer <OverviewFilterButton enableEventsFilter />
mode="events"
projectId={projectId}
enableEventsFilter
/>
<OverviewFiltersButtons className="justify-end p-0" /> <OverviewFiltersButtons className="justify-end p-0" />
</div> </div>
<DataTableViewOptions table={table} /> <DataTableViewOptions table={table} />

View File

@@ -2,6 +2,7 @@ import { formatDateTime, formatTime } from '@/utils/date';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { isToday } from 'date-fns'; import { isToday } from 'date-fns';
import { ColumnCreatedAt } from '@/components/column-created-at';
import { ProjectLink } from '@/components/links'; import { ProjectLink } from '@/components/links';
import { SerieIcon } from '@/components/report-chart/common/serie-icon'; import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { createHeaderColumn } from '@/components/ui/data-table/data-table-helpers'; import { createHeaderColumn } from '@/components/ui/data-table/data-table-helpers';
@@ -162,14 +163,10 @@ export function useColumns() {
{ {
accessorKey: 'createdAt', accessorKey: 'createdAt',
header: 'Created at', header: 'Created at',
cell({ row }) { size: ColumnCreatedAt.size,
const date = row.original.createdAt; cell: ({ row }) => {
if (!date) { const item = row.original;
return null; return <ColumnCreatedAt>{item.createdAt}</ColumnCreatedAt>;
}
return (
<div>{isToday(date) ? formatTime(date) : formatDateTime(date)}</div>
);
}, },
filterFn: 'isWithinRange', filterFn: 'isWithinRange',
meta: { meta: {

View File

@@ -3,10 +3,12 @@ import {
useEventQueryFilters, useEventQueryFilters,
useEventQueryNamesFilter, useEventQueryNamesFilter,
} from '@/hooks/use-event-query-filters'; } from '@/hooks/use-event-query-filters';
import { pushModal } from '@/modals';
import type { OverviewFiltersProps } from '@/modals/overview-filters';
import { getPropertyLabel } from '@/translations/properties'; import { getPropertyLabel } from '@/translations/properties';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { operators } from '@openpanel/constants'; import { operators } from '@openpanel/constants';
import { X } from 'lucide-react'; import { FilterIcon, X } from 'lucide-react';
import type { Options as NuqsOptions } from 'nuqs'; import type { Options as NuqsOptions } from 'nuqs';
interface OverviewFiltersButtonsProps { interface OverviewFiltersButtonsProps {
@@ -14,6 +16,23 @@ interface OverviewFiltersButtonsProps {
nuqsOptions?: NuqsOptions; nuqsOptions?: NuqsOptions;
} }
export function OverviewFilterButton(props: OverviewFiltersProps) {
return (
<Button
variant="outline"
responsive
icon={FilterIcon}
onClick={() =>
pushModal('OverviewFilters', {
...props,
})
}
>
Filters
</Button>
);
}
export function OverviewFiltersButtons({ export function OverviewFiltersButtons({
className, className,
nuqsOptions, nuqsOptions,

View File

@@ -1,199 +0,0 @@
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

@@ -1,23 +0,0 @@
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

@@ -4,46 +4,21 @@ import {
isMinuteIntervalEnabledByRange, isMinuteIntervalEnabledByRange,
} from '@openpanel/constants'; } from '@openpanel/constants';
import { ClockIcon } from 'lucide-react'; import { ClockIcon } from 'lucide-react';
import { ReportInterval } from '../report/ReportInterval';
import { Combobox } from '../ui/combobox'; import { Combobox } from '../ui/combobox';
export function OverviewInterval() { export function OverviewInterval() {
const { interval, setInterval, range } = useOverviewOptions(); const { interval, setInterval, range, startDate, endDate } =
useOverviewOptions();
return ( return (
<Combobox <ReportInterval
className="hidden md:flex" interval={interval}
icon={ClockIcon} onChange={setInterval}
placeholder="Interval" range={range}
onChange={(value) => { chartType="linear"
setInterval(value); startDate={startDate}
}} endDate={endDate}
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

@@ -8,6 +8,7 @@ import { isToday } from 'date-fns';
import type { IServiceProfile } from '@openpanel/db'; import type { IServiceProfile } from '@openpanel/db';
import { ColumnCreatedAt } from '@/components/column-created-at';
import { ProfileAvatar } from '../profile-avatar'; import { ProfileAvatar } from '../profile-avatar';
export function useColumns(type: 'profiles' | 'power-users') { export function useColumns(type: 'profiles' | 'power-users') {
@@ -100,17 +101,10 @@ export function useColumns(type: 'profiles' | 'power-users') {
{ {
accessorKey: 'createdAt', accessorKey: 'createdAt',
header: 'Last seen', header: 'Last seen',
size: ColumnCreatedAt.size,
cell: ({ row }) => { cell: ({ row }) => {
const profile = row.original; const item = row.original;
return ( return <ColumnCreatedAt>{item.createdAt}</ColumnCreatedAt>;
<Tooltiper asChild content={formatDateTime(profile.createdAt)}>
<div className="text-muted-foreground">
{isToday(profile.createdAt)
? formatTime(profile.createdAt)
: formatDateTime(profile.createdAt)}
</div>
</Tooltiper>
);
}, },
}, },
]; ];

View File

@@ -129,7 +129,7 @@ export function Chart({ data }: Props) {
<ReferenceLine <ReferenceLine
key={ref.id} key={ref.id}
x={ref.date.getTime()} x={ref.date.getTime()}
stroke={'#94a3b8'} stroke={'oklch(from var(--foreground) l c h / 0.1)'}
strokeDasharray={'3 3'} strokeDasharray={'3 3'}
label={{ label={{
value: ref.title, value: ref.title,

View File

@@ -90,7 +90,7 @@ export function Chart({ data }: Props) {
<ReferenceLine <ReferenceLine
key={ref.id} key={ref.id}
x={ref.date.getTime()} x={ref.date.getTime()}
stroke={'#94a3b8'} stroke={'oklch(from var(--foreground) l c h / 0.1)'}
strokeDasharray={'3 3'} strokeDasharray={'3 3'}
label={{ label={{
value: ref.title, value: ref.title,
@@ -114,7 +114,6 @@ export function Chart({ data }: Props) {
strokeOpacity={0.5} strokeOpacity={0.5}
/> />
<Line <Line
dot={false}
dataKey="rate" dataKey="rate"
stroke={getChartColor(0)} stroke={getChartColor(0)}
type={lineType} type={lineType}

View File

@@ -6,11 +6,11 @@ import { ChevronRightIcon, InfoIcon } from 'lucide-react';
import { alphabetIds } from '@openpanel/constants'; import { alphabetIds } from '@openpanel/constants';
import { createChartTooltip } from '@/components/charts/chart-tooltip'; import { createChartTooltip } from '@/components/charts/chart-tooltip';
import { BarShapeBlue } from '@/components/charts/common-bar'; import { BarShapeBlue, BarShapeProps } from '@/components/charts/common-bar';
import { Tooltiper } from '@/components/ui/tooltip'; import { Tooltiper } from '@/components/ui/tooltip';
import { WidgetTable } from '@/components/widget-table'; import { WidgetTable } from '@/components/widget-table';
import { useNumber } from '@/hooks/use-numer-formatter'; import { useNumber } from '@/hooks/use-numer-formatter';
import { getChartColor } from '@/utils/theme'; import { getChartColor, getChartTranslucentColor } from '@/utils/theme';
import { getPreviousMetric } from '@openpanel/common'; import { getPreviousMetric } from '@openpanel/common';
import { import {
Bar, Bar,
@@ -327,9 +327,17 @@ export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) {
} }
/> />
<YAxis {...yAxisProps} /> <YAxis {...yAxisProps} />
<Bar data={rechartData} dataKey="step:percent:0"> <Bar
data={rechartData}
dataKey="step:percent:0"
shape={<BarShapeProps />}
>
{rechartData.map((item, index) => ( {rechartData.map((item, index) => (
<Cell key={item.name} fill={getChartColor(index)} /> <Cell
key={item.name}
fill={getChartTranslucentColor(index)}
stroke={getChartColor(index)}
/>
))} ))}
</Bar> </Bar>
<Tooltip /> <Tooltip />
@@ -340,22 +348,30 @@ export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) {
); );
} }
type Hej = RouterOutputs['chart']['funnel']['current'];
const { Tooltip, TooltipProvider } = createChartTooltip< const { Tooltip, TooltipProvider } = createChartTooltip<
RechartData, RechartData,
Record<string, unknown> {
>(({ data: dataArray }) => { data: RouterOutputs['chart']['funnel']['current'];
}
>(({ data: dataArray, context, ...props }) => {
const data = dataArray[0]!; const data = dataArray[0]!;
const number = useNumber(); const number = useNumber();
const variants = Object.keys(data).filter((key) => const variants = Object.keys(data).filter((key) =>
key.startsWith('step:data:'), key.startsWith('step:data:'),
) as `step:data:${number}`[]; ) as `step:data:${number}`[];
const index = context.data[0].steps.findIndex(
(step) => step.event.id === (data as any).id,
);
return ( return (
<> <>
<div className="flex justify-between gap-8 text-muted-foreground"> <div className="flex justify-between gap-8 text-muted-foreground">
<div>{data.name}</div> <div>{data.name}</div>
</div> </div>
{variants.map((key, index) => { {variants.map((key) => {
const variant = data[key]; const variant = data[key];
const prevVariant = data[`prev_${key}`]; const prevVariant = data[`prev_${key}`];
if (!variant?.step) { if (!variant?.step) {

View File

@@ -149,7 +149,7 @@ export function Chart({ data }: Props) {
<ReferenceLine <ReferenceLine
key={ref.id} key={ref.id}
x={ref.date.getTime()} x={ref.date.getTime()}
stroke={'#94a3b8'} stroke={'oklch(from var(--foreground) l c h / 0.1)'}
strokeDasharray={'3 3'} strokeDasharray={'3 3'}
label={{ label={{
value: ref.title, value: ref.title,

View File

@@ -96,6 +96,8 @@ export default function ReportEditor({
onChange={(newInterval) => dispatch(changeInterval(newInterval))} onChange={(newInterval) => dispatch(changeInterval(newInterval))}
range={report.range} range={report.range}
chartType={report.chartType} chartType={report.chartType}
startDate={report.startDate}
endDate={report.endDate}
/> />
<ReportLineType className="min-w-0 flex-1" /> <ReportLineType className="min-w-0 flex-1" />
</div> </div>

View File

@@ -1,4 +1,3 @@
import { useDispatch, useSelector } from '@/redux';
import { ClockIcon } from 'lucide-react'; import { ClockIcon } from 'lucide-react';
import { import {
@@ -8,6 +7,7 @@ import {
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import type { IChartRange, IChartType, IInterval } from '@openpanel/validation'; import type { IChartRange, IChartType, IInterval } from '@openpanel/validation';
import { differenceInDays, isSameDay } from 'date-fns';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { CommandShortcut } from '../ui/command'; import { CommandShortcut } from '../ui/command';
import { import {
@@ -20,7 +20,6 @@ import {
DropdownMenuShortcut, DropdownMenuShortcut,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '../ui/dropdown-menu'; } from '../ui/dropdown-menu';
import { changeInterval } from './reportSlice';
interface ReportIntervalProps { interface ReportIntervalProps {
className?: string; className?: string;
@@ -28,6 +27,8 @@ interface ReportIntervalProps {
onChange: (range: IInterval) => void; onChange: (range: IInterval) => void;
chartType: IChartType; chartType: IChartType;
range: IChartRange; range: IChartRange;
startDate?: string | null;
endDate?: string | null;
} }
export function ReportInterval({ export function ReportInterval({
className, className,
@@ -35,6 +36,8 @@ export function ReportInterval({
onChange, onChange,
chartType, chartType,
range, range,
startDate,
endDate,
}: ReportIntervalProps) { }: ReportIntervalProps) {
if ( if (
chartType !== 'linear' && chartType !== 'linear' &&
@@ -47,6 +50,11 @@ export function ReportInterval({
return null; return null;
} }
let isHourIntervalEnabled = isHourIntervalEnabledByRange(range);
if (startDate && endDate && range === 'custom') {
isHourIntervalEnabled = differenceInDays(endDate, startDate) <= 4;
}
const items = [ const items = [
{ {
value: 'minute', value: 'minute',
@@ -56,7 +64,7 @@ export function ReportInterval({
{ {
value: 'hour', value: 'hour',
label: 'Hour', label: 'Hour',
disabled: !isHourIntervalEnabledByRange(range), disabled: !isHourIntervalEnabled,
}, },
{ {
value: 'day', value: 'day',

View File

@@ -24,6 +24,8 @@ interface PropertiesComboboxProps {
label: string; label: string;
description: string; description: string;
}) => void; }) => void;
exclude?: string[];
mode?: 'events' | 'profile';
} }
function SearchHeader({ function SearchHeader({
@@ -56,6 +58,8 @@ export function PropertiesCombobox({
event, event,
children, children,
onSelect, onSelect,
mode,
exclude = [],
}: PropertiesComboboxProps) { }: PropertiesComboboxProps) {
const { projectId } = useAppParams(); const { projectId } = useAppParams();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -69,20 +73,35 @@ export function PropertiesCombobox({
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setState('index'); setState(!mode ? 'index' : mode === 'events' ? 'event' : 'profile');
} }
}, [open]); }, [open, mode]);
const shouldShowProperty = (property: string) => {
return !exclude.find((ex) => {
if (ex.endsWith('*')) {
return property.startsWith(ex.slice(0, -1));
}
return property === ex;
});
};
// Mock data for the lists // Mock data for the lists
const profileActions = properties const profileActions = properties
.filter((property) => property.startsWith('profile')) .filter(
(property) =>
property.startsWith('profile') && shouldShowProperty(property),
)
.map((property) => ({ .map((property) => ({
value: property, value: property,
label: property.split('.').pop() ?? property, label: property.split('.').pop() ?? property,
description: property.split('.').slice(0, -1).join('.'), description: property.split('.').slice(0, -1).join('.'),
})); }));
const eventActions = properties const eventActions = properties
.filter((property) => !property.startsWith('profile')) .filter(
(property) =>
!property.startsWith('profile') && shouldShowProperty(property),
)
.map((property) => ({ .map((property) => ({
value: property, value: property,
label: property.split('.').pop() ?? property, label: property.split('.').pop() ?? property,
@@ -142,7 +161,9 @@ export function PropertiesCombobox({
return ( return (
<div className="col"> <div className="col">
<SearchHeader <SearchHeader
onBack={() => handleStateChange('index')} onBack={
mode === undefined ? () => handleStateChange('index') : undefined
}
onSearch={setSearch} onSearch={setSearch}
value={search} value={search}
/> />

View File

@@ -3,6 +3,7 @@ import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { formatDateTime, formatTimeAgoOrDateTime } from '@/utils/date'; import { formatDateTime, formatTimeAgoOrDateTime } from '@/utils/date';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { ColumnCreatedAt } from '@/components/column-created-at';
import { getProfileName } from '@/utils/getters'; import { getProfileName } from '@/utils/getters';
import { round } from '@openpanel/common'; import { round } from '@openpanel/common';
import type { IServiceSession } from '@openpanel/db'; import type { IServiceSession } from '@openpanel/db';
@@ -29,19 +30,10 @@ export function useColumns() {
{ {
accessorKey: 'createdAt', accessorKey: 'createdAt',
header: 'Started', header: 'Started',
size: 140, size: ColumnCreatedAt.size,
cell: ({ row }) => { cell: ({ row }) => {
const session = row.original; const item = row.original;
return ( return <ColumnCreatedAt>{item.createdAt}</ColumnCreatedAt>;
<div className="relative">
<div className="absolute inset-0 opacity-0 group-hover/row:opacity-100 transition-opacity duration-100">
{formatDateTime(session.createdAt)}
</div>
<div className="text-muted-foreground group-hover/row:opacity-0 transition-opacity duration-100">
{formatTimeAgoOrDateTime(session.createdAt)}
</div>
</div>
);
}, },
}, },
{ {

View File

@@ -6,6 +6,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { ColumnDef, Row } from '@tanstack/react-table'; import type { ColumnDef, Row } from '@tanstack/react-table';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { ColumnCreatedAt } from '@/components/column-created-at';
import { createActionColumn } from '@/components/ui/data-table/data-table-helpers'; import { createActionColumn } from '@/components/ui/data-table/data-table-helpers';
import type { RouterOutputs } from '@/trpc/client'; import type { RouterOutputs } from '@/trpc/client';
import { clipboard } from '@/utils/clipboard'; import { clipboard } from '@/utils/clipboard';
@@ -39,13 +40,11 @@ export function useColumns(): ColumnDef<
{ {
accessorKey: 'createdAt', accessorKey: 'createdAt',
header: 'Created', header: 'Created',
cell: ({ row }) => ( size: ColumnCreatedAt.size,
<TooltipComplete cell: ({ row }) => {
content={new Date(row.original.createdAt).toLocaleString()} const item = row.original;
> return <ColumnCreatedAt>{item.createdAt}</ColumnCreatedAt>;
{new Date(row.original.createdAt).toLocaleDateString()} },
</TooltipComplete>
),
meta: { meta: {
label: 'Created', label: 'Created',
}, },

View File

@@ -5,6 +5,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { ColumnCreatedAt } from '@/components/column-created-at';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { createActionColumn } from '@/components/ui/data-table/data-table-helpers'; import { createActionColumn } from '@/components/ui/data-table/data-table-helpers';
import { pushModal } from '@/modals'; import { pushModal } from '@/modals';
@@ -52,13 +53,11 @@ export function useColumns() {
{ {
accessorKey: 'createdAt', accessorKey: 'createdAt',
header: 'Created', header: 'Created',
cell: ({ row }) => ( size: ColumnCreatedAt.size,
<TooltipComplete cell: ({ row }) => {
content={new Date(row.original.createdAt).toLocaleString()} const item = row.original;
> return <ColumnCreatedAt>{item.createdAt}</ColumnCreatedAt>;
{new Date(row.original.createdAt).toLocaleDateString()} },
</TooltipComplete>
),
meta: { meta: {
label: 'Created', label: 'Created',
}, },

View File

@@ -69,7 +69,7 @@ function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
<tr <tr
data-slot="table-row" data-slot="table-row"
className={cn( className={cn(
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors', 'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors group/row',
className, className,
)} )}
{...props} {...props}

View File

@@ -23,6 +23,7 @@ import EditReport from './edit-report';
import EventDetails from './event-details'; import EventDetails from './event-details';
import OnboardingTroubleshoot from './onboarding-troubleshoot'; import OnboardingTroubleshoot from './onboarding-troubleshoot';
import OverviewChartDetails from './overview-chart-details'; import OverviewChartDetails from './overview-chart-details';
import OverviewFilters from './overview-filters';
import RequestPasswordReset from './request-reset-password'; import RequestPasswordReset from './request-reset-password';
import SaveReport from './save-report'; import SaveReport from './save-report';
import ShareOverviewModal from './share-overview-modal'; import ShareOverviewModal from './share-overview-modal';
@@ -52,6 +53,7 @@ const modals = {
OverviewChartDetails: OverviewChartDetails, OverviewChartDetails: OverviewChartDetails,
AddIntegration: AddIntegration, AddIntegration: AddIntegration,
AddNotificationRule: AddNotificationRule, AddNotificationRule: AddNotificationRule,
OverviewFilters: OverviewFilters,
CreateInvite: CreateInvite, CreateInvite: CreateInvite,
}; };

View File

@@ -0,0 +1,157 @@
import { PureFilterItem } from '@/components/report/sidebar/filters/FilterItem';
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import { SheetContent } from '@/components/ui/sheet';
import { useEventNames } from '@/hooks/use-event-names';
import {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/use-event-query-filters';
import { useProfileValues } from '@/hooks/use-profile-values';
import { FilterIcon, XIcon } from 'lucide-react';
import type { Options as NuqsOptions } from 'nuqs';
import type {
IChartEventFilter,
IChartEventFilterOperator,
IChartEventFilterValue,
} from '@openpanel/validation';
import { OriginFilter } from '@/components/overview/filters/origin-filter';
import { PropertiesCombobox } from '@/components/report/sidebar/PropertiesCombobox';
import { ComboboxEvents } from '@/components/ui/combobox-events';
import { useAppParams } from '@/hooks/use-app-params';
import { cn } from '@/utils/cn';
import { ModalHeader } from './Modal/Container';
export interface OverviewFiltersProps {
nuqsOptions?: NuqsOptions;
enableEventsFilter?: boolean;
mode?: 'events' | 'profile';
}
export default function OverviewFilters({
nuqsOptions,
enableEventsFilter,
mode,
}: OverviewFiltersProps) {
const { projectId } = useAppParams();
const [filters, setFilter] = useEventQueryFilters(nuqsOptions);
const [event, setEvent] = useEventQueryNamesFilter(nuqsOptions);
const eventNames = useEventNames({ projectId });
const selectedFilters = filters.filter((filter) => filter.value[0] !== null);
return (
<SheetContent className="[&>button.absolute]:hidden">
<ModalHeader title="Filters" />
<div className="flex flex-col gap-4">
<OriginFilter />
{enableEventsFilter && (
<ComboboxEvents
size="lg"
className="w-full"
value={event}
onChange={setEvent}
multiple
items={eventNames}
placeholder="Select event"
maxDisplayItems={2}
searchable
/>
)}
</div>
<div className="flex flex-col gap-2">
<div
className={cn(
'bg-def-200 rounded-lg border',
selectedFilters.length === 0 && 'hidden',
)}
>
{selectedFilters.map((filter) => {
return (
<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);
}}
/>
);
})}
</div>
<PropertiesCombobox
mode={mode}
exclude={[
'properties.*',
'name',
'duration',
'created_at',
'has_profile',
]}
onSelect={(action) => {
setFilter(action.value, [], 'is');
}}
>
{(setOpen) => (
<Button
onClick={() => setOpen((p) => !p)}
variant="outline"
size="lg"
className="w-full"
icon={FilterIcon}
>
Add filter
</Button>
)}
</PropertiesCombobox>
</div>
</SheetContent>
);
}
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

@@ -3,7 +3,7 @@ import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/use-app-params'; import { useAppParams } from '@/hooks/use-app-params';
import { handleError } from '@/integrations/trpc/react'; import { handleError } from '@/integrations/trpc/react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { z } from 'zod'; import type { z } from 'zod';
@@ -22,6 +22,7 @@ type IForm = z.infer<typeof validator>;
export default function ShareOverviewModal() { export default function ShareOverviewModal() {
const { projectId, organizationId } = useAppParams(); const { projectId, organizationId } = useAppParams();
const navigate = useNavigate();
const { register, handleSubmit } = useForm<IForm>({ const { register, handleSubmit } = useForm<IForm>({
resolver: zodResolver(validator), resolver: zodResolver(validator),
@@ -44,6 +45,16 @@ export default function ShareOverviewModal() {
description: `Your overview is now ${ description: `Your overview is now ${
res.public ? 'public' : 'private' res.public ? 'public' : 'private'
}`, }`,
action: {
label: 'View',
onClick: () =>
navigate({
to: '/share/overview/$shareId',
params: {
shareId: res.id,
},
}),
},
}); });
popModal(); popModal();
}, },

View File

@@ -1,5 +1,7 @@
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons'; import {
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer'; OverviewFilterButton,
OverviewFiltersButtons,
} from '@/components/overview/filters/overview-filters-buttons';
import { LiveCounter } from '@/components/overview/live-counter'; import { LiveCounter } from '@/components/overview/live-counter';
import { OverviewInterval } from '@/components/overview/overview-interval'; import { OverviewInterval } from '@/components/overview/overview-interval';
import OverviewMetrics from '@/components/overview/overview-metrics'; import OverviewMetrics from '@/components/overview/overview-metrics';
@@ -35,7 +37,7 @@ function ProjectDashboard() {
<div className="flex gap-2"> <div className="flex gap-2">
<OverviewRange /> <OverviewRange />
<OverviewInterval /> <OverviewInterval />
<OverviewFiltersDrawer projectId={projectId} mode="events" /> <OverviewFilterButton mode="events" />
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<LiveCounter projectId={projectId} /> <LiveCounter projectId={projectId} />

View File

@@ -1,4 +1,5 @@
import { EventsTable } from '@/components/events/table'; import { EventsTable } from '@/components/events/table';
import { useEventQueryNamesFilter } from '@/hooks/use-event-query-filters';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { useInfiniteQuery } from '@tanstack/react-query'; import { useInfiniteQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from '@tanstack/react-router';
@@ -18,12 +19,14 @@ function Component() {
parseAsIsoDateTime, parseAsIsoDateTime,
); );
const [endDate, setEndDate] = useQueryState('endDate', parseAsIsoDateTime); const [endDate, setEndDate] = useQueryState('endDate', parseAsIsoDateTime);
const [eventNames] = useEventQueryNamesFilter();
const query = useInfiniteQuery( const query = useInfiniteQuery(
trpc.event.conversions.infiniteQueryOptions( trpc.event.conversions.infiniteQueryOptions(
{ {
projectId, projectId,
startDate: startDate || undefined, startDate: startDate || undefined,
endDate: endDate || undefined, endDate: endDate || undefined,
events: eventNames,
}, },
{ {
getNextPageParam: (lastPage) => lastPage.meta.next, getNextPageParam: (lastPage) => lastPage.meta.next,

View File

@@ -1,5 +1,7 @@
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons'; import {
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer'; OverviewFilterButton,
OverviewFiltersButtons,
} from '@/components/overview/filters/overview-filters-buttons';
import { ReportChartShortcut } from '@/components/report-chart/shortcut'; import { ReportChartShortcut } from '@/components/report-chart/shortcut';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget'; import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { import {
@@ -34,11 +36,7 @@ function Component() {
return ( return (
<div> <div>
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
<OverviewFiltersDrawer <OverviewFilterButton enableEventsFilter />
mode="events"
projectId={projectId}
enableEventsFilter
/>
<OverviewFiltersButtons className="justify-end p-0" /> <OverviewFiltersButtons className="justify-end p-0" />
</div> </div>
<div className="grid grid-cols-1 gap-2 md:grid-cols-2"> <div className="grid grid-cols-1 gap-2 md:grid-cols-2">

View File

@@ -1,5 +1,5 @@
import { FullPageEmptyState } from '@/components/full-page-empty-state'; import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer'; import { OverviewFilterButton } from '@/components/overview/filters/overview-filters-buttons';
import { OverviewInterval } from '@/components/overview/overview-interval'; import { OverviewInterval } from '@/components/overview/overview-interval';
import { OverviewRange } from '@/components/overview/overview-range'; import { OverviewRange } from '@/components/overview/overview-range';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
@@ -79,7 +79,7 @@ function Component() {
<TableButtons> <TableButtons>
<OverviewRange /> <OverviewRange />
<OverviewInterval /> <OverviewInterval />
<OverviewFiltersDrawer projectId={projectId} mode="events" /> <OverviewFilterButton enableEventsFilter />
<Input <Input
className="self-auto" className="self-auto"
placeholder="Search path" placeholder="Search path"

View File

@@ -1,3 +1,4 @@
import { ColumnCreatedAt } from '@/components/column-created-at';
import { PageContainer } from '@/components/page-container'; import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -87,9 +88,10 @@ export const columnDefs: ColumnDef<IServiceReference>[] = [
{ {
accessorKey: 'createdAt', accessorKey: 'createdAt',
header: createHeaderColumn('Created at'), header: createHeaderColumn('Created at'),
cell({ row }) { size: ColumnCreatedAt.size,
const date = row.original.createdAt; cell: ({ row }) => {
return formatDate(date); const item = row.original;
return <ColumnCreatedAt>{item.createdAt}</ColumnCreatedAt>;
}, },
filterFn: 'isWithinRange', filterFn: 'isWithinRange',
sortingFn: 'datetime', sortingFn: 'datetime',

View File

@@ -32,6 +32,8 @@ export function formatDateTime(date: Date) {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
hour12: false, hour12: false,
year:
date.getFullYear() === new Date().getFullYear() ? undefined : 'numeric',
}).format(date); }).format(date);
return `${datePart}, ${timePart}`; return `${datePart}, ${timePart}`;

View File

@@ -7,27 +7,25 @@
// export const theme = resolvedTailwindConfig.theme as Record<string, any>; // export const theme = resolvedTailwindConfig.theme as Record<string, any>;
const chartColors = [ const chartColors = [
'#2563EB', { main: '#2563EB', translucent: 'rgba(37, 99, 235, 0.1)' },
'#ff7557', { main: '#ff7557', translucent: 'rgba(255, 117, 87, 0.1)' },
'#7fe1d8', { main: '#7fe1d8', translucent: 'rgba(127, 225, 216, 0.1)' },
'#f8bc3c', { main: '#f8bc3c', translucent: 'rgba(248, 188, 60, 0.1)' },
'#b3596e', { main: '#b3596e', translucent: 'rgba(179, 89, 110, 0.1)' },
'#72bef4', { main: '#72bef4', translucent: 'rgba(114, 190, 244, 0.1)' },
'#ffb27a', { main: '#ffb27a', translucent: 'rgba(255, 178, 122, 0.1)' },
'#0f7ea0', { main: '#0f7ea0', translucent: 'rgba(15, 126, 160, 0.1)' },
'#3ba974', { main: '#3ba974', translucent: 'rgba(59, 169, 116, 0.1)' },
'#febbb2', { main: '#febbb2', translucent: 'rgba(254, 187, 178, 0.1)' },
'#cb80dc', { main: '#cb80dc', translucent: 'rgba(203, 128, 220, 0.1)' },
'#5cb7af', { main: '#5cb7af', translucent: 'rgba(92, 183, 175, 0.1)' },
'#7856ff', { main: '#7856ff', translucent: 'rgba(120, 86, 255, 0.1)' },
]; ];
export function getChartColor(index: number): string { export function getChartColor(index: number): string {
// const colors = theme?.colors ?? {}; return chartColors[index % chartColors.length]!.main;
// const chartColors: string[] = Object.keys(colors) }
// .filter((key) => key.startsWith('chart-'))
// .map((key) => colors[key]) export function getChartTranslucentColor(index: number): string {
// .filter((item): item is string => typeof item === 'string'); return chartColors[index % chartColors.length]!.translucent;
return chartColors[index % chartColors.length]!;
} }

View File

@@ -680,12 +680,10 @@ clix.toStartOf = (node: string, interval: IInterval, timezone?: string) => {
return `toStartOfDay(${node})`; return `toStartOfDay(${node})`;
} }
case 'week': { case 'week': {
// Does not respect timezone settings (session_timezone) so we need to pass it manually return `toStartOfWeek(toDateTime(${node}))`;
return `toStartOfWeek(${node}${timezone ? `, 1, '${timezone}'` : ''})`;
} }
case 'month': { case 'month': {
// Does not respect timezone settings (session_timezone) so we need to pass it manually return `toStartOfMonth(toDateTime(${node}))`;
return `toStartOfMonth(${node}${timezone ? `, '${timezone}'` : ''})`;
} }
} }
}; };

View File

@@ -1,4 +1,4 @@
import { path, assocPath, last, mergeDeepRight } from 'ramda'; import { path, assocPath, last, mergeDeepRight, uniq } from 'ramda';
import sqlstring from 'sqlstring'; import sqlstring from 'sqlstring';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
@@ -561,6 +561,15 @@ export async function getEventList(options: GetEventListOptions) {
...sb.where, ...sb.where,
...getEventFiltersWhereClause(filters), ...getEventFiltersWhereClause(filters),
}; };
// Join profiles table if any filter uses profile fields
const profileFilters = filters
.filter((f) => f.name.startsWith('profile.'))
.map((f) => f.name.replace('profile.', ''));
if (profileFilters.length > 0) {
sb.joins.profiles = `LEFT ANY JOIN (SELECT id, ${uniq(profileFilters.map((f) => f.split('.')[0])).join(', ')} FROM ${TABLE_NAMES.profiles} FINAL WHERE project_id = ${sqlstring.escape(projectId)}) as profile on profile.id = profile_id`;
}
} }
sb.orderBy.created_at = sb.orderBy.created_at =
@@ -622,6 +631,15 @@ export async function getEventsCount({
...sb.where, ...sb.where,
...getEventFiltersWhereClause(filters), ...getEventFiltersWhereClause(filters),
}; };
// Join profiles table if any filter uses profile fields
const profileFilters = filters
.filter((f) => f.name.startsWith('profile.'))
.map((f) => f.name.replace('profile.', ''));
if (profileFilters.length > 0) {
sb.joins.profiles = `LEFT ANY JOIN (SELECT id, ${uniq(profileFilters.map((f) => f.split('.')[0])).join(', ')} FROM ${TABLE_NAMES.profiles} FINAL WHERE project_id = ${sqlstring.escape(projectId)}) as profile on profile.id = profile_id`;
}
} }
const res = await chQuery<{ count: number }>( const res = await chQuery<{ count: number }>(
@@ -701,6 +719,7 @@ class EventService {
select, select,
limit, limit,
orderBy, orderBy,
filters,
}: { }: {
projectId: string; projectId: string;
profileId?: string; profileId?: string;
@@ -715,7 +734,14 @@ class EventService {
}; };
limit?: number; limit?: number;
orderBy?: keyof IClickhouseEvent; orderBy?: keyof IClickhouseEvent;
filters?: IChartEventFilter[];
}) { }) {
// Extract profile filters if any
const profileFilters =
filters
?.filter((f) => f.name.startsWith('profile.'))
.map((f) => f.name.replace('profile.', '')) ?? [];
const events = clix(this.client) const events = clix(this.client)
.select< .select<
Partial<IClickhouseEvent> & { Partial<IClickhouseEvent> & {
@@ -744,6 +770,12 @@ class EventService {
]) ])
.from('events e') .from('events e')
.where('project_id', '=', projectId) .where('project_id', '=', projectId)
.when(profileFilters.length > 0, (q) => {
q.leftJoin(
`(SELECT id, ${uniq(profileFilters.map((f) => f.split('.')[0])).join(', ')} FROM ${TABLE_NAMES.profiles} FINAL WHERE project_id = ${sqlstring.escape(projectId)}) as profile`,
'profile.id = e.profile_id',
);
})
.when(!!where?.event, where?.event) .when(!!where?.event, where?.event)
// Do not limit if profileId, we will limit later since we need the "correct" profileId // Do not limit if profileId, we will limit later since we need the "correct" profileId
.when(!!limit && !profileId, (q) => q.limit(limit!)) .when(!!limit && !profileId, (q) => q.limit(limit!))
@@ -941,6 +973,7 @@ class EventService {
profileId, profileId,
limit, limit,
orderBy: 'created_at', orderBy: 'created_at',
filters,
select: { select: {
event: { event: {
deviceId: true, deviceId: true,

View File

@@ -200,6 +200,12 @@ export class OverviewService {
]) ])
.rawWhere(this.getRawWhereClause('events', filters)); .rawWhere(this.getRawWhereClause('events', filters));
// Use toDate for month/week intervals, toDateTime for others
const rollupDate =
interval === 'month' || interval === 'week'
? clix.date('1970-01-01')
: clix.datetime('1970-01-01 00:00:00');
return clix(this.client, timezone) return clix(this.client, timezone)
.with('session_agg', sessionAggQuery) .with('session_agg', sessionAggQuery)
.with( .with(
@@ -207,14 +213,14 @@ export class OverviewService {
clix(this.client, timezone) clix(this.client, timezone)
.select(['bounce_rate']) .select(['bounce_rate'])
.from('session_agg') .from('session_agg')
.where('date', '=', clix.datetime('1970-01-01 00:00:00')), .where('date', '=', rollupDate),
) )
.with( .with(
'daily_stats', 'daily_stats',
clix(this.client, timezone) clix(this.client, timezone)
.select(['date', 'bounce_rate']) .select(['date', 'bounce_rate'])
.from('session_agg') .from('session_agg')
.where('date', '!=', clix.datetime('1970-01-01 00:00:00')), .where('date', '!=', rollupDate),
) )
.with('overall_unique_visitors', overallUniqueVisitorsQuery) .with('overall_unique_visitors', overallUniqueVisitorsQuery)
.select<{ .select<{

View File

@@ -172,7 +172,7 @@ export const eventRouter = createTRPCRouter({
data: items, data: items,
meta: { meta: {
next: next:
items.length === 50 && lastItem items.length > 0 && lastItem
? lastItem.createdAt.toISOString() ? lastItem.createdAt.toISOString()
: null, : null,
}, },
@@ -190,12 +190,19 @@ export const eventRouter = createTRPCRouter({
cursor: z.string().optional(), cursor: z.string().optional(),
startDate: z.date().optional(), startDate: z.date().optional(),
endDate: z.date().optional(), endDate: z.date().optional(),
events: z.array(z.string()).optional(),
}), }),
) )
.query(async ({ input }) => { .query(async ({ input }) => {
const conversions = await getConversionEventNames(input.projectId); const conversions = await getConversionEventNames(input.projectId);
const filteredConversions = conversions.filter((event) => {
if (input.events && input.events.length > 0) {
return input.events.includes(event.name);
}
return true;
});
if (conversions.length === 0) { if (filteredConversions.length === 0) {
return { return {
data: [], data: [],
meta: { meta: {
@@ -220,7 +227,7 @@ export const eventRouter = createTRPCRouter({
origin: true, origin: true,
}, },
custom: (sb) => { custom: (sb) => {
sb.where.name = `name IN (${conversions.map((event) => sqlstring.escape(event.name)).join(',')})`; sb.where.name = `name IN (${filteredConversions.map((event) => sqlstring.escape(event.name)).join(',')})`;
}, },
}); });
@@ -249,7 +256,7 @@ export const eventRouter = createTRPCRouter({
data: items, data: items,
meta: { meta: {
next: next:
items.length === 50 && lastItem items.length > 0 && lastItem
? lastItem.createdAt.toISOString() ? lastItem.createdAt.toISOString()
: null, : null,
}, },
@@ -354,15 +361,11 @@ export const eventRouter = createTRPCRouter({
) )
.query(async ({ input }) => { .query(async ({ input }) => {
const res = await chQuery<{ origin: string }>( const res = await chQuery<{ origin: string }>(
`SELECT DISTINCT origin FROM ${TABLE_NAMES.events} WHERE project_id = ${sqlstring.escape( `SELECT DISTINCT origin, count(id) as count FROM ${TABLE_NAMES.events} WHERE project_id = ${sqlstring.escape(
input.projectId, input.projectId,
)} AND origin IS NOT NULL AND origin != '' AND toDate(created_at) > now() - INTERVAL 30 DAY ORDER BY origin ASC`, )} AND origin IS NOT NULL AND origin != '' AND toDate(created_at) > now() - INTERVAL 30 DAY GROUP BY origin ORDER BY count DESC LIMIT 3`,
); );
return res.sort((a, b) => return res;
a.origin
.replace(/https?:\/\//, '')
.localeCompare(b.origin.replace(/https?:\/\//, '')),
);
}), }),
}); });