diff --git a/frontend/apps/finance/src/features/dashboard/__tests__/DashboardFeature.test.tsx b/frontend/apps/finance/src/features/dashboard/__tests__/DashboardFeature.test.tsx index b79f33d..ca38614 100644 --- a/frontend/apps/finance/src/features/dashboard/__tests__/DashboardFeature.test.tsx +++ b/frontend/apps/finance/src/features/dashboard/__tests__/DashboardFeature.test.tsx @@ -254,6 +254,22 @@ describe("DashboardFeature", () => { expect(ctaLink).toHaveAttribute("href", "/expenses/new"); }); + it("does not expose outline metadata for dashboard sections without rendered content", async () => { + globalThis.fetch = createMockApi({ + "/api/finance/periods/current": { body: { period: testPeriod } }, + ...dashboardDataEmptyRoutes(), + }) as unknown as typeof fetch; + renderDashboard(); + + await waitFor(() => { + expect(screen.getByText("No expenses yet")).toBeInTheDocument(); + }); + + expect(document.querySelector('[data-outline-title="Historical Comparison"]')).toBeNull(); + expect(document.querySelector('[data-outline-title="Trends"]')).toBeNull(); + expect(document.querySelector('[data-outline-title="Cumulative Spending"]')).toBeNull(); + }); + it("renders repeated-expenses chart with frequency and recency context", async () => { const mockApi = createMockApi({ "/api/finance/periods/current": { body: { period: testPeriod } }, diff --git a/frontend/apps/finance/src/features/dashboard/__tests__/DashboardOutline.test.tsx b/frontend/apps/finance/src/features/dashboard/__tests__/DashboardOutline.test.tsx index 1ea9c64..fc5129c 100644 --- a/frontend/apps/finance/src/features/dashboard/__tests__/DashboardOutline.test.tsx +++ b/frontend/apps/finance/src/features/dashboard/__tests__/DashboardOutline.test.tsx @@ -1,54 +1,356 @@ -import { describe, it, expect } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { createRef, type ReactNode, type RefObject } from "react"; import { DashboardOutline } from "../components/DashboardOutline"; +import { + chooseActiveDashboardOutlineItem, + collectDashboardOutline, + toDashboardOutlineItems, + type DashboardOutlineItem, +} from "../components/dashboard-outline"; + +interface MockMutationObserverInstance { + callback: MutationCallback; + observe: ReturnType; + disconnect: ReturnType; +} + +interface MockIntersectionObserverInstance { + callback: IntersectionObserverCallback; + observe: ReturnType; + disconnect: ReturnType; +} + +const mutationObservers: MockMutationObserverInstance[] = []; +const intersectionObservers: MockIntersectionObserverInstance[] = []; + +class MockMutationObserver implements MutationObserver { + callback: MutationCallback; + observe = vi.fn(); + disconnect = vi.fn(); + takeRecords = vi.fn(() => []); + + constructor(callback: MutationCallback) { + this.callback = callback; + mutationObservers.push(this); + } +} + +class MockIntersectionObserver implements IntersectionObserver { + readonly root = null; + readonly rootMargin = ""; + readonly thresholds = []; + callback: IntersectionObserverCallback; + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); + takeRecords = vi.fn(() => []); + + constructor(callback: IntersectionObserverCallback) { + this.callback = callback; + intersectionObservers.push(this); + } +} + +beforeEach(() => { + mutationObservers.length = 0; + intersectionObservers.length = 0; + vi.stubGlobal("MutationObserver", MockMutationObserver); + vi.stubGlobal("IntersectionObserver", MockIntersectionObserver); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); describe("DashboardOutline", () => { - it("renders a nav with Dashboard sections aria-label", () => { - render(); - expect(screen.getByRole("navigation", { name: "Dashboard sections" })).toBeInTheDocument(); + it("stays hidden until at least one outline item has been collected", () => { + const rootRef = createDashboardRoot(
); + + render(); + + expect(screen.queryByRole("navigation", { name: "Dashboard sections" })).not.toBeInTheDocument(); + }); + + it("collects visible outline nodes from the dashboard root in DOM order", async () => { + const rootRef = createDashboardRoot( + <> +
+
{/* Dashboard Outline (TOC) - fixed positioned, renders on xl+ viewports */} - + ); } diff --git a/frontend/apps/finance/src/features/dashboard/components/DashboardOutline.tsx b/frontend/apps/finance/src/features/dashboard/components/DashboardOutline.tsx index bc338b6..aaf8b69 100644 --- a/frontend/apps/finance/src/features/dashboard/components/DashboardOutline.tsx +++ b/frontend/apps/finance/src/features/dashboard/components/DashboardOutline.tsx @@ -1,77 +1,53 @@ -const TOC_ITEMS = [ - { - label: "Summary", - href: "#summary", - children: [ - { label: "Budget Allocations", href: "#budget-allocations" }, - { label: "Spending Pace", href: "#spending-pace" }, - { label: "Historical Comparison", href: "#historical-comparison" }, - ], - }, - { - label: "Trends", - href: "#trends", - children: [ - { label: "Monthly Spending", href: "#trends" }, - { label: "Category Split", href: "#trends" }, - ], - }, - { - label: "Breakdown", - href: "#breakdown", - children: [ - { label: "Spending by Tag", href: "#breakdown" }, - { label: "Repeated Expenses", href: "#breakdown" }, - ], - }, - { label: "Cumulative Spending", href: "#cumulative-spending" }, - { label: "Recent Expenses", href: "#recent-expenses" }, -] as const; +import type { ReactNode } from "react"; +import { + useDashboardOutline, + type DashboardOutlineItem, + type DashboardOutlineProps, +} from "./dashboard-outline"; -interface TocItem { - label: string; - href: string; - children?: readonly { label: string; href: string }[]; -} +export function DashboardOutline({ rootRef }: DashboardOutlineProps) { + const { items, activeId } = useDashboardOutline(rootRef); + + if (items.length === 0) return null; -export function DashboardOutline() { return ( ); } -function TocEntry({ item }: { item: TocItem }) { - return ( -
  • +function renderOutlineItems( + items: DashboardOutlineItem[], + activeId: string | null, + isNested: boolean, +): ReactNode { + return items.map((item) => ( +
  • - {item.label} + {item.title} - {item.children && item.children.length > 0 && ( + {item.children.length > 0 && (
      - {item.children.map((child) => ( -
    • - - {child.label} - -
    • - ))} + {renderOutlineItems(item.children, activeId, true)}
    )}
  • - ); + )); +} + +function getLinkClassName(isActive: boolean, isNested: boolean): string { + const sizeClassName = isNested ? "py-0.5 text-[11px]" : "py-1"; + const activeClassName = isActive ? "text-foreground font-medium" : "hover:text-foreground"; + + return `block ${sizeClassName} ${activeClassName} transition-colors`; } diff --git a/frontend/apps/finance/src/features/dashboard/components/dashboard-outline/chooseActiveDashboardOutlineItem.ts b/frontend/apps/finance/src/features/dashboard/components/dashboard-outline/chooseActiveDashboardOutlineItem.ts new file mode 100644 index 0000000..1c3ded7 --- /dev/null +++ b/frontend/apps/finance/src/features/dashboard/components/dashboard-outline/chooseActiveDashboardOutlineItem.ts @@ -0,0 +1,42 @@ +import type { DashboardOutlineItem } from "./types"; + +interface FlattenedOutlineItem { + id: string; + depth: number; + order: number; +} + +export function chooseActiveDashboardOutlineItem( + items: DashboardOutlineItem[], + activeIds: Set, +): string | null { + const activeItems = flattenOutlineItems(items).filter((item) => activeIds.has(item.id)); + if (activeItems.length === 0) return null; + + activeItems.sort((left, right) => { + if (left.depth !== right.depth) return right.depth - left.depth; + return left.order - right.order; + }); + + return activeItems[0].id; +} + +function flattenOutlineItems(items: DashboardOutlineItem[]): FlattenedOutlineItem[] { + const flattenedItems: FlattenedOutlineItem[] = []; + let order = 0; + + function visit(item: DashboardOutlineItem, depth: number) { + flattenedItems.push({ id: item.id, depth, order }); + order += 1; + + for (const child of item.children) { + visit(child, depth + 1); + } + } + + for (const item of items) { + visit(item, 0); + } + + return flattenedItems; +} diff --git a/frontend/apps/finance/src/features/dashboard/components/dashboard-outline/collectDashboardOutline.ts b/frontend/apps/finance/src/features/dashboard/components/dashboard-outline/collectDashboardOutline.ts new file mode 100644 index 0000000..2f77738 --- /dev/null +++ b/frontend/apps/finance/src/features/dashboard/components/dashboard-outline/collectDashboardOutline.ts @@ -0,0 +1,78 @@ +import type { DashboardOutlineElement, DashboardOutlineItem } from "./types"; + +const OUTLINE_SELECTOR = "[id][data-outline-title]"; + +export function collectDashboardOutline(root: HTMLElement): DashboardOutlineElement[] { + const candidates = Array.from(root.querySelectorAll(OUTLINE_SELECTOR)); + const outlineElements = candidates.reduce((items, element) => { + if (!isElementVisible(element)) return items; + + const title = element.dataset.outlineTitle?.trim(); + if (!title) return items; + + items.push({ + id: element.id, + title, + element, + children: [], + }); + + return items; + }, []); + + return buildOutlineTree(outlineElements); +} + +export function toDashboardOutlineItems( + outlineElements: DashboardOutlineElement[], +): DashboardOutlineItem[] { + return outlineElements.map((item) => ({ + id: item.id, + title: item.title, + children: toDashboardOutlineItems(item.children), + })); +} + +function buildOutlineTree(items: DashboardOutlineElement[]): DashboardOutlineElement[] { + const roots: DashboardOutlineElement[] = []; + + for (const item of items) { + const parent = findNearestCollectedParent(item, items); + + if (parent) { + parent.children.push(item); + continue; + } + + roots.push(item); + } + + return roots; +} + +function findNearestCollectedParent( + item: DashboardOutlineElement, + items: DashboardOutlineElement[], +): DashboardOutlineElement | null { + const itemIndex = items.indexOf(item); + let nearestParent: DashboardOutlineElement | null = null; + + for (const candidate of items.slice(0, itemIndex)) { + if (!candidate.element.contains(item.element)) continue; + if (nearestParent && !nearestParent.element.contains(candidate.element)) continue; + + nearestParent = candidate; + } + + return nearestParent; +} + +function isElementVisible(element: HTMLElement): boolean { + if (element.hidden || element.getAttribute("aria-hidden") === "true") return false; + + const style = window.getComputedStyle(element); + if (style.display === "none" || style.visibility === "hidden") return false; + + const rect = element.getBoundingClientRect(); + return rect.width > 0 || rect.height > 0; +} diff --git a/frontend/apps/finance/src/features/dashboard/components/dashboard-outline/index.ts b/frontend/apps/finance/src/features/dashboard/components/dashboard-outline/index.ts new file mode 100644 index 0000000..ebc2178 --- /dev/null +++ b/frontend/apps/finance/src/features/dashboard/components/dashboard-outline/index.ts @@ -0,0 +1,4 @@ +export { chooseActiveDashboardOutlineItem } from "./chooseActiveDashboardOutlineItem"; +export { collectDashboardOutline, toDashboardOutlineItems } from "./collectDashboardOutline"; +export { useDashboardOutline } from "./useDashboardOutline"; +export type { DashboardOutlineItem, DashboardOutlineProps } from "./types"; diff --git a/frontend/apps/finance/src/features/dashboard/components/dashboard-outline/types.ts b/frontend/apps/finance/src/features/dashboard/components/dashboard-outline/types.ts new file mode 100644 index 0000000..119f845 --- /dev/null +++ b/frontend/apps/finance/src/features/dashboard/components/dashboard-outline/types.ts @@ -0,0 +1,23 @@ +import type { RefObject } from "react"; + +export interface DashboardOutlineItem { + id: string; + title: string; + children: DashboardOutlineItem[]; +} + +export interface DashboardOutlineState { + items: DashboardOutlineItem[]; + activeId: string | null; +} + +export interface DashboardOutlineElement { + id: string; + title: string; + element: HTMLElement; + children: DashboardOutlineElement[]; +} + +export interface DashboardOutlineProps { + rootRef: RefObject; +} diff --git a/frontend/apps/finance/src/features/dashboard/components/dashboard-outline/useDashboardOutline.ts b/frontend/apps/finance/src/features/dashboard/components/dashboard-outline/useDashboardOutline.ts new file mode 100644 index 0000000..e9ce262 --- /dev/null +++ b/frontend/apps/finance/src/features/dashboard/components/dashboard-outline/useDashboardOutline.ts @@ -0,0 +1,115 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { RefObject } from "react"; +import { chooseActiveDashboardOutlineItem } from "./chooseActiveDashboardOutlineItem"; +import { + collectDashboardOutline, + toDashboardOutlineItems, +} from "./collectDashboardOutline"; +import type { + DashboardOutlineElement, + DashboardOutlineItem, + DashboardOutlineState, +} from "./types"; + +const OBSERVED_ATTRIBUTES = [ + "id", + "data-outline-title", + "class", + "style", + "hidden", + "aria-hidden", +]; + +export function useDashboardOutline(rootRef: RefObject): DashboardOutlineState { + const [state, setState] = useState({ items: [], activeId: null }); + const outlineElementsRef = useRef([]); + const intersectingIdsRef = useRef>(new Set()); + + const recollect = useCallback(() => { + const root = rootRef.current; + if (!root) { + outlineElementsRef.current = []; + intersectingIdsRef.current = new Set(); + setState({ items: [], activeId: null }); + return []; + } + + const outlineElements = collectDashboardOutline(root); + const items = toDashboardOutlineItems(outlineElements); + outlineElementsRef.current = outlineElements; + intersectingIdsRef.current = new Set( + Array.from(intersectingIdsRef.current).filter((id) => hasOutlineItem(items, id)), + ); + const activeId = chooseActiveDashboardOutlineItem(items, intersectingIdsRef.current); + setState({ items, activeId }); + + return flattenOutlineElements(outlineElements).map((item) => item.element); + }, [rootRef]); + + useEffect(() => { + const root = rootRef.current; + if (!root) return; + + recollect(); + + const mutationObserver = new MutationObserver(() => { + recollect(); + }); + + mutationObserver.observe(root, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: OBSERVED_ATTRIBUTES, + }); + + return () => { + mutationObserver.disconnect(); + }; + }, [recollect, rootRef]); + + useEffect(() => { + const observedElements = flattenOutlineElements(outlineElementsRef.current).map((item) => item.element); + if (observedElements.length === 0) return; + + const intersectionObserver = new IntersectionObserver((entries) => { + for (const entry of entries) { + const target = entry.target; + if (!(target instanceof HTMLElement)) continue; + + if (entry.isIntersecting) { + intersectingIdsRef.current.add(target.id); + continue; + } + + intersectingIdsRef.current.delete(target.id); + } + + setState((previousState) => { + const activeId = chooseActiveDashboardOutlineItem( + previousState.items, + intersectingIdsRef.current, + ); + return { ...previousState, activeId }; + }); + }, { threshold: 0.1 }); + + for (const element of observedElements) { + intersectionObserver.observe(element); + } + + return () => { + intersectionObserver.disconnect(); + }; + }, [state.items]); + + return state; +} + +function flattenOutlineElements(items: DashboardOutlineElement[]): DashboardOutlineElement[] { + return items.flatMap((item) => [item, ...flattenOutlineElements(item.children)]); +} + +function hasOutlineItem(items: DashboardOutlineItem[], id: string): boolean { + return items.some((item) => item.id === id || hasOutlineItem(item.children, id)); +}