From bdc3cb9d6e42d5f54a7d7d63ea9a77b4d64d891f Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Thu, 18 Sep 2025 12:32:50 +0200 Subject: [PATCH] feat: allow to choose dateTarget for /insights (startTime by default) (#23752) * feat: allow to choose dateTarget for /insights (startTime by default) * feat: add timestamp selector for insights date filtering - Add TimestampFilter component with Start Time/Created At options - Extend useInsightsBookingParameters hook with timestamp selection - Update all insight components to use dateTarget parameter - Add i18n translations for new UI strings - Position selector next to DateRangeFilter as requested Addresses user request to add select box next to date range filter allowing users to choose between startTime (default) and createdAt for displaying booking metrics on the Insights page. Co-Authored-By: eunjae@cal.com * refactor: rename TimestampFilter to DateTargetSelector with nuqs URL state - Rename TimestampFilter component to DateTargetSelector - Implement nuqs hook in InsightsPageContent for URL state management - Update useInsightsBookingParameters to return dateTarget from URL state - Add dateTarget field to insightsRoutingServiceInputSchema and related types - Simplify individual insight components to use insightsBookingParams directly - Remove manual timestampTarget destructuring from all components - Update all tRPC routing service calls to include dateTarget parameter - All TypeScript checks now pass successfully Co-Authored-By: eunjae@cal.com * update styles * fix inconsistency * feat: replace Select with Command component and rename filter ID - Replace Select with Command + Popover for compact width and wider dropdown - Add descriptive option labels with translations - Change filter ID from 'createdAt' to 'timestamp' across all components - Maintain URL state management with nuqs - Fix ESLint warning for missing dependency in useEffect Co-Authored-By: eunjae@cal.com * fix: revert routing components to use createdAt filter ID - Keep timestamp filter ID change scoped only to main insights page - Routing components should continue using createdAt as filter ID - Only insights-view.tsx and related booking hooks use timestamp Co-Authored-By: eunjae@cal.com * update styles * fix trpc router * refactor timestamp column for insights booking service * fix * update text * rename and clean up * fix endDate in DateRangeFilter * fix type errors * fix startTime filter and type errors * provide default date range * add completed to getMembersStatsWithCount * add unit tests * fix type error * address feedback --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/web/modules/insights/insights-view.tsx | 31 +- apps/web/public/static/locales/en/common.json | 4 + .../components/filters/DateRangeFilter.tsx | 28 +- ...useChangeTimeZoneWithPreservedLocalTime.ts | 16 +- .../data-table/lib/preserveLocalTime.ts | 6 + packages/features/data-table/lib/types.ts | 2 +- .../components/booking/EventTrendsChart.tsx | 2 +- .../booking/LeastCompletedBookings.tsx | 13 - .../booking/MostCompletedBookings.tsx | 13 - .../components/booking/TimezoneBadge.tsx | 5 +- .../routing/RoutingFormResponsesTable.tsx | 2 +- .../insights/filters/DateTargetSelector.tsx | 78 +++ .../insights/filters/Download/Download.tsx | 3 +- .../hooks/useInsightsBookingParameters.ts | 58 +- .../lib/__tests__/bookingUtils.test.ts | 538 ++++++++++++++++++ .../features/insights/lib/bookingUtils.ts | 57 ++ .../insights/server/raw-data.schema.ts | 2 - .../features/insights/server/trpc-router.ts | 65 ++- .../components/UserTable/UserListTable.tsx | 9 - .../service/InsightsBookingBaseService.ts | 106 ++-- 20 files changed, 884 insertions(+), 154 deletions(-) create mode 100644 packages/features/insights/filters/DateTargetSelector.tsx create mode 100644 packages/features/insights/lib/__tests__/bookingUtils.test.ts create mode 100644 packages/features/insights/lib/bookingUtils.ts diff --git a/apps/web/modules/insights/insights-view.tsx b/apps/web/modules/insights/insights-view.tsx index cbac224b79..24a0c007db 100644 --- a/apps/web/modules/insights/insights-view.tsx +++ b/apps/web/modules/insights/insights-view.tsx @@ -1,5 +1,7 @@ "use client"; +import { useState, useCallback } from "react"; + import { DataTableProvider, DataTableFilters, @@ -7,6 +9,7 @@ import { ColumnFilterType, type FilterableColumn, } from "@calcom/features/data-table"; +import { useDataTable } from "@calcom/features/data-table/hooks/useDataTable"; import { useSegments } from "@calcom/features/data-table/hooks/useSegments"; import { AverageEventDurationChart, @@ -27,11 +30,13 @@ import { TimezoneBadge, } from "@calcom/features/insights/components/booking"; import { InsightsOrgTeamsProvider } from "@calcom/features/insights/context/InsightsOrgTeamsProvider"; +import { DateTargetSelector, type DateTarget } from "@calcom/features/insights/filters/DateTargetSelector"; import { Download } from "@calcom/features/insights/filters/Download"; import { OrgTeamsFilter } from "@calcom/features/insights/filters/OrgTeamsFilter"; import { useInsightsBookings } from "@calcom/features/insights/hooks/useInsightsBookings"; import { useInsightsOrgTeams } from "@calcom/features/insights/hooks/useInsightsOrgTeams"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { ButtonGroup } from "@calcom/ui/components/buttonGroup"; export default function InsightsPage({ timeZone }: { timeZone: string }) { return ( @@ -49,10 +54,26 @@ const createdAtColumn: Extract = { + id: "startTime", + title: "startTime", + type: ColumnFilterType.DATE_RANGE, +}; + function InsightsPageContent() { const { t } = useLocale(); const { table } = useInsightsBookings(); const { isAll, teamId, userId } = useInsightsOrgTeams(); + const { removeFilter } = useDataTable(); + const [dateTarget, _setDateTarget] = useState<"startTime" | "createdAt">("startTime"); + + const setDateTarget = useCallback( + (target: "startTime" | "createdAt") => { + _setDateTarget(target); + removeFilter(target === "startTime" ? "createdAt" : "startTime"); + }, + [_setDateTarget, removeFilter] + ); return ( <> @@ -63,10 +84,16 @@ function InsightsPageContent() { - +
- + + + +
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 1225495428..6c820b95fb 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -3689,6 +3689,10 @@ "before_scheduled_start_time": "Before scheduled start time", "cancel_booking_acknowledge_no_show_fee": "I acknowledge that by cancelling the booking within {{timeValue}} {{timeUnit}} of the start time I will be charged the no show fee of {{amount, currency}}", "contact_organizer": "If you have any questions, please contact the organizer.", + "booking_time_option": "Booking time", + "booking_time_option_description": "When the booking is scheduled (start to end)", + "created_at_option": "Created at", + "created_at_option_description": "When the booking was originally created", "call_details": "Call Details", "call_id": "Call ID", "call_information": "Call Information", diff --git a/packages/features/data-table/components/filters/DateRangeFilter.tsx b/packages/features/data-table/components/filters/DateRangeFilter.tsx index 2456400bbd..96e6464c5d 100644 --- a/packages/features/data-table/components/filters/DateRangeFilter.tsx +++ b/packages/features/data-table/components/filters/DateRangeFilter.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from "react"; import dayjs from "@calcom/dayjs"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants"; import classNames from "@calcom/ui/classNames"; import { Badge } from "@calcom/ui/components/badge"; import { Button, buttonClasses } from "@calcom/ui/components/button"; @@ -29,6 +30,7 @@ import { getDateRangeFromPreset, type PresetOption, } from "../../lib/dateRange"; +import { preserveLocalTime } from "../../lib/preserveLocalTime"; import type { FilterableColumn, DateRangeFilterOptions } from "../../lib/types"; import { ZDateRangeFilterValue, ColumnFilterType } from "../../lib/types"; import { useFilterPopoverOpen } from "./useFilterPopoverOpen"; @@ -48,9 +50,8 @@ export const DateRangeFilter = ({ }: DateRangeFilterProps) => { const { open, onOpenChange } = useFilterPopoverOpen(column.id); const filterValue = useFilterValue(column.id, ZDateRangeFilterValue); - const { updateFilter, removeFilter } = useDataTable(); + const { updateFilter, removeFilter, timeZone: givenTimeZone } = useDataTable(); const range = options?.range ?? "past"; - const endOfDay = options?.endOfDay ?? false; const forceCustom = range === "custom"; const forcePast = range === "past"; @@ -70,6 +71,19 @@ export const DateRangeFilter = ({ : DEFAULT_PRESET ); + const convertTimestamp = useCallback( + (timestamp: string) => { + if (!options?.convertToTimeZone) { + return timestamp; + } + if (!givenTimeZone || CURRENT_TIMEZONE === givenTimeZone) { + return timestamp; + } + return preserveLocalTime(timestamp, CURRENT_TIMEZONE, givenTimeZone); + }, + [options?.convertToTimeZone, givenTimeZone] + ); + const updateValues = useCallback( ({ preset, startDate, endDate }: { preset: PresetOption; startDate?: Dayjs; endDate?: Dayjs }) => { setSelectedPreset(preset); @@ -80,14 +94,14 @@ export const DateRangeFilter = ({ updateFilter(column.id, { type: ColumnFilterType.DATE_RANGE, data: { - startDate: startDate.toDate().toISOString(), - endDate: (endOfDay ? endDate.endOf("day") : endDate).toDate().toISOString(), + startDate: convertTimestamp(startDate.toDate().toISOString()), + endDate: convertTimestamp(endDate.toDate().toISOString()), preset: preset.value, }, }); } }, - [column.id, endOfDay] + [column.id, updateFilter, convertTimestamp] ); useEffect(() => { @@ -126,10 +140,12 @@ export const DateRangeFilter = ({ startDate?: Date | undefined; endDate?: Date | undefined; }) => { + // DateRangePicker returns the beginning of the day, + // so we need to update `endDate` to the end of the day. updateValues({ preset: CUSTOM_PRESET, startDate: startDate ? dayjs(startDate) : getDefaultStartDate(), - endDate: endDate ? dayjs(endDate) : undefined, + endDate: endDate ? dayjs(endDate).add(1, "day").subtract(1, "millisecond") : undefined, }); }; diff --git a/packages/features/data-table/hooks/useChangeTimeZoneWithPreservedLocalTime.ts b/packages/features/data-table/hooks/useChangeTimeZoneWithPreservedLocalTime.ts index 549e6192e7..4f91d41ef7 100644 --- a/packages/features/data-table/hooks/useChangeTimeZoneWithPreservedLocalTime.ts +++ b/packages/features/data-table/hooks/useChangeTimeZoneWithPreservedLocalTime.ts @@ -1,28 +1,20 @@ import { useMemo } from "react"; -import dayjs from "@calcom/dayjs"; +import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants"; import { preserveLocalTime } from "../lib/preserveLocalTime"; import { useDataTable } from "./useDataTable"; /** * Converts a timestamp to maintain the same local time in a different timezone. - * - * For example, if it's midnight (00:00) in Paris time: - * - Input : "2025-05-22T22:00:00.000Z" (Midnight/00:00 in Paris) - * - Output: "2025-05-22T15:00:00.000Z" (Midnight/00:00 in Seoul) - * - * This ensures that times like midnight (00:00) or end of day (23:59) - * remain at those exact local times when converting between timezones. - * The output timestamp is based on the timezone in the user's profile settings. + * Fore more info, read packages/features/data-table/lib/preserveLocalTime.ts */ export function useChangeTimeZoneWithPreservedLocalTime(isoString: string) { const { timeZone: profileTimeZone } = useDataTable(); return useMemo(() => { - const currentTimeZone = dayjs.tz.guess(); - if (!profileTimeZone || currentTimeZone === profileTimeZone) { + if (!profileTimeZone || CURRENT_TIMEZONE === profileTimeZone) { return isoString; } - return preserveLocalTime(isoString, currentTimeZone, profileTimeZone); + return preserveLocalTime(isoString, CURRENT_TIMEZONE, profileTimeZone); }, [isoString, profileTimeZone]); } diff --git a/packages/features/data-table/lib/preserveLocalTime.ts b/packages/features/data-table/lib/preserveLocalTime.ts index 783e24ac82..b51eeb98c7 100644 --- a/packages/features/data-table/lib/preserveLocalTime.ts +++ b/packages/features/data-table/lib/preserveLocalTime.ts @@ -10,6 +10,12 @@ import dayjs from "@calcom/dayjs"; * This ensures that times like midnight (00:00) or end of day (23:59) * remain at those exact local times when converting between timezones. * The output timestamp is based on the timezone in the user's profile settings. + * + * For example, the profile timezone is Asia/Seoul, + * but the current user is in Europe/Paris. + * `Date` pickers will normally emit timestamps in the user's local timezone. (00:00:00 ~ 23:59:59 in Paris time) + * but what we really want is to fetch the data based on the user's profile timezone. (00:00:00 ~ 23:59:59 in Seoul time) + * That's why we need to convert the timestamp to the user's profile timezone. */ export const preserveLocalTime = (isoString: string, originalTimeZone: string, targetTimeZone: string) => { // Parse the input time diff --git a/packages/features/data-table/lib/types.ts b/packages/features/data-table/lib/types.ts index 2589d7d661..033d58179d 100644 --- a/packages/features/data-table/lib/types.ts +++ b/packages/features/data-table/lib/types.ts @@ -116,7 +116,7 @@ export const ZFilterValue = z.union([ export type DateRangeFilterOptions = { range?: "past" | "custom"; - endOfDay?: boolean; + convertToTimeZone?: boolean; }; export type TextFilterOptions = { diff --git a/packages/features/insights/components/booking/EventTrendsChart.tsx b/packages/features/insights/components/booking/EventTrendsChart.tsx index 4e192e5b45..e08d5280f3 100644 --- a/packages/features/insights/components/booking/EventTrendsChart.tsx +++ b/packages/features/insights/components/booking/EventTrendsChart.tsx @@ -36,7 +36,7 @@ type EventTrendsData = RouterOutputs["viewer"]["insights"]["eventTrends"][number const CustomTooltip = ({ active, payload, - label, + label: _label, }: { active?: boolean; payload?: Array<{ diff --git a/packages/features/insights/components/booking/LeastCompletedBookings.tsx b/packages/features/insights/components/booking/LeastCompletedBookings.tsx index 4b147cd152..14026dfe6f 100644 --- a/packages/features/insights/components/booking/LeastCompletedBookings.tsx +++ b/packages/features/insights/components/booking/LeastCompletedBookings.tsx @@ -3,7 +3,6 @@ import { useMemo } from "react"; import dayjs from "@calcom/dayjs"; -import { useChangeTimeZoneWithPreservedLocalTime } from "@calcom/features/data-table/hooks/useChangeTimeZoneWithPreservedLocalTime"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; @@ -16,18 +15,6 @@ export const LeastCompletedTeamMembersTable = () => { const { t } = useLocale(); let insightsBookingParams = useInsightsBookingParameters(); - const currentTime = useChangeTimeZoneWithPreservedLocalTime( - useMemo(() => { - return dayjs().toISOString(); - }, []) - ); - - // booking with endDate < now is "accepted" booking - insightsBookingParams = { - ...insightsBookingParams, - endDate: currentTime, - }; - const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithLeastCompletedBookings.useQuery( insightsBookingParams, { diff --git a/packages/features/insights/components/booking/MostCompletedBookings.tsx b/packages/features/insights/components/booking/MostCompletedBookings.tsx index 57ea6aced8..d29f23caf6 100644 --- a/packages/features/insights/components/booking/MostCompletedBookings.tsx +++ b/packages/features/insights/components/booking/MostCompletedBookings.tsx @@ -3,7 +3,6 @@ import { useMemo } from "react"; import dayjs from "@calcom/dayjs"; -import { useChangeTimeZoneWithPreservedLocalTime } from "@calcom/features/data-table/hooks/useChangeTimeZoneWithPreservedLocalTime"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; @@ -16,18 +15,6 @@ export const MostCompletedTeamMembersTable = () => { const { t } = useLocale(); let insightsBookingParams = useInsightsBookingParameters(); - const currentTime = useChangeTimeZoneWithPreservedLocalTime( - useMemo(() => { - return dayjs().toISOString(); - }, []) - ); - - // booking with endDate < now is "accepted" booking - insightsBookingParams = { - ...insightsBookingParams, - endDate: currentTime, - }; - const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithMostCompletedBookings.useQuery( insightsBookingParams, { diff --git a/packages/features/insights/components/booking/TimezoneBadge.tsx b/packages/features/insights/components/booking/TimezoneBadge.tsx index 2cdd93b36a..ce2f8f0669 100644 --- a/packages/features/insights/components/booking/TimezoneBadge.tsx +++ b/packages/features/insights/components/booking/TimezoneBadge.tsx @@ -6,7 +6,6 @@ import { useDataTable } from "@calcom/features/data-table"; import NoSSR from "@calcom/lib/components/NoSSR"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants"; -import { Badge } from "@calcom/ui/components/badge"; import { Icon } from "@calcom/ui/components/icon"; import { Tooltip } from "@calcom/ui/components/tooltip"; @@ -41,9 +40,7 @@ const TimezoneBadgeContent = () => { return ( - - - + ); }; diff --git a/packages/features/insights/components/routing/RoutingFormResponsesTable.tsx b/packages/features/insights/components/routing/RoutingFormResponsesTable.tsx index 164ebd4f4b..6828f5ec35 100644 --- a/packages/features/insights/components/routing/RoutingFormResponsesTable.tsx +++ b/packages/features/insights/components/routing/RoutingFormResponsesTable.tsx @@ -105,7 +105,7 @@ export function RoutingFormResponsesTable() { // this also prevents user from clearing the routing form filter updateFilter("formId", { type: ColumnFilterType.SINGLE_SELECT, data: newRoutingFormId }); } - }, [table, getInsightsFacetedUniqueValues, routingFormId]); + }, [table, getInsightsFacetedUniqueValues, routingFormId, updateFilter]); return ( <> diff --git a/packages/features/insights/filters/DateTargetSelector.tsx b/packages/features/insights/filters/DateTargetSelector.tsx new file mode 100644 index 0000000000..0a50e8c44d --- /dev/null +++ b/packages/features/insights/filters/DateTargetSelector.tsx @@ -0,0 +1,78 @@ +import { useState } from "react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Button } from "@calcom/ui/components/button"; +import { Command, CommandList, CommandItem } from "@calcom/ui/components/command"; +import { Icon } from "@calcom/ui/components/icon"; +import { Popover, PopoverTrigger, PopoverContent } from "@calcom/ui/components/popover"; + +export type DateTarget = "startTime" | "createdAt"; + +interface DateTargetOption { + label: string; + description: string; + value: DateTarget; +} + +interface DateTargetSelectorProps { + value: DateTarget; + onChange: (value: DateTarget) => void; +} + +export const DateTargetSelector = ({ value, onChange }: DateTargetSelectorProps) => { + const { t } = useLocale(); + const [open, setOpen] = useState(false); + + const options: DateTargetOption[] = [ + { + label: t("booking_time_option"), + description: t("booking_time_option_description"), + value: "startTime", + }, + { + label: t("created_at_option"), + description: t("created_at_option_description"), + value: "createdAt", + }, + ]; + + const selectedOption = options.find((option) => option.value === value) || options[0]; + + return ( + + + + + + + + {options.map((option) => ( + { + onChange(option.value); + setOpen(false); + }} + className="flex items-center gap-2 px-4 py-3"> +
+
{option.label}
+
{option.description}
+
+ {selectedOption.label === option.label && ( + + )} +
+ ))} +
+
+
+
+ ); +}; diff --git a/packages/features/insights/filters/Download/Download.tsx b/packages/features/insights/filters/Download/Download.tsx index 3a40d7602d..ed911a8e2d 100644 --- a/packages/features/insights/filters/Download/Download.tsx +++ b/packages/features/insights/filters/Download/Download.tsx @@ -15,6 +15,7 @@ import { import { showToast, showProgressToast, hideProgressToast } from "@calcom/ui/components/toast"; import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; +import { extractDateRangeFromColumnFilters } from "../../lib/bookingUtils"; type RawData = RouterOutputs["viewer"]["insights"]["rawData"]["data"][number]; @@ -23,7 +24,7 @@ const BATCH_SIZE = 100; const Download = () => { const { t } = useLocale(); const insightsBookingParams = useInsightsBookingParameters(); - const { startDate, endDate } = insightsBookingParams; + const { startDate, endDate } = extractDateRangeFromColumnFilters(insightsBookingParams.columnFilters); const [isDownloading, setIsDownloading] = useState(false); const utils = trpc.useUtils(); diff --git a/packages/features/insights/hooks/useInsightsBookingParameters.ts b/packages/features/insights/hooks/useInsightsBookingParameters.ts index cbe87615cb..1330346f9d 100644 --- a/packages/features/insights/hooks/useInsightsBookingParameters.ts +++ b/packages/features/insights/hooks/useInsightsBookingParameters.ts @@ -1,12 +1,14 @@ import { useMemo } from "react"; import dayjs from "@calcom/dayjs"; -import { ZDateRangeFilterValue } from "@calcom/features/data-table"; -import { useChangeTimeZoneWithPreservedLocalTime } from "@calcom/features/data-table/hooks/useChangeTimeZoneWithPreservedLocalTime"; import { useColumnFilters } from "@calcom/features/data-table/hooks/useColumnFilters"; import { useDataTable } from "@calcom/features/data-table/hooks/useDataTable"; -import { useFilterValue } from "@calcom/features/data-table/hooks/useFilterValue"; -import { getDefaultStartDate, getDefaultEndDate } from "@calcom/features/data-table/lib/dateRange"; +import { + getDefaultStartDate, + getDefaultEndDate, + DEFAULT_PRESET, +} from "@calcom/features/data-table/lib/dateRange"; +import { ColumnFilterType, type ColumnFilter } from "@calcom/features/data-table/lib/types"; import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants"; import { useInsightsOrgTeams } from "./useInsightsOrgTeams"; @@ -14,33 +16,35 @@ import { useInsightsOrgTeams } from "./useInsightsOrgTeams"; export function useInsightsBookingParameters() { const { scope, selectedTeamId } = useInsightsOrgTeams(); const { timeZone } = useDataTable(); - - const createdAtRange = useFilterValue("createdAt", 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()) - .startOf("day") - .toISOString(); - }, [createdAtRange?.startDate]) - ); - const endDate = useChangeTimeZoneWithPreservedLocalTime( - useMemo(() => { - return dayjs(createdAtRange?.endDate ?? getDefaultEndDate().toISOString()) - .endOf("day") - .toISOString(); - }, [createdAtRange?.endDate]) - ); - const columnFilters = useColumnFilters({ exclude: ["createdAt"] }); + const columnFilters = useColumnFilters(); + const columnFiltersWithDefaultDateRange = useMemo(() => { + const hasDateRangeFilter = columnFilters.find( + (filter) => filter.id === "startTime" || filter.id === "createdAt" + ); + if (hasDateRangeFilter) { + return columnFilters; + } else { + return [ + ...columnFilters, + { + id: "startTime", + value: { + type: ColumnFilterType.DATE_RANGE, + data: { + startDate: dayjs(getDefaultStartDate().toISOString()).startOf("day").toISOString(), + endDate: dayjs(getDefaultEndDate().toISOString()).endOf("day").toISOString(), + preset: DEFAULT_PRESET.value, + }, + }, + } satisfies ColumnFilter, + ]; + } + }, [columnFilters]); return { scope, selectedTeamId, - startDate, - endDate, timeZone: timeZone || CURRENT_TIMEZONE, - columnFilters, + columnFilters: columnFiltersWithDefaultDateRange, }; } diff --git a/packages/features/insights/lib/__tests__/bookingUtils.test.ts b/packages/features/insights/lib/__tests__/bookingUtils.test.ts new file mode 100644 index 0000000000..05c2db0d1c --- /dev/null +++ b/packages/features/insights/lib/__tests__/bookingUtils.test.ts @@ -0,0 +1,538 @@ +import { describe, it, expect } from "vitest"; + +import { ColumnFilterType, type ColumnFilter } from "@calcom/features/data-table/lib/types"; + +import { extractDateRangeFromColumnFilters, replaceDateRangeColumnFilter } from "../bookingUtils"; + +describe("extractDateRangeFromColumnFilters", () => { + const mockStartTimeFilter: ColumnFilter = { + id: "startTime", + value: { + type: ColumnFilterType.DATE_RANGE, + data: { + startDate: "2024-01-01T00:00:00.000Z", + endDate: "2024-01-31T23:59:59.999Z", + preset: "thisMonth", + }, + }, + }; + + const mockCreatedAtFilter: ColumnFilter = { + id: "createdAt", + value: { + type: ColumnFilterType.DATE_RANGE, + data: { + startDate: "2024-02-01T00:00:00.000Z", + endDate: "2024-02-29T23:59:59.999Z", + preset: "lastMonth", + }, + }, + }; + + const mockTextFilter: ColumnFilter = { + id: "status", + value: { + type: ColumnFilterType.TEXT, + data: { + operator: "equals" as const, + operand: "confirmed", + }, + }, + }; + + const mockSingleSelectFilter: ColumnFilter = { + id: "eventType", + value: { + type: ColumnFilterType.SINGLE_SELECT, + data: "meeting", + }, + }; + + describe("successful extraction", () => { + it("should extract date range from startTime filter", () => { + const result = extractDateRangeFromColumnFilters([mockStartTimeFilter, mockTextFilter]); + + expect(result).toEqual({ + startDate: "2024-01-01T00:00:00.000Z", + endDate: "2024-01-31T23:59:59.999Z", + dateTarget: "startTime", + }); + }); + + it("should extract date range from createdAt filter", () => { + const result = extractDateRangeFromColumnFilters([mockCreatedAtFilter, mockSingleSelectFilter]); + + expect(result).toEqual({ + startDate: "2024-02-01T00:00:00.000Z", + endDate: "2024-02-29T23:59:59.999Z", + dateTarget: "createdAt", + }); + }); + + it("should find startTime filter when it comes after other filters", () => { + const result = extractDateRangeFromColumnFilters([ + mockTextFilter, + mockStartTimeFilter, + mockSingleSelectFilter, + ]); + + expect(result).toEqual({ + startDate: "2024-01-01T00:00:00.000Z", + endDate: "2024-01-31T23:59:59.999Z", + dateTarget: "startTime", + }); + }); + + it("should ignore non-date-range filters", () => { + const result = extractDateRangeFromColumnFilters([ + mockTextFilter, + mockSingleSelectFilter, + mockStartTimeFilter, + ]); + + expect(result).toEqual({ + startDate: "2024-01-01T00:00:00.000Z", + endDate: "2024-01-31T23:59:59.999Z", + dateTarget: "startTime", + }); + }); + }); + + describe("error cases", () => { + it("should throw error when no column filters provided", () => { + expect(() => extractDateRangeFromColumnFilters()).toThrow("No date range filter found"); + }); + + it("should throw error when column filters is undefined", () => { + expect(() => extractDateRangeFromColumnFilters(undefined)).toThrow("No date range filter found"); + }); + + it("should handle empty column filters array", () => { + expect(() => extractDateRangeFromColumnFilters([])).toThrow("No date range filter found"); + }); + + it("should throw error when no date range filters exist", () => { + expect(() => extractDateRangeFromColumnFilters([mockTextFilter, mockSingleSelectFilter])).toThrow( + "No date range filter found" + ); + }); + + it("should throw error when date range filter has missing startDate", () => { + const filterWithMissingStartDate: ColumnFilter = { + id: "startTime", + value: { + type: ColumnFilterType.DATE_RANGE, + data: { + startDate: null, + endDate: "2024-01-31T23:59:59.999Z", + preset: "thisMonth", + }, + }, + }; + + expect(() => extractDateRangeFromColumnFilters([filterWithMissingStartDate])).toThrow( + "No date range filter found" + ); + }); + + it("should throw error when date range filter has missing endDate", () => { + const filterWithMissingEndDate: ColumnFilter = { + id: "createdAt", + value: { + type: ColumnFilterType.DATE_RANGE, + data: { + startDate: "2024-01-01T00:00:00.000Z", + endDate: null, + preset: "thisMonth", + }, + }, + }; + + expect(() => extractDateRangeFromColumnFilters([filterWithMissingEndDate])).toThrow( + "No date range filter found" + ); + }); + + it("should throw error when date range filter has both dates missing", () => { + const filterWithMissingDates: ColumnFilter = { + id: "startTime", + value: { + type: ColumnFilterType.DATE_RANGE, + data: { + startDate: null, + endDate: null, + preset: "thisMonth", + }, + }, + }; + + expect(() => extractDateRangeFromColumnFilters([filterWithMissingDates])).toThrow( + "No date range filter found" + ); + }); + + it("should throw error when startTime/createdAt filter is not DATE_RANGE type", () => { + const invalidStartTimeFilter: ColumnFilter = { + id: "startTime", + value: { + type: ColumnFilterType.TEXT, + data: { + operator: "equals" as const, + operand: "some-date", + }, + }, + }; + + expect(() => extractDateRangeFromColumnFilters([invalidStartTimeFilter])).toThrow( + "No date range filter found" + ); + }); + }); + + describe("edge cases", () => { + it("should work with only one date range filter among many others", () => { + const numberFilter: ColumnFilter = { + id: "number-filter", + value: { + type: ColumnFilterType.NUMBER, + data: { operator: "gt" as const, operand: 5 }, + }, + }; + + const anotherTextFilter: ColumnFilter = { + id: "another-text", + value: { + type: ColumnFilterType.TEXT, + data: { operator: "contains" as const, operand: "test" }, + }, + }; + + const manyFilters = [ + mockTextFilter, + mockSingleSelectFilter, + anotherTextFilter, + mockStartTimeFilter, + numberFilter, + ]; + + const result = extractDateRangeFromColumnFilters(manyFilters); + + expect(result).toEqual({ + startDate: "2024-01-01T00:00:00.000Z", + endDate: "2024-01-31T23:59:59.999Z", + dateTarget: "startTime", + }); + }); + }); +}); + +describe("replaceDateRangeColumnFilter", () => { + const mockStartTimeFilter: ColumnFilter = { + id: "startTime", + value: { + type: ColumnFilterType.DATE_RANGE, + data: { + startDate: "2024-01-01T00:00:00.000Z", + endDate: "2024-01-31T23:59:59.999Z", + preset: "thisMonth", + }, + }, + }; + + const mockCreatedAtFilter: ColumnFilter = { + id: "createdAt", + value: { + type: ColumnFilterType.DATE_RANGE, + data: { + startDate: "2024-02-01T00:00:00.000Z", + endDate: "2024-02-29T23:59:59.999Z", + preset: "lastMonth", + }, + }, + }; + + const mockTextFilter: ColumnFilter = { + id: "status", + value: { + type: ColumnFilterType.TEXT, + data: { + operator: "equals" as const, + operand: "confirmed", + }, + }, + }; + + const mockSingleSelectFilter: ColumnFilter = { + id: "eventType", + value: { + type: ColumnFilterType.SINGLE_SELECT, + data: "meeting", + }, + }; + + const newStartDate = "2024-03-01T00:00:00.000Z"; + const newEndDate = "2024-03-31T23:59:59.999Z"; + + describe("successful replacement", () => { + it("should replace startTime filter with new dates", () => { + const columnFilters = [mockStartTimeFilter, mockTextFilter]; + + const result = replaceDateRangeColumnFilter({ + columnFilters, + newStartDate, + newEndDate, + }); + + expect(result).toEqual([ + { + id: "startTime", + value: { + type: ColumnFilterType.DATE_RANGE, + data: { + startDate: newStartDate, + endDate: newEndDate, + preset: "thisMonth", // Preserves original preset + }, + }, + }, + mockTextFilter, // Non-date filters remain unchanged + ]); + }); + + it("should replace createdAt filter with new dates", () => { + const columnFilters = [mockCreatedAtFilter, mockSingleSelectFilter]; + + const result = replaceDateRangeColumnFilter({ + columnFilters, + newStartDate, + newEndDate, + }); + + expect(result).toEqual([ + { + id: "createdAt", + value: { + type: ColumnFilterType.DATE_RANGE, + data: { + startDate: newStartDate, + endDate: newEndDate, + preset: "lastMonth", // Preserves original preset + }, + }, + }, + mockSingleSelectFilter, // Non-date filters remain unchanged + ]); + }); + + it("should preserve preset value when replacing filter", () => { + const customPresetFilter: ColumnFilter = { + id: "startTime", + value: { + type: ColumnFilterType.DATE_RANGE, + data: { + startDate: "2024-01-01T00:00:00.000Z", + endDate: "2024-01-31T23:59:59.999Z", + preset: "customPreset", + }, + }, + }; + + const result = replaceDateRangeColumnFilter({ + columnFilters: [customPresetFilter], + newStartDate, + newEndDate, + }); + + expect(result![0].value).toMatchObject({ + data: expect.objectContaining({ + preset: "customPreset", + }), + }); + }); + + it("should leave non-date-range filters unchanged", () => { + const columnFilters = [mockTextFilter, mockSingleSelectFilter, mockStartTimeFilter]; + + const result = replaceDateRangeColumnFilter({ + columnFilters, + newStartDate, + newEndDate, + }); + + expect(result).toEqual([ + mockTextFilter, // Unchanged + mockSingleSelectFilter, // Unchanged + { + id: "startTime", + value: { + type: ColumnFilterType.DATE_RANGE, + data: { + startDate: newStartDate, + endDate: newEndDate, + preset: "thisMonth", + }, + }, + }, + ]); + }); + + it("should handle startTime filter replacement among other filters", () => { + const columnFilters = [mockTextFilter, mockStartTimeFilter, mockSingleSelectFilter]; + + const result = replaceDateRangeColumnFilter({ + columnFilters, + newStartDate, + newEndDate, + }); + + expect(result).toEqual([ + mockTextFilter, // Unchanged + { + id: "startTime", + value: { + type: ColumnFilterType.DATE_RANGE, + data: { + startDate: newStartDate, + endDate: newEndDate, + preset: "thisMonth", + }, + }, + }, + mockSingleSelectFilter, // Unchanged + ]); + }); + + it("should maintain filter structure when replacing", () => { + const result = replaceDateRangeColumnFilter({ + columnFilters: [mockStartTimeFilter], + newStartDate, + newEndDate, + }); + + expect(result?.[0]).toHaveProperty("id"); + expect(result?.[0]).toHaveProperty("value"); + expect(result?.[0].value).toHaveProperty("type", ColumnFilterType.DATE_RANGE); + expect(result?.[0].value).toHaveProperty("data"); + expect(result?.[0].value.data).toHaveProperty("startDate"); + expect(result?.[0].value.data).toHaveProperty("endDate"); + expect(result?.[0].value.data).toHaveProperty("preset"); + }); + }); + + describe("edge cases", () => { + it("should return undefined when no column filters provided", () => { + const result = replaceDateRangeColumnFilter({ + columnFilters: undefined, + newStartDate, + newEndDate, + }); + + expect(result).toBeUndefined(); + }); + + it("should handle empty column filters array", () => { + const result = replaceDateRangeColumnFilter({ + columnFilters: [], + newStartDate, + newEndDate, + }); + + expect(result).toEqual([]); + }); + + it("should return unchanged filters when no date range filters exist", () => { + const columnFilters = [mockTextFilter, mockSingleSelectFilter]; + + const result = replaceDateRangeColumnFilter({ + columnFilters, + newStartDate, + newEndDate, + }); + + expect(result).toEqual(columnFilters); + }); + + it("should not modify filters with startTime/createdAt id but wrong type", () => { + const invalidStartTimeFilter: ColumnFilter = { + id: "startTime", + value: { + type: ColumnFilterType.TEXT, + data: { + operator: "equals" as const, + operand: "some-date", + }, + }, + }; + + const result = replaceDateRangeColumnFilter({ + columnFilters: [invalidStartTimeFilter], + newStartDate, + newEndDate, + }); + + expect(result).toEqual([invalidStartTimeFilter]); // Should remain unchanged + }); + + it("should handle filters with similar but different ids", () => { + const similarIdFilter: ColumnFilter = { + id: "startTimeRange", // Similar but not exact match + value: { + type: ColumnFilterType.DATE_RANGE, + data: { + startDate: "2024-01-01T00:00:00.000Z", + endDate: "2024-01-31T23:59:59.999Z", + preset: "thisMonth", + }, + }, + }; + + const result = replaceDateRangeColumnFilter({ + columnFilters: [similarIdFilter], + newStartDate, + newEndDate, + }); + + expect(result).toEqual([similarIdFilter]); // Should remain unchanged + }); + + it("should handle mixed case scenarios", () => { + const invalidStartTimeTextFilter: ColumnFilter = { + id: "startTime", + value: { + type: ColumnFilterType.TEXT, // Wrong type, should be ignored + data: { operator: "equals" as const, operand: "test" }, + }, + }; + + const mixedFilters = [ + mockTextFilter, + invalidStartTimeTextFilter, + mockCreatedAtFilter, // Valid date range filter + mockSingleSelectFilter, + ]; + + const result = replaceDateRangeColumnFilter({ + columnFilters: mixedFilters, + newStartDate, + newEndDate, + }); + + expect(result).toEqual([ + mockTextFilter, // Unchanged + invalidStartTimeTextFilter, // Unchanged (wrong type) + { + id: "createdAt", + value: { + type: ColumnFilterType.DATE_RANGE, + data: { + startDate: newStartDate, + endDate: newEndDate, + preset: "lastMonth", // Replaced + }, + }, + }, + mockSingleSelectFilter, // Unchanged + ]); + }); + }); +}); diff --git a/packages/features/insights/lib/bookingUtils.ts b/packages/features/insights/lib/bookingUtils.ts new file mode 100644 index 0000000000..6ddf794af1 --- /dev/null +++ b/packages/features/insights/lib/bookingUtils.ts @@ -0,0 +1,57 @@ +import { ColumnFilterType } from "@calcom/features/data-table/lib/types"; +import { type ColumnFilter } from "@calcom/features/data-table/lib/types"; +import { isDateRangeFilterValue } from "@calcom/features/data-table/lib/utils"; + +export function extractDateRangeFromColumnFilters(columnFilters?: ColumnFilter[]) { + if (!columnFilters) throw new Error("No date range filter found"); + + for (const filter of columnFilters) { + if ((filter.id === "startTime" || filter.id === "createdAt") && isDateRangeFilterValue(filter.value)) { + const dateFilter = filter.value as Extract< + ColumnFilter["value"], + { type: ColumnFilterType.DATE_RANGE } + >; + if (dateFilter.data.startDate && dateFilter.data.endDate) { + return { + startDate: dateFilter.data.startDate, + endDate: dateFilter.data.endDate, + dateTarget: filter.id, + }; + } + } + } + + throw new Error("No date range filter found"); +} + +export function replaceDateRangeColumnFilter({ + columnFilters, + newStartDate, + newEndDate, +}: { + columnFilters?: ColumnFilter[]; + newStartDate: string; + newEndDate: string; +}) { + if (!columnFilters) { + return undefined; + } + + return columnFilters.map((filter) => { + if ((filter.id === "startTime" || filter.id === "createdAt") && isDateRangeFilterValue(filter.value)) { + return { + id: filter.id, + value: { + type: ColumnFilterType.DATE_RANGE, + data: { + startDate: newStartDate, + endDate: newEndDate, + preset: filter.value.data.preset, + }, + }, + } satisfies ColumnFilter; + } else { + return filter; + } + }); +} diff --git a/packages/features/insights/server/raw-data.schema.ts b/packages/features/insights/server/raw-data.schema.ts index ba2384ec46..309e6c7c01 100644 --- a/packages/features/insights/server/raw-data.schema.ts +++ b/packages/features/insights/server/raw-data.schema.ts @@ -103,8 +103,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(), }); diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts index 5cddecf9b3..c8d55f681e 100644 --- a/packages/features/insights/server/trpc-router.ts +++ b/packages/features/insights/server/trpc-router.ts @@ -1,6 +1,10 @@ import { z } from "zod"; import dayjs from "@calcom/dayjs"; +import { + extractDateRangeFromColumnFilters, + replaceDateRangeColumnFilter, +} from "@calcom/features/insights/lib/bookingUtils"; import { insightsRoutingServiceInputSchema, insightsRoutingServicePaginatedInputSchema, @@ -313,10 +317,9 @@ export interface IResultTeamList { */ function createInsightsBookingService( ctx: { user: { id: number; organizationId: number | null } }, - input: z.infer, - dateTarget: "createdAt" | "startTime" = "createdAt" + input: z.infer ) { - const { scope, selectedTeamId, startDate, endDate, columnFilters } = input; + const { scope, selectedTeamId, columnFilters } = input; return getInsightsBookingService({ options: { scope, @@ -326,11 +329,6 @@ function createInsightsBookingService( }, filters: { ...(columnFilters && { columnFilters }), - dateRange: { - target: dateTarget, - startDate, - endDate, - }, }, }); } @@ -365,10 +363,15 @@ export const insightsRouter = router({ // Calculate previous period dates and create service for previous period const previousPeriodDates = currentPeriodService.calculatePreviousPeriodDates(); + const previousPeriodColumnFilters = replaceDateRangeColumnFilter({ + columnFilters: input.columnFilters, + newStartDate: previousPeriodDates.startDate, + newEndDate: previousPeriodDates.endDate, + }); + const previousPeriodService = createInsightsBookingService(ctx, { ...input, - startDate: previousPeriodDates.startDate, - endDate: previousPeriodDates.endDate, + columnFilters: previousPeriodColumnFilters, }); // Get previous period stats @@ -456,7 +459,8 @@ export const insightsRouter = router({ }; }), eventTrends: insightsPbacProcedure.input(bookingRepositoryBaseInputSchema).query(async ({ ctx, input }) => { - const { startDate, endDate, timeZone } = input; + const { columnFilters, timeZone } = input; + const { startDate, endDate } = extractDateRangeFromColumnFilters(columnFilters); // Calculate timeView and dateRanges const timeView = getTimeView(startDate, endDate); @@ -492,7 +496,8 @@ export const insightsRouter = router({ averageEventDuration: insightsPbacProcedure .input(bookingRepositoryBaseInputSchema) .query(async ({ ctx, input }) => { - const { startDate, endDate, timeZone } = input; + const { columnFilters, timeZone } = input; + const { startDate, endDate, dateTarget } = extractDateRangeFromColumnFilters(columnFilters); const insightsBookingService = createInsightsBookingService(ctx, input); @@ -516,6 +521,7 @@ export const insightsRouter = router({ select: { eventLength: true, createdAt: true, + startTime: true, }, }); @@ -527,7 +533,9 @@ export const insightsRouter = router({ } for (const booking of allBookings) { - const periodStart = dayjs(booking.createdAt).startOf(startOfEndOf).format("ll"); + const periodStart = dayjs(dateTarget === "startTime" ? booking.startTime : booking.createdAt) + .startOf(startOfEndOf) + .format("ll"); if (resultMap.has(periodStart)) { const current = resultMap.get(periodStart); if (!current) continue; @@ -552,7 +560,10 @@ export const insightsRouter = router({ const insightsBookingService = createInsightsBookingService(ctx, input); try { - return await insightsBookingService.getMembersStatsWithCount("cancelled", "DESC"); + return await insightsBookingService.getMembersStatsWithCount({ + type: "cancelled", + sortOrder: "DESC", + }); } catch (e) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } @@ -560,10 +571,14 @@ export const insightsRouter = router({ membersWithMostCompletedBookings: insightsPbacProcedure .input(bookingRepositoryBaseInputSchema) .query(async ({ input, ctx }) => { - const insightsBookingService = createInsightsBookingService(ctx, input, "startTime"); + const insightsBookingService = createInsightsBookingService(ctx, input); try { - return await insightsBookingService.getMembersStatsWithCount("accepted", "DESC"); + return await insightsBookingService.getMembersStatsWithCount({ + type: "accepted", + sortOrder: "DESC", + completed: true, + }); } catch (e) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } @@ -571,10 +586,14 @@ export const insightsRouter = router({ membersWithLeastCompletedBookings: insightsPbacProcedure .input(bookingRepositoryBaseInputSchema) .query(async ({ input, ctx }) => { - const insightsBookingService = createInsightsBookingService(ctx, input, "startTime"); + const insightsBookingService = createInsightsBookingService(ctx, input); try { - return await insightsBookingService.getMembersStatsWithCount("accepted", "ASC"); + return await insightsBookingService.getMembersStatsWithCount({ + type: "accepted", + sortOrder: "ASC", + completed: true, + }); } catch (e) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } @@ -585,7 +604,7 @@ export const insightsRouter = router({ const insightsBookingService = createInsightsBookingService(ctx, input); try { - return await insightsBookingService.getMembersStatsWithCount("all", "DESC"); + return await insightsBookingService.getMembersStatsWithCount({ type: "all", sortOrder: "DESC" }); } catch (e) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } @@ -596,7 +615,7 @@ export const insightsRouter = router({ const insightsBookingService = createInsightsBookingService(ctx, input); try { - return await insightsBookingService.getMembersStatsWithCount("all", "ASC"); + return await insightsBookingService.getMembersStatsWithCount({ type: "all", sortOrder: "ASC" }); } catch (e) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } @@ -792,7 +811,7 @@ export const insightsRouter = router({ const insightsBookingService = createInsightsBookingService(ctx, input); try { - return await insightsBookingService.getMembersStatsWithCount("noShow", "DESC"); + return await insightsBookingService.getMembersStatsWithCount({ type: "noShow", sortOrder: "DESC" }); } catch (e) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } @@ -1001,7 +1020,7 @@ export const insightsRouter = router({ .input(bookingRepositoryBaseInputSchema) .query(async ({ ctx, input }) => { const { timeZone } = input; - const insightsBookingService = createInsightsBookingService(ctx, input, "startTime"); + const insightsBookingService = createInsightsBookingService(ctx, input); try { return await insightsBookingService.getBookingsByHourStats({ @@ -1014,7 +1033,7 @@ export const insightsRouter = router({ recentNoShowGuests: insightsPbacProcedure .input(bookingRepositoryBaseInputSchema) .query(async ({ ctx, input }) => { - const insightsBookingService = createInsightsBookingService(ctx, input, "startTime"); + const insightsBookingService = createInsightsBookingService(ctx, input); try { return await insightsBookingService.getRecentNoShowGuests(); diff --git a/packages/features/users/components/UserTable/UserListTable.tsx b/packages/features/users/components/UserTable/UserListTable.tsx index 6e3f80cddf..4460d1c7c4 100644 --- a/packages/features/users/components/UserTable/UserListTable.tsx +++ b/packages/features/users/components/UserTable/UserListTable.tsx @@ -386,9 +386,6 @@ function UserListTableContent({ meta: { filter: { type: ColumnFilterType.DATE_RANGE, - dateRangeOptions: { - endOfDay: true, - }, }, }, cell: ({ row }) =>
{row.original.lastActiveAt}
, @@ -402,9 +399,6 @@ function UserListTableContent({ meta: { filter: { type: ColumnFilterType.DATE_RANGE, - dateRangeOptions: { - endOfDay: true, - }, }, }, cell: ({ row }) =>
{row.original.createdAt || ""}
, @@ -418,9 +412,6 @@ function UserListTableContent({ meta: { filter: { type: ColumnFilterType.DATE_RANGE, - dateRangeOptions: { - endOfDay: true, - }, }, }, cell: ({ row }) =>
{row.original.updatedAt || ""}
, diff --git a/packages/lib/server/service/InsightsBookingBaseService.ts b/packages/lib/server/service/InsightsBookingBaseService.ts index 6a8f9ea045..064ecb3d10 100644 --- a/packages/lib/server/service/InsightsBookingBaseService.ts +++ b/packages/lib/server/service/InsightsBookingBaseService.ts @@ -4,13 +4,19 @@ 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 type { ColumnFilter } from "@calcom/features/data-table/lib/types"; +import { 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 { + extractDateRangeFromColumnFilters, + replaceDateRangeColumnFilter, +} from "@calcom/features/insights/lib/bookingUtils"; import type { DateRange } from "@calcom/features/insights/server/insightsDateUtils"; import type { PrismaClient } from "@calcom/prisma"; import { Prisma } from "@calcom/prisma/client"; @@ -127,13 +133,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(), }); @@ -273,23 +272,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; } @@ -355,6 +337,39 @@ export class InsightsBookingBaseService { } } + if ((id === "startTime" || id === "createdAt") && isDateRangeFilterValue(value)) { + const conditions: Prisma.Sql[] = []; + // if `startTime` filter -> x <= "startTime" AND "endTime" <= y + // if `createdAt` filter -> x <= "createdAt" AND "createdAt" <= y + if (value.data.startDate) { + if (isNaN(Date.parse(value.data.startDate))) { + throw new Error(`Invalid date format: ${value.data.startDate}`); + } + if (id === "startTime") { + conditions.push(Prisma.sql`${value.data.startDate}::timestamp <= "startTime"`); + } else { + conditions.push(Prisma.sql`${value.data.startDate}::timestamp <= "createdAt"`); + } + } + if (value.data.endDate) { + if (isNaN(Date.parse(value.data.endDate))) { + throw new Error(`Invalid date format: ${value.data.endDate}`); + } + if (id === "startTime") { + conditions.push(Prisma.sql`"endTime" <= ${value.data.endDate}::timestamp`); + } else { + conditions.push(Prisma.sql`"createdAt" <= ${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; } @@ -863,27 +878,42 @@ export class InsightsBookingBaseService { return result; } - async getMembersStatsWithCount( - type: "all" | "accepted" | "cancelled" | "noShow" = "all", - sortOrder: "ASC" | "DESC" = "DESC" - ): Promise { + async getMembersStatsWithCount({ + type = "all", + sortOrder = "DESC", + completed, + }: { + type?: "all" | "accepted" | "cancelled" | "noShow"; + sortOrder?: "ASC" | "DESC"; + completed?: boolean; + } = {}): Promise { const baseConditions = await this.getBaseConditions(); - let additionalCondition = Prisma.sql``; + const conditions: Prisma.Sql[] = [Prisma.sql`"userId" IS NOT NULL`]; + if (type === "cancelled") { - additionalCondition = Prisma.sql`AND status = 'cancelled'`; + conditions.push(Prisma.sql`status = 'cancelled'`); } else if (type === "noShow") { - additionalCondition = Prisma.sql`AND "noShowHost" = true`; + conditions.push(Prisma.sql`"noShowHost" = true`); } else if (type === "accepted") { - additionalCondition = Prisma.sql`AND status = 'accepted'`; + conditions.push(Prisma.sql`status = 'accepted'`); } + if (completed) { + conditions.push(Prisma.sql`"endTime" <= NOW()`); + } + + const additionalCondition = conditions.reduce((acc, condition, index) => { + if (index === 0) return condition; + return Prisma.sql`(${acc}) AND (${condition})`; + }); + const query = Prisma.sql` SELECT "userId", COUNT(id)::int as count FROM "BookingTimeStatusDenormalized" - WHERE ${baseConditions} AND "userId" IS NOT NULL ${additionalCondition} + WHERE (${baseConditions}) AND (${additionalCondition}) GROUP BY "userId" ORDER BY count ${sortOrder === "ASC" ? Prisma.sql`ASC` : Prisma.sql`DESC`} LIMIT 10 @@ -1208,12 +1238,10 @@ export class InsightsBookingBaseService { } calculatePreviousPeriodDates() { - if (!this.filters?.dateRange) { - throw new Error("Date range is required for calculating previous period"); - } + const result = extractDateRangeFromColumnFilters(this.filters?.columnFilters); + const startDate = dayjs(result.startDate); + const endDate = dayjs(result.endDate); - const startDate = dayjs(this.filters.dateRange.startDate); - const endDate = dayjs(this.filters.dateRange.endDate); const startTimeEndTimeDiff = endDate.diff(startDate, "day"); const lastPeriodStartDate = startDate.subtract(startTimeEndTimeDiff, "day");