From a4ee6e61be2a1ff37598bca640369ebbe27dfda4 Mon Sep 17 00:00:00 2001 From: Tom Alexander Date: Fri, 22 May 2026 10:51:19 -0400 Subject: [PATCH 01/17] feat(app): add dashboard section navigation aids Adds three opt-in helpers for navigating dashboards with many sections, all off by default so existing dashboards look unchanged. - Hash anchors per section: each DashboardContainer renders with id="container-". Hovering a section header reveals a "copy link" icon that copies the current URL plus a #container- fragment (using the shared @/utils/clipboard helper). Loading the page with such a hash auto-scrolls to that section, auto-expanding it first. - View-options menu in the dashboard toolbar: "Collapse all sections" / "Expand all sections" / "Show table of contents" toggle. Batch collapse/expand reuse the existing URL-synced collapse state so the resulting view is shareable. - Sticky in-flow TOC rail: when enabled via the view-options toggle, a 180px column renders beside the dashboard content (Flex sibling, not an overlay) with one entry per section. useScrollSpy highlights the active section as the user scrolls; click jumps. Hidden below the md breakpoint. The "tocVisible" preference is per-user in localStorage. Core hook lives in src/hooks/useDashboardSectionNav.ts and exposes scrollToContainer, copySectionLink, collapseAll, and expandAll. --- packages/app/src/DBDashboardPage.tsx | 268 ++++++++++++------ .../src/__tests__/DashboardContainer.test.tsx | 53 ++++ .../app/src/components/DashboardContainer.tsx | 27 ++ packages/app/src/components/DashboardTOC.tsx | 100 +++++++ .../src/components/DashboardViewOptions.tsx | 69 +++++ .../__tests__/DashboardTOC.test.tsx | 90 ++++++ .../__tests__/DashboardViewOptions.test.tsx | 80 ++++++ .../__tests__/useDashboardSectionNav.test.tsx | 206 ++++++++++++++ .../app/src/hooks/useDashboardSectionNav.ts | 115 ++++++++ packages/app/src/useUserPreferences.tsx | 2 + 10 files changed, 925 insertions(+), 85 deletions(-) create mode 100644 packages/app/src/components/DashboardTOC.tsx create mode 100644 packages/app/src/components/DashboardViewOptions.tsx create mode 100644 packages/app/src/components/__tests__/DashboardTOC.test.tsx create mode 100644 packages/app/src/components/__tests__/DashboardViewOptions.test.tsx create mode 100644 packages/app/src/hooks/__tests__/useDashboardSectionNav.test.tsx create mode 100644 packages/app/src/hooks/useDashboardSectionNav.ts diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index cd9c73f466..e0c39cfc58 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -104,6 +104,8 @@ import { DashboardDndProvider, type DragHandleProps, } from '@/components/DashboardDndContext'; +import { DashboardTOC } from '@/components/DashboardTOC'; +import { DashboardViewOptions } from '@/components/DashboardViewOptions'; import EditTimeChartForm from '@/components/DBEditTimeChartForm'; import DBNumberChart from '@/components/DBNumberChart'; import DBTableChart from '@/components/DBTableChart'; @@ -122,6 +124,7 @@ import { import useDashboardContainers, { TabDeleteAction, } from '@/hooks/useDashboardContainers'; +import { useDashboardSectionNav } from '@/hooks/useDashboardSectionNav'; import { calculateNextTilePosition, makeId } from '@/utils/tilePositioning'; import ChartContainer from './components/charts/ChartContainer'; @@ -1188,6 +1191,7 @@ function DashboardContainerRow({ onDeleteTab, onRenameContainer, onTabChange, + onCopyLink, dragHandleProps, makeLayoutChangeHandler, tileToLayoutItem, @@ -1209,6 +1213,7 @@ function DashboardContainerRow({ onDeleteTab: (tabId: string, action: TabDeleteAction) => void; onRenameContainer: (newTitle: string) => void; onTabChange: (tabId: string) => void; + onCopyLink: () => void; dragHandleProps: DragHandleProps; makeLayoutChangeHandler: (tiles: Tile[]) => (newLayout: RGL.Layout[]) => void; tileToLayoutItem: (tile: Tile) => RGL.Layout; @@ -1248,6 +1253,7 @@ function DashboardContainerRow({ onRenameTab={onRenameTab} onDeleteTab={onDeleteTab} onRename={onRenameContainer} + onCopyLink={onCopyLink} dragHandleProps={dragHandleProps} alertingTabIds={alertingTabIds} > @@ -1611,6 +1617,42 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { () => dashboard?.containers ?? [], [dashboard?.containers], ); + + // Section-level navigation helpers: scroll-to-section, deep-link copy, and + // batch collapse/expand. Consumed by the view-options menu (collapseAll / + // expandAll), the per-container header (copySectionLink), the TOC rail + // (scrollToContainer), and the hash-on-load effect (scrollToContainer). + const { scrollToContainer, copySectionLink, collapseAll, expandAll } = + useDashboardSectionNav({ containers }); + + // Per-user preference for whether the sticky table-of-contents rail is shown. + // Defaults to false (off) so dashboard behavior is unchanged for users who + // never opt in via the view-options menu. + const { userPreferences, setUserPreference } = useUserPreferences(); + const tocVisible = userPreferences.tocVisible ?? false; + const toggleToc = useCallback( + () => setUserPreference({ tocVisible: !tocVisible }), + [tocVisible, setUserPreference], + ); + + // Lightweight projection of containers (id + label) for the TOC rail. + // The label mirrors what the user perceives as the section name: + // - 1-tab container: the visible header is the first tab's title (the + // header rename flow in DashboardContainer redirects to the first tab), + // so use that. + // - Multi-tab container: the visible header is the tab bar showing every + // tab; `container.title` is the section-level name that doesn't move + // when the active tab changes, so use that as the TOC label. + const tocContainers = useMemo( + () => + containers.map(c => { + const tabs = c.tabs ?? []; + const label = tabs.length >= 2 ? c.title : (tabs[0]?.title ?? c.title); + return { id: c.id, title: label }; + }), + [containers], + ); + // URL-based collapse state: tracks which containers the current viewer has // explicitly collapsed/expanded. Falls back to the DB-stored default. const [urlCollapsedIds, setUrlCollapsedIds] = useQueryState( @@ -1692,6 +1734,37 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { setUrlActiveTabs, ]); + // Hash-based deep links to dashboard sections (e.g. `#container-abc123`). + // Runs an initial scroll once per dashboard load (re-armed when the user + // navigates between dashboards in the SPA), then keeps a hashchange listener + // attached so in-page hash navigation also scrolls. Unknown containers + // (deleted sections, stale links) are silently ignored. + const initialHashScrolledForDashboardRef = useRef(null); + useEffect(() => { + if (typeof window === 'undefined' || !dashboard) return; + // Wait until containers are available so the existence check below can + // distinguish "unknown container" from "containers still loading". + if (!dashboard.containers || dashboard.containers.length === 0) return; + + const handleHash = () => { + const match = window.location.hash.match(/^#container-(.+)$/); + if (!match) return; + const containerId = match[1]; + const exists = dashboard.containers?.some(c => c.id === containerId); + if (!exists) return; + scrollToContainer(containerId); + }; + + // Fire the initial scroll once per dashboard.id so SPA navigation between + // dashboards (same component instance) re-arms the deep-link behavior. + if (initialHashScrolledForDashboardRef.current !== dashboard.id) { + initialHashScrolledForDashboardRef.current = dashboard.id; + handleHash(); + } + window.addEventListener('hashchange', handleHash); + return () => window.removeEventListener('hashchange', handleHash); + }, [dashboard, scrollToContainer]); + // Valid move targets: groups and individual tabs within groups const moveTargetContainers = useMemo(() => { const targets: MoveTarget[] = []; @@ -2457,6 +2530,12 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { +