diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 09338c06a2..b2c178942f 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1159,10 +1159,10 @@ "enabled_after_update": "Enabled after update", "enabled_after_update_description": "The private link will work after saving", "confirm_delete_api_key": "Revoke this API key", - "disable_rescheduling":"Disable Rescheduling", - "description_disable_rescheduling":"Guests can no longer reschedule the event with calendar invite or email", - "disable_cancelling":"Disable Cancelling", - "description_disable_cancelling":"Guests can no longer cancel the event with calendar invite or email", + "disable_rescheduling": "Disable Rescheduling", + "description_disable_rescheduling": "Guests can no longer reschedule the event with calendar invite or email", + "disable_cancelling": "Disable Cancelling", + "description_disable_cancelling": "Guests can no longer cancel the event with calendar invite or email", "revoke_api_key": "Revoke API key", "api_key_copied": "API key copied!", "api_key_expires_on": "The API key will expire on", @@ -1562,6 +1562,7 @@ "download_responses": "Download responses", "download_responses_description": "Download all responses to your form in CSV format.", "download": "Download", + "downloading": "Downloading", "download_recording": "Download Recording", "transcription_enabled": "Transcriptions are enabled now", "transcription_stopped": "Transcriptions are stopped now", @@ -1872,8 +1873,7 @@ "not_verified": "Not yet verified", "no_availability_in_month": "No availability in {{month}}", "no_availability_rolling": "Scheduling is only available up to {{days}} in advance. Please check again soon.", - "no_availability_range": - "Scheduling ended on {{date}}. Please check again soon.", + "no_availability_range": "Scheduling ended on {{date}}. Please check again soon.", "view_previous_month": "View previous month", "view_next_month": "View next month", "send_code": "Send code", diff --git a/packages/features/insights/filters/Download/Download.tsx b/packages/features/insights/filters/Download/Download.tsx index 0f31b47c43..7e0ddff59b 100644 --- a/packages/features/insights/filters/Download/Download.tsx +++ b/packages/features/insights/filters/Download/Download.tsx @@ -1,8 +1,10 @@ import { useState } from "react"; +import dayjs from "@calcom/dayjs"; import { downloadAsCsv } from "@calcom/lib/csvUtils"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; +import type { RouterOutputs } from "@calcom/trpc/react"; import { Button } from "@calcom/ui/components/button"; import { Dropdown, @@ -10,20 +12,28 @@ import { DropdownMenuContent, DropdownMenuTrigger, } from "@calcom/ui/components/dropdown"; -import { showToast } from "@calcom/ui/components/toast"; +import { showToast, showProgressToast, hideProgressToast } from "@calcom/ui/components/toast"; import { useInsightsParameters } from "../../hooks/useInsightsParameters"; +type RawData = RouterOutputs["viewer"]["insights"]["rawData"]["data"][number]; + +const BATCH_SIZE = 100; + const Download = () => { const { t } = useLocale(); const { startDate, endDate, teamId, userId, eventTypeId, memberUserId, isAll } = useInsightsParameters(); const [isDownloading, setIsDownloading] = useState(false); const utils = trpc.useUtils(); - const handleDownloadClick = async () => { + type PaginatedResponse = { + data: RawData[]; + total: number; + }; + + const fetchBatch = async (offset: number): Promise => { try { - setIsDownloading(true); - const data = await utils.viewer.insights.rawData.fetch({ + const result = await utils.viewer.insights.rawData.fetch({ startDate, endDate, teamId, @@ -31,15 +41,56 @@ const Download = () => { eventTypeId, memberUserId, isAll, + limit: BATCH_SIZE, + offset, }); - if (!data) return; - const { data: csvRaw, filename } = data; - downloadAsCsv(csvRaw, filename); + if (result && "data" in result && "total" in result) { + return result as PaginatedResponse; + } + return null; + } catch (error) { + return null; + } + }; + + const handleDownloadClick = async () => { + try { + setIsDownloading(true); + showProgressToast(0); + let allData: RawData[] = []; + let offset = 0; + + // Get first batch to get total count + const firstBatch = await fetchBatch(0); + if (!firstBatch) return; + + allData = firstBatch.data; + const totalRecords = firstBatch.total; + + // Continue fetching remaining batches + while (totalRecords > 0 && allData.length < totalRecords) { + offset += BATCH_SIZE; + const result = await fetchBatch(offset); + if (!result) break; + allData = [...allData, ...result.data]; + + const currentProgress = Math.min(Math.round((allData.length / totalRecords) * 100), 99); + showProgressToast(currentProgress); + } + + if (allData.length >= totalRecords) { + showProgressToast(100); // Set to 100% before actual download + const filename = `Insights-${dayjs(startDate).format("YYYY-MM-DD")}-${dayjs(endDate).format( + "YYYY-MM-DD" + )}.csv`; + downloadAsCsv(allData as Record[], filename); + } } catch (error) { showToast(t("unexpected_error_try_again"), "error"); } finally { setIsDownloading(false); + hideProgressToast(); // Reset progress } }; diff --git a/packages/features/insights/filters/Download/RoutingFormResponsesDownload.tsx b/packages/features/insights/filters/Download/RoutingFormResponsesDownload.tsx index 98f3017356..118de2acd9 100644 --- a/packages/features/insights/filters/Download/RoutingFormResponsesDownload.tsx +++ b/packages/features/insights/filters/Download/RoutingFormResponsesDownload.tsx @@ -6,9 +6,14 @@ import { downloadAsCsv } from "@calcom/lib/csvUtils"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; import type { RouterOutputs } from "@calcom/trpc/react"; -import { Dropdown, DropdownItem, DropdownMenuContent, DropdownMenuTrigger } from "@calcom/ui/components/dropdown"; -import { showToast } from "@calcom/ui/components/toast"; import { Button } from "@calcom/ui/components/button"; +import { + Dropdown, + DropdownItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@calcom/ui/components/dropdown"; +import { showToast, showProgressToast, hideProgressToast } from "@calcom/ui/components/toast"; import { useInsightsParameters } from "../../hooks/useInsightsParameters"; @@ -53,6 +58,7 @@ export const RoutingFormResponsesDownload = ({ sorting }: Props) => { const handleDownloadClick = async () => { try { setIsDownloading(true); + showProgressToast(0); // Reset progress let allData: RoutingData[] = []; let offset = 0; @@ -62,13 +68,17 @@ export const RoutingFormResponsesDownload = ({ sorting }: Props) => { const totalRecords = firstBatch.total; // Continue fetching remaining batches - while (allData.length < totalRecords) { + while (totalRecords > 0 && allData.length < totalRecords) { offset += BATCH_SIZE; const result = await fetchBatch(offset); allData = [...allData, ...result.data]; + + const currentProgress = Math.min(Math.round((allData.length / totalRecords) * 100), 99); + showProgressToast(currentProgress); } - if (allData.length > 0) { + if (allData.length >= totalRecords) { + showProgressToast(100); // Set to 100% before actual download const filename = `RoutingFormResponses-${dayjs(startDate).format("YYYY-MM-DD")}-${dayjs( endDate ).format("YYYY-MM-DD")}.csv`; @@ -78,6 +88,7 @@ export const RoutingFormResponsesDownload = ({ sorting }: Props) => { showToast(t("error_downloading_data"), "error"); } finally { setIsDownloading(false); + hideProgressToast(); // Reset progress } }; diff --git a/packages/features/insights/server/events.ts b/packages/features/insights/server/events.ts index 5bd5bd55f0..be79d350b4 100644 --- a/packages/features/insights/server/events.ts +++ b/packages/features/insights/server/events.ts @@ -382,8 +382,14 @@ class EventsInsights { ) => { // Obtain the where conditional const whereConditional = await this.obtainWhereConditionalForDownload(props); + const limit = props.limit ?? 100; // Default batch size + const offset = props.offset ?? 0; - const csvData = await prisma.bookingTimeStatus.findMany({ + const totalCountPromise = prisma.bookingTimeStatus.count({ + where: whereConditional, + }); + + const csvDataPromise = prisma.bookingTimeStatus.findMany({ select: { id: true, uid: true, @@ -402,12 +408,16 @@ class EventsInsights { noShowHost: true, }, where: whereConditional, + skip: offset, + take: limit, }); + const [totalCount, csvData] = await Promise.all([totalCountPromise, csvDataPromise]); + const uids = csvData.filter((b) => b.uid !== null).map((b) => b.uid as string); if (uids.length === 0) { - return csvData; + return { data: csvData, total: totalCount }; } const bookings = await prisma.booking.findMany({ @@ -430,7 +440,7 @@ class EventsInsights { const bookingMap = new Map(bookings.map((booking) => [booking.uid, booking.attendees[0] || null])); - return csvData.map((bookingTimeStatus) => { + const data = csvData.map((bookingTimeStatus) => { if (!bookingTimeStatus.uid) { // should not be reached because we filtered above return bookingTimeStatus; @@ -449,6 +459,8 @@ class EventsInsights { bookerName: booker.name, }; }); + + return { data, total: totalCount }; }; /* @@ -633,16 +645,6 @@ class EventsInsights { return !!isOwnerAdminOfParentTeam; }; - - static objectToCsv(data: Record[]) { - // if empty data return empty string - if (!data.length) { - return ""; - } - const header = `${Object.keys(data[0]).join(",")}\n`; - const rows = data.map((obj: any) => `${Object.values(obj).join(",")}\n`); - return header + rows.join(""); - } } export { EventsInsights }; diff --git a/packages/features/insights/server/raw-data.schema.ts b/packages/features/insights/server/raw-data.schema.ts index d0bbcdb3aa..273a867990 100644 --- a/packages/features/insights/server/raw-data.schema.ts +++ b/packages/features/insights/server/raw-data.schema.ts @@ -11,6 +11,8 @@ export type RawDataInput = { memberUserId?: number | null; isAll?: boolean; eventTypeId?: number | null; + offset?: number; + limit?: number; }; export const rawDataInputSchema = z.object({ @@ -21,6 +23,8 @@ export const rawDataInputSchema = z.object({ memberUserId: z.coerce.number().optional().nullable(), isAll: z.boolean().optional(), eventTypeId: z.coerce.number().optional().nullable(), + offset: z.number().optional(), + limit: z.number().max(100).optional(), }) satisfies z.ZodType; export type RoutingFormStatsInput = { diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts index 8dc1ea2adc..8bb69a4664 100644 --- a/packages/features/insights/server/trpc-router.ts +++ b/packages/features/insights/server/trpc-router.ts @@ -8,7 +8,6 @@ import { routingFormResponsesInputSchema, routingFormStatsInputSchema, } from "@calcom/features/insights/server/raw-data.schema"; -import { randomString } from "@calcom/lib/random"; import type { readonlyPrisma } from "@calcom/prisma"; import { BookingStatus } from "@calcom/prisma/enums"; import authedProcedure from "@calcom/trpc/server/procedures/authedProcedure"; @@ -1540,7 +1539,7 @@ export const insightsRouter = router({ return result; }), rawData: userBelongsToTeamProcedure.input(rawDataInputSchema).query(async ({ ctx, input }) => { - const { startDate, endDate, teamId, userId, memberUserId, isAll } = input; + const { startDate, endDate, teamId, userId, memberUserId, isAll, limit, offset } = input; const eventTypeList = await getEventTypeList({ prisma: ctx.prisma, @@ -1559,8 +1558,7 @@ export const insightsRouter = router({ const isOrgAdminOrOwner = ctx.user.isOwnerAdminOfParentTeam; try { - // Get the data - const csvData = await EventsInsights.getCsvData({ + return await EventsInsights.getCsvData({ startDate, endDate, teamId, @@ -1570,14 +1568,9 @@ export const insightsRouter = router({ isOrgAdminOrOwner, eventTypeId, organizationId: ctx.user.organizationId || null, + limit, + offset, }); - - const csvAsString = EventsInsights.objectToCsv(csvData); - const downloadAs = `Insights-${dayjs(startDate).format("YYYY-MM-DD")}-${dayjs(endDate).format( - "YYYY-MM-DD" - )}-${randomString(10)}.csv`; - - return { data: csvAsString, filename: downloadAs }; } catch (e) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } diff --git a/packages/ui/components/toast/ProgressToast.tsx b/packages/ui/components/toast/ProgressToast.tsx new file mode 100644 index 0000000000..3b5065a297 --- /dev/null +++ b/packages/ui/components/toast/ProgressToast.tsx @@ -0,0 +1,58 @@ +import * as React from "react"; +import { toast } from "sonner"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; + +import { Icon } from "../icon"; + +type ProgressToastProps = { + message?: string; + progress: number; + toastId: string | number; + onClose: (toastId: string | number) => void; +}; + +export const ProgressToast = ({ message, progress, onClose, toastId }: ProgressToastProps) => { + const { t } = useLocale(); + const defaultMessage = t("downloading"); + + return ( +
+
+ + + +

{message || defaultMessage}

+ +
+
+
+
+
{Math.floor(progress)}%
+
+ ); +}; + +export function showProgressToast(progress: number, message?: string, toastId = "download-progress") { + const onClose = (id: string | number) => { + toast.dismiss(id); + }; + + return toast.custom( + (id) => , + { + id: toastId, + duration: Infinity, // Keep the toast visible until dismissed + position: "bottom-center", + } + ); +} + +export function hideProgressToast(toastId = "download-progress") { + toast.dismiss(toastId); +} diff --git a/packages/ui/components/toast/index.ts b/packages/ui/components/toast/index.ts index 9ff0ec9f75..9b21147493 100644 --- a/packages/ui/components/toast/index.ts +++ b/packages/ui/components/toast/index.ts @@ -1 +1,2 @@ export { ErrorToast, SuccessToast, WarningToast, showToast } from "./showToast"; +export { ProgressToast, showProgressToast, hideProgressToast } from "./ProgressToast";