From b407be933daddbdeabedf33278243c78fc216db6 Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Fri, 19 Sep 2025 18:16:54 +0200 Subject: [PATCH] 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 * 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 * 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 * 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 * 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 * 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> --- .../insights/server/routing-events.ts | 24 +++++++++++++++ .../features/insights/server/trpc-router.ts | 4 ++- .../service/InsightsBookingBaseService.ts | 29 +++++++++++++++++-- 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/packages/features/insights/server/routing-events.ts b/packages/features/insights/server/routing-events.ts index 238eca8482..aa56f0ef00 100644 --- a/packages/features/insights/server/routing-events.ts +++ b/packages/features/insights/server/routing-events.ts @@ -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; dataPromise: ReturnType; + 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 || "", diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts index e97d7f15ed..a287db00ae 100644 --- a/packages/features/insights/server/trpc-router.ts +++ b/packages/features/insights/server/trpc-router.ts @@ -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 diff --git a/packages/lib/server/service/InsightsBookingBaseService.ts b/packages/lib/server/service/InsightsBookingBaseService.ts index 3046bd5913..183f9af07a 100644 --- a/packages/lib/server/service/InsightsBookingBaseService.ts +++ b/packages/lib/server/service/InsightsBookingBaseService.ts @@ -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 = {}; @@ -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"))),