feature(dashboard): add new retention chart type

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-10-15 20:40:24 +02:00
committed by Carl-Gerhard Lindesvärd
parent e2065da16e
commit f977c5454a
53 changed files with 1463 additions and 364 deletions

View File

@@ -21,7 +21,8 @@ export function ReportInterval({ className }: ReportIntervalProps) {
chartType !== 'linear' &&
chartType !== 'histogram' &&
chartType !== 'area' &&
chartType !== 'metric'
chartType !== 'metric' &&
chartType !== 'retention'
) {
return null;
}

View File

@@ -24,7 +24,9 @@ import type {
IChartRange,
IChartType,
IInterval,
zCriteria,
} from '@openpanel/validation';
import type { z } from 'zod';
type InitialState = IChartProps & {
dirty: boolean;
@@ -53,6 +55,7 @@ const initialState: InitialState = {
unit: undefined,
metric: 'sum',
limit: 500,
criteria: 'on_or_after',
};
export const reportSlice = createSlice({
@@ -251,6 +254,18 @@ export const reportSlice = createSlice({
state.dirty = true;
state.formula = action.payload;
},
changeCriteria(state, action: PayloadAction<z.infer<typeof zCriteria>>) {
state.dirty = true;
state.criteria = action.payload;
},
changeUnit(state, action: PayloadAction<string | undefined>) {
console.log('here?!?!', action.payload);
state.dirty = true;
state.unit = action.payload || undefined;
},
},
});
@@ -276,6 +291,8 @@ export const {
resetDirty,
changeFormula,
changePrevious,
changeCriteria,
changeUnit,
} = reportSlice.actions;
export default reportSlice.reducer;

View File

@@ -19,14 +19,10 @@ export function EventPropertiesCombobox({
}: EventPropertiesComboboxProps) {
const dispatch = useDispatch();
const { projectId } = useAppParams();
const range = useSelector((state) => state.report.range);
const interval = useSelector((state) => state.report.interval);
const properties = useEventProperties(
{
event: event.name,
projectId,
range,
interval,
},
{
enabled: !!event.name,

View File

@@ -16,14 +16,10 @@ import type { ReportEventMoreProps } from './ReportEventMore';
export function ReportBreakdowns() {
const { projectId } = useAppParams();
const selectedBreakdowns = useSelector((state) => state.report.breakdowns);
const interval = useSelector((state) => state.report.interval);
const range = useSelector((state) => state.report.range);
const dispatch = useDispatch();
const properties = useEventProperties({
projectId,
range,
interval,
}).map((item) => ({
value: item,
label: item, // <RenderDots truncate>{item}</RenderDots>,

View File

@@ -1,7 +1,6 @@
'use client';
import { ColorSquare } from '@/components/color-square';
import { Checkbox } from '@/components/ui/checkbox';
import { Combobox } from '@/components/ui/combobox';
import { DropdownMenuComposed } from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
@@ -14,12 +13,8 @@ import { GanttChart, GanttChartIcon, Users } from 'lucide-react';
import { alphabetIds } from '@openpanel/constants';
import type { IChartEvent } from '@openpanel/validation';
import {
addEvent,
changeEvent,
changePrevious,
removeEvent,
} from '../reportSlice';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import { addEvent, changeEvent, removeEvent } from '../reportSlice';
import { EventPropertiesCombobox } from './EventPropertiesCombobox';
import { ReportEventMore } from './ReportEventMore';
import type { ReportEventMoreProps } from './ReportEventMore';
@@ -27,25 +22,22 @@ import { FiltersCombobox } from './filters/FiltersCombobox';
import { FiltersList } from './filters/FiltersList';
export function ReportEvents() {
const previous = useSelector((state) => state.report.previous);
const selectedEvents = useSelector((state) => state.report.events);
const startDate = useSelector((state) => state.report.startDate);
const endDate = useSelector((state) => state.report.endDate);
const range = useSelector((state) => state.report.range);
const interval = useSelector((state) => state.report.interval);
const chartType = useSelector((state) => state.report.chartType);
const dispatch = useDispatch();
const { projectId } = useAppParams();
const eventNames = useEventNames({
projectId,
startDate,
endDate,
range,
interval,
});
const showSegment = !['retention', 'funnel'].includes(chartType);
const showAddFilter = !['retention'].includes(chartType);
const showDisplayNameInput = !['retention'].includes(chartType);
const isAddEventDisabled =
chartType === 'retention' && selectedEvents.length >= 2;
const dispatchChangeEvent = useDebounceFn((event: IChartEvent) => {
dispatch(changeEvent(event));
});
const isSelectManyEvents = chartType === 'retention';
const handleMore = (event: IChartEvent) => {
const callback: ReportEventMoreProps['onClick'] = (action) => {
@@ -68,137 +60,173 @@ export function ReportEvents() {
<div key={event.id} className="rounded-lg border bg-def-100">
<div className="flex items-center gap-2 p-2">
<ColorSquare>{alphabetIds[index]}</ColorSquare>
<Combobox
icon={GanttChartIcon}
className="flex-1"
searchable
value={event.name}
onChange={(value) => {
dispatch(
changeEvent({
{isSelectManyEvents ? (
<ComboboxAdvanced
className="flex-1"
value={event.filters[0]?.value ?? []}
onChange={(value) => {
dispatch(
changeEvent({
id: event.id,
segment: 'user',
filters: [
{
name: 'name',
operator: 'is',
value: value,
},
],
name: '*',
}),
);
}}
items={eventNames.map((item) => ({
label: item.name,
value: item.name,
}))}
placeholder="Select event"
/>
) : (
<Combobox
icon={GanttChartIcon}
className="flex-1"
searchable
value={event.name}
onChange={(value) => {
dispatch(
changeEvent({
...event,
name: value,
filters: [],
}),
);
}}
items={eventNames.map((item) => ({
label: item.name,
value: item.name,
}))}
placeholder="Select event"
/>
)}
{showDisplayNameInput && (
<Input
placeholder={
event.name
? `${event.name} (${alphabetIds[index]})`
: 'Display name'
}
defaultValue={event.displayName}
onChange={(e) => {
dispatchChangeEvent({
...event,
name: value,
filters: [],
}),
);
}}
items={eventNames.map((item) => ({
label: item.name,
value: item.name,
}))}
placeholder="Select event"
/>
<Input
placeholder={
event.name
? `${event.name} (${alphabetIds[index]})`
: 'Display name'
}
defaultValue={event.displayName}
onChange={(e) => {
dispatchChangeEvent({
...event,
displayName: e.target.value,
});
}}
/>
displayName: e.target.value,
});
}}
/>
)}
<ReportEventMore onClick={handleMore(event)} />
</div>
{/* Segment and Filter buttons */}
<div className="flex gap-2 p-2 pt-0 ">
<DropdownMenuComposed
onChange={(segment) => {
dispatch(
changeEvent({
...event,
segment,
}),
);
}}
items={[
{
value: 'event',
label: 'All events',
},
{
value: 'user',
label: 'Unique users',
},
{
value: 'session',
label: 'Unique sessions',
},
{
value: 'user_average',
label: 'Average event per user',
},
{
value: 'one_event_per_user',
label: 'One event per user',
},
{
value: 'property_sum',
label: 'Sum of property',
},
{
value: 'property_average',
label: 'Average of property',
},
]}
label="Segment"
>
<button
type="button"
className="flex items-center gap-1 rounded-md border border-border bg-card p-1 px-2 text-sm font-medium leading-none"
>
{event.segment === 'user' ? (
<>
<Users size={12} /> Unique users
</>
) : event.segment === 'session' ? (
<>
<Users size={12} /> Unique sessions
</>
) : event.segment === 'user_average' ? (
<>
<Users size={12} /> Average event per user
</>
) : event.segment === 'one_event_per_user' ? (
<>
<Users size={12} /> One event per user
</>
) : event.segment === 'property_sum' ? (
<>
<Users size={12} /> Sum of property
</>
) : event.segment === 'property_average' ? (
<>
<Users size={12} /> Average of property
</>
) : (
<>
<GanttChart size={12} /> All events
</>
)}
</button>
</DropdownMenuComposed>
{/* */}
<FiltersCombobox event={event} />
{(showSegment || showAddFilter) && (
<div className="flex gap-2 p-2 pt-0 ">
{showSegment && (
<DropdownMenuComposed
onChange={(segment) => {
dispatch(
changeEvent({
...event,
segment,
}),
);
}}
items={[
{
value: 'event',
label: 'All events',
},
{
value: 'user',
label: 'Unique users',
},
{
value: 'session',
label: 'Unique sessions',
},
{
value: 'user_average',
label: 'Average event per user',
},
{
value: 'one_event_per_user',
label: 'One event per user',
},
{
value: 'property_sum',
label: 'Sum of property',
},
{
value: 'property_average',
label: 'Average of property',
},
]}
label="Segment"
>
<button
type="button"
className="flex items-center gap-1 rounded-md border border-border bg-card p-1 px-2 text-sm font-medium leading-none"
>
{event.segment === 'user' ? (
<>
<Users size={12} /> Unique users
</>
) : event.segment === 'session' ? (
<>
<Users size={12} /> Unique sessions
</>
) : event.segment === 'user_average' ? (
<>
<Users size={12} /> Average event per user
</>
) : event.segment === 'one_event_per_user' ? (
<>
<Users size={12} /> One event per user
</>
) : event.segment === 'property_sum' ? (
<>
<Users size={12} /> Sum of property
</>
) : event.segment === 'property_average' ? (
<>
<Users size={12} /> Average of property
</>
) : (
<>
<GanttChart size={12} /> All events
</>
)}
</button>
</DropdownMenuComposed>
)}
{/* */}
{showAddFilter && <FiltersCombobox event={event} />}
{(event.segment === 'property_average' ||
event.segment === 'property_sum') && (
<EventPropertiesCombobox event={event} />
)}
</div>
{showSegment &&
(event.segment === 'property_average' ||
event.segment === 'property_sum') && (
<EventPropertiesCombobox event={event} />
)}
</div>
)}
{/* Filters */}
<FiltersList event={event} />
{!isSelectManyEvents && <FiltersList event={event} />}
</div>
);
})}
<Combobox
disabled={isAddEventDisabled}
icon={GanttChartIcon}
value={''}
searchable
@@ -218,17 +246,6 @@ export function ReportEvents() {
placeholder="Select event"
/>
</div>
<label
className="mt-4 flex cursor-pointer select-none items-center gap-2 font-medium"
htmlFor="previous"
>
<Checkbox
id="previous"
checked={previous}
onCheckedChange={(val) => dispatch(changePrevious(!!val))}
/>
Show previous / Compare
</label>
</div>
);
}

View File

@@ -0,0 +1,98 @@
'use client';
import { Combobox } from '@/components/ui/combobox';
import { useDispatch, useSelector } from '@/redux';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { useMemo } from 'react';
import { changeCriteria, changePrevious, changeUnit } from '../reportSlice';
export function ReportSettings() {
const chartType = useSelector((state) => state.report.chartType);
const previous = useSelector((state) => state.report.previous);
const criteria = useSelector((state) => state.report.criteria);
const unit = useSelector((state) => state.report.unit);
const dispatch = useDispatch();
const fields = useMemo(() => {
const fields = [];
if (chartType !== 'retention') {
fields.push('previous');
}
if (chartType === 'retention') {
fields.push('criteria');
fields.push('unit');
}
return fields;
}, [chartType]);
if (fields.length === 0) {
return null;
}
return (
<div>
<h3 className="mb-2 font-medium">Settings</h3>
<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>
<Switch
checked={previous}
onCheckedChange={(val) => dispatch(changePrevious(!!val))}
/>
</Label>
)}
{fields.includes('criteria') && (
<div className="flex items-center justify-between">
<span>Criteria</span>
<Combobox
align="end"
placeholder="Select criteria"
value={criteria}
onChange={(val) => dispatch(changeCriteria(val))}
items={[
{
label: 'On or After',
value: 'on_or_after',
},
{
label: 'On',
value: 'on',
},
]}
/>
</div>
)}
{fields.includes('unit') && (
<div className="flex items-center justify-between">
<span>Unit</span>
<Combobox
align="end"
placeholder="Unit"
value={unit || 'count'}
onChange={(val) => {
dispatch(changeUnit(val === 'count' ? undefined : val));
}}
items={[
{
label: 'Count',
value: 'count',
},
{
label: '%',
value: '%',
},
]}
/>
</div>
)}
</div>
</div>
);
}

View File

@@ -5,15 +5,17 @@ import { useSelector } from '@/redux';
import { ReportBreakdowns } from './ReportBreakdowns';
import { ReportEvents } from './ReportEvents';
import { ReportFormula } from './ReportFormula';
import { ReportSettings } from './ReportSettings';
export function ReportSidebar() {
const { chartType } = useSelector((state) => state.report);
const showFormula = chartType !== 'funnel';
const showBreakdown = chartType !== 'funnel';
const showFormula = chartType !== 'funnel' && chartType !== 'retention';
const showBreakdown = chartType !== 'funnel' && chartType !== 'retention';
return (
<>
<div className="flex flex-col gap-8">
<ReportEvents />
<ReportSettings />
{showFormula && <ReportFormula />}
{showBreakdown && <ReportBreakdowns />}
</div>

View File

@@ -34,10 +34,6 @@ interface FilterProps {
interface PureFilterProps {
eventName: string;
filter: IChartEventFilter;
range: IChartRange;
startDate: string | null;
endDate: string | null;
interval: IInterval;
onRemove: (filter: IChartEventFilter) => void;
onChangeValue: (
value: IChartEventFilterValue[],
@@ -111,10 +107,6 @@ export function FilterItem({ filter, event }: FilterProps) {
<PureFilterItem
filter={filter}
eventName={event.name}
range={range}
startDate={startDate}
endDate={endDate}
interval={interval}
onRemove={onRemove}
onChangeValue={onChangeValue}
onChangeOperator={onChangeOperator}
@@ -126,10 +118,6 @@ export function FilterItem({ filter, event }: FilterProps) {
export function PureFilterItem({
filter,
eventName,
range,
startDate,
endDate,
interval,
onRemove,
onChangeValue,
onChangeOperator,
@@ -142,10 +130,6 @@ export function PureFilterItem({
event: eventName,
property: filter.name,
projectId,
range,
interval,
startDate,
endDate,
});
const valuesCombobox =
@@ -188,11 +172,7 @@ export function PureFilterItem({
}))}
label="Operator"
>
<Button
variant={'outline'}
className="whitespace-nowrap"
size="default"
>
<Button variant={'outline'} className="whitespace-nowrap">
{operators[filter.operator]}
</Button>
</DropdownMenuComposed>

View File

@@ -15,20 +15,12 @@ interface FiltersComboboxProps {
export function FiltersCombobox({ event }: FiltersComboboxProps) {
const dispatch = useDispatch();
const interval = useSelector((state) => state.report.interval);
const range = useSelector((state) => state.report.range);
const startDate = useSelector((state) => state.report.startDate);
const endDate = useSelector((state) => state.report.endDate);
const { projectId } = useAppParams();
const properties = useEventProperties(
{
event: event.name,
projectId,
range,
interval,
startDate,
endDate,
},
{
enabled: !!event.name,