diff --git a/apps/dashboard/src/components/charts/chart-tooltip.tsx b/apps/dashboard/src/components/charts/chart-tooltip.tsx index f34619da..d79f3ac5 100644 --- a/apps/dashboard/src/components/charts/chart-tooltip.tsx +++ b/apps/dashboard/src/components/charts/chart-tooltip.tsx @@ -9,7 +9,7 @@ export function createChartTooltip< Tooltip: React.ComponentType< { context: PropsFromContext; - data: PropsFromTooltip; + data: PropsFromTooltip[]; } & TooltipProps >, ) { @@ -24,7 +24,7 @@ export function createChartTooltip< const InnerTooltip = (tooltip: TooltipProps) => { const context = useContext(); - const data = tooltip.payload?.[0]?.payload; + const data = tooltip.payload?.map((p) => p.payload) ?? []; if (!data || !tooltip.active) { return null; 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 d0d14b20..b279fc25 100644 --- a/apps/dashboard/src/components/report-chart/common/report-table.tsx +++ b/apps/dashboard/src/components/report-chart/common/report-table.tsx @@ -53,7 +53,7 @@ export function ReportTable({ return ( <> - + +
+ + + + {references.data?.map((ref) => ( + + ))} + + + + {data.current.map((serie, index) => { + const color = getChartColor(index); + return ( + + + + + ); + })} + + +
+ + ); +} + +const { Tooltip, TooltipProvider } = createChartTooltip< + NonNullable< + RouterOutputs['chart']['conversion']['current'][number] + >['data'][number], + { + conversion: RouterOutputs['chart']['conversion']; + interval: IInterval; + } +>(({ data, context }) => { + const { date } = data[0]!; + const formatDate = useFormatDateInterval(context.interval); + const number = useNumber(); + return ( + <> +
+
{formatDate(date)}
+
+ {context.conversion.current.map((serie, index) => { + const item = data[index]; + if (!item) { + return null; + } + const prevItem = + context.conversion?.previous?.[item.serieIndex]?.data[item.index]; + + const title = + serie.breakdowns.length > 0 + ? (serie.breakdowns.join(',') ?? 'Not set') + : 'Conversion'; + return ( +
+
+
+
{title}
+
+
+ {number.formatWithUnit(item.rate / 100, '%')} + + ({number.format(item.total)}) + +
+ + +
+
+
+ ); + })} + + ); +}); diff --git a/apps/dashboard/src/components/report-chart/conversion/index.tsx b/apps/dashboard/src/components/report-chart/conversion/index.tsx new file mode 100644 index 00000000..72492f4d --- /dev/null +++ b/apps/dashboard/src/components/report-chart/conversion/index.tsx @@ -0,0 +1,69 @@ +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'; +import { ReportChartLoading } from '../common/loading'; +import { useReportChartContext } from '../context'; +import { Chart } from './chart'; +import { Summary } from './summary'; + +export function ReportConversionChart() { + const { isLazyLoading, report } = useReportChartContext(); + + const res = api.chart.conversion.useQuery(report, { + keepPreviousData: true, + staleTime: 1000 * 60 * 1, + enabled: !isLazyLoading, + }); + + if ( + isLazyLoading || + res.isLoading || + (res.isFetching && !res.data?.current.length) + ) { + return ; + } + + if (res.isError) { + return ; + } + + if (res.data.current.length === 0) { + return ; + } + + return ( +
+ + + + +
+ ); +} + +function Loading() { + return ( + + + + ); +} + +function Error() { + return ( + + + + ); +} + +function Empty() { + return ( + + + + ); +} diff --git a/apps/dashboard/src/components/report-chart/conversion/summary.tsx b/apps/dashboard/src/components/report-chart/conversion/summary.tsx new file mode 100644 index 00000000..163eabd8 --- /dev/null +++ b/apps/dashboard/src/components/report-chart/conversion/summary.tsx @@ -0,0 +1,228 @@ +'use client'; +import type { RouterOutputs } from '@/trpc/client'; +import React, { useMemo } from 'react'; + +import { Stats, StatsCard } from '@/components/stats'; +import { useNumber } from '@/hooks/useNumerFormatter'; +import { formatDate } from '@/utils/date'; +import { average, getPreviousMetric, sum } from '@openpanel/common'; +import { ChevronRightIcon } from 'lucide-react'; +import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator'; +import { useReportChartContext } from '../context'; + +interface Props { + data: RouterOutputs['chart']['conversion']; +} + +export function Summary({ data }: Props) { + const number = useNumber(); + const { report } = useReportChartContext(); + + const bestConversionRateMatch = useMemo(() => { + return data.current.reduce( + (acc, serie, serieIndex) => { + const serieMax = serie.data.reduce( + (maxInSerie, item, dataIndex) => { + if (item.rate > maxInSerie.rate) { + return { rate: item.rate, serieIndex, dataIndex }; + } + return maxInSerie; + }, + { rate: 0, serieIndex, dataIndex: 0 }, + ); + + return serieMax.rate > acc.rate ? serieMax : acc; + }, + { + rate: 0, + serieIndex: 0, + dataIndex: 0, + }, + ); + }, [data.current]); + + const worstConversionRateMatch = useMemo(() => { + return data.current.reduce( + (acc, serie, serieIndex) => { + const serieMin = serie.data.reduce( + (minInSerie, item, dataIndex) => { + if (item.rate < minInSerie.rate) { + return { rate: item.rate, serieIndex, dataIndex }; + } + return minInSerie; + }, + { rate: 100, serieIndex, dataIndex: 0 }, + ); + + return serieMin.rate < acc.rate ? serieMin : acc; + }, + { + rate: 100, + serieIndex: 0, + dataIndex: 0, + }, + ); + }, [data.current]); + const bestConversionRate = + data.current[bestConversionRateMatch.serieIndex]?.data[ + bestConversionRateMatch.dataIndex + ]; + const worstConversionRate = + data.current[worstConversionRateMatch.serieIndex]?.data[ + worstConversionRateMatch.dataIndex + ]; + + const bestAverageConversionRateMatch = data.current.reduce( + (acc, serie) => { + const averageRate = average(serie.data.map((item) => item.rate)); + return averageRate > acc.averageRate ? { serie, averageRate } : acc; + }, + { serie: data.current[0], averageRate: 0 }, + ); + const worstAverageConversionRateMatch = data.current.reduce( + (acc, serie) => { + const averageRate = average(serie.data.map((item) => item.rate)); + return averageRate < acc.averageRate ? { serie, averageRate } : acc; + }, + { serie: data.current[0], averageRate: 100 }, + ); + + const averageConversionRate = average( + data.current.map((serie) => { + return average(serie.data.map((item) => item.rate)); + }, 0), + ); + + const averageConversionRatePrevious = + average( + data.previous?.map((serie) => { + return average(serie.data.map((item) => item.rate)); + }) ?? [], + ) ?? 0; + + const sumConversions = data.current.reduce((acc, serie) => { + return acc + sum(serie.data.map((item) => item.conversions)); + }, 0); + const sumConversionsPrevious = data.previous?.reduce((acc, serie) => { + return acc + sum(serie.data.map((item) => item.conversions)); + }, 0); + + const hasManySeries = data.current.length > 1; + + const getConversionRateNode = ( + item: RouterOutputs['chart']['conversion']['current'][0]['data'][0], + ) => { + const breakdowns = item.serie.breakdowns.join(', '); + if (breakdowns) { + return ( + + On{' '} + + {item.serie.breakdowns.join(', ')} + {' '} + with{' '} + + {number.formatWithUnit(item.rate / 100, '%')} + {' '} + at {formatDate(new Date(item.date))} + + ); + } + + return ( + + + {number.formatWithUnit(item.rate / 100, '%')} + {' '} + at {formatDate(new Date(item.date))} + + ); + }; + + return ( + + + {report.events.map((event, index) => { + return ( +
+ {index !== 0 && } + {event.name} +
+ ); + })} +
+ } + /> + {bestAverageConversionRateMatch && hasManySeries && ( + + {bestAverageConversionRateMatch.serie?.breakdowns.join(', ')}{' '} + with{' '} + {number.formatWithUnit( + bestAverageConversionRateMatch.averageRate / 100, + '%', + )} + + } + /> + )} + {worstAverageConversionRateMatch && hasManySeries && ( + + {worstAverageConversionRateMatch.serie?.breakdowns.join(', ')}{' '} + with{' '} + {number.formatWithUnit( + worstAverageConversionRateMatch.averageRate / 100, + '%', + )} + + } + /> + )} + + ) + } + /> + + ) + } + /> + {bestConversionRate && ( + + )} + {worstConversionRate && ( + + )} +
+ ); +} diff --git a/apps/dashboard/src/components/report-chart/funnel/chart.tsx b/apps/dashboard/src/components/report-chart/funnel/chart.tsx index 2cdb81c8..3c9075be 100644 --- a/apps/dashboard/src/components/report-chart/funnel/chart.tsx +++ b/apps/dashboard/src/components/report-chart/funnel/chart.tsx @@ -31,7 +31,7 @@ type Props = { }; }; -const Metric = ({ +export const Metric = ({ label, value, enhancer, @@ -218,7 +218,7 @@ export function Tables({ { name: 'Completed', render: (item) => number.format(item.count), - className: 'text-right font-mono', + className: 'text-right font-mono hidden @xl:block', width: '82px', }, { @@ -227,7 +227,7 @@ export function Tables({ item.dropoffCount !== null && item.dropoffPercent !== null ? number.format(item.dropoffCount) : null, - className: 'text-right font-mono', + className: 'text-right font-mono hidden @xl:block', width: '110px', }, { @@ -341,7 +341,8 @@ export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) { const { Tooltip, TooltipProvider } = createChartTooltip< RechartData, Record ->(({ data }) => { +>(({ data: dataArray }) => { + const data = dataArray[0]!; const number = useNumber(); const variants = Object.keys(data).filter((key) => key.startsWith('step:data:'), diff --git a/apps/dashboard/src/components/report-chart/index.tsx b/apps/dashboard/src/components/report-chart/index.tsx index 535e1435..cb89a5af 100644 --- a/apps/dashboard/src/components/report-chart/index.tsx +++ b/apps/dashboard/src/components/report-chart/index.tsx @@ -8,6 +8,7 @@ import { ReportAreaChart } from './area'; import { ReportBarChart } from './bar'; import type { ReportChartProps } from './context'; import { ReportChartProvider } from './context'; +import { ReportConversionChart } from './conversion'; import { ReportFunnelChart } from './funnel'; import { ReportHistogramChart } from './histogram'; import { ReportLineChart } from './line'; @@ -51,6 +52,8 @@ export function ReportChart(props: ReportChartProps) { return ; case 'retention': return ; + case 'conversion': + return ; default: return null; } diff --git a/apps/dashboard/src/components/report/ReportChartType.tsx b/apps/dashboard/src/components/report/ReportChartType.tsx index 8e7a5fb9..15764e53 100644 --- a/apps/dashboard/src/components/report/ReportChartType.tsx +++ b/apps/dashboard/src/components/report/ReportChartType.tsx @@ -9,6 +9,7 @@ import { LineChartIcon, type LucideIcon, PieChartIcon, + TrendingUpIcon, UsersIcon, } from 'lucide-react'; @@ -52,6 +53,7 @@ export function ReportChartType({ className }: ReportChartTypeProps) { metric: GaugeIcon, retention: UsersIcon, map: Globe2Icon, + conversion: TrendingUpIcon, }; return ( @@ -76,10 +78,11 @@ export function ReportChartType({ className }: ReportChartTypeProps) { dispatch(changeChartType(item.value))} + className="group" > {item.label} - + ); diff --git a/apps/dashboard/src/components/report/ReportInterval.tsx b/apps/dashboard/src/components/report/ReportInterval.tsx index dd764a15..c5ab0b72 100644 --- a/apps/dashboard/src/components/report/ReportInterval.tsx +++ b/apps/dashboard/src/components/report/ReportInterval.tsx @@ -22,7 +22,8 @@ export function ReportInterval({ className }: ReportIntervalProps) { chartType !== 'histogram' && chartType !== 'area' && chartType !== 'metric' && - chartType !== 'retention' + chartType !== 'retention' && + chartType !== 'conversion' ) { return null; } diff --git a/apps/dashboard/src/components/report/ReportLineType.tsx b/apps/dashboard/src/components/report/ReportLineType.tsx index 3f9e21e0..5e1cf7b4 100644 --- a/apps/dashboard/src/components/report/ReportLineType.tsx +++ b/apps/dashboard/src/components/report/ReportLineType.tsx @@ -15,9 +15,12 @@ export function ReportLineType({ className }: ReportLineTypeProps) { const chartType = useSelector((state) => state.report.chartType); const type = useSelector((state) => state.report.lineType); - if (chartType !== 'linear' && chartType !== 'area') { + if ( + chartType !== 'conversion' && + chartType !== 'linear' && + chartType !== 'area' + ) return null; - } return ( = 2; + (chartType === 'retention' || chartType === 'conversion') && + selectedEvents.length >= 2; const dispatchChangeEvent = useDebounceFn((event: IChartEvent) => { dispatch(changeEvent(event)); }); diff --git a/apps/dashboard/src/components/report/sidebar/ReportSidebar.tsx b/apps/dashboard/src/components/report/sidebar/ReportSidebar.tsx index f27b69a7..f879448b 100644 --- a/apps/dashboard/src/components/report/sidebar/ReportSidebar.tsx +++ b/apps/dashboard/src/components/report/sidebar/ReportSidebar.tsx @@ -9,7 +9,10 @@ import { ReportSettings } from './ReportSettings'; export function ReportSidebar() { const { chartType } = useSelector((state) => state.report); - const showFormula = chartType !== 'funnel' && chartType !== 'retention'; + const showFormula = + chartType !== 'conversion' && + chartType !== 'funnel' && + chartType !== 'retention'; const showBreakdown = chartType !== 'retention'; return ( <> diff --git a/apps/dashboard/src/components/stats.tsx b/apps/dashboard/src/components/stats.tsx index ed3850d0..e5a5dce0 100644 --- a/apps/dashboard/src/components/stats.tsx +++ b/apps/dashboard/src/components/stats.tsx @@ -10,21 +10,25 @@ export function Stats({ return (
); } -export function StatsCard({ title, value }: { title: string; value: string }) { +export function StatsCard({ + title, + value, + enhancer, +}: { title: string; value: React.ReactNode; enhancer?: React.ReactNode }) { return (
-
{title}
-
{value}
+
{title}
+
+
{value}
+
{enhancer}
+
); } diff --git a/apps/dashboard/src/components/widget-table.tsx b/apps/dashboard/src/components/widget-table.tsx index 6fedc2c1..212eed51 100644 --- a/apps/dashboard/src/components/widget-table.tsx +++ b/apps/dashboard/src/components/widget-table.tsx @@ -80,7 +80,7 @@ export function WidgetTable({
diff --git a/apps/worker/src/jobs/cron.delete-projects.ts b/apps/worker/src/jobs/cron.delete-projects.ts index 9daa668e..be1d8499 100644 --- a/apps/worker/src/jobs/cron.delete-projects.ts +++ b/apps/worker/src/jobs/cron.delete-projects.ts @@ -24,12 +24,21 @@ export async function deleteProjects() { }); } - await ch.command({ - query: `DELETE FROM ${TABLE_NAMES.events} WHERE project_id IN (${projects.map((project) => escape(project.id)).join(',')});`, - clickhouse_settings: { - lightweight_deletes_sync: 0, - }, - }); + if (process.env.SELF_HOSTED) { + await ch.command({ + query: `DELETE FROM ${TABLE_NAMES.events} WHERE project_id IN (${projects.map((project) => escape(project.id)).join(',')});`, + clickhouse_settings: { + lightweight_deletes_sync: 0, + }, + }); + } else { + await ch.command({ + query: `DELETE FROM ${TABLE_NAMES.events}_replicated ON CLUSTER '{cluster}' WHERE project_id IN (${projects.map((project) => escape(project.id)).join(',')});`, + clickhouse_settings: { + lightweight_deletes_sync: 0, + }, + }); + } logger.info(`Deleted ${projects.length} projects`, { projects, diff --git a/packages/constants/index.ts b/packages/constants/index.ts index 6991b0e6..eb0a6bf1 100644 --- a/packages/constants/index.ts +++ b/packages/constants/index.ts @@ -84,6 +84,7 @@ export const chartTypes = { map: 'Map', funnel: 'Funnel', retention: 'Retention', + conversion: 'Conversion', } as const; export const lineTypes = { diff --git a/packages/db/index.ts b/packages/db/index.ts index deb62854..623eb6ee 100644 --- a/packages/db/index.ts +++ b/packages/db/index.ts @@ -13,6 +13,7 @@ export * from './src/services/salt.service'; export * from './src/services/share.service'; export * from './src/services/session.service'; export * from './src/services/funnel.service'; +export * from './src/services/conversion.service'; export * from './src/services/user.service'; export * from './src/services/reference.service'; export * from './src/services/id.service'; diff --git a/packages/db/prisma/migrations/20250326202409_on_cascade/migration.sql b/packages/db/prisma/migrations/20250326202409_on_cascade/migration.sql new file mode 100644 index 00000000..fd982dda --- /dev/null +++ b/packages/db/prisma/migrations/20250326202409_on_cascade/migration.sql @@ -0,0 +1,5 @@ +-- DropForeignKey +ALTER TABLE "clients" DROP CONSTRAINT "clients_organizationId_fkey"; + +-- AddForeignKey +ALTER TABLE "clients" ADD CONSTRAINT "clients_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20250326202444_add_conversion/migration.sql b/packages/db/prisma/migrations/20250326202444_add_conversion/migration.sql new file mode 100644 index 00000000..55bed67c --- /dev/null +++ b/packages/db/prisma/migrations/20250326202444_add_conversion/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "ChartType" ADD VALUE 'conversion'; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 5e49e4e3..a5b393ee 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -230,7 +230,7 @@ model Client { type ClientType @default(write) projectId String? project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade) - organization Organization @relation(fields: [organizationId], references: [id]) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organizationId String createdAt DateTime @default(now()) @@ -257,6 +257,7 @@ enum ChartType { map funnel retention + conversion } model Dashboard { diff --git a/packages/db/src/services/conversion.service.ts b/packages/db/src/services/conversion.service.ts new file mode 100644 index 00000000..cc036fa9 --- /dev/null +++ b/packages/db/src/services/conversion.service.ts @@ -0,0 +1,199 @@ +import { NOT_SET_VALUE } from '@openpanel/constants'; +import type { IChartInput } from '@openpanel/validation'; +import { omit } from 'ramda'; +import { TABLE_NAMES, ch } from '../clickhouse/client'; +import { clix } from '../clickhouse/query-builder'; +import { + getEventFiltersWhereClause, + getSelectPropertyKey, +} from './chart.service'; + +export class ConversionService { + constructor(private client: typeof ch) {} + + async getConversion({ + projectId, + startDate, + endDate, + funnelGroup, + funnelWindow = 24, + events, + breakdowns = [], + interval, + }: Omit) { + const group = funnelGroup === 'profile_id' ? 'profile_id' : 'session_id'; + const breakdownColumns = breakdowns.map( + (b, index) => `${getSelectPropertyKey(b.name)} as b_${index}`, + ); + const breakdownGroupBy = breakdowns.map((b, index) => `b_${index}`); + + if (events.length !== 2) { + throw new Error('events must be an array of two events'); + } + + if (!startDate || !endDate) { + throw new Error('startDate and endDate are required'); + } + + const eventA = events[0]!; + const eventB = events[1]!; + const whereA = Object.values( + getEventFiltersWhereClause(eventA.filters), + ).join(' AND '); + const whereB = Object.values( + getEventFiltersWhereClause(eventB.filters), + ).join(' AND '); + + const eventACte = clix(this.client) + .select([ + `DISTINCT ${group}`, + 'created_at AS a_time', + `${clix.toStartOf('created_at', interval)} AS event_day`, + ...breakdownColumns, + ]) + .from(TABLE_NAMES.events) + .where('project_id', '=', projectId) + .where('name', '=', eventA.name) + .rawWhere(whereA) + .where('created_at', 'BETWEEN', [ + clix.datetime(startDate), + clix.datetime(endDate), + ]); + + const eventBCte = clix(this.client) + .select([group, 'created_at AS b_time']) + .from(TABLE_NAMES.events) + .where('project_id', '=', projectId) + .where('name', '=', eventB.name) + .rawWhere(whereB) + .where('created_at', 'BETWEEN', [ + clix.datetime(startDate), + clix.datetime(endDate), + ]); + + const query = clix(this.client) + .with('event_a', eventACte) + .with('event_b', eventBCte) + .select<{ + event_day: string; + total_first: number; + conversions: number; + conversion_rate_percentage: number; + [key: string]: string | number; // For breakdown columns + }>([ + 'event_day', + ...breakdownGroupBy, + 'count(*) AS total_first', + 'sum(if(conversion_time IS NOT NULL, 1, 0)) AS conversions', + 'round(100.0 * sum(if(conversion_time IS NOT NULL, 1, 0)) / count(*), 2) AS conversion_rate_percentage', + ]) + .from( + clix.exp(` + (SELECT + a.${group}, + a.a_time, + a.event_day, + ${breakdownGroupBy.length ? `${breakdownGroupBy.join(', ')},` : ''} + nullIf(min(b.b_time), '1970-01-01 00:00:00.000') AS conversion_time + FROM event_a AS a + LEFT JOIN event_b AS b ON a.${group} = b.${group} + AND b.b_time BETWEEN a.a_time AND a.a_time + INTERVAL ${funnelWindow} HOUR + GROUP BY a.${group}, a.a_time, a.event_day${breakdownGroupBy.length ? `, ${breakdownGroupBy.join(', ')}` : ''}) + `), + ) + .groupBy(['event_day', ...breakdownGroupBy]); + + for (const order of ['event_day', ...breakdownGroupBy]) { + query.orderBy(order); + } + + const results = await query.execute(); + return this.toSeries(results, breakdowns).map((serie, serieIndex) => { + return { + ...serie, + data: serie.data.map((d, index) => ({ + ...d, + timestamp: new Date(d.date).getTime(), + serieIndex, + index, + serie: omit(['data'], serie), + })), + }; + }); + } + + private toSeries( + data: { + event_day: string; + total_first: number; + conversions: number; + conversion_rate_percentage: number; + [key: string]: string | number; + }[], + breakdowns: { name: string }[] = [], + ) { + if (!breakdowns.length) { + return [ + { + id: 'conversion', + breakdowns: [], + data: data.map((d) => ({ + date: d.event_day, + total: d.total_first, + conversions: d.conversions, + rate: d.conversion_rate_percentage, + })), + }, + ]; + } + + // Group by breakdown values + const series = data.reduce( + (acc, d) => { + const key = + breakdowns.map((b, index) => d[`b_${index}`]).join('|') || + NOT_SET_VALUE; + if (!acc[key]) { + acc[key] = { + id: key, + breakdowns: breakdowns.map( + (b, index) => (d[`b_${index}`] || NOT_SET_VALUE) as string, + ), + data: [], + }; + } + acc[key]!.data.push({ + date: d.event_day, + total: d.total_first, + conversions: d.conversions, + rate: d.conversion_rate_percentage, + }); + return acc; + }, + {} as Record< + string, + { + id: string; + breakdowns: string[]; + data: { + date: string; + total: number; + conversions: number; + rate: number; + }[]; + } + >, + ); + + return Object.values(series).map((serie, serieIndex) => ({ + ...serie, + data: serie.data.map((item, dataIndex) => ({ + ...item, + dataIndex, + serieIndex, + })), + })); + } +} + +export const conversionService = new ConversionService(ch); diff --git a/packages/db/test.ts b/packages/db/test.ts new file mode 100644 index 00000000..06ad41d5 --- /dev/null +++ b/packages/db/test.ts @@ -0,0 +1,38 @@ +import { conversionService } from './src/services/conversion.service'; +// 68/37 +async function main() { + const conversion = await conversionService.getConversion({ + projectId: 'kiddokitchen-app', + startDate: '2025-02-01', + endDate: '2025-03-01', + funnelGroup: 'session_id', + breakdowns: [ + { + name: 'os', + }, + ], + interval: 'day', + events: [ + { + segment: 'event', + name: 'screen_view', + filters: [ + { + name: 'path', + operator: 'is', + value: ['Start'], + }, + ], + }, + { + segment: 'event', + name: 'sign_up', + filters: [], + }, + ], + }); + + console.dir(conversion, { depth: null }); +} + +main(); diff --git a/packages/trpc/src/routers/chart.ts b/packages/trpc/src/routers/chart.ts index b2099bcb..ffacb976 100644 --- a/packages/trpc/src/routers/chart.ts +++ b/packages/trpc/src/routers/chart.ts @@ -5,6 +5,7 @@ import { z } from 'zod'; import { TABLE_NAMES, chQuery, + conversionService, createSqlBuilder, db, funnelService, @@ -32,7 +33,6 @@ import { getChart, getChartPrevStartEndDate, getChartStartEndDate, - getFunnelData, } from './chart.helpers'; function utc(date: string | Date) { @@ -197,6 +197,29 @@ export const chartRouter = createTRPCRouter({ }; }), + conversion: protectedProcedure.input(zChartInput).query(async ({ input }) => { + const currentPeriod = getChartStartEndDate(input); + const previousPeriod = getChartPrevStartEndDate(currentPeriod); + + const [current, previous] = await Promise.all([ + conversionService.getConversion({ ...input, ...currentPeriod }), + input.previous + ? conversionService.getConversion({ ...input, ...previousPeriod }) + : Promise.resolve(null), + ]); + + return { + current: current.map((serie, sIndex) => ({ + ...serie, + data: serie.data.map((d, dIndex) => ({ + ...d, + previousRate: previous?.[sIndex]?.data?.[dIndex]?.rate, + })), + })), + previous, + }; + }), + chart: publicProcedure.input(zChartInput).query(async ({ input, ctx }) => { if (ctx.session.userId) { const access = await getProjectAccessCached({