feat: convert statistics overview to cumulative graph

This commit is contained in:
leander committed 2026-11-04 22:42:44 +01:00
1 parent 121e4c25de
commit 020e68221a
4 files changed
+151 -47

No files matched your search

+7 -2
View File
@@ -5,7 +5,8 @@ import { chartTooltipStyle } from "./chartTheme";
type TooltipRow = {
name: React.ReactNode;
value: React.ReactNode;
color: string;
// The series colour; leave it out for rows like totals that have no mark in the chart
color?: string;
};
export const ChartTooltip = ({ title, rows }: { title?: React.ReactNode; rows: TooltipRow[] }) => (
@@ -17,7 +18,11 @@ export const ChartTooltip = ({ title, rows }: { title?: React.ReactNode; rows: T
)}
{rows.map((row, index) => (
<div key={index}>
<span style={{ background: row.color, width: 13, height: 13, display: "inline-block" }} />{" "}
{row.color && (
<>
<span style={{ background: row.color, width: 13, height: 13, display: "inline-block" }} />{" "}
</>
)}
<strong>{row.value}</strong> {row.name}
</div>
))}
+3 -2
View File
@@ -4,7 +4,7 @@ import { Pie, PieChart, PieLabelRenderProps, Sector, Tooltip } from "recharts";
import { ChartTooltip } from "./ChartTooltip";
import styles from "./Stats.module.css";
import { StatsView } from "./StatsView";
import { chartColor, chartStyle, chartTextColor } from "./chartTheme";
import { chartColor, chartStyle, chartTextColor, labelColorOn } from "./chartTheme";
import { AllProductStatsDocument } from "../../types";
type UserCount = {
@@ -29,6 +29,7 @@ const renderLabel = ({
textAnchor,
name,
value,
index,
}: PieLabelRenderProps) => {
const radius = Number(innerRadius) + (Number(outerRadius) - Number(innerRadius)) / 2;
return (
@@ -42,7 +43,7 @@ const renderLabel = ({
y={Number(cy) + radius * Math.sin(-midAngle * RADIAN)}
textAnchor="middle"
dominantBaseline="central"
fill="#17121a"
fill={labelColorOn(chartColor(index))}
>
{value}
</text>
@@ -1,35 +1,71 @@
import { addMonths, format, parseISO, startOfMonth } from "date-fns";
import { CartesianGrid, Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts";
import { Area, AreaChart, CartesianGrid, Legend, Tooltip, XAxis, YAxis } from "recharts";
import { ChartTooltip } from "./ChartTooltip";
import { axisLabelStyle, axisProps, chartColor, chartStyle, chartTextColor, gridStroke } from "./chartTheme";
import {
axisLabelStyle,
axisProps,
chartStyle,
chartSurfaceColor,
chartTextColor,
gridStroke,
chartColors,
otherColor,
} 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 band of the stack: a product, or all the remaining products together
type Band = {
name: string;
color: string;
};
// 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);
}
type Row = {
date: number;
// Units sold up to and including this day, one entry per band, bottom band first
values: number[];
total: number;
};
// The best sellers each get a band, largest at the bottom. The rest are added up in one band on top,
// since the palette has no more colours that stay distinguishable next to each other.
const toChart = (stats: ProductOrderStat[]): { bands: Band[]; rows: Row[] } => {
const products = stats
.map(({ productName, data }) => {
let total = 0;
const totalByDate = new Map<number, number>();
for (const point of data) {
total += point.y;
totalByDate.set(parseISO(point.x).getTime(), total);
}
return { productName, totalByDate, total };
})
.sort((a, b) => b.total - a.total);
const shown = products.slice(0, chartColors.length);
const others = products.slice(shown.length);
const bands = shown.map(({ productName }, index) => ({ name: productName, color: chartColors[index] }));
if (others.length) {
bands.push({ name: `Andre (${others.length} ${others.length === 1 ? "vare" : "varer"})`, color: otherColor });
}
return [...rows.values()].sort((a, b) => a.date - b.date);
// A product's running total carries over to the days it sold nothing
const dates = [...new Set(products.flatMap(({ totalByDate }) => [...totalByDate.keys()]))].sort((a, b) => a - b);
const current = products.map(() => 0);
const rows = dates.map((date) => {
products.forEach(({ totalByDate }, index) => {
current[index] = totalByDate.get(date) ?? current[index];
});
const values = current.slice(0, shown.length);
if (others.length) values.push(current.slice(shown.length).reduce((sum, value) => sum + value, 0));
return { date, values, total: values.reduce((sum, value) => sum + value, 0) };
});
return { bands, rows };
};
// A tick on the first of each month, so each month is labelled once. Spans too short to have
@@ -47,11 +83,14 @@ const monthTicks = (rows: Row[]): number[] | undefined => {
return ticks.length >= 2 ? ticks : undefined;
};
const percent = (value: number, total: number) => `${total ? Math.round((value / total) * 100) : 0} %`;
// Total units sold over time, stacked by product, so each band's thickness is that product's share
export const PurchasesOverTimeChart = ({ stats }: { stats: ProductOrderStat[] }) => {
const rows = toRows(stats);
const { bands, rows } = toChart(stats);
const ticks = monthTicks(rows);
return (
<LineChart responsive style={chartStyle} data={rows} margin={{ top: 20, right: 30, bottom: 30, left: 10 }}>
<AreaChart responsive style={chartStyle} data={rows} margin={{ top: 20, right: 30, bottom: 30, left: 10 }}>
<CartesianGrid stroke={gridStroke} />
<XAxis
{...axisProps}
@@ -70,33 +109,63 @@ export const PurchasesOverTimeChart = ({ stats }: { stats: ProductOrderStat[] })
/>
<Tooltip
cursor={{ stroke: chartTextColor, strokeOpacity: 0.4 }}
content={({ active, payload, label }) =>
active && payload.length ? (
content={({ active, payload }) => {
const row: Row | undefined = payload[0]?.payload;
if (!active || !row) return null;
// Top band first, as in the chart
const bandRows = bands
.map((band, index) => ({
name: band.name,
value: `${row.values[index]} (${percent(row.values[index], row.total)})`,
color: band.color,
}))
.reverse();
return (
<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) }))}
title={format(row.date, "yyyy-MM-dd")}
rows={[{ name: "totalt", value: row.total }, ...bandRows]}
/>
) : null
}
);
}}
/>
<Legend
verticalAlign="top"
formatter={(value) => <span style={{ color: chartTextColor }}>{value}</span>}
wrapperStyle={{ paddingBottom: 8 }}
content={() => (
<ul
style={{
display: "flex",
flexWrap: "wrap",
justifyContent: "center",
gap: "4px 16px",
margin: 0,
padding: 0,
listStyle: "none",
}}
>
{bands.map((band) => (
<li key={band.name} style={{ color: chartTextColor }}>
<span style={{ background: band.color, width: 12, height: 12, display: "inline-block" }} /> {band.name}
</li>
))}
</ul>
)}
/>
{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
{bands.map((band, index) => (
<Area
key={band.name}
name={band.name}
dataKey={(row: Row) => row.values[index]}
stackId="units"
type="linear"
fill={band.color}
fillOpacity={1}
// A thin line in the background colour keeps touching bands apart
stroke={chartSurfaceColor}
strokeWidth={1}
activeDot={false}
/>
))}
</LineChart>
</AreaChart>
);
};
+31 -2
View File
@@ -1,12 +1,41 @@
import type { CSSProperties } from "react";
export const chartTextColor = "#f7effa";
const darkLabelColor = "#17121a";
// sRGB relative luminance, as in WCAG's contrast ratio
const luminance = (hex: string) => {
const [r, g, b] = [1, 3, 5].map((i) => {
const c = parseInt(hex.slice(i, i + 2), 16) / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
// Dark or white, whichever reads better on text drawn over the given colour
export const labelColorOn = (fill: string) => {
const background = luminance(fill);
const contrastWith = (text: string) => {
const [hi, lo] = [luminance(text), background].sort((a, b) => b - a);
return (hi + 0.05) / (lo + 0.05);
};
return contrastWith(darkLabelColor) >= contrastWith("#ffffff") ? darkLabelColor : "#ffffff";
};
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"];
// Categorical colours for the dark background. The order matters: each colour stays easy to tell
// apart from the next one, also with colour blindness, so neighbouring bars, slices and bands work.
// Colours repeat past the eighth; where that would put two of the same side by side, fold the rest
// into one otherColor group instead.
export const chartColors = ["#3987e5", "#d95926", "#199e70", "#c98500", "#d55181", "#008300", "#9085e9", "#e66767"];
export const chartColor = (index: number) => chartColors[index % chartColors.length];
// For a group of "the rest", e.g. "Andre"; distinct from the last colour it follows
export const otherColor = "#a89cae";
// The darkest background colour, used to separate touching bands
export const chartSurfaceColor = "#0e0610";
// Recharts draws plain SVG; give it the app font and fill its container
export const chartStyle: CSSProperties = {
width: "100%",