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 <hey@eunjae.dev> * 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 <hey@eunjae.dev> * 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 <hey@eunjae.dev> * 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 <hey@eunjae.dev> * 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>
This commit is contained in:
co-authored by
eunjae@cal.com <hey@eunjae.dev>
Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
616e4c4908
commit
bdc3cb9d6e
@@ -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<FilterableColumn, { type: ColumnFilterType.DATE_R
|
||||
type: ColumnFilterType.DATE_RANGE,
|
||||
};
|
||||
|
||||
const startTimeColumn: Extract<FilterableColumn, { type: ColumnFilterType.DATE_RANGE }> = {
|
||||
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() {
|
||||
<DataTableFilters.AddFilterButton table={table} hideWhenFilterApplied />
|
||||
<DataTableFilters.ActiveFilters table={table} />
|
||||
<DataTableFilters.AddFilterButton table={table} variant="sm" showWhenFilterApplied />
|
||||
<DataTableFilters.ClearFiltersButton exclude={["createdAt"]} />
|
||||
<DataTableFilters.ClearFiltersButton exclude={["startTime", "createdAt"]} />
|
||||
<div className="grow" />
|
||||
<Download />
|
||||
<DateRangeFilter column={createdAtColumn} />
|
||||
<ButtonGroup combined>
|
||||
<DateRangeFilter
|
||||
column={dateTarget === "startTime" ? startTimeColumn : createdAtColumn}
|
||||
options={{ convertToTimeZone: true }}
|
||||
/>
|
||||
<DateTargetSelector value={dateTarget as DateTarget} onChange={setDateTarget} />
|
||||
</ButtonGroup>
|
||||
<TimezoneBadge />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -116,7 +116,7 @@ export const ZFilterValue = z.union([
|
||||
|
||||
export type DateRangeFilterOptions = {
|
||||
range?: "past" | "custom";
|
||||
endOfDay?: boolean;
|
||||
convertToTimeZone?: boolean;
|
||||
};
|
||||
|
||||
export type TextFilterOptions = {
|
||||
|
||||
@@ -36,7 +36,7 @@ type EventTrendsData = RouterOutputs["viewer"]["insights"]["eventTrends"][number
|
||||
const CustomTooltip = ({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
label: _label,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: Array<{
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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 (
|
||||
<Tooltip content={timezoneData.tooltipContent}>
|
||||
<Badge variant="gray" size="sm" data-testid="timezone-mismatch-badge">
|
||||
<Icon name="info" />
|
||||
</Badge>
|
||||
<Icon name="info" data-testid="timezone-mismatch-badge" className="text-subtle" />
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="icon"
|
||||
color="secondary"
|
||||
StartIcon="sliders-horizontal"
|
||||
role="combobox"
|
||||
aria-expanded={open}>
|
||||
<span className="sr-only">{selectedOption.label}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-0" align="end">
|
||||
<Command>
|
||||
<CommandList>
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
onSelect={() => {
|
||||
onChange(option.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-3">
|
||||
<div>
|
||||
<div className="font-medium">{option.label}</div>
|
||||
<div className="text-muted-foreground text-sm">{option.description}</div>
|
||||
</div>
|
||||
{selectedOption.label === option.label && (
|
||||
<Icon name="check" className="text-primary-foreground h-4 w-4" />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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<typeof bookingRepositoryBaseInputSchema>,
|
||||
dateTarget: "createdAt" | "startTime" = "createdAt"
|
||||
input: z.infer<typeof bookingRepositoryBaseInputSchema>
|
||||
) {
|
||||
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();
|
||||
|
||||
@@ -386,9 +386,6 @@ function UserListTableContent({
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.DATE_RANGE,
|
||||
dateRangeOptions: {
|
||||
endOfDay: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
cell: ({ row }) => <div>{row.original.lastActiveAt}</div>,
|
||||
@@ -402,9 +399,6 @@ function UserListTableContent({
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.DATE_RANGE,
|
||||
dateRangeOptions: {
|
||||
endOfDay: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
cell: ({ row }) => <div>{row.original.createdAt || ""}</div>,
|
||||
@@ -418,9 +412,6 @@ function UserListTableContent({
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.DATE_RANGE,
|
||||
dateRangeOptions: {
|
||||
endOfDay: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
cell: ({ row }) => <div>{row.original.updatedAt || ""}</div>,
|
||||
|
||||
@@ -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<typeof insightsBookingServic
|
||||
export type InsightsBookingServiceFilterOptions = z.infer<typeof insightsBookingServiceFilterOptionsSchema>;
|
||||
|
||||
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<UserStatsData> {
|
||||
async getMembersStatsWithCount({
|
||||
type = "all",
|
||||
sortOrder = "DESC",
|
||||
completed,
|
||||
}: {
|
||||
type?: "all" | "accepted" | "cancelled" | "noShow";
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
completed?: boolean;
|
||||
} = {}): Promise<UserStatsData> {
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user