feat: revenue tracking
* wip * wip * wip * wip * show revenue better on overview * align realtime and overview counters * update revenue docs * always return device id * add project settings, improve projects charts, * fix: comments * fixes * fix migration * ignore sql files * fix comments
This commit is contained in:
committed by
GitHub
parent
d61cbf6f2c
commit
790801b728
@@ -1,112 +0,0 @@
|
||||
import * as d3 from 'd3';
|
||||
|
||||
export function ChartSSR({
|
||||
data,
|
||||
dots = false,
|
||||
color = 'blue',
|
||||
}: {
|
||||
dots?: boolean;
|
||||
color?: 'blue' | 'green' | 'red';
|
||||
data: { value: number; date: Date }[];
|
||||
}) {
|
||||
if (data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const xScale = d3
|
||||
.scaleTime()
|
||||
.domain([data[0]!.date, data[data.length - 1]!.date])
|
||||
.range([0, 100]);
|
||||
const yScale = d3
|
||||
.scaleLinear()
|
||||
.domain([0, d3.max(data.map((d) => d.value)) ?? 0])
|
||||
.range([100, 0]);
|
||||
|
||||
const line = d3
|
||||
.line<(typeof data)[number]>()
|
||||
.curve(d3.curveMonotoneX)
|
||||
.x((d) => xScale(d.date))
|
||||
.y((d) => yScale(d.value));
|
||||
|
||||
const area = d3
|
||||
.area<(typeof data)[number]>()
|
||||
.curve(d3.curveMonotoneX)
|
||||
.x((d) => xScale(d.date))
|
||||
.y0(yScale(0))
|
||||
.y1((d) => yScale(d.value));
|
||||
|
||||
const pathLine = line(data);
|
||||
const pathArea = area(data);
|
||||
|
||||
if (!pathLine) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const gradientId = `gradient-${color}`;
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{/* Chart area */}
|
||||
<svg className="absolute inset-0 h-full w-full overflow-visible">
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
className="overflow-visible"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={gradientId}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="100%"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.2} />
|
||||
<stop offset="50%" stopColor={color} stopOpacity={0.05} />
|
||||
<stop offset="100%" stopColor={color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{/* Gradient area */}
|
||||
{pathArea && (
|
||||
<path
|
||||
d={pathArea}
|
||||
fill={`url(#${gradientId})`}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
{/* Line */}
|
||||
<path
|
||||
d={pathLine}
|
||||
fill="none"
|
||||
className={
|
||||
color === 'green'
|
||||
? 'text-green-600'
|
||||
: color === 'red'
|
||||
? 'text-red-600'
|
||||
: 'text-highlight'
|
||||
}
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
|
||||
{/* Circles */}
|
||||
{dots &&
|
||||
data.map((d) => (
|
||||
<path
|
||||
key={d.date.toString()}
|
||||
d={`M ${xScale(d.date)} ${yScale(d.value)} l 0.0001 0`}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="text-gray-400"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
import { createContext, useContext as useBaseContext } from 'react';
|
||||
|
||||
import { Tooltip as RechartsTooltip, type TooltipProps } from 'recharts';
|
||||
@@ -21,11 +22,18 @@ export const ChartTooltipHeader = ({
|
||||
export const ChartTooltipItem = ({
|
||||
children,
|
||||
color,
|
||||
}: { children: React.ReactNode; color: string }) => {
|
||||
className,
|
||||
innerClassName,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
color: string;
|
||||
className?: string;
|
||||
innerClassName?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<div className={cn('flex gap-2', className)}>
|
||||
<div className="w-[3px] rounded-full" style={{ background: color }} />
|
||||
<div className="col flex-1 gap-1">{children}</div>
|
||||
<div className={cn('col flex-1 gap-1', innerClassName)}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -77,6 +77,15 @@ export const BarShapeBlue = BarWithBorder({
|
||||
fill: 'rgba(59, 121, 255, 0.4)',
|
||||
},
|
||||
});
|
||||
export const BarShapeGreen = BarWithBorder({
|
||||
borderHeight: 2,
|
||||
border: 'rgba(59, 169, 116, 1)',
|
||||
fill: 'rgba(59, 169, 116, 0.3)',
|
||||
active: {
|
||||
border: 'rgba(59, 169, 116, 1)',
|
||||
fill: 'rgba(59, 169, 116, 0.4)',
|
||||
},
|
||||
});
|
||||
export const BarShapeProps = BarWithBorder({
|
||||
borderHeight: 2,
|
||||
border: 'props',
|
||||
|
||||
@@ -48,6 +48,10 @@ export const EventIconRecords: Record<
|
||||
icon: 'ExternalLinkIcon',
|
||||
color: 'indigo',
|
||||
},
|
||||
revenue: {
|
||||
icon: 'DollarSignIcon',
|
||||
color: 'green',
|
||||
},
|
||||
};
|
||||
|
||||
export const EventIconMapper: Record<string, LucideIcon> = {
|
||||
|
||||
@@ -54,7 +54,7 @@ export const EventItem = memo<EventItemProps>(
|
||||
}}
|
||||
data-slot="inner"
|
||||
className={cn(
|
||||
'col gap-2 flex-1 p-2',
|
||||
'col gap-1 flex-1 p-2',
|
||||
// Desktop
|
||||
'@lg:row @lg:items-center',
|
||||
'cursor-pointer',
|
||||
@@ -63,7 +63,7 @@ export const EventItem = memo<EventItemProps>(
|
||||
: 'hover:bg-def-200',
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1 row items-center gap-4">
|
||||
<div className="min-w-0 flex-1 row items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="transition-transform hover:scale-105"
|
||||
@@ -77,7 +77,7 @@ export const EventItem = memo<EventItemProps>(
|
||||
>
|
||||
<EventIcon name={event.name} size="sm" meta={event.meta} />
|
||||
</button>
|
||||
<span className="min-w-0 whitespace-break-spaces wrap-break-word break-all">
|
||||
<span className="min-w-0 whitespace-break-spaces wrap-break-word break-all text-sm leading-normal">
|
||||
{event.name === 'screen_view' ? (
|
||||
<>
|
||||
<span className="text-muted-foreground mr-2">Visit:</span>
|
||||
@@ -87,13 +87,12 @@ export const EventItem = memo<EventItemProps>(
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-muted-foreground mr-2">Event:</span>
|
||||
<span className="font-medium">{event.name}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="row gap-2 items-center @max-lg:pl-10">
|
||||
<div className="row gap-2 items-center @max-lg:pl-8">
|
||||
{event.referrerName && viewOptions.referrerName !== false && (
|
||||
<Pill
|
||||
icon={<SerieIcon className="mr-2" name={event.referrerName} />}
|
||||
|
||||
@@ -108,8 +108,8 @@ function Wrapper({ children, count, icons }: WrapperProps) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="row gap-2 justify-between">
|
||||
<div className="relative mb-1 text-sm font-medium text-muted-foreground">
|
||||
{count} sessions last 30 minutes
|
||||
<div className="relative mb-1 text-xs font-medium text-muted-foreground">
|
||||
{count} sessions last 30 min
|
||||
</div>
|
||||
<div>{icons}</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Area, AreaChart, Tooltip } from 'recharts';
|
||||
import { formatDate, timeAgo } from '@/utils/date';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { getPreviousMetric } from '@openpanel/common';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ChartTooltipContainer,
|
||||
ChartTooltipHeader,
|
||||
@@ -24,12 +24,13 @@ interface MetricCardProps {
|
||||
data: {
|
||||
current: number;
|
||||
previous?: number;
|
||||
date: string;
|
||||
}[];
|
||||
metric: {
|
||||
current: number;
|
||||
previous?: number | null;
|
||||
};
|
||||
unit?: '' | 'date' | 'timeAgo' | 'min' | '%';
|
||||
unit?: '' | 'date' | 'timeAgo' | 'min' | '%' | 'currency';
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
active?: boolean;
|
||||
@@ -48,9 +49,28 @@ export function OverviewMetricCard({
|
||||
inverted = false,
|
||||
isLoading = false,
|
||||
}: MetricCardProps) {
|
||||
const [value, setValue] = useState(metric.current);
|
||||
const [currentIndex, setCurrentIndex] = useState<number | null>(null);
|
||||
const number = useNumber();
|
||||
const { current, previous } = metric;
|
||||
const timer = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current);
|
||||
}
|
||||
|
||||
if (currentIndex) {
|
||||
timer.current = setTimeout(() => {
|
||||
setCurrentIndex(null);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current);
|
||||
}
|
||||
};
|
||||
}, [currentIndex]);
|
||||
|
||||
const renderValue = (value: number, unitClassName?: string, short = true) => {
|
||||
if (unit === 'date') {
|
||||
@@ -65,6 +85,11 @@ export function OverviewMetricCard({
|
||||
return <>{fancyMinutes(value)}</>;
|
||||
}
|
||||
|
||||
if (unit === 'currency') {
|
||||
// Revenue is stored in cents, convert to dollars
|
||||
return <>{number.currency(value / 100)}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{short ? number.short(value) : number.format(value)}
|
||||
@@ -81,19 +106,33 @@ export function OverviewMetricCard({
|
||||
'#93c5fd', // blue
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltiper
|
||||
content={
|
||||
const renderTooltip = () => {
|
||||
if (currentIndex) {
|
||||
return (
|
||||
<span>
|
||||
{label}:{' '}
|
||||
{formatDate(new Date(data[currentIndex]?.date))}:{' '}
|
||||
<span className="font-semibold">
|
||||
{renderValue(value, 'ml-1 font-light text-xl', false)}
|
||||
{renderValue(
|
||||
data[currentIndex].current,
|
||||
'ml-1 font-light text-xl',
|
||||
false,
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
asChild
|
||||
sideOffset={-20}
|
||||
>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
{label}:{' '}
|
||||
<span className="font-semibold">
|
||||
{renderValue(metric.current, 'ml-1 font-light text-xl', false)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Tooltiper content={renderTooltip()} asChild sideOffset={-20}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
@@ -116,9 +155,7 @@ export function OverviewMetricCard({
|
||||
data={data}
|
||||
style={{ marginTop: (height / 4) * 3 }}
|
||||
onMouseMove={(event) => {
|
||||
setValue(
|
||||
event.activePayload?.[0]?.payload?.current ?? current,
|
||||
);
|
||||
setCurrentIndex(event.activeTooltipIndex ?? null);
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
import { useCookieStore } from '@/hooks/use-cookie-store';
|
||||
import { useDashedStroke } from '@/hooks/use-dashed-stroke';
|
||||
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
@@ -13,24 +12,22 @@ import { getPreviousMetric } from '@openpanel/common';
|
||||
import type { IInterval } from '@openpanel/validation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
|
||||
import { last, omit } from 'ramda';
|
||||
import { last } from 'ramda';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Area,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ComposedChart,
|
||||
Customized,
|
||||
Line,
|
||||
LineChart,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { useLocalStorage } from 'usehooks-ts';
|
||||
import { createChartTooltip } from '../charts/chart-tooltip';
|
||||
import { BarShapeBlue, BarShapeGrey } from '../charts/common-bar';
|
||||
import { useXAxisProps, useYAxisProps } from '../report-chart/common/axis';
|
||||
import { PreviousDiffIndicatorPure } from '../report-chart/common/previous-diff-indicator';
|
||||
import { Skeleton } from '../skeleton';
|
||||
@@ -78,6 +75,12 @@ const TITLES = [
|
||||
unit: 'min',
|
||||
inverted: false,
|
||||
},
|
||||
{
|
||||
title: 'Revenue',
|
||||
key: 'total_revenue',
|
||||
unit: 'currency',
|
||||
inverted: false,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
||||
@@ -86,11 +89,6 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
||||
const [filters] = useEventQueryFilters();
|
||||
const trpc = useTRPC();
|
||||
|
||||
const [chartType, setChartType] = useCookieStore<'bars' | 'lines'>(
|
||||
'chartType',
|
||||
'bars',
|
||||
);
|
||||
|
||||
const activeMetric = TITLES[metric]!;
|
||||
const overviewQuery = useQuery(
|
||||
trpc.overview.stats.queryOptions({
|
||||
@@ -125,6 +123,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
||||
}}
|
||||
unit={title.unit}
|
||||
data={data.map((item) => ({
|
||||
date: item.date,
|
||||
current: item[title.key],
|
||||
previous: item[`prev_${title.key}`],
|
||||
}))}
|
||||
@@ -136,7 +135,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'col-span-4 min-h-16 flex-1 p-4 pb-0 shadow-[0_0_0_0.5px] shadow-border max-md:row-start-1 md:col-span-2',
|
||||
'col-span-4 min-h-16 flex-1 p-4 pb-0 shadow-[0_0_0_0.5px] shadow-border max-md:row-start-1 md:col-span-1',
|
||||
)}
|
||||
>
|
||||
<OverviewLiveHistogram projectId={projectId} />
|
||||
@@ -148,32 +147,6 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
{activeMetric.title}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChartType('bars')}
|
||||
className={cn(
|
||||
'px-2 py-1 text-xs rounded transition-colors',
|
||||
chartType === 'bars'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
Bars
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChartType('lines')}
|
||||
className={cn(
|
||||
'px-2 py-1 text-xs rounded transition-colors',
|
||||
chartType === 'lines'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
Lines
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-[150px]">
|
||||
{overviewQuery.isLoading && <Skeleton className="h-full w-full" />}
|
||||
@@ -181,7 +154,6 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
||||
activeMetric={activeMetric}
|
||||
interval={interval}
|
||||
data={data}
|
||||
chartType={chartType}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
@@ -194,18 +166,25 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
||||
const { Tooltip, TooltipProvider } = createChartTooltip<
|
||||
RouterOutputs['overview']['stats']['series'][number],
|
||||
{
|
||||
anyMetric?: boolean;
|
||||
metric: (typeof TITLES)[number];
|
||||
interval: IInterval;
|
||||
}
|
||||
>(({ context: { metric, interval }, data: dataArray }) => {
|
||||
>(({ context: { metric, interval, anyMetric }, data: dataArray }) => {
|
||||
const data = dataArray[0];
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
const formatDate = useFormatDateInterval({
|
||||
interval,
|
||||
short: false,
|
||||
});
|
||||
const number = useNumber();
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const revenue = data.total_revenue ?? 0;
|
||||
const prevRevenue = data.prev_total_revenue ?? 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between gap-8 text-muted-foreground">
|
||||
@@ -215,16 +194,25 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
|
||||
<div className="flex gap-2">
|
||||
<div
|
||||
className="w-[3px] rounded-full"
|
||||
style={{ background: getChartColor(0) }}
|
||||
style={{ background: anyMetric ? getChartColor(0) : '#3ba974' }}
|
||||
/>
|
||||
<div className="col flex-1 gap-1">
|
||||
<div className="flex items-center gap-1">{metric.title}</div>
|
||||
<div className="flex justify-between gap-8 font-mono font-medium">
|
||||
<div className="row gap-1">
|
||||
{number.formatWithUnit(data[metric.key])}
|
||||
{metric.unit === 'currency'
|
||||
? number.currency((data[metric.key] ?? 0) / 100)
|
||||
: number.formatWithUnit(data[metric.key], metric.unit)}
|
||||
{!!data[`prev_${metric.key}`] && (
|
||||
<span className="text-muted-foreground">
|
||||
({number.formatWithUnit(data[`prev_${metric.key}`])})
|
||||
(
|
||||
{metric.unit === 'currency'
|
||||
? number.currency((data[`prev_${metric.key}`] ?? 0) / 100)
|
||||
: number.formatWithUnit(
|
||||
data[`prev_${metric.key}`],
|
||||
metric.unit,
|
||||
)}
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -238,6 +226,32 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{anyMetric && revenue > 0 && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<div
|
||||
className="w-[3px] rounded-full"
|
||||
style={{ background: '#3ba974' }}
|
||||
/>
|
||||
<div className="col flex-1 gap-1">
|
||||
<div className="flex items-center gap-1">Revenue</div>
|
||||
<div className="flex justify-between gap-8 font-mono font-medium">
|
||||
<div className="row gap-1">
|
||||
{number.currency(revenue / 100)}
|
||||
{prevRevenue > 0 && (
|
||||
<span className="text-muted-foreground">
|
||||
({number.currency(prevRevenue / 100)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{prevRevenue > 0 && (
|
||||
<PreviousDiffIndicatorPure
|
||||
{...getPreviousMetric(revenue, prevRevenue)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
</>
|
||||
);
|
||||
@@ -247,17 +261,19 @@ function Chart({
|
||||
activeMetric,
|
||||
interval,
|
||||
data,
|
||||
chartType,
|
||||
projectId,
|
||||
}: {
|
||||
activeMetric: (typeof TITLES)[number];
|
||||
interval: IInterval;
|
||||
data: RouterOutputs['overview']['stats']['series'];
|
||||
chartType: 'bars' | 'lines';
|
||||
projectId: string;
|
||||
}) {
|
||||
const xAxisProps = useXAxisProps({ interval });
|
||||
const yAxisProps = useYAxisProps();
|
||||
const number = useNumber();
|
||||
const revenueYAxisProps = useYAxisProps({
|
||||
tickFormatter: (value) => number.short(value / 100),
|
||||
});
|
||||
const [activeBar, setActiveBar] = useState(-1);
|
||||
const { range, startDate, endDate } = useOverviewOptions();
|
||||
|
||||
@@ -278,13 +294,11 @@ function Chart({
|
||||
|
||||
// Line chart specific logic
|
||||
let dotIndex = undefined;
|
||||
if (chartType === 'lines') {
|
||||
if (interval === 'hour') {
|
||||
// Find closest index based on times
|
||||
dotIndex = data.findIndex((item) => {
|
||||
return isSameHour(item.date, new Date());
|
||||
});
|
||||
}
|
||||
if (interval === 'hour') {
|
||||
// Find closest index based on times
|
||||
dotIndex = data.findIndex((item) => {
|
||||
return isSameHour(item.date, new Date());
|
||||
});
|
||||
}
|
||||
|
||||
const { calcStrokeDasharray, handleAnimationEnd, getStrokeDasharray } =
|
||||
@@ -294,6 +308,10 @@ function Chart({
|
||||
|
||||
const lastSerieDataItem = last(data)?.date || new Date();
|
||||
const useDashedLastLine = (() => {
|
||||
if (range === 'today') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (interval === 'hour') {
|
||||
return isSameHour(lastSerieDataItem, new Date());
|
||||
}
|
||||
@@ -313,11 +331,11 @@ function Chart({
|
||||
return false;
|
||||
})();
|
||||
|
||||
if (chartType === 'lines') {
|
||||
if (activeMetric.key === 'total_revenue') {
|
||||
return (
|
||||
<TooltipProvider metric={activeMetric} interval={interval}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data}>
|
||||
<ComposedChart data={data}>
|
||||
<Customized component={calcStrokeDasharray} />
|
||||
<Line
|
||||
dataKey="calcStrokeDasharray"
|
||||
@@ -326,13 +344,8 @@ function Chart({
|
||||
onAnimationEnd={handleAnimationEnd}
|
||||
/>
|
||||
<Tooltip />
|
||||
<YAxis
|
||||
{...yAxisProps}
|
||||
domain={[0, activeMetric.key === 'bounce_rate' ? 100 : 'dataMax']}
|
||||
width={25}
|
||||
/>
|
||||
<YAxis {...yAxisProps} domain={[0, 'dataMax']} width={25} />
|
||||
<XAxis {...xAxisProps} />
|
||||
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
horizontal={true}
|
||||
@@ -340,10 +353,30 @@ function Chart({
|
||||
className="stroke-border"
|
||||
/>
|
||||
|
||||
<defs>
|
||||
<filter
|
||||
id="rainbow-line-glow"
|
||||
x="-20%"
|
||||
y="-20%"
|
||||
width="140%"
|
||||
height="140%"
|
||||
>
|
||||
<feGaussianBlur stdDeviation="5" result="blur" />
|
||||
<feComponentTransfer in="blur" result="dimmedBlur">
|
||||
<feFuncA type="linear" slope="0.5" />
|
||||
</feComponentTransfer>
|
||||
<feComposite
|
||||
in="SourceGraphic"
|
||||
in2="dimmedBlur"
|
||||
operator="over"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<Line
|
||||
key={`prev_${activeMetric.key}`}
|
||||
type="linear"
|
||||
dataKey={`prev_${activeMetric.key}`}
|
||||
key={'prev_total_revenue'}
|
||||
type="monotone"
|
||||
dataKey={'prev_total_revenue'}
|
||||
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
|
||||
strokeWidth={2}
|
||||
isAnimationActive={false}
|
||||
@@ -352,24 +385,26 @@ function Chart({
|
||||
? false
|
||||
: {
|
||||
stroke: 'oklch(from var(--foreground) l c h / 0.1)',
|
||||
fill: 'var(--def-100)',
|
||||
fill: 'transparent',
|
||||
strokeWidth: 1.5,
|
||||
r: 2,
|
||||
}
|
||||
}
|
||||
activeDot={{
|
||||
stroke: 'oklch(from var(--foreground) l c h / 0.2)',
|
||||
fill: 'var(--def-100)',
|
||||
fill: 'transparent',
|
||||
strokeWidth: 1.5,
|
||||
r: 3,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Line
|
||||
key={activeMetric.key}
|
||||
type="linear"
|
||||
dataKey={activeMetric.key}
|
||||
stroke={getChartColor(0)}
|
||||
<Area
|
||||
key={'total_revenue'}
|
||||
type="monotone"
|
||||
dataKey={'total_revenue'}
|
||||
stroke={'#3ba974'}
|
||||
fill={'#3ba974'}
|
||||
fillOpacity={0.05}
|
||||
strokeWidth={2}
|
||||
strokeDasharray={
|
||||
useDashedLastLine
|
||||
@@ -381,18 +416,19 @@ function Chart({
|
||||
data.length > 90
|
||||
? false
|
||||
: {
|
||||
stroke: getChartColor(0),
|
||||
fill: 'var(--def-100)',
|
||||
stroke: '#3ba974',
|
||||
fill: '#3ba974',
|
||||
strokeWidth: 1.5,
|
||||
r: 3,
|
||||
}
|
||||
}
|
||||
activeDot={{
|
||||
stroke: getChartColor(0),
|
||||
stroke: '#3ba974',
|
||||
fill: 'var(--def-100)',
|
||||
strokeWidth: 2,
|
||||
r: 4,
|
||||
}}
|
||||
filter="url(#rainbow-line-glow)"
|
||||
/>
|
||||
|
||||
{references.data?.map((ref) => (
|
||||
@@ -410,36 +446,48 @@ function Chart({
|
||||
fontSize={10}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Bar chart (default)
|
||||
return (
|
||||
<TooltipProvider metric={activeMetric} interval={interval}>
|
||||
<TooltipProvider metric={activeMetric} interval={interval} anyMetric={true}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
<ComposedChart
|
||||
data={data}
|
||||
margin={{ top: 0, right: 0, left: 0, bottom: 10 }}
|
||||
onMouseMove={(e) => {
|
||||
setActiveBar(e.activeTooltipIndex ?? -1);
|
||||
}}
|
||||
barCategoryGap={2}
|
||||
>
|
||||
<Tooltip
|
||||
cursor={{
|
||||
stroke: 'var(--def-200)',
|
||||
fill: 'var(--def-200)',
|
||||
}}
|
||||
<Customized component={calcStrokeDasharray} />
|
||||
<Line
|
||||
dataKey="calcStrokeDasharray"
|
||||
legendType="none"
|
||||
animationDuration={0}
|
||||
onAnimationEnd={handleAnimationEnd}
|
||||
/>
|
||||
<Tooltip />
|
||||
<YAxis
|
||||
{...yAxisProps}
|
||||
domain={[0, activeMetric.key === 'bounce_rate' ? 100 : 'auto']}
|
||||
domain={[0, activeMetric.key === 'bounce_rate' ? 100 : 'dataMax']}
|
||||
width={25}
|
||||
/>
|
||||
<XAxis {...omit(['scale', 'type'], xAxisProps)} />
|
||||
<YAxis
|
||||
{...revenueYAxisProps}
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
domain={[
|
||||
0,
|
||||
data.reduce(
|
||||
(max, item) => Math.max(max, item.total_revenue ?? 0),
|
||||
0,
|
||||
) * 2,
|
||||
]}
|
||||
width={30}
|
||||
/>
|
||||
<XAxis {...xAxisProps} />
|
||||
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
@@ -448,21 +496,103 @@ function Chart({
|
||||
className="stroke-border"
|
||||
/>
|
||||
|
||||
<Bar
|
||||
<defs>
|
||||
<filter
|
||||
id="rainbow-line-glow"
|
||||
x="-20%"
|
||||
y="-20%"
|
||||
width="140%"
|
||||
height="140%"
|
||||
>
|
||||
<feGaussianBlur stdDeviation="5" result="blur" />
|
||||
<feComponentTransfer in="blur" result="dimmedBlur">
|
||||
<feFuncA type="linear" slope="0.5" />
|
||||
</feComponentTransfer>
|
||||
<feComposite
|
||||
in="SourceGraphic"
|
||||
in2="dimmedBlur"
|
||||
operator="over"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<Line
|
||||
key={`prev_${activeMetric.key}`}
|
||||
type="monotone"
|
||||
dataKey={`prev_${activeMetric.key}`}
|
||||
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
|
||||
strokeWidth={2}
|
||||
isAnimationActive={false}
|
||||
shape={(props: any) => (
|
||||
<BarShapeGrey isActive={activeBar === props.index} {...props} />
|
||||
)}
|
||||
dot={
|
||||
data.length > 90
|
||||
? false
|
||||
: {
|
||||
stroke: 'oklch(from var(--foreground) l c h / 0.1)',
|
||||
fill: 'transparent',
|
||||
strokeWidth: 1.5,
|
||||
r: 2,
|
||||
}
|
||||
}
|
||||
activeDot={{
|
||||
stroke: 'oklch(from var(--foreground) l c h / 0.2)',
|
||||
fill: 'transparent',
|
||||
strokeWidth: 1.5,
|
||||
r: 3,
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
key={activeMetric.key}
|
||||
dataKey={activeMetric.key}
|
||||
key="total_revenue"
|
||||
dataKey="total_revenue"
|
||||
yAxisId="right"
|
||||
stackId="revenue"
|
||||
isAnimationActive={false}
|
||||
shape={(props: any) => (
|
||||
<BarShapeBlue isActive={activeBar === props.index} {...props} />
|
||||
)}
|
||||
radius={5}
|
||||
maxBarSize={20}
|
||||
>
|
||||
{data.map((item, index) => {
|
||||
return (
|
||||
<Cell
|
||||
key={item.date}
|
||||
className={cn(
|
||||
index === activeBar
|
||||
? 'fill-emerald-700/100'
|
||||
: 'fill-emerald-700/80',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Bar>
|
||||
<Area
|
||||
key={activeMetric.key}
|
||||
type="monotone"
|
||||
dataKey={activeMetric.key}
|
||||
stroke={getChartColor(0)}
|
||||
fill={getChartColor(0)}
|
||||
fillOpacity={0.05}
|
||||
strokeWidth={2}
|
||||
strokeDasharray={
|
||||
useDashedLastLine
|
||||
? getStrokeDasharray(activeMetric.key)
|
||||
: undefined
|
||||
}
|
||||
isAnimationActive={false}
|
||||
dot={
|
||||
data.length > 90
|
||||
? false
|
||||
: {
|
||||
stroke: getChartColor(0),
|
||||
fill: 'transparent',
|
||||
strokeWidth: 1.5,
|
||||
r: 3,
|
||||
}
|
||||
}
|
||||
activeDot={{
|
||||
stroke: getChartColor(0),
|
||||
fill: 'var(--def-100)',
|
||||
strokeWidth: 2,
|
||||
r: 4,
|
||||
}}
|
||||
filter="url(#rainbow-line-glow)"
|
||||
/>
|
||||
|
||||
{references.data?.map((ref) => (
|
||||
@@ -480,7 +610,7 @@ function Chart({
|
||||
fontSize={10}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,43 @@ import { Skeleton } from '../skeleton';
|
||||
import { Tooltiper } from '../ui/tooltip';
|
||||
import { WidgetTable, type Props as WidgetTableProps } from '../widget-table';
|
||||
|
||||
function RevenuePieChart({ percentage }: { percentage: number }) {
|
||||
const size = 16;
|
||||
const strokeWidth = 2;
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - percentage * circumference;
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} className="flex-shrink-0">
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
className="text-def-200"
|
||||
/>
|
||||
{/* Revenue arc */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="#3ba974"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||
className="transition-all"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
type Props<T> = WidgetTableProps<T> & {
|
||||
getColumnPercentage: (item: T) => number;
|
||||
};
|
||||
@@ -45,9 +82,7 @@ export const OverviewWidgetTable = <T,>({
|
||||
index === 0
|
||||
? 'text-left w-full font-medium min-w-0'
|
||||
: 'text-right font-mono',
|
||||
index !== 0 &&
|
||||
index !== columns.length - 1 &&
|
||||
'hidden @[310px]:table-cell',
|
||||
// Remove old responsive logic - now handled by responsive prop
|
||||
column.className,
|
||||
),
|
||||
};
|
||||
@@ -119,12 +154,15 @@ export function OverviewWidgetTablePages({
|
||||
avg_duration: number;
|
||||
bounce_rate: number;
|
||||
sessions: number;
|
||||
revenue: number;
|
||||
}[];
|
||||
showDomain?: boolean;
|
||||
}) {
|
||||
const [_filters, setFilter] = useEventQueryFilters();
|
||||
const number = useNumber();
|
||||
const maxSessions = Math.max(...data.map((item) => item.sessions));
|
||||
const totalRevenue = data.reduce((sum, item) => sum + item.revenue, 0);
|
||||
const hasRevenue = data.some((item) => item.revenue > 0);
|
||||
return (
|
||||
<OverviewWidgetTable
|
||||
className={className}
|
||||
@@ -135,6 +173,7 @@ export function OverviewWidgetTablePages({
|
||||
{
|
||||
name: 'Path',
|
||||
width: 'w-full',
|
||||
responsive: { priority: 1 }, // Always visible
|
||||
render(item) {
|
||||
return (
|
||||
<Tooltiper asChild content={item.origin + item.path} side="left">
|
||||
@@ -178,6 +217,7 @@ export function OverviewWidgetTablePages({
|
||||
{
|
||||
name: 'BR',
|
||||
width: '60px',
|
||||
responsive: { priority: 6 }, // Hidden when space is tight
|
||||
render(item) {
|
||||
return number.shortWithUnit(item.bounce_rate, '%');
|
||||
},
|
||||
@@ -185,13 +225,41 @@ export function OverviewWidgetTablePages({
|
||||
{
|
||||
name: 'Duration',
|
||||
width: '75px',
|
||||
responsive: { priority: 7 }, // Hidden when space is tight
|
||||
render(item) {
|
||||
return number.shortWithUnit(item.avg_duration, 'min');
|
||||
},
|
||||
},
|
||||
...(hasRevenue
|
||||
? [
|
||||
{
|
||||
name: 'Revenue',
|
||||
width: '100px',
|
||||
responsive: { priority: 3 }, // Always show if possible
|
||||
render(item: (typeof data)[number]) {
|
||||
const revenuePercentage =
|
||||
totalRevenue > 0 ? item.revenue / totalRevenue : 0;
|
||||
return (
|
||||
<div className="row gap-2 items-center justify-end">
|
||||
<span
|
||||
className="font-semibold"
|
||||
style={{ color: '#3ba974' }}
|
||||
>
|
||||
{item.revenue > 0
|
||||
? number.currency(item.revenue / 100)
|
||||
: '-'}
|
||||
</span>
|
||||
<RevenuePieChart percentage={revenuePercentage} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
} as const,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: lastColumnName,
|
||||
width: '84px',
|
||||
responsive: { priority: 2 }, // Always show if possible
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
@@ -303,20 +371,24 @@ export function OverviewWidgetTableGeneric({
|
||||
}) {
|
||||
const number = useNumber();
|
||||
const maxSessions = Math.max(...data.map((item) => item.sessions));
|
||||
const totalRevenue = data.reduce((sum, item) => sum + (item.revenue ?? 0), 0);
|
||||
const hasRevenue = data.some((item) => (item.revenue ?? 0) > 0);
|
||||
return (
|
||||
<OverviewWidgetTable
|
||||
className={className}
|
||||
data={data ?? []}
|
||||
keyExtractor={(item) => item.name}
|
||||
keyExtractor={(item) => item.prefix + item.name}
|
||||
getColumnPercentage={(item) => item.sessions / maxSessions}
|
||||
columns={[
|
||||
{
|
||||
...column,
|
||||
width: 'w-full',
|
||||
responsive: { priority: 1 }, // Always visible
|
||||
},
|
||||
{
|
||||
name: 'BR',
|
||||
width: '60px',
|
||||
responsive: { priority: 6 }, // Hidden when space is tight
|
||||
render(item) {
|
||||
return number.shortWithUnit(item.bounce_rate, '%');
|
||||
},
|
||||
@@ -327,9 +399,38 @@ export function OverviewWidgetTableGeneric({
|
||||
// return number.shortWithUnit(item.avg_session_duration, 'min');
|
||||
// },
|
||||
// },
|
||||
|
||||
...(hasRevenue
|
||||
? [
|
||||
{
|
||||
name: 'Revenue',
|
||||
width: '100px',
|
||||
responsive: { priority: 3 }, // Always show if possible
|
||||
render(item: RouterOutputs['overview']['topGeneric'][number]) {
|
||||
const revenue = item.revenue ?? 0;
|
||||
const revenuePercentage =
|
||||
totalRevenue > 0 ? revenue / totalRevenue : 0;
|
||||
return (
|
||||
<div className="row gap-2 items-center justify-end">
|
||||
<span
|
||||
className="font-semibold"
|
||||
style={{ color: '#3ba974' }}
|
||||
>
|
||||
{revenue > 0
|
||||
? number.currency(revenue / 100, { short: true })
|
||||
: '-'}
|
||||
</span>
|
||||
<RevenuePieChart percentage={revenuePercentage} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
} as const,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: 'Sessions',
|
||||
width: '84px',
|
||||
responsive: { priority: 2 }, // Always show if possible
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
|
||||
@@ -12,72 +12,91 @@ const PROFILE_METRICS = [
|
||||
key: 'totalEvents',
|
||||
unit: '',
|
||||
inverted: false,
|
||||
hideOnZero: false,
|
||||
},
|
||||
{
|
||||
title: 'Sessions',
|
||||
key: 'sessions',
|
||||
unit: '',
|
||||
inverted: false,
|
||||
hideOnZero: false,
|
||||
},
|
||||
{
|
||||
title: 'Page Views',
|
||||
key: 'screenViews',
|
||||
unit: '',
|
||||
inverted: false,
|
||||
hideOnZero: false,
|
||||
},
|
||||
{
|
||||
title: 'Avg Events/Session',
|
||||
key: 'avgEventsPerSession',
|
||||
unit: '',
|
||||
inverted: false,
|
||||
hideOnZero: false,
|
||||
},
|
||||
{
|
||||
title: 'Bounce Rate',
|
||||
key: 'bounceRate',
|
||||
unit: '%',
|
||||
inverted: true,
|
||||
hideOnZero: false,
|
||||
},
|
||||
{
|
||||
title: 'Session Duration (Avg)',
|
||||
key: 'durationAvg',
|
||||
unit: 'min',
|
||||
inverted: false,
|
||||
hideOnZero: false,
|
||||
},
|
||||
{
|
||||
title: 'Session Duration (P90)',
|
||||
key: 'durationP90',
|
||||
unit: 'min',
|
||||
inverted: false,
|
||||
hideOnZero: false,
|
||||
},
|
||||
{
|
||||
title: 'First seen',
|
||||
key: 'firstSeen',
|
||||
unit: 'timeAgo',
|
||||
inverted: false,
|
||||
hideOnZero: false,
|
||||
},
|
||||
{
|
||||
title: 'Last seen',
|
||||
key: 'lastSeen',
|
||||
unit: 'timeAgo',
|
||||
inverted: false,
|
||||
hideOnZero: false,
|
||||
},
|
||||
{
|
||||
title: 'Days Active',
|
||||
key: 'uniqueDaysActive',
|
||||
unit: '',
|
||||
inverted: false,
|
||||
hideOnZero: false,
|
||||
},
|
||||
{
|
||||
title: 'Conversion Events',
|
||||
key: 'conversionEvents',
|
||||
unit: '',
|
||||
inverted: false,
|
||||
hideOnZero: false,
|
||||
},
|
||||
{
|
||||
title: 'Avg Time Between Sessions (h)',
|
||||
key: 'avgTimeBetweenSessions',
|
||||
unit: 'min',
|
||||
inverted: false,
|
||||
hideOnZero: false,
|
||||
},
|
||||
{
|
||||
title: 'Revenue',
|
||||
key: 'revenue',
|
||||
unit: 'currency',
|
||||
inverted: false,
|
||||
hideOnZero: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -85,7 +104,12 @@ export const ProfileMetrics = ({ data }: Props) => {
|
||||
return (
|
||||
<div className="relative col-span-6 -m-4 mb-0 mt-0 md:m-0">
|
||||
<div className="card grid grid-cols-2 overflow-hidden rounded-md md:grid-cols-4 lg:grid-cols-6">
|
||||
{PROFILE_METRICS.map((metric) => (
|
||||
{PROFILE_METRICS.filter((metric) => {
|
||||
if (metric.hideOnZero && data[metric.key] === 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).map((metric) => (
|
||||
<OverviewMetricCard
|
||||
key={metric.key}
|
||||
id={metric.key}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { shortNumber } from '@/hooks/use-numer-formatter';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
@@ -7,11 +7,11 @@ import type { IServiceProject } from '@openpanel/db';
|
||||
|
||||
import { cn } from '@/utils/cn';
|
||||
import { SettingsIcon, TrendingDownIcon, TrendingUpIcon } from 'lucide-react';
|
||||
import { ChartSSR } from '../chart-ssr';
|
||||
import { FadeIn } from '../fade-in';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { Skeleton } from '../skeleton';
|
||||
import { LinkButton } from '../ui/button';
|
||||
import { ProjectChart } from './project-chart';
|
||||
|
||||
export function ProjectCardRoot({
|
||||
children,
|
||||
@@ -60,7 +60,7 @@ function ProjectCard({ id, domain, name, organizationId }: IServiceProject) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mx-4 aspect-[8/1] mb-4">
|
||||
<ProjectChart id={id} />
|
||||
<ProjectChartOuter id={id} />
|
||||
</div>
|
||||
<div className="flex flex-1 gap-4 h-9 md:h-4">
|
||||
<ProjectMetrics id={id} />
|
||||
@@ -77,7 +77,7 @@ function ProjectCard({ id, domain, name, organizationId }: IServiceProject) {
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectChart({ id }: { id: string }) {
|
||||
function ProjectChartOuter({ id }: { id: string }) {
|
||||
const trpc = useTRPC();
|
||||
const { data } = useQuery(
|
||||
trpc.chart.projectCard.queryOptions({
|
||||
@@ -87,7 +87,7 @@ function ProjectChart({ id }: { id: string }) {
|
||||
|
||||
return (
|
||||
<FadeIn className="h-full w-full">
|
||||
<ChartSSR data={data?.chart || []} color={'blue'} />
|
||||
<ProjectChart data={data?.chart || []} color={'blue'} />
|
||||
</FadeIn>
|
||||
);
|
||||
}
|
||||
@@ -102,6 +102,7 @@ function Metric({ value, label }: { value: React.ReactNode; label: string }) {
|
||||
}
|
||||
|
||||
function ProjectMetrics({ id }: { id: string }) {
|
||||
const number = useNumber();
|
||||
const trpc = useTRPC();
|
||||
const { data } = useQuery(
|
||||
trpc.chart.projectCard.queryOptions({
|
||||
@@ -138,16 +139,18 @@ function ProjectMetrics({ id }: { id: string }) {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!!data?.metrics?.revenue && (
|
||||
<Metric
|
||||
label="Revenue"
|
||||
value={number.currency(data?.metrics?.revenue / 100, {
|
||||
short: true,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Metric
|
||||
label="3M"
|
||||
value={shortNumber('en')(data?.metrics?.months_3 ?? 0)}
|
||||
/>
|
||||
<Metric
|
||||
label="30D"
|
||||
value={shortNumber('en')(data?.metrics?.month ?? 0)}
|
||||
/>
|
||||
<Metric label="24H" value={shortNumber('en')(data?.metrics?.day ?? 0)} />
|
||||
<Metric label="3M" value={number.short(data?.metrics?.months_3 ?? 0)} />
|
||||
<Metric label="30D" value={number.short(data?.metrics?.month ?? 0)} />
|
||||
<Metric label="24H" value={number.short(data?.metrics?.day ?? 0)} />
|
||||
</FadeIn>
|
||||
);
|
||||
}
|
||||
|
||||
215
apps/start/src/components/projects/project-chart.tsx
Normal file
215
apps/start/src/components/projects/project-chart.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import {
|
||||
ChartTooltipHeader,
|
||||
ChartTooltipItem,
|
||||
createChartTooltip,
|
||||
} from '@/components/charts/chart-tooltip';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Bar,
|
||||
Cell,
|
||||
ComposedChart,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
type ChartDataItem = {
|
||||
value: number;
|
||||
date: Date;
|
||||
revenue: number;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
const { Tooltip, TooltipProvider } = createChartTooltip<
|
||||
ChartDataItem,
|
||||
{
|
||||
color: 'blue' | 'green' | 'red';
|
||||
}
|
||||
>(
|
||||
({
|
||||
context,
|
||||
data: dataArray,
|
||||
}: {
|
||||
context: { color: 'blue' | 'green' | 'red' };
|
||||
data: ChartDataItem[];
|
||||
}) => {
|
||||
const { color } = context;
|
||||
const data = dataArray[0];
|
||||
const number = useNumber();
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getColorValue = () => {
|
||||
if (color === 'green') return '#16a34a';
|
||||
if (color === 'red') return '#dc2626';
|
||||
return getChartColor(0);
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChartTooltipHeader>
|
||||
<div className="text-muted-foreground">{formatDate(data.date)}</div>
|
||||
</ChartTooltipHeader>
|
||||
<ChartTooltipItem
|
||||
color={getColorValue()}
|
||||
innerClassName="row justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-1">Sessions</div>
|
||||
<div className="font-mono font-bold">{number.format(data.value)}</div>
|
||||
</ChartTooltipItem>
|
||||
{data.revenue > 0 && (
|
||||
<ChartTooltipItem color="#3ba974">
|
||||
<div className="flex items-center gap-1">Revenue</div>
|
||||
<div className="font-mono font-medium">
|
||||
{number.currency(data.revenue / 100)}
|
||||
</div>
|
||||
</ChartTooltipItem>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export function ProjectChart({
|
||||
data,
|
||||
dots = false,
|
||||
color = 'blue',
|
||||
}: {
|
||||
dots?: boolean;
|
||||
color?: 'blue' | 'green' | 'red';
|
||||
data: { value: number; date: Date; revenue: number }[];
|
||||
}) {
|
||||
const [activeBar, setActiveBar] = useState(-1);
|
||||
|
||||
if (data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Transform data for Recharts (needs timestamp for time-based x-axis)
|
||||
const chartData = data.map((item) => ({
|
||||
...item,
|
||||
timestamp: item.date.getTime(),
|
||||
}));
|
||||
|
||||
const maxValue = Math.max(...data.map((d) => d.value), 0);
|
||||
const maxRevenue = Math.max(...data.map((d) => d.revenue), 0);
|
||||
|
||||
const getColorValue = () => {
|
||||
if (color === 'green') return '#16a34a';
|
||||
if (color === 'red') return '#dc2626';
|
||||
return getChartColor(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<TooltipProvider color={color}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart
|
||||
data={chartData}
|
||||
margin={{ top: 10, right: 10, bottom: 10, left: 10 }}
|
||||
onMouseMove={(e) => {
|
||||
setActiveBar(e.activeTooltipIndex ?? -1);
|
||||
}}
|
||||
>
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
type="number"
|
||||
scale="time"
|
||||
domain={['dataMin', 'dataMax']}
|
||||
hide
|
||||
/>
|
||||
<YAxis domain={[0, maxValue || 'dataMax']} hide width={0} />
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
domain={[0, maxRevenue * 2 || 'dataMax']}
|
||||
hide
|
||||
width={0}
|
||||
/>
|
||||
|
||||
<Tooltip />
|
||||
|
||||
<defs>
|
||||
<filter
|
||||
id="rainbow-line-glow"
|
||||
x="-20%"
|
||||
y="-20%"
|
||||
width="140%"
|
||||
height="140%"
|
||||
>
|
||||
<feGaussianBlur stdDeviation="5" result="blur" />
|
||||
<feComponentTransfer in="blur" result="dimmedBlur">
|
||||
<feFuncA type="linear" slope="0.5" />
|
||||
</feComponentTransfer>
|
||||
<feComposite
|
||||
in="SourceGraphic"
|
||||
in2="dimmedBlur"
|
||||
operator="over"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={getColorValue()}
|
||||
strokeWidth={2}
|
||||
isAnimationActive={false}
|
||||
dot={
|
||||
dots && data.length <= 90
|
||||
? {
|
||||
stroke: getColorValue(),
|
||||
fill: 'transparent',
|
||||
strokeWidth: 1.5,
|
||||
r: 3,
|
||||
}
|
||||
: false
|
||||
}
|
||||
activeDot={{
|
||||
stroke: getColorValue(),
|
||||
fill: 'var(--def-100)',
|
||||
strokeWidth: 2,
|
||||
r: 4,
|
||||
}}
|
||||
filter="url(#rainbow-line-glow)"
|
||||
/>
|
||||
|
||||
<Bar
|
||||
dataKey="revenue"
|
||||
yAxisId="right"
|
||||
stackId="revenue"
|
||||
isAnimationActive={false}
|
||||
radius={5}
|
||||
maxBarSize={20}
|
||||
>
|
||||
{chartData.map((item, index) => (
|
||||
<Cell
|
||||
key={item.timestamp}
|
||||
className={cn(
|
||||
index === activeBar
|
||||
? 'fill-emerald-700/100'
|
||||
: 'fill-emerald-700/80',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,126 +1,99 @@
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import type { IChartProps } from '@openpanel/validation';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import * as Portal from '@radix-ui/react-portal';
|
||||
import { bind } from 'bind-event-listener';
|
||||
import throttle from 'lodash.throttle';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { AnimatedNumber } from '../animated-number';
|
||||
import { BarShapeBlue } from '../charts/common-bar';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
|
||||
interface RealtimeLiveHistogramProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export const getReport = (projectId: string): IChartProps => {
|
||||
return {
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'user',
|
||||
filters: [],
|
||||
name: '*',
|
||||
displayName: 'Active users',
|
||||
},
|
||||
],
|
||||
chartType: 'histogram',
|
||||
interval: 'minute',
|
||||
range: '30min',
|
||||
name: '',
|
||||
metric: 'sum',
|
||||
breakdowns: [],
|
||||
lineType: 'monotone',
|
||||
previous: false,
|
||||
};
|
||||
};
|
||||
|
||||
export const getCountReport = (projectId: string): IChartProps => {
|
||||
return {
|
||||
name: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'user',
|
||||
filters: [],
|
||||
id: 'A',
|
||||
name: 'session_start',
|
||||
},
|
||||
],
|
||||
breakdowns: [],
|
||||
chartType: 'metric',
|
||||
lineType: 'monotone',
|
||||
interval: 'minute',
|
||||
range: '30min',
|
||||
previous: false,
|
||||
metric: 'sum',
|
||||
};
|
||||
};
|
||||
|
||||
export function RealtimeLiveHistogram({
|
||||
projectId,
|
||||
}: RealtimeLiveHistogramProps) {
|
||||
const report = getReport(projectId);
|
||||
const countReport = getCountReport(projectId);
|
||||
|
||||
const trpc = useTRPC();
|
||||
const res = useQuery(trpc.chart.chart.queryOptions(report));
|
||||
const countRes = useQuery(trpc.chart.chart.queryOptions(countReport));
|
||||
|
||||
const metrics = res.data?.series[0]?.metrics;
|
||||
const minutes = (res.data?.series[0]?.data || []).slice(-30);
|
||||
const liveCount = countRes.data?.series[0]?.metrics?.sum ?? 0;
|
||||
// Use the same liveData endpoint as overview
|
||||
const { data: liveData, isLoading } = useQuery(
|
||||
trpc.overview.liveData.queryOptions({ projectId }),
|
||||
);
|
||||
|
||||
if (res.isInitialLoading || countRes.isInitialLoading || liveCount === 0) {
|
||||
const staticArray = [
|
||||
10, 25, 30, 45, 20, 5, 55, 18, 40, 12, 50, 35, 8, 22, 38, 42, 15, 28, 52,
|
||||
5, 48, 14, 32, 58, 7, 19, 33, 56, 24, 5,
|
||||
];
|
||||
const chartData = liveData?.minuteCounts ?? [];
|
||||
// Calculate total unique visitors (sum of unique visitors per minute)
|
||||
// Note: This is an approximation - ideally we'd want unique visitors across all minutes
|
||||
const totalVisitors = liveData?.totalSessions ?? 0;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Wrapper count={0}>
|
||||
{staticArray.map((percent, i) => (
|
||||
<div
|
||||
key={i as number}
|
||||
className="flex-1 animate-pulse rounded-sm bg-def-200"
|
||||
style={{ height: `${percent}%` }}
|
||||
/>
|
||||
))}
|
||||
<div className="h-full w-full animate-pulse bg-def-200 rounded" />
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (!res.isSuccess && !countRes.isSuccess) {
|
||||
if (!liveData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxDomain =
|
||||
Math.max(...chartData.map((item) => item.visitorCount), 0) * 1.2 || 1;
|
||||
|
||||
return (
|
||||
<Wrapper count={liveCount}>
|
||||
{minutes.map((minute) => {
|
||||
return (
|
||||
<Tooltip key={minute.date}>
|
||||
<TooltipTrigger asChild>
|
||||
<Wrapper
|
||||
count={totalVisitors}
|
||||
icons={
|
||||
liveData.referrers && liveData.referrers.length > 0 ? (
|
||||
<div className="row gap-2">
|
||||
{liveData.referrers.slice(0, 3).map((ref, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 rounded-sm transition-all ease-in-out hover:scale-110',
|
||||
minute.count === 0 ? 'bg-def-200' : 'bg-highlight',
|
||||
)}
|
||||
style={{
|
||||
height:
|
||||
minute.count === 0
|
||||
? '20%'
|
||||
: `${(minute.count / metrics!.max) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<div>{minute.count} active users</div>
|
||||
<div>@ {new Date(minute.date).toLocaleTimeString()}</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
key={`${ref.referrer}-${ref.count}-${index}`}
|
||||
className="font-bold text-xs row gap-1 items-center"
|
||||
>
|
||||
<SerieIcon name={ref.referrer} />
|
||||
<span>{ref.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
|
||||
>
|
||||
<Tooltip
|
||||
content={CustomTooltip}
|
||||
cursor={{
|
||||
fill: 'var(--def-200)',
|
||||
}}
|
||||
/>
|
||||
<XAxis dataKey="time" axisLine={false} tickLine={false} hide />
|
||||
<YAxis hide domain={[0, maxDomain]} />
|
||||
<Bar
|
||||
dataKey="visitorCount"
|
||||
fill="rgba(59, 121, 255, 0.2)"
|
||||
isAnimationActive={false}
|
||||
shape={BarShapeBlue}
|
||||
activeBar={BarShapeBlue}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
@@ -128,22 +101,144 @@ export function RealtimeLiveHistogram({
|
||||
interface WrapperProps {
|
||||
children: React.ReactNode;
|
||||
count: number;
|
||||
icons?: React.ReactNode;
|
||||
}
|
||||
|
||||
function Wrapper({ children, count }: WrapperProps) {
|
||||
function Wrapper({ children, count, icons }: WrapperProps) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="col gap-2 p-4">
|
||||
<div className="font-medium text-muted-foreground">
|
||||
Unique vistors last 30 minutes
|
||||
<div className="row gap-2 justify-between mb-2">
|
||||
<div className="relative text-sm font-medium text-muted-foreground leading-normal">
|
||||
Unique visitors {icons ? <br /> : null}
|
||||
last 30 min
|
||||
</div>
|
||||
<div>{icons}</div>
|
||||
</div>
|
||||
<div className="col gap-2 mb-4">
|
||||
<div className="font-mono text-6xl font-bold">
|
||||
<AnimatedNumber value={count} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex aspect-[6/1] w-full flex-1 items-end gap-0.5">
|
||||
{children}
|
||||
</div>
|
||||
<div className="relative aspect-[6/1] w-full">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom tooltip component that uses portals to escape overflow hidden
|
||||
const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
||||
const number = useNumber();
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const inactive = !active || !payload?.length;
|
||||
useEffect(() => {
|
||||
const setPositionThrottled = throttle(setPosition, 50);
|
||||
const unsubMouseMove = bind(window, {
|
||||
type: 'mousemove',
|
||||
listener(event) {
|
||||
if (!inactive) {
|
||||
setPositionThrottled({ x: event.clientX, y: event.clientY + 20 });
|
||||
}
|
||||
},
|
||||
});
|
||||
const unsubDragEnter = bind(window, {
|
||||
type: 'pointerdown',
|
||||
listener() {
|
||||
setPosition(null);
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubMouseMove();
|
||||
unsubDragEnter();
|
||||
};
|
||||
}, [inactive]);
|
||||
|
||||
if (inactive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!active || !payload || !payload.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = payload[0].payload;
|
||||
|
||||
const tooltipWidth = 200;
|
||||
const correctXPosition = (x: number | undefined) => {
|
||||
if (!x) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const screenWidth = window.innerWidth;
|
||||
const newX = x;
|
||||
|
||||
if (newX + tooltipWidth > screenWidth) {
|
||||
return screenWidth - tooltipWidth;
|
||||
}
|
||||
return newX;
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal.Portal
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: position?.y,
|
||||
left: correctXPosition(position?.x),
|
||||
zIndex: 1000,
|
||||
width: tooltipWidth,
|
||||
}}
|
||||
className="bg-background/80 p-3 rounded-md border shadow-xl backdrop-blur-sm"
|
||||
>
|
||||
<div className="flex justify-between gap-8 text-muted-foreground">
|
||||
<div>{data.time}</div>
|
||||
</div>
|
||||
<React.Fragment>
|
||||
<div className="flex gap-2">
|
||||
<div
|
||||
className="w-[3px] rounded-full"
|
||||
style={{ background: getChartColor(0) }}
|
||||
/>
|
||||
<div className="col flex-1 gap-1">
|
||||
<div className="flex items-center gap-1">Active users</div>
|
||||
<div className="flex justify-between gap-8 font-mono font-medium">
|
||||
<div className="row gap-1">
|
||||
{number.formatWithUnit(data.visitorCount)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{data.referrers && data.referrers.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-border">
|
||||
<div className="text-xs text-muted-foreground mb-2">Referrers:</div>
|
||||
<div className="space-y-1">
|
||||
{data.referrers.slice(0, 3).map((ref: any, index: number) => (
|
||||
<div
|
||||
key={`${ref.referrer}-${ref.count}-${index}`}
|
||||
className="row items-center justify-between text-xs"
|
||||
>
|
||||
<div className="row items-center gap-1">
|
||||
<SerieIcon name={ref.referrer} />
|
||||
<span
|
||||
className="truncate max-w-[120px]"
|
||||
title={ref.referrer}
|
||||
>
|
||||
{ref.referrer}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono">{ref.count}</span>
|
||||
</div>
|
||||
))}
|
||||
{data.referrers.length > 3 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
+{data.referrers.length - 3} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
</Portal.Portal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import useWS from '@/hooks/use-ws';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { getCountReport, getReport } from './realtime-live-histogram';
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
@@ -17,11 +16,15 @@ const RealtimeReloader = ({ projectId }: Props) => {
|
||||
if (!document.hidden) {
|
||||
client.refetchQueries(trpc.realtime.pathFilter());
|
||||
client.refetchQueries(
|
||||
trpc.chart.chart.queryFilter(getReport(projectId)),
|
||||
trpc.overview.liveData.queryFilter({ projectId }),
|
||||
);
|
||||
client.refetchQueries(
|
||||
trpc.chart.chart.queryFilter(getCountReport(projectId)),
|
||||
trpc.realtime.activeSessions.queryFilter({ projectId }),
|
||||
);
|
||||
client.refetchQueries(
|
||||
trpc.realtime.referrals.queryFilter({ projectId }),
|
||||
);
|
||||
client.refetchQueries(trpc.realtime.paths.queryFilter({ projectId }));
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useRef, useState } from 'react';
|
||||
import type { AxisDomain } from 'recharts/types/util/types';
|
||||
|
||||
import type { IInterval } from '@openpanel/validation';
|
||||
|
||||
export const AXIS_FONT_PROPS = {
|
||||
fontSize: 8,
|
||||
className: 'font-mono',
|
||||
@@ -69,9 +68,11 @@ export const useXAxisProps = (
|
||||
interval: 'auto',
|
||||
},
|
||||
) => {
|
||||
const formatDate = useFormatDateInterval(
|
||||
interval === 'auto' ? 'day' : interval,
|
||||
);
|
||||
const formatDate = useFormatDateInterval({
|
||||
interval: interval === 'auto' ? 'day' : interval,
|
||||
short: true,
|
||||
});
|
||||
|
||||
return {
|
||||
...X_AXIS_STYLE_PROPS,
|
||||
height: hide ? 0 : X_AXIS_STYLE_PROPS.height,
|
||||
|
||||
@@ -62,7 +62,10 @@ export const ReportChartTooltip = createChartTooltip<Data, Context>(
|
||||
const {
|
||||
report: { interval, unit },
|
||||
} = useReportChartContext();
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
const formatDate = useFormatDateInterval({
|
||||
interval,
|
||||
short: false,
|
||||
});
|
||||
const number = useNumber();
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
|
||||
@@ -40,7 +40,10 @@ export function ReportTable({
|
||||
const number = useNumber();
|
||||
const interval = useSelector((state) => state.report.interval);
|
||||
const breakdowns = useSelector((state) => state.report.breakdowns);
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
const formatDate = useFormatDateInterval({
|
||||
interval,
|
||||
short: true,
|
||||
});
|
||||
|
||||
function handleChange(name: string, checked: boolean) {
|
||||
setVisibleSeries((prev) => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { pushModal } from '@/modals';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
@@ -10,8 +12,6 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { pushModal } from '@/modals';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { createChartTooltip } from '@/components/charts/chart-tooltip';
|
||||
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
|
||||
@@ -171,7 +171,10 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
|
||||
}
|
||||
|
||||
const { date } = data[0];
|
||||
const formatDate = useFormatDateInterval(context.interval);
|
||||
const formatDate = useFormatDateInterval({
|
||||
interval: context.interval,
|
||||
short: false,
|
||||
});
|
||||
const number = useNumber();
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -26,6 +26,7 @@ const validator = zProject.pick({
|
||||
domain: true,
|
||||
cors: true,
|
||||
crossDomain: true,
|
||||
allowUnsafeRevenueTracking: true,
|
||||
});
|
||||
type IForm = z.infer<typeof validator>;
|
||||
|
||||
@@ -39,6 +40,7 @@ export default function EditProjectDetails({ project }: Props) {
|
||||
domain: project.domain,
|
||||
cors: project.cors,
|
||||
crossDomain: project.crossDomain,
|
||||
allowUnsafeRevenueTracking: project.allowUnsafeRevenueTracking,
|
||||
},
|
||||
});
|
||||
const trpc = useTRPC();
|
||||
@@ -155,22 +157,45 @@ export default function EditProjectDetails({ project }: Props) {
|
||||
control={form.control}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<WithLabel label="Cross domain support" className="mt-4">
|
||||
<CheckboxInput
|
||||
ref={field.ref}
|
||||
onBlur={field.onBlur}
|
||||
defaultChecked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
>
|
||||
<div>Enable cross domain support</div>
|
||||
<div className="font-normal text-muted-foreground">
|
||||
This will let you track users across multiple domains
|
||||
</div>
|
||||
</CheckboxInput>
|
||||
</WithLabel>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</AnimateHeight>
|
||||
|
||||
<Controller
|
||||
name="allowUnsafeRevenueTracking"
|
||||
control={form.control}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<WithLabel label="Revenue tracking">
|
||||
<CheckboxInput
|
||||
className="mt-4"
|
||||
ref={field.ref}
|
||||
onBlur={field.onBlur}
|
||||
defaultChecked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
>
|
||||
<div>Enable cross domain support</div>
|
||||
<div>Allow "unsafe" revenue tracking</div>
|
||||
<div className="font-normal text-muted-foreground">
|
||||
This will let you track users across multiple domains
|
||||
With this enabled, you can track revenue from client code.
|
||||
</div>
|
||||
</CheckboxInput>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</AnimateHeight>
|
||||
</WithLabel>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
loading={mutation.isPending}
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
import React from 'react';
|
||||
|
||||
export type ColumnPriority = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10;
|
||||
|
||||
export interface ColumnResponsive {
|
||||
/**
|
||||
* Priority determines the order columns are hidden.
|
||||
* Lower numbers = higher priority (hidden last).
|
||||
* Higher numbers = lower priority (hidden first).
|
||||
* Default: 5 (medium priority)
|
||||
*/
|
||||
priority?: ColumnPriority;
|
||||
/**
|
||||
* Minimum container width (in pixels) at which this column should be visible.
|
||||
* If not specified, uses priority-based breakpoints.
|
||||
*/
|
||||
minWidth?: number;
|
||||
}
|
||||
|
||||
export interface Props<T> {
|
||||
columns: {
|
||||
@@ -6,6 +24,11 @@ export interface Props<T> {
|
||||
render: (item: T, index: number) => React.ReactNode;
|
||||
className?: string;
|
||||
width: string;
|
||||
/**
|
||||
* Responsive settings for this column.
|
||||
* If not provided, column is always visible.
|
||||
*/
|
||||
responsive?: ColumnResponsive;
|
||||
}[];
|
||||
keyExtractor: (item: T) => string;
|
||||
data: T[];
|
||||
@@ -33,6 +56,44 @@ export const WidgetTableHead = ({
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates container query class based on priority.
|
||||
* Lower priority numbers = hidden at smaller widths.
|
||||
* Priority 1 = always visible (highest priority)
|
||||
* Priority 10 = hidden first (lowest priority)
|
||||
*/
|
||||
function getResponsiveClass(priority: ColumnPriority): string {
|
||||
// Priority 1 = always visible (no hiding)
|
||||
if (priority === 1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Columns will be hidden via CSS container queries
|
||||
// Return empty string - hiding is handled by CSS
|
||||
return '';
|
||||
}
|
||||
|
||||
function getResponsiveStyle(
|
||||
priority: ColumnPriority,
|
||||
): React.CSSProperties | undefined {
|
||||
if (priority === 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const minWidth = (priority - 1) * 100 + 100;
|
||||
return {
|
||||
// Use CSS custom property for container query
|
||||
// Will be handled by inline style with container query
|
||||
} as React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates container query class based on custom min-width.
|
||||
*/
|
||||
function getMinWidthClass(minWidth: number): string {
|
||||
return 'hidden';
|
||||
}
|
||||
|
||||
export function WidgetTable<T>({
|
||||
className,
|
||||
columns,
|
||||
@@ -49,29 +110,91 @@ export function WidgetTable<T>({
|
||||
.join(' ')}`
|
||||
: '1fr';
|
||||
|
||||
const containerId = React.useMemo(
|
||||
() => `widget-table-${Math.random().toString(36).substring(7)}`,
|
||||
[],
|
||||
);
|
||||
|
||||
// Generate CSS for container queries
|
||||
const containerQueryStyles = React.useMemo(() => {
|
||||
const styles: string[] = [];
|
||||
|
||||
columns.forEach((column) => {
|
||||
if (
|
||||
column.responsive?.priority !== undefined &&
|
||||
column.responsive.priority > 1
|
||||
) {
|
||||
// Breakpoints - Priority 2 = 150px, Priority 3 = 250px, etc.
|
||||
// Less aggressive: columns show at smaller container widths
|
||||
const minWidth = (column.responsive.priority - 1) * 100 + 50;
|
||||
// Hide by default by collapsing width and hiding content
|
||||
// Keep in grid flow but take up minimal space
|
||||
styles.push(
|
||||
`.${containerId} .cell[data-priority="${column.responsive.priority}"] { min-width: 0; max-width: 0; padding-left: 0; padding-right: 0; overflow: hidden; visibility: hidden; }`,
|
||||
`@container (min-width: ${minWidth}px) { .${containerId} .cell[data-priority="${column.responsive.priority}"] { min-width: revert; max-width: revert; padding-left: revert; padding-right: 0.5rem; overflow: revert; visibility: visible !important; } }`,
|
||||
);
|
||||
} else if (column.responsive?.minWidth !== undefined) {
|
||||
styles.push(
|
||||
`.${containerId} .cell[data-min-width="${column.responsive.minWidth}"] { min-width: 0; max-width: 0; padding-left: 0; padding-right: 0; overflow: hidden; visibility: hidden; }`,
|
||||
`@container (min-width: ${column.responsive.minWidth}px) { .${containerId} .cell[data-min-width="${column.responsive.minWidth}"] { min-width: revert; max-width: revert; padding-left: revert; padding-right: 0.5rem; overflow: revert; visibility: visible !important; } }`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure last visible cell always has padding-right
|
||||
styles.push(
|
||||
`.${containerId} .cell:last-child { padding-right: 1rem !important; }`,
|
||||
);
|
||||
|
||||
return styles.length > 0 ? <style>{styles.join('\n')}</style> : null;
|
||||
}, [columns, containerId]);
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<div className={cn('w-full', className)}>
|
||||
<div
|
||||
className={cn('w-full', className, containerId)}
|
||||
style={{ containerType: 'inline-size' }}
|
||||
>
|
||||
{containerQueryStyles}
|
||||
{/* Header */}
|
||||
<div
|
||||
className={cn('grid border-b border-border head', columnClassName)}
|
||||
style={{ gridTemplateColumns }}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<div
|
||||
key={column.name?.toString()}
|
||||
className={cn(
|
||||
'p-2 font-medium font-sans text-sm whitespace-nowrap cell',
|
||||
columns.length > 1 && column !== columns[0]
|
||||
? 'text-right'
|
||||
: 'text-left',
|
||||
column.className,
|
||||
)}
|
||||
style={{ width: column.width }}
|
||||
>
|
||||
{column.name}
|
||||
</div>
|
||||
))}
|
||||
{columns.map((column, colIndex) => {
|
||||
const responsiveClass =
|
||||
column.responsive?.priority !== undefined
|
||||
? getResponsiveClass(column.responsive.priority)
|
||||
: column.responsive?.minWidth !== undefined
|
||||
? getMinWidthClass(column.responsive.minWidth)
|
||||
: '';
|
||||
|
||||
const dataAttrs: Record<string, string> = {};
|
||||
if (column.responsive?.priority !== undefined) {
|
||||
dataAttrs['data-priority'] = String(column.responsive.priority);
|
||||
}
|
||||
if (column.responsive?.minWidth !== undefined) {
|
||||
dataAttrs['data-min-width'] = String(column.responsive.minWidth);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={column.name?.toString()}
|
||||
className={cn(
|
||||
'p-2 font-medium font-sans text-sm whitespace-nowrap cell',
|
||||
columns.length > 1 && column !== columns[0]
|
||||
? 'text-right'
|
||||
: 'text-left',
|
||||
column.className,
|
||||
responsiveClass,
|
||||
)}
|
||||
style={{ width: column.width }}
|
||||
{...dataAttrs}
|
||||
>
|
||||
{column.name}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
@@ -89,24 +212,47 @@ export function WidgetTable<T>({
|
||||
className="grid h-8 items-center"
|
||||
style={{ gridTemplateColumns }}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<div
|
||||
key={column.name?.toString()}
|
||||
className={cn(
|
||||
'px-2 relative cell',
|
||||
columns.length > 1 && column !== columns[0]
|
||||
? 'text-right'
|
||||
: 'text-left',
|
||||
column.className,
|
||||
column.width === 'w-full' && 'w-full min-w-0',
|
||||
)}
|
||||
style={
|
||||
column.width !== 'w-full' ? { width: column.width } : {}
|
||||
}
|
||||
>
|
||||
{column.render(item, index)}
|
||||
</div>
|
||||
))}
|
||||
{columns.map((column, colIndex) => {
|
||||
const responsiveClass =
|
||||
column.responsive?.priority !== undefined
|
||||
? getResponsiveClass(column.responsive.priority)
|
||||
: column.responsive?.minWidth !== undefined
|
||||
? getMinWidthClass(column.responsive.minWidth)
|
||||
: '';
|
||||
|
||||
const dataAttrs: Record<string, string> = {};
|
||||
if (column.responsive?.priority !== undefined) {
|
||||
dataAttrs['data-priority'] = String(
|
||||
column.responsive.priority,
|
||||
);
|
||||
}
|
||||
if (column.responsive?.minWidth !== undefined) {
|
||||
dataAttrs['data-min-width'] = String(
|
||||
column.responsive.minWidth,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={column.name?.toString()}
|
||||
className={cn(
|
||||
'px-2 relative cell',
|
||||
columns.length > 1 && column !== columns[0]
|
||||
? 'text-right'
|
||||
: 'text-left',
|
||||
column.className,
|
||||
column.width === 'w-full' && 'w-full min-w-0',
|
||||
responsiveClass,
|
||||
)}
|
||||
style={
|
||||
column.width !== 'w-full' ? { width: column.width } : {}
|
||||
}
|
||||
{...dataAttrs}
|
||||
>
|
||||
{column.render(item, index)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user