From 020e68221a3493dc8fe780a49b0397511faab585 Mon Sep 17 00:00:00 2001 From: Leander Furumo Date: Wed, 4 Nov 2026 22:42:44 +0100 Subject: [PATCH] feat: convert statistics overview to cumulative graph --- frontend/src/features/stats/ChartTooltip.tsx | 9 +- frontend/src/features/stats/ProductStats.tsx | 5 +- .../features/stats/PurchasesOverTimeChart.tsx | 151 +++++++++++++----- frontend/src/features/stats/chartTheme.ts | 33 +++- 4 files changed, 151 insertions(+), 47 deletions(-) diff --git a/frontend/src/features/stats/ChartTooltip.tsx b/frontend/src/features/stats/ChartTooltip.tsx index e952f2f..3694e0d 100644 --- a/frontend/src/features/stats/ChartTooltip.tsx +++ b/frontend/src/features/stats/ChartTooltip.tsx @@ -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) => (
- {" "} + {row.color && ( + <> + {" "} + + )} {row.value} {row.name}
))} diff --git a/frontend/src/features/stats/ProductStats.tsx b/frontend/src/features/stats/ProductStats.tsx index e501950..d0c7802 100644 --- a/frontend/src/features/stats/ProductStats.tsx +++ b/frontend/src/features/stats/ProductStats.tsx @@ -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} diff --git a/frontend/src/features/stats/PurchasesOverTimeChart.tsx b/frontend/src/features/stats/PurchasesOverTimeChart.tsx index 22fac8c..6aa4014 100644 --- a/frontend/src/features/stats/PurchasesOverTimeChart.tsx +++ b/frontend/src/features/stats/PurchasesOverTimeChart.tsx @@ -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; +// 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(); - 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(); + 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 ( - + - 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 ( 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 - } + ); + }} /> {value}} wrapperStyle={{ paddingBottom: 8 }} + content={() => ( +
    + {bands.map((band) => ( +
  • + {band.name} +
  • + ))} +
+ )} /> - {stats.map(({ productName }, index) => ( - row.totals[productName]} - stroke={chartColor(index)} - strokeWidth={2} - dot={{ r: 3, fill: chartColor(index) }} - connectNulls + {bands.map((band, index) => ( + 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} /> ))} -
+ ); }; diff --git a/frontend/src/features/stats/chartTheme.ts b/frontend/src/features/stats/chartTheme.ts index cf168d9..824c169 100644 --- a/frontend/src/features/stats/chartTheme.ts +++ b/frontend/src/features/stats/chartTheme.ts @@ -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%",