feat: User Journey
This commit is contained in:
@@ -25,6 +25,7 @@
|
|||||||
"@faker-js/faker": "^9.6.0",
|
"@faker-js/faker": "^9.6.0",
|
||||||
"@hookform/resolvers": "^3.3.4",
|
"@hookform/resolvers": "^3.3.4",
|
||||||
"@hyperdx/node-opentelemetry": "^0.8.1",
|
"@hyperdx/node-opentelemetry": "^0.8.1",
|
||||||
|
"@nivo/sankey": "^0.99.0",
|
||||||
"@number-flow/react": "0.3.5",
|
"@number-flow/react": "0.3.5",
|
||||||
"@openpanel/common": "workspace:^",
|
"@openpanel/common": "workspace:^",
|
||||||
"@openpanel/constants": "workspace:^",
|
"@openpanel/constants": "workspace:^",
|
||||||
@@ -172,4 +173,4 @@
|
|||||||
"web-vitals": "^4.2.4",
|
"web-vitals": "^4.2.4",
|
||||||
"wrangler": "^4.42.2"
|
"wrangler": "^4.42.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,9 +5,15 @@ import { Tooltip as RechartsTooltip, type TooltipProps } from 'recharts';
|
|||||||
|
|
||||||
export const ChartTooltipContainer = ({
|
export const ChartTooltipContainer = ({
|
||||||
children,
|
children,
|
||||||
}: { children: React.ReactNode }) => {
|
className,
|
||||||
|
}: { children: React.ReactNode; className?: string }) => {
|
||||||
return (
|
return (
|
||||||
<div className="min-w-[180px] col gap-2 rounded-xl border bg-background/80 p-3 shadow-xl backdrop-blur-sm">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'min-w-[180px] col gap-2 rounded-xl border bg-background/80 p-3 shadow-xl backdrop-blur-sm',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</WidgetBody>
|
</WidgetBody>
|
||||||
<WidgetFooter>
|
<WidgetFooter className="row items-center justify-between">
|
||||||
<OverviewDetailsButton
|
<OverviewDetailsButton
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
pushModal('OverviewTopGenericModal', {
|
pushModal('OverviewTopGenericModal', {
|
||||||
@@ -140,6 +140,17 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
|
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
|
||||||
|
<span className="text-sm text-muted-foreground pr-2">
|
||||||
|
Geo data provided by{' '}
|
||||||
|
<a
|
||||||
|
href="https://ipdata.co"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer nofollow"
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
MaxMind
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
</WidgetFooter>
|
</WidgetFooter>
|
||||||
</Widget>
|
</Widget>
|
||||||
<Widget className="col-span-6 md:col-span-3">
|
<Widget className="col-span-6 md:col-span-3">
|
||||||
|
|||||||
408
apps/start/src/components/overview/overview-user-journey.tsx
Normal file
408
apps/start/src/components/overview/overview-user-journey.tsx
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
import {
|
||||||
|
ChartTooltipContainer,
|
||||||
|
ChartTooltipHeader,
|
||||||
|
ChartTooltipItem,
|
||||||
|
} from '@/components/charts/chart-tooltip';
|
||||||
|
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||||
|
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
import { round } from '@/utils/math';
|
||||||
|
import { ResponsiveSankey } from '@nivo/sankey';
|
||||||
|
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||||
|
import {
|
||||||
|
type ReactNode,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
|
import { truncate } from '@/utils/truncate';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { ArrowRightIcon } from 'lucide-react';
|
||||||
|
import { useTheme } from '../theme-provider';
|
||||||
|
import { Widget, WidgetBody } from '../widget';
|
||||||
|
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
|
||||||
|
import { useOverviewOptions } from './useOverviewOptions';
|
||||||
|
|
||||||
|
interface OverviewUserJourneyProps {
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PortalTooltipPosition = { left: number; top: number; ready: boolean };
|
||||||
|
|
||||||
|
const showPath = (string: string) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(string);
|
||||||
|
return url.pathname;
|
||||||
|
} catch {
|
||||||
|
return string;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showDomain = (string: string) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(string);
|
||||||
|
return url.hostname;
|
||||||
|
} catch {
|
||||||
|
return string;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function SankeyPortalTooltip({
|
||||||
|
children,
|
||||||
|
offset = 12,
|
||||||
|
padding = 8,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
offset?: number;
|
||||||
|
padding?: number;
|
||||||
|
}) {
|
||||||
|
const anchorRef = useRef<HTMLSpanElement | null>(null);
|
||||||
|
const tooltipRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null);
|
||||||
|
const [pos, setPos] = useState<PortalTooltipPosition>({
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
ready: false,
|
||||||
|
});
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = anchorRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
// Nivo renders the tooltip content inside an absolutely-positioned wrapper <div>.
|
||||||
|
// 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. */}
|
||||||
|
<span ref={anchorRef} className="sr-only" />
|
||||||
|
{mounted &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
ref={tooltipRef}
|
||||||
|
className="pointer-events-none fixed z-[9999]"
|
||||||
|
style={{
|
||||||
|
left: pos.left,
|
||||||
|
top: pos.top,
|
||||||
|
visibility: pos.ready ? 'visible' : 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>,
|
||||||
|
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<HTMLDivElement>(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 (
|
||||||
|
<Widget className="col-span-6">
|
||||||
|
<WidgetHead>
|
||||||
|
<div className="title">User Journey</div>
|
||||||
|
<WidgetButtons>
|
||||||
|
{stepOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={option}
|
||||||
|
onClick={() => setSteps(option)}
|
||||||
|
className={cn((steps ?? 5) === option && 'active')}
|
||||||
|
>
|
||||||
|
{option} Steps
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</WidgetButtons>
|
||||||
|
</WidgetHead>
|
||||||
|
<WidgetBody>
|
||||||
|
{query.isLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-96">
|
||||||
|
<div className="text-sm text-muted-foreground">Loading...</div>
|
||||||
|
</div>
|
||||||
|
) : sankeyData.nodes.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-96">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
No journey data available
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="w-full relative aspect-square md:aspect-[2]"
|
||||||
|
>
|
||||||
|
<ResponsiveSankey
|
||||||
|
margin={{ top: 20, right: 20, bottom: 20, left: 20 }}
|
||||||
|
data={sankeyData}
|
||||||
|
colors={(node: any) => 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 (
|
||||||
|
<SankeyPortalTooltip>
|
||||||
|
<ChartTooltipContainer className="min-w-[250px]">
|
||||||
|
<ChartTooltipHeader>
|
||||||
|
<div className="min-w-0 flex-1 font-medium break-words">
|
||||||
|
<span className="opacity-40 mr-1">
|
||||||
|
{showDomain(label)}
|
||||||
|
</span>
|
||||||
|
{showPath(label)}
|
||||||
|
</div>
|
||||||
|
{typeof step === 'number' && (
|
||||||
|
<div className="shrink-0 text-muted-foreground">
|
||||||
|
Step {step}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ChartTooltipHeader>
|
||||||
|
<ChartTooltipItem color={color} innerClassName="gap-2">
|
||||||
|
<div className="flex items-center justify-between gap-8 font-mono font-medium">
|
||||||
|
<div className="text-muted-foreground">Sessions</div>
|
||||||
|
<div>{number.format(value)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-8 font-mono font-medium">
|
||||||
|
<div className="text-muted-foreground">Share</div>
|
||||||
|
<div>{number.format(round(pct, 1))} %</div>
|
||||||
|
</div>
|
||||||
|
</ChartTooltipItem>
|
||||||
|
</ChartTooltipContainer>
|
||||||
|
</SankeyPortalTooltip>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
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 (
|
||||||
|
<SankeyPortalTooltip>
|
||||||
|
<ChartTooltipContainer>
|
||||||
|
<ChartTooltipHeader>
|
||||||
|
<div className="min-w-0 flex-1 font-medium break-words">
|
||||||
|
<span className="opacity-40 mr-1">
|
||||||
|
{showDomain(sourceLabel)}
|
||||||
|
</span>
|
||||||
|
{showPath(sourceLabel)}
|
||||||
|
<ArrowRightIcon className="size-2 inline-block mx-3" />
|
||||||
|
{!isSameDomain && (
|
||||||
|
<span className="opacity-40 mr-1">
|
||||||
|
{showDomain(targetLabel)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{showPath(targetLabel)}
|
||||||
|
</div>
|
||||||
|
{typeof sourceStep === 'number' &&
|
||||||
|
typeof targetStep === 'number' && (
|
||||||
|
<div className="shrink-0 text-muted-foreground">
|
||||||
|
{sourceStep} → {targetStep}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ChartTooltipHeader>
|
||||||
|
|
||||||
|
<ChartTooltipItem color={color} innerClassName="gap-2">
|
||||||
|
<div className="flex items-center justify-between gap-8 font-mono font-medium">
|
||||||
|
<div className="text-muted-foreground">Sessions</div>
|
||||||
|
<div>{number.format(value)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-8 font-mono text-sm">
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
% of total
|
||||||
|
</div>
|
||||||
|
<div>{number.format(round(pctOfTotal, 1))} %</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-8 font-mono text-sm">
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
% of source
|
||||||
|
</div>
|
||||||
|
<div>{number.format(round(pctOfSource, 1))} %</div>
|
||||||
|
</div>
|
||||||
|
</ChartTooltipItem>
|
||||||
|
</ChartTooltipContainer>
|
||||||
|
</SankeyPortalTooltip>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</WidgetBody>
|
||||||
|
<WidgetFooter>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Shows the most common paths users take through your application
|
||||||
|
</div>
|
||||||
|
</WidgetFooter>
|
||||||
|
</Widget>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import OverviewTopEvents from '@/components/overview/overview-top-events';
|
|||||||
import OverviewTopGeo from '@/components/overview/overview-top-geo';
|
import OverviewTopGeo from '@/components/overview/overview-top-geo';
|
||||||
import OverviewTopPages from '@/components/overview/overview-top-pages';
|
import OverviewTopPages from '@/components/overview/overview-top-pages';
|
||||||
import OverviewTopSources from '@/components/overview/overview-top-sources';
|
import OverviewTopSources from '@/components/overview/overview-top-sources';
|
||||||
|
import OverviewUserJourney from '@/components/overview/overview-user-journey';
|
||||||
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
|
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
|
||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ function ProjectDashboard() {
|
|||||||
<OverviewTopDevices projectId={projectId} />
|
<OverviewTopDevices projectId={projectId} />
|
||||||
<OverviewTopEvents projectId={projectId} />
|
<OverviewTopEvents projectId={projectId} />
|
||||||
<OverviewTopGeo projectId={projectId} />
|
<OverviewTopGeo projectId={projectId} />
|
||||||
|
<OverviewUserJourney projectId={projectId} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,27 +1,13 @@
|
|||||||
// import resolveConfig from 'tailwindcss/resolveConfig';
|
// import resolveConfig from 'tailwindcss/resolveConfig';
|
||||||
|
|
||||||
|
import { chartColors } from '@openpanel/constants';
|
||||||
|
|
||||||
// import tailwinConfig from '../../tailwind.config';
|
// import tailwinConfig from '../../tailwind.config';
|
||||||
|
|
||||||
// export const resolvedTailwindConfig = resolveConfig(tailwinConfig);
|
// export const resolvedTailwindConfig = resolveConfig(tailwinConfig);
|
||||||
|
|
||||||
// export const theme = resolvedTailwindConfig.theme as Record<string, any>;
|
// export const theme = resolvedTailwindConfig.theme as Record<string, any>;
|
||||||
|
|
||||||
const chartColors = [
|
|
||||||
{ main: '#2563EB', translucent: 'rgba(37, 99, 235, 0.1)' },
|
|
||||||
{ main: '#ff7557', translucent: 'rgba(255, 117, 87, 0.1)' },
|
|
||||||
{ main: '#7fe1d8', translucent: 'rgba(127, 225, 216, 0.1)' },
|
|
||||||
{ main: '#f8bc3c', translucent: 'rgba(248, 188, 60, 0.1)' },
|
|
||||||
{ main: '#b3596e', translucent: 'rgba(179, 89, 110, 0.1)' },
|
|
||||||
{ main: '#72bef4', translucent: 'rgba(114, 190, 244, 0.1)' },
|
|
||||||
{ main: '#ffb27a', translucent: 'rgba(255, 178, 122, 0.1)' },
|
|
||||||
{ main: '#0f7ea0', translucent: 'rgba(15, 126, 160, 0.1)' },
|
|
||||||
{ main: '#3ba974', translucent: 'rgba(59, 169, 116, 0.1)' },
|
|
||||||
{ main: '#febbb2', translucent: 'rgba(254, 187, 178, 0.1)' },
|
|
||||||
{ main: '#cb80dc', translucent: 'rgba(203, 128, 220, 0.1)' },
|
|
||||||
{ main: '#5cb7af', translucent: 'rgba(92, 183, 175, 0.1)' },
|
|
||||||
{ main: '#7856ff', translucent: 'rgba(120, 86, 255, 0.1)' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function getChartColor(index: number): string {
|
export function getChartColor(index: number): string {
|
||||||
return chartColors[index % chartColors.length]?.main || chartColors[0].main;
|
return chartColors[index % chartColors.length]?.main || chartColors[0].main;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
export function truncate(str: string, len: number) {
|
export function truncate(
|
||||||
|
str: string,
|
||||||
|
len: number,
|
||||||
|
mode: 'start' | 'end' | 'middle' = 'end',
|
||||||
|
) {
|
||||||
if (str.length <= len) {
|
if (str.length <= len) {
|
||||||
return str;
|
return str;
|
||||||
}
|
}
|
||||||
|
if (mode === 'start') {
|
||||||
|
return `...${str.slice(-len)}`;
|
||||||
|
}
|
||||||
|
if (mode === 'middle') {
|
||||||
|
return `${str.slice(0, len / 2)}...${str.slice(-len / 2)}`;
|
||||||
|
}
|
||||||
return `${str.slice(0, len)}...`;
|
return `${str.slice(0, len)}...`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -501,3 +501,19 @@ export const countries = {
|
|||||||
export function getCountry(code?: string) {
|
export function getCountry(code?: string) {
|
||||||
return countries[code as keyof typeof countries];
|
return countries[code as keyof typeof countries];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const chartColors = [
|
||||||
|
{ main: '#2563EB', translucent: 'rgba(37, 99, 235, 0.1)' },
|
||||||
|
{ main: '#ff7557', translucent: 'rgba(255, 117, 87, 0.1)' },
|
||||||
|
{ main: '#7fe1d8', translucent: 'rgba(127, 225, 216, 0.1)' },
|
||||||
|
{ main: '#f8bc3c', translucent: 'rgba(248, 188, 60, 0.1)' },
|
||||||
|
{ main: '#b3596e', translucent: 'rgba(179, 89, 110, 0.1)' },
|
||||||
|
{ main: '#72bef4', translucent: 'rgba(114, 190, 244, 0.1)' },
|
||||||
|
{ main: '#ffb27a', translucent: 'rgba(255, 178, 122, 0.1)' },
|
||||||
|
{ main: '#0f7ea0', translucent: 'rgba(15, 126, 160, 0.1)' },
|
||||||
|
{ main: '#3ba974', translucent: 'rgba(59, 169, 116, 0.1)' },
|
||||||
|
{ main: '#febbb2', translucent: 'rgba(254, 187, 178, 0.1)' },
|
||||||
|
{ main: '#cb80dc', translucent: 'rgba(203, 128, 220, 0.1)' },
|
||||||
|
{ main: '#5cb7af', translucent: 'rgba(92, 183, 175, 0.1)' },
|
||||||
|
{ main: '#7856ff', translucent: 'rgba(120, 86, 255, 0.1)' },
|
||||||
|
];
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { average, sum } from '@openpanel/common';
|
import { average, sum } from '@openpanel/common';
|
||||||
|
import { chartColors } from '@openpanel/constants';
|
||||||
import { getCache } from '@openpanel/redis';
|
import { getCache } from '@openpanel/redis';
|
||||||
import { type IChartEventFilter, zTimeInterval } from '@openpanel/validation';
|
import { type IChartEventFilter, zTimeInterval } from '@openpanel/validation';
|
||||||
import { omit } from 'ramda';
|
import { omit } from 'ramda';
|
||||||
@@ -104,6 +105,18 @@ export type IGetTopGenericInput = z.infer<typeof zGetTopGenericInput> & {
|
|||||||
timezone: string;
|
timezone: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const zGetUserJourneyInput = z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
filters: z.array(z.any()),
|
||||||
|
startDate: z.string(),
|
||||||
|
endDate: z.string(),
|
||||||
|
steps: z.number().min(2).max(10).default(5),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type IGetUserJourneyInput = z.infer<typeof zGetUserJourneyInput> & {
|
||||||
|
timezone: string;
|
||||||
|
};
|
||||||
|
|
||||||
export class OverviewService {
|
export class OverviewService {
|
||||||
constructor(private client: typeof ch) {}
|
constructor(private client: typeof ch) {}
|
||||||
|
|
||||||
@@ -729,6 +742,345 @@ export class OverviewService {
|
|||||||
|
|
||||||
return mainQuery.execute();
|
return mainQuery.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getUserJourney({
|
||||||
|
projectId,
|
||||||
|
filters,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
steps = 5,
|
||||||
|
timezone,
|
||||||
|
}: IGetUserJourneyInput): Promise<{
|
||||||
|
nodes: Array<{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
nodeColor: string;
|
||||||
|
percentage?: number;
|
||||||
|
value?: number;
|
||||||
|
step?: number;
|
||||||
|
}>;
|
||||||
|
links: Array<{ source: string; target: string; value: number }>;
|
||||||
|
}> {
|
||||||
|
// Config
|
||||||
|
const TOP_ENTRIES = 3; // Only show top 3 entry pages
|
||||||
|
const TOP_DESTINATIONS_PER_NODE = 3; // Top 3 destinations from each node
|
||||||
|
|
||||||
|
// Color palette - each entry page gets a consistent color
|
||||||
|
const COLORS = chartColors.map((color) => color.main);
|
||||||
|
|
||||||
|
// Step 1: Get session paths (deduped consecutive pages)
|
||||||
|
const orderedEventsQuery = clix(this.client, timezone)
|
||||||
|
.select<{
|
||||||
|
session_id: string;
|
||||||
|
path: string;
|
||||||
|
created_at: string;
|
||||||
|
}>(['session_id', 'concat(origin, path) as path', 'created_at'])
|
||||||
|
.from(TABLE_NAMES.events)
|
||||||
|
.where('project_id', '=', projectId)
|
||||||
|
.where('name', '=', 'screen_view')
|
||||||
|
.where('path', '!=', '')
|
||||||
|
.where('path', 'IS NOT NULL')
|
||||||
|
.where('created_at', 'BETWEEN', [
|
||||||
|
clix.datetime(startDate, 'toDateTime'),
|
||||||
|
clix.datetime(endDate, 'toDateTime'),
|
||||||
|
])
|
||||||
|
.rawWhere(this.getRawWhereClause('events', filters))
|
||||||
|
.orderBy('session_id', 'ASC')
|
||||||
|
.orderBy('created_at', 'ASC');
|
||||||
|
|
||||||
|
// Intermediate CTE to compute deduped paths
|
||||||
|
const pathsDedupedCTE = clix(this.client, timezone)
|
||||||
|
.with('ordered_events', orderedEventsQuery)
|
||||||
|
.select<{
|
||||||
|
session_id: string;
|
||||||
|
paths_deduped: string[];
|
||||||
|
}>([
|
||||||
|
'session_id',
|
||||||
|
`arraySlice(
|
||||||
|
arrayFilter(
|
||||||
|
(x, i) -> i = 1 OR x != paths_raw[i - 1],
|
||||||
|
groupArray(path) as paths_raw,
|
||||||
|
arrayEnumerate(paths_raw)
|
||||||
|
),
|
||||||
|
1, ${steps}
|
||||||
|
) as paths_deduped`,
|
||||||
|
])
|
||||||
|
.from('ordered_events')
|
||||||
|
.groupBy(['session_id']);
|
||||||
|
|
||||||
|
const sessionPathsQuery = clix(this.client, timezone)
|
||||||
|
.with('paths_deduped_cte', pathsDedupedCTE)
|
||||||
|
.select<{
|
||||||
|
session_id: string;
|
||||||
|
entry_page: string;
|
||||||
|
paths: string[];
|
||||||
|
}>([
|
||||||
|
'session_id',
|
||||||
|
// Truncate at first repeat
|
||||||
|
`if(
|
||||||
|
arrayFirstIndex(x -> x > 1, arrayEnumerateUniq(paths_deduped)) = 0,
|
||||||
|
paths_deduped,
|
||||||
|
arraySlice(
|
||||||
|
paths_deduped,
|
||||||
|
1,
|
||||||
|
arrayFirstIndex(x -> x > 1, arrayEnumerateUniq(paths_deduped)) - 1
|
||||||
|
)
|
||||||
|
) as paths`,
|
||||||
|
// Entry page is first element
|
||||||
|
'paths[1] as entry_page',
|
||||||
|
])
|
||||||
|
.from('paths_deduped_cte')
|
||||||
|
.having('length(paths)', '>=', 2);
|
||||||
|
|
||||||
|
// Step 2: Find top 3 entry pages
|
||||||
|
const topEntriesQuery = clix(this.client, timezone)
|
||||||
|
.with('session_paths', sessionPathsQuery)
|
||||||
|
.select<{ entry_page: string; count: number }>([
|
||||||
|
'entry_page',
|
||||||
|
'count() as count',
|
||||||
|
])
|
||||||
|
.from('session_paths')
|
||||||
|
.groupBy(['entry_page'])
|
||||||
|
.orderBy('count', 'DESC')
|
||||||
|
.limit(TOP_ENTRIES);
|
||||||
|
|
||||||
|
const topEntries = await topEntriesQuery.execute();
|
||||||
|
|
||||||
|
if (topEntries.length === 0) {
|
||||||
|
return { nodes: [], links: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const topEntryPages = topEntries.map((e) => e.entry_page);
|
||||||
|
const totalSessions = topEntries.reduce((sum, e) => sum + e.count, 0);
|
||||||
|
|
||||||
|
// Step 3: Get all transitions, but ONLY for sessions starting with top entries
|
||||||
|
const transitionsQuery = clix(this.client, timezone)
|
||||||
|
.with('paths_deduped_cte', pathsDedupedCTE)
|
||||||
|
.with(
|
||||||
|
'session_paths',
|
||||||
|
clix(this.client, timezone)
|
||||||
|
.select([
|
||||||
|
'session_id',
|
||||||
|
// Truncate at first repeat
|
||||||
|
`if(
|
||||||
|
arrayFirstIndex(x -> x > 1, arrayEnumerateUniq(paths_deduped)) = 0,
|
||||||
|
paths_deduped,
|
||||||
|
arraySlice(
|
||||||
|
paths_deduped,
|
||||||
|
1,
|
||||||
|
arrayFirstIndex(x -> x > 1, arrayEnumerateUniq(paths_deduped)) - 1
|
||||||
|
)
|
||||||
|
) as paths`,
|
||||||
|
])
|
||||||
|
.from('paths_deduped_cte')
|
||||||
|
.having('length(paths)', '>=', 2)
|
||||||
|
// ONLY sessions starting with top entry pages
|
||||||
|
.having('paths[1]', 'IN', topEntryPages),
|
||||||
|
)
|
||||||
|
.select<{
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
step: number;
|
||||||
|
value: number;
|
||||||
|
}>([
|
||||||
|
'pair.1 as source',
|
||||||
|
'pair.2 as target',
|
||||||
|
'pair.3 as step',
|
||||||
|
'count() as value',
|
||||||
|
])
|
||||||
|
.from(
|
||||||
|
clix.exp(
|
||||||
|
'(SELECT arrayJoin(arrayMap(i -> (paths[i], paths[i + 1], i), range(1, length(paths)))) as pair FROM session_paths WHERE length(paths) >= 2)',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(['source', 'target', 'step'])
|
||||||
|
.orderBy('step', 'ASC')
|
||||||
|
.orderBy('value', 'DESC');
|
||||||
|
|
||||||
|
const transitions = await transitionsQuery.execute();
|
||||||
|
|
||||||
|
if (transitions.length === 0) {
|
||||||
|
return { nodes: [], links: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Build the sankey progressively step by step
|
||||||
|
// Start with entry nodes, then follow top destinations at each step
|
||||||
|
// Use unique node IDs by combining path with step to prevent circular references
|
||||||
|
const nodes = new Map<
|
||||||
|
string,
|
||||||
|
{ path: string; value: number; step: number; color: string }
|
||||||
|
>();
|
||||||
|
const links: Array<{ source: string; target: string; value: number }> = [];
|
||||||
|
|
||||||
|
// Helper to create unique node ID
|
||||||
|
const getNodeId = (path: string, step: number) => `${path}::step${step}`;
|
||||||
|
|
||||||
|
// Group transitions by step
|
||||||
|
const transitionsByStep = new Map<number, typeof transitions>();
|
||||||
|
for (const t of transitions) {
|
||||||
|
if (!transitionsByStep.has(t.step)) {
|
||||||
|
transitionsByStep.set(t.step, []);
|
||||||
|
}
|
||||||
|
transitionsByStep.get(t.step)!.push(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize with entry pages (step 1)
|
||||||
|
const activeNodes = new Map<string, string>(); // path -> nodeId
|
||||||
|
topEntries.forEach((entry, idx) => {
|
||||||
|
const nodeId = getNodeId(entry.entry_page, 1);
|
||||||
|
nodes.set(nodeId, {
|
||||||
|
path: entry.entry_page,
|
||||||
|
value: entry.count,
|
||||||
|
step: 1,
|
||||||
|
color: COLORS[idx % COLORS.length]!,
|
||||||
|
});
|
||||||
|
activeNodes.set(entry.entry_page, nodeId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process each step: from active nodes, find top destinations
|
||||||
|
for (let step = 1; step < steps; step++) {
|
||||||
|
const stepTransitions = transitionsByStep.get(step) || [];
|
||||||
|
const nextActiveNodes = new Map<string, string>();
|
||||||
|
|
||||||
|
// For each currently active node, find its top destinations
|
||||||
|
for (const [sourcePath, sourceNodeId] of activeNodes) {
|
||||||
|
// Get transitions FROM this source path
|
||||||
|
const fromSource = stepTransitions
|
||||||
|
.filter((t) => t.source === sourcePath)
|
||||||
|
.sort((a, b) => b.value - a.value)
|
||||||
|
.slice(0, TOP_DESTINATIONS_PER_NODE);
|
||||||
|
|
||||||
|
for (const t of fromSource) {
|
||||||
|
// Skip self-loops
|
||||||
|
if (t.source === t.target) continue;
|
||||||
|
|
||||||
|
const targetNodeId = getNodeId(t.target, step + 1);
|
||||||
|
|
||||||
|
// Add link using unique node IDs
|
||||||
|
links.push({
|
||||||
|
source: sourceNodeId,
|
||||||
|
target: targetNodeId,
|
||||||
|
value: t.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add/update target node
|
||||||
|
const existing = nodes.get(targetNodeId);
|
||||||
|
if (existing) {
|
||||||
|
existing.value += t.value;
|
||||||
|
} else {
|
||||||
|
// Inherit color from source or assign new
|
||||||
|
const sourceData = nodes.get(sourceNodeId);
|
||||||
|
nodes.set(targetNodeId, {
|
||||||
|
path: t.target,
|
||||||
|
value: t.value,
|
||||||
|
step: step + 1,
|
||||||
|
color: sourceData?.color || COLORS[nodes.size % COLORS.length]!,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
nextActiveNodes.set(t.target, targetNodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update active nodes for next iteration
|
||||||
|
activeNodes.clear();
|
||||||
|
for (const [path, nodeId] of nextActiveNodes) {
|
||||||
|
activeNodes.set(path, nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop if no more nodes to process
|
||||||
|
if (activeNodes.size === 0) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Filter links by threshold (0.25% of total sessions)
|
||||||
|
const MIN_LINK_PERCENT = 0.25;
|
||||||
|
const minLinkValue = Math.ceil((totalSessions * MIN_LINK_PERCENT) / 100);
|
||||||
|
const filteredLinks = links.filter((link) => link.value >= minLinkValue);
|
||||||
|
|
||||||
|
// Step 6: Find all nodes referenced by remaining links
|
||||||
|
const referencedNodeIds = new Set<string>();
|
||||||
|
filteredLinks.forEach((link) => {
|
||||||
|
referencedNodeIds.add(link.source);
|
||||||
|
referencedNodeIds.add(link.target);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 7: Recompute node values from filtered links (sum of incoming links)
|
||||||
|
const nodeValuesFromLinks = new Map<string, number>();
|
||||||
|
filteredLinks.forEach((link) => {
|
||||||
|
// Add to target node value
|
||||||
|
const current = nodeValuesFromLinks.get(link.target) || 0;
|
||||||
|
nodeValuesFromLinks.set(link.target, current + link.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// For entry nodes (step 1), only keep them if they have outgoing links after filtering
|
||||||
|
nodes.forEach((nodeData, nodeId) => {
|
||||||
|
if (nodeData.step === 1) {
|
||||||
|
const hasOutgoing = filteredLinks.some((l) => l.source === nodeId);
|
||||||
|
if (!hasOutgoing) {
|
||||||
|
// No outgoing links, remove entry node
|
||||||
|
referencedNodeIds.delete(nodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 8: Build final nodes array sorted by step then value
|
||||||
|
// Only include nodes that are referenced by filtered links
|
||||||
|
const finalNodes = Array.from(nodes.entries())
|
||||||
|
.filter(([id]) => referencedNodeIds.has(id))
|
||||||
|
.map(([id, data]) => {
|
||||||
|
// Use value from links for non-entry nodes, or original value for entry nodes with outgoing links
|
||||||
|
const value =
|
||||||
|
data.step === 1
|
||||||
|
? data.value
|
||||||
|
: nodeValuesFromLinks.get(id) || data.value;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label: data.path, // Add label for display
|
||||||
|
nodeColor: data.color,
|
||||||
|
percentage: (value / totalSessions) * 100,
|
||||||
|
value,
|
||||||
|
step: data.step,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Sort by step first, then by value descending
|
||||||
|
if (a.step !== b.step) return a.step - b.step;
|
||||||
|
return b.value - a.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sanity check: Ensure all link endpoints exist in nodes
|
||||||
|
const nodeIds = new Set(finalNodes.map((n) => n.id));
|
||||||
|
const invalidLinks = filteredLinks.filter(
|
||||||
|
(link) => !nodeIds.has(link.source) || !nodeIds.has(link.target),
|
||||||
|
);
|
||||||
|
if (invalidLinks.length > 0) {
|
||||||
|
console.warn(
|
||||||
|
`UserJourney: Found ${invalidLinks.length} links with missing nodes`,
|
||||||
|
);
|
||||||
|
// Remove invalid links
|
||||||
|
const validLinks = filteredLinks.filter(
|
||||||
|
(link) => nodeIds.has(link.source) && nodeIds.has(link.target),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
nodes: finalNodes,
|
||||||
|
links: validLinks,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanity check: Ensure steps are monotonic (should always be true, but verify)
|
||||||
|
const stepsValid = finalNodes.every((node, idx, arr) => {
|
||||||
|
if (idx === 0) return true;
|
||||||
|
return node.step! >= arr[idx - 1]!.step!;
|
||||||
|
});
|
||||||
|
if (!stepsValid) {
|
||||||
|
console.warn('UserJourney: Steps are not monotonic');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: finalNodes,
|
||||||
|
links: filteredLinks,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const overviewService = new OverviewService(ch);
|
export const overviewService = new OverviewService(ch);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
zGetMetricsInput,
|
zGetMetricsInput,
|
||||||
zGetTopGenericInput,
|
zGetTopGenericInput,
|
||||||
zGetTopPagesInput,
|
zGetTopPagesInput,
|
||||||
|
zGetUserJourneyInput,
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
import { type IChartRange, zRange } from '@openpanel/validation';
|
import { type IChartRange, zRange } from '@openpanel/validation';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
@@ -301,6 +302,33 @@ export const overviewRouter = createTRPCRouter({
|
|||||||
timezone,
|
timezone,
|
||||||
)(overviewService.getTopGeneric.bind(overviewService));
|
)(overviewService.getTopGeneric.bind(overviewService));
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}),
|
||||||
|
|
||||||
|
userJourney: publicProcedure
|
||||||
|
.input(
|
||||||
|
zGetUserJourneyInput.omit({ startDate: true, endDate: true }).extend({
|
||||||
|
startDate: z.string().nullish(),
|
||||||
|
endDate: z.string().nullish(),
|
||||||
|
range: zRange,
|
||||||
|
steps: z.number().min(2).max(10).default(5).optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.use(cacher)
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const { timezone } = await getSettingsForProject(input.projectId);
|
||||||
|
const { current } = await getCurrentAndPrevious(
|
||||||
|
{ ...input, timezone },
|
||||||
|
false,
|
||||||
|
timezone,
|
||||||
|
)(async (input) => {
|
||||||
|
return overviewService.getUserJourney({
|
||||||
|
...input,
|
||||||
|
steps: input.steps ?? 5,
|
||||||
|
timezone,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return current;
|
return current;
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
82
pnpm-lock.yaml
generated
82
pnpm-lock.yaml
generated
@@ -416,6 +416,9 @@ importers:
|
|||||||
'@hyperdx/node-opentelemetry':
|
'@hyperdx/node-opentelemetry':
|
||||||
specifier: ^0.8.1
|
specifier: ^0.8.1
|
||||||
version: 0.8.1
|
version: 0.8.1
|
||||||
|
'@nivo/sankey':
|
||||||
|
specifier: ^0.99.0
|
||||||
|
version: 0.99.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
'@number-flow/react':
|
'@number-flow/react':
|
||||||
specifier: 0.3.5
|
specifier: 0.3.5
|
||||||
version: 0.3.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 0.3.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
@@ -4803,6 +4806,16 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.14 || ^17.0 || ^18.0 || ^19.0
|
react: ^16.14 || ^17.0 || ^18.0 || ^19.0
|
||||||
|
|
||||||
|
'@nivo/legends@0.99.0':
|
||||||
|
resolution: {integrity: sha512-P16FjFqNceuTTZphINAh5p0RF0opu3cCKoWppe2aRD9IuVkvRm/wS5K1YwMCxDzKyKh5v0AuTlu9K6o3/hk8hA==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.14 || ^17.0 || ^18.0 || ^19.0
|
||||||
|
|
||||||
|
'@nivo/sankey@0.99.0':
|
||||||
|
resolution: {integrity: sha512-u5hySywsachjo9cHdUxCR9qwD6gfRVPEAcpuIUKiA0WClDjdGbl3vkrQcQcFexJUBThqSSbwGCDWR+2INXSbTw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.14 || ^17.0 || ^18.0 || ^19.0
|
||||||
|
|
||||||
'@nivo/text@0.99.0':
|
'@nivo/text@0.99.0':
|
||||||
resolution: {integrity: sha512-ho3oZpAZApsJNjsIL5WJSAdg/wjzTBcwo1KiHBlRGUmD+yUWO8qp7V+mnYRhJchwygtRVALlPgZ/rlcW2Xr/MQ==}
|
resolution: {integrity: sha512-ho3oZpAZApsJNjsIL5WJSAdg/wjzTBcwo1KiHBlRGUmD+yUWO8qp7V+mnYRhJchwygtRVALlPgZ/rlcW2Xr/MQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -8347,6 +8360,9 @@ packages:
|
|||||||
'@types/d3-interpolate@3.0.4':
|
'@types/d3-interpolate@3.0.4':
|
||||||
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
|
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
|
||||||
|
|
||||||
|
'@types/d3-path@1.0.11':
|
||||||
|
resolution: {integrity: sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==}
|
||||||
|
|
||||||
'@types/d3-path@3.1.0':
|
'@types/d3-path@3.1.0':
|
||||||
resolution: {integrity: sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==}
|
resolution: {integrity: sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==}
|
||||||
|
|
||||||
@@ -8359,6 +8375,9 @@ packages:
|
|||||||
'@types/d3-random@3.0.3':
|
'@types/d3-random@3.0.3':
|
||||||
resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==}
|
resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==}
|
||||||
|
|
||||||
|
'@types/d3-sankey@0.11.2':
|
||||||
|
resolution: {integrity: sha512-U6SrTWUERSlOhnpSrgvMX64WblX1AxX6nEjI2t3mLK2USpQrnbwYYK+AS9SwiE7wgYmOsSSKoSdr8aoKBH0HgQ==}
|
||||||
|
|
||||||
'@types/d3-scale-chromatic@3.0.3':
|
'@types/d3-scale-chromatic@3.0.3':
|
||||||
resolution: {integrity: sha512-laXM4+1o5ImZv3RpFAsTRn3TEkzqkytiOY0Dz0sq5cnd1dtNlk6sHLon4OvqaiJb28T0S/TdsBI3Sjsy+keJrw==}
|
resolution: {integrity: sha512-laXM4+1o5ImZv3RpFAsTRn3TEkzqkytiOY0Dz0sq5cnd1dtNlk6sHLon4OvqaiJb28T0S/TdsBI3Sjsy+keJrw==}
|
||||||
|
|
||||||
@@ -8371,6 +8390,9 @@ packages:
|
|||||||
'@types/d3-selection@3.0.10':
|
'@types/d3-selection@3.0.10':
|
||||||
resolution: {integrity: sha512-cuHoUgS/V3hLdjJOLTT691+G2QoqAjCVLmr4kJXR4ha56w1Zdu8UUQ5TxLRqudgNjwXeQxKMq4j+lyf9sWuslg==}
|
resolution: {integrity: sha512-cuHoUgS/V3hLdjJOLTT691+G2QoqAjCVLmr4kJXR4ha56w1Zdu8UUQ5TxLRqudgNjwXeQxKMq4j+lyf9sWuslg==}
|
||||||
|
|
||||||
|
'@types/d3-shape@1.3.12':
|
||||||
|
resolution: {integrity: sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==}
|
||||||
|
|
||||||
'@types/d3-shape@3.1.6':
|
'@types/d3-shape@3.1.6':
|
||||||
resolution: {integrity: sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==}
|
resolution: {integrity: sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==}
|
||||||
|
|
||||||
@@ -9916,6 +9938,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-path@1.0.9:
|
||||||
|
resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==}
|
||||||
|
|
||||||
d3-path@3.1.0:
|
d3-path@3.1.0:
|
||||||
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
|
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -9932,6 +9957,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==}
|
resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-sankey@0.12.3:
|
||||||
|
resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==}
|
||||||
|
|
||||||
d3-scale-chromatic@3.0.0:
|
d3-scale-chromatic@3.0.0:
|
||||||
resolution: {integrity: sha512-Lx9thtxAKrO2Pq6OO2Ua474opeziKr279P/TKZsMAhYyNDD3EnCffdbgeSYN5O7m2ByQsxtuP2CSDczNUIZ22g==}
|
resolution: {integrity: sha512-Lx9thtxAKrO2Pq6OO2Ua474opeziKr279P/TKZsMAhYyNDD3EnCffdbgeSYN5O7m2ByQsxtuP2CSDczNUIZ22g==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -9947,6 +9975,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
|
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-shape@1.3.7:
|
||||||
|
resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==}
|
||||||
|
|
||||||
d3-shape@3.2.0:
|
d3-shape@3.2.0:
|
||||||
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
|
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -19762,6 +19793,36 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- react-dom
|
- react-dom
|
||||||
|
|
||||||
|
'@nivo/legends@0.99.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
|
dependencies:
|
||||||
|
'@nivo/colors': 0.99.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@nivo/core': 0.99.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@nivo/text': 0.99.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@nivo/theming': 0.99.0(react@19.2.3)
|
||||||
|
'@types/d3-scale': 4.0.8
|
||||||
|
d3-scale: 4.0.2
|
||||||
|
react: 19.2.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- react-dom
|
||||||
|
|
||||||
|
'@nivo/sankey@0.99.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
|
dependencies:
|
||||||
|
'@nivo/colors': 0.99.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@nivo/core': 0.99.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@nivo/legends': 0.99.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@nivo/text': 0.99.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@nivo/theming': 0.99.0(react@19.2.3)
|
||||||
|
'@nivo/tooltip': 0.99.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@react-spring/web': 9.7.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@types/d3-sankey': 0.11.2
|
||||||
|
'@types/d3-shape': 3.1.6
|
||||||
|
d3-sankey: 0.12.3
|
||||||
|
d3-shape: 3.2.0
|
||||||
|
lodash: 4.17.21
|
||||||
|
react: 19.2.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- react-dom
|
||||||
|
|
||||||
'@nivo/text@0.99.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
'@nivo/text@0.99.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nivo/core': 0.99.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
'@nivo/core': 0.99.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
@@ -23841,6 +23902,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/d3-color': 3.1.3
|
'@types/d3-color': 3.1.3
|
||||||
|
|
||||||
|
'@types/d3-path@1.0.11': {}
|
||||||
|
|
||||||
'@types/d3-path@3.1.0': {}
|
'@types/d3-path@3.1.0': {}
|
||||||
|
|
||||||
'@types/d3-polygon@3.0.2': {}
|
'@types/d3-polygon@3.0.2': {}
|
||||||
@@ -23849,6 +23912,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/d3-random@3.0.3': {}
|
'@types/d3-random@3.0.3': {}
|
||||||
|
|
||||||
|
'@types/d3-sankey@0.11.2':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-shape': 1.3.12
|
||||||
|
|
||||||
'@types/d3-scale-chromatic@3.0.3': {}
|
'@types/d3-scale-chromatic@3.0.3': {}
|
||||||
|
|
||||||
'@types/d3-scale@4.0.8':
|
'@types/d3-scale@4.0.8':
|
||||||
@@ -23859,6 +23926,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/d3-selection@3.0.10': {}
|
'@types/d3-selection@3.0.10': {}
|
||||||
|
|
||||||
|
'@types/d3-shape@1.3.12':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-path': 1.0.11
|
||||||
|
|
||||||
'@types/d3-shape@3.1.6':
|
'@types/d3-shape@3.1.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/d3-path': 3.1.0
|
'@types/d3-path': 3.1.0
|
||||||
@@ -25754,6 +25825,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
d3-color: 3.1.0
|
d3-color: 3.1.0
|
||||||
|
|
||||||
|
d3-path@1.0.9: {}
|
||||||
|
|
||||||
d3-path@3.1.0: {}
|
d3-path@3.1.0: {}
|
||||||
|
|
||||||
d3-polygon@3.0.1: {}
|
d3-polygon@3.0.1: {}
|
||||||
@@ -25762,6 +25835,11 @@ snapshots:
|
|||||||
|
|
||||||
d3-random@3.0.1: {}
|
d3-random@3.0.1: {}
|
||||||
|
|
||||||
|
d3-sankey@0.12.3:
|
||||||
|
dependencies:
|
||||||
|
d3-array: 2.12.1
|
||||||
|
d3-shape: 1.3.7
|
||||||
|
|
||||||
d3-scale-chromatic@3.0.0:
|
d3-scale-chromatic@3.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
d3-color: 3.1.0
|
d3-color: 3.1.0
|
||||||
@@ -25779,6 +25857,10 @@ snapshots:
|
|||||||
|
|
||||||
d3-selection@3.0.0: {}
|
d3-selection@3.0.0: {}
|
||||||
|
|
||||||
|
d3-shape@1.3.7:
|
||||||
|
dependencies:
|
||||||
|
d3-path: 1.0.9
|
||||||
|
|
||||||
d3-shape@3.2.0:
|
d3-shape@3.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
d3-path: 3.1.0
|
d3-path: 3.1.0
|
||||||
|
|||||||
Reference in New Issue
Block a user