diff --git a/apps/start/package.json b/apps/start/package.json index b9de5ab5..4ab99a63 100644 --- a/apps/start/package.json +++ b/apps/start/package.json @@ -3,7 +3,6 @@ "private": true, "type": "module", "scripts": { - "testing": "pnpm dev", "dev": "pnpm with-env vite dev --port 3000", "start_deprecated": "pnpm with-env node .output/server/index.mjs", "preview": "vite preview", diff --git a/apps/start/src/components/charts/common-bar.tsx b/apps/start/src/components/charts/common-bar.tsx index dc74bb83..12ff7224 100644 --- a/apps/start/src/components/charts/common-bar.tsx +++ b/apps/start/src/components/charts/common-bar.tsx @@ -1,3 +1,4 @@ +import { getChartColor, getChartTranslucentColor } from '@/utils/theme'; import { Bar } from 'recharts'; type Options = { @@ -11,6 +12,26 @@ export const BarWithBorder = (options: Options) => { return (props: any) => { 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 ( { width={width} height={height} stroke="none" - fill={isActive ? options.active.fill : options.fill} + fill={withActive(fill)} + rx={3} /> {value > 0 && ( )} @@ -54,3 +77,24 @@ export const BarShapeBlue = BarWithBorder({ 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), + }, + }), +); diff --git a/apps/start/src/components/clients/table/columns.tsx b/apps/start/src/components/clients/table/columns.tsx index 6a2c22d4..adc707c3 100644 --- a/apps/start/src/components/clients/table/columns.tsx +++ b/apps/start/src/components/clients/table/columns.tsx @@ -2,6 +2,7 @@ import { formatDateTime, formatTime } from '@/utils/date'; import type { ColumnDef } from '@tanstack/react-table'; import { isToday } from 'date-fns'; +import { ColumnCreatedAt } from '@/components/column-created-at'; import CopyInput from '@/components/forms/copy-input'; import { createActionColumn } from '@/components/ui/data-table/data-table-helpers'; import { DropdownMenuItem } from '@/components/ui/dropdown-menu'; @@ -30,11 +31,10 @@ export function useColumns() { { accessorKey: 'createdAt', header: 'Created at', - cell({ row }) { - const date = row.original.createdAt; - return ( -
{isToday(date) ? formatTime(date) : formatDateTime(date)}
- ); + size: ColumnCreatedAt.size, + cell: ({ row }) => { + const item = row.original; + return {item.createdAt}; }, }, createActionColumn(({ row }) => { diff --git a/apps/start/src/components/column-created-at.tsx b/apps/start/src/components/column-created-at.tsx new file mode 100644 index 00000000..92fd22cb --- /dev/null +++ b/apps/start/src/components/column-created-at.tsx @@ -0,0 +1,18 @@ +import { formatDateTime, timeAgo } from '@/utils/date'; + +export function ColumnCreatedAt({ children }: { children: Date | string }) { + return ( +
+
+ {formatDateTime( + typeof children === 'string' ? new Date(children) : children, + )} +
+
+ {timeAgo(typeof children === 'string' ? new Date(children) : children)} +
+
+ ); +} + +ColumnCreatedAt.size = 150; diff --git a/apps/start/src/components/events/event-listener.tsx b/apps/start/src/components/events/event-listener.tsx index de5e5ed9..defabb7d 100644 --- a/apps/start/src/components/events/event-listener.tsx +++ b/apps/start/src/components/events/event-listener.tsx @@ -8,7 +8,8 @@ import { useDebounceState } from '@/hooks/use-debounce-state'; import useWS from '@/hooks/use-ws'; 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'; export default function EventListener({ @@ -16,13 +17,24 @@ export default function EventListener({ }: { onRefresh: () => void; }) { + const params = useParams({ + strict: false, + }); const { projectId } = useAppParams(); const counter = useDebounceState(0, 1000); - - useWS( + useWS( `/live/events/${projectId}`, (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); } }, diff --git a/apps/start/src/components/events/table/columns.tsx b/apps/start/src/components/events/table/columns.tsx index a1725ae4..f801d221 100644 --- a/apps/start/src/components/events/table/columns.tsx +++ b/apps/start/src/components/events/table/columns.tsx @@ -3,10 +3,11 @@ import { ProjectLink } from '@/components/links'; import { SerieIcon } from '@/components/report-chart/common/serie-icon'; import { useNumber } from '@/hooks/use-numer-formatter'; import { pushModal } from '@/modals'; -import { formatDateTime, formatTimeAgoOrDateTime } from '@/utils/date'; +import { formatDateTime, formatTimeAgoOrDateTime, timeAgo } from '@/utils/date'; import { getProfileName } from '@/utils/getters'; import type { ColumnDef } from '@tanstack/react-table'; +import { ColumnCreatedAt } from '@/components/column-created-at'; import { KeyValueGrid } from '@/components/ui/key-value-grid'; import type { IServiceEvent } from '@openpanel/db'; @@ -16,19 +17,10 @@ export function useColumns() { { accessorKey: 'createdAt', header: 'Created at', - size: 140, + size: ColumnCreatedAt.size, cell: ({ row }) => { const session = row.original; - return ( -
-
- {formatDateTime(session.createdAt)} -
-
- {formatTimeAgoOrDateTime(session.createdAt)} -
-
- ); + return {session.createdAt}; }, }, { diff --git a/apps/start/src/components/events/table/index.tsx b/apps/start/src/components/events/table/index.tsx index ac4e1742..af06e9f7 100644 --- a/apps/start/src/components/events/table/index.tsx +++ b/apps/start/src/components/events/table/index.tsx @@ -1,6 +1,8 @@ import { FullPageEmptyState } from '@/components/full-page-empty-state'; -import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons'; -import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer'; +import { + OverviewFilterButton, + OverviewFiltersButtons, +} from '@/components/overview/filters/overview-filters-buttons'; import { Skeleton } from '@/components/skeleton'; import { Button } from '@/components/ui/button'; 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 { pushModal } from '@/modals'; import type { RouterInputs, RouterOutputs } from '@/trpc/client'; -import { arePropsEqual } from '@/utils/are-props-equal'; import { cn } from '@/utils/cn'; import type { IServiceEvent } from '@openpanel/db'; import type { UseInfiniteQueryResult } from '@tanstack/react-query'; import type { Table } 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 type { TRPCInfiniteData } from '@trpc/tanstack-react-query'; import { format } from 'date-fns'; -import throttle from 'lodash.throttle'; -import { CalendarIcon, Loader2Icon } from 'lucide-react'; +import { CalendarIcon, FilterIcon, Loader2Icon } from 'lucide-react'; import { parseAsIsoDateTime, useQueryState } from 'nuqs'; 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 { useLocalStorage } from 'usehooks-ts'; import EventListener from '../event-listener'; import { useColumns } from './columns'; @@ -328,11 +325,7 @@ function EventsTableToolbar({ ? `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d')}` : 'Date range'} - + diff --git a/apps/start/src/components/notifications/table/columns.tsx b/apps/start/src/components/notifications/table/columns.tsx index 00129e33..ac97ed26 100644 --- a/apps/start/src/components/notifications/table/columns.tsx +++ b/apps/start/src/components/notifications/table/columns.tsx @@ -2,6 +2,7 @@ import { formatDateTime, formatTime } from '@/utils/date'; import type { ColumnDef } from '@tanstack/react-table'; import { isToday } from 'date-fns'; +import { ColumnCreatedAt } from '@/components/column-created-at'; import { ProjectLink } from '@/components/links'; import { SerieIcon } from '@/components/report-chart/common/serie-icon'; import { createHeaderColumn } from '@/components/ui/data-table/data-table-helpers'; @@ -162,14 +163,10 @@ export function useColumns() { { accessorKey: 'createdAt', header: 'Created at', - cell({ row }) { - const date = row.original.createdAt; - if (!date) { - return null; - } - return ( -
{isToday(date) ? formatTime(date) : formatDateTime(date)}
- ); + size: ColumnCreatedAt.size, + cell: ({ row }) => { + const item = row.original; + return {item.createdAt}; }, filterFn: 'isWithinRange', meta: { diff --git a/apps/start/src/components/overview/filters/overview-filters-buttons.tsx b/apps/start/src/components/overview/filters/overview-filters-buttons.tsx index cbffb171..27a27741 100644 --- a/apps/start/src/components/overview/filters/overview-filters-buttons.tsx +++ b/apps/start/src/components/overview/filters/overview-filters-buttons.tsx @@ -3,10 +3,12 @@ import { useEventQueryFilters, useEventQueryNamesFilter, } from '@/hooks/use-event-query-filters'; +import { pushModal } from '@/modals'; +import type { OverviewFiltersProps } from '@/modals/overview-filters'; import { getPropertyLabel } from '@/translations/properties'; import { cn } from '@/utils/cn'; import { operators } from '@openpanel/constants'; -import { X } from 'lucide-react'; +import { FilterIcon, X } from 'lucide-react'; import type { Options as NuqsOptions } from 'nuqs'; interface OverviewFiltersButtonsProps { @@ -14,6 +16,23 @@ interface OverviewFiltersButtonsProps { nuqsOptions?: NuqsOptions; } +export function OverviewFilterButton(props: OverviewFiltersProps) { + return ( + + ); +} + export function OverviewFiltersButtons({ className, nuqsOptions, diff --git a/apps/start/src/components/overview/filters/overview-filters-drawer-content.tsx b/apps/start/src/components/overview/filters/overview-filters-drawer-content.tsx deleted file mode 100644 index 1c9f03e1..00000000 --- a/apps/start/src/components/overview/filters/overview-filters-drawer-content.tsx +++ /dev/null @@ -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 ( -
- - Overview filters - - -
-
- - {enableEventsFilter && ( - !excludePropertyFilter(item.name), - )} - placeholder="Select event" - maxDisplayItems={2} - /> - )} - { - 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" - /> -
- {filters - .filter((filter) => filter.value[0] !== null) - .map((filter) => { - return mode === 'events' ? ( - { - 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; - })} -
-
- ); -} - -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 ( -
-
{filter.name}
- setFilter(filter.name, value, filter.operator)} - placeholder={'Select a value'} - items={values.map((value) => ({ - value, - label: value, - }))} - value={String(filter.value[0] ?? '')} - /> - -
- ); -} - -export function FilterOptionProfile({ - setFilter, - projectId, - ...filter -}: IChartEventFilter & { - projectId: string; - setFilter: ( - name: string, - value: IChartEventFilterValue, - operator: IChartEventFilterOperator, - ) => void; -}) { - const values = useProfileValues(projectId, filter.name); - - return ( -
-
{filter.name}
- setFilter(filter.name, value, filter.operator)} - placeholder={'Select a value'} - items={values.map((value) => ({ - value, - label: value, - }))} - value={String(filter.value[0] ?? '')} - /> - -
- ); -} diff --git a/apps/start/src/components/overview/filters/overview-filters-drawer.tsx b/apps/start/src/components/overview/filters/overview-filters-drawer.tsx deleted file mode 100644 index 8f94f9bb..00000000 --- a/apps/start/src/components/overview/filters/overview-filters-drawer.tsx +++ /dev/null @@ -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 ( - - - - - - - - - ); -} diff --git a/apps/start/src/components/overview/overview-interval.tsx b/apps/start/src/components/overview/overview-interval.tsx index ba4984cc..aa0b7cf1 100644 --- a/apps/start/src/components/overview/overview-interval.tsx +++ b/apps/start/src/components/overview/overview-interval.tsx @@ -4,46 +4,21 @@ import { isMinuteIntervalEnabledByRange, } from '@openpanel/constants'; import { ClockIcon } from 'lucide-react'; +import { ReportInterval } from '../report/ReportInterval'; import { Combobox } from '../ui/combobox'; export function OverviewInterval() { - const { interval, setInterval, range } = useOverviewOptions(); + const { interval, setInterval, range, startDate, endDate } = + useOverviewOptions(); return ( - { - setInterval(value); - }} - value={interval} - items={[ - { - value: 'minute', - label: 'Minute', - disabled: !isMinuteIntervalEnabledByRange(range), - }, - { - value: 'hour', - label: 'Hour', - disabled: !isHourIntervalEnabledByRange(range), - }, - { - value: 'day', - label: 'Day', - }, - { - value: 'week', - label: 'Week', - }, - { - value: 'month', - label: 'Month', - disabled: - range === 'today' || range === 'lastHour' || range === '30min', - }, - ]} + ); } diff --git a/apps/start/src/components/profiles/table/columns.tsx b/apps/start/src/components/profiles/table/columns.tsx index cef783d8..40cbf424 100644 --- a/apps/start/src/components/profiles/table/columns.tsx +++ b/apps/start/src/components/profiles/table/columns.tsx @@ -8,6 +8,7 @@ import { isToday } from 'date-fns'; import type { IServiceProfile } from '@openpanel/db'; +import { ColumnCreatedAt } from '@/components/column-created-at'; import { ProfileAvatar } from '../profile-avatar'; export function useColumns(type: 'profiles' | 'power-users') { @@ -100,17 +101,10 @@ export function useColumns(type: 'profiles' | 'power-users') { { accessorKey: 'createdAt', header: 'Last seen', + size: ColumnCreatedAt.size, cell: ({ row }) => { - const profile = row.original; - return ( - -
- {isToday(profile.createdAt) - ? formatTime(profile.createdAt) - : formatDateTime(profile.createdAt)} -
-
- ); + const item = row.original; + return {item.createdAt}; }, }, ]; diff --git a/apps/start/src/components/report-chart/area/chart.tsx b/apps/start/src/components/report-chart/area/chart.tsx index cbe20c4e..155d28b8 100644 --- a/apps/start/src/components/report-chart/area/chart.tsx +++ b/apps/start/src/components/report-chart/area/chart.tsx @@ -129,7 +129,7 @@ export function Chart({ data }: Props) { - + } + > {rechartData.map((item, index) => ( - + ))} @@ -340,22 +348,30 @@ export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) { ); } +type Hej = RouterOutputs['chart']['funnel']['current']; + const { Tooltip, TooltipProvider } = createChartTooltip< RechartData, - Record ->(({ data: dataArray }) => { + { + data: RouterOutputs['chart']['funnel']['current']; + } +>(({ data: dataArray, context, ...props }) => { const data = dataArray[0]!; const number = useNumber(); const variants = Object.keys(data).filter((key) => key.startsWith('step:data:'), ) as `step:data:${number}`[]; + const index = context.data[0].steps.findIndex( + (step) => step.event.id === (data as any).id, + ); + return ( <>
{data.name}
- {variants.map((key, index) => { + {variants.map((key) => { const variant = data[key]; const prevVariant = data[`prev_${key}`]; if (!variant?.step) { diff --git a/apps/start/src/components/report-chart/line/chart.tsx b/apps/start/src/components/report-chart/line/chart.tsx index a2fae48a..0e4a25c3 100644 --- a/apps/start/src/components/report-chart/line/chart.tsx +++ b/apps/start/src/components/report-chart/line/chart.tsx @@ -149,7 +149,7 @@ export function Chart({ data }: Props) { dispatch(changeInterval(newInterval))} range={report.range} chartType={report.chartType} + startDate={report.startDate} + endDate={report.endDate} /> diff --git a/apps/start/src/components/report/ReportInterval.tsx b/apps/start/src/components/report/ReportInterval.tsx index b3920842..216892df 100644 --- a/apps/start/src/components/report/ReportInterval.tsx +++ b/apps/start/src/components/report/ReportInterval.tsx @@ -1,4 +1,3 @@ -import { useDispatch, useSelector } from '@/redux'; import { ClockIcon } from 'lucide-react'; import { @@ -8,6 +7,7 @@ import { import { cn } from '@/utils/cn'; import type { IChartRange, IChartType, IInterval } from '@openpanel/validation'; +import { differenceInDays, isSameDay } from 'date-fns'; import { Button } from '../ui/button'; import { CommandShortcut } from '../ui/command'; import { @@ -20,7 +20,6 @@ import { DropdownMenuShortcut, DropdownMenuTrigger, } from '../ui/dropdown-menu'; -import { changeInterval } from './reportSlice'; interface ReportIntervalProps { className?: string; @@ -28,6 +27,8 @@ interface ReportIntervalProps { onChange: (range: IInterval) => void; chartType: IChartType; range: IChartRange; + startDate?: string | null; + endDate?: string | null; } export function ReportInterval({ className, @@ -35,6 +36,8 @@ export function ReportInterval({ onChange, chartType, range, + startDate, + endDate, }: ReportIntervalProps) { if ( chartType !== 'linear' && @@ -47,6 +50,11 @@ export function ReportInterval({ return null; } + let isHourIntervalEnabled = isHourIntervalEnabledByRange(range); + if (startDate && endDate && range === 'custom') { + isHourIntervalEnabled = differenceInDays(endDate, startDate) <= 4; + } + const items = [ { value: 'minute', @@ -56,7 +64,7 @@ export function ReportInterval({ { value: 'hour', label: 'Hour', - disabled: !isHourIntervalEnabledByRange(range), + disabled: !isHourIntervalEnabled, }, { value: 'day', diff --git a/apps/start/src/components/report/sidebar/PropertiesCombobox.tsx b/apps/start/src/components/report/sidebar/PropertiesCombobox.tsx index 20fa2c15..99637fcf 100644 --- a/apps/start/src/components/report/sidebar/PropertiesCombobox.tsx +++ b/apps/start/src/components/report/sidebar/PropertiesCombobox.tsx @@ -24,6 +24,8 @@ interface PropertiesComboboxProps { label: string; description: string; }) => void; + exclude?: string[]; + mode?: 'events' | 'profile'; } function SearchHeader({ @@ -56,6 +58,8 @@ export function PropertiesCombobox({ event, children, onSelect, + mode, + exclude = [], }: PropertiesComboboxProps) { const { projectId } = useAppParams(); const [open, setOpen] = useState(false); @@ -69,20 +73,35 @@ export function PropertiesCombobox({ useEffect(() => { 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 const profileActions = properties - .filter((property) => property.startsWith('profile')) + .filter( + (property) => + property.startsWith('profile') && shouldShowProperty(property), + ) .map((property) => ({ value: property, label: property.split('.').pop() ?? property, description: property.split('.').slice(0, -1).join('.'), })); const eventActions = properties - .filter((property) => !property.startsWith('profile')) + .filter( + (property) => + !property.startsWith('profile') && shouldShowProperty(property), + ) .map((property) => ({ value: property, label: property.split('.').pop() ?? property, @@ -142,7 +161,9 @@ export function PropertiesCombobox({ return (
handleStateChange('index')} + onBack={ + mode === undefined ? () => handleStateChange('index') : undefined + } onSearch={setSearch} value={search} /> diff --git a/apps/start/src/components/sessions/table/columns.tsx b/apps/start/src/components/sessions/table/columns.tsx index 53302198..33f11bc7 100644 --- a/apps/start/src/components/sessions/table/columns.tsx +++ b/apps/start/src/components/sessions/table/columns.tsx @@ -3,6 +3,7 @@ import { SerieIcon } from '@/components/report-chart/common/serie-icon'; import { formatDateTime, formatTimeAgoOrDateTime } from '@/utils/date'; import type { ColumnDef } from '@tanstack/react-table'; +import { ColumnCreatedAt } from '@/components/column-created-at'; import { getProfileName } from '@/utils/getters'; import { round } from '@openpanel/common'; import type { IServiceSession } from '@openpanel/db'; @@ -29,19 +30,10 @@ export function useColumns() { { accessorKey: 'createdAt', header: 'Started', - size: 140, + size: ColumnCreatedAt.size, cell: ({ row }) => { - const session = row.original; - return ( -
-
- {formatDateTime(session.createdAt)} -
-
- {formatTimeAgoOrDateTime(session.createdAt)} -
-
- ); + const item = row.original; + return {item.createdAt}; }, }, { diff --git a/apps/start/src/components/settings/invites/columns.tsx b/apps/start/src/components/settings/invites/columns.tsx index 36264ead..d335a1cf 100644 --- a/apps/start/src/components/settings/invites/columns.tsx +++ b/apps/start/src/components/settings/invites/columns.tsx @@ -6,6 +6,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import type { ColumnDef, Row } from '@tanstack/react-table'; import { toast } from 'sonner'; +import { ColumnCreatedAt } from '@/components/column-created-at'; import { createActionColumn } from '@/components/ui/data-table/data-table-helpers'; import type { RouterOutputs } from '@/trpc/client'; import { clipboard } from '@/utils/clipboard'; @@ -39,13 +40,11 @@ export function useColumns(): ColumnDef< { accessorKey: 'createdAt', header: 'Created', - cell: ({ row }) => ( - - {new Date(row.original.createdAt).toLocaleDateString()} - - ), + size: ColumnCreatedAt.size, + cell: ({ row }) => { + const item = row.original; + return {item.createdAt}; + }, meta: { label: 'Created', }, diff --git a/apps/start/src/components/settings/members/columns.tsx b/apps/start/src/components/settings/members/columns.tsx index 57ed5fda..91572566 100644 --- a/apps/start/src/components/settings/members/columns.tsx +++ b/apps/start/src/components/settings/members/columns.tsx @@ -5,6 +5,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import type { ColumnDef } from '@tanstack/react-table'; import { toast } from 'sonner'; +import { ColumnCreatedAt } from '@/components/column-created-at'; import { Badge } from '@/components/ui/badge'; import { createActionColumn } from '@/components/ui/data-table/data-table-helpers'; import { pushModal } from '@/modals'; @@ -52,13 +53,11 @@ export function useColumns() { { accessorKey: 'createdAt', header: 'Created', - cell: ({ row }) => ( - - {new Date(row.original.createdAt).toLocaleDateString()} - - ), + size: ColumnCreatedAt.size, + cell: ({ row }) => { + const item = row.original; + return {item.createdAt}; + }, meta: { label: 'Created', }, diff --git a/apps/start/src/components/ui/table.tsx b/apps/start/src/components/ui/table.tsx index 04e90c0d..cbd7b7d6 100644 --- a/apps/start/src/components/ui/table.tsx +++ b/apps/start/src/components/ui/table.tsx @@ -69,7 +69,7 @@ function TableRow({ className, ...props }: React.ComponentProps<'tr'>) { filter.value[0] !== null); + return ( + + +
+ + {enableEventsFilter && ( + + )} +
+
+
+ {selectedFilters.map((filter) => { + return ( + { + setFilter(filter.name, [], filter.operator); + }} + onChangeValue={(value) => { + setFilter(filter.name, value, filter.operator); + }} + onChangeOperator={(operator) => { + setFilter(filter.name, filter.value, operator); + }} + /> + ); + })} +
+ { + setFilter(action.value, [], 'is'); + }} + > + {(setOpen) => ( + + )} + +
+
+ ); +} + +export function FilterOptionProfile({ + setFilter, + projectId, + ...filter +}: IChartEventFilter & { + projectId: string; + setFilter: ( + name: string, + value: IChartEventFilterValue, + operator: IChartEventFilterOperator, + ) => void; +}) { + const values = useProfileValues(projectId, filter.name); + + return ( +
+
{filter.name}
+ setFilter(filter.name, value, filter.operator)} + placeholder={'Select a value'} + items={values.map((value) => ({ + value, + label: value, + }))} + value={String(filter.value[0] ?? '')} + /> + +
+ ); +} diff --git a/apps/start/src/modals/share-overview-modal.tsx b/apps/start/src/modals/share-overview-modal.tsx index c705035b..5ee503d2 100644 --- a/apps/start/src/modals/share-overview-modal.tsx +++ b/apps/start/src/modals/share-overview-modal.tsx @@ -3,7 +3,7 @@ import { Button } from '@/components/ui/button'; import { useAppParams } from '@/hooks/use-app-params'; import { handleError } from '@/integrations/trpc/react'; 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 { toast } from 'sonner'; import type { z } from 'zod'; @@ -22,6 +22,7 @@ type IForm = z.infer; export default function ShareOverviewModal() { const { projectId, organizationId } = useAppParams(); + const navigate = useNavigate(); const { register, handleSubmit } = useForm({ resolver: zodResolver(validator), @@ -44,6 +45,16 @@ export default function ShareOverviewModal() { description: `Your overview is now ${ res.public ? 'public' : 'private' }`, + action: { + label: 'View', + onClick: () => + navigate({ + to: '/share/overview/$shareId', + params: { + shareId: res.id, + }, + }), + }, }); popModal(); }, diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.tsx index fa9e40dc..25837b31 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.tsx @@ -1,5 +1,7 @@ -import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons'; -import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer'; +import { + OverviewFilterButton, + OverviewFiltersButtons, +} from '@/components/overview/filters/overview-filters-buttons'; import { LiveCounter } from '@/components/overview/live-counter'; import { OverviewInterval } from '@/components/overview/overview-interval'; import OverviewMetrics from '@/components/overview/overview-metrics'; @@ -35,7 +37,7 @@ function ProjectDashboard() {
- +
diff --git a/apps/start/src/routes/_app.$organizationId.$projectId_.events._tabs.conversions.tsx b/apps/start/src/routes/_app.$organizationId.$projectId_.events._tabs.conversions.tsx index 5125067e..49781aa4 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId_.events._tabs.conversions.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId_.events._tabs.conversions.tsx @@ -1,4 +1,5 @@ import { EventsTable } from '@/components/events/table'; +import { useEventQueryNamesFilter } from '@/hooks/use-event-query-filters'; import { useTRPC } from '@/integrations/trpc/react'; import { useInfiniteQuery } from '@tanstack/react-query'; import { createFileRoute } from '@tanstack/react-router'; @@ -18,12 +19,14 @@ function Component() { parseAsIsoDateTime, ); const [endDate, setEndDate] = useQueryState('endDate', parseAsIsoDateTime); + const [eventNames] = useEventQueryNamesFilter(); const query = useInfiniteQuery( trpc.event.conversions.infiniteQueryOptions( { projectId, startDate: startDate || undefined, endDate: endDate || undefined, + events: eventNames, }, { getNextPageParam: (lastPage) => lastPage.meta.next, diff --git a/apps/start/src/routes/_app.$organizationId.$projectId_.events._tabs.stats.tsx b/apps/start/src/routes/_app.$organizationId.$projectId_.events._tabs.stats.tsx index 867271cc..928e52c5 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId_.events._tabs.stats.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId_.events._tabs.stats.tsx @@ -1,5 +1,7 @@ -import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons'; -import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer'; +import { + OverviewFilterButton, + OverviewFiltersButtons, +} from '@/components/overview/filters/overview-filters-buttons'; import { ReportChartShortcut } from '@/components/report-chart/shortcut'; import { Widget, WidgetBody, WidgetHead } from '@/components/widget'; import { @@ -34,11 +36,7 @@ function Component() { return (
- +
diff --git a/apps/start/src/routes/_app.$organizationId.$projectId_.pages.tsx b/apps/start/src/routes/_app.$organizationId.$projectId_.pages.tsx index 0b2f6ad3..11b6e3e7 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId_.pages.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId_.pages.tsx @@ -1,5 +1,5 @@ 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 { OverviewRange } from '@/components/overview/overview-range'; import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; @@ -79,7 +79,7 @@ function Component() { - + [] = [ { accessorKey: 'createdAt', header: createHeaderColumn('Created at'), - cell({ row }) { - const date = row.original.createdAt; - return formatDate(date); + size: ColumnCreatedAt.size, + cell: ({ row }) => { + const item = row.original; + return {item.createdAt}; }, filterFn: 'isWithinRange', sortingFn: 'datetime', diff --git a/apps/start/src/utils/date.ts b/apps/start/src/utils/date.ts index 36163b07..46f6d638 100644 --- a/apps/start/src/utils/date.ts +++ b/apps/start/src/utils/date.ts @@ -32,6 +32,8 @@ export function formatDateTime(date: Date) { hour: '2-digit', minute: '2-digit', hour12: false, + year: + date.getFullYear() === new Date().getFullYear() ? undefined : 'numeric', }).format(date); return `${datePart}, ${timePart}`; diff --git a/apps/start/src/utils/theme.ts b/apps/start/src/utils/theme.ts index 419bedd1..c70f4931 100644 --- a/apps/start/src/utils/theme.ts +++ b/apps/start/src/utils/theme.ts @@ -7,27 +7,25 @@ // export const theme = resolvedTailwindConfig.theme as Record; const chartColors = [ - '#2563EB', - '#ff7557', - '#7fe1d8', - '#f8bc3c', - '#b3596e', - '#72bef4', - '#ffb27a', - '#0f7ea0', - '#3ba974', - '#febbb2', - '#cb80dc', - '#5cb7af', - '#7856ff', + { main: '#2563EB', translucent: 'rgba(37, 99, 235, 0.1)' }, + { main: '#ff7557', translucent: 'rgba(255, 117, 87, 0.1)' }, + { main: '#7fe1d8', translucent: 'rgba(127, 225, 216, 0.1)' }, + { main: '#f8bc3c', translucent: 'rgba(248, 188, 60, 0.1)' }, + { main: '#b3596e', translucent: 'rgba(179, 89, 110, 0.1)' }, + { main: '#72bef4', translucent: 'rgba(114, 190, 244, 0.1)' }, + { main: '#ffb27a', translucent: 'rgba(255, 178, 122, 0.1)' }, + { main: '#0f7ea0', translucent: 'rgba(15, 126, 160, 0.1)' }, + { main: '#3ba974', translucent: 'rgba(59, 169, 116, 0.1)' }, + { main: '#febbb2', translucent: 'rgba(254, 187, 178, 0.1)' }, + { main: '#cb80dc', translucent: 'rgba(203, 128, 220, 0.1)' }, + { main: '#5cb7af', translucent: 'rgba(92, 183, 175, 0.1)' }, + { main: '#7856ff', translucent: 'rgba(120, 86, 255, 0.1)' }, ]; export function getChartColor(index: number): string { - // const colors = theme?.colors ?? {}; - // const chartColors: string[] = Object.keys(colors) - // .filter((key) => key.startsWith('chart-')) - // .map((key) => colors[key]) - // .filter((item): item is string => typeof item === 'string'); - - return chartColors[index % chartColors.length]!; + return chartColors[index % chartColors.length]!.main; +} + +export function getChartTranslucentColor(index: number): string { + return chartColors[index % chartColors.length]!.translucent; } diff --git a/packages/db/src/clickhouse/query-builder.ts b/packages/db/src/clickhouse/query-builder.ts index fd2afa2d..351ef6b1 100644 --- a/packages/db/src/clickhouse/query-builder.ts +++ b/packages/db/src/clickhouse/query-builder.ts @@ -680,12 +680,10 @@ clix.toStartOf = (node: string, interval: IInterval, timezone?: string) => { return `toStartOfDay(${node})`; } case 'week': { - // Does not respect timezone settings (session_timezone) so we need to pass it manually - return `toStartOfWeek(${node}${timezone ? `, 1, '${timezone}'` : ''})`; + return `toStartOfWeek(toDateTime(${node}))`; } case 'month': { - // Does not respect timezone settings (session_timezone) so we need to pass it manually - return `toStartOfMonth(${node}${timezone ? `, '${timezone}'` : ''})`; + return `toStartOfMonth(toDateTime(${node}))`; } } }; diff --git a/packages/db/src/services/event.service.ts b/packages/db/src/services/event.service.ts index 195fc887..a182b69b 100644 --- a/packages/db/src/services/event.service.ts +++ b/packages/db/src/services/event.service.ts @@ -1,4 +1,4 @@ -import { path, assocPath, last, mergeDeepRight } from 'ramda'; +import { path, assocPath, last, mergeDeepRight, uniq } from 'ramda'; import sqlstring from 'sqlstring'; import { v4 as uuid } from 'uuid'; @@ -561,6 +561,15 @@ export async function getEventList(options: GetEventListOptions) { ...sb.where, ...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 = @@ -622,6 +631,15 @@ export async function getEventsCount({ ...sb.where, ...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 }>( @@ -701,6 +719,7 @@ class EventService { select, limit, orderBy, + filters, }: { projectId: string; profileId?: string; @@ -715,7 +734,14 @@ class EventService { }; limit?: number; 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) .select< Partial & { @@ -744,6 +770,12 @@ class EventService { ]) .from('events e') .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) // Do not limit if profileId, we will limit later since we need the "correct" profileId .when(!!limit && !profileId, (q) => q.limit(limit!)) @@ -941,6 +973,7 @@ class EventService { profileId, limit, orderBy: 'created_at', + filters, select: { event: { deviceId: true, diff --git a/packages/db/src/services/overview.service.ts b/packages/db/src/services/overview.service.ts index e0897c17..38ef8cc7 100644 --- a/packages/db/src/services/overview.service.ts +++ b/packages/db/src/services/overview.service.ts @@ -200,6 +200,12 @@ export class OverviewService { ]) .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) .with('session_agg', sessionAggQuery) .with( @@ -207,14 +213,14 @@ export class OverviewService { clix(this.client, timezone) .select(['bounce_rate']) .from('session_agg') - .where('date', '=', clix.datetime('1970-01-01 00:00:00')), + .where('date', '=', rollupDate), ) .with( 'daily_stats', clix(this.client, timezone) .select(['date', 'bounce_rate']) .from('session_agg') - .where('date', '!=', clix.datetime('1970-01-01 00:00:00')), + .where('date', '!=', rollupDate), ) .with('overall_unique_visitors', overallUniqueVisitorsQuery) .select<{ diff --git a/packages/trpc/src/routers/event.ts b/packages/trpc/src/routers/event.ts index 0506fe6c..5b5b2d52 100644 --- a/packages/trpc/src/routers/event.ts +++ b/packages/trpc/src/routers/event.ts @@ -172,7 +172,7 @@ export const eventRouter = createTRPCRouter({ data: items, meta: { next: - items.length === 50 && lastItem + items.length > 0 && lastItem ? lastItem.createdAt.toISOString() : null, }, @@ -190,12 +190,19 @@ export const eventRouter = createTRPCRouter({ cursor: z.string().optional(), startDate: z.date().optional(), endDate: z.date().optional(), + events: z.array(z.string()).optional(), }), ) .query(async ({ input }) => { 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 { data: [], meta: { @@ -220,7 +227,7 @@ export const eventRouter = createTRPCRouter({ origin: true, }, 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, meta: { next: - items.length === 50 && lastItem + items.length > 0 && lastItem ? lastItem.createdAt.toISOString() : null, }, @@ -354,15 +361,11 @@ export const eventRouter = createTRPCRouter({ ) .query(async ({ input }) => { 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, - )} 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) => - a.origin - .replace(/https?:\/\//, '') - .localeCompare(b.origin.replace(/https?:\/\//, '')), - ); + return res; }), });