fix(bookings): enable past date selection for cancelled bookings (#25644)

* refactor(data-table): clean up DateRangeFilter range options

- Replace 'past' | 'custom' with 'past' | 'future' | 'any' | 'customOnly'
- Add direction field to PresetOption for preset compatibility filtering
- Derive presets visibility automatically based on compatible presets
- Update bookings list to use new range values:
  - past -> 'past'
  - upcoming -> 'future'
  - unconfirmed/recurring/cancelled -> 'any'

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* feat(playground): add DateRangeFilter playground page with E2E tests

- Add playground page at /settings/admin/playground/date-range-filter
- Demonstrate all 4 range options: past, future, any, customOnly
- Add link to playground index page
- Add E2E tests for presets visibility and date restrictions

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix(playground): use correct meta.filter pattern for column filter config

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* clean up the playground esign

* add unit tests instead of e2e

* fix the implementation

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Eunjae Lee
2025-12-17 13:47:51 +00:00
committed by GitHub
co-authored by eunjae@cal.com <hey@eunjae.dev> eunjae@cal.com <hey@eunjae.dev> Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent dd27d077ca
commit aaaff0705b
9 changed files with 283 additions and 30 deletions
@@ -0,0 +1,150 @@
"use client";
import { useReactTable, getCoreRowModel, createColumnHelper } from "@tanstack/react-table";
import { useMemo } from "react";
import { ColumnFilterType, DateRangeFilter, DataTableProvider } from "@calcom/features/data-table";
import type { DateRangeFilterOptions } from "@calcom/features/data-table/lib/types";
type DemoRow = {
id: number;
name: string;
date: string;
};
const columnHelper = createColumnHelper<DemoRow>();
type ScenarioProps = {
id: string;
title: string;
description: string;
expected: string;
range: DateRangeFilterOptions["range"];
};
const scenarios: ScenarioProps[] = [
{
id: "past",
title: 'Range: "past"',
description: "Restricts date selection to past dates only. Shows presets that are past-compatible.",
expected:
"Presets visible: Today, Last 7 days, Last 30 days, Month to date, Year to date, Custom. Calendar maxDate = today.",
range: "past",
},
{
id: "future",
title: 'Range: "future"',
description: "Restricts date selection to future dates only. Shows only future-compatible presets.",
expected: "Presets visible: Custom only (presets with direction 'any'). Calendar minDate = today.",
range: "future",
},
{
id: "any",
title: 'Range: "any"',
description: "No date restrictions. Shows all presets.",
expected: "All presets visible. No calendar date restrictions.",
range: "any",
},
{
id: "customOnly",
title: 'Range: "customOnly"',
description: "Forces custom date picker only. Always hides presets dropdown.",
expected: "No presets dropdown. Only calendar picker visible when opened. No date restrictions.",
range: "customOnly",
},
];
function ScenarioCard({ scenario }: { scenario: ScenarioProps }) {
const columns = useMemo(
() => [
columnHelper.accessor("name", {
header: "Name",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("date", {
id: "dateRange",
header: "Date Range",
cell: (info) => info.getValue(),
enableColumnFilter: true,
meta: {
filter: {
type: ColumnFilterType.DATE_RANGE,
dateRangeOptions: {
range: scenario.range,
},
},
},
}),
],
[scenario.range]
);
const data = useMemo<DemoRow[]>(
() => [
{ id: 1, name: "Demo Item 1", date: "2024-01-15" },
{ id: 2, name: "Demo Item 2", date: "2024-02-20" },
],
[]
);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
// Get the column definition to pass to DateRangeFilter
const dateRangeColumn = table.getAllColumns().find((col) => col.id === "dateRange");
const columnMeta = dateRangeColumn?.columnDef.meta as
| { filter?: { type: string; dateRangeOptions?: DateRangeFilterOptions } }
| undefined;
const dateRangeOptions = columnMeta?.filter?.dateRangeOptions;
return (
<div className="border-subtle mb-8 rounded-lg border p-6" data-testid={`drf-scenario-${scenario.id}`}>
<h3 className="text-emphasis mb-2 text-lg font-semibold">{scenario.title}</h3>
<p className="text-default mb-2 text-sm">{scenario.description}</p>
<p className="text-subtle mb-4 text-xs">
<strong>Expected:</strong> {scenario.expected}
</p>
<div className="mt-4">
<DataTableProvider tableIdentifier={`playground-date-range-${scenario.id}`}>
{dateRangeColumn && (
<DateRangeFilter
column={{
id: dateRangeColumn.id,
title: dateRangeColumn.columnDef.header as string,
type: "dr",
}}
options={dateRangeOptions}
showColumnName={false}
showClearButton={false}
/>
)}
</DataTableProvider>
</div>
</div>
);
}
export default function DateRangeFilterPlayground() {
return (
<div className="space-y-6">
<div>
<h1 className="text-emphasis text-2xl font-bold">DateRangeFilter Playground</h1>
<p className="text-default mt-2">
This page demonstrates the different <code>range</code> options for the DateRangeFilter component.
</p>
<p className="text-subtle mt-1 text-sm">
The <code>range</code> option controls both date restrictions and presets visibility. Presets
visibility is derived automatically based on compatible presets.
</p>
</div>
{scenarios.map((scenario) => (
<ScenarioCard key={scenario.id} scenario={scenario} />
))}
</div>
);
}
@@ -2,19 +2,32 @@ import { _generateMetadata, getTranslate } from "app/_utils";
import Link from "next/link";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { Icon } from "@calcom/ui/components/icon";
const LINKS = [
{
title: "Routing Funnel",
description: "Visualize booking conversion flow and routing patterns",
href: "/settings/admin/playground/routing-funnel",
icon: "filter" as const,
},
{
title: "Bookings by Hour",
description: "View booking distribution across different hours",
href: "/settings/admin/playground/bookings-by-hour",
icon: "chart-bar" as const,
},
{
title: "Weekly Calendar",
description: "Interactive weekly calendar view for scheduling",
href: "/settings/admin/playground/weekly-calendar",
icon: "calendar" as const,
},
{
title: "Date Range Filter",
description: "Test date range selection and filtering components",
href: "/settings/admin/playground/date-range-filter",
icon: "calendar-days" as const,
},
];
@@ -31,16 +44,27 @@ const Page = async () => {
const t = await getTranslate();
return (
<SettingsHeader title={t("playground")} description={t("admin_playground_description")}>
<div>
<ul className="mt-8">
<div className="mt-6">
<div className="bg-default border-subtle divide-subtle flex flex-col divide-y rounded-md border">
{LINKS.map((link) => (
<li key={link.title}>
<Link href={link.href} className="list-item list-disc font-medium underline">
{link.title}
</Link>
</li>
<Link
key={link.title}
href={link.href}
className="hover:bg-muted group flex items-center gap-4 p-5 transition-colors">
<div className="bg-emphasis/10 group-hover:bg-emphasis/20 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md transition-colors">
<Icon name={link.icon} className="text-emphasis h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<h3 className="text-emphasis text-sm font-semibold leading-none">{link.title}</h3>
<p className="text-subtle mt-2 text-sm">{link.description}</p>
</div>
<Icon
name="arrow-right"
className="text-subtle group-hover:text-emphasis h-4 w-4 flex-shrink-0 transition-colors"
/>
</Link>
))}
</ul>
</div>
</div>
</SettingsHeader>
);
@@ -109,7 +109,7 @@ export function buildFilterColumns({ t, permissions, status }: BuildFilterColumn
filter: {
type: ColumnFilterType.DATE_RANGE,
dateRangeOptions: {
range: status === "past" ? "past" : "custom",
range: status === "past" ? "past" : status === "cancelled" ? "any" : "future", // upcoming, unconfirmed, recurring are all future-only
},
},
},
@@ -98,7 +98,7 @@ export function useBookingListColumns({
filter: {
type: ColumnFilterType.DATE_RANGE,
dateRangeOptions: {
range: status === "past" ? "past" : "custom",
range: status === "past" ? "past" : status === "cancelled" ? "any" : "future", // upcoming, unconfirmed, recurring are all future-only
},
},
},
@@ -24,10 +24,10 @@ import {
CUSTOM_PRESET,
CUSTOM_PRESET_VALUE,
DEFAULT_PRESET,
PRESET_OPTIONS,
getDefaultStartDate,
getDefaultEndDate,
getDateRangeFromPreset,
getCompatiblePresets,
type PresetOption,
} from "../../lib/dateRange";
import { preserveLocalTime } from "../../lib/preserveLocalTime";
@@ -53,8 +53,10 @@ export const DateRangeFilter = ({
const filterValue = useFilterValue(column.id, ZDateRangeFilterValue);
const { updateFilter, removeFilter, timeZone: givenTimeZone } = useDataTable();
const range = options?.range ?? "past";
const forceCustom = range === "custom";
const forcePast = range === "past";
const compatiblePresets = getCompatiblePresets(range);
const showPresets = compatiblePresets.length > 1;
const forceCustomOnly = range === "customOnly" || !showPresets;
const { t } = useLocale();
const currentDate = dayjs();
@@ -65,10 +67,10 @@ export const DateRangeFilter = ({
filterValue?.data.endDate ? dayjs(filterValue.data.endDate) : undefined
);
const [selectedPreset, setSelectedPreset] = useState<PresetOption>(
forceCustom
forceCustomOnly
? CUSTOM_PRESET
: filterValue?.data.preset
? PRESET_OPTIONS.find((o) => o.value === filterValue.data.preset) ?? DEFAULT_PRESET
? compatiblePresets.find((o) => o.value === filterValue.data.preset) ?? DEFAULT_PRESET
: DEFAULT_PRESET
);
@@ -108,14 +110,14 @@ export const DateRangeFilter = ({
useEffect(() => {
// initially apply the default value
// if the query param is not set yet
if (!filterValue && !forceCustom) {
if (!filterValue && !forceCustomOnly) {
updateValues({
preset: DEFAULT_PRESET,
startDate: getDefaultStartDate(),
endDate: getDefaultEndDate(),
});
}
}, [filterValue, forceCustom, updateValues]);
}, [filterValue, forceCustomOnly, updateValues]);
const updateDateRangeFromPreset = (val: string | null) => {
if (val === CUSTOM_PRESET_VALUE) {
@@ -195,13 +197,19 @@ export const DateRangeFilter = ({
endDate: endDate?.toDate(),
}}
data-testid="date-range-calendar"
minDate={forcePast ? currentDate.subtract(2, "year").toDate() : null}
maxDate={forcePast ? currentDate.toDate() : undefined}
minDate={
range === "past"
? currentDate.subtract(2, "year").toDate()
: range === "future"
? currentDate.toDate()
: null
}
maxDate={range === "past" ? currentDate.toDate() : undefined}
disabled={false}
onDatesChange={updateDateRangeFromPicker}
withoutPopover={true}
/>
{forceCustom && (
{forceCustomOnly && (
<div className="border-subtle border-t px-2 py-3">
<Button
color="secondary"
@@ -213,10 +221,10 @@ export const DateRangeFilter = ({
)}
</div>
)}
{!forceCustom && (
{showPresets && (
<Command className={classNames("w-40", isCustomPreset && "rounded-b-none")}>
<CommandList>
{PRESET_OPTIONS.map((option) => (
{compatiblePresets.map((option) => (
<CommandItem
key={option.value}
data-testid={`date-range-options-${option.value}`}
@@ -5,6 +5,7 @@ import dayjs from "@calcom/dayjs";
import {
getDateRangeFromPreset,
recalculateDateRange,
getCompatiblePresets,
PRESET_OPTIONS,
CUSTOM_PRESET,
DEFAULT_PRESET,
@@ -111,4 +112,55 @@ describe("PRESET_OPTIONS", () => {
expect(CUSTOM_PRESET.value).toBe(CUSTOM_PRESET_VALUE);
expect(CUSTOM_PRESET.labelKey).toBe("custom_range");
});
it("should have correct direction for each preset", () => {
expect(PRESET_OPTIONS[0].direction).toBe("past"); // Today
expect(PRESET_OPTIONS[1].direction).toBe("past"); // Last 7 days
expect(PRESET_OPTIONS[2].direction).toBe("past"); // Last 30 days
expect(PRESET_OPTIONS[3].direction).toBe("past"); // Month to date
expect(PRESET_OPTIONS[4].direction).toBe("past"); // Year to date
expect(PRESET_OPTIONS[5].direction).toBe("any"); // Custom
});
});
describe("getCompatiblePresets", () => {
it("should return empty array for customOnly range", () => {
const result = getCompatiblePresets("customOnly");
expect(result).toEqual([]);
});
it("should return all presets for any range", () => {
const result = getCompatiblePresets("any");
expect(result.length).toBe(6); // All 6 presets
expect(result.map((p) => p.value)).toEqual(["tdy", "w", "t", "m", "y", "c"]);
});
it("should return all presets for past range (including direction:any)", () => {
const result = getCompatiblePresets("past");
expect(result.length).toBe(6); // All 6 presets (5 past + 1 any)
// Should include both "past" direction and "any" direction presets
const directions = result.map((p) => p.direction);
expect(directions).toContain("past");
expect(directions).toContain("any");
expect(result.map((p) => p.value)).toEqual(["tdy", "w", "t", "m", "y", "c"]);
});
it("should return only any-direction presets for future range", () => {
const result = getCompatiblePresets("future");
expect(result.length).toBe(1); // Only Custom (Today is now past-direction)
expect(result.map((p) => p.value)).toEqual(["c"]);
// All returned presets should have direction "any"
expect(result.every((p) => p.direction === "any")).toBe(true);
});
it("should not return past-only presets for future range", () => {
const result = getCompatiblePresets("future");
const values = result.map((p) => p.value);
// Should NOT include past-only presets (including Today)
expect(values).not.toContain("tdy"); // Today (now past-direction)
expect(values).not.toContain("w"); // Last 7 days
expect(values).not.toContain("t"); // Last 30 days
expect(values).not.toContain("m"); // Month to date
expect(values).not.toContain("y"); // Year to date
});
});
+24 -5
View File
@@ -4,10 +4,13 @@ import { ColumnFilterType, type DateRangeFilterValue } from "./types";
export type PresetOptionValue = "c" | "w" | "m" | "y" | "t" | "tdy";
export type PresetDirection = "past" | "future" | "any";
export type PresetOption = {
labelKey: string;
i18nOptions?: Record<string, string | number>;
value: PresetOptionValue;
direction: PresetDirection;
};
export const CUSTOM_PRESET_VALUE = "c" as const;
@@ -16,18 +19,34 @@ export const DEFAULT_PRESET: PresetOption = {
labelKey: "last_number_of_days",
i18nOptions: { count: 7 },
value: "w",
direction: "past",
};
export const CUSTOM_PRESET: PresetOption = {
labelKey: "custom_range",
value: CUSTOM_PRESET_VALUE,
direction: "any",
};
export const CUSTOM_PRESET: PresetOption = { labelKey: "custom_range", value: CUSTOM_PRESET_VALUE };
export const PRESET_OPTIONS: PresetOption[] = [
{ labelKey: "today", value: "tdy" },
{ labelKey: "today", value: "tdy", direction: "past" },
DEFAULT_PRESET,
{ labelKey: "last_number_of_days", i18nOptions: { count: 30 }, value: "t" },
{ labelKey: "month_to_date", value: "m" },
{ labelKey: "year_to_date", value: "y" },
{ labelKey: "last_number_of_days", i18nOptions: { count: 30 }, value: "t", direction: "past" },
{ labelKey: "month_to_date", value: "m", direction: "past" },
{ labelKey: "year_to_date", value: "y", direction: "past" },
CUSTOM_PRESET,
];
export const getCompatiblePresets = (range: "past" | "future" | "any" | "customOnly"): PresetOption[] => {
if (range === "customOnly") {
return [];
}
return PRESET_OPTIONS.filter((preset) => {
if (preset.direction === "any") return true;
if (range === "any") return true;
return preset.direction === range;
});
};
export const getDefaultStartDate = () => dayjs().subtract(6, "day").startOf("day");
export const getDefaultEndDate = () => dayjs().endOf("day");
+1 -1
View File
@@ -118,7 +118,7 @@ export const ZFilterValue = z.union([
]);
export type DateRangeFilterOptions = {
range?: "past" | "custom";
range?: "past" | "future" | "any" | "customOnly";
convertToTimeZone?: boolean;
};
+2 -2
View File
@@ -10,7 +10,7 @@ export type TextFilterOptions = {
};
export type DateRangeFilterOptions = {
range?: "past" | "custom";
range?: "past" | "future" | "any" | "customOnly";
convertToTimeZone?: boolean;
};
@@ -54,4 +54,4 @@ export type FilterableColumn = {
type: Extract<FilterType, "dr">;
dateRangeOptions?: DateRangeFilterOptions;
}
);
);