Skip to content

Commit b8e874d

Browse files
committed
feat: refactor shadcn chart to the v3 recharts
fix: chart point labels overflow and layering
1 parent bd13f52 commit b8e874d

15 files changed

Lines changed: 622 additions & 709 deletions

dashboard/bun.lock

Lines changed: 247 additions & 248 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dashboard/package.json

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"codegen": "graphql-codegen --config codegen.ts"
1717
},
1818
"dependencies": {
19-
"@apollo/client": "^4.1.2",
19+
"@apollo/client": "^4.1.6",
2020
"@radix-ui/react-avatar": "^1.1.11",
2121
"@radix-ui/react-checkbox": "^1.3.3",
2222
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -27,37 +27,38 @@
2727
"@radix-ui/react-separator": "^1.1.8",
2828
"@radix-ui/react-slot": "^1.2.4",
2929
"@radix-ui/react-tabs": "^1.1.13",
30-
"@tanstack/react-router": "^1.154.7",
30+
"@tanstack/react-router": "^1.166.3",
3131
"class-variance-authority": "^0.7.1",
3232
"clsx": "^2.1.1",
3333
"date-fns": "^4.1.0",
34-
"graphql": "^16.12.0",
35-
"lucide-react": "^0.563.0",
36-
"react": "^19.2.3",
37-
"react-day-picker": "^9.13.0",
38-
"react-dom": "^19.2.3",
39-
"recharts": "2.15.4",
40-
"tailwind-merge": "^3.4.0",
34+
"graphql": "^16.13.1",
35+
"lucide-react": "^0.577.0",
36+
"react": "^19.2.4",
37+
"react-day-picker": "^9.14.0",
38+
"react-dom": "^19.2.4",
39+
"react-is": "^19.2.4",
40+
"recharts": "3.8.0",
41+
"tailwind-merge": "^3.5.0",
4142
"tailwindcss-animate": "^1.0.7",
42-
"zod": "^4.3.5"
43+
"zod": "^4.3.6"
4344
},
4445
"devDependencies": {
4546
"@biomejs/biome": "^2.4.6",
46-
"@graphql-codegen/cli": "^6.1.1",
47-
"@graphql-codegen/client-preset": "^5.2.2",
48-
"@tailwindcss/postcss": "^4.1.18",
49-
"@tailwindcss/vite": "^4.1.18",
50-
"@tanstack/router-generator": "^1.154.8",
51-
"@tanstack/router-plugin": "^1.154.8",
52-
"@types/node": "^25.0.10",
53-
"@types/react": "^19.2.9",
47+
"@graphql-codegen/cli": "^6.1.3",
48+
"@graphql-codegen/client-preset": "^5.2.4",
49+
"@tailwindcss/postcss": "^4.2.1",
50+
"@tailwindcss/vite": "^4.2.1",
51+
"@tanstack/router-generator": "^1.166.2",
52+
"@tanstack/router-plugin": "^1.166.3",
53+
"@types/node": "^25.3.5",
54+
"@types/react": "^19.2.14",
5455
"@types/react-dom": "^19.2.3",
55-
"@vitejs/plugin-react": "^5.1.2",
56-
"@vitejs/plugin-react-swc": "^4.2.2",
57-
"autoprefixer": "^10.4.23",
58-
"lightningcss": "^1.31.1",
59-
"postcss": "^8.5.6",
60-
"tailwindcss": "^4.1.18",
56+
"@vitejs/plugin-react": "^5.1.4",
57+
"@vitejs/plugin-react-swc": "^4.2.3",
58+
"autoprefixer": "^10.4.27",
59+
"lightningcss": "^1.32.0",
60+
"postcss": "^8.5.8",
61+
"tailwindcss": "^4.2.1",
6162
"typescript": "~5.9.3",
6263
"vite": "^7.3.1"
6364
}

dashboard/src/components/chart-skeleton.tsx

Lines changed: 0 additions & 14 deletions
This file was deleted.

dashboard/src/components/overview-chart-section.tsx

Lines changed: 6 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import { TrendingUp } from 'lucide-react';
22
import type { FunctionComponent } from 'react';
33
import { useMemo } from 'react';
4-
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts';
54
import DashboardCardState from '@/components/dashboard-card-state';
6-
import { Card, CardContent, CardHeader, CardTitle, ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, Progress, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Skeleton } from '@/components/ui';
5+
import OverviewChartPlot from '@/components/overview-chart/overview-chart-plot';
6+
import { Card, CardContent, CardHeader, CardTitle, Progress, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Skeleton } from '@/components/ui';
77
import type { FilterInput } from '@/gql/graphql';
8+
import { buildOverviewChartData } from '@/components/overview-chart/overview-chart-data';
89
import { useChartDataLoader } from '@/hooks/use-chart-data-loader';
9-
import { buildTimeFormatter, parseChartDate } from '@/lib/chart-date';
10-
import { CHART_CONFIG, CHART_MARGIN, TICK_MARGIN } from '@/lib/chart-config';
1110

1211
interface OverviewChartSectionProps {
1312
siteId: string;
@@ -22,11 +21,7 @@ const PROGRESS_MIN = 0;
2221

2322
const OverviewChartSection: FunctionComponent<OverviewChartSectionProps> = ({ siteId, dateRange, filter, bucket, onBucketChange }) => {
2423
const { loadedData, state, loadingMore, progress, expectedCount } = useChartDataLoader({ siteId, dateRange, filter, bucket });
25-
const formatters = useMemo(() => ({ daily: buildTimeFormatter('daily'), hourly: buildTimeFormatter('hourly') }), []);
26-
const chartData = useMemo(() => loadedData.map((stat) => {
27-
const timestamp = parseChartDate(stat.date);
28-
return timestamp === null ? null : { timestamp, visitors: stat.visitors, pageViews: stat.pageViews, sessions: stat.sessions };
29-
}).filter((item): item is NonNullable<typeof item> => item !== null).sort((a, b) => a.timestamp - b.timestamp), [loadedData]);
24+
const chartData = useMemo(() => buildOverviewChartData(loadedData), [loadedData]);
3025
const showProgress = (state === 'refreshing' && chartData.length > EMPTY_COUNT) || loadingMore;
3126
const progressLabel = expectedCount === null ? `Loaded ${loadedData.length.toLocaleString()} points` : `Loaded ${Math.min(loadedData.length, expectedCount).toLocaleString()} of ${expectedCount.toLocaleString()} points`;
3227

@@ -42,19 +37,8 @@ const OverviewChartSection: FunctionComponent<OverviewChartSectionProps> = ({ si
4237
{showProgress ? <div className="space-y-2"><div className="flex items-center justify-between text-xs text-muted-foreground"><span>{loadingMore ? 'Loading more data' : 'Refreshing chart data'}</span><span>{progressLabel}</span></div><Progress value={progress ?? PROGRESS_MIN} className="h-2" /></div> : null}
4338
</CardHeader>
4439
<CardContent>
45-
<DashboardCardState state={state} overlayLabel={loadingMore ? 'Loading more' : 'Refreshing chart'} skeleton={<Skeleton className="h-[300px] w-full" />} className="min-h-[300px]">
46-
<ChartContainer config={CHART_CONFIG} className="h-[300px] w-full">
47-
<AreaChart data={chartData} margin={CHART_MARGIN}>
48-
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
49-
<XAxis dataKey="timestamp" type="number" scale="time" domain={['dataMin', 'dataMax']} tickFormatter={(value) => formatters[bucket].format(Number(value))} tickLine={false} axisLine={false} tickMargin={TICK_MARGIN} className="text-xs" />
50-
<YAxis tickLine={false} axisLine={false} tickMargin={TICK_MARGIN} className="text-xs" />
51-
<ChartTooltip content={<ChartTooltipContent labelFormatter={(value) => typeof value === 'number' ? formatters[bucket].format(value) : String(value)} />} />
52-
<ChartLegend content={<ChartLegendContent />} />
53-
<Area type="monotone" dataKey="visitors" stackId="1" stroke="var(--color-visitors)" fill="var(--color-visitors)" fillOpacity={0.6} />
54-
<Area type="monotone" dataKey="pageViews" stackId="2" stroke="var(--color-pageViews)" fill="var(--color-pageViews)" fillOpacity={0.6} />
55-
<Area type="monotone" dataKey="sessions" stackId="3" stroke="var(--color-sessions)" fill="var(--color-sessions)" fillOpacity={0.6} />
56-
</AreaChart>
57-
</ChartContainer>
40+
<DashboardCardState state={state} overlayLabel={loadingMore ? 'Loading more' : 'Refreshing chart'} skeleton={<Skeleton className="h-[clamp(240px,44vw,320px)] w-full" />} className="min-h-[clamp(240px,44vw,320px)]">
41+
<OverviewChartPlot bucket={bucket} data={chartData} />
5842
</DashboardCardState>
5943
</CardContent>
6044
</Card>
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { OverviewPoint } from '@/components/overview-chart/overview-chart-series';
2+
3+
const MAX_AXIS_TICKS = 6;
4+
const FIRST_INDEX = 0;
5+
const MIN_TICK_COUNT = 2;
6+
const dailyAxisFormatter = new Intl.DateTimeFormat('en-US', {
7+
month: 'short',
8+
day: 'numeric',
9+
timeZone: 'UTC',
10+
});
11+
const hourlyAxisDateFormatter = new Intl.DateTimeFormat('en-US', {
12+
month: 'short',
13+
day: 'numeric',
14+
timeZone: 'UTC',
15+
});
16+
const hourlyAxisTimeFormatter = new Intl.DateTimeFormat('en-US', {
17+
hour: 'numeric',
18+
timeZone: 'UTC',
19+
});
20+
21+
export const buildOverviewAxisTicks = (data: OverviewPoint[]): number[] => {
22+
if (data.length <= MAX_AXIS_TICKS) {
23+
return data.map((point) => point.timestamp);
24+
}
25+
26+
const segmentCount = MAX_AXIS_TICKS - 1;
27+
const lastIndex = data.length - 1;
28+
const indexes = new Set<number>([FIRST_INDEX, lastIndex]);
29+
30+
for (let segment = 1; segment < segmentCount; segment += 1) {
31+
indexes.add(Math.round((segment * lastIndex) / segmentCount));
32+
}
33+
34+
return [...indexes]
35+
.sort((left, right) => left - right)
36+
.slice(FIRST_INDEX, Math.max(MIN_TICK_COUNT, MAX_AXIS_TICKS))
37+
.map((index) => data[index]?.timestamp)
38+
.filter((tick): tick is number => typeof tick === 'number');
39+
};
40+
41+
export const formatOverviewAxisTickLines = (bucket: 'daily' | 'hourly', value: number): string[] => {
42+
const date = new Date(value);
43+
if (bucket === 'hourly') {
44+
return [hourlyAxisDateFormatter.format(date), hourlyAxisTimeFormatter.format(date)];
45+
}
46+
47+
return [dailyAxisFormatter.format(date)];
48+
};
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { DailyStatsFieldsFragment } from '@/gql/graphql';
2+
import { parseChartDate } from '@/lib/chart-date';
3+
import type { OverviewPoint } from '@/components/overview-chart/overview-chart-series';
4+
5+
export const buildOverviewChartData = (stats: DailyStatsFieldsFragment[]): OverviewPoint[] =>
6+
stats
7+
.map((stat) => {
8+
const timestamp = parseChartDate(stat.date);
9+
if (timestamp === null) {
10+
return null;
11+
}
12+
13+
return {
14+
timestamp,
15+
visitors: stat.visitors,
16+
pageViews: stat.pageViews,
17+
sessions: stat.sessions,
18+
};
19+
})
20+
.filter((point): point is OverviewPoint => point !== null)
21+
.sort((left, right) => left.timestamp - right.timestamp);
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
const compactNumberFormatter = new Intl.NumberFormat('en-US', {
2+
notation: 'compact',
3+
maximumFractionDigits: 1,
4+
});
5+
6+
export const formatOverviewAxisValue = (value: number): string => compactNumberFormatter.format(value);
7+
8+
export const formatOverviewValue = (value: number): string => value.toLocaleString();
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { FunctionComponent } from 'react';
2+
import type { DefaultLegendContentProps, LegendPayload } from 'recharts';
3+
import { compareOverviewSeriesOrder, getOverviewSeries, isOverviewSeriesKey } from '@/components/overview-chart/overview-chart-series';
4+
5+
const OverviewChartLegend: FunctionComponent<DefaultLegendContentProps> = ({ payload }) => {
6+
const items = (payload ?? [])
7+
.map((entry: LegendPayload) => {
8+
if (!isOverviewSeriesKey(entry.dataKey)) {
9+
return null;
10+
}
11+
12+
return getOverviewSeries(entry.dataKey) ?? null;
13+
})
14+
.filter((entry): entry is NonNullable<typeof entry> => entry !== null)
15+
.sort((left, right) => compareOverviewSeriesOrder(left.key, right.key));
16+
17+
return (
18+
<div className="mt-4 flex flex-wrap gap-2">
19+
{items.map((series) => (
20+
<div
21+
key={series.key}
22+
className="inline-flex max-w-full items-center gap-2 rounded-full border border-border/70 bg-muted/30 px-3 py-1.5 text-xs text-muted-foreground"
23+
title={series.label}
24+
>
25+
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: series.stroke }} />
26+
<span className="max-w-[10rem] truncate font-medium text-foreground">{series.label}</span>
27+
</div>
28+
))}
29+
</div>
30+
);
31+
};
32+
33+
export default OverviewChartLegend;
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import type { CSSProperties, FunctionComponent } from 'react';
2+
import { Area, AreaChart, CartesianGrid, Legend, Tooltip, XAxis, YAxis } from 'recharts';
3+
import { buildOverviewAxisTicks } from '@/components/overview-chart/overview-chart-axis';
4+
import OverviewChartLegend from '@/components/overview-chart/overview-chart-legend';
5+
import OverviewChartTooltip from '@/components/overview-chart/overview-chart-tooltip';
6+
import OverviewChartXAxisTick from '@/components/overview-chart/overview-chart-x-axis-tick';
7+
import { formatOverviewAxisValue } from '@/components/overview-chart/overview-chart-formatters';
8+
import { OVERVIEW_RENDER_SERIES, OVERVIEW_SERIES, getOverviewSeriesIndex, type OverviewPoint } from '@/components/overview-chart/overview-chart-series';
9+
10+
interface OverviewChartPlotProps {
11+
bucket: 'daily' | 'hourly';
12+
data: OverviewPoint[];
13+
}
14+
15+
const MAX_TICK_COUNT = 6;
16+
const X_AXIS_MIN_TICK_GAP = 24;
17+
const X_AXIS_TICK_MARGIN = 10;
18+
const DAILY_X_AXIS_HEIGHT = 40;
19+
const HOURLY_X_AXIS_HEIGHT = 52;
20+
const LEGEND_HEIGHT = 52;
21+
22+
const chartStyle = {
23+
width: '100%',
24+
height: '100%',
25+
maxWidth: '100%',
26+
} satisfies CSSProperties;
27+
28+
const OverviewChartPlot: FunctionComponent<OverviewChartPlotProps> = ({ bucket, data }) => {
29+
const ticks = buildOverviewAxisTicks(data).slice(0, MAX_TICK_COUNT);
30+
const xAxisHeight = bucket === 'hourly' ? HOURLY_X_AXIS_HEIGHT : DAILY_X_AXIS_HEIGHT;
31+
32+
return (
33+
<div className="h-[clamp(240px,44vw,320px)] w-full">
34+
<AreaChart accessibilityLayer data={data} responsive style={chartStyle} margin={{ top: 12, right: 6, bottom: 0, left: -18 }}>
35+
<defs>
36+
{OVERVIEW_SERIES.map((series) => (
37+
<linearGradient key={series.key} id={`overview-${series.key}`} x1="0" y1="0" x2="0" y2="1">
38+
<stop offset="5%" stopColor={series.fillStart} />
39+
<stop offset="95%" stopColor={series.fillEnd} />
40+
</linearGradient>
41+
))}
42+
</defs>
43+
<CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-border/60" />
44+
<XAxis
45+
dataKey="timestamp"
46+
type="number"
47+
scale="time"
48+
domain={['dataMin', 'dataMax']}
49+
ticks={ticks}
50+
tickLine={false}
51+
axisLine={false}
52+
minTickGap={X_AXIS_MIN_TICK_GAP}
53+
interval={0}
54+
tickMargin={X_AXIS_TICK_MARGIN}
55+
height={xAxisHeight}
56+
tick={(props) => <OverviewChartXAxisTick {...props} bucket={bucket} />}
57+
className="text-xs"
58+
/>
59+
<YAxis tickFormatter={(value) => formatOverviewAxisValue(Number(value))} tickLine={false} axisLine={false} width={44} className="text-xs" />
60+
<Tooltip
61+
cursor={{ stroke: 'hsl(var(--border))', strokeDasharray: '4 4' }}
62+
isAnimationActive={false}
63+
itemSorter={(item) => getOverviewSeriesIndex(item.dataKey)}
64+
content={(props) => <OverviewChartTooltip {...props} bucket={bucket} />}
65+
/>
66+
<Legend
67+
verticalAlign="bottom"
68+
align="left"
69+
height={LEGEND_HEIGHT}
70+
itemSorter={(item) => getOverviewSeriesIndex(item.dataKey)}
71+
content={(props) => <OverviewChartLegend {...props} />}
72+
/>
73+
{OVERVIEW_RENDER_SERIES.map((series) => (
74+
<Area
75+
key={series.key}
76+
type="monotone"
77+
dataKey={series.key}
78+
name={series.label}
79+
stroke={series.stroke}
80+
fill={`url(#overview-${series.key})`}
81+
strokeWidth={series.strokeWidth}
82+
fillOpacity={1}
83+
dot={false}
84+
activeDot={{ r: 4, fill: series.stroke, stroke: 'hsl(var(--background))', strokeWidth: 2 }}
85+
isAnimationActive={false}
86+
/>
87+
))}
88+
</AreaChart>
89+
</div>
90+
);
91+
};
92+
93+
export default OverviewChartPlot;

0 commit comments

Comments
 (0)