diff --git a/app/sites/[id]/journeys/page.tsx b/app/sites/[id]/journeys/page.tsx
index 30e5660..61ae39b 100644
--- a/app/sites/[id]/journeys/page.tsx
+++ b/app/sites/[id]/journeys/page.tsx
@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import { getDateRange, formatDate } from '@ciphera-net/ui'
import { Select, DatePicker } from '@ciphera-net/ui'
-import SankeyDiagram from '@/components/journeys/SankeyDiagram'
+import ColumnJourney from '@/components/journeys/ColumnJourney'
import TopPathsTable from '@/components/journeys/TopPathsTable'
import { JourneysSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
import {
@@ -172,9 +172,9 @@ export default function JourneysPage() {
- {/* Sankey Diagram */}
+ {/* Journey Columns */}
-
void
+}
+
+interface ColumnPage {
+ path: string
+ sessionCount: number
+}
+
+interface Column {
+ index: number
+ totalSessions: number
+ dropOffPercent: number
+ pages: ColumnPage[]
+}
+
+interface LineDef {
+ sourceY: number
+ destY: number
+ sourceX: number
+ destX: number
+ weight: number
+}
+
+// ─── Constants ──────────────────────────────────────────────────────
+
+const COLUMN_COLORS = [
+ '#FD5E0F', '#3B82F6', '#10B981', '#F59E0B', '#8B5CF6',
+ '#EC4899', '#06B6D4', '#EF4444', '#84CC16', '#F97316', '#6366F1',
+]
+const MAX_NODES_PER_COLUMN = 10
+
+function colorForColumn(col: number): string {
+ return COLUMN_COLORS[col % COLUMN_COLORS.length]
+}
+
+// ─── Helpers ────────────────────────────────────────────────────────
+
+function smartLabel(path: string): string {
+ if (path === '/' || path === '(other)') return path
+ const segments = path.replace(/\/$/, '').split('/')
+ if (segments.length <= 2) return path
+ return `…/${segments[segments.length - 1]}`
+}
+
+// ─── Data transformation ────────────────────────────────────────────
+
+function buildColumns(
+ transitions: PathTransition[],
+ depth: number,
+ selections: Map,
+): Column[] {
+ const numCols = depth + 1
+ const columns: Column[] = []
+
+ // Build a filtered transitions set based on selections
+ // For each column N with a selection, only keep transitions at step_index=N
+ // where from_path matches the selection
+ let filteredTransitions = transitions
+
+ for (let col = 0; col < numCols - 1; col++) {
+ const selected = selections.get(col)
+ if (selected) {
+ filteredTransitions = filteredTransitions.filter(
+ (t) => t.step_index !== col || t.from_path === selected
+ )
+ }
+ }
+
+ for (let col = 0; col < numCols; col++) {
+ const pageMap = new Map()
+
+ if (col === 0) {
+ // Column 0: aggregate from_path across step_index=0
+ for (const t of filteredTransitions) {
+ if (t.step_index === 0) {
+ pageMap.set(t.from_path, (pageMap.get(t.from_path) ?? 0) + t.session_count)
+ }
+ }
+ } else {
+ // Column N: aggregate to_path across step_index=N-1
+ for (const t of filteredTransitions) {
+ if (t.step_index === col - 1) {
+ pageMap.set(t.to_path, (pageMap.get(t.to_path) ?? 0) + t.session_count)
+ }
+ }
+ }
+
+ // Sort descending by count
+ let pages = Array.from(pageMap.entries())
+ .map(([path, sessionCount]) => ({ path, sessionCount }))
+ .sort((a, b) => b.sessionCount - a.sessionCount)
+
+ // Cap and merge into (other)
+ if (pages.length > MAX_NODES_PER_COLUMN) {
+ const kept = pages.slice(0, MAX_NODES_PER_COLUMN)
+ const otherCount = pages
+ .slice(MAX_NODES_PER_COLUMN)
+ .reduce((sum, p) => sum + p.sessionCount, 0)
+ kept.push({ path: '(other)', sessionCount: otherCount })
+ pages = kept
+ }
+
+ const totalSessions = pages.reduce((sum, p) => sum + p.sessionCount, 0)
+ const prevTotal = col > 0 ? columns[col - 1].totalSessions : totalSessions
+ const dropOffPercent =
+ col === 0 || prevTotal === 0
+ ? 0
+ : Math.round(((totalSessions - prevTotal) / prevTotal) * 100)
+
+ columns.push({ index: col, totalSessions, dropOffPercent, pages })
+ }
+
+ return columns
+}
+
+// ─── Sub-components ─────────────────────────────────────────────────
+
+function ColumnHeader({
+ column,
+ color,
+}: {
+ column: Column
+ color: string
+}) {
+ return (
+
+
+ {column.index + 1}
+
+
+
+ {column.totalSessions.toLocaleString()}
+
+
+ visitors
+
+ {column.dropOffPercent !== 0 && (
+
+ {column.dropOffPercent > 0 ? '+' : ''}
+ {column.dropOffPercent}%
+
+ )}
+
+
+ )
+}
+
+function PageRow({
+ page,
+ colIndex,
+ columnTotal,
+ isSelected,
+ isOther,
+ onClick,
+}: {
+ page: ColumnPage
+ colIndex: number
+ columnTotal: number
+ isSelected: boolean
+ isOther: boolean
+ onClick: () => void
+}) {
+ const pct = columnTotal > 0 ? Math.round((page.sessionCount / columnTotal) * 100) : 0
+
+ return (
+
+ )
+}
+
+function JourneyColumn({
+ column,
+ color,
+ selectedPath,
+ onSelect,
+}: {
+ column: Column
+ color: string
+ selectedPath: string | undefined
+ onSelect: (path: string) => void
+}) {
+ if (column.pages.length === 0) {
+ return (
+
+
+
+
+ No onward traffic
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ {column.pages.map((page) => {
+ const isOther = page.path === '(other)'
+ return (
+
{
+ if (!isOther) onSelect(page.path)
+ }}
+ />
+ )
+ })}
+
+
+ )
+}
+
+// ─── Connection Lines ───────────────────────────────────────────────
+
+function ConnectionLines({
+ containerRef,
+ selections,
+ columns,
+ transitions,
+}: {
+ containerRef: React.RefObject
+ selections: Map
+ columns: Column[]
+ transitions: PathTransition[]
+}) {
+ const [lines, setLines] = useState<(LineDef & { color: string })[]>([])
+ const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
+
+ useLayoutEffect(() => {
+ const container = containerRef.current
+ if (!container || selections.size === 0) {
+ setLines([])
+ return
+ }
+
+ const containerRect = container.getBoundingClientRect()
+ setDimensions({
+ width: container.scrollWidth,
+ height: container.scrollHeight,
+ })
+
+ const newLines: (LineDef & { color: string })[] = []
+
+ for (const [colIdx, selectedPath] of selections) {
+ const nextCol = columns[colIdx + 1]
+ if (!nextCol) continue
+
+ // Find the source row element
+ const sourceEl = container.querySelector(
+ `[data-col="${colIdx}"][data-path="${CSS.escape(selectedPath)}"]`
+ ) as HTMLElement | null
+ if (!sourceEl) continue
+
+ const sourceRect = sourceEl.getBoundingClientRect()
+ const sourceY =
+ sourceRect.top + sourceRect.height / 2 - containerRect.top + container.scrollTop
+ const sourceX = sourceRect.right - containerRect.left + container.scrollLeft
+
+ // Find matching transitions
+ const relevantTransitions = transitions.filter(
+ (t) => t.step_index === colIdx && t.from_path === selectedPath
+ )
+
+ const color = colorForColumn(colIdx)
+
+ for (const t of relevantTransitions) {
+ const destEl = container.querySelector(
+ `[data-col="${colIdx + 1}"][data-path="${CSS.escape(t.to_path)}"]`
+ ) as HTMLElement | null
+ if (!destEl) continue
+
+ const destRect = destEl.getBoundingClientRect()
+ const destY =
+ destRect.top + destRect.height / 2 - containerRect.top + container.scrollTop
+ const destX = destRect.left - containerRect.left + container.scrollLeft
+
+ const maxCount = Math.max(...relevantTransitions.map((rt) => rt.session_count))
+ const weight = Math.max(1, Math.min(4, (t.session_count / maxCount) * 4))
+
+ newLines.push({ sourceY, destY, sourceX, destX, weight, color })
+ }
+ }
+
+ setLines(newLines)
+ }, [selections, columns, transitions, containerRef])
+
+ if (lines.length === 0) return null
+
+ return (
+
+ )
+}
+
+// ─── Main Component ─────────────────────────────────────────────────
+
+export default function ColumnJourney({
+ transitions,
+ totalSessions,
+ depth,
+ onNodeClick,
+}: ColumnJourneyProps) {
+ const [selections, setSelections] = useState