From b249986287c99e209cbc5ffcac296069b7bf37ee Mon Sep 17 00:00:00 2001 From: Fides Date: Tue, 4 Aug 2026 16:51:53 +0200 Subject: [PATCH 1/2] feat(frontend): add shared chart primitives library --- .../charts/BarChart/BarChart.stories.tsx | 94 ++++++ .../charts/BarChart/BarChart.test.tsx | 126 ++++++++ .../components/charts/BarChart/BarChart.tsx | 195 ++++++++++++ .../src/components/charts/BarChart/index.ts | 1 + .../charts/DonutChart/DonutChart.stories.tsx | 138 +++++++++ .../charts/DonutChart/DonutChart.test.tsx | 133 ++++++++ .../charts/DonutChart/DonutChart.tsx | 197 ++++++++++++ .../src/components/charts/DonutChart/index.ts | 1 + .../charts/GaugeBar/GaugeBar.stories.tsx | 111 +++++++ .../charts/GaugeBar/GaugeBar.test.tsx | 132 ++++++++ .../components/charts/GaugeBar/GaugeBar.tsx | 83 +++++ .../src/components/charts/GaugeBar/index.ts | 1 + .../components/charts/HBar/HBar.stories.tsx | 124 ++++++++ .../src/components/charts/HBar/HBar.test.tsx | 127 ++++++++ frontend/src/components/charts/HBar/HBar.tsx | 102 +++++++ frontend/src/components/charts/HBar/index.ts | 1 + .../charts/Histogram/Histogram.stories.tsx | 108 +++++++ .../charts/Histogram/Histogram.test.tsx | 108 +++++++ .../components/charts/Histogram/Histogram.tsx | 203 ++++++++++++ .../src/components/charts/Histogram/index.ts | 1 + .../charts/LineChart/LineChart.stories.tsx | 133 ++++++++ .../charts/LineChart/LineChart.test.tsx | 123 ++++++++ .../components/charts/LineChart/LineChart.tsx | 196 ++++++++++++ .../src/components/charts/LineChart/index.ts | 1 + .../charts/Sparkline/Sparkline.stories.tsx | 77 +++++ .../charts/Sparkline/Sparkline.test.tsx | 94 ++++++ .../components/charts/Sparkline/Sparkline.tsx | 106 +++++++ .../src/components/charts/Sparkline/index.ts | 1 + .../src/components/charts/chart-palette.ts | 25 ++ .../src/components/charts/chart-scale.test.ts | 288 ++++++++++++++++++ frontend/src/components/charts/chart-scale.ts | 136 +++++++++ .../ChartAxes/ChartAxes.stories.tsx | 54 ++++ .../components/ChartAxes/ChartAxes.test.tsx | 123 ++++++++ .../charts/components/ChartAxes/ChartAxes.tsx | 87 ++++++ .../charts/components/ChartAxes/index.ts | 1 + .../ChartEmpty/ChartEmpty.stories.tsx | 43 +++ .../components/ChartEmpty/ChartEmpty.test.tsx | 26 ++ .../components/ChartEmpty/ChartEmpty.tsx | 34 +++ .../charts/components/ChartEmpty/index.ts | 1 + .../ChartFrame/ChartFrame.stories.tsx | 92 ++++++ .../components/ChartFrame/ChartFrame.test.tsx | 118 +++++++ .../components/ChartFrame/ChartFrame.tsx | 135 ++++++++ .../charts/components/ChartFrame/index.ts | 1 + .../ChartLegend/ChartLegend.stories.tsx | 108 +++++++ .../ChartLegend/ChartLegend.test.tsx | 87 ++++++ .../components/ChartLegend/ChartLegend.tsx | 89 ++++++ .../charts/components/ChartLegend/index.ts | 1 + .../ChartTooltip/ChartTooltip.stories.tsx | 64 ++++ .../ChartTooltip/ChartTooltip.test.tsx | 51 ++++ .../components/ChartTooltip/ChartTooltip.tsx | 57 ++++ .../charts/components/ChartTooltip/index.ts | 1 + .../components/charts/use-measure.test.tsx | 61 ++++ frontend/src/components/charts/use-measure.ts | 30 ++ frontend/src/components/ui/progress.tsx | 23 ++ .../src/i18n/locales/en-GB/translation.json | 24 ++ frontend/src/index.css | 20 ++ frontend/src/test/setup.ts | 31 ++ 57 files changed, 4528 insertions(+) create mode 100644 frontend/src/components/charts/BarChart/BarChart.stories.tsx create mode 100644 frontend/src/components/charts/BarChart/BarChart.test.tsx create mode 100644 frontend/src/components/charts/BarChart/BarChart.tsx create mode 100644 frontend/src/components/charts/BarChart/index.ts create mode 100644 frontend/src/components/charts/DonutChart/DonutChart.stories.tsx create mode 100644 frontend/src/components/charts/DonutChart/DonutChart.test.tsx create mode 100644 frontend/src/components/charts/DonutChart/DonutChart.tsx create mode 100644 frontend/src/components/charts/DonutChart/index.ts create mode 100644 frontend/src/components/charts/GaugeBar/GaugeBar.stories.tsx create mode 100644 frontend/src/components/charts/GaugeBar/GaugeBar.test.tsx create mode 100644 frontend/src/components/charts/GaugeBar/GaugeBar.tsx create mode 100644 frontend/src/components/charts/GaugeBar/index.ts create mode 100644 frontend/src/components/charts/HBar/HBar.stories.tsx create mode 100644 frontend/src/components/charts/HBar/HBar.test.tsx create mode 100644 frontend/src/components/charts/HBar/HBar.tsx create mode 100644 frontend/src/components/charts/HBar/index.ts create mode 100644 frontend/src/components/charts/Histogram/Histogram.stories.tsx create mode 100644 frontend/src/components/charts/Histogram/Histogram.test.tsx create mode 100644 frontend/src/components/charts/Histogram/Histogram.tsx create mode 100644 frontend/src/components/charts/Histogram/index.ts create mode 100644 frontend/src/components/charts/LineChart/LineChart.stories.tsx create mode 100644 frontend/src/components/charts/LineChart/LineChart.test.tsx create mode 100644 frontend/src/components/charts/LineChart/LineChart.tsx create mode 100644 frontend/src/components/charts/LineChart/index.ts create mode 100644 frontend/src/components/charts/Sparkline/Sparkline.stories.tsx create mode 100644 frontend/src/components/charts/Sparkline/Sparkline.test.tsx create mode 100644 frontend/src/components/charts/Sparkline/Sparkline.tsx create mode 100644 frontend/src/components/charts/Sparkline/index.ts create mode 100644 frontend/src/components/charts/chart-palette.ts create mode 100644 frontend/src/components/charts/chart-scale.test.ts create mode 100644 frontend/src/components/charts/chart-scale.ts create mode 100644 frontend/src/components/charts/components/ChartAxes/ChartAxes.stories.tsx create mode 100644 frontend/src/components/charts/components/ChartAxes/ChartAxes.test.tsx create mode 100644 frontend/src/components/charts/components/ChartAxes/ChartAxes.tsx create mode 100644 frontend/src/components/charts/components/ChartAxes/index.ts create mode 100644 frontend/src/components/charts/components/ChartEmpty/ChartEmpty.stories.tsx create mode 100644 frontend/src/components/charts/components/ChartEmpty/ChartEmpty.test.tsx create mode 100644 frontend/src/components/charts/components/ChartEmpty/ChartEmpty.tsx create mode 100644 frontend/src/components/charts/components/ChartEmpty/index.ts create mode 100644 frontend/src/components/charts/components/ChartFrame/ChartFrame.stories.tsx create mode 100644 frontend/src/components/charts/components/ChartFrame/ChartFrame.test.tsx create mode 100644 frontend/src/components/charts/components/ChartFrame/ChartFrame.tsx create mode 100644 frontend/src/components/charts/components/ChartFrame/index.ts create mode 100644 frontend/src/components/charts/components/ChartLegend/ChartLegend.stories.tsx create mode 100644 frontend/src/components/charts/components/ChartLegend/ChartLegend.test.tsx create mode 100644 frontend/src/components/charts/components/ChartLegend/ChartLegend.tsx create mode 100644 frontend/src/components/charts/components/ChartLegend/index.ts create mode 100644 frontend/src/components/charts/components/ChartTooltip/ChartTooltip.stories.tsx create mode 100644 frontend/src/components/charts/components/ChartTooltip/ChartTooltip.test.tsx create mode 100644 frontend/src/components/charts/components/ChartTooltip/ChartTooltip.tsx create mode 100644 frontend/src/components/charts/components/ChartTooltip/index.ts create mode 100644 frontend/src/components/charts/use-measure.test.tsx create mode 100644 frontend/src/components/charts/use-measure.ts create mode 100644 frontend/src/components/ui/progress.tsx diff --git a/frontend/src/components/charts/BarChart/BarChart.stories.tsx b/frontend/src/components/charts/BarChart/BarChart.stories.tsx new file mode 100644 index 0000000..c5d7878 --- /dev/null +++ b/frontend/src/components/charts/BarChart/BarChart.stories.tsx @@ -0,0 +1,94 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, waitFor, within } from "storybook/test"; +import { BarChart, DATA_TEST_ID } from "./BarChart"; +import { DATA_TEST_ID as FRAME_TEST_ID } from "@/components/charts/components/ChartFrame"; +import { DATA_TEST_ID as LEGEND_TEST_ID } from "@/components/charts/components/ChartLegend"; +import { DATA_TEST_ID as TOOLTIP_TEST_ID } from "@/components/charts/components/ChartTooltip"; + +// Placeholder data throughout — the real copy is not settled. +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug"]; + +const SERIES_A = { id: "a", label: "Series A", values: [155, 96, 160, 152, 258, 118, 205, 128] }; +const SERIES_B = { id: "b", label: "Series B", values: [63, 41, 66, 58, 105, 51, 108, 52] }; + +const meta = { + component: BarChart, + tags: ["autodocs"], + args: { + label: "Series A by month", + categories: MONTHS, + series: [SERIES_A], + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const SingleSeries: Story = { + play: async ({ canvas }) => { + // One bar per category, and no legend — a single series is named by the title. + await waitFor(async () => await expect(canvas.getAllByTestId(DATA_TEST_ID.BAR)).toHaveLength(MONTHS.length)); + + await expect(canvas.queryByTestId(LEGEND_TEST_ID.CONTAINER)).not.toBeInTheDocument(); + }, +}; + +export const Stacked: Story = { + args: { + label: "Series A and Series B by month", + series: [SERIES_A, SERIES_B], + stacked: true, + }, + play: async ({ canvas }) => { + // Both segments of every column, separated by the 2px surface gap. + await waitFor(async () => await expect(canvas.getAllByTestId(DATA_TEST_ID.BAR)).toHaveLength(MONTHS.length * 2)); + + const legend = within(canvas.getByTestId(LEGEND_TEST_ID.CONTAINER)); + await expect(legend.getByText("Series A")).toBeVisible(); + await expect(legend.getByText("Series B")).toBeVisible(); + + // A stack is read against its total, so the data table carries one. + const table = within(canvas.getByTestId(FRAME_TEST_ID.TABLE)); + await expect(table.getByRole("columnheader", { name: "Total" })).toBeInTheDocument(); + }, +}; + +export const Grouped: Story = { + args: { + label: "Series A and Series B by month", + series: [SERIES_A, SERIES_B], + stacked: false, + }, + play: async ({ canvas }) => { + await waitFor(async () => await expect(canvas.getAllByTestId(DATA_TEST_ID.BAR)).toHaveLength(MONTHS.length * 2)); + }, +}; + +export const HoverShowsTooltip: Story = { + args: { series: [SERIES_A, SERIES_B], stacked: true }, + play: async ({ canvas }) => { + const bands = await waitFor(() => canvas.getAllByTestId(DATA_TEST_ID.BAND)); + + await userEvent.hover(bands[2]); + + // The readout names the category and lists every series in the stack, so + // the pointer never has to land on one segment to read it. + const tooltip = await waitFor(() => canvas.getByTestId(TOOLTIP_TEST_ID.CONTAINER)); + await expect(within(tooltip).getByText("Mar")).toBeInTheDocument(); + await expect(within(tooltip).getAllByTestId(TOOLTIP_TEST_ID.ROW)).toHaveLength(2); + }, +}; + +export const Empty: Story = { + args: { categories: [], series: [] }, + play: async ({ canvas }) => { + await expect(canvas.getByTestId(FRAME_TEST_ID.EMPTY)).toBeVisible(); + }, +}; diff --git a/frontend/src/components/charts/BarChart/BarChart.test.tsx b/frontend/src/components/charts/BarChart/BarChart.test.tsx new file mode 100644 index 0000000..2b37a80 --- /dev/null +++ b/frontend/src/components/charts/BarChart/BarChart.test.tsx @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; +import { render, screen, within } from "@/_test_utilities/test-utils"; +import { BarChart, DATA_TEST_ID, type BarChartSeries } from "./BarChart"; +import { DATA_TEST_ID as FRAME_TEST_ID } from "@/components/charts/components/ChartFrame"; +import { DATA_TEST_ID as LEGEND_TEST_ID } from "@/components/charts/components/ChartLegend"; + +const LABEL = "New and returning users by month"; +const CATEGORIES = ["Jul", "Aug", "Sep", "Oct"]; + +const NEW_USERS: BarChartSeries = { id: "new", label: "New", values: [155, 96, 160, 152] }; +const RETURNING_USERS: BarChartSeries = { id: "returning", label: "Returning", values: [63, 41, 66, 58] }; + +/** Bars are paths, so width comes from the geometry rather than an attribute. */ +function barWidths(): number[] { + return screen.getAllByTestId(DATA_TEST_ID.BAR).map((bar) => { + const xs = [...(bar.getAttribute("d") ?? "").matchAll(/[ML](-?[\d.]+),/g)].map((match) => Number(match[1])); + return Math.max(...xs) - Math.min(...xs); + }); +} + +describe("BarChart", () => { + it("should draw one bar per category for a single series", () => { + // GIVEN one series across four months + // WHEN it is rendered + render(); + + // THEN there is a bar per month, and no legend to restate the title + expect(screen.getAllByTestId(DATA_TEST_ID.BAR)).toHaveLength(CATEGORIES.length); + expect(screen.queryByTestId(LEGEND_TEST_ID.CONTAINER)).not.toBeInTheDocument(); + }); + + it("should stack a segment per series into each column", () => { + // GIVEN two series over the same months + // WHEN they are stacked + render(); + + // THEN every column carries a segment for each series, named by the legend + expect(screen.getAllByTestId(DATA_TEST_ID.BAR)).toHaveLength(CATEGORIES.length * 2); + const actualLegend = within(screen.getByTestId(LEGEND_TEST_ID.CONTAINER)); + expect(actualLegend.getByText("New")).toBeInTheDocument(); + expect(actualLegend.getByText("Returning")).toBeInTheDocument(); + }); + + it("should give a stacked chart's data table the column total, since that is what the stack shows", () => { + // GIVEN two stacked series + // WHEN they are rendered + render(); + + // THEN the table carries each part and the whole the column is read against + const actualTable = within(screen.getByTestId(FRAME_TEST_ID.TABLE)); + expect(actualTable.getByRole("columnheader", { name: "Total" })).toBeInTheDocument(); + expect(actualTable.getByRole("cell", { name: "218" })).toBeInTheDocument(); + }); + + it("should leave the total out of a grouped chart's table, which has no stack to sum", () => { + // GIVEN two series rendered side by side + // WHEN they are rendered + render(); + + // THEN each series still gets a column, but nothing claims a combined figure + const actualTable = within(screen.getByTestId(FRAME_TEST_ID.TABLE)); + expect(actualTable.getByRole("columnheader", { name: "New" })).toBeInTheDocument(); + expect(actualTable.queryByRole("columnheader", { name: "Total" })).not.toBeInTheDocument(); + }); + + it("should place grouped bars side by side, narrower than a stacked one", () => { + // GIVEN two series rendered stacked + const { unmount } = render( + + ); + const expectedStackedWidth = barWidths()[0]; + unmount(); + + // WHEN the same two series are grouped instead + render(); + + // THEN each bar takes its share of the slot, so they fit beside each other + expect(screen.getAllByTestId(DATA_TEST_ID.BAR)).toHaveLength(CATEGORIES.length * 2); + expect(barWidths()[0]).toBeLessThan(expectedStackedWidth); + }); + + it("should cap bar width, so a bar never fills its whole band", () => { + // GIVEN a chart with only two categories in a wide container + // WHEN it is rendered + render(); + + // THEN the bars stay thin and the band's leftover reads as deliberate air + for (const width of barWidths()) { + expect(width).toBeLessThanOrEqual(24); + } + }); + + it("should give every column a full-height hit target, not just its painted bar", () => { + // GIVEN a chart with a short bar in it + // WHEN it is rendered + render(); + + // THEN each category has a band covering the whole column height + expect(screen.getAllByTestId(DATA_TEST_ID.BAND)).toHaveLength(CATEGORIES.length); + }); + + it("should draw no bar for a category with no value", () => { + // GIVEN a series with a zero in it + const withGap: BarChartSeries = { id: "new", label: "New", values: [155, 0, 160, 152] }; + + // WHEN it is rendered + render(); + + // THEN the empty month gets nothing rather than a hairline pretending to be a value + expect(screen.getAllByTestId(DATA_TEST_ID.BAR)).toHaveLength(3); + }); + + it("should show the empty state when there is nothing to plot", () => { + // GIVEN no categories and no series + // WHEN the chart is rendered + const { unmount } = render(); + + // THEN the reader is told so + expect(screen.getByTestId(FRAME_TEST_ID.EMPTY)).toBeInTheDocument(); + unmount(); + + // AND the same when there are months but no series to plot against them + render(); + expect(screen.getByTestId(FRAME_TEST_ID.EMPTY)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/charts/BarChart/BarChart.tsx b/frontend/src/components/charts/BarChart/BarChart.tsx new file mode 100644 index 0000000..b175ac8 --- /dev/null +++ b/frontend/src/components/charts/BarChart/BarChart.tsx @@ -0,0 +1,195 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ChartGrid, ChartXLabels } from "@/components/charts/components/ChartAxes"; +import { ChartFrame, type ChartTable } from "@/components/charts/components/ChartFrame"; +import { ChartLegend } from "@/components/charts/components/ChartLegend"; +import { ChartTooltip } from "@/components/charts/components/ChartTooltip"; +import { seriesColorAt } from "@/components/charts/chart-palette"; +import { + axisMax, + bandCenter, + formatNumber, + niceTicks, + plotFrom, + topRoundedRectPath, + type ChartMargin, +} from "@/components/charts/chart-scale"; + +const uniqueId = "e5a72c31-8b04-4f9d-a1c6-3d90b7e284fa"; + +export const DATA_TEST_ID = { + BAND: `bar-chart-band-${uniqueId}`, + BAR: `bar-chart-bar-${uniqueId}`, +}; + +export interface BarChartSeries { + id: string; + label: string; + values: readonly number[]; +} + +export interface BarChartProps { + label: string; + categories: readonly string[]; + series: readonly BarChartSeries[]; + stacked?: boolean; + height?: number; + isLoading?: boolean; + emptyMessage?: string; + categoryLabel?: string; + valueFormatter?: (value: number) => string; + className?: string; +} + +const MARGIN: ChartMargin = { top: 12, right: 12, bottom: 28, left: 44 }; +/** Capped rather than filling the band — the leftover is deliberate air. */ +const MAX_BAR_WIDTH = 24; +const BAND_PADDING = 0.3; +const GAP = 2; +const CORNER_RADIUS = 4; + +export function BarChart({ + label, + categories, + series, + stacked = false, + height = 240, + isLoading = false, + emptyMessage, + categoryLabel, + valueFormatter = formatNumber, + className, +}: Readonly) { + const { t } = useTranslation(); + const [hovered, setHovered] = useState<{ index: number; x: number; y: number } | null>(null); + + const isEmpty = categories.length === 0 || series.length === 0; + const isStacked = stacked && series.length > 1; + + // A stack is read against its total; grouped bars against the tallest single bar. + const columnTotals = categories.map((_, index) => + series.reduce((total, line) => total + (line.values[index] ?? 0), 0) + ); + const peak = isStacked ? Math.max(0, ...columnTotals) : Math.max(0, ...series.flatMap((line) => line.values)); + const max = axisMax(peak); + const ticks = niceTicks(peak); + + const table: ChartTable = { + caption: label, + columns: [ + categoryLabel ?? t("charts.table.period"), + ...series.map((line) => line.label), + ...(isStacked ? [t("charts.table.total")] : []), + ], + rows: categories.map((category, index) => ({ + header: category, + cells: [ + ...series.map((line) => valueFormatter(line.values[index] ?? 0)), + ...(isStacked ? [valueFormatter(columnTotals[index])] : []), + ], + })), + }; + + return ( + 1 ? ( + ({ id: line.id, label: line.label, color: seriesColorAt(index) }))} + /> + ) : null + } + overlay={(width) => + hovered && ( + ({ + label: line.label, + value: valueFormatter(line.values[hovered.index] ?? 0), + color: seriesColorAt(index), + }))} + /> + ) + } + > + {(width) => { + const plot = plotFrom(width, height, MARGIN); + const bandWidth = plot.width / categories.length; + const slotWidth = Math.min(MAX_BAR_WIDTH, bandWidth * (1 - BAND_PADDING)); + // Grouped series split the slot between them, each keeping the gap. + const barWidth = isStacked || series.length === 1 ? slotWidth : Math.max(2, slotWidth / series.length - GAP); + + return ( + <> + + bandCenter(index, categories.length, plot)} /> + + {categories.map((category, index) => { + const center = bandCenter(index, categories.length, plot); + const baseline = plot.top + plot.height; + let stackTop = baseline; + + return ( + + {/* Full-height band, so a short bar is still easy to hit. */} + { + const bounds = event.currentTarget.ownerSVGElement?.getBoundingClientRect(); + if (!bounds) return; + setHovered({ index, x: event.clientX - bounds.left, y: event.clientY - bounds.top }); + }} + onPointerLeave={() => setHovered(null)} + /> + {series.map((line, seriesIndex) => { + const value = line.values[index] ?? 0; + const barHeight = max > 0 ? (value / max) * plot.height : 0; + if (barHeight <= 0) return null; + + const x = isStacked + ? center - barWidth / 2 + : center - slotWidth / 2 + seriesIndex * (barWidth + GAP); + + // Segments are separated by the 2px surface gap, not a stroke. + const y = isStacked ? stackTop - barHeight : baseline - barHeight; + const drawnHeight = isStacked && seriesIndex > 0 ? Math.max(0, barHeight - GAP) : barHeight; + if (isStacked) stackTop -= barHeight; + + // Only the column's top is rounded; bars stay square at the baseline. + const isTop = !isStacked || seriesIndex === series.length - 1; + + return ( + + ); + })} + + ); + })} + + ); + }} + + ); +} diff --git a/frontend/src/components/charts/BarChart/index.ts b/frontend/src/components/charts/BarChart/index.ts new file mode 100644 index 0000000..ea65af2 --- /dev/null +++ b/frontend/src/components/charts/BarChart/index.ts @@ -0,0 +1 @@ +export * from "./BarChart"; diff --git a/frontend/src/components/charts/DonutChart/DonutChart.stories.tsx b/frontend/src/components/charts/DonutChart/DonutChart.stories.tsx new file mode 100644 index 0000000..14fb101 --- /dev/null +++ b/frontend/src/components/charts/DonutChart/DonutChart.stories.tsx @@ -0,0 +1,138 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "storybook/test"; +import { DonutChart, DATA_TEST_ID, type DonutChartProps } from "./DonutChart"; +import { DATA_TEST_ID as LEGEND_TEST_ID } from "@/components/charts/components/ChartLegend"; + +// Placeholder data throughout — the real copy is not settled. +const TWO_GROUPS = [ + { id: "a", label: "Group A", value: 60 }, + { id: "b", label: "Group B", value: 40 }, +]; + +const THREE_GROUPS = [ + { id: "a", label: "Group A", value: 52 }, + { id: "b", label: "Group B", value: 41 }, + { id: "c", label: "Group C", value: 7 }, +]; + +// The donut is controlled, so the story owns the selection to keep the legend interactive. +function ControlledDonutChart({ selectedId, onSelect, ...props }: Readonly) { + const [value, setValue] = useState(selectedId ?? null); + + return ( + { + setValue(next); + onSelect?.(next); + }} + /> + ); +} + +const meta = { + component: DonutChart, + tags: ["autodocs"], + args: { + label: "Share by group", + slices: TWO_GROUPS, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvas }) => { + await expect(canvas.getAllByTestId(DATA_TEST_ID.SEGMENT)).toHaveLength(2); + await expect(canvas.getByRole("img", { name: "Share by group" })).toBeInTheDocument(); + + // The legend carries each slice's share, so no angle has to be judged. + const legend = within(canvas.getByTestId(LEGEND_TEST_ID.CONTAINER)); + await expect(legend.getByText("60%")).toBeVisible(); + await expect(legend.getByText("40%")).toBeVisible(); + }, +}; + +export const WithCenterLabel: Story = { + args: { + centerLabel: "2.2", + centerCaption: "per group", + }, + play: async ({ canvas }) => { + const center = canvas.getByTestId(DATA_TEST_ID.CENTER_LABEL); + + await expect(center).toHaveTextContent("2.2"); + // Hidden from assistive tech: the figure belongs to the stat beside the + // chart, so announcing it here would only duplicate it. + await expect(center).toHaveAttribute("aria-hidden", "true"); + }, +}; + +export const ThreeSlices: Story = { + args: { slices: THREE_GROUPS }, + play: async ({ canvas }) => { + await expect(canvas.getAllByTestId(DATA_TEST_ID.SEGMENT)).toHaveLength(3); + }, +}; + +export const WithSelection: Story = { + args: { slices: THREE_GROUPS, selectedId: "a" }, + render: (args) => , + play: async ({ canvas }) => { + // The legend rows are the keyboard path to the filter — the ring is one + // opaque node to assistive tech, so the buttons have to live outside it. + await expect(canvas.getByRole("button", { name: /Group A/ })).toHaveAttribute("aria-pressed", "true"); + await expect(canvas.getByRole("button", { name: /Group B/ })).toHaveAttribute("aria-pressed", "false"); + }, +}; + +export const SelectingASlice: Story = { + args: { slices: THREE_GROUPS }, + render: (args) => , + play: async ({ canvas }) => { + await userEvent.click(canvas.getByRole("button", { name: /Group B/ })); + + await expect(canvas.getByRole("button", { name: /Group B/ })).toHaveAttribute("aria-pressed", "true"); + }, +}; + +export const ClearingTheSelection: Story = { + args: { slices: THREE_GROUPS, selectedId: "b" }, + render: (args) => , + play: async ({ canvas }) => { + // Picking the selected slice again clears the filter. + await userEvent.click(canvas.getByRole("button", { name: /Group B/ })); + + await expect(canvas.getByRole("button", { name: /Group B/ })).toHaveAttribute("aria-pressed", "false"); + }, +}; + +// A full ring, with no gap cut into it — a gap here would read as a missing +// segment rather than as 100%. +export const SingleSlice: Story = { + args: { + slices: [{ id: "a", label: "Group A", value: 100 }], + }, + play: async ({ canvas }) => { + await expect(canvas.getAllByTestId(DATA_TEST_ID.SEGMENT)).toHaveLength(1); + }, +}; + +export const Empty: Story = { + args: { slices: [] }, + play: async ({ canvas }) => { + const empty = canvas.getByTestId(DATA_TEST_ID.EMPTY); + + await expect(within(empty).getByRole("status")).toBeVisible(); + }, +}; diff --git a/frontend/src/components/charts/DonutChart/DonutChart.test.tsx b/frontend/src/components/charts/DonutChart/DonutChart.test.tsx new file mode 100644 index 0000000..74d1332 --- /dev/null +++ b/frontend/src/components/charts/DonutChart/DonutChart.test.tsx @@ -0,0 +1,133 @@ +import { describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { render, screen, within } from "@/_test_utilities/test-utils"; +import { DonutChart, DATA_TEST_ID, type DonutSlice } from "./DonutChart"; +import { DATA_TEST_ID as LEGEND_TEST_ID } from "@/components/charts/components/ChartLegend"; + +const LABEL = "Share by group"; + +// Placeholder fixtures — the real copy is not settled. +const GROUPS: readonly DonutSlice[] = [ + { id: "a", label: "Group A", value: 52 }, + { id: "b", label: "Group B", value: 41 }, + { id: "c", label: "Group C", value: 7 }, +]; + +const SINGLE_SLICE: readonly DonutSlice[] = [{ id: "a", label: "Group A", value: 100 }]; + +describe("DonutChart", () => { + it("should name the plot and draw a segment per slice", () => { + // GIVEN a three-way split + // WHEN it is rendered + render(); + + // THEN the ring is one named image made of three segments + expect(screen.getByRole("img", { name: LABEL })).toBeInTheDocument(); + expect(screen.getAllByTestId(DATA_TEST_ID.SEGMENT)).toHaveLength(GROUPS.length); + }); + + it("should give every slice its share in the legend, so no angle has to be judged", () => { + // GIVEN a split that does not divide evenly + // WHEN it is rendered + render(); + + // THEN each entry is named and carries its percentage + const actualLegend = within(screen.getByTestId(LEGEND_TEST_ID.CONTAINER)); + expect(actualLegend.getByText("Group A")).toBeInTheDocument(); + expect(actualLegend.getByText("52%")).toBeInTheDocument(); + expect(actualLegend.getByText("7%")).toBeInTheDocument(); + }); + + it("should keep every slice reachable in the data table", () => { + // GIVEN a donut whose values are otherwise only in the legend + // WHEN it is rendered + render(); + + // THEN the table lists each slice with its value and its share + const actualTable = within(screen.getByRole("table", { name: LABEL })); + expect(actualTable.getByRole("rowheader", { name: "Group B" })).toBeInTheDocument(); + expect(actualTable.getByRole("cell", { name: "41" })).toBeInTheDocument(); + }); + + it("should show the centre figure without announcing it twice", () => { + // GIVEN a donut with a figure in the hole + // WHEN it is rendered + render(); + + // THEN the figure is visible but hidden from assistive tech, since it + // belongs to the stat beside the chart + const actualCenter = screen.getByTestId(DATA_TEST_ID.CENTER_LABEL); + expect(actualCenter).toHaveTextContent("2.2"); + expect(actualCenter).toHaveTextContent("avg logins / user"); + expect(actualCenter).toHaveAttribute("aria-hidden", "true"); + }); + + it("should leave the hole empty when there is no centre figure", () => { + // GIVEN a donut with nothing to put in the middle + // WHEN it is rendered + render(); + + // THEN the middle stays empty + expect(screen.queryByTestId(DATA_TEST_ID.CENTER_LABEL)).not.toBeInTheDocument(); + }); + + it("should offer the filter through the legend, since the ring itself is opaque to assistive tech", async () => { + // GIVEN a donut whose slices filter + const onSelect = vi.fn(); + render(); + + // WHEN a legend entry is picked + await userEvent.click(screen.getByRole("button", { name: /Group B/ })); + + // THEN the filter is raised for that slice + expect(onSelect).toHaveBeenCalledWith("b"); + }); + + it("should clear the filter when the selected slice is picked again", async () => { + // GIVEN a donut with a slice already selected + const onSelect = vi.fn(); + render(); + + // AND the selection is exposed as a pressed toggle + expect(screen.getByRole("button", { name: /Group A/ })).toHaveAttribute("aria-pressed", "true"); + + // WHEN that same entry is picked + await userEvent.click(screen.getByRole("button", { name: /Group A/ })); + + // THEN the filter clears + expect(onSelect).toHaveBeenCalledWith(null); + }); + + it("should draw a single slice as a full ring, with no gap cut into it", () => { + // GIVEN a whole made of one slice + // WHEN it is rendered + render(); + + // THEN it is drawn as two half-arcs closing a full ring, since a gap here + // would read as a missing segment rather than as 100% + const [actualSegment] = screen.getAllByTestId(DATA_TEST_ID.SEGMENT); + expect(actualSegment.getAttribute("d")?.match(/A/g)).toHaveLength(4); + }); + + it("should show the empty state when there is nothing to divide", () => { + // GIVEN no slices at all + const { unmount } = render(); + + // THEN the reader is told so + expect(screen.getByTestId(DATA_TEST_ID.EMPTY)).toBeInTheDocument(); + unmount(); + + // AND the same when the slices exist but add up to nothing, which has no + // shares to draw + render( + + ); + expect(screen.getByTestId(DATA_TEST_ID.EMPTY)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/charts/DonutChart/DonutChart.tsx b/frontend/src/components/charts/DonutChart/DonutChart.tsx new file mode 100644 index 0000000..cc8278e --- /dev/null +++ b/frontend/src/components/charts/DonutChart/DonutChart.tsx @@ -0,0 +1,197 @@ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { ChartEmpty } from "@/components/charts/components/ChartEmpty"; +import { cn } from "@/lib/utils"; +import { ChartDataTable, type ChartTable } from "@/components/charts/components/ChartFrame"; +import { ChartLegend } from "@/components/charts/components/ChartLegend"; +import { seriesColorAt } from "@/components/charts/chart-palette"; +import { formatNumber, percentageOf } from "@/components/charts/chart-scale"; + +const uniqueId = "4b8e1d67-c052-4937-8ab4-e91f7c60d3b8"; + +export const DATA_TEST_ID = { + CONTAINER: `donut-chart-container-${uniqueId}`, + PLOT: `donut-chart-plot-${uniqueId}`, + SEGMENT: `donut-chart-segment-${uniqueId}`, + CENTER_LABEL: `donut-chart-center-label-${uniqueId}`, + EMPTY: `donut-chart-empty-${uniqueId}`, +}; + +export interface DonutSlice { + id: string; + label: string; + value: number; +} + +export interface DonutChartProps { + label: string; + slices: readonly DonutSlice[]; + centerLabel?: ReactNode; + centerCaption?: string; + onSelect?: (id: string | null) => void; + selectedId?: string | null; + size?: number; + emptyMessage?: string; + valueFormatter?: (value: number) => string; + className?: string; +} + +const THICKNESS = 26; +const GAP_DEGREES = 2; + +function polar(center: number, radius: number, degrees: number): [number, number] { + // -90° so the first segment starts at 12 o'clock. + const radians = ((degrees - 90) * Math.PI) / 180; + return [ + Math.round((center + radius * Math.cos(radians)) * 100) / 100, + Math.round((center + radius * Math.sin(radians)) * 100) / 100, + ]; +} + +/** An annular sector between two angles, clockwise from 12 o'clock. */ +function arcPath(center: number, outer: number, inner: number, startDeg: number, endDeg: number): string { + const sweep = endDeg - startDeg; + if (sweep <= 0) return ""; + + // A 360° arc has coincident endpoints, so a full ring needs two half-arcs. + if (sweep >= 360) { + return [ + `M${center},${center - outer}`, + `A${outer},${outer} 0 1 1 ${center},${center + outer}`, + `A${outer},${outer} 0 1 1 ${center},${center - outer}`, + `M${center},${center - inner}`, + `A${inner},${inner} 0 1 0 ${center},${center + inner}`, + `A${inner},${inner} 0 1 0 ${center},${center - inner}`, + "Z", + ].join(" "); + } + + const largeArc = sweep > 180 ? 1 : 0; + const [ox1, oy1] = polar(center, outer, startDeg); + const [ox2, oy2] = polar(center, outer, endDeg); + const [ix1, iy1] = polar(center, inner, endDeg); + const [ix2, iy2] = polar(center, inner, startDeg); + + return [ + `M${ox1},${oy1}`, + `A${outer},${outer} 0 ${largeArc} 1 ${ox2},${oy2}`, + `L${ix1},${iy1}`, + `A${inner},${inner} 0 ${largeArc} 0 ${ix2},${iy2}`, + "Z", + ].join(" "); +} + +export function DonutChart({ + label, + slices, + centerLabel, + centerCaption, + onSelect, + selectedId, + size = 180, + emptyMessage, + valueFormatter = formatNumber, + className, +}: Readonly) { + const { t } = useTranslation(); + const total = slices.reduce((sum, slice) => sum + slice.value, 0); + const isInteractive = Boolean(onSelect); + + if (slices.length === 0 || total <= 0) { + return ( +
+ +
+ ); + } + + const table: ChartTable = { + caption: label, + columns: [t("charts.table.category"), t("charts.table.value"), t("charts.table.share")], + rows: slices.map((slice) => ({ + header: slice.label, + cells: [valueFormatter(slice.value), `${percentageOf(slice.value, total)}%`], + })), + }; + + const radius = size / 2; + const center = radius; + const innerRadius = radius - THICKNESS; + // A gap cut into a single slice would read as a missing segment, not 100%. + const gap = slices.length > 1 ? GAP_DEGREES : 0; + + let angle = 0; + + return ( +
+
+ + {slices.map((slice, index) => { + const sweep = (slice.value / total) * 360; + const start = angle; + angle += sweep; + + const isSelected = selectedId === slice.id; + const isDimmed = isInteractive && selectedId != null && !isSelected; + + return ( + onSelect?.(isSelected ? null : slice.id) : undefined} + className={cn( + "transition-opacity duration-(--duration-fast)", + isInteractive && "cursor-pointer hover:opacity-80" + )} + /> + ); + })} + + {centerLabel != null && ( + + )} +
+ + ({ + id: slice.id, + label: slice.label, + color: seriesColorAt(index), + value: `${percentageOf(slice.value, total)}%`, + }))} + onSelect={onSelect} + selectedId={selectedId} + className="min-w-0 flex-1" + /> + + +
+ ); +} diff --git a/frontend/src/components/charts/DonutChart/index.ts b/frontend/src/components/charts/DonutChart/index.ts new file mode 100644 index 0000000..8aae8be --- /dev/null +++ b/frontend/src/components/charts/DonutChart/index.ts @@ -0,0 +1 @@ +export * from "./DonutChart"; diff --git a/frontend/src/components/charts/GaugeBar/GaugeBar.stories.tsx b/frontend/src/components/charts/GaugeBar/GaugeBar.stories.tsx new file mode 100644 index 0000000..d29f9e5 --- /dev/null +++ b/frontend/src/components/charts/GaugeBar/GaugeBar.stories.tsx @@ -0,0 +1,111 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; +import { GaugeBar, DATA_TEST_ID } from "./GaugeBar"; +import { ChartLegend } from "@/components/charts/components/ChartLegend"; + +// Placeholder data throughout — the real copy is not settled. +const ITEMS = [ + { label: "Item A", value: 561, secondaryValue: 1016 }, + { label: "Item B", value: 926, secondaryValue: 1415 }, + { label: "Item C", value: 724, secondaryValue: 1073 }, + { label: "Item D", value: 592, secondaryValue: 1000 }, +]; + +const SCALE = 2000; + +const LEGEND = [ + { id: "done", label: "Completed", color: "var(--chart-progress-done)" }, + { id: "active", label: "In progress", color: "var(--chart-progress-active)" }, + { id: "not-started", label: "Not started", color: "var(--chart-track)" }, +]; + +const meta = { + component: GaugeBar, + tags: ["autodocs"], + args: { + label: "Item A", + value: 561, + secondaryValue: 1016, + max: SCALE, + valueLabel: "completed", + secondaryValueLabel: "started", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvas }) => { + // Both figures are written out, so the decorative track adds nothing a + // screen reader has to parse. + await expect(canvas.getByTestId(DATA_TEST_ID.CAPTION)).toHaveTextContent("561 completed · 1,016 started"); + await expect(canvas.getByTestId(DATA_TEST_ID.TRACK)).toHaveAttribute("aria-hidden", "true"); + }, +}; + +// A group of rows on one shared scale, with the legend below rather than +// repeated per bar — how the completions card is composed. +export const SeveralRows: Story = { + name: "Several rows on a shared scale", + render: () => ( +
+ {ITEMS.map((item) => ( + + ))} + +
+ ), + play: async ({ canvas }) => { + await expect(canvas.getAllByTestId(DATA_TEST_ID.CONTAINER)).toHaveLength(ITEMS.length); + await expect(canvas.getByText("Not started")).toBeVisible(); + }, +}; + +// Without a shared maximum each row scales to its own outer figure, so the +// bars stop being comparable — useful for a single standalone measure only. +export const WithoutASharedScale: Story = { + args: { max: undefined }, + play: async ({ canvas }) => { + await expect(Number.parseFloat(canvas.getByTestId(DATA_TEST_ID.ACTIVE).style.width)).toBe(100); + }, +}; + +export const SingleSegment: Story = { + args: { secondaryValue: undefined, value: 750, max: 1000, valueLabel: "completed" }, + play: async ({ canvas }) => { + await expect(canvas.getByTestId(DATA_TEST_ID.CAPTION)).toHaveTextContent("750 completed"); + }, +}; + +export const NothingStartedYet: Story = { + args: { value: 0, secondaryValue: 0 }, + play: async ({ canvas }) => { + await expect(Number.parseFloat(canvas.getByTestId(DATA_TEST_ID.DONE).style.width)).toBe(0); + }, +}; + +export const StartedButNoneComplete: Story = { + args: { value: 0, secondaryValue: 120 }, + play: async ({ canvas }) => { + const track = within(canvas.getByTestId(DATA_TEST_ID.TRACK)); + + await expect(track.getByTestId(DATA_TEST_ID.ACTIVE)).toBeInTheDocument(); + await expect(Number.parseFloat(canvas.getByTestId(DATA_TEST_ID.DONE).style.width)).toBe(0); + }, +}; diff --git a/frontend/src/components/charts/GaugeBar/GaugeBar.test.tsx b/frontend/src/components/charts/GaugeBar/GaugeBar.test.tsx new file mode 100644 index 0000000..f4403d6 --- /dev/null +++ b/frontend/src/components/charts/GaugeBar/GaugeBar.test.tsx @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@/_test_utilities/test-utils"; +import { GaugeBar, DATA_TEST_ID } from "./GaugeBar"; + +const LABEL = "CV Builder"; + +function segmentWidths(): { done: number; active: number } { + return { + done: Number.parseFloat(screen.getByTestId(DATA_TEST_ID.DONE).style.width), + active: Number.parseFloat(screen.getByTestId(DATA_TEST_ID.ACTIVE).style.width), + }; +} + +describe("GaugeBar", () => { + it("should write both figures out beside the bar, since the bar itself is decorative", () => { + // GIVEN a module with a completed and a started count + // WHEN it is rendered + render( + + ); + + // THEN the row reads as text, and the track adds nothing for a screen reader + expect(screen.getByText(LABEL)).toBeInTheDocument(); + expect(screen.getByTestId(DATA_TEST_ID.CAPTION)).toHaveTextContent("561 completed · 1,016 started"); + expect(screen.getByTestId(DATA_TEST_ID.TRACK)).toHaveAttribute("aria-hidden", "true"); + }); + + it("should size both segments against the shared scale, not against each other", () => { + // GIVEN a row on a scale of 2,000 shared with its neighbours + // WHEN it is rendered + render(); + + // THEN each segment is its own share of that scale, so rows stay comparable + const actual = segmentWidths(); + expect(actual.done).toBeCloseTo(28.05, 1); + expect(actual.active).toBeCloseTo(50.8, 1); + }); + + it("should nest the completed segment inside the started one", () => { + // GIVEN more started than completed + // WHEN it is rendered + render(); + + // THEN the outer segment always reaches further, so the inner reads as part + // of it rather than as a competing bar + const actual = segmentWidths(); + expect(actual.active).toBeGreaterThan(actual.done); + }); + + it("should fall back to the outer figure as the scale when none is shared", () => { + // GIVEN a lone row with no shared maximum + // WHEN it is rendered + render(); + + // THEN the started figure fills the track and completed takes its half + const actual = segmentWidths(); + expect(actual.active).toBe(100); + expect(actual.done).toBe(50); + }); + + it("should draw a single segment when there is no outer figure", () => { + // GIVEN a measure with only a completed count + // WHEN it is rendered + render(); + + // THEN both segments land on the same width, so only one is visible + const actual = segmentWidths(); + expect(actual.done).toBe(75); + expect(actual.active).toBe(75); + expect(screen.getByTestId(DATA_TEST_ID.CAPTION)).toHaveTextContent("750 completed"); + }); + + it("should clamp a segment that overruns the scale", () => { + // GIVEN a row whose figures exceed the shared maximum + // WHEN it is rendered + render(); + + // THEN neither segment runs past the end of the track + const actual = segmentWidths(); + expect(actual.done).toBe(100); + expect(actual.active).toBe(100); + }); + + it("should draw nothing on the track when nothing has happened yet", () => { + // GIVEN a module nobody has started + // WHEN it is rendered + render(); + + // THEN the track is empty rather than showing a hairline of progress + const actual = segmentWidths(); + expect(actual.done).toBe(0); + expect(actual.active).toBe(0); + }); + + it("should treat an outer figure below the completed one as no smaller than it", () => { + // GIVEN inconsistent data, where fewer started than completed + // WHEN it is rendered + render(); + + // THEN the outer segment never renders shorter than the inner, which would + // read as a completed count spilling out of its own total + const actual = segmentWidths(); + expect(actual.active).toBeGreaterThanOrEqual(actual.done); + }); + + it("should format both figures the way the caller asks", () => { + // GIVEN a formatter for the unit the values are in + const minutes = (value: number) => `${value}m`; + + // WHEN the row is rendered with it + render( + + ); + + // THEN the caption carries the unit on both figures + expect(screen.getByTestId(DATA_TEST_ID.CAPTION)).toHaveTextContent("12m spent · 15m budget"); + }); +}); diff --git a/frontend/src/components/charts/GaugeBar/GaugeBar.tsx b/frontend/src/components/charts/GaugeBar/GaugeBar.tsx new file mode 100644 index 0000000..52c0206 --- /dev/null +++ b/frontend/src/components/charts/GaugeBar/GaugeBar.tsx @@ -0,0 +1,83 @@ +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/utils"; +import { formatNumber } from "@/components/charts/chart-scale"; + +const uniqueId = "a2f70e59-6c81-4bd3-9074-51e3a8c6b207"; + +export const DATA_TEST_ID = { + CONTAINER: `gauge-bar-container-${uniqueId}`, + CAPTION: `gauge-bar-caption-${uniqueId}`, + TRACK: `gauge-bar-track-${uniqueId}`, + DONE: `gauge-bar-done-${uniqueId}`, + ACTIVE: `gauge-bar-active-${uniqueId}`, +}; + +export interface GaugeBarProps { + label: string; + value: number; + secondaryValue?: number; + max?: number; + valueLabel?: string; + secondaryValueLabel?: string; + valueFormatter?: (value: number) => string; + className?: string; +} + +function percentageOfScale(value: number, max: number): number { + if (max <= 0) return 0; + return Math.min(100, Math.max(0, (value / max) * 100)); +} + +export function GaugeBar({ + label, + value, + secondaryValue, + max, + valueLabel, + secondaryValueLabel, + valueFormatter = formatNumber, + className, +}: Readonly) { + const { t } = useTranslation(); + + const outer = Math.max(value, secondaryValue ?? value); + const scaleMax = max ?? outer; + + const caption = + secondaryValue == null + ? t("charts.gaugeBar.oneValue", { value: valueFormatter(value), valueLabel }) + : t("charts.gaugeBar.twoValues", { + value: valueFormatter(value), + valueLabel, + secondaryValue: valueFormatter(secondaryValue), + secondaryLabel: secondaryValueLabel, + }); + + return ( +
+
+ {label} + + {caption} + +
+ {/* One clipping track: outer ends curve, the inner boundary stays square. */} + +
+ ); +} diff --git a/frontend/src/components/charts/GaugeBar/index.ts b/frontend/src/components/charts/GaugeBar/index.ts new file mode 100644 index 0000000..3e98e73 --- /dev/null +++ b/frontend/src/components/charts/GaugeBar/index.ts @@ -0,0 +1 @@ +export * from "./GaugeBar"; diff --git a/frontend/src/components/charts/HBar/HBar.stories.tsx b/frontend/src/components/charts/HBar/HBar.stories.tsx new file mode 100644 index 0000000..28c12d7 --- /dev/null +++ b/frontend/src/components/charts/HBar/HBar.stories.tsx @@ -0,0 +1,124 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; +import { HBar, DATA_TEST_ID } from "./HBar"; +import { seriesColorAt } from "@/components/charts/chart-palette"; + +// Placeholder data throughout — the real copy is not settled. +const CATEGORIES = [ + { id: "a", label: "Category A", value: 188 }, + { id: "b", label: "Category B", value: 152 }, + { id: "c", label: "Category C", value: 137 }, + { id: "d", label: "Category D", value: 130 }, + { id: "e", label: "Category E", value: 116 }, +]; + +const BANDS = [ + { id: "band-1", label: "Band 1", value: 993 }, + { id: "band-2", label: "Band 2", value: 780 }, + { id: "band-3", label: "Band 3", value: 378 }, + { id: "band-4", label: "Band 4", value: 213 }, +]; + +const LEVELS = [ + { id: "level-1", label: "Level 1", value: 331 }, + { id: "level-2", label: "Level 2", value: 1135 }, + { id: "level-3", label: "Level 3", value: 898 }, +]; + +const GROUPS = [ + { id: "group-1", label: "Group 1", value: 419 }, + { id: "group-2", label: "Group 2", value: 304 }, + { id: "group-3", label: "Group 3", value: 435 }, + { id: "group-4", label: "Group 4", value: 643 }, + { id: "group-5", label: "Group 5", value: 563 }, +]; + +const meta = { + component: HBar, + tags: ["autodocs"], + args: { + label: "Values by category", + items: CATEGORIES, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvas }) => { + // A read-only ranking: the label and value are already beside every bar, + // so there is nothing a click would reveal. + await expect(canvas.getAllByTestId(DATA_TEST_ID.ROW)).toHaveLength(CATEGORIES.length); + await expect(canvas.queryAllByRole("button")).toHaveLength(0); + await expect(canvas.getByText("Category A")).toBeVisible(); + // The value sits outside the bar, where a short bar can't clip it. + await expect(canvas.getByText("188")).toBeVisible(); + }, +}; + +// With no visible heading the list is named for assistive tech instead, so the +// breakdown still has a name when the surrounding card supplies the title. +export const WithoutHeading: Story = { + play: async ({ canvas }) => { + await expect(canvas.queryByTestId(DATA_TEST_ID.HEADING)).not.toBeInTheDocument(); + await expect(canvas.getByRole("list", { name: "Values by category" })).toBeInTheDocument(); + }, +}; + +export const WithHeading: Story = { + args: { label: "Band", items: BANDS, showLabel: true }, + play: async ({ canvas }) => { + await expect(canvas.getByTestId(DATA_TEST_ID.HEADING)).toHaveTextContent("Band"); + // Named by the heading rather than by a duplicate aria-label. + await expect(canvas.getByRole("list", { name: "Band" })).toBeInTheDocument(); + }, +}; + +// Scaled against a total rather than the largest row, so the bars read as +// shares of the whole population. +export const AgainstAnExplicitMax: Story = { + args: { + label: "Values by band", + items: BANDS, + max: 2364, + }, + play: async ({ canvas }) => { + const [first] = canvas.getAllByTestId(DATA_TEST_ID.BAR); + + await expect(Number(first.getAttribute("aria-valuenow"))).toBeCloseTo(42, 0); + }, +}; + +// One breakdown per color slot, so groups sitting side by side stay +// distinguishable. +export const SeveralBreakdowns: Story = { + name: "Several breakdowns, one color each", + render: () => ( +
+ + + +
+ ), + play: async ({ canvas }) => { + await expect(canvas.getAllByTestId(DATA_TEST_ID.HEADING)).toHaveLength(3); + await expect(canvas.getByRole("list", { name: "Level" })).toBeInTheDocument(); + }, +}; + +export const Empty: Story = { + args: { items: [] }, + play: async ({ canvas }) => { + const empty = canvas.getByTestId(DATA_TEST_ID.EMPTY); + + await expect(within(empty).getByRole("status")).toBeVisible(); + }, +}; diff --git a/frontend/src/components/charts/HBar/HBar.test.tsx b/frontend/src/components/charts/HBar/HBar.test.tsx new file mode 100644 index 0000000..fad930b --- /dev/null +++ b/frontend/src/components/charts/HBar/HBar.test.tsx @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@/_test_utilities/test-utils"; +import { HBar, DATA_TEST_ID, type HBarItem } from "./HBar"; +import { seriesColorAt } from "@/components/charts/chart-palette"; + +const LABEL = "Values by category"; + +// Placeholder fixtures — the real copy is not settled. +const CATEGORIES: readonly HBarItem[] = [ + { id: "a", label: "Category A", value: 200 }, + { id: "b", label: "Category B", value: 150 }, + { id: "c", label: "Category C", value: 100 }, +]; + +/** shadcn's Progress is a Radix progressbar, so the fill is reported as a value. */ +function fillWidths(): number[] { + return screen.getAllByTestId(DATA_TEST_ID.BAR).map((bar) => Number(bar.getAttribute("aria-valuenow"))); +} + +describe("HBar", () => { + it("should render a row for every item, with its value outside the bar", () => { + // GIVEN a ranked list of categories + // WHEN it is rendered + render(); + + // THEN each category gets a row, and its value sits where a short bar can't clip it + expect(screen.getAllByTestId(DATA_TEST_ID.ROW)).toHaveLength(CATEGORIES.length); + expect(screen.getByText("Category A")).toBeInTheDocument(); + expect(screen.getByText("200")).toBeInTheDocument(); + }); + + it("should size each bar against the largest value in the list", () => { + // GIVEN a list whose top value is 200 + // WHEN it is rendered + render(); + + // THEN the leader fills the track and the rest are drawn to the same scale + expect(fillWidths()).toEqual([100, 75, 50]); + }); + + it("should size bars against an explicit maximum when one is given", () => { + // GIVEN a scale set by the cohort total rather than by the top row + const max = 400; + + // WHEN the list is rendered against it + render(); + + // THEN the bars read as shares of everyone, so no row is forced to 100% + expect(fillWidths()).toEqual([50, 37.5, 25]); + }); + + it("should be a read-only ranking, with nothing offering itself as a control", () => { + // GIVEN a breakdown whose values are already written out beside every bar + // WHEN it is rendered + render(); + + // THEN there is nothing to click, since a click would reveal nothing new + expect(screen.queryAllByRole("button")).toHaveLength(0); + }); + + it("should color the bars with the first slot by default", () => { + // GIVEN a breakdown with no color of its own + // WHEN it is rendered + render(); + + // THEN the bars take the first categorical slot + for (const bar of screen.getAllByTestId(DATA_TEST_ID.BAR)) { + expect(bar.style.getPropertyValue("--h-bar-fill")).toBe(seriesColorAt(0)); + } + }); + + it("should take a color, so breakdowns sitting side by side stay distinguishable", () => { + // GIVEN a second breakdown that needs its own hue + const color = seriesColorAt(1); + + // WHEN it is rendered with that color + render(); + + // THEN every bar in the group uses it + for (const bar of screen.getAllByTestId(DATA_TEST_ID.BAR)) { + expect(bar.style.getPropertyValue("--h-bar-fill")).toBe(color); + } + }); + + it("should name the list for assistive tech when the label is not shown", () => { + // GIVEN a breakdown whose title is supplied by the surrounding card + // WHEN it is rendered without a heading + render(); + + // THEN the list still carries the name, and nothing is drawn twice + expect(screen.getByRole("list", { name: LABEL })).toBeInTheDocument(); + expect(screen.queryByTestId(DATA_TEST_ID.HEADING)).not.toBeInTheDocument(); + }); + + it("should render the label as a heading, and name the list by it, when asked to show it", () => { + // GIVEN a breakdown that carries its own heading + // WHEN it is rendered with the label shown + render(); + + // THEN the heading is visible and names the list, rather than the name + // being announced once as a heading and again as an aria-label + expect(screen.getByTestId(DATA_TEST_ID.HEADING)).toHaveTextContent("Band"); + expect(screen.getByRole("list", { name: "Band" })).toBeInTheDocument(); + expect(screen.getByRole("list")).not.toHaveAttribute("aria-label"); + }); + + it("should hide the decorative bar from assistive tech, since the value is already written out", () => { + // GIVEN a ranked list + // WHEN it is rendered + render(); + + // THEN the bars add no second progressbar announcement per row + for (const bar of screen.getAllByTestId(DATA_TEST_ID.BAR)) { + expect(bar).toHaveAttribute("aria-hidden", "true"); + } + }); + + it("should show the empty state when there is nothing to rank", () => { + // GIVEN no items + // WHEN the list is rendered + render(); + + // THEN the reader is told so rather than shown an empty frame + expect(screen.getByTestId(DATA_TEST_ID.EMPTY)).toBeInTheDocument(); + expect(screen.getByText("No data to show for this selection.")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/charts/HBar/HBar.tsx b/frontend/src/components/charts/HBar/HBar.tsx new file mode 100644 index 0000000..c76daee --- /dev/null +++ b/frontend/src/components/charts/HBar/HBar.tsx @@ -0,0 +1,102 @@ +import { useId, type CSSProperties } from "react"; +import { useTranslation } from "react-i18next"; +import { ChartEmpty } from "@/components/charts/components/ChartEmpty"; +import { Progress } from "@/components/ui/progress"; +import { cn } from "@/lib/utils"; +import { seriesColorAt } from "@/components/charts/chart-palette"; +import { formatNumber } from "@/components/charts/chart-scale"; + +const uniqueId = "0c96e4b2-7d13-4a58-9e07-64b1f8a3c2d5"; + +export const DATA_TEST_ID = { + CONTAINER: `h-bar-container-${uniqueId}`, + HEADING: `h-bar-heading-${uniqueId}`, + ROW: `h-bar-row-${uniqueId}`, + BAR: `h-bar-bar-${uniqueId}`, + EMPTY: `h-bar-empty-${uniqueId}`, +}; + +export interface HBarItem { + id: string; + label: string; + value: number; +} + +export interface HBarProps { + label: string; + items: readonly HBarItem[]; + color?: string; + showLabel?: boolean; + max?: number; + emptyMessage?: string; + valueFormatter?: (value: number) => string; + className?: string; +} + +export function HBar({ + label, + items, + color = seriesColorAt(0), + showLabel = false, + max, + emptyMessage, + valueFormatter = formatNumber, + className, +}: Readonly) { + const { t } = useTranslation(); + const headingId = useId(); + const scaleMax = max ?? Math.max(0, ...items.map((item) => item.value)); + + if (items.length === 0) { + return ( +
+ +
+ ); + } + + const rows = ( +
    + {items.map((item) => { + const percentage = scaleMax > 0 ? (item.value / scaleMax) * 100 : 0; + + return ( +
  • + + {item.label} + {valueFormatter(item.value)} + +
  • + ); + })} +
+ ); + + if (!showLabel) return rows; + + return ( +
+

+ {label} +

+ {rows} +
+ ); +} diff --git a/frontend/src/components/charts/HBar/index.ts b/frontend/src/components/charts/HBar/index.ts new file mode 100644 index 0000000..2e9fe21 --- /dev/null +++ b/frontend/src/components/charts/HBar/index.ts @@ -0,0 +1 @@ +export * from "./HBar"; diff --git a/frontend/src/components/charts/Histogram/Histogram.stories.tsx b/frontend/src/components/charts/Histogram/Histogram.stories.tsx new file mode 100644 index 0000000..7e341f3 --- /dev/null +++ b/frontend/src/components/charts/Histogram/Histogram.stories.tsx @@ -0,0 +1,108 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, waitFor, within } from "storybook/test"; +import { Histogram, DATA_TEST_ID } from "./Histogram"; +import { DATA_TEST_ID as FRAME_TEST_ID } from "@/components/charts/components/ChartFrame"; + +// Placeholder data throughout — the real copy is not settled. Five-unit bins. +const COMPLETION_TIME_BINS = [ + { from: 0, to: 5, count: 42 }, + { from: 5, to: 10, count: 168 }, + { from: 10, to: 15, count: 214 }, + { from: 15, to: 20, count: 131 }, + { from: 20, to: 25, count: 64 }, + { from: 25, to: 30, count: 27 }, +]; + +const UNEVEN_BINS = [ + { from: 0, to: 5, count: 42 }, + { from: 5, to: 10, count: 168 }, + { from: 10, to: 20, count: 345 }, + { from: 20, to: 40, count: 91 }, +]; + +const units = (value: number) => `${value}`; + +const meta = { + component: Histogram, + tags: ["autodocs"], + args: { + label: "Distribution of values", + bins: COMPLETION_TIME_BINS, + boundFormatter: units, + countLabel: "Jobseekers", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvas }) => { + await waitFor( + async () => await expect(canvas.getAllByTestId(DATA_TEST_ID.BIN)).toHaveLength(COMPLETION_TIME_BINS.length) + ); + + await expect(canvas.getByRole("img", { name: "Distribution of values" })).toBeInTheDocument(); + await expect(canvas.queryByTestId(DATA_TEST_ID.TARGET)).not.toBeInTheDocument(); + }, +}; + +export const WithTargetMarker: Story = { + name: "With a target marker", + args: { + target: 15, + targetLabel: "Target 15", + }, + play: async ({ canvas }) => { + // Dashed on purpose: here the dashing means "threshold", which is exactly + // what the line is. Gridlines stay solid. + await waitFor(async () => await expect(canvas.getByTestId(DATA_TEST_ID.TARGET)).toBeInTheDocument()); + + await expect(canvas.getByTestId(DATA_TEST_ID.TARGET)).toHaveAttribute("stroke-dasharray", "4 3"); + await expect(canvas.getByTestId(DATA_TEST_ID.TARGET_LABEL)).toHaveTextContent("Target 15"); + + // Every bin stays reachable without hovering. + const table = within(canvas.getByTestId(FRAME_TEST_ID.TABLE)); + await expect(table.getByRole("rowheader", { name: "10 to 15" })).toBeInTheDocument(); + }, +}; + +// Bins sit on a continuous scale, so a bin twice as wide is drawn twice as +// wide rather than flattened into an equal slot. +export const UnevenBins: Story = { + args: { + bins: UNEVEN_BINS, + target: 15, + targetLabel: "Target 15", + }, + play: async ({ canvas }) => { + const bars = await waitFor(() => canvas.getAllByTestId(DATA_TEST_ID.BIN)); + const widthOf = (index: number) => Number(bars[index].getAttribute("data-bin-width")); + + await expect(widthOf(3)).toBeGreaterThan(widthOf(2)); + await expect(widthOf(2)).toBeGreaterThan(widthOf(1)); + }, +}; + +export const Empty: Story = { + args: { bins: [] }, + play: async ({ canvas }) => { + await expect(canvas.getByTestId(FRAME_TEST_ID.EMPTY)).toBeVisible(); + }, +}; + +export const Loading: Story = { + args: { isLoading: true }, + play: async ({ canvas }) => { + await waitFor( + async () => await expect(canvas.getByTestId(FRAME_TEST_ID.CONTAINER)).toHaveAttribute("aria-busy", "true") + ); + }, +}; diff --git a/frontend/src/components/charts/Histogram/Histogram.test.tsx b/frontend/src/components/charts/Histogram/Histogram.test.tsx new file mode 100644 index 0000000..9984edd --- /dev/null +++ b/frontend/src/components/charts/Histogram/Histogram.test.tsx @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { render, screen, within } from "@/_test_utilities/test-utils"; +import { Histogram, DATA_TEST_ID, type HistogramBin } from "./Histogram"; +import { DATA_TEST_ID as FRAME_TEST_ID } from "@/components/charts/components/ChartFrame"; + +const LABEL = "Time to complete Build Your Profile"; + +/** Build Your Profile: how long jobseekers take to finish, in five-minute bins. */ +const COMPLETION_TIME_BINS: readonly HistogramBin[] = [ + { from: 0, to: 5, count: 42 }, + { from: 5, to: 10, count: 168 }, + { from: 10, to: 15, count: 214 }, + { from: 15, to: 20, count: 131 }, +]; + +const UNEVEN_BINS: readonly HistogramBin[] = [ + { from: 0, to: 5, count: 42 }, + { from: 5, to: 10, count: 168 }, + { from: 10, to: 20, count: 345 }, + { from: 20, to: 40, count: 91 }, +]; + +const minutes = (value: number) => `${value}m`; + +function binWidths(): number[] { + return screen.getAllByTestId(DATA_TEST_ID.BIN).map((bin) => Number(bin.getAttribute("data-bin-width"))); +} + +describe("Histogram", () => { + it("should name the plot and draw a bar per bin", () => { + // GIVEN a distribution across four bins + // WHEN it is rendered + render(); + + // THEN the plot is one named image with a bar for each bin + expect(screen.getByRole("img", { name: LABEL })).toBeInTheDocument(); + expect(screen.getAllByTestId(DATA_TEST_ID.BIN)).toHaveLength(COMPLETION_TIME_BINS.length); + }); + + it("should draw bins on a continuous scale, so an uneven bin stays proportional", () => { + // GIVEN bins of different widths + // WHEN they are rendered + render(); + + // THEN a bin twice as wide is drawn twice as wide, rather than flattened + // into an equal slot + const actualWidths = binWidths(); + expect(actualWidths[2]).toBeGreaterThan(actualWidths[1]); + expect(actualWidths[3]).toBeGreaterThan(actualWidths[2]); + }); + + it("should give bins of equal span the same width", () => { + // GIVEN four evenly spaced bins + // WHEN they are rendered + render(); + + // THEN every bar is the same width, whatever its count + const actualWidths = binWidths(); + for (const width of actualWidths) { + expect(width).toBeCloseTo(actualWidths[0], 5); + } + }); + + it("should mark the target only when there is one, and dash it so it reads as a threshold", () => { + // GIVEN a distribution with no target + const { unmount } = render(); + + // THEN nothing is drawn across the plot + expect(screen.queryByTestId(DATA_TEST_ID.TARGET)).not.toBeInTheDocument(); + unmount(); + + // WHEN the same distribution is given a fifteen-minute target + render( + + ); + + // THEN a dashed marker names the threshold, while the gridlines stay solid + expect(screen.getByTestId(DATA_TEST_ID.TARGET)).toHaveAttribute("stroke-dasharray", "4 3"); + expect(screen.getByTestId(DATA_TEST_ID.TARGET_LABEL)).toHaveTextContent("Target 15m"); + }); + + it("should keep every bin reachable in the data table", () => { + // GIVEN a distribution whose counts are otherwise only shown on hover + // WHEN it is rendered + render(); + + // THEN each bin's range and count are listed in full + const actualTable = within(screen.getByTestId(FRAME_TEST_ID.TABLE)); + expect(actualTable.getByRole("rowheader", { name: "10m to 15m" })).toBeInTheDocument(); + expect(actualTable.getByRole("cell", { name: "214" })).toBeInTheDocument(); + }); + + it("should show the empty state when there is no distribution to bin", () => { + // GIVEN no bins + // WHEN the chart is rendered + render(); + + // THEN the reader is told so instead of being shown an empty grid + expect(screen.getByTestId(FRAME_TEST_ID.EMPTY)).toBeInTheDocument(); + expect(screen.queryByTestId(FRAME_TEST_ID.PLOT)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/charts/Histogram/Histogram.tsx b/frontend/src/components/charts/Histogram/Histogram.tsx new file mode 100644 index 0000000..a8292b8 --- /dev/null +++ b/frontend/src/components/charts/Histogram/Histogram.tsx @@ -0,0 +1,203 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ChartGrid, labelStride } from "@/components/charts/components/ChartAxes"; +import { ChartFrame, type ChartTable } from "@/components/charts/components/ChartFrame"; +import { ChartTooltip } from "@/components/charts/components/ChartTooltip"; +import { CHART_TARGET_COLOR, seriesColorAt } from "@/components/charts/chart-palette"; +import { + axisMax, + formatNumber, + niceTicks, + plotFrom, + topRoundedRectPath, + type ChartMargin, +} from "@/components/charts/chart-scale"; + +const uniqueId = "6e0b3f84-52a7-4c16-b9d8-0741ca25e3b6"; + +export const DATA_TEST_ID = { + BIN: `histogram-bin-${uniqueId}`, + TARGET: `histogram-target-${uniqueId}`, + TARGET_LABEL: `histogram-target-label-${uniqueId}`, +}; + +export interface HistogramBin { + from: number; + to: number; + count: number; +} + +export interface HistogramProps { + label: string; + bins: readonly HistogramBin[]; + target?: number; + targetLabel?: string; + countLabel?: string; + height?: number; + isLoading?: boolean; + emptyMessage?: string; + boundFormatter?: (value: number) => string; + countFormatter?: (value: number) => string; + className?: string; +} + +const MARGIN: ChartMargin = { top: 20, right: 12, bottom: 28, left: 44 }; +const GAP = 2; +const CORNER_RADIUS = 4; + +export function Histogram({ + label, + bins, + target, + targetLabel, + countLabel, + height = 220, + isLoading = false, + emptyMessage, + boundFormatter = formatNumber, + countFormatter = formatNumber, + className, +}: Readonly) { + const { t } = useTranslation(); + const [hovered, setHovered] = useState<{ index: number; x: number; y: number } | null>(null); + + const isEmpty = bins.length === 0; + const peak = Math.max(0, ...bins.map((bin) => bin.count)); + const max = axisMax(peak); + const ticks = niceTicks(peak); + + const domainFrom = Math.min(...bins.map((bin) => bin.from), target ?? Infinity); + const domainTo = Math.max(...bins.map((bin) => bin.to), target ?? -Infinity); + const domainSpan = domainTo - domainFrom || 1; + + const rangeOf = (bin: HistogramBin) => + t("charts.histogram.range", { from: boundFormatter(bin.from), to: boundFormatter(bin.to) }); + + const table: ChartTable = { + caption: label, + columns: [t("charts.table.range"), t("charts.table.count")], + rows: bins.map((bin) => ({ header: rangeOf(bin), cells: [countFormatter(bin.count)] })), + }; + + return ( + + hovered && ( + + ) + } + > + {(width) => { + const plot = plotFrom(width, height, MARGIN); + const xOf = (value: number) => plot.left + ((value - domainFrom) / domainSpan) * plot.width; + const baseline = plot.top + plot.height; + const bounds = [...bins.map((bin) => bin.from), bins[bins.length - 1].to]; + + return ( + <> + + + {bins.map((bin, index) => { + const left = xOf(bin.from); + const binWidth = Math.max(1, xOf(bin.to) - left - GAP); + const barHeight = max > 0 ? (bin.count / max) * plot.height : 0; + + return ( + + { + const svgBounds = event.currentTarget.ownerSVGElement?.getBoundingClientRect(); + if (!svgBounds) return; + setHovered({ index, x: event.clientX - svgBounds.left, y: event.clientY - svgBounds.top }); + }} + onPointerLeave={() => setHovered(null)} + /> + {barHeight > 0 && ( + + )} + + ); + })} + + {/* Labelled at the bin edges: "0" under a 0–5 bar's middle would + claim the middle is zero. The last upper bound closes the axis. */} + + + {target != null && ( + + )} + + ); + }} + + ); +} diff --git a/frontend/src/components/charts/Histogram/index.ts b/frontend/src/components/charts/Histogram/index.ts new file mode 100644 index 0000000..4565bc8 --- /dev/null +++ b/frontend/src/components/charts/Histogram/index.ts @@ -0,0 +1 @@ +export * from "./Histogram"; diff --git a/frontend/src/components/charts/LineChart/LineChart.stories.tsx b/frontend/src/components/charts/LineChart/LineChart.stories.tsx new file mode 100644 index 0000000..d829571 --- /dev/null +++ b/frontend/src/components/charts/LineChart/LineChart.stories.tsx @@ -0,0 +1,133 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, waitFor, within } from "storybook/test"; +import { LineChart, DATA_TEST_ID } from "./LineChart"; +import { DATA_TEST_ID as FRAME_TEST_ID } from "@/components/charts/components/ChartFrame"; +import { DATA_TEST_ID as LEGEND_TEST_ID } from "@/components/charts/components/ChartLegend"; + +// Placeholder data throughout — the real copy is not settled, and a story that +// shows product strings gets mistaken for a spec. +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +const SERIES_A = { + id: "a", + label: "Series A", + points: [180, 210, 240, 260, 330, 300, 380, 410, 520, 560, 610, 590].map((value, index) => ({ + label: MONTHS[index], + value, + })), +}; + +const SERIES_B = { + id: "b", + label: "Series B", + points: [90, 120, 140, 130, 190, 170, 230, 250, 310, 340, 380, 360].map((value, index) => ({ + label: MONTHS[index], + value, + })), +}; + +const meta = { + component: LineChart, + tags: ["autodocs"], + args: { + label: "Series A by month", + series: [SERIES_A], + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const SingleSeries: Story = { + play: async ({ canvas }) => { + // A single series gets no legend — the chart's own title already names it. + await waitFor(async () => await expect(canvas.getAllByTestId(DATA_TEST_ID.LINE)).toHaveLength(1)); + + await expect(canvas.getByRole("img", { name: "Series A by month" })).toBeInTheDocument(); + await expect(canvas.queryByTestId(LEGEND_TEST_ID.CONTAINER)).not.toBeInTheDocument(); + await expect(canvas.queryByTestId(DATA_TEST_ID.AREA)).not.toBeInTheDocument(); + }, +}; + +export const FilledArea: Story = { + args: { filled: true }, + play: async ({ canvas }) => { + await waitFor(async () => await expect(canvas.getAllByTestId(DATA_TEST_ID.AREA)).toHaveLength(1)); + }, +}; + +export const MultipleSeries: Story = { + args: { + label: "Series A and Series B by month", + series: [SERIES_A, SERIES_B], + filled: true, + }, + play: async ({ canvas }) => { + // Two series, so the legend is mandatory: identity is never color alone. + await waitFor(async () => await expect(canvas.getAllByTestId(DATA_TEST_ID.LINE)).toHaveLength(2)); + + const legend = within(canvas.getByTestId(LEGEND_TEST_ID.CONTAINER)); + await expect(legend.getByText("Series A")).toBeVisible(); + await expect(legend.getByText("Series B")).toBeVisible(); + }, +}; + +export const HoverShowsCrosshairAndTooltip: Story = { + args: { series: [SERIES_A, SERIES_B] }, + play: async ({ canvas }) => { + const plot = await waitFor(() => canvas.getByTestId(FRAME_TEST_ID.PLOT)); + + await userEvent.hover(plot); + + // The crosshair snaps to a data position, and every series is marked there, + // so the pointer never has to land on a particular line. + await waitFor(async () => await expect(canvas.getByTestId(DATA_TEST_ID.CROSSHAIR)).toBeInTheDocument()); + await expect(canvas.getAllByTestId(DATA_TEST_ID.MARKER)).toHaveLength(2); + }, +}; + +export const Empty: Story = { + args: { series: [] }, + play: async ({ canvas }) => { + await expect(canvas.getByTestId(FRAME_TEST_ID.EMPTY)).toBeVisible(); + await expect(canvas.queryByTestId(FRAME_TEST_ID.PLOT)).not.toBeInTheDocument(); + }, +}; + +export const Loading: Story = { + args: { isLoading: true }, + play: async ({ canvas }) => { + // The previous render is held at reduced opacity rather than replaced by a + // skeleton, so the card never jumps while data refetches. + await waitFor( + async () => await expect(canvas.getByTestId(FRAME_TEST_ID.CONTAINER)).toHaveAttribute("aria-busy", "true") + ); + + await expect(canvas.getByTestId(FRAME_TEST_ID.PLOT)).toBeInTheDocument(); + }, +}; + +// The plot sizes itself to whatever card it is dropped into, and the x labels +// thin out rather than overlapping. +export const Narrow: Story = { + name: "Responsive (narrow container)", + decorators: [ + (Story) => ( +
+ +
+ ), + ], + play: async ({ canvas }) => { + const plot = await waitFor(() => canvas.getByTestId(FRAME_TEST_ID.PLOT)); + + await expect(Number(plot.getAttribute("width"))).toBeLessThan(280); + }, +}; diff --git a/frontend/src/components/charts/LineChart/LineChart.test.tsx b/frontend/src/components/charts/LineChart/LineChart.test.tsx new file mode 100644 index 0000000..7b450ea --- /dev/null +++ b/frontend/src/components/charts/LineChart/LineChart.test.tsx @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import { render, screen, within } from "@/_test_utilities/test-utils"; +import { LineChart, DATA_TEST_ID, type LineChartSeries } from "./LineChart"; +import { DATA_TEST_ID as FRAME_TEST_ID } from "@/components/charts/components/ChartFrame"; +import { DATA_TEST_ID as LEGEND_TEST_ID } from "@/components/charts/components/ChartLegend"; + +const LABEL = "Active users by month"; + +const ACTIVE_USERS: LineChartSeries = { + id: "active", + label: "Active users", + points: [ + { label: "Jul", value: 180 }, + { label: "Aug", value: 240 }, + { label: "Sep", value: 320 }, + ], +}; + +const CONVERSATIONS: LineChartSeries = { + id: "conversations", + label: "Conversations", + points: [ + { label: "Jul", value: 90 }, + { label: "Aug", value: 140 }, + { label: "Sep", value: 210 }, + ], +}; + +describe("LineChart", () => { + it("should name the plot for assistive tech", () => { + // GIVEN a single time series + // WHEN it is rendered + render(); + + // THEN the SVG is reachable as one named image + expect(screen.getByRole("img", { name: LABEL })).toBeInTheDocument(); + }); + + it("should draw one line per series", () => { + // GIVEN two series over the same months + // WHEN they are rendered + render(); + + // THEN each gets its own line + expect(screen.getAllByTestId(DATA_TEST_ID.LINE)).toHaveLength(2); + }); + + it("should wash the area under each line only when asked", () => { + // GIVEN a series rendered without a fill + const { unmount } = render(); + + // THEN only the line is drawn + expect(screen.queryByTestId(DATA_TEST_ID.AREA)).not.toBeInTheDocument(); + unmount(); + + // WHEN the same series is rendered filled + render(); + + // THEN the area is washed in under it + expect(screen.getAllByTestId(DATA_TEST_ID.AREA)).toHaveLength(1); + }); + + it("should show a legend only once there is more than one series to tell apart", () => { + // GIVEN a lone series, which the chart's own title already names + const { unmount } = render(); + + // THEN a legend would only restate the title, so there isn't one + expect(screen.queryByTestId(LEGEND_TEST_ID.CONTAINER)).not.toBeInTheDocument(); + unmount(); + + // WHEN a second series is added + render(); + + // THEN identity stops depending on colour alone and the legend appears + const actualLegend = within(screen.getByTestId(LEGEND_TEST_ID.CONTAINER)); + expect(actualLegend.getByText("Active users")).toBeInTheDocument(); + expect(actualLegend.getByText("Conversations")).toBeInTheDocument(); + }); + + it("should keep every plotted value reachable in the data table", () => { + // GIVEN two series whose values are only otherwise shown on hover + // WHEN they are rendered + render(); + + // THEN the numbers are all in the table, row by period + const actualTable = within(screen.getByTestId(FRAME_TEST_ID.TABLE)); + expect(actualTable.getByRole("columnheader", { name: "Period" })).toBeInTheDocument(); + expect(actualTable.getByRole("rowheader", { name: "Sep" })).toBeInTheDocument(); + expect(actualTable.getByRole("cell", { name: "320" })).toBeInTheDocument(); + expect(actualTable.getByRole("cell", { name: "210" })).toBeInTheDocument(); + }); + + it("should show the empty state rather than a bare set of axes when there is no series", () => { + // GIVEN no data at all + // WHEN the chart is rendered + render(); + + // THEN the reader is told so instead of being shown an empty grid + expect(screen.getByTestId(FRAME_TEST_ID.EMPTY)).toBeInTheDocument(); + expect(screen.queryByTestId(FRAME_TEST_ID.PLOT)).not.toBeInTheDocument(); + }); + + it("should show the empty state when a series carries no points", () => { + // GIVEN a named series with nothing in it + const emptySeries: LineChartSeries = { id: "active", label: "Active users", points: [] }; + + // WHEN it is rendered + render(); + + // THEN the chart reads as empty rather than drawing a flat line at zero + expect(screen.getByTestId(FRAME_TEST_ID.EMPTY)).toBeInTheDocument(); + }); + + it("should keep the crosshair and its markers off the plot until the pointer arrives", () => { + // GIVEN a chart nobody is hovering + // WHEN it is rendered + render(); + + // THEN no crosshair or marker is drawn + expect(screen.queryByTestId(DATA_TEST_ID.CROSSHAIR)).not.toBeInTheDocument(); + expect(screen.queryAllByTestId(DATA_TEST_ID.MARKER)).toHaveLength(0); + }); +}); diff --git a/frontend/src/components/charts/LineChart/LineChart.tsx b/frontend/src/components/charts/LineChart/LineChart.tsx new file mode 100644 index 0000000..aaa62e0 --- /dev/null +++ b/frontend/src/components/charts/LineChart/LineChart.tsx @@ -0,0 +1,196 @@ +import { useState, type PointerEvent } from "react"; +import { useTranslation } from "react-i18next"; +import { ChartGrid, ChartXLabels } from "@/components/charts/components/ChartAxes"; +import { ChartFrame, type ChartTable } from "@/components/charts/components/ChartFrame"; +import { ChartLegend } from "@/components/charts/components/ChartLegend"; +import { ChartTooltip } from "@/components/charts/components/ChartTooltip"; +import { CHART_GRID_COLOR, CHART_SURFACE_COLOR, seriesColorAt } from "@/components/charts/chart-palette"; +import { + areaPath, + axisMax, + formatNumber, + linePath, + nearestIndex, + niceTicks, + plotFrom, + xAt, + yAt, + type ChartMargin, +} from "@/components/charts/chart-scale"; + +const uniqueId = "c81f5a37-2e94-4b0d-96a3-fa7b28e4c105"; + +export const DATA_TEST_ID = { + LINE: `line-chart-line-${uniqueId}`, + AREA: `line-chart-area-${uniqueId}`, + CROSSHAIR: `line-chart-crosshair-${uniqueId}`, + MARKER: `line-chart-marker-${uniqueId}`, +}; + +export interface LineChartPoint { + /** The x-axis label for this position. */ + label: string; + value: number; +} + +export interface LineChartSeries { + id: string; + label: string; + points: readonly LineChartPoint[]; +} + +export interface LineChartProps { + label: string; + series: readonly LineChartSeries[]; + filled?: boolean; + height?: number; + isLoading?: boolean; + emptyMessage?: string; + categoryLabel?: string; + valueFormatter?: (value: number) => string; + className?: string; +} + +const MARGIN: ChartMargin = { top: 12, right: 12, bottom: 28, left: 44 }; + +export function LineChart({ + label, + series, + filled = false, + height = 240, + isLoading = false, + emptyMessage, + categoryLabel, + valueFormatter = formatNumber, + className, +}: Readonly) { + const { t } = useTranslation(); + const [hoveredIndex, setHoveredIndex] = useState(null); + const [pointer, setPointer] = useState({ x: 0, y: 0 }); + + const categories = series[0]?.points.map((point) => point.label) ?? []; + const isEmpty = categories.length === 0 || series.every((line) => line.points.length === 0); + const max = axisMax(Math.max(0, ...series.flatMap((line) => line.points.map((point) => point.value)))); + const ticks = niceTicks(max); + + const table: ChartTable = { + caption: label, + columns: [categoryLabel ?? t("charts.table.period"), ...series.map((line) => line.label)], + rows: categories.map((category, index) => ({ + header: category, + cells: series.map((line) => valueFormatter(line.points[index]?.value ?? 0)), + })), + }; + + const handlePointerMove = (event: PointerEvent) => { + const bounds = event.currentTarget.getBoundingClientRect(); + const x = event.clientX - bounds.left; + setPointer({ x, y: event.clientY - bounds.top }); + setHoveredIndex(nearestIndex(x, plotFrom(bounds.width, height, MARGIN), categories.length)); + }; + + return ( + setHoveredIndex(null) }} + footer={ + series.length > 1 ? ( + ({ id: line.id, label: line.label, color: seriesColorAt(index) }))} + /> + ) : null + } + overlay={(width) => + hoveredIndex !== null && ( + ({ + label: line.label, + value: valueFormatter(line.points[hoveredIndex]?.value ?? 0), + color: seriesColorAt(index), + }))} + /> + ) + } + > + {(width) => { + const plot = plotFrom(width, height, MARGIN); + const hoverX = hoveredIndex !== null ? xAt(hoveredIndex, categories.length, plot) : 0; + + return ( + <> + + xAt(index, categories.length, plot)} /> + + {series.map((line, index) => { + const values = line.points.map((point) => point.value); + const color = seriesColorAt(index); + return ( + + {filled && ( + + )} + + + ); + })} + + {hoveredIndex !== null && ( + + + {series.map((line, index) => { + const point = line.points[hoveredIndex]; + if (!point) return null; + return ( + + ); + })} + + )} + + ); + }} + + ); +} diff --git a/frontend/src/components/charts/LineChart/index.ts b/frontend/src/components/charts/LineChart/index.ts new file mode 100644 index 0000000..e873ee1 --- /dev/null +++ b/frontend/src/components/charts/LineChart/index.ts @@ -0,0 +1 @@ +export * from "./LineChart"; diff --git a/frontend/src/components/charts/Sparkline/Sparkline.stories.tsx b/frontend/src/components/charts/Sparkline/Sparkline.stories.tsx new file mode 100644 index 0000000..2092c3c --- /dev/null +++ b/frontend/src/components/charts/Sparkline/Sparkline.stories.tsx @@ -0,0 +1,77 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect } from "storybook/test"; +import { Sparkline, DATA_TEST_ID } from "./Sparkline"; + +// Placeholder data throughout — the real copy is not settled. +const RISING = [180, 210, 240, 260, 330, 300, 380, 410, 520, 560, 610, 590]; +const FALLING = [610, 560, 520, 410, 380, 300, 330, 260, 240, 210, 180, 170]; + +const meta = { + component: Sparkline, + tags: ["autodocs"], + args: { + values: RISING, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Rising: Story = { + play: async ({ canvas }) => { + // With no label given, the shape is described from its first and last values. + await expect(canvas.getByRole("img", { name: "Trend rising, from 180 to 590" })).toBeVisible(); + await expect(canvas.getByTestId(DATA_TEST_ID.CONTAINER)).toHaveAttribute("data-direction", "up"); + }, +}; + +export const Falling: Story = { + args: { values: FALLING }, + play: async ({ canvas }) => { + await expect(canvas.getByRole("img", { name: "Trend falling, from 610 to 170" })).toBeVisible(); + await expect(canvas.getByTestId(DATA_TEST_ID.CONTAINER)).toHaveAttribute("data-direction", "down"); + }, +}; + +export const FilledWithEndMarker: Story = { + args: { filled: true, showEndMarker: true }, + play: async ({ canvas }) => { + await expect(canvas.getByTestId(DATA_TEST_ID.AREA)).toBeInTheDocument(); + await expect(canvas.getByTestId(DATA_TEST_ID.END_MARKER)).toBeInTheDocument(); + }, +}; + +export const WithExplicitLabel: Story = { + args: { label: "Series A over the last twelve points" }, + play: async ({ canvas }) => { + await expect(canvas.getByRole("img", { name: "Series A over the last twelve points" })).toBeVisible(); + }, +}; + +// A single point has no shape to draw, so nothing renders at all. +export const TooFewPoints: Story = { + args: { values: [42] }, + play: async ({ canvas }) => { + await expect(canvas.queryByTestId(DATA_TEST_ID.CONTAINER)).not.toBeInTheDocument(); + }, +}; + +// Beside a figure, which is the usual home: the number is the value, the +// sparkline only carries the shape. +export const BesideAValue: Story = { + name: "Beside a value", + args: { showEndMarker: true }, + render: (args) => ( +
+

Series A

+
+

1,284

+ +
+
+ ), + play: async ({ canvas }) => { + await expect(canvas.getByText("1,284")).toBeVisible(); + await expect(canvas.getByRole("img", { name: /Trend rising/ })).toBeVisible(); + }, +}; diff --git a/frontend/src/components/charts/Sparkline/Sparkline.test.tsx b/frontend/src/components/charts/Sparkline/Sparkline.test.tsx new file mode 100644 index 0000000..cca5d31 --- /dev/null +++ b/frontend/src/components/charts/Sparkline/Sparkline.test.tsx @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@/_test_utilities/test-utils"; +import { Sparkline, DATA_TEST_ID } from "./Sparkline"; + +const RISING = [180, 240, 320, 590]; +const FALLING = [610, 400, 260, 170]; +const FLAT = [200, 260, 200]; + +describe("Sparkline", () => { + it("should describe a rising trend by its first and last value", () => { + // GIVEN a series that ends higher than it started + // WHEN it is rendered without a label of its own + render(); + + // THEN the generated name states the direction and both ends, since a bare + // SVG has no accessible name at all + expect(screen.getByRole("img", { name: "Trend rising, from 180 to 590" })).toBeInTheDocument(); + expect(screen.getByTestId(DATA_TEST_ID.CONTAINER)).toHaveAttribute("data-direction", "up"); + }); + + it("should describe a falling trend", () => { + // GIVEN a series that ends lower than it started + // WHEN it is rendered + render(); + + // THEN the name says so + expect(screen.getByRole("img", { name: "Trend falling, from 610 to 170" })).toBeInTheDocument(); + expect(screen.getByTestId(DATA_TEST_ID.CONTAINER)).toHaveAttribute("data-direction", "down"); + }); + + it("should describe a flat trend by its single value", () => { + // GIVEN a series that ends where it started + // WHEN it is rendered + render(); + + // THEN the name reports no movement rather than a direction + expect(screen.getByRole("img", { name: "Trend flat at 200" })).toBeInTheDocument(); + expect(screen.getByTestId(DATA_TEST_ID.CONTAINER)).toHaveAttribute("data-direction", "flat"); + }); + + it("should prefer an explicit label over the generated summary", () => { + // GIVEN a caller that knows what the trend means + const label = "Active users over the last twelve months"; + + // WHEN the sparkline is given that label + render(); + + // THEN it is used as-is + expect(screen.getByRole("img", { name: label })).toBeInTheDocument(); + }); + + it("should render nothing when there is no shape to draw", () => { + // GIVEN fewer than two points + // WHEN a sparkline is rendered from them + const { unmount } = render(); + + // THEN nothing is drawn, rather than a dot claiming to be a trend + expect(screen.queryByTestId(DATA_TEST_ID.CONTAINER)).not.toBeInTheDocument(); + unmount(); + + // AND the same for no points at all + render(); + expect(screen.queryByTestId(DATA_TEST_ID.CONTAINER)).not.toBeInTheDocument(); + }); + + it("should draw the area and the end marker only when asked", () => { + // GIVEN a plain sparkline + const { unmount } = render(); + + // THEN it is just the line + expect(screen.getByTestId(DATA_TEST_ID.LINE)).toBeInTheDocument(); + expect(screen.queryByTestId(DATA_TEST_ID.AREA)).not.toBeInTheDocument(); + expect(screen.queryByTestId(DATA_TEST_ID.END_MARKER)).not.toBeInTheDocument(); + unmount(); + + // WHEN the fill and the end marker are turned on + render(); + + // THEN the latest value is called out and the area washed in + expect(screen.getByTestId(DATA_TEST_ID.AREA)).toBeInTheDocument(); + expect(screen.getByTestId(DATA_TEST_ID.END_MARKER)).toBeInTheDocument(); + }); + + it("should format the values in its generated name the way the caller asks", () => { + // GIVEN a formatter for the unit the values are in + const formatter = (value: number) => `${value}m`; + + // WHEN the sparkline is rendered with it + render(); + + // THEN the generated name carries the unit too + expect(screen.getByRole("img", { name: "Trend rising, from 12m to 18m" })).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/charts/Sparkline/Sparkline.tsx b/frontend/src/components/charts/Sparkline/Sparkline.tsx new file mode 100644 index 0000000..6333c57 --- /dev/null +++ b/frontend/src/components/charts/Sparkline/Sparkline.tsx @@ -0,0 +1,106 @@ +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/utils"; +import { CHART_SURFACE_COLOR, seriesColorAt } from "@/components/charts/chart-palette"; +import { + areaPath, + formatNumber, + linePath, + plotFrom, + xAt, + yAt, + type ChartMargin, +} from "@/components/charts/chart-scale"; + +const uniqueId = "7d3c0b95-1af8-4e62-b47d-8c2091e5a6f3"; + +export const DATA_TEST_ID = { + CONTAINER: `sparkline-container-${uniqueId}`, + LINE: `sparkline-line-${uniqueId}`, + AREA: `sparkline-area-${uniqueId}`, + END_MARKER: `sparkline-end-marker-${uniqueId}`, +}; + +export interface SparklineProps { + values: readonly number[]; + label?: string; + width?: number; + height?: number; + color?: string; + filled?: boolean; + showEndMarker?: boolean; + valueFormatter?: (value: number) => string; + className?: string; +} + +const MARGIN: ChartMargin = { top: 5, right: 6, bottom: 5, left: 1 }; + +const DIRECTION_LABEL_KEYS = { + up: "charts.sparkline.up", + down: "charts.sparkline.down", + flat: "charts.sparkline.flat", +} as const; + +export function Sparkline({ + values, + label, + width = 80, + height = 24, + color = seriesColorAt(0), + filled = false, + showEndMarker = false, + valueFormatter = formatNumber, + className, +}: Readonly) { + const { t } = useTranslation(); + + // A single point has no shape to draw or describe. + if (values.length < 2) return null; + + const plot = plotFrom(width, height, MARGIN); + // Scaled to the data's own maximum — no ticks here, so a round top would only flatten the shape. + const max = Math.max(...values) || 1; + const first = values[0]; + const last = values[values.length - 1]; + + const direction = last > first ? "up" : last < first ? "down" : "flat"; + const accessibleLabel = + label ?? t(DIRECTION_LABEL_KEYS[direction], { from: valueFormatter(first), to: valueFormatter(last) }); + + return ( + + {filled && ( + + )} + + {showEndMarker && ( + + )} + + ); +} diff --git a/frontend/src/components/charts/Sparkline/index.ts b/frontend/src/components/charts/Sparkline/index.ts new file mode 100644 index 0000000..44b0848 --- /dev/null +++ b/frontend/src/components/charts/Sparkline/index.ts @@ -0,0 +1 @@ +export * from "./Sparkline"; diff --git a/frontend/src/components/charts/chart-palette.ts b/frontend/src/components/charts/chart-palette.ts new file mode 100644 index 0000000..4aded23 --- /dev/null +++ b/frontend/src/components/charts/chart-palette.ts @@ -0,0 +1,25 @@ +// Charts color marks from data, so the color has to be an SVG fill/stroke +// value rather than a Tailwind class. `var(--chart-N)` works in both, keeping +// the source of truth in index.css with every other design token. + +const CHART_SERIES_COLORS = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)"] as const; + +/** Marks a threshold, never a series. */ +export const CHART_TARGET_COLOR = "var(--chart-warning)"; + +export const CHART_GRID_COLOR = "var(--chart-grid)"; + +/** Draws the 2px gaps between touching marks, so it must match the card behind the chart. */ +export const CHART_SURFACE_COLOR = "var(--chart-surface)"; + +/** + * The color for a series at `index`, in fixed assignment order — index by a + * stable series identity, never by the row's current rank. + * + * Past the fourth slot the palette stops: a fifth generated hue would be + * indistinguishable under color-vision deficiency. Fold the tail into an + * "Other" series upstream rather than relying on the wrap. + */ +export function seriesColorAt(index: number): string { + return CHART_SERIES_COLORS[index % CHART_SERIES_COLORS.length]; +} diff --git a/frontend/src/components/charts/chart-scale.test.ts b/frontend/src/components/charts/chart-scale.test.ts new file mode 100644 index 0000000..c9e5317 --- /dev/null +++ b/frontend/src/components/charts/chart-scale.test.ts @@ -0,0 +1,288 @@ +import { describe, expect, it } from "vitest"; +import { + areaPath, + axisMax, + bandCenter, + formatCompact, + formatNumber, + linePath, + nearestIndex, + niceTicks, + percentageOf, + plotFrom, + topRoundedRectPath, + xAt, + yAt, + type ChartMargin, + type ChartPlot, +} from "./chart-scale"; + +const MARGIN: ChartMargin = { top: 10, right: 10, bottom: 20, left: 40 }; +const PLOT: ChartPlot = { left: 40, top: 10, width: 200, height: 100 }; + +describe("plotFrom", () => { + it("should take the axis gutters out of the container", () => { + // GIVEN a container and the margins its axes need + // WHEN the plot area is derived + const actualPlot = plotFrom(300, 200, MARGIN); + + // THEN the plot sits inside the gutters + expect(actualPlot).toEqual({ left: 40, top: 10, width: 250, height: 170 }); + }); + + it("should collapse to an empty plot when the container is narrower than its own gutters", () => { + // GIVEN a container too small to fit its axes + // WHEN the plot area is derived + const actualPlot = plotFrom(20, 10, MARGIN); + + // THEN the plot is empty rather than negative, so no mark renders inverted + expect(actualPlot.width).toBe(0); + expect(actualPlot.height).toBe(0); + }); +}); + +describe("niceTicks", () => { + it("should land on round numbers rather than fractions of the maximum", () => { + // GIVEN an awkward maximum + // WHEN ticks are chosen + const actualTicks = niceTicks(656); + + // THEN they read as round numbers that cover the data + expect(actualTicks).toEqual([0, 200, 400, 600, 800]); + }); + + it("should step in 1s, 2s or 5s of the appropriate magnitude", () => { + // GIVEN maxima at different magnitudes + // WHEN ticks are chosen for each + // THEN the step is always a 1, 2 or 5 of the right power of ten + expect(niceTicks(4)).toEqual([0, 1, 2, 3, 4]); + expect(niceTicks(9)).toEqual([0, 5, 10]); + expect(niceTicks(38)).toEqual([0, 10, 20, 30, 40]); + expect(niceTicks(9000)).toEqual([0, 5000, 10000]); + }); + + it("should keep float drift out of the labels", () => { + // GIVEN a maximum small enough that a fractional step is needed + // WHEN ticks are chosen + const actualTicks = niceTicks(0.9); + + // THEN no tick carries an accumulated rounding error + for (const tick of actualTicks) { + expect(String(tick)).not.toMatch(/\d{6,}/); + } + }); + + it("should fall back to a single zero tick when there is nothing to scale", () => { + // GIVEN a maximum that can't define a scale + // WHEN ticks are chosen + // THEN only the baseline is offered + expect(niceTicks(0)).toEqual([0]); + expect(niceTicks(-10)).toEqual([0]); + expect(niceTicks(Number.NaN)).toEqual([0]); + }); +}); + +describe("axisMax", () => { + it("should raise the axis top to the first round tick above the data", () => { + // GIVEN a data maximum between two round ticks + // WHEN the axis top is derived + // THEN it is the tick above, so the tallest mark never touches the ceiling + expect(axisMax(656)).toBe(800); + expect(axisMax(38)).toBe(40); + }); + + it("should never return zero, so a scale can always be divided by it", () => { + // GIVEN no data + // WHEN the axis top is derived + // THEN it falls back to one rather than zero + expect(axisMax(0)).toBe(1); + }); +}); + +describe("formatCompact", () => { + it("should write values below ten thousand in full", () => { + // GIVEN values that fit comfortably on a tick + // WHEN they are compacted + // THEN they keep their thousands separator and every digit + expect(formatCompact(1284)).toBe("1,284"); + expect(formatCompact(9999)).toBe("9,999"); + }); + + it("should abbreviate thousands and millions", () => { + // GIVEN values large enough to crowd a tick + // WHEN they are compacted + // THEN they are abbreviated, and a trailing zero decimal is dropped + expect(formatCompact(12_900)).toBe("12.9K"); + expect(formatCompact(10_000)).toBe("10K"); + expect(formatCompact(4_200_000)).toBe("4.2M"); + }); + + it("should compact negative values by magnitude", () => { + // GIVEN a large negative value + // WHEN it is compacted + // THEN the sign survives the abbreviation + expect(formatCompact(-12_900)).toBe("-12.9K"); + }); +}); + +describe("formatNumber", () => { + it("should separate thousands for values read in full", () => { + // GIVEN a value shown in a tooltip or a data table + // WHEN it is formatted + // THEN it is grouped and rounded to at most one decimal + expect(formatNumber(1284)).toBe("1,284"); + expect(formatNumber(12.34)).toBe("12.3"); + }); +}); + +describe("percentageOf", () => { + it("should give a slice's whole-percentage share", () => { + // GIVEN a slice and the whole it belongs to + // WHEN its share is taken + // THEN it is rounded to a whole percentage + expect(percentageOf(60, 100)).toBe(60); + expect(percentageOf(1, 3)).toBe(33); + }); + + it("should report nothing rather than dividing by an empty whole", () => { + // GIVEN a whole with no value in it + // WHEN a share is taken + // THEN it is zero, not infinite + expect(percentageOf(5, 0)).toBe(0); + }); +}); + +describe("nearestIndex", () => { + it("should snap to the closest data position, so the reader aims at a date", () => { + // GIVEN a plot with five positions across 200px, starting at x=40 + const count = 5; + + // WHEN the pointer lands just past the second position + const actualIndex = nearestIndex(95, PLOT, count); + + // THEN it snaps to that position rather than to the raw pixel + expect(actualIndex).toBe(1); + }); + + it("should clamp to the ends when the pointer leaves the plot", () => { + // GIVEN a pointer beyond either edge of the plot + // WHEN the nearest position is taken + // THEN it never escapes the data's range + expect(nearestIndex(-500, PLOT, 5)).toBe(0); + expect(nearestIndex(9999, PLOT, 5)).toBe(4); + }); + + it("should report the only position when there is a single one", () => { + // GIVEN a single data position + // WHEN the nearest is taken from anywhere + // THEN it is that one + expect(nearestIndex(150, PLOT, 1)).toBe(0); + }); +}); + +describe("bandCenter", () => { + it("should centre each band in its own share of the plot", () => { + // GIVEN four bands sharing a 200px plot that starts at x=40 + // WHEN each band's centre is taken + // THEN the bands are evenly spaced, each centred in its 50px slot + expect(bandCenter(0, 4, PLOT)).toBe(65); + expect(bandCenter(3, 4, PLOT)).toBe(215); + }); +}); + +describe("xAt and yAt", () => { + it("should spread positions from the plot's left edge to its right", () => { + // GIVEN three positions across the plot + // WHEN their x coordinates are taken + // THEN the first and last sit on the plot's edges + expect(xAt(0, 3, PLOT)).toBe(40); + expect(xAt(2, 3, PLOT)).toBe(240); + }); + + it("should centre a lone position in the plot", () => { + // GIVEN a single position + // WHEN its x coordinate is taken + // THEN it sits in the middle rather than pinned to an edge + expect(xAt(0, 1, PLOT)).toBe(140); + }); + + it("should measure values up from the baseline", () => { + // GIVEN a value scale topping out at 100 + const max = 100; + + // WHEN coordinates are taken across the scale + // THEN zero sits on the baseline and the maximum at the plot's top + expect(yAt(0, max, PLOT)).toBe(110); + expect(yAt(50, max, PLOT)).toBe(60); + expect(yAt(100, max, PLOT)).toBe(10); + }); + + it("should rest on the baseline when there is no scale to measure against", () => { + // GIVEN an empty value scale + // WHEN a coordinate is taken + // THEN it falls on the baseline rather than dividing by zero + expect(yAt(5, 0, PLOT)).toBe(110); + }); +}); + +describe("linePath and areaPath", () => { + it("should move to the first point and draw through the rest", () => { + // GIVEN a series of three values + const values = [0, 50, 100]; + + // WHEN a line path is built + const actualPath = linePath(values, 100, PLOT); + + // THEN it opens with a move and continues with line segments + expect(actualPath).toBe("M40,110 L140,60 L240,10"); + }); + + it("should close the area down to the baseline", () => { + // GIVEN the same series + const values = [0, 50, 100]; + + // WHEN an area path is built + const actualPath = areaPath(values, 100, PLOT); + + // THEN the line is carried down to the baseline and closed + expect(actualPath).toBe("M40,110 L140,60 L240,10 L240,110 L40,110 Z"); + }); + + it("should produce nothing for a series with no values", () => { + // GIVEN an empty series + // WHEN paths are built + // THEN both are empty, so nothing is drawn + expect(linePath([], 100, PLOT)).toBe(""); + expect(areaPath([], 100, PLOT)).toBe(""); + }); +}); + +describe("topRoundedRectPath", () => { + it("should round only the top corners, leaving the baseline square", () => { + // GIVEN a bar tall enough for its corner radius + // WHEN its path is built + const actualPath = topRoundedRectPath(10, 20, 24, 100, 4); + + // THEN the top corners curve and the bottom edge stays straight + expect(actualPath).toContain("Q"); + expect(actualPath.startsWith("M10,120")).toBe(true); + }); + + it("should shrink the radius to fit a bar shorter than it, so the corner never overruns", () => { + // GIVEN a bar only a pixel tall, against a 4px corner radius + // WHEN its path is built + const actualPath = topRoundedRectPath(10, 20, 24, 1, 4); + + // THEN the curve is clamped to the bar's own height rather than escaping it + expect(actualPath).toContain("Q10,20 11,20"); + }); + + it("should drop the curve entirely for a bar with no height", () => { + // GIVEN a bar with nothing to draw + // WHEN its path is built + const actualPath = topRoundedRectPath(10, 20, 24, 0, 4); + + // THEN there is no curve command at all + expect(actualPath).not.toContain("Q"); + }); +}); diff --git a/frontend/src/components/charts/chart-scale.ts b/frontend/src/components/charts/chart-scale.ts new file mode 100644 index 0000000..51e2a46 --- /dev/null +++ b/frontend/src/components/charts/chart-scale.ts @@ -0,0 +1,136 @@ +/** Geometry and number formatting shared by every chart. */ + +export interface ChartPlot { + left: number; + top: number; + width: number; + height: number; +} + +export interface ChartMargin { + top: number; + right: number; + bottom: number; + left: number; +} + +/** The area left to draw in, once the axis gutters are taken out. */ +export function plotFrom(width: number, height: number, margin: ChartMargin): ChartPlot { + return { + left: margin.left, + top: margin.top, + // Never negative: a container smaller than its own gutters would otherwise + // draw marks inside out. + width: Math.max(0, width - margin.left - margin.right), + height: Math.max(0, height - margin.top - margin.bottom), + }; +} + +/** Ticks from 0 to at least `max`, on 1/2/5 steps so they read as round numbers. */ +export function niceTicks(max: number, count = 4): number[] { + if (!Number.isFinite(max) || max <= 0 || count < 1) return [0]; + + const rawStep = max / count; + const magnitude = 10 ** Math.floor(Math.log10(rawStep)); + const normalized = rawStep / magnitude; + const niceStep = (normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10) * magnitude; + + const ticks: number[] = []; + for (let tick = 0; tick < max + niceStep; tick += niceStep) { + // Multiplied, not accumulated, so labels don't drift into 0.30000000000000004. + ticks.push(Number((ticks.length * niceStep).toPrecision(12))); + } + return ticks; +} + +/** The first round tick at or above `max`. */ +export function axisMax(max: number, count = 4): number { + const ticks = niceTicks(max, count); + return ticks[ticks.length - 1] || 1; +} + +/** 1,284 / 12.9K / 4.2M — for ticks and tight labels. */ +export function formatCompact(value: number): string { + const magnitude = Math.abs(value); + if (magnitude >= 1_000_000) return `${trimZero(value / 1_000_000)}M`; + if (magnitude >= 10_000) return `${trimZero(value / 1_000)}K`; + return formatNumber(value); +} + +/** Thousands-separated, for values read in full. */ +export function formatNumber(value: number): string { + return new Intl.NumberFormat("en-GB", { maximumFractionDigits: 1 }).format(value); +} + +function trimZero(value: number): string { + return value.toFixed(1).replace(/\.0$/, ""); +} + +export function percentageOf(value: number, total: number): number { + if (total <= 0) return 0; + return Math.round((value / total) * 100); +} + +/** The data point closest to `x` — what the crosshair snaps to. */ +export function nearestIndex(x: number, plot: ChartPlot, count: number): number { + if (count <= 1) return 0; + const ratio = (x - plot.left) / plot.width; + return Math.min(count - 1, Math.max(0, Math.round(ratio * (count - 1)))); +} + +/** The x center of band `index`, when `count` bands share the plot's width. */ +export function bandCenter(index: number, count: number, plot: ChartPlot): number { + if (count <= 0) return plot.left; + const band = plot.width / count; + return plot.left + band * index + band / 2; +} + +export function linePath(values: readonly number[], max: number, plot: ChartPlot): string { + return values + .map((value, index) => `${index === 0 ? "M" : "L"}${pointAt(values, index, value, max, plot)}`) + .join(" "); +} + +/** The same line, closed down to the baseline to fill the area under it. */ +export function areaPath(values: readonly number[], max: number, plot: ChartPlot): string { + if (values.length === 0) return ""; + const baseline = plot.top + plot.height; + const first = xAt(0, values.length, plot); + const last = xAt(values.length - 1, values.length, plot); + return `${linePath(values, max, plot)} L${last},${baseline} L${first},${baseline} Z`; +} + +export function xAt(index: number, count: number, plot: ChartPlot): number { + if (count <= 1) return plot.left + plot.width / 2; + return plot.left + (index / (count - 1)) * plot.width; +} + +export function yAt(value: number, max: number, plot: ChartPlot): number { + if (max <= 0) return plot.top + plot.height; + return plot.top + plot.height * (1 - value / max); +} + +/** A rect rounded on top only. The radius shrinks to fit short or narrow marks. */ +export function topRoundedRectPath(x: number, y: number, width: number, height: number, radius: number): string { + const r = Math.max(0, Math.min(radius, height, width / 2)); + const bottom = y + height; + return [ + `M${x},${bottom}`, + `L${x},${y + r}`, + r > 0 ? `Q${x},${y} ${x + r},${y}` : "", + `L${x + width - r},${y}`, + r > 0 ? `Q${x + width},${y} ${x + width},${y + r}` : "", + `L${x + width},${bottom}`, + "Z", + ] + .filter(Boolean) + .join(" "); +} + +function pointAt(values: readonly number[], index: number, value: number, max: number, plot: ChartPlot): string { + return `${round(xAt(index, values.length, plot))},${round(yAt(value, max, plot))}`; +} + +function round(value: number): number { + return Math.round(value * 100) / 100; +} diff --git a/frontend/src/components/charts/components/ChartAxes/ChartAxes.stories.tsx b/frontend/src/components/charts/components/ChartAxes/ChartAxes.stories.tsx new file mode 100644 index 0000000..91f8b5f --- /dev/null +++ b/frontend/src/components/charts/components/ChartAxes/ChartAxes.stories.tsx @@ -0,0 +1,54 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect } from "storybook/test"; +import { ChartGrid, ChartXLabels, DATA_TEST_ID } from "./ChartAxes"; +import { plotFrom } from "@/components/charts/chart-scale"; + +// Placeholder data throughout — the real copy is not settled. +const PLOT = plotFrom(480, 220, { top: 20, right: 12, bottom: 28, left: 44 }); +const TICKS = [0, 100, 200, 300, 400]; +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; +const xOf = (index: number) => PLOT.left + (index / (MONTHS.length - 1)) * PLOT.width; + +const meta = { + component: ChartGrid, + tags: ["autodocs"], + args: { + ticks: TICKS, + max: 400, + plot: PLOT, + }, + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Grid: Story = { + play: async ({ canvas }) => { + await expect(canvas.getAllByTestId(DATA_TEST_ID.Y_TICK)).toHaveLength(TICKS.length); + await expect(canvas.getByText("400")).toBeVisible(); + }, +}; + +// The two axes are always used together in a real chart — this shows them as +// they'll actually appear, rather than the grid in isolation. +export const WithCategoryLabels: Story = { + render: (args) => ( + <> + + + + ), + play: async ({ canvas }) => { + // Twelve months don't all fit at ~56px apart, so they're thinned — but the + // axis still ends on December rather than stopping short. + await expect(canvas.getAllByTestId(DATA_TEST_ID.X_LABEL).length).toBeLessThan(MONTHS.length); + await expect(canvas.getByText("Dec")).toBeVisible(); + }, +}; diff --git a/frontend/src/components/charts/components/ChartAxes/ChartAxes.test.tsx b/frontend/src/components/charts/components/ChartAxes/ChartAxes.test.tsx new file mode 100644 index 0000000..7099e12 --- /dev/null +++ b/frontend/src/components/charts/components/ChartAxes/ChartAxes.test.tsx @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@/_test_utilities/test-utils"; +import { ChartGrid, ChartXLabels, labelStride, DATA_TEST_ID } from "./ChartAxes"; +import { plotFrom } from "@/components/charts/chart-scale"; + +const PLOT = plotFrom(400, 200, { top: 20, right: 12, bottom: 28, left: 44 }); + +describe("labelStride", () => { + it("should show every label when the plot is wide enough for all of them", () => { + // GIVEN four labels and a plot with room for well over four + const givenLabels = ["Jan", "Feb", "Mar", "Apr"]; + + // WHEN the stride is computed for a wide plot + const actualStride = labelStride(givenLabels, 400); + + // THEN nothing is thinned out + expect(actualStride).toBe(1); + }); + + it("should thin the labels out once there is no longer room for all of them", () => { + // GIVEN twelve labels and a plot too narrow to fit all of them at ~56px each + const givenLabels = Array.from({ length: 12 }, (_, index) => `Month ${index}`); + + // WHEN the stride is computed for that plot + const actualStride = labelStride(givenLabels, 300); + + // THEN only every second (or coarser) label is kept + expect(actualStride).toBeGreaterThan(1); + }); + + it("should never divide by a zero-width plot", () => { + // GIVEN a plot with no width left to draw in + const givenLabels = ["Jan", "Feb"]; + + // WHEN the stride is computed + const actualStride = labelStride(givenLabels, 0); + + // THEN it falls back to showing as few labels as it must, not NaN or Infinity + expect(Number.isFinite(actualStride)).toBe(true); + expect(actualStride).toBeGreaterThanOrEqual(1); + }); +}); + +describe("ChartGrid", () => { + it("should draw one gridline and one value label per tick", () => { + // GIVEN four round ticks + const givenTicks = [0, 100, 200, 300]; + + // WHEN it is rendered + render( + + + + ); + + // THEN each tick gets a labelled gridline + expect(screen.getAllByTestId(DATA_TEST_ID.Y_TICK)).toHaveLength(givenTicks.length); + expect(screen.getByText("300")).toBeInTheDocument(); + }); + + it("should format tick labels the way the caller asks", () => { + // GIVEN a formatter for the ticks' unit + const minutes = (value: number) => `${value}m`; + + // WHEN it is rendered with it + render( + + + + ); + + // THEN the formatted label is shown + expect(screen.getByText("15m")).toBeInTheDocument(); + }); + + it("should be hidden from assistive tech, since the same values are in the data table", () => { + // GIVEN a grid + // WHEN it is rendered + render( + + + + ); + + // THEN it carries aria-hidden rather than being announced + expect(screen.getByTestId(DATA_TEST_ID.GRID_CONTAINER)).toHaveAttribute("aria-hidden", "true"); + }); +}); + +describe("ChartXLabels", () => { + const xOf = (index: number) => PLOT.left + index * 20; + + it("should show every label when there is room for all of them", () => { + // GIVEN four category labels and a plot wide enough for all of them + const givenLabels = ["Jan", "Feb", "Mar", "Apr"]; + + // WHEN it is rendered + render( + + + + ); + + // THEN all four are shown + expect(screen.getAllByTestId(DATA_TEST_ID.X_LABEL)).toHaveLength(givenLabels.length); + }); + + it("should always keep the last label, even when thinning drops it from the stride", () => { + // GIVEN enough labels that thinning would otherwise skip the final one + const givenLabels = Array.from({ length: 11 }, (_, index) => `Month ${index}`); + const narrowPlot = plotFrom(150, 200, { top: 20, right: 12, bottom: 28, left: 44 }); + + // WHEN it is rendered against a narrow plot + render( + + + + ); + + // THEN the axis still ends on the final label, rather than stopping short + expect(screen.getByText("Month 10")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/charts/components/ChartAxes/ChartAxes.tsx b/frontend/src/components/charts/components/ChartAxes/ChartAxes.tsx new file mode 100644 index 0000000..d151009 --- /dev/null +++ b/frontend/src/components/charts/components/ChartAxes/ChartAxes.tsx @@ -0,0 +1,87 @@ +import { CHART_GRID_COLOR } from "@/components/charts/chart-palette"; +import { formatCompact, yAt, type ChartPlot } from "@/components/charts/chart-scale"; + +const uniqueId = "9f4d2e08-3b71-4c85-a1e6-d7c530b9f2a4"; + +export const DATA_TEST_ID = { + GRID_CONTAINER: `chart-grid-container-${uniqueId}`, + Y_TICK: `chart-y-tick-${uniqueId}`, + X_LABEL: `chart-x-label-${uniqueId}`, +}; + +const AXIS_TEXT_CLASS = "fill-muted-foreground text-[11px] tabular-nums"; + +export interface ChartGridProps { + ticks: readonly number[]; + max: number; + plot: ChartPlot; + formatTick?: (value: number) => string; +} + +/** Horizontal gridlines with their value labels. Solid, never dashed — dashing reads as a threshold. */ +export function ChartGrid({ ticks, max, plot, formatTick = formatCompact }: Readonly) { + return ( + + ); +} + +export interface ChartXLabelsProps { + labels: readonly string[]; + plot: ChartPlot; + /** Positions label `index` along the x axis. */ + xOf: (index: number) => number; +} + +/** + * Category labels under the plot, thinned at a stride rather than crowded or + * rotated. The dropped ones stay in the tooltip and the data table. + */ +export function ChartXLabels({ labels, plot, xOf }: Readonly) { + const stride = labelStride(labels, plot.width); + const y = plot.top + plot.height + 18; + + return ( + + ); +} + +/** Roughly 56px of room per label before they start to collide. */ +export function labelStride(labels: readonly unknown[], width: number): number { + const affordable = Math.max(1, Math.floor(width / 56)); + return Math.max(1, Math.ceil(labels.length / affordable)); +} diff --git a/frontend/src/components/charts/components/ChartAxes/index.ts b/frontend/src/components/charts/components/ChartAxes/index.ts new file mode 100644 index 0000000..b9d6b2d --- /dev/null +++ b/frontend/src/components/charts/components/ChartAxes/index.ts @@ -0,0 +1 @@ +export * from "./ChartAxes"; diff --git a/frontend/src/components/charts/components/ChartEmpty/ChartEmpty.stories.tsx b/frontend/src/components/charts/components/ChartEmpty/ChartEmpty.stories.tsx new file mode 100644 index 0000000..53f2d17 --- /dev/null +++ b/frontend/src/components/charts/components/ChartEmpty/ChartEmpty.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect } from "storybook/test"; +import { ChartEmpty, DATA_TEST_ID } from "./ChartEmpty"; + +const meta = { + component: ChartEmpty, + tags: ["autodocs"], + args: { + message: "No data to show for this selection.", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvas }) => { + await expect(canvas.getByText("No data to show for this selection.")).toBeVisible(); + await expect(canvas.getByRole("status")).toBeInTheDocument(); + }, +}; + +export const LoadingCopy: Story = { + name: "With a loading message", + args: { message: "Loading…" }, + play: async ({ canvas }) => { + await expect(canvas.getByText("Loading…")).toBeVisible(); + }, +}; + +export const IconIsDecorative: Story = { + name: "Icon adds nothing for assistive tech", + play: async ({ canvas }) => { + await expect(canvas.getByTestId(DATA_TEST_ID.ICON)).toHaveAttribute("aria-hidden", "true"); + }, +}; diff --git a/frontend/src/components/charts/components/ChartEmpty/ChartEmpty.test.tsx b/frontend/src/components/charts/components/ChartEmpty/ChartEmpty.test.tsx new file mode 100644 index 0000000..fbdcc0b --- /dev/null +++ b/frontend/src/components/charts/components/ChartEmpty/ChartEmpty.test.tsx @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@/_test_utilities/test-utils"; +import { ChartEmpty, DATA_TEST_ID } from "./ChartEmpty"; + +describe("ChartEmpty", () => { + it("should show the given message", () => { + // GIVEN a message explaining why there is nothing to plot + const givenMessage = "No jobseekers in this range."; + + // WHEN it is rendered + render(); + + // THEN the message is shown + expect(screen.getByText(givenMessage)).toBeInTheDocument(); + }); + + it("should announce itself to assistive tech as a status, not a silent empty region", () => { + // GIVEN an empty state + // WHEN it is rendered + render(); + + // THEN it is reachable as a status region, and its icon adds nothing extra + expect(screen.getByRole("status")).toBe(screen.getByTestId(DATA_TEST_ID.CONTAINER)); + expect(screen.getByTestId(DATA_TEST_ID.ICON)).toHaveAttribute("aria-hidden", "true"); + }); +}); diff --git a/frontend/src/components/charts/components/ChartEmpty/ChartEmpty.tsx b/frontend/src/components/charts/components/ChartEmpty/ChartEmpty.tsx new file mode 100644 index 0000000..c6ec0dd --- /dev/null +++ b/frontend/src/components/charts/components/ChartEmpty/ChartEmpty.tsx @@ -0,0 +1,34 @@ +import { SearchX } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const uniqueId = "f0a37c62-9d18-4e5b-8a41-6b2ce9047d35"; + +export const DATA_TEST_ID = { + CONTAINER: `chart-empty-container-${uniqueId}`, + ICON: `chart-empty-icon-${uniqueId}`, +}; + +export interface ChartEmptyProps { + message: string; + className?: string; +} + +export function ChartEmpty({ message, className }: Readonly) { + return ( +
+ +

{message}

+
+ ); +} diff --git a/frontend/src/components/charts/components/ChartEmpty/index.ts b/frontend/src/components/charts/components/ChartEmpty/index.ts new file mode 100644 index 0000000..81cb7a9 --- /dev/null +++ b/frontend/src/components/charts/components/ChartEmpty/index.ts @@ -0,0 +1 @@ +export * from "./ChartEmpty"; diff --git a/frontend/src/components/charts/components/ChartFrame/ChartFrame.stories.tsx b/frontend/src/components/charts/components/ChartFrame/ChartFrame.stories.tsx new file mode 100644 index 0000000..ca644b3 --- /dev/null +++ b/frontend/src/components/charts/components/ChartFrame/ChartFrame.stories.tsx @@ -0,0 +1,92 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, waitFor, within } from "storybook/test"; +import { ChartFrame, DATA_TEST_ID, type ChartTable } from "./ChartFrame"; + +// Placeholder data throughout — the real copy is not settled. +const TABLE: ChartTable = { + caption: "New users by month", + columns: ["Period", "New", "Returning"], + rows: [ + { header: "Jul", cells: ["155", "63"] }, + { header: "Aug", cells: ["96", "41"] }, + ], +}; + +const LABEL = "New and returning users by month"; + +const meta = { + component: ChartFrame, + tags: ["autodocs"], + args: { + label: LABEL, + height: 200, + table: TABLE, + children: (width: number) => , + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvas }) => { + await waitFor(async () => await expect(canvas.getByRole("img", { name: LABEL })).toBeInTheDocument()); + }, +}; + +export const Empty: Story = { + args: { isEmpty: true, emptyMessage: "No jobseekers in this range." }, + play: async ({ canvas }) => { + await expect(canvas.getByTestId(DATA_TEST_ID.EMPTY)).toBeVisible(); + await expect(canvas.queryByTestId(DATA_TEST_ID.PLOT)).not.toBeInTheDocument(); + }, +}; + +export const LoadingWithNoDataYet: Story = { + name: "Loading, before any data has arrived", + args: { isEmpty: true, isLoading: true }, + play: async ({ canvas }) => { + await expect(canvas.getByText("Loading…")).toBeVisible(); + }, +}; + +export const RefetchingWithDataAlreadyShown: Story = { + name: "Loading, while holding the previous render", + args: { isLoading: true }, + play: async ({ canvas }) => { + await waitFor( + async () => await expect(canvas.getByTestId(DATA_TEST_ID.CONTAINER)).toHaveAttribute("aria-busy", "true") + ); + await expect(canvas.getByTestId(DATA_TEST_ID.PLOT)).toBeInTheDocument(); + }, +}; + +export const WithFooterAndOverlay: Story = { + name: "With a legend footer and a tooltip overlay", + args: { + footer:

Legend goes here

, + overlay: (width: number) => ( +

{`overlay at ${width}px`}

+ ), + }, + play: async ({ canvas }) => { + await expect(canvas.getByText("Legend goes here")).toBeVisible(); + await waitFor(async () => await expect(canvas.getByText(/^overlay at [1-9]/)).toBeVisible()); + }, +}; + +export const DataStaysReachableInATable: Story = { + name: "Every plotted value stays reachable in a hidden table", + play: async ({ canvas }) => { + const table = within(canvas.getByTestId(DATA_TEST_ID.TABLE)); + await expect(table.getByRole("rowheader", { name: "Jul" })).toBeInTheDocument(); + await expect(table.getByRole("cell", { name: "63" })).toBeInTheDocument(); + }, +}; diff --git a/frontend/src/components/charts/components/ChartFrame/ChartFrame.test.tsx b/frontend/src/components/charts/components/ChartFrame/ChartFrame.test.tsx new file mode 100644 index 0000000..c15cac6 --- /dev/null +++ b/frontend/src/components/charts/components/ChartFrame/ChartFrame.test.tsx @@ -0,0 +1,118 @@ +import type { ComponentProps } from "react"; +import { describe, expect, it } from "vitest"; +import { render, screen, within } from "@/_test_utilities/test-utils"; +import { ChartFrame, DATA_TEST_ID, type ChartTable } from "./ChartFrame"; + +const TABLE: ChartTable = { + caption: "New users by month", + columns: ["Period", "New", "Returning"], + rows: [ + { header: "Jul", cells: ["155", "63"] }, + { header: "Aug", cells: ["96", "41"] }, + ], +}; + +const LABEL = "New and returning users by month"; + +function renderFrame(props: Partial> = {}) { + return render( + + {(width) => } + + ); +} + +describe("ChartFrame", () => { + it("should name the plot for assistive tech, so the SVG is not an unlabelled node", () => { + // GIVEN a chart with something to draw + // WHEN it is rendered + renderFrame(); + + // THEN the plot is a single named image rather than a pile of paths + expect(screen.getByRole("img", { name: LABEL })).toBe(screen.getByTestId(DATA_TEST_ID.PLOT)); + }); + + it("should draw its marks at the measured width of the container", () => { + // GIVEN a chart in a measurable container + // WHEN it is rendered + renderFrame(); + + // THEN the marks are laid out against a real pixel width + expect(Number(screen.getByTestId("mark").getAttribute("width"))).toBeGreaterThan(0); + }); + + it("should keep every plotted value reachable in a visually hidden table", () => { + // GIVEN a chart whose values are otherwise only in the marks and the tooltip + // WHEN it is rendered + renderFrame(); + + // THEN the same numbers are available as a table, captioned and headed + const actualTable = within(screen.getByTestId(DATA_TEST_ID.TABLE)); + expect(screen.getByRole("table", { name: TABLE.caption })).toBeInTheDocument(); + expect(actualTable.getByRole("columnheader", { name: "Returning" })).toBeInTheDocument(); + expect(actualTable.getByRole("rowheader", { name: "Jul" })).toBeInTheDocument(); + expect(actualTable.getByRole("cell", { name: "63" })).toBeInTheDocument(); + }); + + it("should show the empty state instead of a plot when there is nothing to draw", () => { + // GIVEN a chart with no data + // WHEN it is rendered + renderFrame({ isEmpty: true, emptyMessage: "No jobseekers in this range." }); + + // THEN the reader is told so, and no empty axes are drawn + expect(screen.getByText("No jobseekers in this range.")).toBeInTheDocument(); + expect(screen.queryByTestId(DATA_TEST_ID.PLOT)).not.toBeInTheDocument(); + }); + + it("should fall back to a general message when no empty copy is given", () => { + // GIVEN a chart with no data and no message of its own + // WHEN it is rendered + renderFrame({ isEmpty: true }); + + // THEN the shared copy stands in + expect(screen.getByText("No data to show for this selection.")).toBeInTheDocument(); + }); + + it("should say it is loading rather than empty on a first load", () => { + // GIVEN a chart that has no data yet but is still fetching + // WHEN it is rendered + renderFrame({ isEmpty: true, isLoading: true, emptyMessage: "No jobseekers in this range." }); + + // THEN it promises data rather than claiming there is none + expect(screen.getByText("Loading…")).toBeInTheDocument(); + expect(screen.queryByText("No jobseekers in this range.")).not.toBeInTheDocument(); + }); + + it("should hold the previous render while refetching, instead of flashing a skeleton", () => { + // GIVEN a chart that already has data and is refetching + // WHEN it is rendered + renderFrame({ isLoading: true }); + + // THEN the plot stays on screen, marked busy, so the card never jumps + expect(screen.getByTestId(DATA_TEST_ID.CONTAINER)).toHaveAttribute("aria-busy", "true"); + expect(screen.getByTestId(DATA_TEST_ID.PLOT)).toBeInTheDocument(); + }); + + it("should not mark the frame busy when it is not loading", () => { + // GIVEN a settled chart + // WHEN it is rendered + renderFrame(); + + // THEN nothing announces a pending update + expect(screen.getByTestId(DATA_TEST_ID.CONTAINER)).not.toHaveAttribute("aria-busy"); + }); + + it("should render the footer and give the overlay the measured width", () => { + // GIVEN a chart with a legend below it and a tooltip layer over it + // WHEN it is rendered + renderFrame({ + footer:

Legend goes here

, + overlay: (width) =>

{`overlay at ${width}`}

, + }); + + // THEN both are placed, and the overlay knows how wide the plot is so it + // can flip a tooltip away from the edge + expect(screen.getByText("Legend goes here")).toBeInTheDocument(); + expect(screen.getByText(/^overlay at [1-9]/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/charts/components/ChartFrame/ChartFrame.tsx b/frontend/src/components/charts/components/ChartFrame/ChartFrame.tsx new file mode 100644 index 0000000..b17ebe5 --- /dev/null +++ b/frontend/src/components/charts/components/ChartFrame/ChartFrame.tsx @@ -0,0 +1,135 @@ +import type { ReactNode, SVGProps } from "react"; +import { useTranslation } from "react-i18next"; +import { ChartEmpty } from "@/components/charts/components/ChartEmpty"; +import { cn } from "@/lib/utils"; +import { useMeasure } from "@/components/charts/use-measure"; + +const uniqueId = "3a1e8f42-6d5b-4c19-9f77-2b0c8d4e1a63"; + +export const DATA_TEST_ID = { + CONTAINER: `chart-frame-container-${uniqueId}`, + PLOT: `chart-frame-plot-${uniqueId}`, + TABLE: `chart-frame-table-${uniqueId}`, + EMPTY: `chart-frame-empty-${uniqueId}`, +}; + +export interface ChartTableRow { + header: string; + cells: readonly string[]; +} + +export interface ChartTable { + caption: string; + columns: readonly string[]; + rows: readonly ChartTableRow[]; +} + +export interface ChartFrameProps { + label: string; + height: number; + table: ChartTable; + isEmpty?: boolean; + emptyMessage?: string; + isLoading?: boolean; + children: (width: number) => ReactNode; + footer?: ReactNode; + overlay?: (width: number) => ReactNode; + svgProps?: SVGProps; + className?: string; +} + +export function ChartFrame({ + label, + height, + table, + isEmpty = false, + emptyMessage, + isLoading = false, + children, + footer, + overlay, + svgProps, + className, +}: Readonly) { + const { t } = useTranslation(); + const [containerRef, width] = useMeasure(); + + // On a first load there will be data — say so rather than claiming there is none. + if (isEmpty) { + return ( +
+
+ +
+
+ ); + } + + return ( +
+ {/* Marks need a pixel width; skip the first, unmeasured pass. */} + {width > 0 && ( + + {children(width)} + + )} + {width > 0 && overlay?.(width)} + {footer} + +
+ ); +} + +export function ChartDataTable({ table }: Readonly<{ table: ChartTable }>) { + return ( + + + + + {table.columns.map((column) => ( + + ))} + + + + {table.rows.map((row) => ( + + + {/* Keyed by column header: cells are positional and may repeat a value. */} + {row.cells.map((cell, index) => ( + + ))} + + ))} + +
{table.caption}
+ {column} +
{row.header}{cell}
+ ); +} diff --git a/frontend/src/components/charts/components/ChartFrame/index.ts b/frontend/src/components/charts/components/ChartFrame/index.ts new file mode 100644 index 0000000..c815a64 --- /dev/null +++ b/frontend/src/components/charts/components/ChartFrame/index.ts @@ -0,0 +1 @@ +export * from "./ChartFrame"; diff --git a/frontend/src/components/charts/components/ChartLegend/ChartLegend.stories.tsx b/frontend/src/components/charts/components/ChartLegend/ChartLegend.stories.tsx new file mode 100644 index 0000000..25f6ef0 --- /dev/null +++ b/frontend/src/components/charts/components/ChartLegend/ChartLegend.stories.tsx @@ -0,0 +1,108 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "storybook/test"; +import { ChartLegend, DATA_TEST_ID, type ChartLegendProps } from "./ChartLegend"; +import { seriesColorAt } from "@/components/charts/chart-palette"; + +// Placeholder data throughout — the real copy is not settled. +const SERIES = [ + { id: "a", label: "Series A", color: seriesColorAt(0) }, + { id: "b", label: "Series B", color: seriesColorAt(1) }, +]; + +const SLICES = [ + { id: "a", label: "Group A", color: seriesColorAt(0), value: "52%" }, + { id: "b", label: "Group B", color: seriesColorAt(1), value: "41%" }, + { id: "c", label: "Group C", color: seriesColorAt(2), value: "7%" }, +]; + +// The legend is controlled, so the story owns the selection to keep it interactive. +function ControlledChartLegend({ selectedId, onSelect, ...props }: Readonly) { + const [value, setValue] = useState(selectedId ?? null); + + return ( + { + setValue(next); + onSelect?.(next); + }} + /> + ); +} + +const meta = { + component: ChartLegend, + tags: ["autodocs"], + args: { + items: SERIES, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvas }) => { + // A plain key: no handler, so nothing offers itself as a control. + await expect(canvas.getAllByTestId(DATA_TEST_ID.ITEM)).toHaveLength(SERIES.length); + await expect(canvas.queryAllByRole("button")).toHaveLength(0); + }, +}; + +// A rule rather than a swatch, mirroring the mark it names on a line chart. +export const LineKeys: Story = { + args: { markShape: "line" }, + play: async ({ canvas }) => { + await expect(canvas.getByText("Series A")).toBeVisible(); + }, +}; + +export const VerticalWithValues: Story = { + args: { items: SLICES, orientation: "vertical" }, + play: async ({ canvas }) => { + await expect(canvas.getByText("52%")).toBeVisible(); + await expect(canvas.getByText("Group C")).toBeVisible(); + }, +}; + +export const WithSelection: Story = { + args: { items: SLICES, orientation: "vertical", selectedId: "a" }, + render: (args) => , + play: async ({ canvas }) => { + // Only the swatch dims for the unselected rows — the labels keep full + // contrast, so nothing becomes unreadable. + await expect(canvas.getByRole("button", { name: /Group A/ })).toHaveAttribute("aria-pressed", "true"); + await expect(canvas.getByRole("button", { name: /Group B/ })).toHaveAttribute("aria-pressed", "false"); + }, +}; + +export const TogglingAnEntry: Story = { + args: { items: SLICES, orientation: "vertical" }, + render: (args) => , + play: async ({ canvas }) => { + await userEvent.click(canvas.getByRole("button", { name: /Group B/ })); + await expect(canvas.getByRole("button", { name: /Group B/ })).toHaveAttribute("aria-pressed", "true"); + + // Picking it again clears the filter it set. + await userEvent.click(canvas.getByRole("button", { name: /Group B/ })); + await expect(canvas.getByRole("button", { name: /Group B/ })).toHaveAttribute("aria-pressed", "false"); + }, +}; + +export const Empty: Story = { + args: { items: [] }, + play: async ({ canvas }) => { + const legend = within(canvas.getByTestId(DATA_TEST_ID.CONTAINER)); + + await expect(legend.queryAllByTestId(DATA_TEST_ID.ITEM)).toHaveLength(0); + }, +}; diff --git a/frontend/src/components/charts/components/ChartLegend/ChartLegend.test.tsx b/frontend/src/components/charts/components/ChartLegend/ChartLegend.test.tsx new file mode 100644 index 0000000..0a9f3c0 --- /dev/null +++ b/frontend/src/components/charts/components/ChartLegend/ChartLegend.test.tsx @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { render, screen, within } from "@/_test_utilities/test-utils"; +import { ChartLegend, DATA_TEST_ID } from "./ChartLegend"; + +const SERIES = [ + { id: "a", label: "Series A", color: "var(--chart-1)" }, + { id: "b", label: "Series B", color: "var(--chart-2)" }, +]; + +const SLICES = [ + { id: "a", label: "Group A", color: "var(--chart-1)", value: "52%" }, + { id: "b", label: "Group B", color: "var(--chart-2)", value: "41%" }, +]; + +describe("ChartLegend", () => { + it("should list one entry per item, each named by its label", () => { + // GIVEN two series + // WHEN it is rendered + render(); + + // THEN there is one entry per series + expect(screen.getAllByTestId(DATA_TEST_ID.ITEM)).toHaveLength(SERIES.length); + expect(screen.getByText("Series A")).toBeInTheDocument(); + }); + + it("should offer nothing as a control when there is no selection handler", () => { + // GIVEN a legend with no onSelect + // WHEN it is rendered + render(); + + // THEN it reads as plain text, not an interactive control + expect(screen.queryAllByRole("button")).toHaveLength(0); + }); + + it("should show each entry's value alongside its label, when given one", () => { + // GIVEN items carrying a value + // WHEN it is rendered + render(); + + // THEN both the label and the value are visible + expect(screen.getByText("Group A")).toBeInTheDocument(); + expect(screen.getByText("52%")).toBeInTheDocument(); + }); + + it("should become a set of pressable controls once a selection handler is given", () => { + // GIVEN a legend that can filter, with one entry already selected + const givenOnSelect = vi.fn(); + + // WHEN it is rendered + render(); + + // THEN the selected entry is pressed, and the rest are not + expect(screen.getByRole("button", { name: /Group A/ })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("button", { name: /Group B/ })).toHaveAttribute("aria-pressed", "false"); + }); + + it("should ask to select an entry on click, and to clear it when the same entry is already selected", async () => { + // GIVEN an interactive legend + const givenUser = userEvent.setup(); + const givenOnSelect = vi.fn(); + const { rerender } = render(); + + // WHEN an unselected entry is clicked + await givenUser.click(screen.getByRole("button", { name: /Group B/ })); + + // THEN the handler is asked to select it + expect(givenOnSelect).toHaveBeenCalledWith("b"); + + // WHEN that same entry, now selected, is clicked again + rerender(); + await givenUser.click(screen.getByRole("button", { name: /Group B/ })); + + // THEN the handler is asked to clear the selection instead + expect(givenOnSelect).toHaveBeenCalledWith(null); + }); + + it("should render no entries when there is nothing to show", () => { + // GIVEN an empty legend + // WHEN it is rendered + render(); + + // THEN there is nothing to list + const actualLegend = within(screen.getByTestId(DATA_TEST_ID.CONTAINER)); + expect(actualLegend.queryAllByTestId(DATA_TEST_ID.ITEM)).toHaveLength(0); + }); +}); diff --git a/frontend/src/components/charts/components/ChartLegend/ChartLegend.tsx b/frontend/src/components/charts/components/ChartLegend/ChartLegend.tsx new file mode 100644 index 0000000..4d8e3e8 --- /dev/null +++ b/frontend/src/components/charts/components/ChartLegend/ChartLegend.tsx @@ -0,0 +1,89 @@ +import { cn } from "@/lib/utils"; + +const uniqueId = "5e2b91c7-4f83-4a60-b8d2-71c6e5a0f394"; + +export const DATA_TEST_ID = { + CONTAINER: `chart-legend-container-${uniqueId}`, + ITEM: `chart-legend-item-${uniqueId}`, +}; + +export interface ChartLegendItem { + id: string; + label: string; + color: string; + value?: string; +} + +export interface ChartLegendProps { + items: readonly ChartLegendItem[]; + markShape?: "rect" | "line"; + orientation?: "horizontal" | "vertical"; + onSelect?: (id: string | null) => void; + selectedId?: string | null; + className?: string; +} + +export function ChartLegend({ + items, + markShape = "rect", + orientation = "horizontal", + onSelect, + selectedId, + className, +}: Readonly) { + const isInteractive = Boolean(onSelect); + + return ( +
    + {items.map((item) => { + const isSelected = selectedId === item.id; + const isDimmed = isInteractive && selectedId != null && !isSelected; + + const content = ( + <> +
+ ); +} diff --git a/frontend/src/components/charts/components/ChartLegend/index.ts b/frontend/src/components/charts/components/ChartLegend/index.ts new file mode 100644 index 0000000..0cc1e03 --- /dev/null +++ b/frontend/src/components/charts/components/ChartLegend/index.ts @@ -0,0 +1 @@ +export * from "./ChartLegend"; diff --git a/frontend/src/components/charts/components/ChartTooltip/ChartTooltip.stories.tsx b/frontend/src/components/charts/components/ChartTooltip/ChartTooltip.stories.tsx new file mode 100644 index 0000000..bbe45d6 --- /dev/null +++ b/frontend/src/components/charts/components/ChartTooltip/ChartTooltip.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; +import { ChartTooltip, DATA_TEST_ID } from "./ChartTooltip"; +import { seriesColorAt } from "@/components/charts/chart-palette"; + +const CONTAINER_WIDTH = 440; + +// Placeholder data throughout — the real copy is not settled. +const ROWS = [ + { label: "Series A", value: "258", color: seriesColorAt(0) }, + { label: "Series B", value: "105", color: seriesColorAt(1) }, +]; + +const meta = { + component: ChartTooltip, + tags: ["autodocs"], + args: { + title: "Mar", + rows: ROWS, + x: 120, + y: 80, + containerWidth: CONTAINER_WIDTH, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const EverySeriesAtOnePosition: Story = { + play: async ({ canvas }) => { + const tooltip = within(canvas.getByTestId(DATA_TEST_ID.CONTAINER)); + + await expect(tooltip.getByText("Mar")).toBeVisible(); + await expect(tooltip.getAllByTestId(DATA_TEST_ID.ROW)).toHaveLength(2); + await expect(tooltip.getByText("258")).toBeVisible(); + }, +}; + +export const SingleSeries: Story = { + args: { rows: [ROWS[0]] }, + play: async ({ canvas }) => { + await expect(canvas.getAllByTestId(DATA_TEST_ID.ROW)).toHaveLength(1); + }, +}; + +export const FlippedAtTheRightEdge: Story = { + args: { x: CONTAINER_WIDTH - 20 }, + play: async ({ canvas }) => { + await expect(canvas.getByTestId(DATA_TEST_ID.CONTAINER)).toHaveClass("-translate-x-full"); + }, +}; + +export const HiddenFromAssistiveTech: Story = { + play: async ({ canvas }) => { + await expect(canvas.getByTestId(DATA_TEST_ID.CONTAINER)).toHaveAttribute("aria-hidden", "true"); + }, +}; diff --git a/frontend/src/components/charts/components/ChartTooltip/ChartTooltip.test.tsx b/frontend/src/components/charts/components/ChartTooltip/ChartTooltip.test.tsx new file mode 100644 index 0000000..0c92b24 --- /dev/null +++ b/frontend/src/components/charts/components/ChartTooltip/ChartTooltip.test.tsx @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@/_test_utilities/test-utils"; +import { ChartTooltip, DATA_TEST_ID } from "./ChartTooltip"; + +const CONTAINER_WIDTH = 440; + +const ROWS = [ + { label: "Series A", value: "258", color: "var(--chart-1)" }, + { label: "Series B", value: "105", color: "var(--chart-2)" }, +]; + +describe("ChartTooltip", () => { + it("should name the hovered point and list a row per series", () => { + // GIVEN a point with two series + // WHEN it is rendered + render(); + + // THEN the title and every row's value and label are shown + expect(screen.getByText("Mar")).toBeInTheDocument(); + expect(screen.getAllByTestId(DATA_TEST_ID.ROW)).toHaveLength(ROWS.length); + expect(screen.getByText("258")).toBeInTheDocument(); + expect(screen.getByText("Series A")).toBeInTheDocument(); + }); + + it("should stay unflipped well clear of the right edge", () => { + // GIVEN a point far from the right edge + // WHEN it is rendered + render(); + + // THEN it opens to the right of the point + expect(screen.getByTestId(DATA_TEST_ID.CONTAINER)).not.toHaveClass("-translate-x-full"); + }); + + it("should flip to the left near the right edge, so the card stays inside the chart", () => { + // GIVEN a point close enough to the right edge that the card would overflow + // WHEN it is rendered + render(); + + // THEN it opens to the left of the point instead + expect(screen.getByTestId(DATA_TEST_ID.CONTAINER)).toHaveClass("-translate-x-full"); + }); + + it("should hide itself from assistive tech, since the same values are in the data table", () => { + // GIVEN a tooltip over the chart + // WHEN it is rendered + render(); + + // THEN it carries aria-hidden rather than being announced on top of the table + expect(screen.getByTestId(DATA_TEST_ID.CONTAINER)).toHaveAttribute("aria-hidden", "true"); + }); +}); diff --git a/frontend/src/components/charts/components/ChartTooltip/ChartTooltip.tsx b/frontend/src/components/charts/components/ChartTooltip/ChartTooltip.tsx new file mode 100644 index 0000000..af62be3 --- /dev/null +++ b/frontend/src/components/charts/components/ChartTooltip/ChartTooltip.tsx @@ -0,0 +1,57 @@ +import { cn } from "@/lib/utils"; + +const uniqueId = "b7c40f18-9a26-4d3e-8c51-e0f2a6b93d47"; + +export const DATA_TEST_ID = { + CONTAINER: `chart-tooltip-container-${uniqueId}`, + ROW: `chart-tooltip-row-${uniqueId}`, +}; + +export interface ChartTooltipRow { + label: string; + value: string; + color: string; +} + +export interface ChartTooltipProps { + title: string; + rows: readonly ChartTooltipRow[]; + x: number; + y: number; + containerWidth: number; + className?: string; +} + +const OFFSET = 12; +const ESTIMATED_WIDTH = 168; + +export function ChartTooltip({ title, rows, x, y, containerWidth, className }: Readonly) { + // Flips left near the right edge so the card stays inside the chart. + const flip = x + OFFSET + ESTIMATED_WIDTH > containerWidth; + + return ( + + ); +} diff --git a/frontend/src/components/charts/components/ChartTooltip/index.ts b/frontend/src/components/charts/components/ChartTooltip/index.ts new file mode 100644 index 0000000..6ee27bc --- /dev/null +++ b/frontend/src/components/charts/components/ChartTooltip/index.ts @@ -0,0 +1 @@ +export * from "./ChartTooltip"; diff --git a/frontend/src/components/charts/use-measure.test.tsx b/frontend/src/components/charts/use-measure.test.tsx new file mode 100644 index 0000000..b02f1dc --- /dev/null +++ b/frontend/src/components/charts/use-measure.test.tsx @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@/_test_utilities/test-utils"; +import { TEST_CONTAINER_WIDTH } from "@/test/setup"; +import { useMeasure } from "./use-measure"; + +const PROBE_TEST_ID = "use-measure-probe"; + +// A minimal consumer, so the hook is exercised the way a chart uses it. +function Probe() { + const [ref, width] = useMeasure(); + return ( +
+ {width} +
+ ); +} + +describe("useMeasure", () => { + it("should report the width of the container it is attached to", () => { + // GIVEN a component that measures its own container + // WHEN it is rendered + render(); + + // THEN it reports the container's rendered width, so a chart can size its marks + expect(screen.getByTestId(PROBE_TEST_ID)).toHaveTextContent(String(TEST_CONTAINER_WIDTH)); + }); + + it("should report no width when the environment cannot measure", () => { + // GIVEN an environment with no ResizeObserver + const originalResizeObserver = window.ResizeObserver; + vi.stubGlobal("ResizeObserver", undefined); + + // WHEN a measuring component is rendered + render(); + + // THEN it reports zero rather than throwing, and the chart skips its marks + expect(screen.getByTestId(PROBE_TEST_ID)).toHaveTextContent("0"); + + vi.stubGlobal("ResizeObserver", originalResizeObserver); + }); + + it("should stop observing once the component is gone", () => { + // GIVEN a measuring component that is observing its container + const disconnect = vi.fn(); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect = disconnect; + } + ); + const { unmount } = render(); + + // WHEN it unmounts + unmount(); + + // THEN the observer is disconnected, so nothing is left watching a detached node + expect(disconnect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/components/charts/use-measure.ts b/frontend/src/components/charts/use-measure.ts new file mode 100644 index 0000000..b5a9e62 --- /dev/null +++ b/frontend/src/components/charts/use-measure.ts @@ -0,0 +1,30 @@ +import { useEffect, useRef, useState } from "react"; + +/** + * Tracks the rendered width of an element, so a chart sizes itself to the card + * it is dropped into. Height is always given explicitly by the caller. + */ +export function useMeasure(): [React.RefObject, number] { + const ref = useRef(null); + const [width, setWidth] = useState(0); + + useEffect(() => { + const element = ref.current; + if (!element) return; + + setWidth(element.getBoundingClientRect().width); + if (typeof ResizeObserver === "undefined") return; + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + // borderBoxSize is the reliable read; contentRect lags in some browsers. + setWidth(entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width); + } + }); + observer.observe(element); + + return () => observer.disconnect(); + }, []); + + return [ref, width]; +} diff --git a/frontend/src/components/ui/progress.tsx b/frontend/src/components/ui/progress.tsx new file mode 100644 index 0000000..200de5f --- /dev/null +++ b/frontend/src/components/ui/progress.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; +import { Progress as ProgressPrimitive } from "radix-ui"; + +import { cn } from "@/lib/utils"; + +function Progress({ className, value, ...props }: React.ComponentProps) { + return ( + + + + ); +} + +export { Progress }; diff --git a/frontend/src/i18n/locales/en-GB/translation.json b/frontend/src/i18n/locales/en-GB/translation.json index 4d31daa..04c8f7e 100644 --- a/frontend/src/i18n/locales/en-GB/translation.json +++ b/frontend/src/i18n/locales/en-GB/translation.json @@ -141,5 +141,29 @@ "completionRing": { "label": "{{value}}% complete" } + }, + "charts": { + "empty": "No data to show for this selection.", + "table": { + "period": "Period", + "category": "Category", + "value": "Value", + "share": "Share", + "total": "Total", + "range": "Range", + "count": "Count" + }, + "sparkline": { + "up": "Trend rising, from {{from}} to {{to}}", + "down": "Trend falling, from {{from}} to {{to}}", + "flat": "Trend flat at {{to}}" + }, + "gaugeBar": { + "oneValue": "{{value}} {{valueLabel}}", + "twoValues": "{{value}} {{valueLabel}} · {{secondaryValue}} {{secondaryLabel}}" + }, + "histogram": { + "range": "{{from}} to {{to}}" + } } } diff --git a/frontend/src/index.css b/frontend/src/index.css index b28edc0..2a67303 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -157,6 +157,21 @@ --input: var(--border-subtle); --ring: var(--tabiya-green); + /* Chart palette */ + --chart-1: var(--green-2); + --chart-2: var(--green-3); + --chart-3: var(--tabiya-blue); + --chart-4: #b8c6d9; + + --chart-progress-done: var(--green-3); + --chart-progress-active: #a5d9bf; + + --chart-warning: #b8761a; + + --chart-grid: rgba(0, 33, 71, 0.12); + --chart-track: #eef0ee; + --chart-surface: var(--white); + --sidebar: var(--tabiya-blue); --sidebar-foreground: var(--white); --sidebar-primary: var(--tabiya-green); @@ -213,6 +228,11 @@ --color-green-2: var(--green-2); --color-green-3: var(--green-3); + --color-chart-1: var(--chart-1); + --color-chart-track: var(--chart-track); + --color-chart-progress-done: var(--chart-progress-done); + --color-chart-progress-active: var(--chart-progress-active); + --radius-lg: var(--radius); --radius-md: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 8px); diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index 343982c..81fae5c 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -19,6 +19,37 @@ vi.mock("@/i18n/i18n", () => ({ initI18n: () => Promise.resolve(mockI18nInstance), })); +// jsdom has no ResizeObserver, so a measuring chart sees width 0 and draws +// nothing. Report a fixed box; Storybook's browser tests use the real thing. +export const TEST_CONTAINER_WIDTH = 600; +export const TEST_CONTAINER_HEIGHT = 300; + +vi.stubGlobal( + "ResizeObserver", + class { + private readonly callback: ResizeObserverCallback; + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + } + observe(target: Element) { + const size = { inlineSize: TEST_CONTAINER_WIDTH, blockSize: TEST_CONTAINER_HEIGHT }; + this.callback( + [ + { + target, + contentRect: { width: TEST_CONTAINER_WIDTH, height: TEST_CONTAINER_HEIGHT }, + borderBoxSize: [size], + contentBoxSize: [size], + } as unknown as ResizeObserverEntry, + ], + this as unknown as ResizeObserver + ); + } + unobserve() {} + disconnect() {} + } +); + // jsdom doesn't implement matchMedia; shadcn's use-mobile hook needs it. Object.defineProperty(window, "matchMedia", { writable: true, From c0d4bf0dabaaaae5c17311b2d8d350f1cecefb0e Mon Sep 17 00:00:00 2001 From: Fides Date: Wed, 5 Aug 2026 13:43:47 +0200 Subject: [PATCH 2/2] chore(frontend): adjust component folder structure --- AGENTS.md | 4 ++-- frontend/src/app/{ => Layout}/Layout.test.tsx | 0 frontend/src/app/{ => Layout}/Layout.tsx | 2 +- frontend/src/app/Layout/index.ts | 1 + frontend/src/app/ProtectedRoute/ProtectedRoute.tsx | 6 ++---- frontend/src/app/ProtectedRoute/index.ts | 1 + frontend/src/app/index.tsx | 6 +++--- frontend/src/auth/components/AuthLayout/index.ts | 1 + frontend/src/auth/components/Field/index.ts | 1 + .../src/auth/components/PasswordRequirements/index.ts | 1 + frontend/src/auth/components/SocialAuth/index.ts | 1 + .../GlobalFilters.stories.tsx} | 2 +- .../GlobalFilters.test.tsx} | 2 +- .../GlobalFilters.tsx} | 0 frontend/src/components/filters/GlobalFilters/index.ts | 1 + .../TimeFilterBar.stories.tsx} | 2 +- .../TimeFilterBar.test.tsx} | 2 +- .../TimeFilterBar.tsx} | 0 frontend/src/components/filters/TimeFilterBar/index.ts | 1 + .../AppSidebar.stories.tsx} | 2 +- .../AppSidebar.test.tsx} | 2 +- .../{app-sidebar.tsx => AppSidebar/AppSidebar.tsx} | 4 ++-- frontend/src/components/sidebar/AppSidebar/index.ts | 1 + .../SidebarNav.stories.tsx} | 2 +- .../SidebarNav.test.tsx} | 2 +- .../{sidebar-nav.tsx => SidebarNav/SidebarNav.tsx} | 0 .../src/components/sidebar/components/SidebarNav/index.ts | 1 + .../SidebarUserMenu.stories.tsx} | 2 +- .../SidebarUserMenu.test.tsx} | 2 +- .../SidebarUserMenu.tsx} | 0 .../sidebar/components/SidebarUserMenu/index.ts | 1 + .../LanguageSwitcher.stories.tsx | 0 .../LanguageSwitcher.test.tsx | 0 .../LanguageSwitcher.tsx | 0 frontend/src/i18n/LanguageSwitcher/index.ts | 1 + frontend/src/pages/Login/Login.tsx | 6 +++--- frontend/src/pages/Login/index.ts | 1 + frontend/src/pages/Register/Register.tsx | 8 ++++---- frontend/src/pages/Register/index.ts | 1 + .../sentry/{ => ErrorFallback}/ErrorFallback.stories.tsx | 0 frontend/src/sentry/{ => ErrorFallback}/ErrorFallback.tsx | 0 frontend/src/sentry/ErrorFallback/index.ts | 1 + 42 files changed, 42 insertions(+), 29 deletions(-) rename frontend/src/app/{ => Layout}/Layout.test.tsx (100%) rename frontend/src/app/{ => Layout}/Layout.tsx (90%) create mode 100644 frontend/src/app/Layout/index.ts create mode 100644 frontend/src/app/ProtectedRoute/index.ts create mode 100644 frontend/src/auth/components/AuthLayout/index.ts create mode 100644 frontend/src/auth/components/Field/index.ts create mode 100644 frontend/src/auth/components/PasswordRequirements/index.ts create mode 100644 frontend/src/auth/components/SocialAuth/index.ts rename frontend/src/components/filters/{global-filters.stories.tsx => GlobalFilters/GlobalFilters.stories.tsx} (97%) rename frontend/src/components/filters/{global-filters.test.tsx => GlobalFilters/GlobalFilters.test.tsx} (98%) rename frontend/src/components/filters/{global-filters.tsx => GlobalFilters/GlobalFilters.tsx} (100%) create mode 100644 frontend/src/components/filters/GlobalFilters/index.ts rename frontend/src/components/filters/{time-filter-bar.stories.tsx => TimeFilterBar/TimeFilterBar.stories.tsx} (97%) rename frontend/src/components/filters/{time-filter-bar.test.tsx => TimeFilterBar/TimeFilterBar.test.tsx} (97%) rename frontend/src/components/filters/{time-filter-bar.tsx => TimeFilterBar/TimeFilterBar.tsx} (100%) create mode 100644 frontend/src/components/filters/TimeFilterBar/index.ts rename frontend/src/components/sidebar/{app-sidebar.stories.tsx => AppSidebar/AppSidebar.stories.tsx} (98%) rename frontend/src/components/sidebar/{app-sidebar.test.tsx => AppSidebar/AppSidebar.test.tsx} (93%) rename frontend/src/components/sidebar/{app-sidebar.tsx => AppSidebar/AppSidebar.tsx} (93%) create mode 100644 frontend/src/components/sidebar/AppSidebar/index.ts rename frontend/src/components/sidebar/components/{sidebar-nav.stories.tsx => SidebarNav/SidebarNav.stories.tsx} (98%) rename frontend/src/components/sidebar/components/{sidebar-nav.test.tsx => SidebarNav/SidebarNav.test.tsx} (99%) rename frontend/src/components/sidebar/components/{sidebar-nav.tsx => SidebarNav/SidebarNav.tsx} (100%) create mode 100644 frontend/src/components/sidebar/components/SidebarNav/index.ts rename frontend/src/components/sidebar/components/{sidebar-user-menu.stories.tsx => SidebarUserMenu/SidebarUserMenu.stories.tsx} (93%) rename frontend/src/components/sidebar/components/{sidebar-user-menu.test.tsx => SidebarUserMenu/SidebarUserMenu.test.tsx} (97%) rename frontend/src/components/sidebar/components/{sidebar-user-menu.tsx => SidebarUserMenu/SidebarUserMenu.tsx} (100%) create mode 100644 frontend/src/components/sidebar/components/SidebarUserMenu/index.ts rename frontend/src/i18n/{languageSwitcher => LanguageSwitcher}/LanguageSwitcher.stories.tsx (100%) rename frontend/src/i18n/{languageSwitcher => LanguageSwitcher}/LanguageSwitcher.test.tsx (100%) rename frontend/src/i18n/{languageSwitcher => LanguageSwitcher}/LanguageSwitcher.tsx (100%) create mode 100644 frontend/src/i18n/LanguageSwitcher/index.ts create mode 100644 frontend/src/pages/Login/index.ts create mode 100644 frontend/src/pages/Register/index.ts rename frontend/src/sentry/{ => ErrorFallback}/ErrorFallback.stories.tsx (100%) rename frontend/src/sentry/{ => ErrorFallback}/ErrorFallback.tsx (100%) create mode 100644 frontend/src/sentry/ErrorFallback/index.ts diff --git a/AGENTS.md b/AGENTS.md index e8c805c..936439d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,7 +80,7 @@ plus one entry each in `SupportedLocales` and `LocalesLabels` (`src/i18n/constan tests assert against real copy, not placeholder keys, without needing async init). Storybook takes the opposite approach: `.storybook/preview.tsx` wires the real i18next instance through `I18nextProvider`, with a toolbar `globalTypes.locale` dropdown to preview other locales live. -- **Language switcher**: `src/i18n/languageSwitcher/LanguageSwitcher.tsx`, in the sidebar footer. +- **Language switcher**: `src/i18n/LanguageSwitcher/LanguageSwitcher.tsx`, in the sidebar footer. ## Error tracking (Sentry) @@ -92,7 +92,7 @@ build/deployment rather than something that should be swappable without a rebuil the literal string `"true"` to enable — any other value (including unset) leaves it off, so a local/unconfigured build never reports. -`Sentry.ErrorBoundary` wraps `` in `main.tsx`, falling back to `src/sentry/ErrorFallback.tsx` on an uncaught +`Sentry.ErrorBoundary` wraps `` in `main.tsx`, falling back to `src/sentry/ErrorFallback/ErrorFallback.tsx` on an uncaught render error. This is intentionally a minimal core setup (init + error boundary only) — no router instrumentation (no router exists yet), no feedback widget, no sourcemap upload pipeline. Add those later if/when they're needed, following compass's `frontend-new/src/sentryInit.ts` for reference, but note compass's setup includes some diff --git a/frontend/src/app/Layout.test.tsx b/frontend/src/app/Layout/Layout.test.tsx similarity index 100% rename from frontend/src/app/Layout.test.tsx rename to frontend/src/app/Layout/Layout.test.tsx diff --git a/frontend/src/app/Layout.tsx b/frontend/src/app/Layout/Layout.tsx similarity index 90% rename from frontend/src/app/Layout.tsx rename to frontend/src/app/Layout/Layout.tsx index 1d8f529..1ae969f 100644 --- a/frontend/src/app/Layout.tsx +++ b/frontend/src/app/Layout/Layout.tsx @@ -1,5 +1,5 @@ import { Outlet } from "react-router-dom"; -import { AppSidebar } from "@/components/sidebar/app-sidebar"; +import { AppSidebar } from "@/components/sidebar/AppSidebar"; import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar"; import { AccessProvider } from "@/access/AccessContext"; import { FiltersProvider } from "@/filters/FiltersContext"; diff --git a/frontend/src/app/Layout/index.ts b/frontend/src/app/Layout/index.ts new file mode 100644 index 0000000..f7ad548 --- /dev/null +++ b/frontend/src/app/Layout/index.ts @@ -0,0 +1 @@ +export * from "./Layout"; diff --git a/frontend/src/app/ProtectedRoute/ProtectedRoute.tsx b/frontend/src/app/ProtectedRoute/ProtectedRoute.tsx index 2ff4e0a..5954bb2 100644 --- a/frontend/src/app/ProtectedRoute/ProtectedRoute.tsx +++ b/frontend/src/app/ProtectedRoute/ProtectedRoute.tsx @@ -4,8 +4,6 @@ interface ProtectedRouteProps { children: ReactNode; } -const ProtectedRoute = ({ children }: ProtectedRouteProps) => { +export function ProtectedRoute({ children }: ProtectedRouteProps) { return <>{children}; -}; - -export default ProtectedRoute; +} diff --git a/frontend/src/app/ProtectedRoute/index.ts b/frontend/src/app/ProtectedRoute/index.ts new file mode 100644 index 0000000..cbd5441 --- /dev/null +++ b/frontend/src/app/ProtectedRoute/index.ts @@ -0,0 +1 @@ +export * from "./ProtectedRoute"; diff --git a/frontend/src/app/index.tsx b/frontend/src/app/index.tsx index 09d6c11..f2c06ef 100644 --- a/frontend/src/app/index.tsx +++ b/frontend/src/app/index.tsx @@ -1,9 +1,9 @@ import { createHashRouter, Navigate, RouterProvider } from "react-router-dom"; -import ProtectedRoute from "@/app/ProtectedRoute/ProtectedRoute"; +import { ProtectedRoute } from "@/app/ProtectedRoute"; import { Layout } from "@/app/Layout"; import { routerPaths } from "@/app/routerPaths"; -import { Login } from "@/pages/Login/Login"; -import { Register } from "@/pages/Register/Register"; +import { Login } from "@/pages/Login"; +import { Register } from "@/pages/Register"; const router = createHashRouter([ { diff --git a/frontend/src/auth/components/AuthLayout/index.ts b/frontend/src/auth/components/AuthLayout/index.ts new file mode 100644 index 0000000..30e3bd9 --- /dev/null +++ b/frontend/src/auth/components/AuthLayout/index.ts @@ -0,0 +1 @@ +export * from "./AuthLayout"; diff --git a/frontend/src/auth/components/Field/index.ts b/frontend/src/auth/components/Field/index.ts new file mode 100644 index 0000000..cdf384c --- /dev/null +++ b/frontend/src/auth/components/Field/index.ts @@ -0,0 +1 @@ +export * from "./Field"; diff --git a/frontend/src/auth/components/PasswordRequirements/index.ts b/frontend/src/auth/components/PasswordRequirements/index.ts new file mode 100644 index 0000000..5c6b6f2 --- /dev/null +++ b/frontend/src/auth/components/PasswordRequirements/index.ts @@ -0,0 +1 @@ +export * from "./PasswordRequirements"; diff --git a/frontend/src/auth/components/SocialAuth/index.ts b/frontend/src/auth/components/SocialAuth/index.ts new file mode 100644 index 0000000..9e844aa --- /dev/null +++ b/frontend/src/auth/components/SocialAuth/index.ts @@ -0,0 +1 @@ +export * from "./SocialAuth"; diff --git a/frontend/src/components/filters/global-filters.stories.tsx b/frontend/src/components/filters/GlobalFilters/GlobalFilters.stories.tsx similarity index 97% rename from frontend/src/components/filters/global-filters.stories.tsx rename to frontend/src/components/filters/GlobalFilters/GlobalFilters.stories.tsx index 366ea16..b0cd7df 100644 --- a/frontend/src/components/filters/global-filters.stories.tsx +++ b/frontend/src/components/filters/GlobalFilters/GlobalFilters.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect } from "storybook/test"; -import { GlobalFilters } from "./global-filters"; +import { GlobalFilters } from "./GlobalFilters"; import { AccessProvider } from "@/access/AccessContext"; import { FiltersProvider } from "@/filters/FiltersContext"; import { createInitialFilters, type FiltersState } from "@/filters/filters"; diff --git a/frontend/src/components/filters/global-filters.test.tsx b/frontend/src/components/filters/GlobalFilters/GlobalFilters.test.tsx similarity index 98% rename from frontend/src/components/filters/global-filters.test.tsx rename to frontend/src/components/filters/GlobalFilters/GlobalFilters.test.tsx index 6063854..860be5a 100644 --- a/frontend/src/components/filters/global-filters.test.tsx +++ b/frontend/src/components/filters/GlobalFilters/GlobalFilters.test.tsx @@ -5,7 +5,7 @@ import type { AccessState } from "@/access/AccessContext"; import { AccessProvider } from "@/access/AccessContext"; import { FiltersProvider } from "@/filters/FiltersContext"; import { createInitialFilters, type FiltersState } from "@/filters/filters"; -import { GlobalFilters } from "./global-filters"; +import { GlobalFilters } from "./GlobalFilters"; const GIVEN_TODAY = new Date(2026, 5, 15); const ALL_INSTITUTIONS: Partial = { scope: { type: "all" } }; diff --git a/frontend/src/components/filters/global-filters.tsx b/frontend/src/components/filters/GlobalFilters/GlobalFilters.tsx similarity index 100% rename from frontend/src/components/filters/global-filters.tsx rename to frontend/src/components/filters/GlobalFilters/GlobalFilters.tsx diff --git a/frontend/src/components/filters/GlobalFilters/index.ts b/frontend/src/components/filters/GlobalFilters/index.ts new file mode 100644 index 0000000..cc6e11a --- /dev/null +++ b/frontend/src/components/filters/GlobalFilters/index.ts @@ -0,0 +1 @@ +export * from "./GlobalFilters"; diff --git a/frontend/src/components/filters/time-filter-bar.stories.tsx b/frontend/src/components/filters/TimeFilterBar/TimeFilterBar.stories.tsx similarity index 97% rename from frontend/src/components/filters/time-filter-bar.stories.tsx rename to frontend/src/components/filters/TimeFilterBar/TimeFilterBar.stories.tsx index d655c3a..77afd4a 100644 --- a/frontend/src/components/filters/time-filter-bar.stories.tsx +++ b/frontend/src/components/filters/TimeFilterBar/TimeFilterBar.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect } from "storybook/test"; -import { TimeFilterBar } from "./time-filter-bar"; +import { TimeFilterBar } from "./TimeFilterBar"; import { FiltersProvider } from "@/filters/FiltersContext"; import { createInitialFilters, deriveGranularity } from "@/filters/filters"; diff --git a/frontend/src/components/filters/time-filter-bar.test.tsx b/frontend/src/components/filters/TimeFilterBar/TimeFilterBar.test.tsx similarity index 97% rename from frontend/src/components/filters/time-filter-bar.test.tsx rename to frontend/src/components/filters/TimeFilterBar/TimeFilterBar.test.tsx index 20cd805..83fe7b4 100644 --- a/frontend/src/components/filters/time-filter-bar.test.tsx +++ b/frontend/src/components/filters/TimeFilterBar/TimeFilterBar.test.tsx @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { fireEvent, render, screen } from "@/_test_utilities/test-utils"; import { FiltersProvider } from "@/filters/FiltersContext"; import { createInitialFilters } from "@/filters/filters"; -import { TimeFilterBar, type TimeFilterBarProps } from "./time-filter-bar"; +import { TimeFilterBar, type TimeFilterBarProps } from "./TimeFilterBar"; const GIVEN_TODAY = new Date(2026, 5, 15); diff --git a/frontend/src/components/filters/time-filter-bar.tsx b/frontend/src/components/filters/TimeFilterBar/TimeFilterBar.tsx similarity index 100% rename from frontend/src/components/filters/time-filter-bar.tsx rename to frontend/src/components/filters/TimeFilterBar/TimeFilterBar.tsx diff --git a/frontend/src/components/filters/TimeFilterBar/index.ts b/frontend/src/components/filters/TimeFilterBar/index.ts new file mode 100644 index 0000000..9fbe030 --- /dev/null +++ b/frontend/src/components/filters/TimeFilterBar/index.ts @@ -0,0 +1 @@ +export * from "./TimeFilterBar"; diff --git a/frontend/src/components/sidebar/app-sidebar.stories.tsx b/frontend/src/components/sidebar/AppSidebar/AppSidebar.stories.tsx similarity index 98% rename from frontend/src/components/sidebar/app-sidebar.stories.tsx rename to frontend/src/components/sidebar/AppSidebar/AppSidebar.stories.tsx index ff64266..c67cef2 100644 --- a/frontend/src/components/sidebar/app-sidebar.stories.tsx +++ b/frontend/src/components/sidebar/AppSidebar/AppSidebar.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect } from "storybook/test"; -import { AppSidebar } from "./app-sidebar"; +import { AppSidebar } from "./AppSidebar"; import { SidebarProvider } from "@/components/ui/sidebar"; import { AccessProvider } from "@/access/AccessContext"; import { PERMISSIONS, MODULE_IDS } from "@/access/AccessContext"; diff --git a/frontend/src/components/sidebar/app-sidebar.test.tsx b/frontend/src/components/sidebar/AppSidebar/AppSidebar.test.tsx similarity index 93% rename from frontend/src/components/sidebar/app-sidebar.test.tsx rename to frontend/src/components/sidebar/AppSidebar/AppSidebar.test.tsx index 6609948..fe2305b 100644 --- a/frontend/src/components/sidebar/app-sidebar.test.tsx +++ b/frontend/src/components/sidebar/AppSidebar/AppSidebar.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { render, screen } from "@/_test_utilities/test-utils"; -import { AppSidebar } from "./app-sidebar"; +import { AppSidebar } from "./AppSidebar"; import { SidebarProvider } from "@/components/ui/sidebar"; import { AccessProvider } from "@/access/AccessContext"; diff --git a/frontend/src/components/sidebar/app-sidebar.tsx b/frontend/src/components/sidebar/AppSidebar/AppSidebar.tsx similarity index 93% rename from frontend/src/components/sidebar/app-sidebar.tsx rename to frontend/src/components/sidebar/AppSidebar/AppSidebar.tsx index d43277f..8509e64 100644 --- a/frontend/src/components/sidebar/app-sidebar.tsx +++ b/frontend/src/components/sidebar/AppSidebar/AppSidebar.tsx @@ -1,8 +1,8 @@ import { useNavigate } from "react-router-dom"; import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader } from "@/components/ui/sidebar"; import { getAppName, getLogoInverseUrl } from "@/branding/brandingConfig"; -import { SidebarNav } from "@/components/sidebar/components/sidebar-nav"; -import { SidebarUserMenu } from "@/components/sidebar/components/sidebar-user-menu"; +import { SidebarNav } from "@/components/sidebar/components/SidebarNav"; +import { SidebarUserMenu } from "@/components/sidebar/components/SidebarUserMenu"; import { AuthenticationServiceFactory } from "@/auth/services/Authentication.service.factory"; import { routerPaths } from "@/app/routerPaths"; diff --git a/frontend/src/components/sidebar/AppSidebar/index.ts b/frontend/src/components/sidebar/AppSidebar/index.ts new file mode 100644 index 0000000..2b27d49 --- /dev/null +++ b/frontend/src/components/sidebar/AppSidebar/index.ts @@ -0,0 +1 @@ +export * from "./AppSidebar"; diff --git a/frontend/src/components/sidebar/components/sidebar-nav.stories.tsx b/frontend/src/components/sidebar/components/SidebarNav/SidebarNav.stories.tsx similarity index 98% rename from frontend/src/components/sidebar/components/sidebar-nav.stories.tsx rename to frontend/src/components/sidebar/components/SidebarNav/SidebarNav.stories.tsx index b2210df..bb8fbf6 100644 --- a/frontend/src/components/sidebar/components/sidebar-nav.stories.tsx +++ b/frontend/src/components/sidebar/components/SidebarNav/SidebarNav.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect } from "storybook/test"; -import { SidebarNav } from "./sidebar-nav"; +import { SidebarNav } from "./SidebarNav"; import { SidebarProvider } from "@/components/ui/sidebar"; import { AccessProvider } from "@/access/AccessContext"; import { MODULE_IDS, PERMISSIONS } from "@/access/AccessContext"; diff --git a/frontend/src/components/sidebar/components/sidebar-nav.test.tsx b/frontend/src/components/sidebar/components/SidebarNav/SidebarNav.test.tsx similarity index 99% rename from frontend/src/components/sidebar/components/sidebar-nav.test.tsx rename to frontend/src/components/sidebar/components/SidebarNav/SidebarNav.test.tsx index 17fac4a..9842f4a 100644 --- a/frontend/src/components/sidebar/components/sidebar-nav.test.tsx +++ b/frontend/src/components/sidebar/components/SidebarNav/SidebarNav.test.tsx @@ -10,7 +10,7 @@ import { type ModuleId, type PermissionKey, } from "@/access/AccessContext"; -import { getModuleSubItems, getVisibleNavItems, NAV_ITEMS, SidebarNav, type NavVisibilityContext } from "./sidebar-nav"; +import { getModuleSubItems, getVisibleNavItems, NAV_ITEMS, SidebarNav, type NavVisibilityContext } from "./SidebarNav"; function renderNav(access: Partial = {}) { return render( diff --git a/frontend/src/components/sidebar/components/sidebar-nav.tsx b/frontend/src/components/sidebar/components/SidebarNav/SidebarNav.tsx similarity index 100% rename from frontend/src/components/sidebar/components/sidebar-nav.tsx rename to frontend/src/components/sidebar/components/SidebarNav/SidebarNav.tsx diff --git a/frontend/src/components/sidebar/components/SidebarNav/index.ts b/frontend/src/components/sidebar/components/SidebarNav/index.ts new file mode 100644 index 0000000..767095e --- /dev/null +++ b/frontend/src/components/sidebar/components/SidebarNav/index.ts @@ -0,0 +1 @@ +export * from "./SidebarNav"; diff --git a/frontend/src/components/sidebar/components/sidebar-user-menu.stories.tsx b/frontend/src/components/sidebar/components/SidebarUserMenu/SidebarUserMenu.stories.tsx similarity index 93% rename from frontend/src/components/sidebar/components/sidebar-user-menu.stories.tsx rename to frontend/src/components/sidebar/components/SidebarUserMenu/SidebarUserMenu.stories.tsx index 51e1f3b..3bea5bc 100644 --- a/frontend/src/components/sidebar/components/sidebar-user-menu.stories.tsx +++ b/frontend/src/components/sidebar/components/SidebarUserMenu/SidebarUserMenu.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, fn } from "storybook/test"; -import { SidebarUserMenu } from "./sidebar-user-menu"; +import { SidebarUserMenu } from "./SidebarUserMenu"; import { SidebarProvider } from "@/components/ui/sidebar"; const meta = { diff --git a/frontend/src/components/sidebar/components/sidebar-user-menu.test.tsx b/frontend/src/components/sidebar/components/SidebarUserMenu/SidebarUserMenu.test.tsx similarity index 97% rename from frontend/src/components/sidebar/components/sidebar-user-menu.test.tsx rename to frontend/src/components/sidebar/components/SidebarUserMenu/SidebarUserMenu.test.tsx index 9512f7a..5e19cb1 100644 --- a/frontend/src/components/sidebar/components/sidebar-user-menu.test.tsx +++ b/frontend/src/components/sidebar/components/SidebarUserMenu/SidebarUserMenu.test.tsx @@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event"; import { render, screen } from "@/_test_utilities/test-utils"; import { SidebarProvider } from "@/components/ui/sidebar"; import { DATA_TEST_ID as USER_AVATAR_TEST_ID } from "@/components/shared/UserAvatar"; -import { SidebarUserMenu } from "./sidebar-user-menu"; +import { SidebarUserMenu } from "./SidebarUserMenu"; function renderMenu() { const onSignOut = vi.fn(); diff --git a/frontend/src/components/sidebar/components/sidebar-user-menu.tsx b/frontend/src/components/sidebar/components/SidebarUserMenu/SidebarUserMenu.tsx similarity index 100% rename from frontend/src/components/sidebar/components/sidebar-user-menu.tsx rename to frontend/src/components/sidebar/components/SidebarUserMenu/SidebarUserMenu.tsx diff --git a/frontend/src/components/sidebar/components/SidebarUserMenu/index.ts b/frontend/src/components/sidebar/components/SidebarUserMenu/index.ts new file mode 100644 index 0000000..4ad19e2 --- /dev/null +++ b/frontend/src/components/sidebar/components/SidebarUserMenu/index.ts @@ -0,0 +1 @@ +export * from "./SidebarUserMenu"; diff --git a/frontend/src/i18n/languageSwitcher/LanguageSwitcher.stories.tsx b/frontend/src/i18n/LanguageSwitcher/LanguageSwitcher.stories.tsx similarity index 100% rename from frontend/src/i18n/languageSwitcher/LanguageSwitcher.stories.tsx rename to frontend/src/i18n/LanguageSwitcher/LanguageSwitcher.stories.tsx diff --git a/frontend/src/i18n/languageSwitcher/LanguageSwitcher.test.tsx b/frontend/src/i18n/LanguageSwitcher/LanguageSwitcher.test.tsx similarity index 100% rename from frontend/src/i18n/languageSwitcher/LanguageSwitcher.test.tsx rename to frontend/src/i18n/LanguageSwitcher/LanguageSwitcher.test.tsx diff --git a/frontend/src/i18n/languageSwitcher/LanguageSwitcher.tsx b/frontend/src/i18n/LanguageSwitcher/LanguageSwitcher.tsx similarity index 100% rename from frontend/src/i18n/languageSwitcher/LanguageSwitcher.tsx rename to frontend/src/i18n/LanguageSwitcher/LanguageSwitcher.tsx diff --git a/frontend/src/i18n/LanguageSwitcher/index.ts b/frontend/src/i18n/LanguageSwitcher/index.ts new file mode 100644 index 0000000..ff62f20 --- /dev/null +++ b/frontend/src/i18n/LanguageSwitcher/index.ts @@ -0,0 +1 @@ +export * from "./LanguageSwitcher"; diff --git a/frontend/src/pages/Login/Login.tsx b/frontend/src/pages/Login/Login.tsx index c435b91..5ee0d52 100644 --- a/frontend/src/pages/Login/Login.tsx +++ b/frontend/src/pages/Login/Login.tsx @@ -4,9 +4,9 @@ import { Link, useNavigate } from "react-router-dom"; import { ArrowRight, Mail, Lock } from "lucide-react"; import { Button } from "@/components/ui/button"; import { getAppName } from "@/branding/brandingConfig"; -import { AuthLayout } from "@/auth/components/AuthLayout/AuthLayout"; -import { Field } from "@/auth/components/Field/Field"; -import { SocialAuth } from "@/auth/components/SocialAuth/SocialAuth"; +import { AuthLayout } from "@/auth/components/AuthLayout"; +import { Field } from "@/auth/components/Field"; +import { SocialAuth } from "@/auth/components/SocialAuth"; import { routerPaths } from "@/app/routerPaths"; import { AuthApiError } from "@/auth/services/Authentication.service"; import { AuthenticationServiceFactory } from "@/auth/services/Authentication.service.factory"; diff --git a/frontend/src/pages/Login/index.ts b/frontend/src/pages/Login/index.ts new file mode 100644 index 0000000..2b0a75c --- /dev/null +++ b/frontend/src/pages/Login/index.ts @@ -0,0 +1 @@ +export * from "./Login"; diff --git a/frontend/src/pages/Register/Register.tsx b/frontend/src/pages/Register/Register.tsx index 66c45b5..c8c6579 100644 --- a/frontend/src/pages/Register/Register.tsx +++ b/frontend/src/pages/Register/Register.tsx @@ -3,10 +3,10 @@ import { useTranslation } from "react-i18next"; import { Link, useNavigate } from "react-router-dom"; import { ArrowRight, Mail, Lock, User, Building2 } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { AuthLayout } from "@/auth/components/AuthLayout/AuthLayout"; -import { Field } from "@/auth/components/Field/Field"; -import { SocialAuth } from "@/auth/components/SocialAuth/SocialAuth"; -import { PasswordRequirements, isStrongPassword } from "@/auth/components/PasswordRequirements/PasswordRequirements"; +import { AuthLayout } from "@/auth/components/AuthLayout"; +import { Field } from "@/auth/components/Field"; +import { SocialAuth } from "@/auth/components/SocialAuth"; +import { PasswordRequirements, isStrongPassword } from "@/auth/components/PasswordRequirements"; import { routerPaths } from "@/app/routerPaths"; import { AuthApiError } from "@/auth/services/Authentication.service"; import { AuthenticationServiceFactory } from "@/auth/services/Authentication.service.factory"; diff --git a/frontend/src/pages/Register/index.ts b/frontend/src/pages/Register/index.ts new file mode 100644 index 0000000..fbf8b4e --- /dev/null +++ b/frontend/src/pages/Register/index.ts @@ -0,0 +1 @@ +export * from "./Register"; diff --git a/frontend/src/sentry/ErrorFallback.stories.tsx b/frontend/src/sentry/ErrorFallback/ErrorFallback.stories.tsx similarity index 100% rename from frontend/src/sentry/ErrorFallback.stories.tsx rename to frontend/src/sentry/ErrorFallback/ErrorFallback.stories.tsx diff --git a/frontend/src/sentry/ErrorFallback.tsx b/frontend/src/sentry/ErrorFallback/ErrorFallback.tsx similarity index 100% rename from frontend/src/sentry/ErrorFallback.tsx rename to frontend/src/sentry/ErrorFallback/ErrorFallback.tsx diff --git a/frontend/src/sentry/ErrorFallback/index.ts b/frontend/src/sentry/ErrorFallback/index.ts new file mode 100644 index 0000000..ee25d10 --- /dev/null +++ b/frontend/src/sentry/ErrorFallback/index.ts @@ -0,0 +1 @@ +export * from "./ErrorFallback";