{fields.includes('previous') && (
)}
{fields.includes('criteria') && (
-
-
Criteria
+
+
Criteria
)}
{fields.includes('unit') && (
-
-
Unit
+
+ Unit
)}
+ {fields.includes('funnelGroup') && (
+
+ Funnel Group
+ {
+ dispatch(
+ changeFunnelGroup(val === 'session_id' ? undefined : val),
+ );
+ }}
+ items={[
+ {
+ label: 'Session',
+ value: 'session_id',
+ },
+ {
+ label: 'Profile',
+ value: 'profile_id',
+ },
+ ]}
+ />
+
+ )}
+ {fields.includes('funnelWindow') && (
+
+ Funnel Window
+ {
+ const parsed = Number.parseFloat(value);
+ if (Number.isNaN(parsed)) {
+ dispatch(changeFunnelWindow(undefined));
+ } else {
+ dispatch(changeFunnelWindow(parsed));
+ }
+ }}
+ />
+
+ )}
);
diff --git a/apps/dashboard/src/components/report/sidebar/filters/FilterItem.tsx b/apps/dashboard/src/components/report/sidebar/filters/FilterItem.tsx
index 8c44c6a5..a0d5dd19 100644
--- a/apps/dashboard/src/components/report/sidebar/filters/FilterItem.tsx
+++ b/apps/dashboard/src/components/report/sidebar/filters/FilterItem.tsx
@@ -1,17 +1,13 @@
import { ColorSquare } from '@/components/color-square';
import { RenderDots } from '@/components/ui/RenderDots';
-import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import { DropdownMenuComposed } from '@/components/ui/dropdown-menu';
-import { Input } from '@/components/ui/input';
import { useAppParams } from '@/hooks/useAppParams';
import { useMappings } from '@/hooks/useMappings';
import { usePropertyValues } from '@/hooks/usePropertyValues';
import { useDispatch, useSelector } from '@/redux';
-import { AnimatePresence, motion } from 'framer-motion';
-import { RefreshCcwIcon, SlidersHorizontal, Trash } from 'lucide-react';
-import { useEffect, useState } from 'react';
+import { SlidersHorizontal, Trash } from 'lucide-react';
import { operators } from '@openpanel/constants';
import type {
@@ -19,11 +15,10 @@ import type {
IChartEventFilter,
IChartEventFilterOperator,
IChartEventFilterValue,
- IChartRange,
- IInterval,
} from '@openpanel/validation';
import { mapKeys } from '@openpanel/validation';
+import { InputEnter } from '@/components/ui/input-enter';
import { changeEvent } from '../../reportSlice';
interface FilterProps {
@@ -185,7 +180,7 @@ export function PureFilterItem({
placeholder="Select..."
/>
) : (
-
changeFilterValue([value])}
/>
@@ -194,53 +189,3 @@ export function PureFilterItem({
);
}
-
-function FilterRawInput({
- value,
- onChangeValue,
-}: {
- value: string;
- onChangeValue: (value: string) => void;
-}) {
- const [internalValue, setInternalValue] = useState(value || '');
-
- useEffect(() => {
- if (value !== internalValue) {
- setInternalValue(value);
- }
- }, [value]);
-
- return (
-
-
setInternalValue(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === 'Enter') {
- onChangeValue(internalValue);
- }
- }}
- placeholder="Value"
- size="default"
- />
-
-
- {internalValue !== value && (
- onChangeValue(internalValue)}
- >
-
- Press enter
-
-
-
- )}
-
-
-
- );
-}
diff --git a/apps/dashboard/src/components/ui/input-enter.tsx b/apps/dashboard/src/components/ui/input-enter.tsx
new file mode 100644
index 00000000..5de9fbbe
--- /dev/null
+++ b/apps/dashboard/src/components/ui/input-enter.tsx
@@ -0,0 +1,60 @@
+import { motion } from 'framer-motion';
+
+import { AnimatePresence } from 'framer-motion';
+import { RefreshCcwIcon } from 'lucide-react';
+import { type InputHTMLAttributes, useEffect, useState } from 'react';
+import { Badge } from './badge';
+import { Input } from './input';
+
+export function InputEnter({
+ value,
+ onChangeValue,
+ ...props
+}: {
+ value: string | undefined;
+ onChangeValue: (value: string) => void;
+} & InputHTMLAttributes
) {
+ const [internalValue, setInternalValue] = useState(value ?? '');
+
+ useEffect(() => {
+ if (value !== internalValue) {
+ console.log(value, internalValue);
+
+ setInternalValue(value ?? '');
+ }
+ }, [value]);
+
+ return (
+
+
setInternalValue(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ onChangeValue(internalValue);
+ }
+ }}
+ size="default"
+ />
+
+
+ {internalValue !== value && (
+ onChangeValue(internalValue)}
+ >
+
+ Press enter
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/packages/db/prisma/migrations/20241018190421_add_funnel_options_to_report/migration.sql b/packages/db/prisma/migrations/20241018190421_add_funnel_options_to_report/migration.sql
new file mode 100644
index 00000000..18654289
--- /dev/null
+++ b/packages/db/prisma/migrations/20241018190421_add_funnel_options_to_report/migration.sql
@@ -0,0 +1,3 @@
+-- AlterTable
+ALTER TABLE "reports" ADD COLUMN "funnelGroup" TEXT,
+ADD COLUMN "funnelWindow" DOUBLE PRECISION;
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index b49c25ef..b5e07643 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -231,21 +231,23 @@ enum Metric {
}
model Report {
- id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
- name String
- interval Interval
- range String @default("30d")
- chartType ChartType
- lineType String @default("monotone")
- breakdowns Json
- events Json
- formula String?
- unit String?
- metric Metric @default(sum)
- projectId String
- project Project @relation(fields: [projectId], references: [id])
- previous Boolean @default(false)
- criteria String?
+ id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
+ name String
+ interval Interval
+ range String @default("30d")
+ chartType ChartType
+ lineType String @default("monotone")
+ breakdowns Json
+ events Json
+ formula String?
+ unit String?
+ metric Metric @default(sum)
+ projectId String
+ project Project @relation(fields: [projectId], references: [id])
+ previous Boolean @default(false)
+ criteria String?
+ funnelGroup String?
+ funnelWindow Float?
dashboardId String
dashboard Dashboard @relation(fields: [dashboardId], references: [id])
diff --git a/packages/db/src/clickhouse-client.ts b/packages/db/src/clickhouse-client.ts
index f1b5842c..2d7413f8 100644
--- a/packages/db/src/clickhouse-client.ts
+++ b/packages/db/src/clickhouse-client.ts
@@ -129,14 +129,16 @@ export async function chQuery>(
}
export function formatClickhouseDate(
- _date: Date | string,
+ date: Date | string,
skipTime = false,
): string {
- if (typeof _date === 'string') {
- return _date.slice(0, 19).replace('T', ' ');
+ if (typeof date === 'string') {
+ if (skipTime) {
+ return date.slice(0, 10);
+ }
+ return date.slice(0, 19).replace('T', ' ');
}
- const date = typeof _date === 'string' ? new Date(_date) : _date;
if (skipTime) {
return date.toISOString().split('T')[0]!;
}
diff --git a/packages/db/src/services/reports.service.ts b/packages/db/src/services/reports.service.ts
index b1fbf4e4..8870fccf 100644
--- a/packages/db/src/services/reports.service.ts
+++ b/packages/db/src/services/reports.service.ts
@@ -66,6 +66,8 @@ export function transformReport(
metric: report.metric ?? 'sum',
unit: report.unit ?? undefined,
criteria: (report.criteria as ICriteria) ?? undefined,
+ funnelGroup: report.funnelGroup ?? undefined,
+ funnelWindow: report.funnelWindow ?? undefined,
};
}
diff --git a/packages/trpc/src/routers/chart.helpers.ts b/packages/trpc/src/routers/chart.helpers.ts
index 7afc7d7d..5d608a18 100644
--- a/packages/trpc/src/routers/chart.helpers.ts
+++ b/packages/trpc/src/routers/chart.helpers.ts
@@ -288,14 +288,15 @@ export function getChartPrevStartEndDate({
};
}
-const ONE_DAY_IN_SECONDS = 60 * 60 * 24;
-
export async function getFunnelData({
projectId,
startDate,
endDate,
...payload
}: IChartInput) {
+ const funnelWindow = (payload.funnelWindow || 24) * 3600;
+ const funnelGroup = payload.funnelGroup || 'session_id';
+
if (!startDate || !endDate) {
throw new Error('startDate and endDate are required');
}
@@ -315,15 +316,15 @@ export async function getFunnelData({
});
const innerSql = `SELECT
- session_id,
- windowFunnel(${ONE_DAY_IN_SECONDS}, 'strict_increase')(toUnixTimestamp(created_at), ${funnels.join(', ')}) AS level
+ ${funnelGroup},
+ windowFunnel(${funnelWindow}, 'strict_increase')(toUnixTimestamp(created_at), ${funnels.join(', ')}) AS level
FROM ${TABLE_NAMES.events}
WHERE
project_id = ${escape(projectId)} AND
created_at >= '${formatClickhouseDate(startDate)}' AND
created_at <= '${formatClickhouseDate(endDate)}' AND
name IN (${payload.events.map((event) => escape(event.name)).join(', ')})
- GROUP BY session_id`;
+ GROUP BY ${funnelGroup}`;
const sql = `SELECT level, count() AS count FROM (${innerSql}) WHERE level != 0 GROUP BY level ORDER BY level DESC`;
@@ -376,55 +377,56 @@ export async function getFunnelStep({
}: IChartInput & {
step: number;
}) {
- if (!startDate || !endDate) {
- throw new Error('startDate and endDate are required');
- }
+ throw new Error('not implemented');
+ // if (!startDate || !endDate) {
+ // throw new Error('startDate and endDate are required');
+ // }
- if (payload.events.length === 0) {
- throw new Error('no events selected');
- }
+ // if (payload.events.length === 0) {
+ // throw new Error('no events selected');
+ // }
- const funnels = payload.events.map((event) => {
- const { sb, getWhere } = createSqlBuilder();
- sb.where = getEventFiltersWhereClause(event.filters);
- sb.where.name = `name = ${escape(event.name)}`;
- return getWhere().replace('WHERE ', '');
- });
+ // const funnels = payload.events.map((event) => {
+ // const { sb, getWhere } = createSqlBuilder();
+ // sb.where = getEventFiltersWhereClause(event.filters);
+ // sb.where.name = `name = ${escape(event.name)}`;
+ // return getWhere().replace('WHERE ', '');
+ // });
- const innerSql = `SELECT
- session_id,
- windowFunnel(${ONE_DAY_IN_SECONDS})(toUnixTimestamp(created_at), ${funnels.join(', ')}) AS level
- FROM ${TABLE_NAMES.events}
- WHERE
- project_id = ${escape(projectId)} AND
- created_at >= '${formatClickhouseDate(startDate)}' AND
- created_at <= '${formatClickhouseDate(endDate)}' AND
- name IN (${payload.events.map((event) => escape(event.name)).join(', ')})
- GROUP BY session_id`;
+ // const innerSql = `SELECT
+ // session_id,
+ // windowFunnel(${ONE_DAY_IN_SECONDS})(toUnixTimestamp(created_at), ${funnels.join(', ')}) AS level
+ // FROM ${TABLE_NAMES.events}
+ // WHERE
+ // project_id = ${escape(projectId)} AND
+ // created_at >= '${formatClickhouseDate(startDate)}' AND
+ // created_at <= '${formatClickhouseDate(endDate)}' AND
+ // name IN (${payload.events.map((event) => escape(event.name)).join(', ')})
+ // GROUP BY session_id`;
- const profileIdsQuery = `WITH sessions AS (${innerSql})
- SELECT
- DISTINCT e.profile_id as id
- FROM sessions s
- JOIN ${TABLE_NAMES.events} e ON s.session_id = e.session_id
- WHERE
- s.level = ${step} AND
- e.project_id = ${escape(projectId)} AND
- e.created_at >= '${formatClickhouseDate(startDate)}' AND
- e.created_at <= '${formatClickhouseDate(endDate)}' AND
- name IN (${payload.events.map((event) => escape(event.name)).join(', ')})
- ORDER BY e.created_at DESC
- LIMIT 500
- `;
+ // const profileIdsQuery = `WITH sessions AS (${innerSql})
+ // SELECT
+ // DISTINCT e.profile_id as id
+ // FROM sessions s
+ // JOIN ${TABLE_NAMES.events} e ON s.session_id = e.session_id
+ // WHERE
+ // s.level = ${step} AND
+ // e.project_id = ${escape(projectId)} AND
+ // e.created_at >= '${formatClickhouseDate(startDate)}' AND
+ // e.created_at <= '${formatClickhouseDate(endDate)}' AND
+ // name IN (${payload.events.map((event) => escape(event.name)).join(', ')})
+ // ORDER BY e.created_at DESC
+ // LIMIT 500
+ // `;
- const res = await chQuery<{
- id: string;
- }>(profileIdsQuery);
+ // const res = await chQuery<{
+ // id: string;
+ // }>(profileIdsQuery);
- return getProfiles(
- res.map((r) => r.id),
- projectId,
- );
+ // return getProfiles(
+ // res.map((r) => r.id),
+ // projectId,
+ // );
}
export async function getChartSerie(payload: IGetChartDataInput) {
diff --git a/packages/trpc/src/routers/chart.ts b/packages/trpc/src/routers/chart.ts
index a55b0712..80227a4d 100644
--- a/packages/trpc/src/routers/chart.ts
+++ b/packages/trpc/src/routers/chart.ts
@@ -406,36 +406,46 @@ function processCohortData(
};
});
- // Initialize aggregation for averages
const averageData: {
- sum: number;
- values: Array;
- percentages: Array;
+ totalSum: number;
+ values: Array<{ sum: number; weightedSum: number }>;
+ percentages: Array<{ sum: number; weightedSum: number }>;
} = {
- sum: 0,
- values: range(0, diffInterval + 1).map(() => 0),
- percentages: range(0, diffInterval + 1).map(() => 0),
+ totalSum: 0,
+ values: range(0, diffInterval + 1).map(() => ({ sum: 0, weightedSum: 0 })),
+ percentages: range(0, diffInterval + 1).map(() => ({
+ sum: 0,
+ weightedSum: 0,
+ })),
};
- // Aggregate data for averages
+ // Aggregate data for weighted averages, excluding zeros
processed.forEach((row) => {
- averageData.sum += row.sum;
+ averageData.totalSum += row.sum;
row.values.forEach((value, index) => {
- averageData.values[index] += value;
- averageData.percentages[index] += row.percentages[index]!;
+ if (value !== 0) {
+ averageData.values[index]!.sum += row.sum;
+ averageData.values[index]!.weightedSum += value * row.sum;
+ }
+ });
+ row.percentages.forEach((percentage, index) => {
+ if (percentage !== 0) {
+ averageData.percentages[index]!.sum += row.sum;
+ averageData.percentages[index]!.weightedSum += percentage * row.sum;
+ }
});
});
- const cohortCount = processed.length;
-
- // Calculate average values
+ // Calculate weighted average values, excluding zeros
const averageRow = {
- cohort_interval: 'Average',
- sum: cohortCount > 0 ? round(averageData.sum / cohortCount, 0) : 0,
- percentages: averageData.percentages.map((item) =>
- round(item / cohortCount, 2),
+ cohort_interval: 'Weighted Average',
+ sum: round(averageData.totalSum / processed.length, 0),
+ percentages: averageData.percentages.map(({ sum, weightedSum }) =>
+ sum > 0 ? round(weightedSum / sum, 2) : 0,
+ ),
+ values: averageData.values.map(({ sum, weightedSum }) =>
+ sum > 0 ? round(weightedSum / sum, 0) : 0,
),
- values: averageData.values.map((item) => round(item / cohortCount, 0)),
};
return [averageRow, ...processed];
diff --git a/packages/trpc/src/routers/report.ts b/packages/trpc/src/routers/report.ts
index 6a6143e4..a1ef76ae 100644
--- a/packages/trpc/src/routers/report.ts
+++ b/packages/trpc/src/routers/report.ts
@@ -45,6 +45,10 @@ export const reportRouter = createTRPCRouter({
formula: report.formula,
previous: report.previous ?? false,
unit: report.unit,
+ criteria: report.criteria,
+ metric: report.metric,
+ funnelGroup: report.funnelGroup,
+ funnelWindow: report.funnelWindow,
},
});
}),
@@ -86,6 +90,10 @@ export const reportRouter = createTRPCRouter({
formula: report.formula,
previous: report.previous ?? false,
unit: report.unit,
+ criteria: report.criteria,
+ metric: report.metric,
+ funnelGroup: report.funnelGroup,
+ funnelWindow: report.funnelWindow,
},
});
}),
diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts
index 47555373..1d8703ce 100644
--- a/packages/validation/src/index.ts
+++ b/packages/validation/src/index.ts
@@ -59,6 +59,8 @@ export const zMetric = z.enum(objectToZodEnums(metrics));
export const zRange = z.enum(objectToZodEnums(timeWindows));
+export const zCriteria = z.enum(['on_or_after', 'on']);
+
export const zChartInput = z.object({
chartType: zChartType.default('linear'),
interval: zTimeInterval.default('day'),
@@ -73,15 +75,15 @@ export const zChartInput = z.object({
endDate: z.string().nullish(),
limit: z.number().optional(),
offset: z.number().optional(),
+ criteria: zCriteria.optional(),
+ funnelGroup: z.string().optional(),
+ funnelWindow: z.number().optional(),
});
-export const zCriteria = z.enum(['on_or_after', 'on']);
-
export const zReportInput = zChartInput.extend({
name: z.string(),
lineType: zLineType,
unit: z.string().optional(),
- criteria: zCriteria.optional(),
});
export const zInviteUser = z.object({