Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
40667a9
feat: allow to choose dateTarget for /insights (startTime by default)
eunjae-lee Sep 10, 2025
12c3716
feat: add timestamp selector for insights date filtering
devin-ai-integration[bot] Sep 10, 2025
0b37a6d
refactor: rename TimestampFilter to DateTargetSelector with nuqs URL …
devin-ai-integration[bot] Sep 10, 2025
6ec24e3
update styles
eunjae-lee Sep 11, 2025
3496840
fix inconsistency
eunjae-lee Sep 11, 2025
d5658f3
feat: replace Select with Command component and rename filter ID
devin-ai-integration[bot] Sep 11, 2025
3c74364
fix: revert routing components to use createdAt filter ID
devin-ai-integration[bot] Sep 11, 2025
af86d1f
update styles
eunjae-lee Sep 11, 2025
f7ad9ba
fix trpc router
eunjae-lee Sep 11, 2025
1e68d15
refactor timestamp column for insights booking service
eunjae-lee Sep 12, 2025
81c502e
Merge branch 'main' into feat/date-target-insights
eunjae-lee Sep 12, 2025
1766e1a
fix
eunjae-lee Sep 12, 2025
711a6fa
update text
eunjae-lee Sep 12, 2025
8430ba3
rename and clean up
eunjae-lee Sep 15, 2025
474020f
Merge branch 'main' into feat/date-target-insights
eunjae-lee Sep 15, 2025
6c737c5
fix endDate in DateRangeFilter
eunjae-lee Sep 15, 2025
40edfb6
fix type errors
eunjae-lee Sep 16, 2025
725123c
Merge branch 'main' into feat/date-target-insights
eunjae-lee Sep 16, 2025
a9be43a
fix startTime filter and type errors
eunjae-lee Sep 16, 2025
29abbb2
provide default date range
eunjae-lee Sep 16, 2025
ad9955c
add completed to getMembersStatsWithCount
eunjae-lee Sep 16, 2025
c4074c9
Merge branch 'main' into feat/date-target-insights
eunjae-lee Sep 16, 2025
bdc0573
add unit tests
eunjae-lee Sep 16, 2025
b604ce5
Merge branch 'main' into feat/date-target-insights
eunjae-lee Sep 17, 2025
5d195b7
fix type error
eunjae-lee Sep 17, 2025
5b234b2
address feedback
eunjae-lee Sep 18, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions apps/web/modules/insights/insights-view.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
"use client";

import { useState, useCallback } from "react";

import {
DataTableProvider,
DataTableFilters,
DateRangeFilter,
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,
Expand All @@ -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 (
Expand All @@ -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 (
<>
Expand All @@ -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>

Expand Down
4 changes: 4 additions & 0 deletions apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand All @@ -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";

Expand All @@ -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);
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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,
});
};

Expand Down
Original file line number Diff line number Diff line change
@@ -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]);
}
6 changes: 6 additions & 0 deletions packages/features/data-table/lib/preserveLocalTime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/features/data-table/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ export const ZFilterValue = z.union([

export type DateRangeFilterOptions = {
range?: "past" | "custom";
endOfDay?: boolean;
convertToTimeZone?: boolean;
};

export type TextFilterOptions = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ type EventTrendsData = RouterOutputs["viewer"]["insights"]["eventTrends"][number
const CustomTooltip = ({
active,
payload,
label,
label: _label,
}: {
active?: boolean;
payload?: Array<{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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,
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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,
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<>
Expand Down
78 changes: 78 additions & 0 deletions packages/features/insights/filters/DateTargetSelector.tsx
Original file line number Diff line number Diff line change
@@ -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 && (
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would value comparision make more sense here?

<Icon name="check" className="text-primary-foreground h-4 w-4" />
)}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
Loading
Loading