fix(api): filter empty values when using sum and average, added min and max

This commit is contained in:
Carl-Gerhard Lindesvärd
2025-06-04 12:45:26 +02:00
parent 5c5154ee86
commit 5b1e94e9ad
8 changed files with 142 additions and 135 deletions

View File

@@ -81,7 +81,7 @@
"lodash.isequal": "^4.5.0",
"lodash.throttle": "^4.1.1",
"lottie-react": "^2.4.0",
"lucide-react": "^0.451.0",
"lucide-react": "^0.513.0",
"mathjs": "^12.3.2",
"mitt": "^3.0.1",
"next": "14.2.1",

View File

@@ -0,0 +1,92 @@
import {
ActivityIcon,
ClockIcon,
EqualApproximatelyIcon,
type LucideIcon,
SigmaIcon,
TrendingDownIcon,
TrendingUpIcon,
UserCheck2Icon,
UserCheckIcon,
UsersIcon,
} from 'lucide-react';
import { chartSegments } from '@openpanel/constants';
import { type IChartEventSegment, mapKeys } from '@openpanel/validation';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/utils/cn';
import { Button } from '../ui/button';
interface ReportChartTypeProps {
className?: string;
value: IChartEventSegment;
onChange: (segment: IChartEventSegment) => void;
}
export function ReportSegment({
className,
value,
onChange,
}: ReportChartTypeProps) {
const items = mapKeys(chartSegments).map((key) => ({
label: chartSegments[key],
value: key,
}));
const Icons: Record<IChartEventSegment, LucideIcon> = {
event: ActivityIcon,
user: UsersIcon,
session: ClockIcon,
user_average: UserCheck2Icon,
one_event_per_user: UserCheckIcon,
property_sum: SigmaIcon,
property_average: EqualApproximatelyIcon,
property_max: TrendingUpIcon,
property_min: TrendingDownIcon,
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
icon={Icons[value]}
className={cn('justify-start', className)}
>
{items.find((item) => item.value === value)?.label}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuLabel>Available charts</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{items.map((item) => {
const Icon = Icons[item.value];
return (
<DropdownMenuItem
key={item.value}
onClick={() => onChange(item.value)}
className="group"
>
{item.label}
<DropdownMenuShortcut>
<Icon className="size-4 group-hover:text-blue-500 group-hover:scale-125 transition-all group-hover:rotate-12" />
</DropdownMenuShortcut>
</DropdownMenuItem>
);
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -3,7 +3,6 @@
import { ColorSquare } from '@/components/color-square';
import { Combobox } from '@/components/ui/combobox';
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 { useDebounceFn } from '@/hooks/useDebounceFn';
@@ -28,7 +27,8 @@ import { CSS } from '@dnd-kit/utilities';
import { shortId } from '@openpanel/common';
import { alphabetIds } from '@openpanel/constants';
import type { IChartEvent } from '@openpanel/validation';
import { FilterIcon, GanttChartIcon, HandIcon, Users } from 'lucide-react';
import { FilterIcon, GanttChartIcon, HandIcon } from 'lucide-react';
import { ReportSegment } from '../ReportSegment';
import {
addEvent,
changeEvent,
@@ -82,7 +82,8 @@ function SortableEvent({
{(showSegment || showAddFilter) && (
<div className="flex gap-2 p-2 pt-0">
{showSegment && (
<DropdownMenuComposed
<ReportSegment
value={event.segment}
onChange={(segment) => {
dispatch(
changeEvent({
@@ -91,73 +92,7 @@ function SortableEvent({
}),
);
}}
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
</>
) : (
<>
<GanttChartIcon size={12} /> All events
</>
)}
</button>
</DropdownMenuComposed>
/>
)}
{showAddFilter && (
<PropertiesCombobox

View File

@@ -102,6 +102,18 @@ export const chartTypes = {
conversion: 'Conversion',
} as const;
export const chartSegments = {
event: 'All events',
user: 'Unique users',
session: 'Unique sessions',
user_average: 'Average users',
one_event_per_user: 'One event per user',
property_sum: 'Sum of property',
property_average: 'Average of property',
property_max: 'Max of property',
property_min: 'Min of property',
};
export const lineTypes = {
monotone: 'Monotone',
monotoneX: 'Monotone X',

View File

@@ -162,12 +162,22 @@ export function getChartSql({
if (event.segment === 'property_sum' && event.property) {
sb.select.count = `sum(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL`;
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
}
if (event.segment === 'property_average' && event.property) {
sb.select.count = `avg(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL`;
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
}
if (event.segment === 'property_max' && event.property) {
sb.select.count = `max(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
}
if (event.segment === 'property_min' && event.property) {
sb.select.count = `min(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
}
if (event.segment === 'one_event_per_user') {

View File

@@ -1,6 +1,7 @@
import { z } from 'zod';
import {
chartSegments,
chartTypes,
intervals,
lineTypes,
@@ -29,6 +30,11 @@ export const zChartEventFilter = z.object({
.describe('The values to filter on'),
});
export const zChartEventSegment = z
.enum(objectToZodEnums(chartSegments))
.default('event')
.describe('Defines how the event data should be segmented or aggregated');
export const zChartEvent = z.object({
id: z
.string()
@@ -45,18 +51,7 @@ export const zChartEvent = z.object({
.describe(
'Optional property of the event used for specific segment calculations (e.g., value for property_sum/average)',
),
segment: z
.enum([
'event',
'user',
'session',
'user_average',
'one_event_per_user',
'property_sum',
'property_average',
])
.default('event')
.describe('Defines how the event data should be segmented or aggregated'),
segment: zChartEventSegment,
filters: z
.array(zChartEventFilter)
.default([])

View File

@@ -3,6 +3,7 @@ import type { z } from 'zod';
import type {
zChartBreakdown,
zChartEvent,
zChartEventSegment,
zChartInput,
zChartInputAI,
zChartType,
@@ -23,6 +24,7 @@ export type IChartProps = z.infer<typeof zReportInput> & {
previousIndicatorInverted?: boolean;
};
export type IChartEvent = z.infer<typeof zChartEvent>;
export type IChartEventSegment = z.infer<typeof zChartEventSegment>;
export type IChartEventFilter = IChartEvent['filters'][number];
export type IChartEventFilterValue =
IChartEvent['filters'][number]['value'][number];

61
pnpm-lock.yaml generated
View File

@@ -413,8 +413,8 @@ importers:
specifier: ^2.4.0
version: 2.4.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
lucide-react:
specifier: ^0.451.0
version: 0.451.0(react@18.2.0)
specifier: ^0.513.0
version: 0.513.0(react@18.2.0)
mathjs:
specifier: ^12.3.2
version: 12.3.2
@@ -1072,40 +1072,6 @@ importers:
specifier: ^5.2.2
version: 5.6.3
packages/fire:
dependencies:
'@faker-js/faker':
specifier: ^9.0.1
version: 9.0.1
'@openpanel/common':
specifier: workspace:*
version: link:../common
'@openpanel/db':
specifier: workspace:*
version: link:../db
csv-parse:
specifier: ^5.6.0
version: 5.6.0
date-fns:
specifier: ^3.3.1
version: 3.3.1
devDependencies:
'@openpanel/tsconfig':
specifier: workspace:*
version: link:../../tooling/typescript
'@openpanel/validation':
specifier: workspace:*
version: link:../validation
'@types/node':
specifier: 20.14.8
version: 20.14.8
tsup:
specifier: ^7.2.0
version: 7.3.0(postcss@8.5.3)(typescript@5.6.3)
typescript:
specifier: ^5.2.2
version: 5.6.3
packages/integrations:
dependencies:
'@slack/bolt':
@@ -7526,9 +7492,6 @@ packages:
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
csv-parse@5.6.0:
resolution: {integrity: sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==}
d3-array@2.12.1:
resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==}
@@ -9630,16 +9593,16 @@ packages:
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
engines: {node: '>=10'}
lucide-react@0.451.0:
resolution: {integrity: sha512-OwQ3uljZLp2cerj8sboy5rnhtGTCl9UCJIhT1J85/yOuGVlEH+xaUPR7tvNdddPvmV5M5VLdr7cQuWE3hzA4jw==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc
lucide-react@0.454.0:
resolution: {integrity: sha512-hw7zMDwykCLnEzgncEEjHeA6+45aeEzRYuKHuyRSOPkhko+J3ySGjGIzu+mmMfDFG1vazHepMaYFYHbTFAZAAQ==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc
lucide-react@0.513.0:
resolution: {integrity: sha512-CJZKq2g8Y8yN4Aq002GahSXbG2JpFv9kXwyiOAMvUBv7pxeOFHUWKB0mO7MiY4ZVFCV4aNjv2BJFq/z3DgKPQg==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
luxon@3.4.4:
resolution: {integrity: sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==}
engines: {node: '>=12'}
@@ -19949,8 +19912,6 @@ snapshots:
csstype@3.1.3: {}
csv-parse@5.6.0: {}
d3-array@2.12.1:
dependencies:
internmap: 1.0.1
@@ -22559,14 +22520,14 @@ snapshots:
dependencies:
yallist: 4.0.0
lucide-react@0.451.0(react@18.2.0):
dependencies:
react: 18.2.0
lucide-react@0.454.0(react@18.3.1):
dependencies:
react: 18.3.1
lucide-react@0.513.0(react@18.2.0):
dependencies:
react: 18.2.0
luxon@3.4.4: {}
luxon@3.6.1: {}