🐛 Refactor populateEdgesWithTotalVisits to fix dropoff compute edge cases
Some checks failed
Create Tag / create-tag (push) Has been cancelled
Deploy Partykit server / deploy (push) Has been cancelled

This commit is contained in:
Baptiste Arnaud 2025-05-14 08:26:54 +02:00
parent 3c8cc8951a
commit b0189f7579
No known key found for this signature in database
3 changed files with 96 additions and 55 deletions

View File

@ -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<string, unknown>) => 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<string, number>(
offDefaultPathEdgeWithTotalVisits.map((e) => [e.id, e.total]),
const edgeTotalsById = new Map(
offDefaultPathEdgeWithTotalVisits.map((offPathEdge) => [
offPathEdge.id,
offPathEdge.total,
]),
);
const visited = new Set<string>();
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<number>();
visitedByEdge.set(edgeId, paths);
}
if (paths.has(pathIdx)) return true;
paths.add(pathIdx);
return false;
};

View File

@ -0,0 +1,12 @@
export type DropoffLogger = (
msg: string,
ctx?: Record<string, unknown>,
) => void;
export type TraversalFrame = {
edgeId: string;
usersRemaining: number;
pathIndex: number;
};
export type VisitedPathsByEdge = Map<string, Set<number>>;

View File

@ -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<AppRouter>({
config() {
return {
links: [
loggerLink({
enabled: (opts) =>
process.env.NODE_ENV === "development" ||
(opts.direction === "down" && opts.result instanceof Error),
}),
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
}),