feat(insights): add progress indicator to download button on /insights and /insights/routing (CAL-5408) (#20631)

* feat(insights): add progress indicator to routing form responses download button (CAL-5408)

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

* fix: address PR feedback - remove initial progress update and ensure no decimal parts in percentage

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

* feat(insights): add progress indicator to Download component

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

* feat: paginate rawData endpoint and implement batch fetching in Download component

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

* refactor: simplify pagination logic in trpc-router.ts

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

* show progress on tooltip

* fix download for /insights

* fix download when there is no data

* feat: replace tooltip progress indicator with toast notification

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

* fix: remove duplicate ProgressToast component from showToast.tsx

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

* fix: export ProgressToast component from index.ts

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

* fix: add missing translation for downloading_data

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

* update progress toast implementation

* fix error

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <eunjae@cal.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
This commit is contained in:
devin-ai-integration[bot]
2025-04-23 15:59:09 +02:00
committed by GitHub
co-authored by eunjae@cal.com Eunjae Lee
parent c6cb3423fa
commit e29f6af540
8 changed files with 161 additions and 41 deletions
@@ -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",
@@ -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<PaginatedResponse | null> => {
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<string, unknown>[], filename);
}
} catch (error) {
showToast(t("unexpected_error_try_again"), "error");
} finally {
setIsDownloading(false);
hideProgressToast(); // Reset progress
}
};
@@ -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
}
};
+15 -13
View File
@@ -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<string, unknown>[]) {
// 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 };
@@ -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<RawDataInput>;
export type RoutingFormStatsInput = {
@@ -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" });
}
@@ -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 (
<div className="animate-fade-in-up bg-subtle shadow-elevation-low border-subtle mb-2 flex h-auto flex-col space-y-2 rounded-lg border px-3 py-2.5 text-sm font-semibold md:max-w-sm">
<div className="flex items-center gap-2">
<span className="mt-0.5">
<Icon name="file-down" className="h-4 w-4" />
</span>
<p className="m-0 w-full text-left">{message || defaultMessage}</p>
<button onClick={() => onClose(toastId)}>
<Icon name="x" className="h-4 w-4 hover:cursor-pointer" />
</button>
</div>
<div className="h-2 w-full overflow-hidden rounded-full">
<div
className="bg-inverted h-2 rounded-full opacity-50 transition-all duration-300 ease-in-out"
style={{ width: `${progress}%` }}
/>
</div>
<div className="text-right text-xs font-normal">{Math.floor(progress)}%</div>
</div>
);
};
export function showProgressToast(progress: number, message?: string, toastId = "download-progress") {
const onClose = (id: string | number) => {
toast.dismiss(id);
};
return toast.custom(
(id) => <ProgressToast message={message} progress={progress} onClose={onClose} toastId={id} />,
{
id: toastId,
duration: Infinity, // Keep the toast visible until dismissed
position: "bottom-center",
}
);
}
export function hideProgressToast(toastId = "download-progress") {
toast.dismiss(toastId);
}
+1
View File
@@ -1 +1,2 @@
export { ErrorToast, SuccessToast, WarningToast, showToast } from "./showToast";
export { ProgressToast, showProgressToast, hideProgressToast } from "./ProgressToast";