diff --git a/apps/web/modules/insights/insights-view.tsx b/apps/web/modules/insights/insights-view.tsx index cbac224b79a810..24a0c007db832c 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 1225495428bfbb..6c820b95fb2304 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 2456400bbd9763..96e6464c5d0f82 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 549e6192e7a597..4f91d41ef72e65 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 783e24ac82d683..b51eeb98c7b00a 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 2589d7d661f8d1..033d58179d5367 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 4e192e5b452e40..e08d5280f3a036 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 4b147cd15232e4..14026dfe6f0d54 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 57ea6aced82553..d29f23caf67314 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 2cdd93b36a52cc..ce2f8f06699551 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 164ebd4f4b8d76..6828f5ec3592d2 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 00000000000000..0a50e8c44d7e62 --- /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 3a40d7602dec55..ed911a8e2d7464 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 cbe87615cb4674..1330346f9d1140 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 00000000000000..05c2db0d1c594a --- /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 00000000000000..6ddf794af1cdeb --- /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 ba2384ec46b03f..309e6c7c01fb41 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 5cddecf9b357cc..c8d55f681e1f00 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 6e3f80cddf78a6..4460d1c7c4a81e 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 6a8f9ea045ce2d..064ecb3d108381 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");