diff --git a/apps/builder/src/features/analytics/helpers/populateEdgesWithTotalVisits.ts b/apps/builder/src/features/analytics/helpers/populateEdgesWithTotalVisits.ts index 4b2355452..b6d49cafd 100644 --- a/apps/builder/src/features/analytics/helpers/populateEdgesWithTotalVisits.ts +++ b/apps/builder/src/features/analytics/helpers/populateEdgesWithTotalVisits.ts @@ -11,12 +11,13 @@ import type { Target, } from "@typebot.io/typebot/schemas/edge"; import type { EdgeWithTotalVisits, TotalAnswers } from "../schemas"; +import type { + DropoffLogger, + TraversalFrame, + VisitedPathsByEdge, +} from "../types"; import { getVisitedEdgeToPropFromId } from "./getVisitedEdgeToPropFromId"; -type Logger = (msg: string, ctx?: Record) => void; - -type Frame = { edgeId: string; totalUsers: number; depth: number }; - type Params = { initialEdge: { id: string; @@ -26,7 +27,7 @@ type Params = { edges: Edge[]; groups: GroupV6[]; totalAnswers: TotalAnswers[]; - logger?: Logger; + logger?: DropoffLogger; }; export function populateEdgesWithTotalVisits({ @@ -37,37 +38,57 @@ export function populateEdgesWithTotalVisits({ totalAnswers, logger, }: Params): EdgeWithTotalVisits[] { - const edgesById = new Map(edges.map((e) => [e.id, e])); - const groupsById = new Map(groups.map((g) => [g.id, g])); + const edgesById = new Map(edges.map((edge) => [edge.id, edge])); + const groupsById = new Map(groups.map((group) => [group.id, group])); const totalAnswersByInputBlockId = new Map( - totalAnswers.map((t) => [t.blockId, t.total]), + totalAnswers.map((answer) => [answer.blockId, answer.total]), ); - const offDefaultPathEdgeIds = new Set( - offDefaultPathEdgeWithTotalVisits.map((e) => e.id), + const offPathEdgeIds = new Set( + offDefaultPathEdgeWithTotalVisits.map((offPathEdge) => offPathEdge.id), ); - const totals = new Map( - offDefaultPathEdgeWithTotalVisits.map((e) => [e.id, e.total]), + const edgeTotalsById = new Map( + offDefaultPathEdgeWithTotalVisits.map((offPathEdge) => [ + offPathEdge.id, + offPathEdge.total, + ]), ); - const visited = new Set(); + const visitedByEdge: VisitedPathsByEdge = new Map(); - const stack: Frame[] = [ - { edgeId: initialEdge.id, totalUsers: initialEdge.total, depth: 0 }, + const depthFirstFrames: TraversalFrame[] = [ + { + edgeId: initialEdge.id, + usersRemaining: initialEdge.total, + pathIndex: 0, + }, ]; - while (stack.length) { - const { edgeId, totalUsers, depth } = stack.pop()!; + while (depthFirstFrames.length) { + visitFrame(depthFirstFrames.pop()!); + } - if (totalUsers <= 0) continue; + return [...edgeTotalsById.entries()].map(([id, total]) => ({ + id, + total, + to: getVisitedEdgeToPropFromId(id, { edges }), + })); - if (!offDefaultPathEdgeIds.has(edgeId)) { - totals.set(edgeId, (totals.get(edgeId) ?? 0) + totalUsers); + /* ================================================================ */ + /* Inner helpers */ + /* ================================================================ */ + function visitFrame({ edgeId, usersRemaining, pathIndex }: TraversalFrame) { + if (usersRemaining <= 0) return; + + if (markVisited(visitedByEdge, edgeId, pathIndex)) return; + + if (!offPathEdgeIds.has(edgeId)) { + edgeTotalsById.set( + edgeId, + (edgeTotalsById.get(edgeId) ?? 0) + usersRemaining, + ); } - if (visited.has(edgeId)) continue; - visited.add(edgeId); - logger?.( `▶︎ visiting ${edgeIdToHumanReadableLabel(edgeId, { edges, @@ -75,37 +96,40 @@ export function populateEdgesWithTotalVisits({ offDefaultPathEdgeWithTotalVisits, })}`, { - totalUsers, - depth, + usersRemaining, }, ); const edge = edgesById.get(edgeId); - if (!edge?.to) continue; + if (!edge?.to) return; const group = groupsById.get(edge.to.groupId); - if (!group) continue; + if (!group) return; - let remainingForNextDefaultOutgoingEdge = totalUsers; + let remainingForNextDefaultOutgoingEdge = usersRemaining; + let nextPathIndexIncrement = 1; for (const block of sliceFrom(group.blocks, edge.to.blockId)) { - if (isInputBlock(block)) + if (isInputBlock(block)) { remainingForNextDefaultOutgoingEdge = totalAnswersByInputBlockId.get(block.id) ?? 0; + totalAnswersByInputBlockId.delete(block.id); + } for (const itemEdgeId of outgoingItemEdges(block)) { - const itemTotal = totals.get(itemEdgeId); - if (itemTotal) { - enqueue(itemEdgeId, itemTotal, depth + 1); + const itemTotal = edgeTotalsById.get(itemEdgeId); + if (itemTotal && itemTotal > 0) { + enqueue(itemEdgeId, itemTotal, pathIndex + nextPathIndexIncrement); + nextPathIndexIncrement++; remainingForNextDefaultOutgoingEdge -= itemTotal; } } if (isJump(block)) { const virtualId = createVirtualEdgeId(block.options); - const virtualTotal = totals.get(virtualId); - if (virtualTotal) { - enqueue(virtualId, virtualTotal, depth + 1); + const virtualTotal = edgeTotalsById.get(virtualId); + if (virtualTotal && virtualTotal > 0) { + enqueue(virtualId, virtualTotal, pathIndex + 1); } } @@ -113,21 +137,15 @@ export function populateEdgesWithTotalVisits({ enqueue( block.outgoingEdgeId, remainingForNextDefaultOutgoingEdge, - depth + 1, + pathIndex, ); } } } - return [...totals.entries()].map(([id, total]) => ({ - id, - total, - to: getVisitedEdgeToPropFromId(id, { edges }), - })); - - function enqueue(id: string, totalUsers: number, depth: number) { - if (totalUsers <= 0 || visited.has(id)) return; - stack.push({ edgeId: id, totalUsers, depth }); + function enqueue(edgeId: string, usersRemaining: number, pathIndex: number) { + if (usersRemaining <= 0) return; + depthFirstFrames.push({ edgeId, usersRemaining, pathIndex }); } } @@ -136,10 +154,11 @@ const sliceFrom = (blocks: Block[], startId?: string) => const outgoingItemEdges = (block: Block) => { if (!blockHasItems(block)) return []; - return ( - block.items?.flatMap((i) => (i.outgoingEdgeId ? [i.outgoingEdgeId] : [])) ?? - [] - ); + const ids: string[] = []; + for (const item of block.items ?? []) { + if (item.outgoingEdgeId) ids.push(item.outgoingEdgeId); + } + return ids; }; const isJump = ( @@ -191,3 +210,18 @@ const edgeIdToHumanReadableLabel = ( label += "]"; return label; }; + +const markVisited = ( + visitedByEdge: VisitedPathsByEdge, + edgeId: string, + pathIdx: number, +): boolean => { + let paths = visitedByEdge.get(edgeId); + if (!paths) { + paths = new Set(); + visitedByEdge.set(edgeId, paths); + } + if (paths.has(pathIdx)) return true; + paths.add(pathIdx); + return false; +}; diff --git a/apps/builder/src/features/analytics/types.ts b/apps/builder/src/features/analytics/types.ts new file mode 100644 index 000000000..fccfa4d57 --- /dev/null +++ b/apps/builder/src/features/analytics/types.ts @@ -0,0 +1,12 @@ +export type DropoffLogger = ( + msg: string, + ctx?: Record, +) => void; + +export type TraversalFrame = { + edgeId: string; + usersRemaining: number; + pathIndex: number; +}; + +export type VisitedPathsByEdge = Map>; diff --git a/apps/builder/src/lib/trpc.ts b/apps/builder/src/lib/trpc.ts index 4f2aa30f9..548cc7bb1 100644 --- a/apps/builder/src/lib/trpc.ts +++ b/apps/builder/src/lib/trpc.ts @@ -1,5 +1,5 @@ import type { AppRouter } from "@/helpers/server/routers/appRouter"; -import { createTRPCProxyClient, httpBatchLink, loggerLink } from "@trpc/client"; +import { createTRPCProxyClient, httpBatchLink } from "@trpc/client"; import { createTRPCNext } from "@trpc/next"; import { env } from "@typebot.io/env"; import superjson from "superjson"; @@ -11,11 +11,6 @@ export const trpc = createTRPCNext({ config() { return { links: [ - loggerLink({ - enabled: (opts) => - process.env.NODE_ENV === "development" || - (opts.direction === "down" && opts.result instanceof Error), - }), httpBatchLink({ url: `${getBaseUrl()}/api/trpc`, }),