feat: share dashboard & reports, sankey report, new widgets

* fix: prompt card shadows on light mode

* fix: handle past_due and unpaid from polar

* wip

* wip

* wip 1

* fix: improve types for chart/reports

* wip share
This commit is contained in:
Carl-Gerhard Lindesvärd
2026-01-14 09:21:18 +01:00
committed by GitHub
parent 39251c8598
commit ed1c57dbb8
105 changed files with 6633 additions and 1273 deletions

View File

@@ -1,6 +1,7 @@
import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error';
@@ -9,15 +10,27 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart';
export function ReportAreaChart() {
const { isLazyLoading, report } = useReportChartContext();
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery(
trpc.chart.chart.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
}),
trpc.chart.chart.queryOptions(
{
...report,
shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
},
{
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
},
),
);
if (

View File

@@ -1,6 +1,7 @@
import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { cn } from '@/utils/cn';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
@@ -9,15 +10,27 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart';
export function ReportBarChart() {
const { isLazyLoading, report } = useReportChartContext();
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery(
trpc.chart.aggregate.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
}),
trpc.chart.aggregate.queryOptions(
{
...report,
shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
},
{
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
},
),
);
if (

View File

@@ -42,10 +42,10 @@ export function PreviousDiffIndicator({
className,
}: PreviousDiffIndicatorProps) {
const {
report: { previousIndicatorInverted, previous },
report: { previous },
} = useReportChartContext();
const variant = getDiffIndicator(
inverted ?? previousIndicatorInverted,
inverted,
state,
'bg-emerald-300',
'bg-rose-300',

View File

@@ -2,16 +2,11 @@ import isEqual from 'lodash.isequal';
import type { LucideIcon } from 'lucide-react';
import { createContext, useContext, useEffect, useState } from 'react';
import type {
IChartInput,
IChartProps,
IChartSerie,
} from '@openpanel/validation';
import type { IChartSerie, IReportInput } from '@openpanel/validation';
export type ReportChartContextType = {
options: Partial<{
columns: React.ReactNode[];
hideID: boolean;
hideLegend: boolean;
hideXAxis: boolean;
hideYAxis: boolean;
@@ -28,9 +23,11 @@ export type ReportChartContextType = {
onClick: () => void;
}[];
}>;
report: IChartProps;
report: IReportInput & { id?: string };
isLazyLoading: boolean;
isEditMode: boolean;
shareId?: string;
reportId?: string;
};
type ReportChartContextProviderProps = ReportChartContextType & {
@@ -38,7 +35,7 @@ type ReportChartContextProviderProps = ReportChartContextType & {
};
export type ReportChartProps = Partial<ReportChartContextType> & {
report: IChartInput;
report: IReportInput & { id?: string };
lazy?: boolean;
};
@@ -54,20 +51,6 @@ export const useReportChartContext = () => {
return ctx;
};
export const useSelectReportChartContext = <T,>(
selector: (ctx: ReportChartContextType) => T,
) => {
const ctx = useReportChartContext();
const [state, setState] = useState(selector(ctx));
useEffect(() => {
const newState = selector(ctx);
if (!isEqual(newState, state)) {
setState(newState);
}
}, [ctx]);
return state;
};
export const ReportChartProvider = ({
children,
...propsToContext

View File

@@ -1,6 +1,7 @@
import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { cn } from '@/utils/cn';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
@@ -11,15 +12,27 @@ import { Chart } from './chart';
import { Summary } from './summary';
export function ReportConversionChart() {
const { isLazyLoading, report } = useReportChartContext();
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
console.log(report.limit);
const res = useQuery(
trpc.chart.conversion.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
}),
trpc.chart.conversion.queryOptions(
{
...report,
shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
},
{
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
},
),
);
if (

View File

@@ -131,34 +131,36 @@ export function Tables({
series: reportSeries,
breakdowns: reportBreakdowns,
previous,
funnelWindow,
funnelGroup,
options,
},
} = useReportChartContext();
const funnelOptions = options?.type === 'funnel' ? options : undefined;
const funnelWindow = funnelOptions?.funnelWindow;
const funnelGroup = funnelOptions?.funnelGroup;
const handleInspectStep = (step: (typeof steps)[0], stepIndex: number) => {
if (!projectId || !step.event.id) return;
// For funnels, we need to pass the step index so the modal can query
// users who completed at least that step in the funnel sequence
pushModal('ViewChartUsers', {
type: 'funnel',
report: {
projectId,
series: reportSeries,
breakdowns: reportBreakdowns || [],
interval: interval || 'day',
startDate,
endDate,
range,
previous,
chartType: 'funnel',
metric: 'sum',
funnelWindow,
funnelGroup,
},
stepIndex, // Pass the step index for funnel queries
});
pushModal('ViewChartUsers', {
type: 'funnel',
report: {
projectId,
series: reportSeries,
breakdowns: reportBreakdowns || [],
interval: interval || 'day',
startDate,
endDate,
range,
previous,
chartType: 'funnel',
metric: 'sum',
options: funnelOptions,
},
stepIndex, // Pass the step index for funnel queries
});
};
return (
<div className={cn('col @container divide-y divide-border card')}>

View File

@@ -2,7 +2,8 @@ import { useTRPC } from '@/integrations/trpc/react';
import type { RouterOutputs } from '@/trpc/client';
import { useQuery } from '@tanstack/react-query';
import type { IChartInput } from '@openpanel/validation';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import type { IReportInput } from '@openpanel/validation';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
@@ -14,35 +15,39 @@ import { Chart, Summary, Tables } from './chart';
export function ReportFunnelChart() {
const {
report: {
id,
series,
range,
projectId,
funnelWindow,
funnelGroup,
options,
startDate,
endDate,
previous,
breakdowns,
interval,
},
isLazyLoading,
shareId,
} = useReportChartContext();
const { range: overviewRange, startDate: overviewStartDate, endDate: overviewEndDate, interval: overviewInterval } = useOverviewOptions();
const input: IChartInput = {
const funnelOptions = options?.type === 'funnel' ? options : undefined;
const trpc = useTRPC();
const input: IReportInput = {
series,
range,
range: overviewRange ?? range,
projectId,
interval: 'day',
interval: overviewInterval ?? interval ?? 'day',
chartType: 'funnel',
breakdowns,
funnelWindow,
funnelGroup,
previous,
metric: 'sum',
startDate,
endDate,
startDate: overviewStartDate ?? startDate,
endDate: overviewEndDate ?? endDate,
limit: 20,
options: funnelOptions,
};
const trpc = useTRPC();
const res = useQuery(
trpc.chart.funnel.queryOptions(input, {
enabled: !isLazyLoading && input.series.length > 0,

View File

@@ -1,6 +1,7 @@
import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error';
@@ -9,15 +10,27 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart';
export function ReportHistogramChart() {
const { isLazyLoading, report } = useReportChartContext();
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery(
trpc.chart.chart.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
}),
trpc.chart.chart.queryOptions(
{
...report,
shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
},
{
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
},
),
);
if (

View File

@@ -15,6 +15,7 @@ import { ReportMapChart } from './map';
import { ReportMetricChart } from './metric';
import { ReportPieChart } from './pie';
import { ReportRetentionChart } from './retention';
import { ReportSankeyChart } from './sankey';
export const ReportChart = ({ lazy = true, ...props }: ReportChartProps) => {
const ref = useRef<HTMLDivElement>(null);
@@ -57,6 +58,8 @@ export const ReportChart = ({ lazy = true, ...props }: ReportChartProps) => {
return <ReportRetentionChart />;
case 'conversion':
return <ReportConversionChart />;
case 'sankey':
return <ReportSankeyChart />;
default:
return null;
}

View File

@@ -2,6 +2,7 @@ import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { cn } from '@/utils/cn';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error';
@@ -10,15 +11,27 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart';
export function ReportLineChart() {
const { isLazyLoading, report } = useReportChartContext();
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery(
trpc.chart.chart.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
}),
trpc.chart.chart.queryOptions(
{
...report,
shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
},
{
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
},
),
);
if (

View File

@@ -1,6 +1,7 @@
import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error';
@@ -9,15 +10,27 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart';
export function ReportMapChart() {
const { isLazyLoading, report } = useReportChartContext();
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery(
trpc.chart.chart.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
}),
trpc.chart.chart.queryOptions(
{
...report,
shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
},
{
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
},
),
);
if (

View File

@@ -1,6 +1,7 @@
import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error';
@@ -8,15 +9,27 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart';
export function ReportMetricChart() {
const { isLazyLoading, report } = useReportChartContext();
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery(
trpc.chart.chart.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
}),
trpc.chart.chart.queryOptions(
{
...report,
shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
},
{
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
},
),
);
if (

View File

@@ -54,10 +54,7 @@ export function MetricCard({
metric,
unit,
}: MetricCardProps) {
const {
report: { previousIndicatorInverted },
isEditMode,
} = useReportChartContext();
const { isEditMode } = useReportChartContext();
const number = useNumber();
const renderValue = (value: number | undefined, unitClassName?: string) => {
@@ -80,7 +77,7 @@ export function MetricCard({
const previous = serie.metrics.previous?.[metric];
const graphColors = getDiffIndicator(
previousIndicatorInverted,
false,
previous?.state,
'#6ee7b7', // green
'#fda4af', // red

View File

@@ -1,6 +1,7 @@
import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error';
@@ -9,15 +10,27 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart';
export function ReportPieChart() {
const { isLazyLoading, report } = useReportChartContext();
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery(
trpc.chart.aggregate.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
}),
trpc.chart.aggregate.queryOptions(
{
...report,
shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
},
{
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
},
),
);
if (

View File

@@ -11,7 +11,6 @@ import {
changeStartDate,
ready,
reset,
setName,
setReport,
} from '@/components/report/reportSlice';
import { ReportSidebar } from '@/components/report/sidebar/ReportSidebar';
@@ -19,9 +18,10 @@ import { TimeWindowPicker } from '@/components/time-window-picker';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { useAppParams } from '@/hooks/use-app-params';
import { pushModal } from '@/modals';
import { useDispatch, useSelector } from '@/redux';
import { bind } from 'bind-event-listener';
import { GanttChartSquareIcon } from 'lucide-react';
import { GanttChartSquareIcon, ShareIcon } from 'lucide-react';
import { useEffect } from 'react';
import type { IServiceReport } from '@openpanel/db';
@@ -54,8 +54,19 @@ export default function ReportEditor({
return (
<Sheet>
<div>
<div className="p-4">
<div className="p-4 flex items-center justify-between">
<EditReportName />
{initialReport?.id && (
<Button
variant="outline"
icon={ShareIcon}
onClick={() =>
pushModal('ShareReportModal', { reportId: initialReport.id })
}
>
Share
</Button>
)}
</div>
<div className="grid grid-cols-2 gap-2 p-4 pt-0 md:grid-cols-6">
<SheetTrigger asChild>

View File

@@ -1,6 +1,7 @@
import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error';
@@ -12,21 +13,33 @@ import CohortTable from './table';
export function ReportRetentionChart() {
const {
report: {
id,
series,
range,
projectId,
options,
startDate,
endDate,
criteria,
interval,
},
isLazyLoading,
shareId,
} = useReportChartContext();
const {
range: overviewRange,
startDate: overviewStartDate,
endDate: overviewEndDate,
interval: overviewInterval,
} = useOverviewOptions();
const eventSeries = series.filter((item) => item.type === 'event');
const firstEvent = (eventSeries[0]?.filters?.[0]?.value ?? []).map(String);
const secondEvent = (eventSeries[1]?.filters?.[0]?.value ?? []).map(String);
const isEnabled =
firstEvent.length > 0 && secondEvent.length > 0 && !isLazyLoading;
const retentionOptions = options?.type === 'retention' ? options : undefined;
const criteria = retentionOptions?.criteria ?? 'on_or_after';
const trpc = useTRPC();
const res = useQuery(
trpc.chart.cohort.queryOptions(
@@ -34,11 +47,13 @@ export function ReportRetentionChart() {
firstEvent,
secondEvent,
projectId,
range,
startDate,
endDate,
range: overviewRange ?? range,
startDate: overviewStartDate ?? startDate,
endDate: overviewEndDate ?? endDate,
criteria,
interval,
interval: overviewInterval ?? interval,
shareId,
reportId: id,
},
{
placeholderData: keepPreviousData,

View File

@@ -0,0 +1,302 @@
import {
ChartTooltipContainer,
ChartTooltipHeader,
ChartTooltipItem,
} from '@/components/charts/chart-tooltip';
import { useNumber } from '@/hooks/use-numer-formatter';
import { round } from '@/utils/math';
import { ResponsiveSankey } from '@nivo/sankey';
import {
type ReactNode,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import { useTheme } from '@/components/theme-provider';
import { truncate } from '@/utils/truncate';
import { ArrowRightIcon } from 'lucide-react';
import { AspectContainer } from '../aspect-container';
type PortalTooltipPosition = { left: number; top: number; ready: boolean };
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;
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;
let left = anchorRect.left + offset;
let top = anchorRect.top + offset;
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]);
if (typeof document === 'undefined') {
return <>{children}</>;
}
return (
<>
<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,
)}
</>
);
}
type SankeyData = {
nodes: Array<{
id: string;
label: string;
nodeColor: string;
percentage?: number;
value?: number;
step?: number;
}>;
links: Array<{ source: string; target: string; value: number }>;
};
export function Chart({ data }: { data: SankeyData }) {
const number = useNumber();
const containerRef = useRef<HTMLDivElement>(null);
const { appTheme } = useTheme();
// Process data for Sankey
const sankeyData = useMemo(() => {
if (!data) return { nodes: [], links: [] };
return {
nodes: data.nodes.map((node) => ({
...node,
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;
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]);
return (
<AspectContainer>
<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">
{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';
return (
<SankeyPortalTooltip>
<ChartTooltipContainer>
<ChartTooltipHeader>
<div className="min-w-0 flex-1 font-medium break-words">
{sourceLabel}
<ArrowRightIcon className="size-2 inline-block mx-3" />
{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 = node.data?.label || node.label || node.id;
return truncate(label, 30, 'middle');
}}
labelTextColor={appTheme === 'dark' ? '#e2e8f0' : '#0f172a'}
nodeSpacing={10}
/>
</div>
</AspectContainer>
);
}

View File

@@ -0,0 +1,93 @@
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import type { IReportInput } from '@openpanel/validation';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error';
import { ReportChartLoading } from '../common/loading';
import { useReportChartContext } from '../context';
import { Chart } from './chart';
export function ReportSankeyChart() {
const {
report: {
series,
range,
projectId,
options,
startDate,
endDate,
breakdowns,
},
isLazyLoading,
} = useReportChartContext();
if (!options) {
return <Empty />;
}
const input: IReportInput = {
series,
range,
projectId,
interval: 'day',
chartType: 'sankey',
breakdowns,
options,
metric: 'sum',
startDate,
endDate,
limit: 20,
previous: false,
};
const trpc = useTRPC();
const res = useQuery(
trpc.chart.sankey.queryOptions(input, {
enabled: !isLazyLoading && input.series.length > 0,
}),
);
if (isLazyLoading || res.isLoading) {
return <Loading />;
}
if (res.isError) {
return <Error />;
}
if (!res.data || res.data.nodes.length === 0) {
return <Empty />;
}
return (
<div className="col gap-4">
<Chart data={res.data} />
</div>
);
}
function Loading() {
return (
<AspectContainer>
<ReportChartLoading />
</AspectContainer>
);
}
function Error() {
return (
<AspectContainer>
<ReportChartError />
</AspectContainer>
);
}
function Empty() {
return (
<AspectContainer>
<ReportChartEmpty />
</AspectContainer>
);
}

View File

@@ -26,7 +26,6 @@ export const ReportChartShortcut = ({
return (
<ReportChart
report={{
name: 'Shortcut',
projectId,
range,
breakdowns: breakdowns ?? [],