+ ),
+ ],
+} 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. */}
+
+ ),
+ ],
+} 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 (
+
+ );
+}
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. */}
+
+ {bounds.map((bound, index) => {
+ const stride = labelStride(bounds, plot.width);
+ if (index % stride !== 0 && index !== bounds.length - 1) return null;
+ return (
+
+ {boundFormatter(bound)}
+
+ );
+ })}
+
+
+ {target != null && (
+
+
+ {targetLabel && (
+ plot.left + plot.width * 0.8 ? "end" : "middle"}
+ className="fill-muted-foreground text-[11px]"
+ >
+ {targetLabel}
+
+ )}
+
+ )}
+ >
+ );
+ }}
+
+ );
+}
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 (
+
+ );
+}
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 (
+
+ {ticks.map((tick) => {
+ const y = yAt(tick, max, plot);
+ return (
+
+
+
+ {formatTick(tick)}
+
+
+ );
+ })}
+
+ );
+}
+
+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 (
+
+ {/* The last label always shows: an axis stopping short reads as truncated data. */}
+ {labels.map((label, index) =>
+ index % stride === 0 || index === labels.length - 1 ? (
+
+ {label}
+
+ ) : null
+ )}
+
+ );
+}
+
+/** 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) => (
+
+ ),
+ },
+ 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 && (
+
+ )}
+ {width > 0 && overlay?.(width)}
+ {footer}
+
+
+ );
+}
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 (
+
+
{title}
+
+ {rows.map((row) => (
+
+
+ {row.value}
+ {row.label}
+
+ ))}
+
+
+ );
+}
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/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/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/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/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/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";
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,