Skip to content

Commit 7abbc8c

Browse files
joeauyeungclaudedevin-ai-integration[bot]
authored
feat: Routing trace presenter (calcom#27372)
* feat: Add routing trace presenters Add domain-specific presenters (SalesforceRoutingTracePresenter, RoutingFormTracePresenter) that format trace steps into human-readable strings, and a core RoutingTracePresenter that delegates to them based on step domain. Includes unit tests for all presenters. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: Add findByBookingUid to RoutingTraceRepository Add method to look up a routing trace by booking UID, needed by the routing trace presenter tRPC endpoint. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: Add getRoutingTrace tRPC endpoint Expose routing trace data for a booking via viewer.bookings.getRoutingTrace. Tries the permanent RoutingTrace first (round robin bookings), then falls back to PendingRoutingTrace via the booking's form response relation. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: Add routing trace side sheet to booking list item Add a route icon button on booking list items that came from routing forms. Clicking it opens a side sheet displaying the full routing trace as human-readable steps. Adds RoutingTraceSheet component, store state, and translation key. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: Move routing trace action to dropdown menu Move the routing trace button from a standalone icon on the booking list item into the actions dropdown menu alongside "Report wrong assignment". The RoutingTraceSheet is now rendered from BookingActionsDropdown. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: Improve routing trace UI and handle unnamed routes - Redesign RoutingTraceSheet with vertical timeline layout, domain badges, skeleton loading state, and millisecond timestamps - Use Salesforce app-store icon for Salesforce steps - Fall back to "Unnamed route" when route name is missing or matches route ID - Fix dropdown menu icon to git-merge (valid icon, unique in menu) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test: Add tests for getRoutingTrace handler and findByBookingUid repository method Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * Abstract functions * Fix build error * Add permission check * fix: Update getRoutingTrace tests to include ctx and mock BookingAccessService Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent e7d3875 commit 7abbc8c

24 files changed

Lines changed: 1086 additions & 24 deletions

apps/web/components/booking/BookingListItem.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,6 @@ function BookingListItem(booking: BookingItemProps) {
280280
const setIsOpenWrongAssignmentDialog = useBookingActionsStoreContext(
281281
(state) => state.setIsOpenWrongAssignmentDialog
282282
);
283-
284283
const reportAction = getReportAction(actionContext);
285284
const reportActionWithHandler = {
286285
...reportAction,
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"use client";
2+
3+
import dayjs from "@calcom/dayjs";
4+
import { DomainIcon } from "@calcom/features/routing-trace/components/DomainIcon";
5+
import { getDomainLabel } from "@calcom/features/routing-trace/presenters/getDomainLabel";
6+
import { useLocale } from "@calcom/lib/hooks/useLocale";
7+
import { trpc } from "@calcom/trpc/react";
8+
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetBody } from "@calcom/ui/components/sheet";
9+
10+
interface RoutingTraceSheetProps {
11+
isOpen: boolean;
12+
setIsOpen: (open: boolean) => void;
13+
bookingUid: string;
14+
}
15+
16+
export function RoutingTraceSheet({ isOpen, setIsOpen, bookingUid }: RoutingTraceSheetProps) {
17+
const { t } = useLocale();
18+
19+
const { data, isLoading } = trpc.viewer.bookings.getRoutingTrace.useQuery(
20+
{ bookingUid },
21+
{
22+
enabled: isOpen,
23+
staleTime: 10 * 60 * 1000,
24+
}
25+
);
26+
27+
return (
28+
<Sheet open={isOpen} onOpenChange={setIsOpen}>
29+
<SheetContent>
30+
<SheetHeader>
31+
<SheetTitle>{t("routing_trace")}</SheetTitle>
32+
</SheetHeader>
33+
<SheetBody>
34+
{isLoading && (
35+
<div className="flex flex-col gap-4 py-2">
36+
{[1, 2, 3].map((i) => (
37+
<div key={i} className="flex gap-3">
38+
<div className="bg-muted h-8 w-8 animate-pulse rounded-full" />
39+
<div className="flex flex-1 flex-col gap-1.5">
40+
<div className="bg-muted h-3 w-24 animate-pulse rounded" />
41+
<div className="bg-muted h-4 w-full animate-pulse rounded" />
42+
</div>
43+
</div>
44+
))}
45+
</div>
46+
)}
47+
{!isLoading && !data?.steps?.length && (
48+
<p className="text-subtle text-sm">{t("no_results_found")}</p>
49+
)}
50+
{!isLoading && data?.steps && data.steps.length > 0 && (
51+
<div className="relative flex flex-col">
52+
{data.steps.map((step, idx) => {
53+
const isLast = idx === data.steps.length - 1;
54+
return (
55+
<div key={idx} className="relative flex gap-3 pb-6 last:pb-0">
56+
{/* Timeline connector line */}
57+
{!isLast && (
58+
<div className="border-subtle absolute left-4 top-8 bottom-0 border-l" />
59+
)}
60+
{/* Icon circle */}
61+
<div className="bg-default border-subtle relative z-10 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full border">
62+
<DomainIcon domain={step.domain} />
63+
</div>
64+
{/* Content */}
65+
<div className="flex min-w-0 flex-1 flex-col gap-0.5 pt-0.5">
66+
<div className="flex items-center gap-2">
67+
<span className="bg-subtle text-subtle rounded px-1.5 py-0.5 text-xs font-medium">
68+
{getDomainLabel(step.domain)}
69+
</span>
70+
<span className="text-muted text-xs">
71+
{dayjs(step.timestamp).format("h:mm:ss.SSS A")}
72+
</span>
73+
</div>
74+
<p className="text-emphasis text-sm">{step.message}</p>
75+
</div>
76+
</div>
77+
);
78+
})}
79+
</div>
80+
)}
81+
</SheetBody>
82+
</SheetContent>
83+
</Sheet>
84+
);
85+
}

apps/web/components/booking/actions/BookingActionsDropdown.tsx

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { ReportBookingDialog } from "@components/dialog/ReportBookingDialog";
3232
import { RerouteDialog } from "@components/dialog/RerouteDialog";
3333
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
3434
import { WrongAssignmentDialog } from "@components/dialog/WrongAssignmentDialog";
35+
import { RoutingTraceSheet } from "../RoutingTraceSheet";
3536

3637
import { useBookingConfirmation } from "../hooks/useBookingConfirmation";
3738
import type { BookingItemProps } from "../types";
@@ -129,6 +130,10 @@ export function BookingActionsDropdown({
129130
const setRerouteDialogIsOpen = useBookingActionsStoreContext((state) => state.setRerouteDialogIsOpen);
130131
const isCancelDialogOpen = useBookingActionsStoreContext((state) => state.isCancelDialogOpen);
131132
const setIsCancelDialogOpen = useBookingActionsStoreContext((state) => state.setIsCancelDialogOpen);
133+
const isOpenRoutingTraceSheet = useBookingActionsStoreContext((state) => state.isOpenRoutingTraceSheet);
134+
const setIsOpenRoutingTraceSheet = useBookingActionsStoreContext(
135+
(state) => state.setIsOpenRoutingTraceSheet
136+
);
132137

133138
const cardCharged = booking?.payment[0]?.success;
134139

@@ -448,16 +453,23 @@ export function BookingActionsDropdown({
448453
status={getBookingStatus()}
449454
/>
450455
{isBookingFromRoutingForm && (
451-
<WrongAssignmentDialog
452-
isOpenDialog={isOpenWrongAssignmentDialog}
453-
setIsOpenDialog={setIsOpenWrongAssignmentDialog}
454-
bookingUid={booking.uid}
455-
routingReason={booking.assignmentReason[0]?.reasonString ?? null}
456-
guestEmail={booking.attendees[0]?.email ?? ""}
457-
hostEmail={booking.user?.email ?? ""}
458-
hostName={booking.user?.name ?? null}
459-
teamId={booking.eventType?.team?.id ?? null}
460-
/>
456+
<>
457+
<WrongAssignmentDialog
458+
isOpenDialog={isOpenWrongAssignmentDialog}
459+
setIsOpenDialog={setIsOpenWrongAssignmentDialog}
460+
bookingUid={booking.uid}
461+
routingReason={booking.assignmentReason[0]?.reasonString ?? null}
462+
guestEmail={booking.attendees[0]?.email ?? ""}
463+
hostEmail={booking.user?.email ?? ""}
464+
hostName={booking.user?.name ?? null}
465+
teamId={booking.eventType?.team?.id ?? null}
466+
/>
467+
<RoutingTraceSheet
468+
isOpen={isOpenRoutingTraceSheet}
469+
setIsOpen={setIsOpenRoutingTraceSheet}
470+
bookingUid={booking.uid}
471+
/>
472+
</>
461473
)}
462474
{booking.paid && booking.payment[0] && (
463475
<ChargeCardDialog
@@ -661,6 +673,20 @@ export function BookingActionsDropdown({
661673
</DropdownItem>
662674
</DropdownMenuItem>
663675
))}
676+
{isBookingFromRoutingForm && (
677+
<DropdownMenuItem className="rounded-lg" key="view_routing_trace">
678+
<DropdownItem
679+
type="button"
680+
StartIcon="git-merge"
681+
onClick={(e) => {
682+
e.stopPropagation();
683+
setIsOpenRoutingTraceSheet(true);
684+
}}
685+
data-testid="view_routing_trace">
686+
{t("routing_trace")}
687+
</DropdownItem>
688+
</DropdownMenuItem>
689+
)}
664690
<DropdownMenuSeparator />
665691
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("after_event")}</DropdownMenuLabel>
666692
{afterEventActions.map((action) => (

apps/web/components/booking/actions/store.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export type BookingActionsStore = {
1818
rerouteDialogIsOpen: boolean;
1919
isCancelDialogOpen: boolean;
2020
isOpenWrongAssignmentDialog: boolean;
21+
isOpenRoutingTraceSheet: boolean;
2122

2223
// Dialog setters
2324
setRejectionDialogIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
@@ -33,6 +34,7 @@ export type BookingActionsStore = {
3334
setRerouteDialogIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
3435
setIsCancelDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
3536
setIsOpenWrongAssignmentDialog: React.Dispatch<React.SetStateAction<boolean>>;
37+
setIsOpenRoutingTraceSheet: React.Dispatch<React.SetStateAction<boolean>>;
3638
};
3739

3840
export const createBookingActionsStore = () => {
@@ -51,6 +53,7 @@ export const createBookingActionsStore = () => {
5153
rerouteDialogIsOpen: false,
5254
isCancelDialogOpen: false,
5355
isOpenWrongAssignmentDialog: false,
56+
isOpenRoutingTraceSheet: false,
5457

5558
// Dialog setters
5659
setRejectionDialogIsOpen: (isOpen) =>
@@ -109,5 +112,10 @@ export const createBookingActionsStore = () => {
109112
isOpenWrongAssignmentDialog:
110113
typeof isOpen === "function" ? isOpen(state.isOpenWrongAssignmentDialog) : isOpen,
111114
})),
115+
setIsOpenRoutingTraceSheet: (isOpen) =>
116+
set((state) => ({
117+
isOpenRoutingTraceSheet:
118+
typeof isOpen === "function" ? isOpen(state.isOpenRoutingTraceSheet) : isOpen,
119+
})),
112120
}));
113121
};

apps/web/public/static/locales/en/common.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4428,6 +4428,7 @@
44284428
"and_more_holidays_with_conflicts_one": "... and {{count}} more holiday with conflicts",
44294429
"and_more_holidays_with_conflicts_other": "... and {{count}} more holidays with conflicts",
44304430
"assignment_reason": "Assignment reason",
4431+
"routing_trace": "Routing trace",
44314432
"saved": "Saved",
44324433
"booking_history": "Booking history",
44334434
"booking_history_description": "View the history of actions performed on this booking",
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Icon } from "@calcom/ui/components/icon";
2+
3+
import { ROUTING_TRACE_DOMAINS } from "../constants";
4+
5+
import type { IconName } from "@calcom/ui/components/icon";
6+
7+
const DOMAIN_ICONS: Record<string, { type: "icon"; name: IconName } | { type: "img"; src: string; alt: string }> = {
8+
[ROUTING_TRACE_DOMAINS.SALESFORCE]: { type: "img", src: "/app-store/salesforce/icon.png", alt: "Salesforce" },
9+
[ROUTING_TRACE_DOMAINS.ROUTING_FORM]: { type: "icon", name: "file-text" },
10+
};
11+
12+
const DEFAULT_ICON: IconName = "shuffle";
13+
14+
export function DomainIcon({ domain }: { domain: string }) {
15+
const config = DOMAIN_ICONS[domain];
16+
17+
if (config?.type === "img") {
18+
return <img src={config.src} alt={config.alt} className="h-4 w-4" />;
19+
}
20+
21+
return (
22+
<Icon
23+
name={config?.type === "icon" ? config.name : DEFAULT_ICON}
24+
className="text-subtle h-4 w-4"
25+
/>
26+
);
27+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export const ROUTING_TRACE_DOMAINS = {
2+
SALESFORCE: "salesforce",
3+
ROUTING_FORM: "routing_form",
4+
} as const;
5+
6+
export const ROUTING_TRACE_STEPS = {
7+
SALESFORCE_ASSIGNMENT: "salesforce_assignment",
8+
ATTRIBUTE_LOGIC_EVALUATED: "attribute-logic-evaluated",
9+
} as const;

packages/features/routing-trace/domains/RoutingFormTraceService.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { ROUTING_TRACE_DOMAINS } from "../constants";
23
import type { RoutingTraceService } from "../services/RoutingTraceService";
3-
import { ROUTING_TRACE_DOMAINS } from "../services/RoutingTraceService";
44
import { ROUTING_FORM_STEPS, RoutingFormTraceService } from "./RoutingFormTraceService";
55

66
describe("RoutingFormTraceService", () => {

packages/features/routing-trace/domains/RoutingFormTraceService.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1+
import { ROUTING_TRACE_DOMAINS } from "../constants";
12
import type { RoutingTraceService } from "../services/RoutingTraceService";
2-
import { ROUTING_TRACE_DOMAINS } from "../services/RoutingTraceService";
33

44
export const ROUTING_FORM_STEPS = {
55
ROUTE_MATCHED: "route_matched",
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import type { RoutingStep } from "../repositories/RoutingTraceRepository.interface";
4+
import { RoutingFormTracePresenter } from "./RoutingFormTracePresenter";
5+
6+
function makeStep(step: string, data: Record<string, unknown> = {}): RoutingStep {
7+
return { domain: "routing_form", step, timestamp: Date.now(), data };
8+
}
9+
10+
describe("RoutingFormTracePresenter", () => {
11+
it("presents route_matched", () => {
12+
const result = RoutingFormTracePresenter.present(
13+
makeStep("route_matched", { routeId: "route-1", routeName: "Enterprise" })
14+
);
15+
expect(result).toBe('Route matched: "Enterprise" (ID: route-1)');
16+
});
17+
18+
it("presents fallback_route_used", () => {
19+
const result = RoutingFormTracePresenter.present(
20+
makeStep("fallback_route_used", { routeId: "route-2", routeName: "Default" })
21+
);
22+
expect(result).toBe('Fallback route used: "Default" (ID: route-2)');
23+
});
24+
25+
it("presents attribute-logic-evaluated with all fields", () => {
26+
const result = RoutingFormTracePresenter.present(
27+
makeStep("attribute-logic-evaluated", {
28+
routeName: "APAC",
29+
routeIsFallback: false,
30+
attributeRoutingDetails: [
31+
{ attributeName: "Company Size", attributeValue: "Enterprise" },
32+
{ attributeName: "Region", attributeValue: "APAC" },
33+
],
34+
})
35+
);
36+
expect(result).toBe(
37+
'Attribute logic evaluated: Route: "APAC" Attributes: [Company Size=Enterprise, Region=APAC]'
38+
);
39+
});
40+
41+
it("presents attribute-logic-evaluated with fallback", () => {
42+
const result = RoutingFormTracePresenter.present(
43+
makeStep("attribute-logic-evaluated", {
44+
routeName: "Fallback Route",
45+
routeIsFallback: true,
46+
})
47+
);
48+
expect(result).toBe('Attribute logic evaluated: Route: "Fallback Route" (fallback)');
49+
});
50+
51+
it("presents attribute-logic-evaluated with no optional fields", () => {
52+
const result = RoutingFormTracePresenter.present(makeStep("attribute-logic-evaluated", {}));
53+
expect(result).toBe('Attribute logic evaluated: Route: "Unnamed route"');
54+
});
55+
56+
it("presents attribute_fallback_used with routeName", () => {
57+
const result = RoutingFormTracePresenter.present(
58+
makeStep("attribute_fallback_used", { routeName: "Default Route" })
59+
);
60+
expect(result).toBe('Attribute fallback used: "Default Route"');
61+
});
62+
63+
it("presents attribute_fallback_used without routeName", () => {
64+
const result = RoutingFormTracePresenter.present(makeStep("attribute_fallback_used", {}));
65+
expect(result).toBe('Attribute fallback used: "Unnamed route"');
66+
});
67+
68+
it("presents route_matched without routeName", () => {
69+
const result = RoutingFormTracePresenter.present(
70+
makeStep("route_matched", { routeId: "route-1" })
71+
);
72+
expect(result).toBe('Route matched: "Unnamed route" (ID: route-1)');
73+
});
74+
75+
it("presents fallback_route_used without routeName", () => {
76+
const result = RoutingFormTracePresenter.present(
77+
makeStep("fallback_route_used", { routeId: "route-2" })
78+
);
79+
expect(result).toBe('Fallback route used: "Unnamed route" (ID: route-2)');
80+
});
81+
82+
it("treats routeName matching routeId as unnamed", () => {
83+
const result = RoutingFormTracePresenter.present(
84+
makeStep("route_matched", { routeId: "abc-123", routeName: "abc-123" })
85+
);
86+
expect(result).toBe('Route matched: "Unnamed route" (ID: abc-123)');
87+
});
88+
89+
it("returns fallback for unknown step", () => {
90+
const result = RoutingFormTracePresenter.present(makeStep("unknown_step", {}));
91+
expect(result).toBe("Routing Form: unknown_step");
92+
});
93+
});

0 commit comments

Comments
 (0)