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:
Carl-Gerhard Lindesvärd
2025-11-19 14:27:34 +01:00
committed by GitHub
parent d61cbf6f2c
commit 790801b728
58 changed files with 2191 additions and 23691 deletions

View File

@@ -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>
);
}

View File

@@ -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>
);
};

View File

@@ -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',

View File

@@ -48,6 +48,10 @@ export const EventIconRecords: Record<
icon: 'ExternalLinkIcon',
color: 'indigo',
},
revenue: {
icon: 'DollarSignIcon',
color: 'green',
},
};
export const EventIconMapper: Record<string, LucideIcon> = {

View File

@@ -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} />}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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">

View File

@@ -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}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
};

View File

@@ -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 }));
}
},
{

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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) => {

View File

@@ -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 (
<>

View File

@@ -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}

View File

@@ -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>
))}