From aa73edf3f67a33fe284f2d93dfd04ddd43153d03 Mon Sep 17 00:00:00 2001 From: Fides Date: Wed, 29 Jul 2026 17:28:36 +0200 Subject: [PATCH] feat(frontend): implement dashboard shell with navigation and global filters --- frontend/src/_test_utilities/test-utils.tsx | 11 +- frontend/src/access/AccessContext.test.tsx | 99 ++++++++ frontend/src/access/AccessContext.tsx | 69 ++++++ frontend/src/app/Layout.test.tsx | 66 ++++++ frontend/src/app/Layout.tsx | 21 +- frontend/src/app/index.tsx | 25 +- frontend/src/app/routerPaths.ts | 13 +- .../src/components/app-sidebar.stories.tsx | 36 --- frontend/src/components/app-sidebar.tsx | 20 -- .../filters/global-filters.stories.tsx | 69 ++++++ .../filters/global-filters.test.tsx | 93 ++++++++ .../src/components/filters/global-filters.tsx | 83 +++++++ .../filters/time-filter-bar.stories.tsx | 57 +++++ .../filters/time-filter-bar.test.tsx | 79 +++++++ .../components/filters/time-filter-bar.tsx | 59 +++++ .../sidebar/app-sidebar.stories.tsx | 98 ++++++++ .../{ => sidebar}/app-sidebar.test.tsx | 11 +- .../src/components/sidebar/app-sidebar.tsx | 33 +++ .../components/sidebar-nav.stories.tsx | 88 +++++++ .../sidebar/components/sidebar-nav.test.tsx | 189 +++++++++++++++ .../sidebar/components/sidebar-nav.tsx | 152 ++++++++++++ .../components/sidebar-user-menu.stories.tsx | 29 +++ .../components/sidebar-user-menu.test.tsx | 42 ++++ .../sidebar/components/sidebar-user-menu.tsx | 50 ++++ frontend/src/components/ui/dropdown-menu.tsx | 217 ++++++++++++++++++ frontend/src/filters/FiltersContext.test.tsx | 110 +++++++++ frontend/src/filters/FiltersContext.tsx | 65 ++++++ frontend/src/filters/filters.test.ts | 122 ++++++++++ frontend/src/filters/filters.ts | 86 +++++++ .../src/i18n/locales/en-GB/translation.json | 48 ++++ frontend/src/pages/HomePage.tsx | 3 - 31 files changed, 2062 insertions(+), 81 deletions(-) create mode 100644 frontend/src/access/AccessContext.test.tsx create mode 100644 frontend/src/access/AccessContext.tsx create mode 100644 frontend/src/app/Layout.test.tsx delete mode 100644 frontend/src/components/app-sidebar.stories.tsx delete mode 100644 frontend/src/components/app-sidebar.tsx create mode 100644 frontend/src/components/filters/global-filters.stories.tsx create mode 100644 frontend/src/components/filters/global-filters.test.tsx create mode 100644 frontend/src/components/filters/global-filters.tsx create mode 100644 frontend/src/components/filters/time-filter-bar.stories.tsx create mode 100644 frontend/src/components/filters/time-filter-bar.test.tsx create mode 100644 frontend/src/components/filters/time-filter-bar.tsx create mode 100644 frontend/src/components/sidebar/app-sidebar.stories.tsx rename frontend/src/components/{ => sidebar}/app-sidebar.test.tsx (62%) create mode 100644 frontend/src/components/sidebar/app-sidebar.tsx create mode 100644 frontend/src/components/sidebar/components/sidebar-nav.stories.tsx create mode 100644 frontend/src/components/sidebar/components/sidebar-nav.test.tsx create mode 100644 frontend/src/components/sidebar/components/sidebar-nav.tsx create mode 100644 frontend/src/components/sidebar/components/sidebar-user-menu.stories.tsx create mode 100644 frontend/src/components/sidebar/components/sidebar-user-menu.test.tsx create mode 100644 frontend/src/components/sidebar/components/sidebar-user-menu.tsx create mode 100644 frontend/src/components/ui/dropdown-menu.tsx create mode 100644 frontend/src/filters/FiltersContext.test.tsx create mode 100644 frontend/src/filters/FiltersContext.tsx create mode 100644 frontend/src/filters/filters.test.ts create mode 100644 frontend/src/filters/filters.ts delete mode 100644 frontend/src/pages/HomePage.tsx diff --git a/frontend/src/_test_utilities/test-utils.tsx b/frontend/src/_test_utilities/test-utils.tsx index 89ee815..cc94be9 100644 --- a/frontend/src/_test_utilities/test-utils.tsx +++ b/frontend/src/_test_utilities/test-utils.tsx @@ -1,10 +1,15 @@ import type { ReactElement, ReactNode } from "react"; import { render as rtlRender, type RenderOptions } from "@testing-library/react"; import { HashRouter } from "react-router-dom"; +import { AccessProvider } from "@/access/AccessContext"; -// Wraps components under test in app-wide providers -export const AllTheProviders = ({ children }: { children: ReactNode }) => { - return {children}; +// Session-wide providers only. Filter tests mount their own FiltersProvider, with a fixed date. +export const AllTheProviders = ({ children }: Readonly<{ children: ReactNode }>) => { + return ( + + {children} + + ); }; function render(ui: ReactElement, options?: Omit) { diff --git a/frontend/src/access/AccessContext.test.tsx b/frontend/src/access/AccessContext.test.tsx new file mode 100644 index 0000000..8a51d70 --- /dev/null +++ b/frontend/src/access/AccessContext.test.tsx @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from "vitest"; +import { render as renderWithoutProviders } from "@testing-library/react"; +import { render, screen } from "@/_test_utilities/test-utils"; +import { MODULE_IDS, PERMISSIONS } from "@/access/AccessContext"; +import { AccessProvider, useAccess } from "./AccessContext"; + +function AccessProbe() { + const access = useAccess(); + return ( +
+ {String(access.hasPermission("institutions:view"))} + {String(access.hasPermission("access-management:manage"))} + {access.activeModules.join(",")} + {String(access.isMultiInstitution)} +
+ ); +} + +describe("AccessProvider", () => { + it("should serve the built-in grant when no access is passed", () => { + // GIVEN no explicit access + // WHEN rendered + render( + + + + ); + + // THEN the placeholder grant defined in code applies — every permission, every module + expect(screen.getByTestId("has-institutions")).toHaveTextContent("true"); + expect(screen.getByTestId("active-modules")).toHaveTextContent(Object.values(MODULE_IDS).join(",")); + }); + + it("should expose the permissions and active modules it is given", () => { + // GIVEN a grant with two active modules + const givenAccess = { + activeModules: [MODULE_IDS.BUILD_YOUR_PROFILE, MODULE_IDS.JOB_READINESS], + }; + + // WHEN rendered + render( + + + + ); + + // THEN only those modules are active + expect(screen.getByTestId("active-modules")).toHaveTextContent("build-your-profile,job-readiness"); + }); + + it("should report false from hasPermission for a permission the grant excludes", () => { + // GIVEN a grant without access-management:manage + const givenAccess = { permissions: new Set([PERMISSIONS.DASHBOARD_VIEW]) }; + + // WHEN rendered + render( + + + + ); + + // THEN that permission is not granted + expect(screen.getByTestId("has-access-management")).toHaveTextContent("false"); + }); + + it("should report isMultiInstitution for an 'all' scope", () => { + // GIVEN a grant covering every institution + render( + + + + ); + + // THEN drilling down is meaningful + expect(screen.getByTestId("is-multi-institution")).toHaveTextContent("true"); + }); + + it("should not report isMultiInstitution for a single-institution scope", () => { + // GIVEN a grant covering exactly one institution + render( + + + + ); + + // THEN drilling down would be a no-op + expect(screen.getByTestId("is-multi-institution")).toHaveTextContent("false"); + }); +}); + +describe("useAccess", () => { + it("should throw when used outside an AccessProvider", () => { + // GIVEN a component using useAccess with no provider above it + // WHEN / THEN rendering it throws + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(() => renderWithoutProviders()).toThrow("useAccess must be used within an AccessProvider."); + consoleError.mockRestore(); + }); +}); diff --git a/frontend/src/access/AccessContext.tsx b/frontend/src/access/AccessContext.tsx new file mode 100644 index 0000000..28b1946 --- /dev/null +++ b/frontend/src/access/AccessContext.tsx @@ -0,0 +1,69 @@ +import { createContext, useContext, useMemo, type ReactNode } from "react"; + +export const PERMISSIONS = { + DASHBOARD_VIEW: "dashboard:view", + INSTITUTIONS_VIEW: "institutions:view", + JOBSEEKERS_VIEW: "jobseekers:view", + ACCESS_MANAGEMENT_MANAGE: "access-management:manage", + ACCOUNT_VIEW: "account:view", +} as const; + +export type PermissionKey = (typeof PERMISSIONS)[keyof typeof PERMISSIONS]; + +export const MODULE_IDS = { + BUILD_YOUR_PROFILE: "build-your-profile", + JOB_READINESS: "job-readiness", + CAREER_EXPLORER: "career-explorer", + JOBS: "jobs", +} as const; + +export type ModuleId = (typeof MODULE_IDS)[keyof typeof MODULE_IDS]; + +/** Which institutions a grant covers: every institution in the deployment, or a named list. */ +export type AccessScope = { type: "all" } | { type: "institutions"; institutionIds: string[] }; + +/** True when the grant covers two or more institutions — either "all", or a list of several. */ +export function coversMultipleInstitutions(scope: AccessScope): boolean { + return scope.type === "all" || scope.institutionIds.length > 1; +} + +export interface AccessState { + permissions: ReadonlySet; + scope: AccessScope; + activeModules: readonly ModuleId[]; +} + +export interface AccessContextValue extends AccessState { + hasPermission: (permission: PermissionKey) => boolean; + isMultiInstitution: boolean; // true ⇒ institution drill-down is meaningful +} + +const DEFAULT_ACCESS: AccessState = { + permissions: new Set(Object.values(PERMISSIONS)), + scope: { type: "institutions", institutionIds: ["inst-1"] }, + activeModules: Object.values(MODULE_IDS), +}; + +const AccessContext = createContext(null); + +export function useAccess(): AccessContextValue { + const context = useContext(AccessContext); + if (!context) { + throw new Error("useAccess must be used within an AccessProvider."); + } + return context; +} + +/** Fields passed in `access` win; the rest fall back to DEFAULT_ACCESS. */ +export function AccessProvider({ children, access }: Readonly<{ children: ReactNode; access?: Partial }>) { + const value = useMemo(() => { + const state: AccessState = { ...DEFAULT_ACCESS, ...access }; + return { + ...state, + hasPermission: (permission) => state.permissions.has(permission), + isMultiInstitution: coversMultipleInstitutions(state.scope), + }; + }, [access]); + + return {children}; +} diff --git a/frontend/src/app/Layout.test.tsx b/frontend/src/app/Layout.test.tsx new file mode 100644 index 0000000..98eae65 --- /dev/null +++ b/frontend/src/app/Layout.test.tsx @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { createMemoryRouter, RouterProvider } from "react-router-dom"; +import { useAccess } from "@/access/AccessContext"; +import { useFilters } from "@/filters/FiltersContext"; +import { Layout } from "./Layout"; + +/** Reads both shared contexts, proving the shell supplies them to the outlet. */ +function Screen() { + const { hasPermission } = useAccess(); + const { filters } = useFilters(); + return ( +
+ Screen + {String(hasPermission("dashboard:view"))} + {filters.granularity} +
+ ); +} + +// Raw RouterProvider: Layout renders an , and two nested Routers aren't supported. +function renderLayout() { + const router = createMemoryRouter([ + { path: "/", element: , children: [{ index: true, element: }] }, + ]); + return render(); +} + +describe("Layout", () => { + it("should render the routed screen alongside the sidebar", () => { + // GIVEN the app shell + // WHEN it mounts + renderLayout(); + + // THEN the outlet content and the sidebar brand mark are both present + expect(screen.getByTestId("screen")).toBeInTheDocument(); + expect(screen.getByAltText("Compass Analytics")).toBeInTheDocument(); + }); + + it("should provide the access context to routed screens", () => { + // GIVEN the app shell + // WHEN it mounts + renderLayout(); + + // THEN a screen can read permissions without wiring its own provider + expect(screen.getByTestId("can-view-dashboard")).toHaveTextContent("true"); + }); + + it("should provide the shared filter state to routed screens", () => { + // GIVEN the app shell + // WHEN it mounts + renderLayout(); + + // THEN a screen reads the shared filters without mounting its own provider + expect(screen.getByTestId("granularity")).toHaveTextContent("day"); + }); + + it("should render the sidebar navigation with the current route marked active", () => { + // GIVEN the app shell at the root path + // WHEN it mounts + renderLayout(); + + // THEN the nav is present and Overview is the active item + expect(screen.getByRole("link", { name: /^Overview$/ })).toHaveAttribute("data-active", "true"); + }); +}); diff --git a/frontend/src/app/Layout.tsx b/frontend/src/app/Layout.tsx index 5706190..1d8f529 100644 --- a/frontend/src/app/Layout.tsx +++ b/frontend/src/app/Layout.tsx @@ -1,14 +1,21 @@ import { Outlet } from "react-router-dom"; -import { AppSidebar } from "@/components/app-sidebar"; +import { AppSidebar } from "@/components/sidebar/app-sidebar"; import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar"; +import { AccessProvider } from "@/access/AccessContext"; +import { FiltersProvider } from "@/filters/FiltersContext"; +/** The shell every screen renders inside. Filters wrap the outlet only — the sidebar doesn't use them. */ export const Layout = () => { return ( - - - - - - + + + + + + + + + + ); }; diff --git a/frontend/src/app/index.tsx b/frontend/src/app/index.tsx index af01da3..09d6c11 100644 --- a/frontend/src/app/index.tsx +++ b/frontend/src/app/index.tsx @@ -1,8 +1,7 @@ import { createHashRouter, Navigate, RouterProvider } from "react-router-dom"; import ProtectedRoute from "@/app/ProtectedRoute/ProtectedRoute"; -import { routerPaths } from "@/app/routerPaths"; import { Layout } from "@/app/Layout"; -import { HomePage } from "@/pages/HomePage"; +import { routerPaths } from "@/app/routerPaths"; import { Login } from "@/pages/Login/Login"; import { Register } from "@/pages/Register/Register"; @@ -29,11 +28,23 @@ const router = createHashRouter([ children: [ { index: true, - element: ( - - - - ), + element: Overview, + }, + { + path: routerPaths.JOBSEEKERS, + element: Jobseekers, + }, + { + path: routerPaths.MODULES, + element: Modules, + }, + { + path: routerPaths.MODULE, + element: Module, + }, + { + path: routerPaths.SETTINGS, + element: Settings, }, ], }, diff --git a/frontend/src/app/routerPaths.ts b/frontend/src/app/routerPaths.ts index d9a6a92..d485556 100644 --- a/frontend/src/app/routerPaths.ts +++ b/frontend/src/app/routerPaths.ts @@ -1,5 +1,16 @@ +import type { ModuleId } from "@/access/AccessContext"; + export const routerPaths = { ROOT: "/", LOGIN: "/login", REGISTER: "/register", -}; + JOBSEEKERS: "/jobseekers", + MODULES: "/modules", + MODULE: "/modules/:moduleId", + SETTINGS: "/settings", +} as const; + +/** Link target for a specific module, e.g. modulePath("jobs") === "/modules/jobs". */ +export function modulePath(moduleId: ModuleId): string { + return `${routerPaths.MODULES}/${moduleId}`; +} diff --git a/frontend/src/components/app-sidebar.stories.tsx b/frontend/src/components/app-sidebar.stories.tsx deleted file mode 100644 index 1c52f7e..0000000 --- a/frontend/src/components/app-sidebar.stories.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect } from "storybook/test"; -import { AppSidebar } from "./app-sidebar"; -import { SidebarProvider } from "@/components/ui/sidebar"; - -const meta = { - component: AppSidebar, - tags: ["ai-generated"], - decorators: [ - (Story) => ( - - - - ), - ], -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Expanded: Story = { - play: async ({ canvas }) => { - await expect(canvas.getByText("Compass Analytics")).toBeVisible(); - await expect(canvas.getByAltText("Compass Analytics")).toBeVisible(); - }, -}; - -export const Collapsed: Story = { - decorators: [ - (Story) => ( - - - - ), - ], -}; diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx deleted file mode 100644 index 0776b9c..0000000 --- a/frontend/src/components/app-sidebar.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader } from "@/components/ui/sidebar"; -import { getAppName, getLogoInverseUrl } from "@/branding/brandingConfig"; -import { LanguageSwitcher } from "@/i18n/languageSwitcher/LanguageSwitcher"; - -export function AppSidebar() { - return ( - - - {getAppName()} - - {getAppName()} - - - - - - - - ); -} diff --git a/frontend/src/components/filters/global-filters.stories.tsx b/frontend/src/components/filters/global-filters.stories.tsx new file mode 100644 index 0000000..366ea16 --- /dev/null +++ b/frontend/src/components/filters/global-filters.stories.tsx @@ -0,0 +1,69 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect } from "storybook/test"; +import { GlobalFilters } from "./global-filters"; +import { AccessProvider } from "@/access/AccessContext"; +import { FiltersProvider } from "@/filters/FiltersContext"; +import { createInitialFilters, type FiltersState } from "@/filters/filters"; +import type { AccessScope } from "@/access/AccessContext"; + +const GIVEN_TODAY = new Date(2026, 5, 15); +const ALL_INSTITUTIONS: AccessScope = { type: "all" }; + +function withState(filters: Partial, scope: AccessScope = ALL_INSTITUTIONS) { + return (Story: () => React.ReactElement) => ( + + + + + + ); +} + +const meta = { + title: "Filters/GlobalFilters", + component: GlobalFilters, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const NoFilters: Story = { + decorators: [withState({})], + play: async ({ canvas }) => { + await expect(canvas.getByText("No filters applied")).toBeVisible(); + }, +}; + +export const AllFilters: Story = { + decorators: [withState({ institutionDrillDownId: "inst-1", audienceSegment: "youth", loginMethod: "email" })], + play: async ({ canvas }) => { + await expect(canvas.getByText("Institution: inst-1")).toBeVisible(); + await expect(canvas.getByText("Audience segment: Youth")).toBeVisible(); + await expect(canvas.getByText("Login method: Email")).toBeVisible(); + await expect(canvas.getByRole("button", { name: "Clear all" })).toBeVisible(); + }, +}; + +export const SingleFilter: Story = { + decorators: [withState({ audienceSegment: "women" })], + play: async ({ canvas }) => { + await expect(canvas.getByText("Audience segment: Women")).toBeVisible(); + }, +}; + +export const SingleInstitutionScope: Story = { + decorators: [ + withState( + { institutionDrillDownId: "inst-1", audienceSegment: "women" }, + { + type: "institutions", + institutionIds: ["inst-1"], + } + ), + ], + play: async ({ canvas }) => { + await expect(canvas.queryByText(/Institution:/)).not.toBeInTheDocument(); + await expect(canvas.getByText("Audience segment: Women")).toBeVisible(); + }, +}; diff --git a/frontend/src/components/filters/global-filters.test.tsx b/frontend/src/components/filters/global-filters.test.tsx new file mode 100644 index 0000000..6063854 --- /dev/null +++ b/frontend/src/components/filters/global-filters.test.tsx @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { render, screen } from "@/_test_utilities/test-utils"; +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"; + +const GIVEN_TODAY = new Date(2026, 5, 15); +const ALL_INSTITUTIONS: Partial = { scope: { type: "all" } }; + +function renderGlobalFilters(filters: Partial = {}, access: Partial = {}) { + const initialFilters: FiltersState = { ...createInitialFilters(GIVEN_TODAY), ...filters }; + render( + + + + + + ); +} + +describe("GlobalFilters", () => { + it("should render an empty state and no Clear all button when no chip filters are set", () => { + // GIVEN no chip filters set + // WHEN rendered + renderGlobalFilters(); + + // THEN the empty state shows and there's nothing to clear + expect(screen.getByText("No filters applied")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Clear all" })).not.toBeInTheDocument(); + }); + + it("should render a chip per active filter, with translated values", () => { + // GIVEN all three chip filters set, for a cross-institution grant + renderGlobalFilters( + { audienceSegment: "youth", loginMethod: "email", institutionDrillDownId: "inst-1" }, + ALL_INSTITUTIONS + ); + + // THEN each renders as a chip — the institution id as-is, the others translated + expect(screen.getByText("Institution: inst-1")).toBeInTheDocument(); + expect(screen.getByText("Audience segment: Youth")).toBeInTheDocument(); + expect(screen.getByText("Login method: Email")).toBeInTheDocument(); + }); + + it("should show the institution chip for a grant covering several institutions but not all", () => { + // GIVEN a grant explicitly covering a portfolio of three institutions (scope is not "all") + renderGlobalFilters( + { institutionDrillDownId: "inst-1" }, + { scope: { type: "institutions", institutionIds: ["inst-1", "inst-2", "inst-3"] } } + ); + + // THEN the drill-down chip still shows — it's meaningful whenever more than one is in scope + expect(screen.getByText("Institution: inst-1")).toBeInTheDocument(); + }); + + it("should suppress the institution chip for a single-institution grant", () => { + // GIVEN an institution drill-down set, but the grant covers only one institution + renderGlobalFilters( + { institutionDrillDownId: "inst-1", audienceSegment: "women" }, + { scope: { type: "institutions", institutionIds: ["inst-1"] } } + ); + + // THEN the institution chip is hidden while the others still show + expect(screen.queryByText(/Institution:/)).not.toBeInTheDocument(); + expect(screen.getByText("Audience segment: Women")).toBeInTheDocument(); + }); + + it("should remove only the clicked filter, preserving the others", async () => { + // GIVEN two chip filters set + renderGlobalFilters({ audienceSegment: "youth", loginMethod: "email" }); + + // WHEN removing the audience segment chip + await userEvent.click(screen.getByRole("button", { name: "Remove Audience segment filter" })); + + // THEN only that one is gone + expect(screen.queryByText(/Audience segment:/)).not.toBeInTheDocument(); + expect(screen.getByText("Login method: Email")).toBeInTheDocument(); + }); + + it("should clear every chip filter when Clear all is clicked", async () => { + // GIVEN two chip filters set + renderGlobalFilters({ audienceSegment: "youth", loginMethod: "email" }); + + // WHEN clicking Clear all + await userEvent.click(screen.getByRole("button", { name: "Clear all" })); + + // THEN the empty state shows + expect(screen.getByText("No filters applied")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/filters/global-filters.tsx b/frontend/src/components/filters/global-filters.tsx new file mode 100644 index 0000000..46dcbdc --- /dev/null +++ b/frontend/src/components/filters/global-filters.tsx @@ -0,0 +1,83 @@ +import { useTranslation } from "react-i18next"; +import { X } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { useFilters } from "@/filters/FiltersContext"; +import { useAccess } from "@/access/AccessContext"; +import { AUDIENCE_SEGMENT_LABEL_KEYS, LOGIN_METHOD_LABEL_KEYS, type ChipFilterKey } from "@/filters/filters"; +import type { TranslationKey } from "@/i18n/react-i18next"; + +const FILTER_LABEL_KEYS: Record = { + institutionDrillDownId: "filters.labels.institution", + audienceSegment: "filters.labels.audienceSegment", + loginMethod: "filters.labels.loginMethod", +}; + +/** Values with a translated label. Institution ids aren't in here — they display as-is. */ +const VALUE_LABEL_KEYS: Record = { + ...AUDIENCE_SEGMENT_LABEL_KEYS, + ...LOGIN_METHOD_LABEL_KEYS, +}; + +function FilterChip({ + label, + value, + onRemove, + removeLabel, +}: { + label: string; + value: string; + onRemove: () => void; + removeLabel: string; +}) { + return ( + + + {label}: {value} + + + + ); +} + +/** The active non-time filters as removable chips, plus "Clear all". Reusable across screens. */ +export function GlobalFilters() { + const { t } = useTranslation(); + const { activeFilters, clearFilter, clearAll } = useFilters(); + const { isMultiInstitution } = useAccess(); + + // Institution drill-down only makes sense for a grant covering more than one institution. + const chips = activeFilters.filter((f) => f.key !== "institutionDrillDownId" || isMultiInstitution); + + if (chips.length === 0) { + return {t("filters.none")}; + } + + return ( +
+ {chips.map(({ key, value }) => { + const label = t(FILTER_LABEL_KEYS[key]); + const valueLabelKey = VALUE_LABEL_KEYS[value]; + return ( + clearFilter(key)} + removeLabel={t("filters.remove", { filter: label })} + /> + ); + })} + +
+ ); +} diff --git a/frontend/src/components/filters/time-filter-bar.stories.tsx b/frontend/src/components/filters/time-filter-bar.stories.tsx new file mode 100644 index 0000000..d655c3a --- /dev/null +++ b/frontend/src/components/filters/time-filter-bar.stories.tsx @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect } from "storybook/test"; +import { TimeFilterBar } from "./time-filter-bar"; +import { FiltersProvider } from "@/filters/FiltersContext"; +import { createInitialFilters, deriveGranularity } from "@/filters/filters"; + +const GIVEN_TODAY = new Date(2026, 5, 15); + +function withRange(start: string, end: string) { + const dateRange = { start, end }; + return (Story: () => React.ReactElement) => ( + + + + ); +} + +const meta = { + title: "Filters/TimeFilterBar", + component: TimeFilterBar, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const DayGranularity: Story = { + decorators: [withRange("2026-06-01", "2026-06-20")], + play: async ({ canvas }) => { + await expect(canvas.getByText("Grouped by day")).toBeVisible(); + }, +}; + +export const WeekGranularity: Story = { + decorators: [withRange("2026-01-01", "2026-03-01")], + play: async ({ canvas }) => { + await expect(canvas.getByText("Grouped by week")).toBeVisible(); + }, +}; + +export const MonthGranularity: Story = { + decorators: [withRange("2025-01-01", "2026-06-01")], + play: async ({ canvas }) => { + await expect(canvas.getByText("Grouped by month")).toBeVisible(); + }, +}; + +export const BareDatesForCard: Story = { + args: { showLabels: false, showGranularity: false }, + decorators: [withRange("2025-07-08", "2026-07-07")], + play: async ({ canvas }) => { + await expect(canvas.getByLabelText("Start date")).toBeVisible(); + await expect(canvas.queryByText(/Grouped by/)).not.toBeInTheDocument(); + }, +}; diff --git a/frontend/src/components/filters/time-filter-bar.test.tsx b/frontend/src/components/filters/time-filter-bar.test.tsx new file mode 100644 index 0000000..20cd805 --- /dev/null +++ b/frontend/src/components/filters/time-filter-bar.test.tsx @@ -0,0 +1,79 @@ +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"; + +const GIVEN_TODAY = new Date(2026, 5, 15); + +function renderBar(props: Partial = {}) { + render( + + + + ); +} + +describe("TimeFilterBar", () => { + it("should render labelled start and end date inputs showing the current range", () => { + // GIVEN the time filter bar + // WHEN rendered + renderBar(); + + // THEN both inputs are reachable by their label and hold the range + expect(screen.getByLabelText("Start date")).toHaveValue("2026-05-16"); + expect(screen.getByLabelText("End date")).toHaveValue("2026-06-15"); + }); + + it("should show the granularity derived from the current span", () => { + // GIVEN the default 30-day range + // WHEN rendered + renderBar(); + + // THEN the badge reports "day" + expect(screen.getByText("Grouped by day")).toBeInTheDocument(); + }); + + it("should re-derive the granularity when the range crosses a boundary", () => { + // GIVEN the default 30-day ("day") range + renderBar(); + + // WHEN extending the end date so the span is 77 days + // (fireEvent, not userEvent.type — typing into is locale/segment dependent) + fireEvent.change(screen.getByLabelText("End date"), { target: { value: "2026-08-01" } }); + + // THEN the badge follows the new span + expect(screen.getByText("Grouped by week")).toBeInTheDocument(); + }); + + it("should hide the labels visually but keep them as the inputs' accessible names", () => { + // GIVEN the bar rendered inside a card, where the dates speak for themselves + renderBar({ showLabels: false }); + + // THEN the inputs are still reachable by name, so the control stays usable by screen readers + expect(screen.getByLabelText("Start date")).toBeInTheDocument(); + expect(screen.getByLabelText("End date")).toBeInTheDocument(); + + // AND the label text is present but visually hidden rather than removed + expect(screen.getByText("Start date")).toHaveClass("sr-only"); + }); + + it("should hide the granularity badge when asked", () => { + // GIVEN the bar with the derived-granularity readout turned off + renderBar({ showGranularity: false }); + + // THEN no granularity text renders, while the date inputs remain + expect(screen.queryByText(/Grouped by/)).not.toBeInTheDocument(); + expect(screen.getByLabelText("Start date")).toBeInTheDocument(); + }); + + it("should cross-constrain the two inputs so the range cannot be inverted", () => { + // GIVEN the default range + // WHEN rendered + renderBar(); + + // THEN each input is bounded by the other's current value + expect(screen.getByLabelText("Start date")).toHaveAttribute("max", "2026-06-15"); + expect(screen.getByLabelText("End date")).toHaveAttribute("min", "2026-05-16"); + }); +}); diff --git a/frontend/src/components/filters/time-filter-bar.tsx b/frontend/src/components/filters/time-filter-bar.tsx new file mode 100644 index 0000000..7337559 --- /dev/null +++ b/frontend/src/components/filters/time-filter-bar.tsx @@ -0,0 +1,59 @@ +import { useTranslation } from "react-i18next"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { useFilters } from "@/filters/FiltersContext"; + +const START_INPUT_ID = "time-filter-start"; +const END_INPUT_ID = "time-filter-end"; + +export interface TimeFilterBarProps { + showLabels?: boolean; + showGranularity?: boolean; +} + +export function TimeFilterBar({ showLabels = true, showGranularity = true }: Readonly) { + const { t } = useTranslation(); + const { filters, setDateRange } = useFilters(); + const { start, end } = filters.dateRange; + const labelClass = showLabels ? undefined : "sr-only"; + + return ( +
+
+ + setDateRange({ start: event.target.value, end })} + /> +
+ {!showLabels && ( + + )} +
+ + setDateRange({ start, end: event.target.value })} + /> +
+ {showGranularity && ( + + {t("filters.time.granularityLabel", { granularity: t(`filters.granularity.${filters.granularity}`) })} + + )} +
+ ); +} diff --git a/frontend/src/components/sidebar/app-sidebar.stories.tsx b/frontend/src/components/sidebar/app-sidebar.stories.tsx new file mode 100644 index 0000000..ff64266 --- /dev/null +++ b/frontend/src/components/sidebar/app-sidebar.stories.tsx @@ -0,0 +1,98 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect } from "storybook/test"; +import { AppSidebar } from "./app-sidebar"; +import { SidebarProvider } from "@/components/ui/sidebar"; +import { AccessProvider } from "@/access/AccessContext"; +import { PERMISSIONS, MODULE_IDS } from "@/access/AccessContext"; + +const meta = { + title: "Sidebar/AppSidebar", + component: AppSidebar, + tags: ["autodocs"], + decorators: [ + (Story) => ( + + + + + + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Expanded: Story = { + play: async ({ canvas }) => { + await expect(canvas.getByText("Compass Analytics")).toBeVisible(); + await expect(canvas.getByAltText("Compass Analytics")).toBeVisible(); + await expect(canvas.getByRole("link", { name: /^Overview$/ })).toBeVisible(); + }, +}; + +export const Collapsed: Story = { + decorators: [ + (Story) => ( + + + + + + ), + ], +}; + +export const FullAccess: Story = { + decorators: [ + (Story) => ( + + + + + + ), + ], + play: async ({ canvas }) => { + await expect(canvas.getByRole("link", { name: /Jobseekers/ })).toBeVisible(); + await expect(canvas.getByRole("link", { name: /^Modules$/ })).toBeVisible(); + await expect(canvas.getByRole("link", { name: "Build Your Profile" })).toBeVisible(); + }, +}; + +export const MinimalAccess: Story = { + decorators: [ + (Story) => ( + + + + + + ), + ], + play: async ({ canvas }) => { + await expect(canvas.getByRole("link", { name: /^Overview$/ })).toBeVisible(); + await expect(canvas.queryByRole("link", { name: /Jobseekers/ })).not.toBeInTheDocument(); + await expect(canvas.queryByRole("link", { name: /^Modules$/ })).not.toBeInTheDocument(); + }, +}; + +export const SingleActiveModule: Story = { + decorators: [ + (Story) => ( + + + + + + ), + ], + play: async ({ canvas }) => { + await expect(canvas.queryByRole("link", { name: /^Modules$/ })).not.toBeInTheDocument(); + }, +}; diff --git a/frontend/src/components/app-sidebar.test.tsx b/frontend/src/components/sidebar/app-sidebar.test.tsx similarity index 62% rename from frontend/src/components/app-sidebar.test.tsx rename to frontend/src/components/sidebar/app-sidebar.test.tsx index 175293d..6609948 100644 --- a/frontend/src/components/app-sidebar.test.tsx +++ b/frontend/src/components/sidebar/app-sidebar.test.tsx @@ -1,14 +1,17 @@ -import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; +import { render, screen } from "@/_test_utilities/test-utils"; import { AppSidebar } from "./app-sidebar"; import { SidebarProvider } from "@/components/ui/sidebar"; +import { AccessProvider } from "@/access/AccessContext"; describe("AppSidebar", () => { it("renders the Compass Analytics brand mark", () => { render( - - - + + + + + ); expect(screen.getByText("Compass Analytics")).toBeInTheDocument(); diff --git a/frontend/src/components/sidebar/app-sidebar.tsx b/frontend/src/components/sidebar/app-sidebar.tsx new file mode 100644 index 0000000..d43277f --- /dev/null +++ b/frontend/src/components/sidebar/app-sidebar.tsx @@ -0,0 +1,33 @@ +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 { AuthenticationServiceFactory } from "@/auth/services/Authentication.service.factory"; +import { routerPaths } from "@/app/routerPaths"; + +export function AppSidebar() { + const navigate = useNavigate(); + + const handleSignOut = () => { + AuthenticationServiceFactory.getCurrentAuthenticationService().logout(); + navigate(routerPaths.LOGIN); + }; + + return ( + + + {getAppName()} + + {getAppName()} + + + + + + + + + + ); +} diff --git a/frontend/src/components/sidebar/components/sidebar-nav.stories.tsx b/frontend/src/components/sidebar/components/sidebar-nav.stories.tsx new file mode 100644 index 0000000..b2210df --- /dev/null +++ b/frontend/src/components/sidebar/components/sidebar-nav.stories.tsx @@ -0,0 +1,88 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect } from "storybook/test"; +import { SidebarNav } from "./sidebar-nav"; +import { SidebarProvider } from "@/components/ui/sidebar"; +import { AccessProvider } from "@/access/AccessContext"; +import { MODULE_IDS, PERMISSIONS } from "@/access/AccessContext"; + +const meta = { + title: "Sidebar/SidebarNav", + component: SidebarNav, + tags: ["autodocs"], + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const FullAccess: Story = { + decorators: [ + (Story) => ( + + + + ), + ], + play: async ({ canvas }) => { + await expect(canvas.getByRole("link", { name: /^Overview$/ })).toBeVisible(); + await expect(canvas.getByRole("link", { name: /Jobseekers/ })).toBeVisible(); + await expect(canvas.getByRole("link", { name: /^Modules$/ })).toBeVisible(); + await expect(canvas.getByRole("link", { name: "Build Your Profile" })).toBeVisible(); + await expect(canvas.getByRole("link", { name: "Job readiness" })).toBeVisible(); + await expect(canvas.getByRole("link", { name: "Career Explorer" })).toBeVisible(); + await expect(canvas.getByRole("link", { name: "Jobs" })).toBeVisible(); + }, +}; + +export const JobseekersHidden: Story = { + decorators: [ + (Story) => ( + + + + ), + ], + play: async ({ canvas }) => { + await expect(canvas.getByRole("link", { name: /^Overview$/ })).toBeVisible(); + await expect(canvas.queryByRole("link", { name: /Jobseekers/ })).not.toBeInTheDocument(); + }, +}; + +export const MinimalAccess: Story = { + decorators: [ + (Story) => ( + + + + ), + ], + play: async ({ canvas }) => { + await expect(canvas.getByRole("link", { name: /^Overview$/ })).toBeVisible(); + await expect(canvas.queryByRole("link", { name: /Jobseekers/ })).not.toBeInTheDocument(); + await expect(canvas.queryByRole("link", { name: /^Modules$/ })).not.toBeInTheDocument(); + }, +}; + +export const SingleActiveModule: Story = { + decorators: [ + (Story) => ( + + + + ), + ], + play: async ({ canvas }) => { + await expect(canvas.queryByRole("link", { name: /^Modules$/ })).not.toBeInTheDocument(); + }, +}; diff --git a/frontend/src/components/sidebar/components/sidebar-nav.test.tsx b/frontend/src/components/sidebar/components/sidebar-nav.test.tsx new file mode 100644 index 0000000..17fac4a --- /dev/null +++ b/frontend/src/components/sidebar/components/sidebar-nav.test.tsx @@ -0,0 +1,189 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { Briefcase, GraduationCap } from "lucide-react"; +import { render, screen } from "@/_test_utilities/test-utils"; +import { SidebarProvider } from "@/components/ui/sidebar"; +import { + AccessProvider, + MODULE_IDS, + PERMISSIONS, + type AccessState, + type ModuleId, + type PermissionKey, +} from "@/access/AccessContext"; +import { getModuleSubItems, getVisibleNavItems, NAV_ITEMS, SidebarNav, type NavVisibilityContext } from "./sidebar-nav"; + +function renderNav(access: Partial = {}) { + return render( + + + + + + ); +} + +describe("SidebarNav", () => { + afterEach(() => { + window.location.hash = ""; + }); + + it("should render a hash-prefixed link per visible top-level nav item", () => { + // GIVEN a full-access, all-modules-active state + renderNav(); + + // THEN each top-level item links to its hash-prefixed path + expect(screen.getByRole("link", { name: /^Overview$/ })).toHaveAttribute("href", "#/"); + expect(screen.getByRole("link", { name: /Jobseekers/ })).toHaveAttribute("href", "#/jobseekers"); + expect(screen.getByRole("link", { name: /^Modules$/ })).toHaveAttribute("href", "#/modules"); + }); + + it("should hide items the grant does not cover", () => { + // GIVEN a minimal grant with no active modules + renderNav({ + permissions: new Set([PERMISSIONS.DASHBOARD_VIEW]), + activeModules: [], + }); + + // THEN the gated items are absent + expect(screen.queryByRole("link", { name: /Jobseekers/ })).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: /^Modules$/ })).not.toBeInTheDocument(); + }); + + it("should mark the current route as the active page", () => { + // GIVEN the current location is /jobseekers + window.location.hash = "#/jobseekers"; + + // WHEN the nav renders + renderNav(); + + // THEN that link is marked as the current page + expect(screen.getByRole("link", { name: /Jobseekers/ })).toHaveAttribute("aria-current", "page"); + expect(screen.getByRole("link", { name: /^Overview$/ })).not.toHaveAttribute("aria-current"); + }); + + it("should highlight only the active submodule, not the Modules parent row, when on a submodule page", () => { + // GIVEN the current location is a specific submodule page + window.location.hash = "#/modules/jobs"; + + // WHEN the nav renders + renderNav(); + + // THEN only the Jobs submodule is marked active — Modules itself is not + expect(screen.getByRole("link", { name: "Jobs" })).toHaveAttribute("data-active", "true"); + expect(screen.getByRole("link", { name: /^Modules$/ })).toHaveAttribute("data-active", "false"); + }); + + it("should always list the active modules as Modules sub-items, with icons, no toggle required", () => { + // GIVEN two active modules + renderNav({ activeModules: [MODULE_IDS.JOB_READINESS, MODULE_IDS.JOBS] }); + + // THEN only the active modules appear as sub-items, visible without any interaction + expect(screen.getByRole("link", { name: "Job readiness" })).toHaveAttribute("href", "#/modules/job-readiness"); + expect(screen.getByRole("link", { name: "Jobs" })).toHaveAttribute("href", "#/modules/jobs"); + expect(screen.queryByRole("link", { name: "Career Explorer" })).not.toBeInTheDocument(); + // No collapse/expand affordance — Modules is a plain link like Overview/Jobseekers. + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); +}); + +function buildContext(overrides: { permissions?: PermissionKey[]; activeModules?: ModuleId[] }): NavVisibilityContext { + const grantedPermissions = new Set(overrides.permissions ?? []); + return { + hasPermission: (permission) => grantedPermissions.has(permission), + activeModules: overrides.activeModules ?? [], + }; +} + +describe("getVisibleNavItems", () => { + it("should show overview, jobseekers, and modules for a full-access grant with more than one active module", () => { + // GIVEN a grant with every permission and two active modules + const givenContext = buildContext({ + permissions: Object.values(PERMISSIONS), + activeModules: [MODULE_IDS.BUILD_YOUR_PROFILE, MODULE_IDS.JOB_READINESS], + }); + + // WHEN computing the visible nav items + const actual = getVisibleNavItems(NAV_ITEMS, givenContext).map((item) => item.id); + + // THEN all three items are visible + expect(actual).toEqual(["overview", "jobseekers", "modules"]); + }); + + it("should hide jobseekers when jobseekers:view is not granted", () => { + // GIVEN a grant missing jobseekers:view + const givenContext = buildContext({ + permissions: [PERMISSIONS.DASHBOARD_VIEW], + activeModules: [MODULE_IDS.BUILD_YOUR_PROFILE, MODULE_IDS.JOB_READINESS], + }); + + // WHEN computing the visible nav items + const actual = getVisibleNavItems(NAV_ITEMS, givenContext).map((item) => item.id); + + // THEN jobseekers is absent, overview and modules remain + expect(actual).toEqual(["overview", "modules"]); + }); + + it("should show only overview for a minimal grant with no active modules", () => { + // GIVEN a minimal grant with no active modules + const givenContext = buildContext({ permissions: [PERMISSIONS.DASHBOARD_VIEW] }); + + // WHEN computing the visible nav items + const actual = getVisibleNavItems(NAV_ITEMS, givenContext).map((item) => item.id); + + // THEN only overview is visible + expect(actual).toEqual(["overview"]); + }); + + it.each([ + [0, false], + [1, false], + [2, true], + [4, true], + ] as const)( + "should show Modules only when more than one module is active (%i active -> visible=%s)", + (activeCount, expectedVisible) => { + // GIVEN a full-permission grant with the given number of active modules + const allModuleIds = Object.values(MODULE_IDS); + const givenContext = buildContext({ + permissions: Object.values(PERMISSIONS), + activeModules: allModuleIds.slice(0, activeCount), + }); + + // WHEN checking whether Modules is visible + const actual = getVisibleNavItems(NAV_ITEMS, givenContext).some((item) => item.id === "modules"); + + // THEN visibility matches the expectation + expect(actual).toBe(expectedVisible); + } + ); +}); + +describe("getModuleSubItems", () => { + it("should map each active module to a sub-item with its label, path, and icon", () => { + // GIVEN two active modules + const givenActiveModules: ModuleId[] = [MODULE_IDS.JOB_READINESS, MODULE_IDS.JOBS]; + + // WHEN computing the module sub-items + const actual = getModuleSubItems(givenActiveModules); + + // THEN each maps to its label key, module path, and icon + expect(actual).toEqual([ + { + id: "job-readiness", + labelKey: "nav.modulesSection.jobReadiness", + path: "/modules/job-readiness", + icon: GraduationCap, + }, + { id: "jobs", labelKey: "nav.modulesSection.jobs", path: "/modules/jobs", icon: Briefcase }, + ]); + }); + + it("should return an empty list when no modules are active", () => { + // GIVEN no active modules + // WHEN computing the module sub-items + const actual = getModuleSubItems([]); + + // THEN there are none + expect(actual).toEqual([]); + }); +}); diff --git a/frontend/src/components/sidebar/components/sidebar-nav.tsx b/frontend/src/components/sidebar/components/sidebar-nav.tsx new file mode 100644 index 0000000..c8a782d --- /dev/null +++ b/frontend/src/components/sidebar/components/sidebar-nav.tsx @@ -0,0 +1,152 @@ +import type { ComponentType } from "react"; +import { NavLink, useLocation } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { Briefcase, Compass, GraduationCap, LayoutGrid, MessageCircle, Users } from "lucide-react"; +import { + SidebarGroup, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, +} from "@/components/ui/sidebar"; +import { MODULE_IDS, PERMISSIONS, useAccess, type ModuleId, type PermissionKey } from "@/access/AccessContext"; +import { modulePath, routerPaths } from "@/app/routerPaths"; +import type { TranslationKey } from "@/i18n/react-i18next"; + +export interface NavItem { + id: string; + labelKey: TranslationKey; + path: string; + icon: ComponentType<{ className?: string }>; + permission?: PermissionKey; // absent ⇒ always visible + requiresMultipleActiveModules?: boolean; +} + +export const NAV_ITEMS: readonly NavItem[] = [ + { + id: "overview", + labelKey: "nav.overview", + path: routerPaths.ROOT, + icon: LayoutGrid, + permission: PERMISSIONS.DASHBOARD_VIEW, + }, + { + id: "jobseekers", + labelKey: "nav.jobseekers", + path: routerPaths.JOBSEEKERS, + icon: Users, + permission: PERMISSIONS.JOBSEEKERS_VIEW, + }, + { + id: "modules", + labelKey: "nav.modules", + path: routerPaths.MODULES, + icon: LayoutGrid, + requiresMultipleActiveModules: true, + }, +]; + +const MODULE_NAV_LABELS: Record = { + [MODULE_IDS.BUILD_YOUR_PROFILE]: "nav.modulesSection.buildYourProfile", + [MODULE_IDS.JOB_READINESS]: "nav.modulesSection.jobReadiness", + [MODULE_IDS.CAREER_EXPLORER]: "nav.modulesSection.careerExplorer", + [MODULE_IDS.JOBS]: "nav.modulesSection.jobs", +}; + +const MODULE_NAV_ICONS: Record> = { + [MODULE_IDS.BUILD_YOUR_PROFILE]: MessageCircle, + [MODULE_IDS.JOB_READINESS]: GraduationCap, + [MODULE_IDS.CAREER_EXPLORER]: Compass, + [MODULE_IDS.JOBS]: Briefcase, +}; + +export interface NavVisibilityContext { + hasPermission: (permission: PermissionKey) => boolean; + activeModules: readonly ModuleId[]; +} + +export function getVisibleNavItems(items: readonly NavItem[], ctx: NavVisibilityContext): NavItem[] { + return items.filter((item) => { + if (item.requiresMultipleActiveModules) return ctx.activeModules.length > 1; + if (item.permission) return ctx.hasPermission(item.permission); + return true; + }); +} + +export interface ModuleSubItem { + id: ModuleId; + labelKey: TranslationKey; + path: string; + icon: ComponentType<{ className?: string }>; +} + +export function getModuleSubItems(activeModules: readonly ModuleId[]): ModuleSubItem[] { + return activeModules.map((id) => ({ + id, + labelKey: MODULE_NAV_LABELS[id], + path: modulePath(id), + icon: MODULE_NAV_ICONS[id], + })); +} + +export function SidebarNav() { + const { t } = useTranslation(); + const location = useLocation(); + const access = useAccess(); + + const visibleItems = getVisibleNavItems(NAV_ITEMS, access); + const moduleSubItems = getModuleSubItems(access.activeModules); + + return ( + + + {visibleItems.map((item) => { + const Icon = item.icon; + const isModules = item.id === "modules"; + // Exact match, so an active submodule doesn't also light up "Modules". + const isActive = location.pathname === item.path; + + return ( + + + + + {t(item.labelKey)} + + + + {isModules && moduleSubItems.length > 0 && ( + + {moduleSubItems.map((subItem) => { + const SubIcon = subItem.icon; + return ( + + + + + {t(subItem.labelKey)} + + + + ); + })} + + )} + + ); + })} + + + ); +} diff --git a/frontend/src/components/sidebar/components/sidebar-user-menu.stories.tsx b/frontend/src/components/sidebar/components/sidebar-user-menu.stories.tsx new file mode 100644 index 0000000..48614c5 --- /dev/null +++ b/frontend/src/components/sidebar/components/sidebar-user-menu.stories.tsx @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn } from "storybook/test"; +import { SidebarUserMenu } from "./sidebar-user-menu"; +import { SidebarProvider } from "@/components/ui/sidebar"; + +const meta = { + title: "Sidebar/SidebarUserMenu", + component: SidebarUserMenu, + tags: ["autodocs"], + args: { onSignOut: fn() }, + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvas }) => { + await expect(canvas.getByRole("button", { name: "Open account menu" })).toBeVisible(); + }, +}; diff --git a/frontend/src/components/sidebar/components/sidebar-user-menu.test.tsx b/frontend/src/components/sidebar/components/sidebar-user-menu.test.tsx new file mode 100644 index 0000000..128b3b3 --- /dev/null +++ b/frontend/src/components/sidebar/components/sidebar-user-menu.test.tsx @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { render, screen } from "@/_test_utilities/test-utils"; +import { SidebarProvider } from "@/components/ui/sidebar"; +import { SidebarUserMenu } from "./sidebar-user-menu"; + +function renderMenu() { + const onSignOut = vi.fn(); + render( + + + + ); + return { onSignOut }; +} + +describe("SidebarUserMenu", () => { + it("should render a labelled trigger for the account menu", () => { + // GIVEN the footer menu + // WHEN rendered + renderMenu(); + + // THEN the trigger is reachable by its accessible name and shows a visible label + expect(screen.getByRole("button", { name: /Open account menu/ })).toBeInTheDocument(); + expect(screen.getByText("My account")).toBeInTheDocument(); + }); + + it("should link Account settings to /settings and call onSignOut when Sign out is clicked", async () => { + // GIVEN the menu is open + const { onSignOut } = renderMenu(); + await userEvent.click(screen.getByRole("button", { name: /Open account menu/ })); + + // THEN Account settings links to /settings (role is "menuitem" — DropdownMenuItem sets it explicitly) + expect(screen.getByRole("menuitem", { name: "Account settings" })).toHaveAttribute("href", "#/settings"); + + // WHEN clicking Sign out + await userEvent.click(screen.getByRole("menuitem", { name: "Sign out" })); + + // THEN the sign-out callback fires + expect(onSignOut).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/components/sidebar/components/sidebar-user-menu.tsx b/frontend/src/components/sidebar/components/sidebar-user-menu.tsx new file mode 100644 index 0000000..7e7bd53 --- /dev/null +++ b/frontend/src/components/sidebar/components/sidebar-user-menu.tsx @@ -0,0 +1,50 @@ +import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { ChevronsUpDown, CircleUser } from "lucide-react"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar"; +import { routerPaths } from "@/app/routerPaths"; + +export function SidebarUserMenu({ onSignOut }: Readonly<{ onSignOut: () => void }>) { + const { t } = useTranslation(); + + return ( + + + + + + + + + + + {t("nav.userMenu.label")} + + + + + + {t("nav.userMenu.accountSettings")} + + + + {t("auth.signOut")} + + + + + + ); +} diff --git a/frontend/src/components/ui/dropdown-menu.tsx b/frontend/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..7887a4d --- /dev/null +++ b/frontend/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,217 @@ +import * as React from "react"; +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; + +import { cn } from "@/lib/utils"; + +function DropdownMenu({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuPortal({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function DropdownMenuGroup({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean; + variant?: "default" | "destructive"; +}) { + return ( + + ); +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuRadioGroup({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function DropdownMenuSeparator({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) { + return ( + + ); +} + +function DropdownMenuSub({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +}; diff --git a/frontend/src/filters/FiltersContext.test.tsx b/frontend/src/filters/FiltersContext.test.tsx new file mode 100644 index 0000000..52ea151 --- /dev/null +++ b/frontend/src/filters/FiltersContext.test.tsx @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { render as renderWithoutProviders } from "@testing-library/react"; +import { render, screen } from "@/_test_utilities/test-utils"; +import { createInitialFilters } from "@/filters/filters"; +import { FiltersProvider, useFilters } from "./FiltersContext"; + +const GIVEN_INITIAL_FILTERS = createInitialFilters(new Date(2026, 5, 15)); + +function FiltersProbe() { + const { filters, patchFilters, setDateRange, clearFilter, clearAll, activeFilters } = useFilters(); + return ( +
+ {filters.audienceSegment ?? ""} + {filters.loginMethod ?? ""} + {`${filters.dateRange.start}..${filters.dateRange.end}`} + {filters.granularity} + {activeFilters.length} + + + + +
+ ); +} + +function renderProbe() { + render( + + + + ); +} + +describe("FiltersProvider", () => { + it("should honor the provided initialFilters", () => { + // GIVEN an explicit initial filters state + // WHEN rendered + renderProbe(); + + // THEN the probe reflects that state, with no active chip filters + expect(screen.getByTestId("date-range")).toHaveTextContent("2026-05-16..2026-06-15"); + expect(screen.getByTestId("granularity")).toHaveTextContent("day"); + expect(screen.getByTestId("active-count")).toHaveTextContent("0"); + }); + + it("should set several filters at once via patchFilters", async () => { + // GIVEN a mounted provider + renderProbe(); + + // WHEN patching two chip filters together + await userEvent.click(screen.getByRole("button", { name: "set two" })); + + // THEN both are set and both count as active + expect(screen.getByTestId("audience-segment")).toHaveTextContent("youth"); + expect(screen.getByTestId("login-method")).toHaveTextContent("email"); + expect(screen.getByTestId("active-count")).toHaveTextContent("2"); + }); + + it("should re-derive granularity when the date range changes", async () => { + // GIVEN the initial 30-day range at "day" granularity + renderProbe(); + expect(screen.getByTestId("granularity")).toHaveTextContent("day"); + + // WHEN setting a range spanning 300 days + await userEvent.click(screen.getByRole("button", { name: "set long range" })); + + // THEN granularity follows the span + expect(screen.getByTestId("date-range")).toHaveTextContent("2026-01-01..2026-10-28"); + expect(screen.getByTestId("granularity")).toHaveTextContent("month"); + }); + + it("should clear exactly one filter via clearFilter", async () => { + // GIVEN two chip filters set + renderProbe(); + await userEvent.click(screen.getByRole("button", { name: "set two" })); + + // WHEN clearing only the audience segment + await userEvent.click(screen.getByRole("button", { name: "clear segment" })); + + // THEN the other one survives + expect(screen.getByTestId("audience-segment")).toHaveTextContent(""); + expect(screen.getByTestId("login-method")).toHaveTextContent("email"); + }); + + it("should clear every chip filter via clearAll while preserving the date range and granularity", async () => { + // GIVEN two chip filters set and a long (month-granularity) range + renderProbe(); + await userEvent.click(screen.getByRole("button", { name: "set two" })); + await userEvent.click(screen.getByRole("button", { name: "set long range" })); + + // WHEN clearing all + await userEvent.click(screen.getByRole("button", { name: "clear all" })); + + // THEN the chips are gone but the time filters are untouched + expect(screen.getByTestId("active-count")).toHaveTextContent("0"); + expect(screen.getByTestId("date-range")).toHaveTextContent("2026-01-01..2026-10-28"); + expect(screen.getByTestId("granularity")).toHaveTextContent("month"); + }); +}); + +describe("useFilters", () => { + it("should throw when used outside a FiltersProvider", () => { + // GIVEN a component using useFilters with no provider above it + // WHEN / THEN rendering it throws + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(() => renderWithoutProviders()).toThrow("useFilters must be used within a FiltersProvider."); + consoleError.mockRestore(); + }); +}); diff --git a/frontend/src/filters/FiltersContext.tsx b/frontend/src/filters/FiltersContext.tsx new file mode 100644 index 0000000..a26a429 --- /dev/null +++ b/frontend/src/filters/FiltersContext.tsx @@ -0,0 +1,65 @@ +import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react"; +import { + createInitialFilters, + deriveGranularity, + getActiveFilters, + type ChipFilterKey, + type DateRange, + type FiltersPatch, + type FiltersState, +} from "@/filters/filters"; + +export interface FiltersContextValue { + filters: FiltersState; + patchFilters: (patch: FiltersPatch) => void; // a dateRange change re-derives granularity + setDateRange: (range: DateRange) => void; + clearFilter: (key: ChipFilterKey) => void; + clearAll: () => void; // chip filters only — keeps the date range + activeFilters: { key: ChipFilterKey; value: string }[]; +} + +const FiltersContext = createContext(null); + +export function useFilters(): FiltersContextValue { + const context = useContext(FiltersContext); + if (!context) { + throw new Error("useFilters must be used within a FiltersProvider."); + } + return context; +} + +/** Mount once around the filter bars and the data they filter, so they share one state. */ +export function FiltersProvider({ + children, + initialFilters, +}: Readonly<{ children: ReactNode; initialFilters?: FiltersState }>) { + const [filters, setFilters] = useState(() => initialFilters ?? createInitialFilters()); + + const patchFilters = useCallback((patch: FiltersPatch) => { + setFilters((prev) => { + const next = { ...prev, ...patch }; + return patch.dateRange ? { ...next, granularity: deriveGranularity(patch.dateRange) } : next; + }); + }, []); + + const setDateRange = useCallback((range: DateRange) => patchFilters({ dateRange: range }), [patchFilters]); + const clearFilter = useCallback((key: ChipFilterKey) => setFilters((prev) => ({ ...prev, [key]: null })), []); + const clearAll = useCallback( + () => setFilters((prev) => ({ ...prev, institutionDrillDownId: null, audienceSegment: null, loginMethod: null })), + [] + ); + + const value = useMemo( + () => ({ + filters, + patchFilters, + setDateRange, + clearFilter, + clearAll, + activeFilters: getActiveFilters(filters), + }), + [filters, patchFilters, setDateRange, clearFilter, clearAll] + ); + + return {children}; +} diff --git a/frontend/src/filters/filters.test.ts b/frontend/src/filters/filters.test.ts new file mode 100644 index 0000000..99256be --- /dev/null +++ b/frontend/src/filters/filters.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import { createInitialFilters, deriveGranularity, getActiveFilters, spanInDays } from "./filters"; + +const GIVEN_TODAY = new Date(2026, 5, 15); +const GIVEN_START = "2026-01-01"; + +function addDays(iso: string, days: number): string { + const [year, month, day] = iso.split("-").map(Number); + const date = new Date(Date.UTC(year, month - 1, day)); + date.setUTCDate(date.getUTCDate() + days); + return date.toISOString().slice(0, 10); +} + +describe("deriveGranularity", () => { + it.each([ + [0, "day"], + [44, "day"], + [45, "day"], + [46, "week"], + [199, "week"], + [200, "week"], + [201, "month"], + [365, "month"], + ] as const)("should derive '%s' granularity for a %i-day span", (offsetDays, expectedGranularity) => { + // GIVEN a range spanning the given number of days + const givenEnd = addDays(GIVEN_START, offsetDays); + + // WHEN deriving the granularity + const actual = deriveGranularity({ start: GIVEN_START, end: givenEnd }); + + // THEN it matches the expected boundary bucket + expect(actual).toBe(expectedGranularity); + }); + + it("should derive the same granularity for a reversed range as its natural counterpart", () => { + // GIVEN a range whose end precedes its start by 50 days + const givenReversedRange = { start: addDays(GIVEN_START, 50), end: GIVEN_START }; + + // WHEN deriving the granularity + const actual = deriveGranularity(givenReversedRange); + + // THEN it matches the forward 50-day range's granularity + expect(actual).toBe("week"); + }); +}); + +describe("spanInDays", () => { + it("should be unaffected by a DST transition inside the range", () => { + // GIVEN a range crossing a UK daylight-saving transition + // WHEN computing the span + const actual = spanInDays("2026-03-28", "2026-03-30"); + + // THEN it's exactly 2 whole days, since the calculation is UTC-based + expect(actual).toBe(2); + }); + + it("should count a leap day correctly", () => { + // GIVEN a range spanning Feb 29 in a leap year + // WHEN computing the span + const actual = spanInDays("2024-02-27", "2024-03-01"); + + // THEN it counts all 3 days, including the leap day + expect(actual).toBe(3); + }); +}); + +describe("createInitialFilters", () => { + it("should default to a 30-day range ending today with 'day' granularity and no chip filters", () => { + // GIVEN a fixed "today" + // WHEN creating the initial filters + const actual = createInitialFilters(GIVEN_TODAY); + + // THEN the range spans the last 30 days, granularity is "day", and no chips are set + expect(actual.dateRange).toEqual({ start: "2026-05-16", end: "2026-06-15" }); + expect(actual.granularity).toBe("day"); + expect(actual.audienceSegment).toBeNull(); + expect(actual.loginMethod).toBeNull(); + expect(actual.institutionDrillDownId).toBeNull(); + }); +}); + +describe("getActiveFilters", () => { + it("should return an empty list when no chip filters are set", () => { + // GIVEN the initial filters state + // WHEN reading the active filters + const actual = getActiveFilters(createInitialFilters(GIVEN_TODAY)); + + // THEN there are none + expect(actual).toEqual([]); + }); + + it("should list active filters in institution, audience segment, login method order", () => { + // GIVEN a state with all three chip filters set, assigned out of order + const givenState = { + ...createInitialFilters(GIVEN_TODAY), + loginMethod: "email" as const, + institutionDrillDownId: "inst-1", + audienceSegment: "youth" as const, + }; + + // WHEN reading the active filters + const actual = getActiveFilters(givenState); + + // THEN they come back in the stable display order + expect(actual).toEqual([ + { key: "institutionDrillDownId", value: "inst-1" }, + { key: "audienceSegment", value: "youth" }, + { key: "loginMethod", value: "email" }, + ]); + }); + + it("should omit a filter that isn't set", () => { + // GIVEN a state with only the audience segment set + const givenState = { ...createInitialFilters(GIVEN_TODAY), audienceSegment: "women" as const }; + + // WHEN reading the active filters + const actual = getActiveFilters(givenState); + + // THEN only that one is present + expect(actual).toEqual([{ key: "audienceSegment", value: "women" }]); + }); +}); diff --git a/frontend/src/filters/filters.ts b/frontend/src/filters/filters.ts new file mode 100644 index 0000000..a291237 --- /dev/null +++ b/frontend/src/filters/filters.ts @@ -0,0 +1,86 @@ +import type { TranslationKey } from "@/i18n/react-i18next"; + +export type Granularity = "day" | "week" | "month"; + +/** Inclusive calendar dates, yyyy-MM-dd. */ +export interface DateRange { + start: string; + end: string; +} + +export const AUDIENCE_SEGMENT_LABEL_KEYS = { + youth: "filters.audienceSegments.youth", + women: "filters.audienceSegments.women", + rural: "filters.audienceSegments.rural", + "first-time-jobseeker": "filters.audienceSegments.firstTimeJobseeker", +} as const satisfies Record; + +export const LOGIN_METHOD_LABEL_KEYS = { + email: "filters.loginMethods.email", + google: "filters.loginMethods.google", + anonymous: "filters.loginMethods.anonymous", +} as const satisfies Record; + +export type AudienceSegmentId = keyof typeof AUDIENCE_SEGMENT_LABEL_KEYS; +export type LoginMethodId = keyof typeof LOGIN_METHOD_LABEL_KEYS; + +export interface FiltersState { + dateRange: DateRange; + granularity: Granularity; // derived from dateRange, never set directly + audienceSegment: AudienceSegmentId | null; + loginMethod: LoginMethodId | null; + institutionDrillDownId: string | null; +} + +export type ChipFilterKey = "institutionDrillDownId" | "audienceSegment" | "loginMethod"; + +export type FiltersPatch = Partial>; + +const DAY_MAX_SPAN_DAYS = 45; +const WEEK_MAX_SPAN_DAYS = 200; +const DEFAULT_RANGE_SPAN_DAYS = 30; + +function toUtcDayIndex(isoDate: string): number { + const [year, month, day] = isoDate.split("-").map(Number); + return Date.UTC(year, month - 1, day) / (24 * 60 * 60 * 1000); +} + +/** Whole days, end − start. UTC-based so a DST shift inside the range can't skew it. */ +export function spanInDays(start: string, end: string): number { + return toUtcDayIndex(end) - toUtcDayIndex(start); +} + +/** ≤45 days → "day", ≤200 → "week", else "month". Uses the absolute span, so start/end order is irrelevant. */ +export function deriveGranularity(range: DateRange): Granularity { + const span = Math.abs(spanInDays(range.start, range.end)); + if (span <= DAY_MAX_SPAN_DAYS) return "day"; + if (span <= WEEK_MAX_SPAN_DAYS) return "week"; + return "month"; +} + +/** Local fields, not toISOString() — that shifts the date by a day outside UTC. */ +function toIsoDate(date: Date): string { + const pad = (value: number) => String(value).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +} + +/** Last 30 days ending `today`. `today` is injectable for tests. */ +export function createInitialFilters(today: Date = new Date()): FiltersState { + const startDate = new Date(today); + startDate.setDate(startDate.getDate() - DEFAULT_RANGE_SPAN_DAYS); + const dateRange: DateRange = { start: toIsoDate(startDate), end: toIsoDate(today) }; + + return { + dateRange, + granularity: deriveGranularity(dateRange), + audienceSegment: null, + loginMethod: null, + institutionDrillDownId: null, + }; +} + +/** The chip filters that currently have a value, in the order they should render. */ +export function getActiveFilters(state: FiltersState): { key: ChipFilterKey; value: string }[] { + const keys: ChipFilterKey[] = ["institutionDrillDownId", "audienceSegment", "loginMethod"]; + return keys.filter((key) => state[key] !== null).map((key) => ({ key, value: state[key] as string })); +} diff --git a/frontend/src/i18n/locales/en-GB/translation.json b/frontend/src/i18n/locales/en-GB/translation.json index 45567dd..17a3baa 100644 --- a/frontend/src/i18n/locales/en-GB/translation.json +++ b/frontend/src/i18n/locales/en-GB/translation.json @@ -76,5 +76,53 @@ "generic": "Something went wrong. Please try again." }, "signOut": "Sign out" + }, + "nav": { + "overview": "Overview", + "jobseekers": "Jobseekers", + "modules": "Modules", + "modulesSection": { + "buildYourProfile": "Build Your Profile", + "jobReadiness": "Job readiness", + "careerExplorer": "Career Explorer", + "jobs": "Jobs" + }, + "userMenu": { + "label": "My account", + "trigger": "Open account menu", + "accountSettings": "Account settings" + } + }, + "filters": { + "activeLabel": "Active filters", + "clearAll": "Clear all", + "none": "No filters applied", + "remove": "Remove {{filter}} filter", + "labels": { + "institution": "Institution", + "audienceSegment": "Audience segment", + "loginMethod": "Login method" + }, + "audienceSegments": { + "youth": "Youth", + "women": "Women", + "rural": "Rural", + "firstTimeJobseeker": "First-time jobseeker" + }, + "loginMethods": { + "email": "Email", + "google": "Google", + "anonymous": "Anonymous" + }, + "time": { + "startLabel": "Start date", + "endLabel": "End date", + "granularityLabel": "Grouped by {{granularity}}" + }, + "granularity": { + "day": "day", + "week": "week", + "month": "month" + } } } diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx deleted file mode 100644 index a97eaf8..0000000 --- a/frontend/src/pages/HomePage.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function HomePage() { - return null; -}