refactor: migrate to recharts

This commit is contained in:
leander committed 2026-11-04 22:30:50 +01:00
1 parent 3e4de842c1
commit dd859a6b67
10 files changed
+689 -948

No files matched your search

+394 -750
View File
File diff suppressed because it is too large. Load diff
+1 -3
View File
@@ -17,9 +17,6 @@
},
"dependencies": {
"@apollo/client": "^4.2.0",
"@nivo/bar": "^0.99.0",
"@nivo/line": "^0.99.0",
"@nivo/pie": "^0.99.0",
"clsx": "^2.1.1",
"date-fns": "^4.3.0",
"graphql": "^16.14.0",
@@ -27,6 +24,7 @@
"react-dom": "^19.2.6",
"react-hook-form": "^7.76.1",
"react-router-dom": "^7.18.4",
"recharts": "^3.10.1",
"use-url-search-params": "^2.5.1"
},
"devDependencies": {
@@ -0,0 +1,25 @@
import React from "react";
import { chartTooltipStyle } from "./chartTheme";
type TooltipRow = {
name: React.ReactNode;
value: React.ReactNode;
color: string;
};
export const ChartTooltip = ({ title, rows }: { title?: React.ReactNode; rows: TooltipRow[] }) => (
<div style={chartTooltipStyle}>
{title !== undefined && (
<div>
<strong>{title}</strong>
</div>
)}
{rows.map((row, index) => (
<div key={index}>
<span style={{ background: row.color, width: 13, height: 13, display: "inline-block" }} />{" "}
<strong>{row.value}</strong> {row.name}
</div>
))}
</div>
);
+38 -16
View File
@@ -1,9 +1,10 @@
import { useQuery } from "@apollo/client/react";
import { BarDatum, ResponsiveBar } from "@nivo/bar";
import { Bar, BarChart, CartesianGrid, Rectangle, Tooltip, XAxis, YAxis } from "recharts";
import { ChartTooltip } from "./ChartTooltip";
import styles from "./Stats.module.css";
import { StatsView } from "./StatsView";
import { chartTheme } from "./chartTheme";
import { axisLabelStyle, axisProps, chartColor, chartStyle, gridStroke, tooltipCursor } from "./chartTheme";
import { OrderStatsDocument, SchemaOrderStatsByTime } from "../../types";
const days = { 1: "Mandag", 2: "Tirsdag", 3: "Onsdag", 4: "Torsdag", 5: "Fredag", 6: "Lørdag", 7: "Søndag" };
@@ -23,18 +24,39 @@ const months = {
12: "Desember",
};
const OrderBar = ({ data }: { data: BarDatum[] }) => (
type OrderDatum = {
id: string | number;
label: number;
value: number;
};
const OrderBar = ({ data }: { data: OrderDatum[] }) => (
<div className={styles.chart}>
<ResponsiveBar
<BarChart
responsive
style={chartStyle}
data={data}
theme={chartTheme}
margin={{ top: 10, right: 10, bottom: 70, left: 60 }}
colorBy="indexValue"
enableLabel={false}
tooltipLabel={(d) => d.id.toString()}
axisLeft={{ legend: "Ordre", legendPosition: "middle", legendOffset: -50 }}
axisBottom={{ tickRotation: -35 }}
/>
barCategoryGap="5%"
margin={{ top: 10, right: 10, bottom: 10, left: 10 }}
>
<CartesianGrid stroke={gridStroke} vertical={false} />
<XAxis {...axisProps} dataKey="id" interval={0} angle={-35} textAnchor="end" height={60} />
<YAxis
{...axisProps}
allowDecimals={false}
label={{ value: "Ordre", angle: -90, position: "insideLeft", style: axisLabelStyle }}
/>
<Tooltip
cursor={tooltipCursor}
content={({ active, payload }) => {
const entry = payload[0];
if (!active || !entry) return null;
const index = data.findIndex((datum) => datum.id === entry.payload.id);
return <ChartTooltip rows={[{ name: entry.payload.id, value: entry.value, color: chartColor(index) }]} />;
}}
/>
<Bar dataKey="value" shape={(props) => <Rectangle {...props} fill={chartColor(props.index)} />} />
</BarChart>
</div>
);
@@ -48,7 +70,7 @@ const OrderStats = () => {
data={(data?.orderStats?.hourly || []).map((stat: SchemaOrderStatsByTime) => ({
id: `${stat.period.toString().padStart(2, "0")}:00`,
label: stat.period,
value: stat.count,
value: Number(stat.count),
}))}
/>
<h3>Ordre per ukedag</h3>
@@ -57,7 +79,7 @@ const OrderStats = () => {
/* @ts-expect-error Here, stat.period can only be a key in days range. */
id: days[stat.period],
label: stat.period,
value: stat.count,
value: Number(stat.count),
}))}
/>
<h3>Ordre per måned</h3>
@@ -66,7 +88,7 @@ const OrderStats = () => {
/* @ts-expect-error Here, stat.period can only be a key in months range. */
id: months[stat.period],
label: stat.period,
value: stat.count,
value: Number(stat.count),
}))}
/>
<h3>Ordre per år</h3>
@@ -74,7 +96,7 @@ const OrderStats = () => {
data={(data?.orderStats?.yearly || []).map((stat: SchemaOrderStatsByTime) => ({
id: stat.period,
label: stat.period,
value: stat.count,
value: Number(stat.count),
}))}
/>
</StatsView>
+61 -13
View File
@@ -1,9 +1,10 @@
import { useQuery } from "@apollo/client/react";
import { ResponsivePie } from "@nivo/pie";
import { Pie, PieChart, PieLabelRenderProps, Sector, Tooltip } from "recharts";
import { ChartTooltip } from "./ChartTooltip";
import styles from "./Stats.module.css";
import { StatsView } from "./StatsView";
import { chartTheme } from "./chartTheme";
import { chartColor, chartStyle, chartTextColor } from "./chartTheme";
import { AllProductStatsDocument } from "../../types";
type UserCount = {
@@ -11,6 +12,45 @@ type UserCount = {
count: number;
};
const RADIAN = Math.PI / 180;
// Slices narrower than this are too small to hold their count
const MIN_VALUE_LABEL_ANGLE = 8;
// The user's name goes outside the slice, at the end of its label line, and the count inside it
const renderLabel = ({
cx,
cy,
midAngle = 0,
innerRadius,
outerRadius,
percent = 0,
x,
y,
textAnchor,
name,
value,
}: PieLabelRenderProps) => {
const radius = Number(innerRadius) + (Number(outerRadius) - Number(innerRadius)) / 2;
return (
<g>
<text x={x} y={y} textAnchor={textAnchor} dominantBaseline="central" fill={chartTextColor}>
{name}
</text>
{percent * 360 >= MIN_VALUE_LABEL_ANGLE && (
<text
x={Number(cx) + radius * Math.cos(-midAngle * RADIAN)}
y={Number(cy) + radius * Math.sin(-midAngle * RADIAN)}
textAnchor="middle"
dominantBaseline="central"
fill="#17121a"
>
{value}
</text>
)}
</g>
);
};
const ProductStats = () => {
const { data, loading } = useQuery(AllProductStatsDocument, {
variables: { active: true },
@@ -23,17 +63,25 @@ const ProductStats = () => {
<div key={product.id}>
{product.name}
<div className={styles.productChart}>
<ResponsivePie
margin={{ top: 30, right: 50, bottom: 30, left: 50 }}
data={product.userCounts.map((uc: UserCount) => ({
id: uc.name,
label: uc.name,
value: uc.count,
}))}
theme={chartTheme}
arcLabelsTextColor="#17121a"
arcLabelsSkipAngle={8}
/>
<PieChart responsive style={chartStyle} margin={{ top: 30, right: 50, bottom: 30, left: 50 }}>
<Pie
data={product.userCounts as UserCount[]}
dataKey="count"
nameKey="name"
outerRadius="80%"
label={renderLabel}
labelLine={{ stroke: "#b9a3c5" }}
shape={(props) => <Sector {...props} fill={chartColor(props.index)} />}
/>
<Tooltip
content={({ active, payload }) => {
const entry = payload[0];
if (!active || !entry) return null;
const index = (product.userCounts as UserCount[]).findIndex((uc) => uc.name === entry.name);
return <ChartTooltip rows={[{ name: entry.name, value: entry.value, color: chartColor(index) }]} />;
}}
/>
</PieChart>
</div>
</div>
))}
@@ -0,0 +1,102 @@
import { addMonths, format, parseISO, startOfMonth } from "date-fns";
import { CartesianGrid, Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts";
import { ChartTooltip } from "./ChartTooltip";
import { axisLabelStyle, axisProps, chartColor, chartStyle, chartTextColor, gridStroke } from "./chartTheme";
type ProductOrderStat = {
productName: string;
data: { x: string; y: number }[];
};
type Row = {
date: number;
// Keyed by product name; a product has no value before its first purchase
totals: Record<string, number>;
};
// One row per day with each product's running total. Products without purchases that day are left
// out of the row, and connectNulls draws their line straight to the next day they have one.
const toRows = (stats: ProductOrderStat[]): Row[] => {
const rows = new Map<number, Row>();
for (const { productName, data } of stats) {
let total = 0;
for (const point of data) {
total += point.y;
const date = parseISO(point.x).getTime();
const row = rows.get(date) ?? { date, totals: {} };
row.totals[productName] = total;
rows.set(date, row);
}
}
return [...rows.values()].sort((a, b) => a.date - b.date);
};
// A tick on the first of each month, so each month is labelled once. Spans too short to have
// two of them get Recharts' own ticks, labelled with the full date.
const monthTicks = (rows: Row[]): number[] | undefined => {
if (!rows.length) return undefined;
const ticks = [];
for (
let month = startOfMonth(addMonths(rows[0].date, 1));
month.getTime() <= rows[rows.length - 1].date;
month = addMonths(month, 1)
) {
ticks.push(month.getTime());
}
return ticks.length >= 2 ? ticks : undefined;
};
export const PurchasesOverTimeChart = ({ stats }: { stats: ProductOrderStat[] }) => {
const rows = toRows(stats);
const ticks = monthTicks(rows);
return (
<LineChart responsive style={chartStyle} data={rows} margin={{ top: 20, right: 30, bottom: 30, left: 10 }}>
<CartesianGrid stroke={gridStroke} />
<XAxis
{...axisProps}
dataKey="date"
type="number"
scale="time"
domain={["dataMin", "dataMax"]}
ticks={ticks}
tickFormatter={(date: number) => format(date, ticks ? "MMM yyyy" : "yyyy-MM-dd")}
label={{ value: "Date", position: "bottom", offset: 10, style: axisLabelStyle }}
/>
<YAxis
{...axisProps}
allowDecimals={false}
label={{ value: "Purchases", angle: -90, position: "insideLeft", style: axisLabelStyle }}
/>
<Tooltip
cursor={{ stroke: chartTextColor, strokeOpacity: 0.4 }}
content={({ active, payload, label }) =>
active && payload.length ? (
<ChartTooltip
title={format(Number(label), "yyyy-MM-dd")}
rows={payload
.filter((entry) => entry.value !== undefined)
.map((entry) => ({ name: entry.name, value: entry.value, color: String(entry.color) }))}
/>
) : null
}
/>
<Legend
verticalAlign="top"
formatter={(value) => <span style={{ color: chartTextColor }}>{value}</span>}
wrapperStyle={{ paddingBottom: 8 }}
/>
{stats.map(({ productName }, index) => (
<Line
key={productName}
name={productName}
dataKey={(row: Row) => row.totals[productName]}
stroke={chartColor(index)}
strokeWidth={2}
dot={{ r: 3, fill: chartColor(index) }}
connectNulls
/>
))}
</LineChart>
);
};
+2 -49
View File
@@ -1,65 +1,18 @@
import { useQuery } from "@apollo/client/react";
import { ResponsiveLine } from "@nivo/line";
import { PurchasesOverTimeChart } from "./PurchasesOverTimeChart";
import styles from "./Stats.module.css";
import { StatsView } from "./StatsView";
import { chartTheme, chartTooltipStyle } from "./chartTheme";
import { ProductStatsDocument } from "../../types";
const Stats = () => {
const { data, loading } = useQuery(ProductStatsDocument);
const series = (data?.productStats || []).map(({ productName: id, data }) => ({
id,
data: data.map((point, index, points) => ({
x: point.x,
y: point.y + (points[index - 1]?.y || 0),
})),
}));
return (
<StatsView loading={loading}>
<h2>Kjøp over tid</h2>
<div className={styles.chart}>
<ResponsiveLine
data={series}
theme={chartTheme}
margin={{ top: 50, right: 110, bottom: 50, left: 60 }}
xScale={{
type: "time",
format: "%Y-%m-%d",
useUTC: false,
precision: "day",
}}
xFormat="time:%Y-%m-%d"
yScale={{
type: "linear",
stacked: false,
}}
axisLeft={{
legend: "Purchases",
legendOffset: 12,
}}
axisBottom={{
format: "%b %Y",
legend: "Date",
legendPosition: "middle",
legendOffset: -20,
}}
useMesh
pointLabelYOffset={0}
tooltip={({ point }) => (
<div style={chartTooltipStyle}>
<div>
<strong>{point.data.xFormatted}</strong>
</div>
<div>
<span style={{ background: point.seriesColor, width: 13, height: 13, display: "inline-block" }} />{" "}
<strong>{String(point.data.yFormatted)}</strong> {point.seriesId}
</div>
</div>
)}
/>
<PurchasesOverTimeChart stats={data?.productStats || []} />
</div>
</StatsView>
);
+36 -21
View File
@@ -1,9 +1,10 @@
import { useQuery } from "@apollo/client/react";
import { ResponsiveBar } from "@nivo/bar";
import { Bar, BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from "recharts";
import { ChartTooltip } from "./ChartTooltip";
import styles from "./Stats.module.css";
import { StatsView } from "./StatsView";
import { chartTheme } from "./chartTheme";
import { axisLabelStyle, axisProps, chartColor, chartStyle, gridStroke, tooltipCursor } from "./chartTheme";
import { GQLError } from "../../components/GQLError";
import { UserStatsDocument } from "../../types";
@@ -19,32 +20,46 @@ const UserStats = () => {
total: Number(user.profile?.orderSumTotal || 0),
}))
.sort((a, b) => b.total - a.total)
.slice(0, 10)
// nivo draws the first item of a horizontal chart at the bottom
.reverse();
.slice(0, 10);
const formatKroner = (value: number) => `${value.toLocaleString("no")} kr`;
return (
<StatsView loading={loading}>
<h3>Topp 10 etter total kjøpesum</h3>
{users.length ? (
<div className={styles.chart} style={{ height: Math.max(320, users.length * 44) }}>
<ResponsiveBar
<BarChart
responsive
style={chartStyle}
data={users}
keys={["total"]}
indexBy="name"
layout="horizontal"
theme={chartTheme}
margin={{ top: 10, right: 35, bottom: 40, left: 100 }}
padding={0.3}
enableLabel={false}
axisBottom={{
legend: "Kjøp totalt (kr)",
legendPosition: "middle",
legendOffset: 32,
}}
axisLeft={{ tickSize: 0, tickPadding: 8 }}
valueFormat={(value) => `${Number(value).toLocaleString("no")} kr`}
/>
layout="vertical"
barCategoryGap="15%"
margin={{ top: 10, right: 35, bottom: 30, left: 10 }}
>
<CartesianGrid stroke={gridStroke} horizontal={false} />
<XAxis
{...axisProps}
type="number"
label={{ value: "Kjøp totalt (kr)", position: "bottom", offset: 10, style: axisLabelStyle }}
/>
<YAxis {...axisProps} type="category" dataKey="name" width={100} tickLine={false} />
<Tooltip
cursor={tooltipCursor}
content={({ active, payload }) => {
const entry = payload[0];
if (!active || !entry) return null;
return (
<ChartTooltip
rows={[
{ name: entry.payload.name, value: formatKroner(entry.payload.total), color: chartColor(0) },
]}
/>
);
}}
/>
<Bar dataKey="total" fill={chartColor(0)} />
</BarChart>
</div>
) : (
<p>Ingen kjøp å vise ennå.</p>
+28 -48
View File
@@ -1,53 +1,33 @@
export const chartTheme = {
background: "transparent",
text: {
fill: "#f7effa",
fontFamily: '"IBM Plex Mono", monospace',
fontSize: 13,
textShadow: "none",
},
axis: {
domain: {
line: { stroke: "#b9a3c5", strokeWidth: 1 },
},
ticks: {
line: { stroke: "#b9a3c5", strokeWidth: 1 },
text: { fill: "#f7effa", fontSize: 12, textShadow: "none" },
},
legend: {
text: { fill: "#f7effa", fontSize: 13, textShadow: "none" },
},
},
grid: {
line: { stroke: "rgba(221, 81, 127, 0.24)", strokeWidth: 1 },
},
legends: {
text: { fill: "#f7effa", fontSize: 12, textShadow: "none" },
title: {
text: { fill: "#f7effa", fontSize: 13, textShadow: "none" },
},
},
labels: {
text: {
fill: "#ffffff",
fontSize: 13,
textShadow: "none",
},
},
tooltip: {
container: {
background: "#2b1733",
color: "#ffffff",
fontFamily: '"IBM Plex Mono", monospace',
fontSize: 13,
border: "1px solid #dd517f",
borderRadius: 4,
boxShadow: "0 4px 16px rgba(0, 0, 0, 0.45)",
},
},
import type { CSSProperties } from "react";
export const chartTextColor = "#f7effa";
const axisColor = "#b9a3c5";
// nivo's default categorical scheme, kept so the charts look the same after moving to Recharts
export const chartColors = ["#e8c1a0", "#f47560", "#f1e15b", "#e8a838", "#61cdbb", "#97e3d5"];
export const chartColor = (index: number) => chartColors[index % chartColors.length];
// Recharts draws plain SVG; give it the app font and fill its container
export const chartStyle: CSSProperties = {
width: "100%",
height: "100%",
fontFamily: '"IBM Plex Mono", monospace',
fontSize: 13,
};
export const chartTooltipStyle = {
export const axisProps = {
stroke: axisColor,
tick: { fill: chartTextColor, fontSize: 12 },
tickLine: { stroke: axisColor },
};
export const axisLabelStyle = { fill: chartTextColor, fontSize: 13 };
export const gridStroke = "rgba(221, 81, 127, 0.24)";
export const tooltipCursor = { fill: "rgba(255, 255, 255, 0.08)" };
export const chartTooltipStyle: CSSProperties = {
background: "#2b1733",
color: "#ffffff",
padding: "9px 12px",
+2 -48
View File
@@ -1,12 +1,11 @@
import { useQuery } from "@apollo/client/react";
import { ResponsiveLine } from "@nivo/line";
import { useParams } from "react-router-dom";
import styles from "./UserDetail.module.css";
import { GQLError } from "../../components/GQLError";
import { SchemaUser as User, UserDetailDocument, SchemaUserProfile as UserProfile } from "../../types";
import { formatMonth, formatTime } from "../../utils/time";
import { chartTheme, chartTooltipStyle } from "../stats/chartTheme";
import { PurchasesOverTimeChart } from "../stats/PurchasesOverTimeChart";
const UserDetail = () => {
const { userId } = useParams();
@@ -18,13 +17,6 @@ const UserDetail = () => {
const user = data?.user as User;
const profile = user?.profile as UserProfile;
const series = (profile?.productOrderStats || []).map(({ productName: id, data }) => ({
id,
data: data.map((point, index, points) => ({
x: point.x,
y: point.y + (points[index - 1]?.y || 0),
})),
}));
return (
<div className={styles.userDetail}>
<div>
@@ -61,45 +53,7 @@ const UserDetail = () => {
<div>
<h2>Kjøp over tid</h2>
<div className={styles.chart}>
<ResponsiveLine
data={series}
theme={chartTheme}
margin={{ top: 50, right: 110, bottom: 50, left: 60 }}
xScale={{
type: "time",
format: "%Y-%m-%d",
useUTC: false,
precision: "day",
}}
xFormat="time:%Y-%m-%d"
yScale={{
type: "linear",
stacked: false,
}}
axisLeft={{
legend: "Purchases",
legendOffset: 12,
}}
axisBottom={{
format: "%b %Y",
legend: "Date",
legendPosition: "middle",
legendOffset: -20,
}}
useMesh
pointLabelYOffset={0}
tooltip={({ point }) => (
<div style={chartTooltipStyle}>
<div>
<strong>{point.data.xFormatted}</strong>
</div>
<div>
<span style={{ background: point.seriesColor, width: 13, height: 13, display: "inline-block" }} />{" "}
<strong>{String(point.data.yFormatted)}</strong> {point.seriesId}
</div>
</div>
)}
/>
<PurchasesOverTimeChart stats={profile?.productOrderStats || []} />
</div>
</div>
</div>