diff --git a/.vscode/settings.json b/.vscode/settings.json index a616cf8b..7fcbc553 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -22,7 +22,8 @@ "editor.formatOnSave": true, "tailwindCSS.experimental.classRegex": [ ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], - ["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"] + ["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"], + ["\\b\\w+ClassName\\s*=\\s*[\"'`]([^\"'`]*)[\"'`]", "[\"'`]([^\"'`]*)[\"'`]" ] ], "typescript.enablePromptUseWorkspaceTsdk": true, "typescript.tsdk": "node_modules/typescript/lib", diff --git a/apps/api/src/bots/index.ts b/apps/api/src/bots/index.ts index bae1873f..705d7def 100644 --- a/apps/api/src/bots/index.ts +++ b/apps/api/src/bots/index.ts @@ -14,6 +14,6 @@ export function isBot(ua: string) { return { name: res.name, - type: res.category || 'Unknown', + type: 'category' in res ? res.category : 'Unknown', }; } diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 708d8518..5732707f 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -18,8 +18,8 @@ "@openpanel/common": "workspace:^", "@openpanel/constants": "workspace:^", "@openpanel/db": "workspace:^", - "@openpanel/nextjs": "1.0.3", "@openpanel/integrations": "workspace:^", + "@openpanel/nextjs": "1.0.3", "@openpanel/queue": "workspace:^", "@openpanel/sdk-info": "workspace:^", "@openpanel/validation": "workspace:^", @@ -72,7 +72,7 @@ "lodash.isequal": "^4.5.0", "lodash.throttle": "^4.1.1", "lottie-react": "^2.4.0", - "lucide-react": "^0.331.0", + "lucide-react": "^0.451.0", "mathjs": "^12.3.2", "mitt": "^3.0.1", "next": "14.2.1", diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/dashboards/list-dashboards/list-dashboards.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/dashboards/list-dashboards/list-dashboards.tsx index 3bf9dbd4..6dfeb3d9 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/dashboards/list-dashboards/list-dashboards.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/dashboards/list-dashboards/list-dashboards.tsx @@ -12,8 +12,10 @@ import { AreaChartIcon, BarChart3Icon, BarChartHorizontalIcon, + ChartScatterIcon, ConeIcon, Globe2Icon, + Grid3X3Icon, HashIcon, LayoutPanelTopIcon, LineChartIcon, @@ -110,6 +112,7 @@ export function ListDashboards({ dashboards }: ListDashboardsProps) { histogram: BarChart3Icon, funnel: ConeIcon, area: AreaChartIcon, + retention: ChartScatterIcon, }[report.chartType]; return ( diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-menu.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-menu.tsx index ea6fbd96..06902e9d 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-menu.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-menu.tsx @@ -1,11 +1,10 @@ 'use client'; import { Button } from '@/components/ui/button'; -import { useAppParams } from '@/hooks/useAppParams'; import { pushModal } from '@/modals'; import { cn } from '@/utils/cn'; -import { useUser } from '@clerk/nextjs'; import { + ChartLineIcon, GanttChartIcon, Globe2Icon, LayersIcon, @@ -15,11 +14,10 @@ import { UsersIcon, WallpaperIcon, } from 'lucide-react'; -import type { LucideProps } from 'lucide-react'; -import Link from 'next/link'; +import type { LucideIcon } from 'lucide-react'; import { usePathname } from 'next/navigation'; -import { useEffect } from 'react'; +import { ProjectLink } from '@/components/links'; import type { IServiceDashboards } from '@openpanel/db'; function LinkWithIcon({ @@ -30,7 +28,7 @@ function LinkWithIcon({ className, }: { href: string; - icon: React.ElementType; + icon: LucideIcon; label: React.ReactNode; active?: boolean; className?: string; @@ -38,7 +36,7 @@ function LinkWithIcon({ const pathname = usePathname(); const active = overrideActive || href === pathname; return ( -
{label}
- + ); } @@ -56,66 +54,34 @@ interface LayoutMenuProps { dashboards: IServiceDashboards; } export default function LayoutMenu({ dashboards }: LayoutMenuProps) { - const { user } = useUser(); - - const params = useAppParams(); - const hasProjectId = - params.projectId && - params.projectId !== 'null' && - params.projectId !== 'undefined'; - const projectId = hasProjectId - ? params.projectId - : (user?.unsafeMetadata.projectId as string); - - useEffect(() => { - if (hasProjectId) { - user?.update({ - unsafeMetadata: { - ...user.unsafeMetadata, - projectId: params.projectId, - }, - }); - } - }, [params.projectId, hasProjectId]); - return ( <> - + + +
+
Create report
+
+ Visualize your events +
+
+ +
+ - - - - - - + + + + +
Your dashboards
@@ -134,7 +100,7 @@ export default function LayoutMenu({ dashboards }: LayoutMenuProps) { key={item.id} icon={LayoutPanelTopIcon} label={item.name} - href={`/${item.organizationSlug}/${item.projectId}/dashboards/${item.id}`} + href={`/dashboards/${item.id}`} /> ))}
diff --git a/apps/dashboard/src/components/overview/filters/overview-filters-drawer-content.tsx b/apps/dashboard/src/components/overview/filters/overview-filters-drawer-content.tsx index 351ba998..ddd9bc24 100644 --- a/apps/dashboard/src/components/overview/filters/overview-filters-drawer-content.tsx +++ b/apps/dashboard/src/components/overview/filters/overview-filters-drawer-content.tsx @@ -40,8 +40,8 @@ export function OverviewFiltersDrawerContent({ const { interval, range, startDate, endDate } = useOverviewOptions(); const [filters, setFilter] = useEventQueryFilters(nuqsOptions); const [event, setEvent] = useEventQueryNamesFilter(nuqsOptions); - const eventNames = useEventNames({ projectId, interval, range }); - const eventProperties = useEventProperties({ projectId, interval, range }); + const eventNames = useEventNames({ projectId }); + const eventProperties = useEventProperties({ projectId, event: event[0] }); const profileProperties = useProfileProperties(projectId); const properties = mode === 'events' ? eventProperties : profileProperties; @@ -94,8 +94,6 @@ export function OverviewFiltersDrawerContent({ eventName="screen_view" key={filter.name} filter={filter} - range={range} - interval={interval} onRemove={() => { setFilter(filter.name, [], filter.operator); }} @@ -105,8 +103,6 @@ export function OverviewFiltersDrawerContent({ onChangeOperator={(operator) => { setFilter(filter.name, filter.value, operator); }} - startDate={startDate} - endDate={endDate} /> ) : /* TODO: Implement profile filters */ null; @@ -128,13 +124,10 @@ export function FilterOptionEvent({ operator: IChartEventFilterOperator, ) => void; }) { - const { interval, range } = useOverviewOptions(); const values = usePropertyValues({ projectId, event: filter.name === 'path' ? 'screen_view' : 'session_start', property: filter.name, - interval, - range, }); return ( diff --git a/apps/dashboard/src/components/report-chart/common/axis.tsx b/apps/dashboard/src/components/report-chart/common/axis.tsx index 623279f0..c58a54db 100644 --- a/apps/dashboard/src/components/report-chart/common/axis.tsx +++ b/apps/dashboard/src/components/report-chart/common/axis.tsx @@ -25,9 +25,11 @@ export function getYAxisWidth(value: string | undefined | null) { export const useYAxisProps = ({ data, hide, + tickFormatter, }: { data: number[]; hide?: boolean; + tickFormatter?: (value: number) => string; }) => { const [width, setWidth] = useState(24); const setWidthDebounced = useDebounceFn(setWidth, 100); @@ -41,7 +43,7 @@ export const useYAxisProps = ({ tickLine: false, allowDecimals: false, tickFormatter: (value: number) => { - const tick = number.short(value); + const tick = tickFormatter ? tickFormatter(value) : number.short(value); const newWidth = getYAxisWidth(tick); ref.current.push(newWidth); setWidthDebounced(Math.max(...ref.current)); diff --git a/apps/dashboard/src/components/report-chart/common/empty.tsx b/apps/dashboard/src/components/report-chart/common/empty.tsx index 7e0510e9..e5f33702 100644 --- a/apps/dashboard/src/components/report-chart/common/empty.tsx +++ b/apps/dashboard/src/components/report-chart/common/empty.tsx @@ -1,13 +1,62 @@ -import { BirdIcon } from 'lucide-react'; +import { cn } from '@/utils/cn'; +import { + ArrowUpLeftIcon, + BirdIcon, + CornerLeftUpIcon, + Forklift, + ForkliftIcon, +} from 'lucide-react'; +import { useReportChartContext } from '../context'; + +export function ReportChartEmpty({ + title = 'No data', + children, +}: { + title?: string; + children?: React.ReactNode; +}) { + const { + isEditMode, + report: { events }, + } = useReportChartContext(); + + if (events.length === 0) { + return ( +
+
+ +
Start here
+
+ +
+ Ready when you're +
+
+ Pick atleast one event to start visualize +
+
+ ); + } -export function ReportChartEmpty() { return ( -
+
-
No data
+
{title}
+
{children}
); } diff --git a/apps/dashboard/src/components/report-chart/common/error.tsx b/apps/dashboard/src/components/report-chart/common/error.tsx index 77279caa..15842171 100644 --- a/apps/dashboard/src/components/report-chart/common/error.tsx +++ b/apps/dashboard/src/components/report-chart/common/error.tsx @@ -1,8 +1,16 @@ +import { cn } from '@/utils/cn'; import { ServerCrashIcon } from 'lucide-react'; +import { useReportChartContext } from '../context'; export function ReportChartError() { + const { isEditMode } = useReportChartContext(); return ( -
+
; +import { cn } from '@/utils/cn'; +import { AnimatePresence, motion } from 'framer-motion'; +import { + ActivityIcon, + AlarmClockIcon, + BarChart2Icon, + BarChartIcon, + ChartLineIcon, + ChartPieIcon, + LineChartIcon, + MessagesSquareIcon, + PieChartIcon, + TrendingUpIcon, +} from 'lucide-react'; +import React, { useEffect, useState } from 'react'; +import { useReportChartContext } from '../context'; + +const icons = [ + { Icon: ActivityIcon, color: 'text-chart-6' }, + { Icon: BarChart2Icon, color: 'text-chart-9' }, + { Icon: ChartLineIcon, color: 'text-chart-0' }, + { Icon: AlarmClockIcon, color: 'text-chart-1' }, + { Icon: ChartPieIcon, color: 'text-chart-2' }, + { Icon: MessagesSquareIcon, color: 'text-chart-3' }, + { Icon: BarChartIcon, color: 'text-chart-4' }, + { Icon: TrendingUpIcon, color: 'text-chart-5' }, + { Icon: PieChartIcon, color: 'text-chart-7' }, + { Icon: LineChartIcon, color: 'text-chart-8' }, +]; + +export function ReportChartLoading({ things }: { things?: boolean }) { + const { isEditMode } = useReportChartContext(); + const [currentIconIndex, setCurrentIconIndex] = React.useState(0); + const [isSlow, setSlow] = useState(false); + + React.useEffect(() => { + const interval = setInterval(() => { + setCurrentIconIndex((prevIndex) => (prevIndex + 1) % icons.length); + }, 1500); + + return () => clearInterval(interval); + }, []); + + useEffect(() => { + if (currentIconIndex >= 3) { + setSlow(true); + } + }, [currentIconIndex]); + + const { Icon, color } = icons[currentIconIndex]!; + + return ( +
+
+ + + + + + +
+ Stay calm, its coming 🙄 +
+
+
+ ); } diff --git a/apps/dashboard/src/components/report-chart/common/report-table.tsx b/apps/dashboard/src/components/report-chart/common/report-table.tsx index bd00f2a5..d0d14b20 100644 --- a/apps/dashboard/src/components/report-chart/common/report-table.tsx +++ b/apps/dashboard/src/components/report-chart/common/report-table.tsx @@ -19,6 +19,7 @@ import type { IChartData } from '@/trpc/client'; import { getChartColor } from '@/utils/theme'; import type * as React from 'react'; +import { logDependencies } from 'mathjs'; import { PreviousDiffIndicator } from './previous-diff-indicator'; import { SerieName } from './serie-name'; @@ -80,7 +81,7 @@ export function ReportTable({ ); return ( - + {serie.names.map((name, nameIndex) => { return ( @@ -140,7 +141,7 @@ export function ReportTable({ {paginate(data.series).map((serie) => { return ( - +
{number.format(serie.metrics.sum)} diff --git a/apps/dashboard/src/components/report-chart/funnel/chart.tsx b/apps/dashboard/src/components/report-chart/funnel/chart.tsx index abc21bc0..d824b352 100644 --- a/apps/dashboard/src/components/report-chart/funnel/chart.tsx +++ b/apps/dashboard/src/components/report-chart/funnel/chart.tsx @@ -3,10 +3,8 @@ import { ColorSquare } from '@/components/color-square'; import { TooltipComplete } from '@/components/tooltip-complete'; import { Progress } from '@/components/ui/progress'; -import { Widget, WidgetBody } from '@/components/widget'; import type { RouterOutputs } from '@/trpc/client'; import { cn } from '@/utils/cn'; -import { getChartColor } from '@/utils/theme'; import { AlertCircleIcon } from 'lucide-react'; import { last } from 'ramda'; @@ -70,7 +68,7 @@ export function Chart({ /> (null); @@ -48,6 +49,8 @@ export function ReportChart(props: ReportChartProps) { return ; case 'funnel': return ; + case 'retention': + return ; default: return null; } diff --git a/apps/dashboard/src/components/report-chart/line/index.tsx b/apps/dashboard/src/components/report-chart/line/index.tsx index e6c85662..09c67239 100644 --- a/apps/dashboard/src/components/report-chart/line/index.tsx +++ b/apps/dashboard/src/components/report-chart/line/index.tsx @@ -1,5 +1,6 @@ import { api } from '@/trpc/client'; +import { cn } from '@/utils/cn'; import { AspectContainer } from '../aspect-container'; import { ReportChartEmpty } from '../common/empty'; import { ReportChartError } from '../common/error'; diff --git a/apps/dashboard/src/components/report-chart/retention/chart.tsx b/apps/dashboard/src/components/report-chart/retention/chart.tsx new file mode 100644 index 00000000..0bcc901b --- /dev/null +++ b/apps/dashboard/src/components/report-chart/retention/chart.tsx @@ -0,0 +1,113 @@ +'use client'; + +import type { RouterOutputs } from '@/trpc/client'; +import { cn } from '@/utils/cn'; +import { getChartColor } from '@/utils/theme'; +import { + Area, + CartesianGrid, + ComposedChart, + ReferenceLine, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +import { average, round } from '@openpanel/common'; +import { fix } from 'mathjs'; +import { useXAxisProps, useYAxisProps } from '../common/axis'; +import { useReportChartContext } from '../context'; +import { RetentionTooltip } from './tooltip'; + +interface Props { + data: RouterOutputs['chart']['cohort']; +} + +export function Chart({ data }: Props) { + const { + report: { interval }, + isEditMode, + options: { hideXAxis, hideYAxis }, + } = useReportChartContext(); + + const xAxisProps = useXAxisProps({ interval, hide: hideXAxis }); + const yAxisProps = useYAxisProps({ + data: [100], + hide: hideYAxis, + tickFormatter: (value) => `${value}%`, + }); + const averageRow = data[0]; + const averageRetentionRate = average(averageRow?.percentages || [], true); + const rechartData = averageRow?.percentages.map((item, index, list) => ({ + days: index, + percentage: item, + value: averageRow.values[index], + sum: averageRow.sum, + })); + + return ( + <> +
+ + + + + value.toString()} + tickCount={31} + interval={0} + /> + } /> + + + + + + + + + + +
+ + ); +} diff --git a/apps/dashboard/src/components/report-chart/retention/index.tsx b/apps/dashboard/src/components/report-chart/retention/index.tsx new file mode 100644 index 00000000..9fc0e11d --- /dev/null +++ b/apps/dashboard/src/components/report-chart/retention/index.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { api } from '@/trpc/client'; + +import { AspectContainer } from '../aspect-container'; +import { ReportChartEmpty } from '../common/empty'; +import { ReportChartError } from '../common/error'; +import { ReportChartLoading } from '../common/loading'; +import { useReportChartContext } from '../context'; +import { Chart } from './chart'; +import CohortTable from './table'; + +export function ReportRetentionChart() { + const { + report: { + events, + range, + projectId, + startDate, + endDate, + criteria, + interval, + }, + isLazyLoading, + } = useReportChartContext(); + const firstEvent = (events[0]?.filters[0]?.value ?? []).map(String); + const secondEvent = (events[1]?.filters[0]?.value ?? []).map(String); + const isEnabled = firstEvent.length > 0 && secondEvent.length > 0; + const res = api.chart.cohort.useQuery( + { + firstEvent, + secondEvent, + projectId, + range, + startDate, + endDate, + criteria, + interval, + }, + { + enabled: isEnabled, + }, + ); + + if (!isEnabled) { + return ; + } + + if (isLazyLoading || res.isLoading) { + return ; + } + + if (res.isError) { + return ; + } + + if (res.data.length === 0) { + return ; + } + + return ( +
+ + + + +
+ ); +} + +function Loading() { + return ( + + + + ); +} + +function Error() { + return ( + + + + ); +} + +function Empty() { + return ( + + + + ); +} + +function Disabled() { + return ( + + + We need two events to determine the retention rate. + + + ); +} diff --git a/apps/dashboard/src/components/report-chart/retention/table.tsx b/apps/dashboard/src/components/report-chart/retention/table.tsx new file mode 100644 index 00000000..4a41d393 --- /dev/null +++ b/apps/dashboard/src/components/report-chart/retention/table.tsx @@ -0,0 +1,136 @@ +import { ProjectLink } from '@/components/links'; +import { Tooltiper } from '@/components/ui/tooltip'; +import { useNumber } from '@/hooks/useNumerFormatter'; +import type { RouterOutputs } from '@/trpc/client'; +import { cn } from '@/utils/cn'; +import { max, min, sum } from '@openpanel/common'; +import { intervals } from '@openpanel/constants'; +import type React from 'react'; +import { useReportChartContext } from '../context'; + +type CohortData = RouterOutputs['chart']['cohort']; + +type CohortTableProps = { + data: CohortData; +}; + +const CohortTable: React.FC = ({ data }) => { + const { + report: { unit, interval }, + } = useReportChartContext(); + const isPercentage = unit === '%'; + const number = useNumber(); + const highestValue = max(data.map((row) => max(row.values))); + const lowestValue = min(data.map((row) => min(row.values))); + const rowWithHigestSum = data.find( + (row) => row.sum === max(data.map((row) => row.sum)), + ); + + const getBackground = (value: number | undefined) => { + if (!value) + return { + backgroundClassName: '', + opacity: 0, + }; + + const percentage = isPercentage + ? value / 100 + : (value - lowestValue) / (highestValue - lowestValue); + const opacity = Math.max(0.05, percentage); + + return { + backgroundClassName: 'bg-highlight dark:bg-emerald-700', + opacity, + }; + }; + + const thClassName = + 'h-10 align-top pt-3 whitespace-nowrap font-semibold text-muted-foreground'; + + return ( +
+
+
+
+ + + + + + {data[0]?.values.map((column, index) => ( + + ))} + + + + {data.map((row) => { + const values = isPercentage ? row.percentages : row.values; + return ( + + + + {values.map((value, index) => { + const { opacity, backgroundClassName } = + getBackground(value); + return ( + + ); + })} + + ); + })} + +
+
+
Date
+
+
Total profiles + {index === 0 ? `< ${interval} 1` : `${interval} ${index}`} +
+
+ {row.cohort_interval} +
+
+
+ {number.format(row?.sum)} + {row.cohort_interval === + rowWithHigestSum?.cohort_interval && ' 🚀'} +
+
+
0.7 && + 'text-white [text-shadow:_0_0_3px_rgb(0_0_0_/_20%)]', + )} + > +
+
+ {number.formatWithUnit(value, unit)} + {value === highestValue && ' 🚀'} +
+
+
+
+
+
+ ); +}; + +export default CohortTable; diff --git a/apps/dashboard/src/components/report-chart/retention/tooltip.tsx b/apps/dashboard/src/components/report-chart/retention/tooltip.tsx new file mode 100644 index 00000000..00175877 --- /dev/null +++ b/apps/dashboard/src/components/report-chart/retention/tooltip.tsx @@ -0,0 +1,47 @@ +import { useNumber } from '@/hooks/useNumerFormatter'; +import type { RouterOutputs } from '@/trpc/client'; +import { useReportChartContext } from '../context'; + +type Props = { + active?: boolean; + payload?: Array<{ + payload: any; + }>; +}; +export function RetentionTooltip({ active, payload }: Props) { + const { + report: { interval }, + } = useReportChartContext(); + const number = useNumber(); + if (!active) { + return null; + } + + if (!payload?.[0]) { + return null; + } + + const { days, percentage, value, sum } = payload[0].payload; + + return ( +
+

+ {interval} {days} +

+
+ Retention Rate: + + {number.formatWithUnit(percentage, '%')} + +
+
+ Retained Users: + {number.format(value)} +
+
+ Total Users: + {number.format(sum)} +
+
+ ); +} diff --git a/apps/dashboard/src/components/report/ReportInterval.tsx b/apps/dashboard/src/components/report/ReportInterval.tsx index 453ad2ca..dd764a15 100644 --- a/apps/dashboard/src/components/report/ReportInterval.tsx +++ b/apps/dashboard/src/components/report/ReportInterval.tsx @@ -21,7 +21,8 @@ export function ReportInterval({ className }: ReportIntervalProps) { chartType !== 'linear' && chartType !== 'histogram' && chartType !== 'area' && - chartType !== 'metric' + chartType !== 'metric' && + chartType !== 'retention' ) { return null; } diff --git a/apps/dashboard/src/components/report/reportSlice.ts b/apps/dashboard/src/components/report/reportSlice.ts index f687f9e3..7d1ed18d 100644 --- a/apps/dashboard/src/components/report/reportSlice.ts +++ b/apps/dashboard/src/components/report/reportSlice.ts @@ -24,7 +24,9 @@ import type { IChartRange, IChartType, IInterval, + zCriteria, } from '@openpanel/validation'; +import type { z } from 'zod'; type InitialState = IChartProps & { dirty: boolean; @@ -53,6 +55,7 @@ const initialState: InitialState = { unit: undefined, metric: 'sum', limit: 500, + criteria: 'on_or_after', }; export const reportSlice = createSlice({ @@ -251,6 +254,18 @@ export const reportSlice = createSlice({ state.dirty = true; state.formula = action.payload; }, + + changeCriteria(state, action: PayloadAction>) { + state.dirty = true; + state.criteria = action.payload; + }, + + changeUnit(state, action: PayloadAction) { + console.log('here?!?!', action.payload); + + state.dirty = true; + state.unit = action.payload || undefined; + }, }, }); @@ -276,6 +291,8 @@ export const { resetDirty, changeFormula, changePrevious, + changeCriteria, + changeUnit, } = reportSlice.actions; export default reportSlice.reducer; diff --git a/apps/dashboard/src/components/report/sidebar/EventPropertiesCombobox.tsx b/apps/dashboard/src/components/report/sidebar/EventPropertiesCombobox.tsx index fb4cd419..0648ee62 100644 --- a/apps/dashboard/src/components/report/sidebar/EventPropertiesCombobox.tsx +++ b/apps/dashboard/src/components/report/sidebar/EventPropertiesCombobox.tsx @@ -19,14 +19,10 @@ export function EventPropertiesCombobox({ }: EventPropertiesComboboxProps) { const dispatch = useDispatch(); const { projectId } = useAppParams(); - const range = useSelector((state) => state.report.range); - const interval = useSelector((state) => state.report.interval); const properties = useEventProperties( { event: event.name, projectId, - range, - interval, }, { enabled: !!event.name, diff --git a/apps/dashboard/src/components/report/sidebar/ReportBreakdowns.tsx b/apps/dashboard/src/components/report/sidebar/ReportBreakdowns.tsx index f5ed2ca0..ede3eabd 100644 --- a/apps/dashboard/src/components/report/sidebar/ReportBreakdowns.tsx +++ b/apps/dashboard/src/components/report/sidebar/ReportBreakdowns.tsx @@ -16,14 +16,10 @@ import type { ReportEventMoreProps } from './ReportEventMore'; export function ReportBreakdowns() { const { projectId } = useAppParams(); const selectedBreakdowns = useSelector((state) => state.report.breakdowns); - const interval = useSelector((state) => state.report.interval); - const range = useSelector((state) => state.report.range); const dispatch = useDispatch(); const properties = useEventProperties({ projectId, - range, - interval, }).map((item) => ({ value: item, label: item, // {item}, diff --git a/apps/dashboard/src/components/report/sidebar/ReportEvents.tsx b/apps/dashboard/src/components/report/sidebar/ReportEvents.tsx index aec73468..02c2004e 100644 --- a/apps/dashboard/src/components/report/sidebar/ReportEvents.tsx +++ b/apps/dashboard/src/components/report/sidebar/ReportEvents.tsx @@ -1,7 +1,6 @@ 'use client'; import { ColorSquare } from '@/components/color-square'; -import { Checkbox } from '@/components/ui/checkbox'; import { Combobox } from '@/components/ui/combobox'; import { DropdownMenuComposed } from '@/components/ui/dropdown-menu'; import { Input } from '@/components/ui/input'; @@ -14,12 +13,8 @@ import { GanttChart, GanttChartIcon, Users } from 'lucide-react'; import { alphabetIds } from '@openpanel/constants'; import type { IChartEvent } from '@openpanel/validation'; -import { - addEvent, - changeEvent, - changePrevious, - removeEvent, -} from '../reportSlice'; +import { ComboboxAdvanced } from '@/components/ui/combobox-advanced'; +import { addEvent, changeEvent, removeEvent } from '../reportSlice'; import { EventPropertiesCombobox } from './EventPropertiesCombobox'; import { ReportEventMore } from './ReportEventMore'; import type { ReportEventMoreProps } from './ReportEventMore'; @@ -27,25 +22,22 @@ import { FiltersCombobox } from './filters/FiltersCombobox'; import { FiltersList } from './filters/FiltersList'; export function ReportEvents() { - const previous = useSelector((state) => state.report.previous); const selectedEvents = useSelector((state) => state.report.events); - const startDate = useSelector((state) => state.report.startDate); - const endDate = useSelector((state) => state.report.endDate); - const range = useSelector((state) => state.report.range); - const interval = useSelector((state) => state.report.interval); + const chartType = useSelector((state) => state.report.chartType); const dispatch = useDispatch(); const { projectId } = useAppParams(); const eventNames = useEventNames({ projectId, - startDate, - endDate, - range, - interval, }); - + const showSegment = !['retention', 'funnel'].includes(chartType); + const showAddFilter = !['retention'].includes(chartType); + const showDisplayNameInput = !['retention'].includes(chartType); + const isAddEventDisabled = + chartType === 'retention' && selectedEvents.length >= 2; const dispatchChangeEvent = useDebounceFn((event: IChartEvent) => { dispatch(changeEvent(event)); }); + const isSelectManyEvents = chartType === 'retention'; const handleMore = (event: IChartEvent) => { const callback: ReportEventMoreProps['onClick'] = (action) => { @@ -68,137 +60,173 @@ export function ReportEvents() {
{alphabetIds[index]} - { - dispatch( - changeEvent({ + {isSelectManyEvents ? ( + { + dispatch( + changeEvent({ + id: event.id, + segment: 'user', + filters: [ + { + name: 'name', + operator: 'is', + value: value, + }, + ], + name: '*', + }), + ); + }} + items={eventNames.map((item) => ({ + label: item.name, + value: item.name, + }))} + placeholder="Select event" + /> + ) : ( + { + dispatch( + changeEvent({ + ...event, + name: value, + filters: [], + }), + ); + }} + items={eventNames.map((item) => ({ + label: item.name, + value: item.name, + }))} + placeholder="Select event" + /> + )} + {showDisplayNameInput && ( + { + dispatchChangeEvent({ ...event, - name: value, - filters: [], - }), - ); - }} - items={eventNames.map((item) => ({ - label: item.name, - value: item.name, - }))} - placeholder="Select event" - /> - { - dispatchChangeEvent({ - ...event, - displayName: e.target.value, - }); - }} - /> + displayName: e.target.value, + }); + }} + /> + )}
{/* Segment and Filter buttons */} -
- { - dispatch( - changeEvent({ - ...event, - segment, - }), - ); - }} - items={[ - { - value: 'event', - label: 'All events', - }, - { - value: 'user', - label: 'Unique users', - }, - { - value: 'session', - label: 'Unique sessions', - }, - { - value: 'user_average', - label: 'Average event per user', - }, - { - value: 'one_event_per_user', - label: 'One event per user', - }, - { - value: 'property_sum', - label: 'Sum of property', - }, - { - value: 'property_average', - label: 'Average of property', - }, - ]} - label="Segment" - > - - - {/* */} - + {(showSegment || showAddFilter) && ( +
+ {showSegment && ( + { + dispatch( + changeEvent({ + ...event, + segment, + }), + ); + }} + items={[ + { + value: 'event', + label: 'All events', + }, + { + value: 'user', + label: 'Unique users', + }, + { + value: 'session', + label: 'Unique sessions', + }, + { + value: 'user_average', + label: 'Average event per user', + }, + { + value: 'one_event_per_user', + label: 'One event per user', + }, + { + value: 'property_sum', + label: 'Sum of property', + }, + { + value: 'property_average', + label: 'Average of property', + }, + ]} + label="Segment" + > + + + )} + {/* */} + {showAddFilter && } - {(event.segment === 'property_average' || - event.segment === 'property_sum') && ( - - )} -
+ {showSegment && + (event.segment === 'property_average' || + event.segment === 'property_sum') && ( + + )} +
+ )} {/* Filters */} - + {!isSelectManyEvents && }
); })}
-
); } diff --git a/apps/dashboard/src/components/report/sidebar/ReportSettings.tsx b/apps/dashboard/src/components/report/sidebar/ReportSettings.tsx new file mode 100644 index 00000000..4f2c6da2 --- /dev/null +++ b/apps/dashboard/src/components/report/sidebar/ReportSettings.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { Combobox } from '@/components/ui/combobox'; +import { useDispatch, useSelector } from '@/redux'; + +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { useMemo } from 'react'; +import { changeCriteria, changePrevious, changeUnit } from '../reportSlice'; + +export function ReportSettings() { + const chartType = useSelector((state) => state.report.chartType); + const previous = useSelector((state) => state.report.previous); + const criteria = useSelector((state) => state.report.criteria); + const unit = useSelector((state) => state.report.unit); + + const dispatch = useDispatch(); + + const fields = useMemo(() => { + const fields = []; + + if (chartType !== 'retention') { + fields.push('previous'); + } + + if (chartType === 'retention') { + fields.push('criteria'); + fields.push('unit'); + } + + return fields; + }, [chartType]); + + if (fields.length === 0) { + return null; + } + + return ( +
+

Settings

+
+ {fields.includes('previous') && ( + + )} + {fields.includes('criteria') && ( +
+ Criteria + dispatch(changeCriteria(val))} + items={[ + { + label: 'On or After', + value: 'on_or_after', + }, + { + label: 'On', + value: 'on', + }, + ]} + /> +
+ )} + {fields.includes('unit') && ( +
+ Unit + { + dispatch(changeUnit(val === 'count' ? undefined : val)); + }} + items={[ + { + label: 'Count', + value: 'count', + }, + { + label: '%', + value: '%', + }, + ]} + /> +
+ )} +
+
+ ); +} diff --git a/apps/dashboard/src/components/report/sidebar/ReportSidebar.tsx b/apps/dashboard/src/components/report/sidebar/ReportSidebar.tsx index 8e715268..ef1e8699 100644 --- a/apps/dashboard/src/components/report/sidebar/ReportSidebar.tsx +++ b/apps/dashboard/src/components/report/sidebar/ReportSidebar.tsx @@ -5,15 +5,17 @@ import { useSelector } from '@/redux'; import { ReportBreakdowns } from './ReportBreakdowns'; import { ReportEvents } from './ReportEvents'; import { ReportFormula } from './ReportFormula'; +import { ReportSettings } from './ReportSettings'; export function ReportSidebar() { const { chartType } = useSelector((state) => state.report); - const showFormula = chartType !== 'funnel'; - const showBreakdown = chartType !== 'funnel'; + const showFormula = chartType !== 'funnel' && chartType !== 'retention'; + const showBreakdown = chartType !== 'funnel' && chartType !== 'retention'; return ( <>
+ {showFormula && } {showBreakdown && }
diff --git a/apps/dashboard/src/components/report/sidebar/filters/FilterItem.tsx b/apps/dashboard/src/components/report/sidebar/filters/FilterItem.tsx index 1a10901c..8c44c6a5 100644 --- a/apps/dashboard/src/components/report/sidebar/filters/FilterItem.tsx +++ b/apps/dashboard/src/components/report/sidebar/filters/FilterItem.tsx @@ -34,10 +34,6 @@ interface FilterProps { interface PureFilterProps { eventName: string; filter: IChartEventFilter; - range: IChartRange; - startDate: string | null; - endDate: string | null; - interval: IInterval; onRemove: (filter: IChartEventFilter) => void; onChangeValue: ( value: IChartEventFilterValue[], @@ -111,10 +107,6 @@ export function FilterItem({ filter, event }: FilterProps) { - diff --git a/apps/dashboard/src/components/report/sidebar/filters/FiltersCombobox.tsx b/apps/dashboard/src/components/report/sidebar/filters/FiltersCombobox.tsx index 59bd7525..52f27414 100644 --- a/apps/dashboard/src/components/report/sidebar/filters/FiltersCombobox.tsx +++ b/apps/dashboard/src/components/report/sidebar/filters/FiltersCombobox.tsx @@ -15,20 +15,12 @@ interface FiltersComboboxProps { export function FiltersCombobox({ event }: FiltersComboboxProps) { const dispatch = useDispatch(); - const interval = useSelector((state) => state.report.interval); - const range = useSelector((state) => state.report.range); - const startDate = useSelector((state) => state.report.startDate); - const endDate = useSelector((state) => state.report.endDate); const { projectId } = useAppParams(); const properties = useEventProperties( { event: event.name, projectId, - range, - interval, - startDate, - endDate, }, { enabled: !!event.name, diff --git a/apps/dashboard/src/components/ui/button.tsx b/apps/dashboard/src/components/ui/button.tsx index 224c1661..6d34badd 100644 --- a/apps/dashboard/src/components/ui/button.tsx +++ b/apps/dashboard/src/components/ui/button.tsx @@ -45,6 +45,26 @@ export interface ButtonProps loading?: boolean; icon?: LucideIcon; responsive?: boolean; + autoHeight?: boolean; +} + +function fixHeight({ + autoHeight, + size, +}: { autoHeight?: boolean; size: ButtonProps['size'] }) { + if (autoHeight) { + switch (size) { + case 'lg': + return 'h-auto min-h-11 py-2'; + case 'icon': + return 'h-auto min-h-8 py-1'; + case 'default': + return 'h-auto min-h-10 py-2'; + default: + return 'h-auto min-h-8 py-1'; + } + } + return ''; } const Button = React.forwardRef( @@ -59,6 +79,7 @@ const Button = React.forwardRef( disabled, icon, responsive, + autoHeight, ...props }, ref, @@ -67,7 +88,10 @@ const Button = React.forwardRef( const Icon = loading ? Loader2 : (icon ?? null); return ( setOpen((prev) => !prev)} - className={cn('h-auto min-h-10 py-2', className)} + className={className} + size={size} + autoHeight >
{value.length === 0 && placeholder} diff --git a/apps/dashboard/src/components/ui/combobox.tsx b/apps/dashboard/src/components/ui/combobox.tsx index 3008e848..da29f9b3 100644 --- a/apps/dashboard/src/components/ui/combobox.tsx +++ b/apps/dashboard/src/components/ui/combobox.tsx @@ -38,6 +38,7 @@ export interface ComboboxProps { align?: 'start' | 'end' | 'center'; portal?: boolean; error?: string; + disabled?: boolean; } export type ExtendedComboboxProps = Omit< @@ -61,6 +62,7 @@ export function Combobox({ align = 'start', portal, error, + disabled, }: ComboboxProps) { const [open, setOpen] = React.useState(false); const [search, setSearch] = React.useState(''); @@ -75,6 +77,7 @@ export function Combobox({ {children ?? (