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:
committed by
GitHub
parent
39251c8598
commit
ed1c57dbb8
302
apps/start/src/components/report-chart/sankey/chart.tsx
Normal file
302
apps/start/src/components/report-chart/sankey/chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
apps/start/src/components/report-chart/sankey/index.tsx
Normal file
93
apps/start/src/components/report-chart/sankey/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user