fix: improvements for frontend
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
18
apps/start/src/components/column-created-at.tsx
Normal file
18
apps/start/src/components/column-created-at.tsx
Normal 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;
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
157
apps/start/src/modals/overview-filters.tsx
Normal file
157
apps/start/src/modals/overview-filters.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
|||||||
@@ -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]!;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}'` : ''})`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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<{
|
||||||
|
|||||||
@@ -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?:\/\//, '')),
|
|
||||||
);
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user