.
+ // The wrapper is the immediate parent of our rendered content.
+ const wrapper = el.parentElement;
+ if (!wrapper) return;
+
+ const update = () => {
+ setAnchorRect(wrapper.getBoundingClientRect());
+ };
+
+ update();
+
+ const ro = new ResizeObserver(update);
+ ro.observe(wrapper);
+
+ window.addEventListener('scroll', update, true);
+ window.addEventListener('resize', update);
+
+ return () => {
+ ro.disconnect();
+ window.removeEventListener('scroll', update, true);
+ window.removeEventListener('resize', update);
+ };
+ }, []);
+
+ useLayoutEffect(() => {
+ if (!mounted) return;
+ if (!anchorRect) return;
+ const tooltipEl = tooltipRef.current;
+ if (!tooltipEl) return;
+
+ const rect = tooltipEl.getBoundingClientRect();
+ const vw = window.innerWidth;
+ const vh = window.innerHeight;
+
+ // Start by following Nivo's tooltip anchor position.
+ let left = anchorRect.left + offset;
+ let top = anchorRect.top + offset;
+
+ // Clamp inside viewport with a little padding.
+ left = Math.min(
+ Math.max(padding, left),
+ Math.max(padding, vw - rect.width - padding),
+ );
+ top = Math.min(
+ Math.max(padding, top),
+ Math.max(padding, vh - rect.height - padding),
+ );
+
+ setPos({ left, top, ready: true });
+ }, [mounted, anchorRect, children, offset, padding]);
+
+ // SSR safety: on the server, just render the tooltip normally.
+ if (typeof document === 'undefined') {
+ return <>{children}>;
+ }
+
+ return (
+ <>
+ {/* Render a tiny (screen-reader-only) anchor inside Nivo's tooltip wrapper. */}
+
+ {mounted &&
+ createPortal(
+
+ {children}
+
,
+ document.body,
+ )}
+ >
+ );
+}
+
+export default function OverviewUserJourney({
+ projectId,
+}: OverviewUserJourneyProps) {
+ const { range, startDate, endDate } = useOverviewOptions();
+ const [filters] = useEventQueryFilters();
+ const [steps, setSteps] = useQueryState(
+ 'journeySteps',
+ parseAsInteger.withDefault(5).withOptions({ history: 'push' }),
+ );
+ const containerRef = useRef
(null);
+ const trpc = useTRPC();
+
+ const query = useQuery(
+ trpc.overview.userJourney.queryOptions({
+ projectId,
+ filters,
+ startDate,
+ endDate,
+ range,
+ steps: steps ?? 5,
+ }),
+ );
+
+ const data = query.data;
+ const number = useNumber();
+
+ // Process data for Sankey - nodes are already sorted by step then value from backend
+ const sankeyData = useMemo(() => {
+ if (!data) return { nodes: [], links: [] };
+
+ return {
+ nodes: data.nodes.map((node: any) => ({
+ ...node,
+ // Store label for display in tooltips
+ label: node.label || node.id,
+ data: {
+ percentage: node.percentage,
+ value: node.value,
+ step: node.step,
+ label: node.label || node.id,
+ },
+ })),
+ links: data.links,
+ };
+ }, [data]);
+
+ const totalSessions = useMemo(() => {
+ if (!sankeyData.nodes || sankeyData.nodes.length === 0) return 0;
+ // Total sessions used by backend for percentages is the sum of entry nodes (step 1).
+ // Fall back to summing all nodes if step is missing for some reason.
+ const step1 = sankeyData.nodes.filter((n: any) => n.data?.step === 1);
+ const base = step1.length > 0 ? step1 : sankeyData.nodes;
+ return base.reduce((sum: number, n: any) => sum + (n.data?.value ?? 0), 0);
+ }, [sankeyData.nodes]);
+
+ const stepOptions = [3, 5];
+
+ const { appTheme } = useTheme();
+
+ return (
+
+
+ User Journey
+
+ {stepOptions.map((option) => (
+
+ ))}
+
+
+
+ {query.isLoading ? (
+
+ ) : sankeyData.nodes.length === 0 ? (
+
+
+ No journey data available
+
+
+ ) : (
+
+
node.nodeColor}
+ nodeBorderRadius={2}
+ animate={false}
+ nodeBorderWidth={0}
+ nodeOpacity={0.8}
+ linkContract={1}
+ linkOpacity={0.3}
+ linkBlendMode={'normal'}
+ nodeTooltip={({ node }: any) => {
+ const label = node?.data?.label ?? node?.label ?? node?.id;
+ const value = node?.data?.value ?? node?.value ?? 0;
+ const step = node?.data?.step;
+ const pct =
+ typeof node?.data?.percentage === 'number'
+ ? node.data.percentage
+ : totalSessions > 0
+ ? (value / totalSessions) * 100
+ : 0;
+ const color =
+ node?.color ??
+ node?.data?.nodeColor ??
+ node?.data?.color ??
+ node?.nodeColor ??
+ '#64748b';
+
+ return (
+
+
+
+
+
+ {showDomain(label)}
+
+ {showPath(label)}
+
+ {typeof step === 'number' && (
+
+ Step {step}
+
+ )}
+
+
+
+
Sessions
+
{number.format(value)}
+
+
+
Share
+
{number.format(round(pct, 1))} %
+
+
+
+
+ );
+ }}
+ linkTooltip={({ link }: any) => {
+ const sourceLabel =
+ link?.source?.data?.label ??
+ link?.source?.label ??
+ link?.source?.id;
+ const targetLabel =
+ link?.target?.data?.label ??
+ link?.target?.label ??
+ link?.target?.id;
+
+ const value = link?.value ?? 0;
+ const sourceValue =
+ link?.source?.data?.value ?? link?.source?.value ?? 0;
+
+ const pctOfTotal =
+ totalSessions > 0 ? (value / totalSessions) * 100 : 0;
+ const pctOfSource =
+ sourceValue > 0 ? (value / sourceValue) * 100 : 0;
+
+ const sourceStep = link?.source?.data?.step;
+ const targetStep = link?.target?.data?.step;
+
+ const color =
+ link?.color ??
+ link?.source?.color ??
+ link?.source?.data?.nodeColor ??
+ '#64748b';
+
+ const sourceDomain = showDomain(sourceLabel);
+ const targetDomain = showDomain(targetLabel);
+ const isSameDomain = sourceDomain === targetDomain;
+
+ return (
+
+
+
+
+
+ {showDomain(sourceLabel)}
+
+ {showPath(sourceLabel)}
+
+ {!isSameDomain && (
+
+ {showDomain(targetLabel)}
+
+ )}
+ {showPath(targetLabel)}
+
+ {typeof sourceStep === 'number' &&
+ typeof targetStep === 'number' && (
+
+ {sourceStep} → {targetStep}
+
+ )}
+
+
+
+
+
Sessions
+
{number.format(value)}
+
+
+
+ % of total
+
+
{number.format(round(pctOfTotal, 1))} %
+
+
+
+ % of source
+
+
{number.format(round(pctOfSource, 1))} %
+
+
+
+
+ );
+ }}
+ label={(node: any) => {
+ const label = showPath(
+ node.data?.label || node.label || node.id,
+ );
+ return truncate(label, 30, 'middle');
+ }}
+ labelTextColor={appTheme === 'dark' ? '#e2e8f0' : '#0f172a'}
+ nodeSpacing={10}
+ />
+
+ )}
+
+
+
+ Shows the most common paths users take through your application
+
+
+
+ );
+}
diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.index.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.index.tsx
index ed241c53..3ced28c0 100644
--- a/apps/start/src/routes/_app.$organizationId.$projectId.index.tsx
+++ b/apps/start/src/routes/_app.$organizationId.$projectId.index.tsx
@@ -13,6 +13,7 @@ import OverviewTopEvents from '@/components/overview/overview-top-events';
import OverviewTopGeo from '@/components/overview/overview-top-geo';
import OverviewTopPages from '@/components/overview/overview-top-pages';
import OverviewTopSources from '@/components/overview/overview-top-sources';
+import OverviewUserJourney from '@/components/overview/overview-user-journey';
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
import { createFileRoute } from '@tanstack/react-router';
@@ -57,6 +58,7 @@ function ProjectDashboard() {
+