fix: a lot of minor improvements for dashboard

This commit is contained in:
Carl-Gerhard Lindesvärd
2025-11-07 12:28:54 +01:00
parent 5b1c582023
commit c762bd7c95
19 changed files with 591 additions and 388 deletions

View File

@@ -1,18 +1,21 @@
import { useRechartDataModel } from '@/hooks/use-rechart-data-model';
import { useVisibleSeries } from '@/hooks/use-visible-series';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import type { IChartData } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
import { useQuery } from '@tanstack/react-query';
import { isSameDay, isSameHour, isSameMonth } from 'date-fns';
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
import { last } from 'ramda';
import React, { useCallback } from 'react';
import {
Area,
CartesianGrid,
ComposedChart,
Customized,
Legend,
Line,
ReferenceLine,
ResponsiveContainer,
Tooltip,
@@ -20,6 +23,7 @@ import {
YAxis,
} from 'recharts';
import { useDashedStroke } from '@/hooks/use-dashed-stroke';
import { useXAxisProps, useYAxisProps } from '../common/axis';
import { SolidToDashedGradient } from '../common/linear-gradient';
import { ReportChartTooltip } from '../common/report-chart-tooltip';
@@ -63,15 +67,20 @@ export function Chart({ data }: Props) {
const { series, setVisibleSeries } = useVisibleSeries(data);
const rechartData = useRechartDataModel(series);
// great care should be taken when computing lastIntervalPercent
// the expression below works for data.length - 1 equal intervals
// but if there are numeric x values in a "linear" axis, the formula
// should be updated to use those values
const lastIntervalPercent =
((rechartData.length - 2) * 100) / (rechartData.length - 1);
let dotIndex = undefined;
if (range === 'today') {
// Find closest index based on times
dotIndex = rechartData.findIndex((item) => {
return isSameHour(item.date, new Date());
});
}
const lastSerieDataItem = last(series[0]?.data || [])?.date || new Date();
const useDashedLastLine = (() => {
if (range === 'today') {
return true;
}
if (interval === 'hour') {
return isSameHour(lastSerieDataItem, new Date());
}
@@ -84,6 +93,10 @@ export function Chart({ data }: Props) {
return isSameMonth(lastSerieDataItem, new Date());
}
if (interval === 'week') {
return isSameWeek(lastSerieDataItem, new Date());
}
return false;
})();
@@ -114,11 +127,34 @@ export function Chart({ data }: Props) {
interval,
});
const handleChartClick = useCallback((e: any) => {
if (e?.activePayload?.[0]) {
const clickedData = e.activePayload[0].payload;
if (clickedData.date) {
pushModal('AddReference', {
datetime: new Date(clickedData.date).toISOString(),
});
}
}
}, []);
const { getStrokeDasharray, calcStrokeDasharray, handleAnimationEnd } =
useDashedStroke({
dotIndex,
});
return (
<>
<ReportChartTooltip.TooltipProvider references={references.data}>
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
<ResponsiveContainer>
<ComposedChart data={rechartData}>
<ComposedChart data={rechartData} onClick={handleChartClick}>
<Customized component={calcStrokeDasharray} />
<Line
dataKey="calcStrokeDasharray"
legendType="none"
animationDuration={0}
onAnimationEnd={handleAnimationEnd}
/>
<CartesianGrid
strokeDasharray="3 3"
horizontal={true}
@@ -143,60 +179,62 @@ export function Chart({ data }: Props) {
<YAxis {...yAxisProps} />
<XAxis {...xAxisProps} />
<Legend content={<CustomLegend />} />
<Tooltip content={<ReportChartTooltip />} />
<Tooltip content={<ReportChartTooltip.Tooltip />} />
{series.map((serie) => {
const color = getChartColor(serie.index);
return (
<React.Fragment key={serie.id}>
<defs>
<linearGradient
id={`color${color}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="0%" stopColor={color} stopOpacity={0.8} />
<stop
offset={'100%'}
stopColor={color}
stopOpacity={0.1}
/>
</linearGradient>
{useDashedLastLine && (
<SolidToDashedGradient
percentage={lastIntervalPercent}
baseColor={color}
id={`stroke${color}`}
/>
)}
</defs>
<Area
stackId="1"
type={lineType}
name={serie.id}
dataKey={`${serie.id}:count`}
stroke={useDashedLastLine ? `url(#stroke${color})` : color}
fill={`url(#color${color})`}
isAnimationActive={false}
fillOpacity={0.7}
/>
{previous && (
<Area
stackId="2"
type={lineType}
name={`${serie.id}:prev`}
dataKey={`${serie.id}:prev:count`}
stroke={color}
fill={color}
fillOpacity={0.3}
strokeOpacity={0.3}
isAnimationActive={false}
/>
)}
</React.Fragment>
<defs key={`defs-${serie.id}`}>
<linearGradient
id={`color${color}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="0%" stopColor={color} stopOpacity={0.8} />
<stop offset={'100%'} stopColor={color} stopOpacity={0.1} />
</linearGradient>
</defs>
);
})}
{series.map((serie) => {
const color = getChartColor(serie.index);
return (
<Area
key={serie.id}
stackId="1"
type={lineType}
name={serie.id}
dataKey={`${serie.id}:count`}
strokeDasharray={
useDashedLastLine
? getStrokeDasharray(`${serie.id}:count`)
: undefined
}
fill={`url(#color${color})`}
isAnimationActive={false}
fillOpacity={0.7}
/>
);
})}
{previous &&
series.map((serie) => {
const color = getChartColor(serie.index);
return (
<Area
key={`${serie.id}:prev`}
stackId="2"
type={lineType}
name={`${serie.id}:prev`}
dataKey={`${serie.id}:prev:count`}
stroke={color}
fill={color}
fillOpacity={0.3}
strokeOpacity={0.3}
isAnimationActive={false}
/>
);
})}
</ComposedChart>
</ResponsiveContainer>
</div>
@@ -207,6 +245,6 @@ export function Chart({ data }: Props) {
setVisibleSeries={setVisibleSeries}
/>
)}
</>
</ReportChartTooltip.TooltipProvider>
);
}

View File

@@ -1,149 +1,148 @@
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
import { useNumber } from '@/hooks/use-numer-formatter';
import type { IRechartPayloadItem } from '@/hooks/use-rechart-data-model';
import type { IToolTipProps } from '@/types';
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 React from 'react';
import { createChartTooltip } from '@/components/charts/chart-tooltip';
import type { RouterOutputs } from '@/trpc/client';
import type { IInterval } from '@openpanel/validation';
import {
format,
isSameDay,
isSameHour,
isSameMinute,
isSameMonth,
isSameWeek,
} from 'date-fns';
import { useReportChartContext } from '../context';
import { PreviousDiffIndicator } from './previous-diff-indicator';
import { SerieIcon } from './serie-icon';
import { SerieName } from './serie-name';
type ReportLineChartTooltipProps = IToolTipProps<{
value: number;
name: string;
dataKey: string;
payload: Record<string, unknown>;
}>;
const getMatchingReferences = (
interval: IInterval,
references: RouterOutputs['reference']['getChartReferences'],
date: Date,
) => {
return references.filter((reference) => {
if (interval === 'minute') {
return isSameMinute(reference.date, date);
}
if (interval === 'hour') {
return isSameHour(reference.date, date);
}
if (interval === 'day') {
return isSameDay(reference.date, date);
}
if (interval === 'week') {
return isSameWeek(reference.date, date);
}
if (interval === 'month') {
return isSameMonth(reference.date, date);
}
return false;
});
};
export function ReportChartTooltip({
active,
payload,
}: ReportLineChartTooltipProps) {
const {
report: { interval, unit },
} = useReportChartContext();
const formatDate = useFormatDateInterval(interval);
const number = useNumber();
const [position, setPosition] = useState<{ x: number; y: number } | null>(
null,
);
type Context = {
references?: RouterOutputs['reference']['getChartReferences'];
};
type Data = {
date: string;
timestamp: number;
[key: `${string}:count`]: number;
[key: `${string}:payload`]: IRechartPayloadItem;
};
export const ReportChartTooltip = createChartTooltip<Data, Context>(
({ context: { references }, data }) => {
const {
report: { interval, unit },
} = useReportChartContext();
const formatDate = useFormatDateInterval(interval);
const number = useNumber();
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;
}
const limit = 3;
const sorted = payload
.slice(0)
.filter((item) => !item.dataKey.includes(':prev:count'))
.filter((item) => !item.name.includes(':noTooltip'))
.sort((a, b) => b.value - a.value);
const visible = sorted.slice(0, limit);
const hidden = sorted.slice(limit);
const correctXPosition = (x: number | undefined) => {
if (!x) {
return undefined;
if (!data || data.length === 0) {
return null;
}
const tooltipWidth = 300;
const screenWidth = window.innerWidth;
const newX = x;
const firstItem = data[0];
const matchingReferences = getMatchingReferences(
interval,
references ?? [],
new Date(firstItem.date),
);
if (newX + tooltipWidth > screenWidth) {
return screenWidth - tooltipWidth;
}
return newX;
};
// Get all payload items from the first data point
const payloadItems = Object.keys(firstItem)
.filter((key) => key.endsWith(':payload'))
.map(
(key) =>
firstItem[key as keyof typeof firstItem] as IRechartPayloadItem,
)
.filter((item) => item && typeof item === 'object' && 'id' in item);
return (
<Portal.Portal
style={{
position: 'fixed',
top: position?.y,
left: correctXPosition(position?.x),
zIndex: 1000,
}}
>
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
{visible.map((item, index) => {
// If we have a <Cell /> component, payload can be nested
const payload = item.payload.payload ?? item.payload;
const data = (
item.dataKey.includes(':')
? // @ts-expect-error
payload[`${item.dataKey.split(':')[0]}:payload`]
: payload
) as IRechartPayloadItem;
// Sort by count
const sorted = payloadItems.sort((a, b) => (b.count || 0) - (a.count || 0));
const limit = 3;
const visible = sorted.slice(0, limit);
const hidden = sorted.slice(limit);
return (
<React.Fragment key={data.id}>
{index === 0 && data.date && (
<div className="flex justify-between gap-8">
<div>{formatDate(new Date(data.date))}</div>
return (
<div className="flex min-w-[180px] flex-col gap-2">
{visible.map((item, index) => (
<React.Fragment key={item.id}>
{index === 0 && item.date && (
<div className="flex justify-between gap-8">
<div>{formatDate(new Date(item.date))}</div>
</div>
)}
<div className="flex gap-2">
<div
className="w-[3px] rounded-full"
style={{ background: item.color }}
/>
<div className="col flex-1 gap-1">
<div className="flex items-center gap-1">
<SerieIcon name={item.names} />
<SerieName name={item.names} />
</div>
)}
<div className="flex gap-2">
<div
className="w-[3px] rounded-full"
style={{ background: data.color }}
/>
<div className="col flex-1 gap-1">
<div className="flex items-center gap-1">
<SerieIcon name={data.names} />
<SerieName name={data.names} />
</div>
<div className="flex justify-between gap-8 font-mono font-medium">
<div className="row gap-1">
{number.formatWithUnit(data.count, unit)}
{!!data.previous && (
<span className="text-muted-foreground">
({number.formatWithUnit(data.previous.value, unit)})
</span>
)}
</div>
<PreviousDiffIndicator {...data.previous} />
<div className="flex justify-between gap-8 font-mono font-medium">
<div className="row gap-1">
{number.formatWithUnit(item.count, unit)}
{!!item.previous && (
<span className="text-muted-foreground">
({number.formatWithUnit(item.previous.value, unit)})
</span>
)}
</div>
<PreviousDiffIndicator {...item.previous} />
</div>
</div>
</React.Fragment>
);
})}
</div>
</React.Fragment>
))}
{hidden.length > 0 && (
<div className="text-muted-foreground">
and {hidden.length} more...
</div>
)}
{matchingReferences.length > 0 && (
<>
<hr className="border-border" />
{matchingReferences.map((reference) => (
<div
key={reference.id}
className="row justify-between items-center"
>
<div className="font-medium text-sm">{reference.title}</div>
<div className="font-medium text-sm shrink-0 text-muted-foreground">
{format(reference.date, 'HH:mm')}
</div>
</div>
))}
</>
)}
</div>
</Portal.Portal>
);
}
);
},
);

View File

@@ -10,6 +10,8 @@ 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';
@@ -75,11 +77,22 @@ export function Chart({ data }: Props) {
};
});
const handleChartClick = useCallback((e: any) => {
if (e?.activePayload?.[0]) {
const clickedData = e.activePayload[0].payload;
if (clickedData.date) {
pushModal('AddReference', {
datetime: new Date(clickedData.date).toISOString(),
});
}
}
}, []);
return (
<TooltipProvider conversion={data} interval={interval}>
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
<ResponsiveContainer>
<LineChart data={rechartData}>
<LineChart data={rechartData} onClick={handleChartClick}>
<CartesianGrid
strokeDasharray="3 3"
horizontal={true}

View File

@@ -1,14 +1,18 @@
import { useRechartDataModel } from '@/hooks/use-rechart-data-model';
import { useTheme } from '@/hooks/use-theme';
import { useVisibleSeries } from '@/hooks/use-visible-series';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import type { IChartData } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import React, { useCallback } from 'react';
import {
Bar,
BarChart,
CartesianGrid,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
@@ -43,9 +47,23 @@ function BarHover({ x, y, width, height, top, left, right, bottom }: any) {
export function Chart({ data }: Props) {
const {
isEditMode,
report: { previous, interval },
report: { previous, interval, projectId, startDate, endDate, range },
options: { hideXAxis, hideYAxis },
} = useReportChartContext();
const trpc = useTRPC();
const references = useQuery(
trpc.reference.getChartReferences.queryOptions(
{
projectId,
startDate,
endDate,
range,
},
{
staleTime: 1000 * 60 * 10,
},
),
);
const { series, setVisibleSeries } = useVisibleSeries(data);
const rechartData = useRechartDataModel(series);
const yAxisProps = useYAxisProps({
@@ -55,17 +73,32 @@ export function Chart({ data }: Props) {
hide: hideXAxis,
interval,
});
const handleChartClick = useCallback((e: any) => {
if (e?.activePayload?.[0]) {
const clickedData = e.activePayload[0].payload;
if (clickedData.date) {
pushModal('AddReference', {
datetime: new Date(clickedData.date).toISOString(),
});
}
}
}, []);
return (
<>
<ReportChartTooltip.TooltipProvider references={references.data}>
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
<ResponsiveContainer>
<BarChart data={rechartData}>
<BarChart data={rechartData} onClick={handleChartClick}>
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
className="stroke-def-200"
/>
<Tooltip content={<ReportChartTooltip />} cursor={<BarHover />} />
<Tooltip
content={<ReportChartTooltip.Tooltip />}
cursor={<BarHover />}
/>
<YAxis {...yAxisProps} />
<XAxis {...xAxisProps} scale={'auto'} type="category" />
{previous
@@ -94,6 +127,21 @@ export function Chart({ data }: Props) {
/>
);
})}
{references.data?.map((ref) => (
<ReferenceLine
key={ref.id}
x={ref.date.getTime()}
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
strokeDasharray={'3 3'}
label={{
value: ref.title,
position: 'centerTop',
fill: '#334155',
fontSize: 12,
}}
fontSize={10}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
@@ -104,6 +152,6 @@ export function Chart({ data }: Props) {
setVisibleSeries={setVisibleSeries}
/>
)}
</>
</ReportChartTooltip.TooltipProvider>
);
}

View File

@@ -1,15 +1,15 @@
import { useRechartDataModel } from '@/hooks/use-rechart-data-model';
import { useVisibleSeries } from '@/hooks/use-visible-series';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import type { IChartData } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
import { useQuery } from '@tanstack/react-query';
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
import { last } from 'ramda';
import React, { useCallback } from 'react';
import { useCallback } from 'react';
import {
Area,
CartesianGrid,
ComposedChart,
Customized,
@@ -127,11 +127,23 @@ export function Chart({ data }: Props) {
const yAxisProps = useYAxisProps({
hide: hideYAxis,
});
const handleChartClick = useCallback((e: any) => {
if (e?.activePayload?.[0]) {
const clickedData = e.activePayload[0].payload;
if (clickedData.date) {
pushModal('AddReference', {
datetime: new Date(clickedData.date).toISOString(),
});
}
}
}, []);
return (
<>
<ReportChartTooltip.TooltipProvider references={references.data}>
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
<ResponsiveContainer>
<ComposedChart data={rechartData}>
<ComposedChart data={rechartData} onClick={handleChartClick}>
<Customized component={calcStrokeDasharray} />
<Line
dataKey="calcStrokeDasharray"
@@ -166,7 +178,7 @@ export function Chart({ data }: Props) {
/>
<XAxis {...xAxisProps} />
{series.length > 1 && <Legend content={<CustomLegend />} />}
<Tooltip content={<ReportChartTooltip />} />
<Tooltip content={<ReportChartTooltip.Tooltip />} />
{/* {series.map((serie) => {
const color = getChartColor(serie.index);
return (
@@ -261,7 +273,9 @@ export function Chart({ data }: Props) {
}
// Use for legend
fill={color}
filter="url(#rainbow-line-glow)"
filter={
series.length === 1 ? 'url(#rainbow-line-glow)' : undefined
}
/>
);
})}
@@ -296,6 +310,6 @@ export function Chart({ data }: Props) {
setVisibleSeries={setVisibleSeries}
/>
)}
</>
</ReportChartTooltip.TooltipProvider>
);
}

View File

@@ -25,6 +25,7 @@ import { GanttChartSquareIcon } from 'lucide-react';
import { useEffect } from 'react';
import type { IServiceReport } from '@openpanel/db';
import EditReportName from '../report/edit-report-name';
interface ReportEditorProps {
report: IServiceReport | null;
@@ -50,66 +51,62 @@ export default function ReportEditor({
};
}, [initialReport, dispatch]);
useEffect(() => {
return bind(window, {
type: 'report-name-change',
listener: (event) => {
if (event instanceof CustomEvent && typeof event.detail === 'string') {
dispatch(setName(event.detail));
}
},
});
}, [dispatch]);
return (
<Sheet>
<div className="grid grid-cols-2 gap-2 p-4 md:grid-cols-6">
<SheetTrigger asChild>
<div>
<Button icon={GanttChartSquareIcon} variant="cta">
<div>
<div className="p-4">
<EditReportName />
</div>
<div className="grid grid-cols-2 gap-2 p-4 pt-0 md:grid-cols-6">
<SheetTrigger asChild>
<Button
icon={GanttChartSquareIcon}
variant="cta"
className="self-start"
>
Pick events
</Button>
</SheetTrigger>
<div className="col-span-4 grid grid-cols-2 gap-2 md:grid-cols-4">
<ReportChartType
className="min-w-0 flex-1"
onChange={(type) => {
dispatch(changeChartType(type));
}}
value={report.chartType}
/>
<TimeWindowPicker
className="min-w-0 flex-1"
onChange={(value) => {
dispatch(changeDateRanges(value));
}}
value={report.range}
onStartDateChange={(date) => dispatch(changeStartDate(date))}
onEndDateChange={(date) => dispatch(changeEndDate(date))}
endDate={report.endDate}
startDate={report.startDate}
/>
<ReportInterval
className="min-w-0 flex-1"
interval={report.interval}
onChange={(newInterval) => dispatch(changeInterval(newInterval))}
range={report.range}
chartType={report.chartType}
startDate={report.startDate}
endDate={report.endDate}
/>
<ReportLineType className="min-w-0 flex-1" />
</div>
<div className="col-start-2 row-start-1 text-right md:col-start-6">
<ReportSaveButton />
</div>
</SheetTrigger>
<div className="col-span-4 grid grid-cols-2 gap-2 md:grid-cols-4">
<ReportChartType
className="min-w-0 flex-1"
onChange={(type) => {
dispatch(changeChartType(type));
}}
value={report.chartType}
/>
<TimeWindowPicker
className="min-w-0 flex-1"
onChange={(value) => {
dispatch(changeDateRanges(value));
}}
value={report.range}
onStartDateChange={(date) => dispatch(changeStartDate(date))}
onEndDateChange={(date) => dispatch(changeEndDate(date))}
endDate={report.endDate}
startDate={report.startDate}
/>
<ReportInterval
className="min-w-0 flex-1"
interval={report.interval}
onChange={(newInterval) => dispatch(changeInterval(newInterval))}
range={report.range}
chartType={report.chartType}
startDate={report.startDate}
endDate={report.endDate}
/>
<ReportLineType className="min-w-0 flex-1" />
</div>
<div className="col-start-2 row-start-1 text-right md:col-start-6">
<ReportSaveButton />
<div className="flex flex-col gap-4 p-4" id="report-editor">
{report.ready && (
<ReportChart report={{ ...report, projectId }} isEditMode />
)}
</div>
</div>
<div className="flex flex-col gap-4 p-4" id="report-editor">
{report.ready && (
<ReportChart report={{ ...report, projectId }} isEditMode />
)}
</div>
<SheetContent className="!max-w-lg" side="left">
<ReportSidebar />
</SheetContent>