feature(dashboard): add more settings for funnels
* wip * feature(dashboard): add more settings for funnels
This commit is contained in:
committed by
GitHub
parent
4846390531
commit
c4a2ea4858
@@ -13,7 +13,7 @@ import { Chart } from './chart';
|
|||||||
|
|
||||||
export function ReportFunnelChart() {
|
export function ReportFunnelChart() {
|
||||||
const {
|
const {
|
||||||
report: { events, range, projectId },
|
report: { events, range, projectId, funnelWindow, funnelGroup },
|
||||||
isLazyLoading,
|
isLazyLoading,
|
||||||
} = useReportChartContext();
|
} = useReportChartContext();
|
||||||
|
|
||||||
@@ -24,6 +24,8 @@ export function ReportFunnelChart() {
|
|||||||
interval: 'day',
|
interval: 'day',
|
||||||
chartType: 'funnel',
|
chartType: 'funnel',
|
||||||
breakdowns: [],
|
breakdowns: [],
|
||||||
|
funnelWindow,
|
||||||
|
funnelGroup,
|
||||||
previous: false,
|
previous: false,
|
||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,12 +8,18 @@ import { api, handleError } from '@/trpc/client';
|
|||||||
import { SaveIcon } from 'lucide-react';
|
import { SaveIcon } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { useIsFetching } from '@tanstack/react-query';
|
||||||
|
import { getQueryKey } from '@trpc/react-query';
|
||||||
import { resetDirty } from './reportSlice';
|
import { resetDirty } from './reportSlice';
|
||||||
|
|
||||||
interface ReportSaveButtonProps {
|
interface ReportSaveButtonProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
export function ReportSaveButton({ className }: ReportSaveButtonProps) {
|
export function ReportSaveButton({ className }: ReportSaveButtonProps) {
|
||||||
|
const fetching = [
|
||||||
|
useIsFetching(getQueryKey(api.chart.chart)),
|
||||||
|
useIsFetching(getQueryKey(api.chart.cohort)),
|
||||||
|
];
|
||||||
const { reportId } = useAppParams<{ reportId: string | undefined }>();
|
const { reportId } = useAppParams<{ reportId: string | undefined }>();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const update = api.report.update.useMutation({
|
const update = api.report.update.useMutation({
|
||||||
@@ -26,13 +32,14 @@ export function ReportSaveButton({ className }: ReportSaveButtonProps) {
|
|||||||
onError: handleError,
|
onError: handleError,
|
||||||
});
|
});
|
||||||
const report = useSelector((state) => state.report);
|
const report = useSelector((state) => state.report);
|
||||||
|
const isLoading = update.isLoading || fetching.some((f) => f !== 0);
|
||||||
|
|
||||||
if (reportId) {
|
if (reportId) {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
className={className}
|
className={className}
|
||||||
disabled={!report.dirty}
|
disabled={!report.dirty}
|
||||||
loading={update.isLoading}
|
loading={update.isLoading || isLoading}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
update.mutate({
|
update.mutate({
|
||||||
reportId: reportId,
|
reportId: reportId,
|
||||||
@@ -55,6 +62,7 @@ export function ReportSaveButton({ className }: ReportSaveButtonProps) {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
icon={SaveIcon}
|
icon={SaveIcon}
|
||||||
|
loading={isLoading}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ const initialState: InitialState = {
|
|||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
limit: 500,
|
limit: 500,
|
||||||
criteria: 'on_or_after',
|
criteria: 'on_or_after',
|
||||||
|
funnelGroup: undefined,
|
||||||
|
funnelWindow: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const reportSlice = createSlice({
|
export const reportSlice = createSlice({
|
||||||
@@ -266,6 +268,16 @@ export const reportSlice = createSlice({
|
|||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
state.unit = action.payload || undefined;
|
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,
|
changePrevious,
|
||||||
changeCriteria,
|
changeCriteria,
|
||||||
changeUnit,
|
changeUnit,
|
||||||
|
changeFunnelGroup,
|
||||||
|
changeFunnelWindow,
|
||||||
} = reportSlice.actions;
|
} = reportSlice.actions;
|
||||||
|
|
||||||
export default reportSlice.reducer;
|
export default reportSlice.reducer;
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { useDispatch, useSelector } from '@/redux';
|
import { useDispatch, useSelector } from '@/redux';
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { InputEnter } from '@/components/ui/input-enter';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
|
||||||
import { RefreshCcwIcon } from 'lucide-react';
|
|
||||||
import { type InputHTMLAttributes, useEffect, useState } from 'react';
|
|
||||||
import { changeFormula } from '../reportSlice';
|
import { changeFormula } from '../reportSlice';
|
||||||
|
|
||||||
export function ReportFormula() {
|
export function ReportFormula() {
|
||||||
@@ -17,7 +13,7 @@ export function ReportFormula() {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="mb-2 font-medium">Formula</h3>
|
<h3 className="mb-2 font-medium">Formula</h3>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<InputFormula
|
<InputEnter
|
||||||
placeholder="eg: A/B"
|
placeholder="eg: A/B"
|
||||||
value={formula ?? ''}
|
value={formula ?? ''}
|
||||||
onChangeValue={(value) => {
|
onChangeValue={(value) => {
|
||||||
@@ -28,54 +24,3 @@ export function ReportFormula() {
|
|||||||
</div>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,16 +3,25 @@
|
|||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { useDispatch, useSelector } from '@/redux';
|
import { useDispatch, useSelector } from '@/redux';
|
||||||
|
|
||||||
|
import { InputEnter } from '@/components/ui/input-enter';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { changeCriteria, changePrevious, changeUnit } from '../reportSlice';
|
import {
|
||||||
|
changeCriteria,
|
||||||
|
changeFunnelGroup,
|
||||||
|
changeFunnelWindow,
|
||||||
|
changePrevious,
|
||||||
|
changeUnit,
|
||||||
|
} from '../reportSlice';
|
||||||
|
|
||||||
export function ReportSettings() {
|
export function ReportSettings() {
|
||||||
const chartType = useSelector((state) => state.report.chartType);
|
const chartType = useSelector((state) => state.report.chartType);
|
||||||
const previous = useSelector((state) => state.report.previous);
|
const previous = useSelector((state) => state.report.previous);
|
||||||
const criteria = useSelector((state) => state.report.criteria);
|
const criteria = useSelector((state) => state.report.criteria);
|
||||||
const unit = useSelector((state) => state.report.unit);
|
const unit = useSelector((state) => state.report.unit);
|
||||||
|
const funnelGroup = useSelector((state) => state.report.funnelGroup);
|
||||||
|
const funnelWindow = useSelector((state) => state.report.funnelWindow);
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
@@ -28,6 +37,11 @@ export function ReportSettings() {
|
|||||||
fields.push('unit');
|
fields.push('unit');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (chartType === 'funnel') {
|
||||||
|
fields.push('funnelGroup');
|
||||||
|
fields.push('funnelWindow');
|
||||||
|
}
|
||||||
|
|
||||||
return fields;
|
return fields;
|
||||||
}, [chartType]);
|
}, [chartType]);
|
||||||
|
|
||||||
@@ -41,7 +55,9 @@ export function ReportSettings() {
|
|||||||
<div className="col rounded-lg border bg-def-100 p-4 gap-2">
|
<div className="col rounded-lg border bg-def-100 p-4 gap-2">
|
||||||
{fields.includes('previous') && (
|
{fields.includes('previous') && (
|
||||||
<Label className="flex items-center justify-between mb-0">
|
<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
|
<Switch
|
||||||
checked={previous}
|
checked={previous}
|
||||||
onCheckedChange={(val) => dispatch(changePrevious(!!val))}
|
onCheckedChange={(val) => dispatch(changePrevious(!!val))}
|
||||||
@@ -49,8 +65,8 @@ export function ReportSettings() {
|
|||||||
</Label>
|
</Label>
|
||||||
)}
|
)}
|
||||||
{fields.includes('criteria') && (
|
{fields.includes('criteria') && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<span>Criteria</span>
|
<span className="whitespace-nowrap font-medium">Criteria</span>
|
||||||
<Combobox
|
<Combobox
|
||||||
align="end"
|
align="end"
|
||||||
placeholder="Select criteria"
|
placeholder="Select criteria"
|
||||||
@@ -70,8 +86,8 @@ export function ReportSettings() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{fields.includes('unit') && (
|
{fields.includes('unit') && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<span>Unit</span>
|
<span className="whitespace-nowrap font-medium">Unit</span>
|
||||||
<Combobox
|
<Combobox
|
||||||
align="end"
|
align="end"
|
||||||
placeholder="Unit"
|
placeholder="Unit"
|
||||||
@@ -92,6 +108,49 @@ export function ReportSettings() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
import { ColorSquare } from '@/components/color-square';
|
import { ColorSquare } from '@/components/color-square';
|
||||||
import { RenderDots } from '@/components/ui/RenderDots';
|
import { RenderDots } from '@/components/ui/RenderDots';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
|
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
|
||||||
import { DropdownMenuComposed } from '@/components/ui/dropdown-menu';
|
import { DropdownMenuComposed } from '@/components/ui/dropdown-menu';
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { useAppParams } from '@/hooks/useAppParams';
|
import { useAppParams } from '@/hooks/useAppParams';
|
||||||
import { useMappings } from '@/hooks/useMappings';
|
import { useMappings } from '@/hooks/useMappings';
|
||||||
import { usePropertyValues } from '@/hooks/usePropertyValues';
|
import { usePropertyValues } from '@/hooks/usePropertyValues';
|
||||||
import { useDispatch, useSelector } from '@/redux';
|
import { useDispatch, useSelector } from '@/redux';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { SlidersHorizontal, Trash } from 'lucide-react';
|
||||||
import { RefreshCcwIcon, SlidersHorizontal, Trash } from 'lucide-react';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { operators } from '@openpanel/constants';
|
import { operators } from '@openpanel/constants';
|
||||||
import type {
|
import type {
|
||||||
@@ -19,11 +15,10 @@ import type {
|
|||||||
IChartEventFilter,
|
IChartEventFilter,
|
||||||
IChartEventFilterOperator,
|
IChartEventFilterOperator,
|
||||||
IChartEventFilterValue,
|
IChartEventFilterValue,
|
||||||
IChartRange,
|
|
||||||
IInterval,
|
|
||||||
} from '@openpanel/validation';
|
} from '@openpanel/validation';
|
||||||
import { mapKeys } from '@openpanel/validation';
|
import { mapKeys } from '@openpanel/validation';
|
||||||
|
|
||||||
|
import { InputEnter } from '@/components/ui/input-enter';
|
||||||
import { changeEvent } from '../../reportSlice';
|
import { changeEvent } from '../../reportSlice';
|
||||||
|
|
||||||
interface FilterProps {
|
interface FilterProps {
|
||||||
@@ -185,7 +180,7 @@ export function PureFilterItem({
|
|||||||
placeholder="Select..."
|
placeholder="Select..."
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<FilterRawInput
|
<InputEnter
|
||||||
value={filter.value[0] ? String(filter.value[0]) : ''}
|
value={filter.value[0] ? String(filter.value[0]) : ''}
|
||||||
onChangeValue={(value) => changeFilterValue([value])}
|
onChangeValue={(value) => changeFilterValue([value])}
|
||||||
/>
|
/>
|
||||||
@@ -194,53 +189,3 @@ export function PureFilterItem({
|
|||||||
</div>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
60
apps/dashboard/src/components/ui/input-enter.tsx
Normal file
60
apps/dashboard/src/components/ui/input-enter.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "reports" ADD COLUMN "funnelGroup" TEXT,
|
||||||
|
ADD COLUMN "funnelWindow" DOUBLE PRECISION;
|
||||||
@@ -231,21 +231,23 @@ enum Metric {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Report {
|
model Report {
|
||||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||||
name String
|
name String
|
||||||
interval Interval
|
interval Interval
|
||||||
range String @default("30d")
|
range String @default("30d")
|
||||||
chartType ChartType
|
chartType ChartType
|
||||||
lineType String @default("monotone")
|
lineType String @default("monotone")
|
||||||
breakdowns Json
|
breakdowns Json
|
||||||
events Json
|
events Json
|
||||||
formula String?
|
formula String?
|
||||||
unit String?
|
unit String?
|
||||||
metric Metric @default(sum)
|
metric Metric @default(sum)
|
||||||
projectId String
|
projectId String
|
||||||
project Project @relation(fields: [projectId], references: [id])
|
project Project @relation(fields: [projectId], references: [id])
|
||||||
previous Boolean @default(false)
|
previous Boolean @default(false)
|
||||||
criteria String?
|
criteria String?
|
||||||
|
funnelGroup String?
|
||||||
|
funnelWindow Float?
|
||||||
|
|
||||||
dashboardId String
|
dashboardId String
|
||||||
dashboard Dashboard @relation(fields: [dashboardId], references: [id])
|
dashboard Dashboard @relation(fields: [dashboardId], references: [id])
|
||||||
|
|||||||
@@ -129,14 +129,16 @@ export async function chQuery<T extends Record<string, any>>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatClickhouseDate(
|
export function formatClickhouseDate(
|
||||||
_date: Date | string,
|
date: Date | string,
|
||||||
skipTime = false,
|
skipTime = false,
|
||||||
): string {
|
): string {
|
||||||
if (typeof _date === 'string') {
|
if (typeof date === 'string') {
|
||||||
return _date.slice(0, 19).replace('T', ' ');
|
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) {
|
if (skipTime) {
|
||||||
return date.toISOString().split('T')[0]!;
|
return date.toISOString().split('T')[0]!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ export function transformReport(
|
|||||||
metric: report.metric ?? 'sum',
|
metric: report.metric ?? 'sum',
|
||||||
unit: report.unit ?? undefined,
|
unit: report.unit ?? undefined,
|
||||||
criteria: (report.criteria as ICriteria) ?? undefined,
|
criteria: (report.criteria as ICriteria) ?? undefined,
|
||||||
|
funnelGroup: report.funnelGroup ?? undefined,
|
||||||
|
funnelWindow: report.funnelWindow ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -288,14 +288,15 @@ export function getChartPrevStartEndDate({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const ONE_DAY_IN_SECONDS = 60 * 60 * 24;
|
|
||||||
|
|
||||||
export async function getFunnelData({
|
export async function getFunnelData({
|
||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
...payload
|
...payload
|
||||||
}: IChartInput) {
|
}: IChartInput) {
|
||||||
|
const funnelWindow = (payload.funnelWindow || 24) * 3600;
|
||||||
|
const funnelGroup = payload.funnelGroup || 'session_id';
|
||||||
|
|
||||||
if (!startDate || !endDate) {
|
if (!startDate || !endDate) {
|
||||||
throw new Error('startDate and endDate are required');
|
throw new Error('startDate and endDate are required');
|
||||||
}
|
}
|
||||||
@@ -315,15 +316,15 @@ export async function getFunnelData({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const innerSql = `SELECT
|
const innerSql = `SELECT
|
||||||
session_id,
|
${funnelGroup},
|
||||||
windowFunnel(${ONE_DAY_IN_SECONDS}, 'strict_increase')(toUnixTimestamp(created_at), ${funnels.join(', ')}) AS level
|
windowFunnel(${funnelWindow}, 'strict_increase')(toUnixTimestamp(created_at), ${funnels.join(', ')}) AS level
|
||||||
FROM ${TABLE_NAMES.events}
|
FROM ${TABLE_NAMES.events}
|
||||||
WHERE
|
WHERE
|
||||||
project_id = ${escape(projectId)} AND
|
project_id = ${escape(projectId)} AND
|
||||||
created_at >= '${formatClickhouseDate(startDate)}' AND
|
created_at >= '${formatClickhouseDate(startDate)}' AND
|
||||||
created_at <= '${formatClickhouseDate(endDate)}' AND
|
created_at <= '${formatClickhouseDate(endDate)}' AND
|
||||||
name IN (${payload.events.map((event) => escape(event.name)).join(', ')})
|
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`;
|
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 & {
|
}: IChartInput & {
|
||||||
step: number;
|
step: number;
|
||||||
}) {
|
}) {
|
||||||
if (!startDate || !endDate) {
|
throw new Error('not implemented');
|
||||||
throw new Error('startDate and endDate are required');
|
// if (!startDate || !endDate) {
|
||||||
}
|
// throw new Error('startDate and endDate are required');
|
||||||
|
// }
|
||||||
|
|
||||||
if (payload.events.length === 0) {
|
// if (payload.events.length === 0) {
|
||||||
throw new Error('no events selected');
|
// throw new Error('no events selected');
|
||||||
}
|
// }
|
||||||
|
|
||||||
const funnels = payload.events.map((event) => {
|
// const funnels = payload.events.map((event) => {
|
||||||
const { sb, getWhere } = createSqlBuilder();
|
// const { sb, getWhere } = createSqlBuilder();
|
||||||
sb.where = getEventFiltersWhereClause(event.filters);
|
// sb.where = getEventFiltersWhereClause(event.filters);
|
||||||
sb.where.name = `name = ${escape(event.name)}`;
|
// sb.where.name = `name = ${escape(event.name)}`;
|
||||||
return getWhere().replace('WHERE ', '');
|
// return getWhere().replace('WHERE ', '');
|
||||||
});
|
// });
|
||||||
|
|
||||||
const innerSql = `SELECT
|
// const innerSql = `SELECT
|
||||||
session_id,
|
// session_id,
|
||||||
windowFunnel(${ONE_DAY_IN_SECONDS})(toUnixTimestamp(created_at), ${funnels.join(', ')}) AS level
|
// windowFunnel(${ONE_DAY_IN_SECONDS})(toUnixTimestamp(created_at), ${funnels.join(', ')}) AS level
|
||||||
FROM ${TABLE_NAMES.events}
|
// FROM ${TABLE_NAMES.events}
|
||||||
WHERE
|
// WHERE
|
||||||
project_id = ${escape(projectId)} AND
|
// project_id = ${escape(projectId)} AND
|
||||||
created_at >= '${formatClickhouseDate(startDate)}' AND
|
// created_at >= '${formatClickhouseDate(startDate)}' AND
|
||||||
created_at <= '${formatClickhouseDate(endDate)}' AND
|
// created_at <= '${formatClickhouseDate(endDate)}' AND
|
||||||
name IN (${payload.events.map((event) => escape(event.name)).join(', ')})
|
// name IN (${payload.events.map((event) => escape(event.name)).join(', ')})
|
||||||
GROUP BY session_id`;
|
// GROUP BY session_id`;
|
||||||
|
|
||||||
const profileIdsQuery = `WITH sessions AS (${innerSql})
|
// const profileIdsQuery = `WITH sessions AS (${innerSql})
|
||||||
SELECT
|
// SELECT
|
||||||
DISTINCT e.profile_id as id
|
// DISTINCT e.profile_id as id
|
||||||
FROM sessions s
|
// FROM sessions s
|
||||||
JOIN ${TABLE_NAMES.events} e ON s.session_id = e.session_id
|
// JOIN ${TABLE_NAMES.events} e ON s.session_id = e.session_id
|
||||||
WHERE
|
// WHERE
|
||||||
s.level = ${step} AND
|
// s.level = ${step} AND
|
||||||
e.project_id = ${escape(projectId)} AND
|
// e.project_id = ${escape(projectId)} AND
|
||||||
e.created_at >= '${formatClickhouseDate(startDate)}' AND
|
// e.created_at >= '${formatClickhouseDate(startDate)}' AND
|
||||||
e.created_at <= '${formatClickhouseDate(endDate)}' AND
|
// e.created_at <= '${formatClickhouseDate(endDate)}' AND
|
||||||
name IN (${payload.events.map((event) => escape(event.name)).join(', ')})
|
// name IN (${payload.events.map((event) => escape(event.name)).join(', ')})
|
||||||
ORDER BY e.created_at DESC
|
// ORDER BY e.created_at DESC
|
||||||
LIMIT 500
|
// LIMIT 500
|
||||||
`;
|
// `;
|
||||||
|
|
||||||
const res = await chQuery<{
|
// const res = await chQuery<{
|
||||||
id: string;
|
// id: string;
|
||||||
}>(profileIdsQuery);
|
// }>(profileIdsQuery);
|
||||||
|
|
||||||
return getProfiles(
|
// return getProfiles(
|
||||||
res.map((r) => r.id),
|
// res.map((r) => r.id),
|
||||||
projectId,
|
// projectId,
|
||||||
);
|
// );
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getChartSerie(payload: IGetChartDataInput) {
|
export async function getChartSerie(payload: IGetChartDataInput) {
|
||||||
|
|||||||
@@ -406,36 +406,46 @@ function processCohortData(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize aggregation for averages
|
|
||||||
const averageData: {
|
const averageData: {
|
||||||
sum: number;
|
totalSum: number;
|
||||||
values: Array<number>;
|
values: Array<{ sum: number; weightedSum: number }>;
|
||||||
percentages: Array<number>;
|
percentages: Array<{ sum: number; weightedSum: number }>;
|
||||||
} = {
|
} = {
|
||||||
sum: 0,
|
totalSum: 0,
|
||||||
values: range(0, diffInterval + 1).map(() => 0),
|
values: range(0, diffInterval + 1).map(() => ({ sum: 0, weightedSum: 0 })),
|
||||||
percentages: range(0, diffInterval + 1).map(() => 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) => {
|
processed.forEach((row) => {
|
||||||
averageData.sum += row.sum;
|
averageData.totalSum += row.sum;
|
||||||
row.values.forEach((value, index) => {
|
row.values.forEach((value, index) => {
|
||||||
averageData.values[index] += value;
|
if (value !== 0) {
|
||||||
averageData.percentages[index] += row.percentages[index]!;
|
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 weighted average values, excluding zeros
|
||||||
|
|
||||||
// Calculate average values
|
|
||||||
const averageRow = {
|
const averageRow = {
|
||||||
cohort_interval: 'Average',
|
cohort_interval: 'Weighted Average',
|
||||||
sum: cohortCount > 0 ? round(averageData.sum / cohortCount, 0) : 0,
|
sum: round(averageData.totalSum / processed.length, 0),
|
||||||
percentages: averageData.percentages.map((item) =>
|
percentages: averageData.percentages.map(({ sum, weightedSum }) =>
|
||||||
round(item / cohortCount, 2),
|
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];
|
return [averageRow, ...processed];
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ export const reportRouter = createTRPCRouter({
|
|||||||
formula: report.formula,
|
formula: report.formula,
|
||||||
previous: report.previous ?? false,
|
previous: report.previous ?? false,
|
||||||
unit: report.unit,
|
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,
|
formula: report.formula,
|
||||||
previous: report.previous ?? false,
|
previous: report.previous ?? false,
|
||||||
unit: report.unit,
|
unit: report.unit,
|
||||||
|
criteria: report.criteria,
|
||||||
|
metric: report.metric,
|
||||||
|
funnelGroup: report.funnelGroup,
|
||||||
|
funnelWindow: report.funnelWindow,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ export const zMetric = z.enum(objectToZodEnums(metrics));
|
|||||||
|
|
||||||
export const zRange = z.enum(objectToZodEnums(timeWindows));
|
export const zRange = z.enum(objectToZodEnums(timeWindows));
|
||||||
|
|
||||||
|
export const zCriteria = z.enum(['on_or_after', 'on']);
|
||||||
|
|
||||||
export const zChartInput = z.object({
|
export const zChartInput = z.object({
|
||||||
chartType: zChartType.default('linear'),
|
chartType: zChartType.default('linear'),
|
||||||
interval: zTimeInterval.default('day'),
|
interval: zTimeInterval.default('day'),
|
||||||
@@ -73,15 +75,15 @@ export const zChartInput = z.object({
|
|||||||
endDate: z.string().nullish(),
|
endDate: z.string().nullish(),
|
||||||
limit: z.number().optional(),
|
limit: z.number().optional(),
|
||||||
offset: 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({
|
export const zReportInput = zChartInput.extend({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
lineType: zLineType,
|
lineType: zLineType,
|
||||||
unit: z.string().optional(),
|
unit: z.string().optional(),
|
||||||
criteria: zCriteria.optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const zInviteUser = z.object({
|
export const zInviteUser = z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user