From 73c074d5b747923dc0d4da044fce81c32b146575 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 11 Sep 2025 16:29:33 +0000 Subject: [PATCH] refactor: unify date filtering with column filter system - Replace hardcoded timestampColumn with dynamic getDateColumn(dateTarget) - Update useInsightsBookingParameters to use dateTarget for date range filtering - Remove dateRange from backend service schema and add date handling to buildColumnFilterCondition - Update ClearFiltersButton to exclude dynamic dateTarget instead of hardcoded 'timestamp' - Extract date range from columnFilters in tRPC router instead of separate dateRange parameter - Remove startDate, endDate, and dateTarget from base input schemas - Add support for startTime and createdAt date column filtering in InsightsBookingBaseService - Fix unused variable lint warning in trpc-router.ts Co-Authored-By: eunjae@cal.com --- apps/web/modules/insights/insights-view.tsx | 18 +-- .../hooks/useInsightsBookingParameters.ts | 12 +- .../insights/hooks/useInsightsParameters.ts | 23 ++-- .../insights/server/raw-data.schema.ts | 12 -- .../features/insights/server/trpc-router.ts | 106 +++++++++++++++--- .../service/InsightsBookingBaseService.ts | 69 +++++++----- 6 files changed, 158 insertions(+), 82 deletions(-) diff --git a/apps/web/modules/insights/insights-view.tsx b/apps/web/modules/insights/insights-view.tsx index 40cc973e5ee1f8..fa622f8e8bc4b9 100644 --- a/apps/web/modules/insights/insights-view.tsx +++ b/apps/web/modules/insights/insights-view.tsx @@ -47,11 +47,15 @@ export default function InsightsPage({ timeZone }: { timeZone: string }) { ); } -const timestampColumn: Extract = { - id: "timestamp", - title: "timestamp", - type: ColumnFilterType.DATE_RANGE, -}; +function getDateColumn( + dateTarget: DateTarget +): Extract { + return { + id: dateTarget, + title: dateTarget, + type: ColumnFilterType.DATE_RANGE, + }; +} function InsightsPageContent() { const { t } = useLocale(); @@ -70,11 +74,11 @@ function InsightsPageContent() { - +
- + diff --git a/packages/features/insights/hooks/useInsightsBookingParameters.ts b/packages/features/insights/hooks/useInsightsBookingParameters.ts index a753da6dece3cb..1a9950821bfdbf 100644 --- a/packages/features/insights/hooks/useInsightsBookingParameters.ts +++ b/packages/features/insights/hooks/useInsightsBookingParameters.ts @@ -22,25 +22,25 @@ export function useInsightsBookingParameters() { parse: (value) => (value === "createdAt" ? "createdAt" : "startTime"), }); - const timestampRange = useFilterValue("timestamp", ZDateRangeFilterValue)?.data; + const dateRange = useFilterValue(dateTarget, ZDateRangeFilterValue)?.data; // TODO for future: this preserving local time & startOf & endOf should be handled // from DateRangeFilter out of the box. // When we do it, we also need to remove those timezone handling logic from the backend side at the same time. const startDate = useChangeTimeZoneWithPreservedLocalTime( useMemo(() => { - return dayjs(timestampRange?.startDate ?? getDefaultStartDate().toISOString()) + return dayjs(dateRange?.startDate ?? getDefaultStartDate().toISOString()) .startOf("day") .toISOString(); - }, [timestampRange?.startDate]) + }, [dateRange?.startDate]) ); const endDate = useChangeTimeZoneWithPreservedLocalTime( useMemo(() => { - return dayjs(timestampRange?.endDate ?? getDefaultEndDate().toISOString()) + return dayjs(dateRange?.endDate ?? getDefaultEndDate().toISOString()) .endOf("day") .toISOString(); - }, [timestampRange?.endDate]) + }, [dateRange?.endDate]) ); - const columnFilters = useColumnFilters({ exclude: ["timestamp"] }); + const columnFilters = useColumnFilters(); return { scope, diff --git a/packages/features/insights/hooks/useInsightsParameters.ts b/packages/features/insights/hooks/useInsightsParameters.ts index b46285e8b086c1..88e5bedb3ea55a 100644 --- a/packages/features/insights/hooks/useInsightsParameters.ts +++ b/packages/features/insights/hooks/useInsightsParameters.ts @@ -1,3 +1,4 @@ +import { useQueryState } from "nuqs"; import { useMemo } from "react"; import dayjs from "@calcom/dayjs"; @@ -20,6 +21,9 @@ import { useInsightsOrgTeams } from "./useInsightsOrgTeams"; export function useInsightsParameters() { const { isAll, teamId, userId, scope, selectedTeamId } = useInsightsOrgTeams(); + const [dateTarget] = useQueryState("dateTarget", { + defaultValue: "startTime" as const, + }); const memberUserIds = useFilterValue("bookingUserId", ZMultiSelectFilterValue)?.data as | number[] @@ -27,32 +31,31 @@ export function useInsightsParameters() { const memberUserId = useFilterValue("bookingUserId", ZSingleSelectFilterValue)?.data as number | undefined; const eventTypeId = useFilterValue("eventTypeId", ZSingleSelectFilterValue)?.data as number | undefined; const routingFormId = useFilterValue("formId", ZSingleSelectFilterValue)?.data as string | undefined; - const createdAtRange = useFilterValue("createdAt", ZDateRangeFilterValue)?.data; + + const dateRange = useFilterValue(dateTarget, ZDateRangeFilterValue)?.data; // TODO for future: this preserving local time & startOf & endOf should be handled // from DateRangeFilter out of the box. // When we do it, we also need to remove those timezone handling logic from the backend side at the same time. const startDate = useChangeTimeZoneWithPreservedLocalTime( useMemo(() => { - return dayjs(createdAtRange?.startDate ?? getDefaultStartDate().toISOString()) + return dayjs(dateRange?.startDate ?? getDefaultStartDate().toISOString()) .startOf("day") .toISOString(); - }, [createdAtRange?.startDate]) + }, [dateRange?.startDate]) ); const endDate = useChangeTimeZoneWithPreservedLocalTime( useMemo(() => { - return dayjs(createdAtRange?.endDate ?? getDefaultEndDate().toISOString()) + return dayjs(dateRange?.endDate ?? getDefaultEndDate().toISOString()) .endOf("day") .toISOString(); - }, [createdAtRange?.endDate]) + }, [dateRange?.endDate]) ); const dateRangePreset = useMemo(() => { - return (createdAtRange?.preset as PresetOptionValue) ?? CUSTOM_PRESET_VALUE; - }, [createdAtRange?.preset]); + return (dateRange?.preset as PresetOptionValue) ?? CUSTOM_PRESET_VALUE; + }, [dateRange?.preset]); - const columnFilters = useColumnFilters({ - exclude: ["createdAt"], - }); + const columnFilters = useColumnFilters(); return { isAll, diff --git a/packages/features/insights/server/raw-data.schema.ts b/packages/features/insights/server/raw-data.schema.ts index db628aabff1c92..327e06bf77efa9 100644 --- a/packages/features/insights/server/raw-data.schema.ts +++ b/packages/features/insights/server/raw-data.schema.ts @@ -44,8 +44,6 @@ export type InsightsRoutingServiceInput = { selectedTeamId?: number; columnFilters?: ColumnFilter[]; - startDate: string; - endDate: string; }; export type InsightsRoutingServicePaginatedInput = InsightsRoutingServiceInput & { @@ -70,8 +68,6 @@ export const insightsRoutingServiceInputSchema = z.object({ scope: z.union([z.literal("user"), z.literal("team"), z.literal("org")]), selectedTeamId: z.number().optional(), columnFilters: z.array(ZColumnFilter).optional(), - startDate: z.string(), - endDate: z.string(), }) satisfies z.ZodType; export const insightsRoutingServicePaginatedInputSchema = z.object({ @@ -84,8 +80,6 @@ export const insightsRoutingServicePaginatedInputSchema = z.object({ export const routingRepositoryBaseInputSchema = z.object({ scope: z.union([z.literal("user"), z.literal("team"), z.literal("org")]), selectedTeamId: z.number().optional(), - startDate: z.string(), - endDate: z.string(), columnFilters: z.array(ZColumnFilter).optional(), }); @@ -103,12 +97,6 @@ export const routedToPerPeriodCsvInputSchema = routingRepositoryBaseInputSchema. export const bookingRepositoryBaseInputSchema = z.object({ scope: z.union([z.literal("user"), z.literal("team"), z.literal("org")]), selectedTeamId: z.number().optional(), - startDate: z.string(), - endDate: z.string(), timeZone: z.string(), columnFilters: z.array(ZColumnFilter).optional(), - dateTarget: z - .union([z.literal("startTime"), z.literal("createdAt")]) - .optional() - .default("startTime"), }); diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts index 7ed69951564ff4..d60b96d4a516e8 100644 --- a/packages/features/insights/server/trpc-router.ts +++ b/packages/features/insights/server/trpc-router.ts @@ -2,6 +2,8 @@ import type { Prisma } from "@prisma/client"; import { z } from "zod"; import dayjs from "@calcom/dayjs"; +import type { ColumnFilter } from "@calcom/features/data-table/lib/types"; +import { ColumnFilterType } from "@calcom/features/data-table/lib/types"; import { insightsRoutingServiceInputSchema, insightsRoutingServicePaginatedInputSchema, @@ -311,11 +313,33 @@ export interface IResultTeamList { /** * Helper function to create InsightsBookingService with standardized parameters */ +function extractDateRangeFromFilters(columnFilters?: ColumnFilter[]) { + if (!columnFilters) return { startDate: null, endDate: null }; + + for (const filter of columnFilters) { + if ( + (filter.id === "startTime" || filter.id === "createdAt") && + filter.value.type === ColumnFilterType.DATE_RANGE + ) { + const dateFilter = filter.value as Extract< + ColumnFilter["value"], + { type: ColumnFilterType.DATE_RANGE } + >; + return { + startDate: dateFilter.data.startDate, + endDate: dateFilter.data.endDate, + }; + } + } + + return { startDate: null, endDate: null }; +} + function createInsightsBookingService( ctx: { user: { id: number; organizationId: number | null } }, input: z.infer ) { - const { scope, selectedTeamId, startDate, endDate, columnFilters, dateTarget } = input; + const { scope, selectedTeamId, columnFilters } = input; return getInsightsBookingService({ options: { scope, @@ -325,11 +349,6 @@ function createInsightsBookingService( }, filters: { ...(columnFilters && { columnFilters }), - dateRange: { - target: dateTarget, - startDate, - endDate, - }, }, }); } @@ -338,6 +357,7 @@ function createInsightsRoutingService( ctx: { insightsDb: PrismaClient; user: { id: number; organizationId: number | null } }, input: z.infer ) { + const { startDate, endDate } = extractDateRangeFromFilters(input.columnFilters); return getInsightsRoutingService({ options: { scope: input.scope, @@ -346,8 +366,8 @@ function createInsightsRoutingService( orgId: ctx.user.organizationId, }, filters: { - startDate: input.startDate, - endDate: input.endDate, + startDate: startDate || "", + endDate: endDate || "", columnFilters: input.columnFilters, }, }); @@ -364,10 +384,43 @@ export const insightsRouter = router({ // Calculate previous period dates and create service for previous period const previousPeriodDates = currentPeriodService.calculatePreviousPeriodDates(); + + const { startDate: currentStartDate, endDate: currentEndDate } = extractDateRangeFromFilters( + input.columnFilters + ); + if (!currentStartDate || !currentEndDate) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Date range is required" }); + } + + // Create column filters for previous period + const previousPeriodColumnFilters = + input.columnFilters?.map((filter) => { + if ( + (filter.id === "startTime" || filter.id === "createdAt") && + filter.value.type === ColumnFilterType.DATE_RANGE + ) { + const originalValue = filter.value as Extract< + ColumnFilter["value"], + { type: ColumnFilterType.DATE_RANGE } + >; + return { + ...filter, + value: { + type: ColumnFilterType.DATE_RANGE, + data: { + startDate: previousPeriodDates.startDate, + endDate: previousPeriodDates.endDate, + preset: originalValue.data.preset, + }, + }, + } as ColumnFilter; + } + return filter; + }) || []; + const previousPeriodService = createInsightsBookingService(ctx, { ...input, - startDate: previousPeriodDates.startDate, - endDate: previousPeriodDates.endDate, + columnFilters: previousPeriodColumnFilters, }); // Get previous period stats @@ -455,7 +508,12 @@ export const insightsRouter = router({ }; }), eventTrends: insightsPbacProcedure.input(bookingRepositoryBaseInputSchema).query(async ({ ctx, input }) => { - const { startDate, endDate, timeZone } = input; + const { timeZone, columnFilters } = input; + const { startDate, endDate } = extractDateRangeFromFilters(columnFilters); + + if (!startDate || !endDate) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Date range is required" }); + } // Calculate timeView and dateRanges const timeView = getTimeView(startDate, endDate); @@ -491,7 +549,12 @@ export const insightsRouter = router({ averageEventDuration: insightsPbacProcedure .input(bookingRepositoryBaseInputSchema) .query(async ({ ctx, input }) => { - const { startDate, endDate, timeZone } = input; + const { timeZone, columnFilters } = input; + const { startDate, endDate } = extractDateRangeFromFilters(columnFilters); + + if (!startDate || !endDate) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Date range is required" }); + } const insightsBookingService = createInsightsBookingService(ctx, input); @@ -958,9 +1021,10 @@ export const insightsRouter = router({ }); const csvString = objectToCsv(csvData); - const downloadAs = `routed-to-${period}-${dayjs(rest.startDate).format("YYYY-MM-DD")}-${dayjs( - rest.endDate - ).format("YYYY-MM-DD")}.csv`; + const { startDate, endDate } = extractDateRangeFromFilters(rest.columnFilters); + const downloadAs = `routed-to-${period}-${dayjs(startDate || new Date()).format( + "YYYY-MM-DD" + )}-${dayjs(endDate || new Date()).format("YYYY-MM-DD")}.csv`; return { data: csvString, filename: downloadAs }; } catch (e) { @@ -981,10 +1045,16 @@ export const insightsRouter = router({ getRoutingFunnelData: insightsPbacProcedure .input(routingRepositoryBaseInputSchema) .query(async ({ ctx, input }) => { - const timeView = getTimeView(input.startDate, input.endDate); + const { startDate, endDate } = extractDateRangeFromFilters(input.columnFilters); + + if (!startDate || !endDate) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Date range is required" }); + } + + const timeView = getTimeView(startDate, endDate); const dateRanges = getDateRanges({ - startDate: input.startDate, - endDate: input.endDate, + startDate, + endDate, timeZone: ctx.user.timeZone, timeView, weekStart: ctx.user.weekStart, diff --git a/packages/lib/server/service/InsightsBookingBaseService.ts b/packages/lib/server/service/InsightsBookingBaseService.ts index 7e0f7f806c56cc..70d84b8dfb2afd 100644 --- a/packages/lib/server/service/InsightsBookingBaseService.ts +++ b/packages/lib/server/service/InsightsBookingBaseService.ts @@ -4,13 +4,14 @@ import { z } from "zod"; import dayjs from "@calcom/dayjs"; import { makeSqlCondition } from "@calcom/features/data-table/lib/server"; -import { ZColumnFilter } from "@calcom/features/data-table/lib/types"; +import { ZColumnFilter, ColumnFilterType } from "@calcom/features/data-table/lib/types"; import type { ColumnFilter } from "@calcom/features/data-table/lib/types"; import { isSingleSelectFilterValue, isMultiSelectFilterValue, isTextFilterValue, isNumberFilterValue, + isDateRangeFilterValue, } from "@calcom/features/data-table/lib/utils"; import type { DateRange } from "@calcom/features/insights/server/insightsDateUtils"; import type { PrismaClient } from "@calcom/prisma"; @@ -127,13 +128,6 @@ export type InsightsBookingServiceOptions = z.infer; export const insightsBookingServiceFilterOptionsSchema = z.object({ - dateRange: z - .object({ - target: z.enum(["createdAt", "startTime"]), - startDate: z.string(), - endDate: z.string(), - }) - .optional(), columnFilters: z.array(ZColumnFilter).optional(), }); @@ -269,23 +263,6 @@ export class InsightsBookingBaseService { } } - // Use dateRange object for date filtering - if (this.filters.dateRange) { - const { target, startDate, endDate } = this.filters.dateRange; - if (startDate) { - if (isNaN(Date.parse(startDate))) { - throw new Error(`Invalid date format: ${startDate}`); - } - conditions.push(Prisma.sql`"${Prisma.raw(target)}" >= ${startDate}::timestamp`); - } - if (endDate) { - if (isNaN(Date.parse(endDate))) { - throw new Error(`Invalid date format: ${endDate}`); - } - conditions.push(Prisma.sql`"${Prisma.raw(target)}" <= ${endDate}::timestamp`); - } - } - if (conditions.length === 0) { return null; } @@ -351,6 +328,29 @@ export class InsightsBookingBaseService { } } + if ((id === "startTime" || id === "createdAt") && isDateRangeFilterValue(value)) { + const conditions: Prisma.Sql[] = []; + if (value.data.startDate) { + if (isNaN(Date.parse(value.data.startDate))) { + throw new Error(`Invalid date format: ${value.data.startDate}`); + } + conditions.push(Prisma.sql`"${Prisma.raw(id)}" >= ${value.data.startDate}::timestamp`); + } + if (value.data.endDate) { + if (isNaN(Date.parse(value.data.endDate))) { + throw new Error(`Invalid date format: ${value.data.endDate}`); + } + conditions.push(Prisma.sql`"${Prisma.raw(id)}" <= ${value.data.endDate}::timestamp`); + } + if (conditions.length === 0) { + return null; + } + return conditions.reduce((acc, condition, index) => { + if (index === 0) return condition; + return Prisma.sql`(${acc}) AND (${condition})`; + }); + } + return null; } @@ -1186,12 +1186,23 @@ export class InsightsBookingBaseService { } calculatePreviousPeriodDates() { - if (!this.filters?.dateRange) { - throw new Error("Date range is required for calculating previous period"); + if (!this.filters?.columnFilters) { + throw new Error("Column filters are required for calculating previous period"); + } + + const dateFilter = this.filters.columnFilters.find( + (filter) => + (filter.id === "startTime" || filter.id === "createdAt") && + filter.value.type === ColumnFilterType.DATE_RANGE && + isDateRangeFilterValue(filter.value) + ); + + if (!dateFilter || !isDateRangeFilterValue(dateFilter.value)) { + throw new Error("Date range filter is required for calculating previous period"); } - const startDate = dayjs(this.filters.dateRange.startDate); - const endDate = dayjs(this.filters.dateRange.endDate); + const startDate = dayjs(dateFilter.value.data.startDate); + const endDate = dayjs(dateFilter.value.data.endDate); const startTimeEndTimeDiff = endDate.diff(startDate, "day"); const lastPeriodStartDate = startDate.subtract(startTimeEndTimeDiff, "day");