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": { "dependencies": {
"@apollo/client": "^4.2.0", "@apollo/client": "^4.2.0",
"@nivo/bar": "^0.99.0",
"@nivo/line": "^0.99.0",
"@nivo/pie": "^0.99.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.3.0", "date-fns": "^4.3.0",
"graphql": "^16.14.0", "graphql": "^16.14.0",
@@ -27,6 +24,7 @@
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"react-hook-form": "^7.76.1", "react-hook-form": "^7.76.1",
"react-router-dom": "^7.18.4", "react-router-dom": "^7.18.4",
"recharts": "^3.10.1",
"use-url-search-params": "^2.5.1" "use-url-search-params": "^2.5.1"
}, },
"devDependencies": { "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 { 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 styles from "./Stats.module.css";
import { StatsView } from "./StatsView"; import { StatsView } from "./StatsView";
import { chartTheme } from "./chartTheme"; import { axisLabelStyle, axisProps, chartColor, chartStyle, gridStroke, tooltipCursor } from "./chartTheme";
import { OrderStatsDocument, SchemaOrderStatsByTime } from "../../types"; import { OrderStatsDocument, SchemaOrderStatsByTime } from "../../types";
const days = { 1: "Mandag", 2: "Tirsdag", 3: "Onsdag", 4: "Torsdag", 5: "Fredag", 6: "Lørdag", 7: "Søndag" }; 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", 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}> <div className={styles.chart}>
<ResponsiveBar <BarChart
responsive
style={chartStyle}
data={data} data={data}
theme={chartTheme} barCategoryGap="5%"
margin={{ top: 10, right: 10, bottom: 70, left: 60 }} margin={{ top: 10, right: 10, bottom: 10, left: 10 }}
colorBy="indexValue" >
enableLabel={false} <CartesianGrid stroke={gridStroke} vertical={false} />
tooltipLabel={(d) => d.id.toString()} <XAxis {...axisProps} dataKey="id" interval={0} angle={-35} textAnchor="end" height={60} />
axisLeft={{ legend: "Ordre", legendPosition: "middle", legendOffset: -50 }} <YAxis
axisBottom={{ tickRotation: -35 }} {...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> </div>
); );
@@ -48,7 +70,7 @@ const OrderStats = () => {
data={(data?.orderStats?.hourly || []).map((stat: SchemaOrderStatsByTime) => ({ data={(data?.orderStats?.hourly || []).map((stat: SchemaOrderStatsByTime) => ({
id: `${stat.period.toString().padStart(2, "0")}:00`, id: `${stat.period.toString().padStart(2, "0")}:00`,
label: stat.period, label: stat.period,
value: stat.count, value: Number(stat.count),
}))} }))}
/> />
<h3>Ordre per ukedag</h3> <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. */ /* @ts-expect-error Here, stat.period can only be a key in days range. */
id: days[stat.period], id: days[stat.period],
label: stat.period, label: stat.period,
value: stat.count, value: Number(stat.count),
}))} }))}
/> />
<h3>Ordre per måned</h3> <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. */ /* @ts-expect-error Here, stat.period can only be a key in months range. */
id: months[stat.period], id: months[stat.period],
label: stat.period, label: stat.period,
value: stat.count, value: Number(stat.count),
}))} }))}
/> />
<h3>Ordre per år</h3> <h3>Ordre per år</h3>
@@ -74,7 +96,7 @@ const OrderStats = () => {
data={(data?.orderStats?.yearly || []).map((stat: SchemaOrderStatsByTime) => ({ data={(data?.orderStats?.yearly || []).map((stat: SchemaOrderStatsByTime) => ({
id: stat.period, id: stat.period,
label: stat.period, label: stat.period,
value: stat.count, value: Number(stat.count),
}))} }))}
/> />
</StatsView> </StatsView>
+61 -13
View File
@@ -1,9 +1,10 @@
import { useQuery } from "@apollo/client/react"; 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 styles from "./Stats.module.css";
import { StatsView } from "./StatsView"; import { StatsView } from "./StatsView";
import { chartTheme } from "./chartTheme"; import { chartColor, chartStyle, chartTextColor } from "./chartTheme";
import { AllProductStatsDocument } from "../../types"; import { AllProductStatsDocument } from "../../types";
type UserCount = { type UserCount = {
@@ -11,6 +12,45 @@ type UserCount = {
count: number; 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 ProductStats = () => {
const { data, loading } = useQuery(AllProductStatsDocument, { const { data, loading } = useQuery(AllProductStatsDocument, {
variables: { active: true }, variables: { active: true },
@@ -23,17 +63,25 @@ const ProductStats = () => {
<div key={product.id}> <div key={product.id}>
{product.name} {product.name}
<div className={styles.productChart}> <div className={styles.productChart}>
<ResponsivePie <PieChart responsive style={chartStyle} margin={{ top: 30, right: 50, bottom: 30, left: 50 }}>
margin={{ top: 30, right: 50, bottom: 30, left: 50 }} <Pie
data={product.userCounts.map((uc: UserCount) => ({ data={product.userCounts as UserCount[]}
id: uc.name, dataKey="count"
label: uc.name, nameKey="name"
value: uc.count, outerRadius="80%"
}))} label={renderLabel}
theme={chartTheme} labelLine={{ stroke: "#b9a3c5" }}
arcLabelsTextColor="#17121a" shape={(props) => <Sector {...props} fill={chartColor(props.index)} />}
arcLabelsSkipAngle={8} />
/> <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>
</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 { useQuery } from "@apollo/client/react";
import { ResponsiveLine } from "@nivo/line";
import { PurchasesOverTimeChart } from "./PurchasesOverTimeChart";
import styles from "./Stats.module.css"; import styles from "./Stats.module.css";
import { StatsView } from "./StatsView"; import { StatsView } from "./StatsView";
import { chartTheme, chartTooltipStyle } from "./chartTheme";
import { ProductStatsDocument } from "../../types"; import { ProductStatsDocument } from "../../types";
const Stats = () => { const Stats = () => {
const { data, loading } = useQuery(ProductStatsDocument); 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 ( return (
<StatsView loading={loading}> <StatsView loading={loading}>
<h2>Kjøp over tid</h2> <h2>Kjøp over tid</h2>
<div className={styles.chart}> <div className={styles.chart}>
<ResponsiveLine <PurchasesOverTimeChart stats={data?.productStats || []} />
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>
)}
/>
</div> </div>
</StatsView> </StatsView>
); );
+36 -21
View File
@@ -1,9 +1,10 @@
import { useQuery } from "@apollo/client/react"; 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 styles from "./Stats.module.css";
import { StatsView } from "./StatsView"; import { StatsView } from "./StatsView";
import { chartTheme } from "./chartTheme"; import { axisLabelStyle, axisProps, chartColor, chartStyle, gridStroke, tooltipCursor } from "./chartTheme";
import { GQLError } from "../../components/GQLError"; import { GQLError } from "../../components/GQLError";
import { UserStatsDocument } from "../../types"; import { UserStatsDocument } from "../../types";
@@ -19,32 +20,46 @@ const UserStats = () => {
total: Number(user.profile?.orderSumTotal || 0), total: Number(user.profile?.orderSumTotal || 0),
})) }))
.sort((a, b) => b.total - a.total) .sort((a, b) => b.total - a.total)
.slice(0, 10) .slice(0, 10);
// nivo draws the first item of a horizontal chart at the bottom
.reverse(); const formatKroner = (value: number) => `${value.toLocaleString("no")} kr`;
return ( return (
<StatsView loading={loading}> <StatsView loading={loading}>
<h3>Topp 10 etter total kjøpesum</h3> <h3>Topp 10 etter total kjøpesum</h3>
{users.length ? ( {users.length ? (
<div className={styles.chart} style={{ height: Math.max(320, users.length * 44) }}> <div className={styles.chart} style={{ height: Math.max(320, users.length * 44) }}>
<ResponsiveBar <BarChart
responsive
style={chartStyle}
data={users} data={users}
keys={["total"]} layout="vertical"
indexBy="name" barCategoryGap="15%"
layout="horizontal" margin={{ top: 10, right: 35, bottom: 30, left: 10 }}
theme={chartTheme} >
margin={{ top: 10, right: 35, bottom: 40, left: 100 }} <CartesianGrid stroke={gridStroke} horizontal={false} />
padding={0.3} <XAxis
enableLabel={false} {...axisProps}
axisBottom={{ type="number"
legend: "Kjøp totalt (kr)", label={{ value: "Kjøp totalt (kr)", position: "bottom", offset: 10, style: axisLabelStyle }}
legendPosition: "middle", />
legendOffset: 32, <YAxis {...axisProps} type="category" dataKey="name" width={100} tickLine={false} />
}} <Tooltip
axisLeft={{ tickSize: 0, tickPadding: 8 }} cursor={tooltipCursor}
valueFormat={(value) => `${Number(value).toLocaleString("no")} kr`} 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> </div>
) : ( ) : (
<p>Ingen kjøp å vise ennå.</p> <p>Ingen kjøp å vise ennå.</p>
+28 -48
View File
@@ -1,53 +1,33 @@
export const chartTheme = { import type { CSSProperties } from "react";
background: "transparent",
text: { export const chartTextColor = "#f7effa";
fill: "#f7effa", const axisColor = "#b9a3c5";
fontFamily: '"IBM Plex Mono", monospace',
fontSize: 13, // nivo's default categorical scheme, kept so the charts look the same after moving to Recharts
textShadow: "none", export const chartColors = ["#e8c1a0", "#f47560", "#f1e15b", "#e8a838", "#61cdbb", "#97e3d5"];
}, export const chartColor = (index: number) => chartColors[index % chartColors.length];
axis: {
domain: { // Recharts draws plain SVG; give it the app font and fill its container
line: { stroke: "#b9a3c5", strokeWidth: 1 }, export const chartStyle: CSSProperties = {
}, width: "100%",
ticks: { height: "100%",
line: { stroke: "#b9a3c5", strokeWidth: 1 }, fontFamily: '"IBM Plex Mono", monospace',
text: { fill: "#f7effa", fontSize: 12, textShadow: "none" }, fontSize: 13,
},
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)",
},
},
}; };
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", background: "#2b1733",
color: "#ffffff", color: "#ffffff",
padding: "9px 12px", padding: "9px 12px",
+2 -48
View File
@@ -1,12 +1,11 @@
import { useQuery } from "@apollo/client/react"; import { useQuery } from "@apollo/client/react";
import { ResponsiveLine } from "@nivo/line";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import styles from "./UserDetail.module.css"; import styles from "./UserDetail.module.css";
import { GQLError } from "../../components/GQLError"; import { GQLError } from "../../components/GQLError";
import { SchemaUser as User, UserDetailDocument, SchemaUserProfile as UserProfile } from "../../types"; import { SchemaUser as User, UserDetailDocument, SchemaUserProfile as UserProfile } from "../../types";
import { formatMonth, formatTime } from "../../utils/time"; import { formatMonth, formatTime } from "../../utils/time";
import { chartTheme, chartTooltipStyle } from "../stats/chartTheme"; import { PurchasesOverTimeChart } from "../stats/PurchasesOverTimeChart";
const UserDetail = () => { const UserDetail = () => {
const { userId } = useParams(); const { userId } = useParams();
@@ -18,13 +17,6 @@ const UserDetail = () => {
const user = data?.user as User; const user = data?.user as User;
const profile = user?.profile as UserProfile; 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 ( return (
<div className={styles.userDetail}> <div className={styles.userDetail}>
<div> <div>
@@ -61,45 +53,7 @@ const UserDetail = () => {
<div> <div>
<h2>Kjøp over tid</h2> <h2>Kjøp over tid</h2>
<div className={styles.chart}> <div className={styles.chart}>
<ResponsiveLine <PurchasesOverTimeChart stats={profile?.productOrderStats || []} />
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>
)}
/>
</div> </div>
</div> </div>
</div> </div>