From c4a2ea4858316b541fdfa4661cd81cd1a450e73e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesv=C3=A4rd?= <1987198+lindesvard@users.noreply.github.com> Date: Mon, 21 Oct 2024 10:13:57 +0200 Subject: [PATCH] feature(dashboard): add more settings for funnels * wip * feature(dashboard): add more settings for funnels --- .../components/report-chart/funnel/index.tsx | 4 +- .../components/report/ReportSaveButton.tsx | 10 +- .../src/components/report/reportSlice.ts | 14 +++ .../report/sidebar/ReportFormula.tsx | 59 +---------- .../report/sidebar/ReportSettings.tsx | 71 ++++++++++++-- .../report/sidebar/filters/FilterItem.tsx | 61 +----------- .../src/components/ui/input-enter.tsx | 60 ++++++++++++ .../migration.sql | 3 + packages/db/prisma/schema.prisma | 32 +++--- packages/db/src/clickhouse-client.ts | 10 +- packages/db/src/services/reports.service.ts | 2 + packages/trpc/src/routers/chart.helpers.ts | 98 ++++++++++--------- packages/trpc/src/routers/chart.ts | 48 +++++---- packages/trpc/src/routers/report.ts | 8 ++ packages/validation/src/index.ts | 8 +- 15 files changed, 276 insertions(+), 212 deletions(-) create mode 100644 apps/dashboard/src/components/ui/input-enter.tsx create mode 100644 packages/db/prisma/migrations/20241018190421_add_funnel_options_to_report/migration.sql diff --git a/apps/dashboard/src/components/report-chart/funnel/index.tsx b/apps/dashboard/src/components/report-chart/funnel/index.tsx index 6fdb62ae..b9728ebf 100644 --- a/apps/dashboard/src/components/report-chart/funnel/index.tsx +++ b/apps/dashboard/src/components/report-chart/funnel/index.tsx @@ -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', }; diff --git a/apps/dashboard/src/components/report/ReportSaveButton.tsx b/apps/dashboard/src/components/report/ReportSaveButton.tsx index f00fa372..a2c80b27 100644 --- a/apps/dashboard/src/components/report/ReportSaveButton.tsx +++ b/apps/dashboard/src/components/report/ReportSaveButton.tsx @@ -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 ( diff --git a/apps/dashboard/src/components/report/reportSlice.ts b/apps/dashboard/src/components/report/reportSlice.ts index 7d1ed18d..a0776968 100644 --- a/apps/dashboard/src/components/report/reportSlice.ts +++ b/apps/dashboard/src/components/report/reportSlice.ts @@ -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) { + state.dirty = true; + state.funnelGroup = action.payload || undefined; + }, + + changeFunnelWindow(state, action: PayloadAction) { + 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; diff --git a/apps/dashboard/src/components/report/sidebar/ReportFormula.tsx b/apps/dashboard/src/components/report/sidebar/ReportFormula.tsx index 727b1b14..82b4995a 100644 --- a/apps/dashboard/src/components/report/sidebar/ReportFormula.tsx +++ b/apps/dashboard/src/components/report/sidebar/ReportFormula.tsx @@ -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() {

Formula

- { @@ -28,54 +24,3 @@ export function ReportFormula() {
); } - -function InputFormula({ - value, - onChangeValue, - ...props -}: { - value: string | undefined; - onChangeValue: (value: string) => void; -} & InputHTMLAttributes) { - 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); - } - }} - size="default" - /> -
- - {internalValue !== value && ( - onChangeValue(internalValue)} - > - - Press enter - - - - )} - -
-
- ); -} diff --git a/apps/dashboard/src/components/report/sidebar/ReportSettings.tsx b/apps/dashboard/src/components/report/sidebar/ReportSettings.tsx index 4f2c6da2..4be290ef 100644 --- a/apps/dashboard/src/components/report/sidebar/ReportSettings.tsx +++ b/apps/dashboard/src/components/report/sidebar/ReportSettings.tsx @@ -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() {
{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({