feat: add date and time columns to insights CSV exports (#23771)

* feat: add ISO timestamp columns to booking CSV exports

- Add createdAt_iso, startTime_iso, endTime_iso columns to getCsvData() method
- Use .toISOString() format for new columns: 2025-09-09T13:03:55+02:00
- Keep existing timestamp columns unchanged to avoid breaking changes
- Addresses user request for parseable timestamp format in CSV downloads

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

* feat: add ISO timestamp columns to routing CSV exports

- Add ISO format columns for createdAt, bookingCreatedAt, bookingStartTime, bookingEndTime
- Preserve original timestamp format to avoid breaking changes
- Complete implementation for both /insights and /insights/routing pages

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

* feat: replace ISO columns with timezone-aware date/time columns

- Replace _iso columns with separate _date and _time columns
- Use user timezone for proper date/time conversion
- Add timezone parameter to CSV export methods
- Maintain backward compatibility with original timestamp format
- Date format: YYYY-MM-DD, Time format: HH:mm:ss

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

* fix: resolve TypeScript type compatibility issues

- Fix WhereForTeamOrAllTeams type compatibility in routing-events.ts
- Restructure conditional object creation to ensure required properties
- Clean up merge conflict remnants from previous rebase

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

* clean up

* fix: use user profile timezone for CSV exports instead of browser timezone

- Modified rawData endpoint to use ctx.user.timeZone instead of input.timeZone
- Removed timeZone field from bookingRepositoryBaseInputSchema
- Updated useInsightsBookingParameters to remove timeZone property
- Fixed RecentNoShowGuestsChart to use useDataTable for timezone access
- Resolves timezone discrepancy where CSV exports showed incorrect time values

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

* Revert "fix: use user profile timezone for CSV exports instead of browser timezone"

This reverts commit 6356657bd0c503f41349c8d1463bb4dd427b1a2c.

* default columns are formatted as iso

* address feedback

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Eunjae Lee
2025-09-19 16:16:54 +00:00
committed by GitHub
co-authored by Devin AI
parent 4e1920471d
commit b407be933d
3 changed files with 54 additions and 3 deletions
@@ -8,6 +8,7 @@ import {
isValidRoutingFormFieldType,
} from "@calcom/app-store/routing-forms/lib/FieldTypes";
import { zodFields as routingFormFieldsSchema } from "@calcom/app-store/routing-forms/zod";
import dayjs from "@calcom/dayjs";
import { WEBAPP_URL } from "@calcom/lib/constants";
import type { InsightsRoutingBaseService } from "@calcom/lib/server/service/InsightsRoutingBaseService";
import { readonlyPrisma as prisma } from "@calcom/prisma";
@@ -71,6 +72,7 @@ class RoutingEventsInsights {
teamId: {
in: teamIds,
},
...(routingFormId && { id: routingFormId }),
}
: {
userId: userId ?? -1,
@@ -119,9 +121,11 @@ class RoutingEventsInsights {
static async getRoutingFormPaginatedResponsesForDownload({
headersPromise,
dataPromise,
timeZone,
}: {
headersPromise: ReturnType<typeof RoutingEventsInsights.getRoutingFormHeaders>;
dataPromise: ReturnType<typeof InsightsRoutingBaseService.prototype.getTableData>;
timeZone: string;
}) {
const [headers, data] = await Promise.all([headersPromise, dataPromise]);
@@ -157,11 +161,31 @@ class RoutingEventsInsights {
"Response ID": item.id,
"Form Name": item.formName,
"Submitted At": item.createdAt.toISOString(),
"Submitted At_date": dayjs(item.createdAt).tz(timeZone).format("YYYY-MM-DD"),
"Submitted At_time": dayjs(item.createdAt).tz(timeZone).format("HH:mm:ss"),
"Has Booking": item.bookingUid !== null,
"Booking Status": item.bookingStatus || "NO_BOOKING",
"Booking Created At": item.bookingCreatedAt?.toISOString() || "",
"Booking Created At_date": item.bookingCreatedAt
? dayjs(item.bookingCreatedAt).tz(timeZone).format("YYYY-MM-DD")
: "",
"Booking Created At_time": item.bookingCreatedAt
? dayjs(item.bookingCreatedAt).tz(timeZone).format("HH:mm:ss")
: "",
"Booking Start Time": item.bookingStartTime?.toISOString() || "",
"Booking Start Time_date": item.bookingStartTime
? dayjs(item.bookingStartTime).tz(timeZone).format("YYYY-MM-DD")
: "",
"Booking Start Time_time": item.bookingStartTime
? dayjs(item.bookingStartTime).tz(timeZone).format("HH:mm:ss")
: "",
"Booking End Time": item.bookingEndTime?.toISOString() || "",
"Booking End Time_date": item.bookingEndTime
? dayjs(item.bookingEndTime).tz(timeZone).format("YYYY-MM-DD")
: "",
"Booking End Time_time": item.bookingEndTime
? dayjs(item.bookingEndTime).tz(timeZone).format("HH:mm:ss")
: "",
"Assignment Reason": item.bookingAssignmentReason || "",
"Routed To Name": item.bookingUserName || "",
"Routed To Email": item.bookingUserEmail || "",
@@ -822,7 +822,7 @@ export const insightsRouter = router({
})
)
.query(async ({ ctx, input }) => {
const { limit, offset } = input;
const { limit, offset, timeZone } = input;
const insightsBookingService = createInsightsBookingService(ctx, input);
@@ -830,6 +830,7 @@ export const insightsRouter = router({
return await insightsBookingService.getCsvData({
limit: limit ?? 100,
offset: offset ?? 0,
timeZone,
});
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
@@ -886,6 +887,7 @@ export const insightsRouter = router({
return await RoutingEventsInsights.getRoutingFormPaginatedResponsesForDownload({
headersPromise,
dataPromise,
timeZone: ctx.user.timeZone,
});
}),
getRoutingFormFieldOptions: userBelongsToTeamProcedure
@@ -479,7 +479,17 @@ export class InsightsBookingBaseService {
});
}
async getCsvData({ limit = 100, offset = 0 }: { limit?: number; offset?: number }) {
async getCsvData({
limit = 100,
offset = 0,
timeZone,
}: {
limit?: number;
offset?: number;
timeZone: string;
}) {
const DATE_FORMAT = "YYYY-MM-DD";
const TIME_FORMAT = "HH:mm:ss";
const baseConditions = await this.getBaseConditions();
// Get total count first
@@ -628,8 +638,20 @@ export class InsightsBookingBaseService {
})
);
// 6. Combine booking data with attendee data
// 6. Combine booking data with attendee data and add ISO timestamp columns
const data = csvData.map((bookingTimeStatus) => {
const dateAndTime = {
createdAt: bookingTimeStatus.createdAt.toISOString(),
createdAt_date: dayjs(bookingTimeStatus.createdAt).tz(timeZone).format(DATE_FORMAT),
createdAt_time: dayjs(bookingTimeStatus.createdAt).tz(timeZone).format(TIME_FORMAT),
startTime: bookingTimeStatus.startTime.toISOString(),
startTime_date: dayjs(bookingTimeStatus.startTime).tz(timeZone).format(DATE_FORMAT),
startTime_time: dayjs(bookingTimeStatus.startTime).tz(timeZone).format(TIME_FORMAT),
endTime: bookingTimeStatus.endTime.toISOString(),
endTime_date: dayjs(bookingTimeStatus.endTime).tz(timeZone).format(DATE_FORMAT),
endTime_time: dayjs(bookingTimeStatus.endTime).tz(timeZone).format(TIME_FORMAT),
};
if (!bookingTimeStatus.uid) {
// should not be reached because we filtered above
const nullAttendeeFields: Record<string, null> = {};
@@ -639,6 +661,7 @@ export class InsightsBookingBaseService {
return {
...bookingTimeStatus,
...dateAndTime,
noShowGuests: null,
noShowGuestsCount: 0,
...nullAttendeeFields,
@@ -655,6 +678,7 @@ export class InsightsBookingBaseService {
return {
...bookingTimeStatus,
...dateAndTime,
noShowGuests: null,
noShowGuestsCount: 0,
...nullAttendeeFields,
@@ -663,6 +687,7 @@ export class InsightsBookingBaseService {
return {
...bookingTimeStatus,
...dateAndTime,
noShowGuests: attendeeData.noShowGuests,
noShowGuestsCount: attendeeData.noShowGuestsCount,
...Object.fromEntries(Object.entries(attendeeData).filter(([key]) => key.startsWith("attendee"))),