Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ plus one entry each in `SupportedLocales` and `LocalesLabels` (`src/i18n/constan
tests assert against real copy, not placeholder keys, without needing async init). Storybook takes the opposite
approach: `.storybook/preview.tsx` wires the real i18next instance through `I18nextProvider`, with a toolbar
`globalTypes.locale` dropdown to preview other locales live.
- **Language switcher**: `src/i18n/languageSwitcher/LanguageSwitcher.tsx`, in the sidebar footer.
- **Language switcher**: `src/i18n/LanguageSwitcher/LanguageSwitcher.tsx`, in the sidebar footer.

## Error tracking (Sentry)

Expand All @@ -92,7 +92,7 @@ build/deployment rather than something that should be swappable without a rebuil
the literal string `"true"` to enable — any other value (including unset) leaves it off, so a local/unconfigured
build never reports.

`Sentry.ErrorBoundary` wraps `<App />` in `main.tsx`, falling back to `src/sentry/ErrorFallback.tsx` on an uncaught
`Sentry.ErrorBoundary` wraps `<App />` in `main.tsx`, falling back to `src/sentry/ErrorFallback/ErrorFallback.tsx` on an uncaught
render error. This is intentionally a minimal core setup (init + error boundary only) — no router instrumentation
(no router exists yet), no feedback widget, no sourcemap upload pipeline. Add those later if/when they're needed,
following compass's `frontend-new/src/sentryInit.ts` for reference, but note compass's setup includes some
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Outlet } from "react-router-dom";
import { AppSidebar } from "@/components/sidebar/app-sidebar";
import { AppSidebar } from "@/components/sidebar/AppSidebar";
import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar";
import { AccessProvider } from "@/access/AccessContext";
import { FiltersProvider } from "@/filters/FiltersContext";
Expand Down
1 change: 1 addition & 0 deletions frontend/src/app/Layout/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./Layout";
6 changes: 2 additions & 4 deletions frontend/src/app/ProtectedRoute/ProtectedRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ interface ProtectedRouteProps {
children: ReactNode;
}

const ProtectedRoute = ({ children }: ProtectedRouteProps) => {
export function ProtectedRoute({ children }: ProtectedRouteProps) {
return <>{children}</>;
};

export default ProtectedRoute;
}
1 change: 1 addition & 0 deletions frontend/src/app/ProtectedRoute/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./ProtectedRoute";
6 changes: 3 additions & 3 deletions frontend/src/app/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { createHashRouter, Navigate, RouterProvider } from "react-router-dom";
import ProtectedRoute from "@/app/ProtectedRoute/ProtectedRoute";
import { ProtectedRoute } from "@/app/ProtectedRoute";
import { Layout } from "@/app/Layout";
import { routerPaths } from "@/app/routerPaths";
import { Login } from "@/pages/Login/Login";
import { Register } from "@/pages/Register/Register";
import { Login } from "@/pages/Login";
import { Register } from "@/pages/Register";

const router = createHashRouter([
{
Expand Down
1 change: 1 addition & 0 deletions frontend/src/auth/components/AuthLayout/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./AuthLayout";
1 change: 1 addition & 0 deletions frontend/src/auth/components/Field/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./Field";
1 change: 1 addition & 0 deletions frontend/src/auth/components/PasswordRequirements/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./PasswordRequirements";
1 change: 1 addition & 0 deletions frontend/src/auth/components/SocialAuth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./SocialAuth";
94 changes: 94 additions & 0 deletions frontend/src/components/charts/BarChart/BarChart.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, waitFor, within } from "storybook/test";
import { BarChart, DATA_TEST_ID } from "./BarChart";
import { DATA_TEST_ID as FRAME_TEST_ID } from "@/components/charts/components/ChartFrame";
import { DATA_TEST_ID as LEGEND_TEST_ID } from "@/components/charts/components/ChartLegend";
import { DATA_TEST_ID as TOOLTIP_TEST_ID } from "@/components/charts/components/ChartTooltip";

// Placeholder data throughout — the real copy is not settled.
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug"];

const SERIES_A = { id: "a", label: "Series A", values: [155, 96, 160, 152, 258, 118, 205, 128] };
const SERIES_B = { id: "b", label: "Series B", values: [63, 41, 66, 58, 105, 51, 108, 52] };

const meta = {
component: BarChart,
tags: ["autodocs"],
args: {
label: "Series A by month",
categories: MONTHS,
series: [SERIES_A],
},
decorators: [
(Story) => (
<div className="w-160 max-w-full rounded-card bg-card p-6">
<Story />
</div>
),
],
} satisfies Meta<typeof BarChart>;

export default meta;
type Story = StoryObj<typeof meta>;

export const SingleSeries: Story = {
play: async ({ canvas }) => {
// One bar per category, and no legend — a single series is named by the title.
await waitFor(async () => await expect(canvas.getAllByTestId(DATA_TEST_ID.BAR)).toHaveLength(MONTHS.length));

await expect(canvas.queryByTestId(LEGEND_TEST_ID.CONTAINER)).not.toBeInTheDocument();
},
};

export const Stacked: Story = {
args: {
label: "Series A and Series B by month",
series: [SERIES_A, SERIES_B],
stacked: true,
},
play: async ({ canvas }) => {
// Both segments of every column, separated by the 2px surface gap.
await waitFor(async () => await expect(canvas.getAllByTestId(DATA_TEST_ID.BAR)).toHaveLength(MONTHS.length * 2));

const legend = within(canvas.getByTestId(LEGEND_TEST_ID.CONTAINER));
await expect(legend.getByText("Series A")).toBeVisible();
await expect(legend.getByText("Series B")).toBeVisible();

// A stack is read against its total, so the data table carries one.
const table = within(canvas.getByTestId(FRAME_TEST_ID.TABLE));
await expect(table.getByRole("columnheader", { name: "Total" })).toBeInTheDocument();
},
};

export const Grouped: Story = {
args: {
label: "Series A and Series B by month",
series: [SERIES_A, SERIES_B],
stacked: false,
},
play: async ({ canvas }) => {
await waitFor(async () => await expect(canvas.getAllByTestId(DATA_TEST_ID.BAR)).toHaveLength(MONTHS.length * 2));
},
};

export const HoverShowsTooltip: Story = {
args: { series: [SERIES_A, SERIES_B], stacked: true },
play: async ({ canvas }) => {
const bands = await waitFor(() => canvas.getAllByTestId(DATA_TEST_ID.BAND));

await userEvent.hover(bands[2]);

// The readout names the category and lists every series in the stack, so
// the pointer never has to land on one segment to read it.
const tooltip = await waitFor(() => canvas.getByTestId(TOOLTIP_TEST_ID.CONTAINER));
await expect(within(tooltip).getByText("Mar")).toBeInTheDocument();
await expect(within(tooltip).getAllByTestId(TOOLTIP_TEST_ID.ROW)).toHaveLength(2);
},
};

export const Empty: Story = {
args: { categories: [], series: [] },
play: async ({ canvas }) => {
await expect(canvas.getByTestId(FRAME_TEST_ID.EMPTY)).toBeVisible();
},
};
126 changes: 126 additions & 0 deletions frontend/src/components/charts/BarChart/BarChart.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { describe, expect, it } from "vitest";
import { render, screen, within } from "@/_test_utilities/test-utils";
import { BarChart, DATA_TEST_ID, type BarChartSeries } from "./BarChart";
import { DATA_TEST_ID as FRAME_TEST_ID } from "@/components/charts/components/ChartFrame";
import { DATA_TEST_ID as LEGEND_TEST_ID } from "@/components/charts/components/ChartLegend";

const LABEL = "New and returning users by month";
const CATEGORIES = ["Jul", "Aug", "Sep", "Oct"];

const NEW_USERS: BarChartSeries = { id: "new", label: "New", values: [155, 96, 160, 152] };
const RETURNING_USERS: BarChartSeries = { id: "returning", label: "Returning", values: [63, 41, 66, 58] };

/** Bars are paths, so width comes from the geometry rather than an attribute. */
function barWidths(): number[] {
return screen.getAllByTestId(DATA_TEST_ID.BAR).map((bar) => {
const xs = [...(bar.getAttribute("d") ?? "").matchAll(/[ML](-?[\d.]+),/g)].map((match) => Number(match[1]));
return Math.max(...xs) - Math.min(...xs);
});
}

describe("BarChart", () => {
it("should draw one bar per category for a single series", () => {
// GIVEN one series across four months
// WHEN it is rendered
render(<BarChart label={LABEL} categories={CATEGORIES} series={[NEW_USERS]} />);

// THEN there is a bar per month, and no legend to restate the title
expect(screen.getAllByTestId(DATA_TEST_ID.BAR)).toHaveLength(CATEGORIES.length);
expect(screen.queryByTestId(LEGEND_TEST_ID.CONTAINER)).not.toBeInTheDocument();
});

it("should stack a segment per series into each column", () => {
// GIVEN two series over the same months
// WHEN they are stacked
render(<BarChart label={LABEL} categories={CATEGORIES} series={[NEW_USERS, RETURNING_USERS]} stacked />);

// THEN every column carries a segment for each series, named by the legend
expect(screen.getAllByTestId(DATA_TEST_ID.BAR)).toHaveLength(CATEGORIES.length * 2);
const actualLegend = within(screen.getByTestId(LEGEND_TEST_ID.CONTAINER));
expect(actualLegend.getByText("New")).toBeInTheDocument();
expect(actualLegend.getByText("Returning")).toBeInTheDocument();
});

it("should give a stacked chart's data table the column total, since that is what the stack shows", () => {
// GIVEN two stacked series
// WHEN they are rendered
render(<BarChart label={LABEL} categories={CATEGORIES} series={[NEW_USERS, RETURNING_USERS]} stacked />);

// THEN the table carries each part and the whole the column is read against
const actualTable = within(screen.getByTestId(FRAME_TEST_ID.TABLE));
expect(actualTable.getByRole("columnheader", { name: "Total" })).toBeInTheDocument();
expect(actualTable.getByRole("cell", { name: "218" })).toBeInTheDocument();
});

it("should leave the total out of a grouped chart's table, which has no stack to sum", () => {
// GIVEN two series rendered side by side
// WHEN they are rendered
render(<BarChart label={LABEL} categories={CATEGORIES} series={[NEW_USERS, RETURNING_USERS]} stacked={false} />);

// THEN each series still gets a column, but nothing claims a combined figure
const actualTable = within(screen.getByTestId(FRAME_TEST_ID.TABLE));
expect(actualTable.getByRole("columnheader", { name: "New" })).toBeInTheDocument();
expect(actualTable.queryByRole("columnheader", { name: "Total" })).not.toBeInTheDocument();
});

it("should place grouped bars side by side, narrower than a stacked one", () => {
// GIVEN two series rendered stacked
const { unmount } = render(
<BarChart label={LABEL} categories={CATEGORIES} series={[NEW_USERS, RETURNING_USERS]} stacked />
);
const expectedStackedWidth = barWidths()[0];
unmount();

// WHEN the same two series are grouped instead
render(<BarChart label={LABEL} categories={CATEGORIES} series={[NEW_USERS, RETURNING_USERS]} stacked={false} />);

// THEN each bar takes its share of the slot, so they fit beside each other
expect(screen.getAllByTestId(DATA_TEST_ID.BAR)).toHaveLength(CATEGORIES.length * 2);
expect(barWidths()[0]).toBeLessThan(expectedStackedWidth);
});

it("should cap bar width, so a bar never fills its whole band", () => {
// GIVEN a chart with only two categories in a wide container
// WHEN it is rendered
render(<BarChart label={LABEL} categories={["Jul", "Aug"]} series={[{ ...NEW_USERS, values: [155, 96] }]} />);

// THEN the bars stay thin and the band's leftover reads as deliberate air
for (const width of barWidths()) {
expect(width).toBeLessThanOrEqual(24);
}
});

it("should give every column a full-height hit target, not just its painted bar", () => {
// GIVEN a chart with a short bar in it
// WHEN it is rendered
render(<BarChart label={LABEL} categories={CATEGORIES} series={[NEW_USERS]} />);

// THEN each category has a band covering the whole column height
expect(screen.getAllByTestId(DATA_TEST_ID.BAND)).toHaveLength(CATEGORIES.length);
});

it("should draw no bar for a category with no value", () => {
// GIVEN a series with a zero in it
const withGap: BarChartSeries = { id: "new", label: "New", values: [155, 0, 160, 152] };

// WHEN it is rendered
render(<BarChart label={LABEL} categories={CATEGORIES} series={[withGap]} />);

// THEN the empty month gets nothing rather than a hairline pretending to be a value
expect(screen.getAllByTestId(DATA_TEST_ID.BAR)).toHaveLength(3);
});

it("should show the empty state when there is nothing to plot", () => {
// GIVEN no categories and no series
// WHEN the chart is rendered
const { unmount } = render(<BarChart label={LABEL} categories={[]} series={[]} />);

// THEN the reader is told so
expect(screen.getByTestId(FRAME_TEST_ID.EMPTY)).toBeInTheDocument();
unmount();

// AND the same when there are months but no series to plot against them
render(<BarChart label={LABEL} categories={CATEGORIES} series={[]} />);
expect(screen.getByTestId(FRAME_TEST_ID.EMPTY)).toBeInTheDocument();
});
});
Loading
Loading