umami/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx
Francis Cao 128217c0f4
Some checks failed
Node.js CI / build (push) Has been cancelled
add event type filter button and implementation to journeys Close #2803
2026-01-24 10:09:10 -08:00

302 lines
11 KiB
TypeScript

import { Column, Focusable, Icon, Row, Text, Tooltip, TooltipTrigger } from '@umami/react-zen';
import classNames from 'classnames';
import { useMemo, useState } from 'react';
import { firstBy } from 'thenby';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useEscapeKey, useMessages, useResultQuery } from '@/components/hooks';
import { File } from '@/components/icons';
import { Lightning } from '@/components/svg';
import { objectToArray } from '@/lib/data';
import { formatLongNumber } from '@/lib/format';
import styles from './Journey.module.css';
const NODE_HEIGHT = 60;
const NODE_GAP = 10;
const LINE_WIDTH = 3;
export interface JourneyProps {
websiteId: string;
startDate: Date;
endDate: Date;
steps: number;
startStep?: string;
endStep?: string;
view: string;
}
const EVENT_TYPES = {
views: 1,
events: 2,
};
export function Journey({ websiteId, steps, startStep, endStep, view }: JourneyProps) {
const [selectedNode, setSelectedNode] = useState(null);
const [activeNode, setActiveNode] = useState(null);
const { formatMessage, labels } = useMessages();
const { data, error, isLoading } = useResultQuery<any>('journey', {
websiteId,
steps,
startStep,
endStep,
view,
eventType: EVENT_TYPES[view],
});
useEscapeKey(() => setSelectedNode(null));
const columns = useMemo(() => {
if (!data) {
return [];
}
const selectedPaths = selectedNode?.paths ?? [];
const activePaths = activeNode?.paths ?? [];
const columns = [];
for (let columnIndex = 0; columnIndex < +steps; columnIndex++) {
const nodes = {};
data.forEach(({ items, count }: any, nodeIndex: any) => {
const name = items[columnIndex];
if (name) {
const selected = !!selectedPaths.find(({ items }) => items[columnIndex] === name);
const active = selected && !!activePaths.find(({ items }) => items[columnIndex] === name);
if (!nodes[name]) {
const paths = data.filter(({ items }) => items[columnIndex] === name);
nodes[name] = {
name,
count,
totalCount: count,
nodeIndex,
columnIndex,
selected,
active,
paths,
pathMap: paths.map(({ items, count }) => ({
[`${columnIndex}:${items.join(':')}`]: count,
})),
};
} else {
nodes[name].totalCount += count;
}
}
});
columns.push({
nodes: objectToArray(nodes).sort(firstBy('total', -1)),
});
}
columns.forEach((column, columnIndex) => {
const nodes = column.nodes.map(
(
currentNode: { totalCount: number; name: string; selected: boolean },
currentNodeIndex: any,
) => {
const previousNodes = columns[columnIndex - 1]?.nodes;
let selectedCount = previousNodes ? 0 : currentNode.totalCount;
let activeCount = selectedCount;
const lines =
previousNodes?.reduce((arr: any[][], previousNode: any, previousNodeIndex: number) => {
const fromCount = selectedNode?.paths.reduce((sum, path) => {
if (
previousNode.name === path.items[columnIndex - 1] &&
currentNode.name === path.items[columnIndex]
) {
sum += path.count;
}
return sum;
}, 0);
if (currentNode.selected && previousNode.selected && fromCount) {
arr.push([previousNodeIndex, currentNodeIndex]);
selectedCount += fromCount;
if (previousNode.active) {
activeCount += fromCount;
}
}
return arr;
}, []) || [];
return { ...currentNode, selectedCount, activeCount, lines };
},
);
const visitorCount = nodes.reduce(
(sum: number, { selected, selectedCount, active, activeCount, totalCount }) => {
if (!selectedNode) {
sum += totalCount;
} else if (!activeNode && selectedNode && selected) {
sum += selectedCount;
} else if (activeNode && active) {
sum += activeCount;
}
return sum;
},
0,
);
const previousTotal = columns[columnIndex - 1]?.visitorCount ?? 0;
const dropOff =
previousTotal > 0 ? ((visitorCount - previousTotal) / previousTotal) * 100 : 0;
Object.assign(column, { nodes, visitorCount, dropOff });
});
return columns;
}, [data, selectedNode, activeNode]);
const handleClick = (name: string, columnIndex: number, paths: any[]) => {
if (name !== selectedNode?.name || columnIndex !== selectedNode?.columnIndex) {
setSelectedNode({ name, columnIndex, paths });
} else {
setSelectedNode(null);
}
setActiveNode(null);
};
return (
<LoadingPanel data={data} isLoading={isLoading} error={error} height="100%">
<div className={styles.container}>
<div className={styles.view}>
{columns.map(({ visitorCount, nodes }, columnIndex) => {
return (
<div
key={columnIndex}
className={classNames(styles.column, {
[styles.selected]: selectedNode,
[styles.active]: activeNode,
})}
>
<div className={styles.header}>
<div className={styles.num}>{columnIndex + 1}</div>
<div className={styles.stats}>
<div className={styles.visitors} title={visitorCount}>
{formatLongNumber(visitorCount)} {formatMessage(labels.visitors)}
</div>
</div>
</div>
<div className={styles.nodes}>
{nodes.map(
({
name,
totalCount,
selected,
active,
paths,
activeCount,
selectedCount,
lines,
}) => {
const nodeCount = selected
? active
? activeCount
: selectedCount
: totalCount;
const remaining =
columnIndex > 0
? Math.round((nodeCount / columns[columnIndex - 1]?.visitorCount) * 100)
: 0;
const dropped = 100 - remaining;
return (
<div
key={name}
className={styles.wrapper}
onMouseEnter={() =>
selected && setActiveNode({ name, columnIndex, paths })
}
onMouseLeave={() => selected && setActiveNode(null)}
>
<div
className={classNames(styles.node, {
[styles.selected]: selected,
[styles.active]: active,
})}
onClick={() => handleClick(name, columnIndex, paths)}
>
<Row alignItems="center" className={styles.name} title={name} gap>
<Icon>{name.startsWith('/') ? <File /> : <Lightning />}</Icon>
<Text truncate>{name}</Text>
</Row>
<div className={styles.count} title={nodeCount}>
<TooltipTrigger
delay={0}
isDisabled={columnIndex === 0 || (selectedNode && !selected)}
>
<Focusable>
<div>{formatLongNumber(nodeCount)}</div>
</Focusable>
<Tooltip placement="top" offset={20} showArrow>
<Text transform="lowercase" color="ruby">
{`${dropped}% ${formatMessage(labels.dropoff)}`}
</Text>
<Column>
<Text transform="lowercase">
{`${remaining}% ${formatMessage(labels.conversion)}`}
</Text>
</Column>
</Tooltip>
</TooltipTrigger>
</div>
{columnIndex < columns.length &&
lines.map(([fromIndex, nodeIndex], i) => {
const height =
(Math.abs(nodeIndex - fromIndex) + 1) * (NODE_HEIGHT + NODE_GAP) -
NODE_GAP;
const midHeight =
(Math.abs(nodeIndex - fromIndex) - 1) * (NODE_HEIGHT + NODE_GAP) +
NODE_GAP +
LINE_WIDTH;
const nodeName = columns[columnIndex - 1]?.nodes[fromIndex].name;
return (
<div
key={`${fromIndex}${nodeIndex}${i}`}
className={classNames(styles.line, {
[styles.active]:
active &&
activeNode?.paths.find(
(path: { items: any[] }) =>
path.items[columnIndex] === name &&
path.items[columnIndex - 1] === nodeName,
),
[styles.up]: fromIndex < nodeIndex,
[styles.down]: fromIndex > nodeIndex,
[styles.flat]: fromIndex === nodeIndex,
})}
style={{ height }}
>
<div className={classNames(styles.segment, styles.start)} />
<div
className={classNames(styles.segment, styles.mid)}
style={{
height: midHeight,
}}
/>
<div className={classNames(styles.segment, styles.end)} />
</div>
);
})}
</div>
</div>
);
},
)}
</div>
</div>
);
})}
</div>
</div>
</LoadingPanel>
);
}