feature(dashboard): add more settings for funnels

* wip
* feature(dashboard): add more settings for funnels
This commit is contained in:
Carl-Gerhard Lindesvärd
2024-10-21 10:13:57 +02:00
committed by GitHub
parent 4846390531
commit c4a2ea4858
15 changed files with 276 additions and 212 deletions

View File

@@ -13,7 +13,7 @@ import { Chart } from './chart';
export function ReportFunnelChart() {
const {
report: { events, range, projectId },
report: { events, range, projectId, funnelWindow, funnelGroup },
isLazyLoading,
} = useReportChartContext();
@@ -24,6 +24,8 @@ export function ReportFunnelChart() {
interval: 'day',
chartType: 'funnel',
breakdowns: [],
funnelWindow,
funnelGroup,
previous: false,
metric: 'sum',
};

View File

@@ -8,12 +8,18 @@ import { api, handleError } from '@/trpc/client';
import { SaveIcon } from 'lucide-react';
import { toast } from 'sonner';
import { useIsFetching } from '@tanstack/react-query';
import { getQueryKey } from '@trpc/react-query';
import { resetDirty } from './reportSlice';
interface ReportSaveButtonProps {
className?: string;
}
export function ReportSaveButton({ className }: ReportSaveButtonProps) {
const fetching = [
useIsFetching(getQueryKey(api.chart.chart)),
useIsFetching(getQueryKey(api.chart.cohort)),
];
const { reportId } = useAppParams<{ reportId: string | undefined }>();
const dispatch = useDispatch();
const update = api.report.update.useMutation({
@@ -26,13 +32,14 @@ export function ReportSaveButton({ className }: ReportSaveButtonProps) {
onError: handleError,
});
const report = useSelector((state) => state.report);
const isLoading = update.isLoading || fetching.some((f) => f !== 0);
if (reportId) {
return (
<Button
className={className}
disabled={!report.dirty}
loading={update.isLoading}
loading={update.isLoading || isLoading}
onClick={() => {
update.mutate({
reportId: reportId,
@@ -55,6 +62,7 @@ export function ReportSaveButton({ className }: ReportSaveButtonProps) {
});
}}
icon={SaveIcon}
loading={isLoading}
>
Save
</Button>

View File

@@ -56,6 +56,8 @@ const initialState: InitialState = {
metric: 'sum',
limit: 500,
criteria: 'on_or_after',
funnelGroup: undefined,
funnelWindow: undefined,
};
export const reportSlice = createSlice({
@@ -266,6 +268,16 @@ export const reportSlice = createSlice({
state.dirty = true;
state.unit = action.payload || undefined;
},
changeFunnelGroup(state, action: PayloadAction<string | undefined>) {
state.dirty = true;
state.funnelGroup = action.payload || undefined;
},
changeFunnelWindow(state, action: PayloadAction<number | undefined>) {
state.dirty = true;
state.funnelWindow = action.payload || undefined;
},
},
});
@@ -293,6 +305,8 @@ export const {
changePrevious,
changeCriteria,
changeUnit,
changeFunnelGroup,
changeFunnelWindow,
} = reportSlice.actions;
export default reportSlice.reducer;

View File

@@ -1,12 +1,8 @@
'use client';
import { Input } from '@/components/ui/input';
import { useDispatch, useSelector } from '@/redux';
import { Badge } from '@/components/ui/badge';
import { AnimatePresence, motion } from 'framer-motion';
import { RefreshCcwIcon } from 'lucide-react';
import { type InputHTMLAttributes, useEffect, useState } from 'react';
import { InputEnter } from '@/components/ui/input-enter';
import { changeFormula } from '../reportSlice';
export function ReportFormula() {
@@ -17,7 +13,7 @@ export function ReportFormula() {
<div>
<h3 className="mb-2 font-medium">Formula</h3>
<div className="flex flex-col gap-4">
<InputFormula
<InputEnter
placeholder="eg: A/B"
value={formula ?? ''}
onChangeValue={(value) => {
@@ -28,54 +24,3 @@ export function ReportFormula() {
</div>
);
}
function InputFormula({
value,
onChangeValue,
...props
}: {
value: string | undefined;
onChangeValue: (value: string) => void;
} & InputHTMLAttributes<HTMLInputElement>) {
const [internalValue, setInternalValue] = useState(value ?? '');
useEffect(() => {
if (value !== internalValue) {
setInternalValue(value ?? '');
}
}, [value]);
return (
<div className="relative w-full">
<Input
{...props}
value={internalValue}
onChange={(e) => setInternalValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onChangeValue(internalValue);
}
}}
size="default"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<AnimatePresence>
{internalValue !== value && (
<motion.button
key="refresh"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
onClick={() => onChangeValue(internalValue)}
>
<Badge variant="muted">
Press enter
<RefreshCcwIcon className="ml-1 h-3 w-3" />
</Badge>
</motion.button>
)}
</AnimatePresence>
</div>
</div>
);
}

View File

@@ -3,16 +3,25 @@
import { Combobox } from '@/components/ui/combobox';
import { useDispatch, useSelector } from '@/redux';
import { InputEnter } from '@/components/ui/input-enter';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { useMemo } from 'react';
import { changeCriteria, changePrevious, changeUnit } from '../reportSlice';
import {
changeCriteria,
changeFunnelGroup,
changeFunnelWindow,
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 funnelGroup = useSelector((state) => state.report.funnelGroup);
const funnelWindow = useSelector((state) => state.report.funnelWindow);
const dispatch = useDispatch();
@@ -28,6 +37,11 @@ export function ReportSettings() {
fields.push('unit');
}
if (chartType === 'funnel') {
fields.push('funnelGroup');
fields.push('funnelWindow');
}
return fields;
}, [chartType]);
@@ -41,7 +55,9 @@ export function ReportSettings() {
<div className="col rounded-lg border bg-def-100 p-4 gap-2">
{fields.includes('previous') && (
<Label className="flex items-center justify-between mb-0">
<span>Compare to previous period</span>
<span className="whitespace-nowrap">
Compare to previous period
</span>
<Switch
checked={previous}
onCheckedChange={(val) => dispatch(changePrevious(!!val))}
@@ -49,8 +65,8 @@ export function ReportSettings() {
</Label>
)}
{fields.includes('criteria') && (
<div className="flex items-center justify-between">
<span>Criteria</span>
<div className="flex items-center justify-between gap-4">
<span className="whitespace-nowrap font-medium">Criteria</span>
<Combobox
align="end"
placeholder="Select criteria"
@@ -70,8 +86,8 @@ export function ReportSettings() {
</div>
)}
{fields.includes('unit') && (
<div className="flex items-center justify-between">
<span>Unit</span>
<div className="flex items-center justify-between gap-4">
<span className="whitespace-nowrap font-medium">Unit</span>
<Combobox
align="end"
placeholder="Unit"
@@ -92,6 +108,49 @@ export function ReportSettings() {
/>
</div>
)}
{fields.includes('funnelGroup') && (
<div className="flex items-center justify-between gap-4">
<span className="whitespace-nowrap font-medium">Funnel Group</span>
<Combobox
align="end"
placeholder="Default: Session"
value={funnelGroup || 'session_id'}
onChange={(val) => {
dispatch(
changeFunnelGroup(val === 'session_id' ? undefined : val),
);
}}
items={[
{
label: 'Session',
value: 'session_id',
},
{
label: 'Profile',
value: 'profile_id',
},
]}
/>
</div>
)}
{fields.includes('funnelWindow') && (
<div className="flex items-center justify-between gap-4">
<span className="whitespace-nowrap font-medium">Funnel Window</span>
<InputEnter
type="number"
value={funnelWindow ? String(funnelWindow) : ''}
placeholder="Default: 24h"
onChangeValue={(value) => {
const parsed = Number.parseFloat(value);
if (Number.isNaN(parsed)) {
dispatch(changeFunnelWindow(undefined));
} else {
dispatch(changeFunnelWindow(parsed));
}
}}
/>
</div>
)}
</div>
</div>
);

View File

@@ -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..."
/>
) : (
<FilterRawInput
<InputEnter
value={filter.value[0] ? String(filter.value[0]) : ''}
onChangeValue={(value) => changeFilterValue([value])}
/>
@@ -194,53 +189,3 @@ export function PureFilterItem({
</div>
);
}
function FilterRawInput({
value,
onChangeValue,
}: {
value: string;
onChangeValue: (value: string) => void;
}) {
const [internalValue, setInternalValue] = useState(value || '');
useEffect(() => {
if (value !== internalValue) {
setInternalValue(value);
}
}, [value]);
return (
<div className="relative w-full">
<Input
value={internalValue}
onChange={(e) => setInternalValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onChangeValue(internalValue);
}
}}
placeholder="Value"
size="default"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<AnimatePresence>
{internalValue !== value && (
<motion.button
key="refresh"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
onClick={() => onChangeValue(internalValue)}
>
<Badge variant="muted">
Press enter
<RefreshCcwIcon className="ml-1 h-3 w-3" />
</Badge>
</motion.button>
)}
</AnimatePresence>
</div>
</div>
);
}

View File

@@ -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<HTMLInputElement>) {
const [internalValue, setInternalValue] = useState(value ?? '');
useEffect(() => {
if (value !== internalValue) {
console.log(value, internalValue);
setInternalValue(value ?? '');
}
}, [value]);
return (
<div className="relative w-full">
<Input
{...props}
value={internalValue}
onChange={(e) => setInternalValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onChangeValue(internalValue);
}
}}
size="default"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<AnimatePresence>
{internalValue !== value && (
<motion.button
key="refresh"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
onClick={() => onChangeValue(internalValue)}
>
<Badge variant="muted">
Press enter
<RefreshCcwIcon className="ml-1 h-3 w-3" />
</Badge>
</motion.button>
)}
</AnimatePresence>
</div>
</div>
);
}

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "reports" ADD COLUMN "funnelGroup" TEXT,
ADD COLUMN "funnelWindow" DOUBLE PRECISION;

View File

@@ -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])

View File

@@ -129,14 +129,16 @@ export async function chQuery<T extends Record<string, any>>(
}
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]!;
}

View File

@@ -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,
};
}

View File

@@ -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) {

View File

@@ -406,36 +406,46 @@ function processCohortData(
};
});
// Initialize aggregation for averages
const averageData: {
sum: number;
values: Array<number>;
percentages: Array<number>;
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];

View File

@@ -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,
},
});
}),

View File

@@ -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({