diff --git a/CLAUDE.md b/CLAUDE.md index cf4e4f936..98487f53e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,3 +24,11 @@ See [AGENTS.md](./AGENTS.md) for full project context, architecture, and coding - Refactored config feature presentation to reusable `SettingsFeatureCard` pattern (header + master toggle + Basic/Advanced blocks). - Kept save contract unchanged: global save/discard, diff-modal confirmation, per-section PATCH batching, and partial-failure behavior. - Updated config editor tests from stale autosave assumptions to explicit manual-save workspace behavior and added coverage for category switching/search/dirty badges. + +## Session Notes (2026-03-07) + +- Dashboard browser titles now sync with dashboard route changes: + - Added shared title helpers in `web/src/lib/page-titles.ts` with the canonical app title string `Volvox.Bot - AI Powered Discord Bot`. + - Mounted `DashboardTitleSync` in `web/src/components/layout/dashboard-shell.tsx` so client-rendered dashboard pages update `document.title` on pathname changes without needing a server-wrapper refactor for every route. + - Added static metadata for server-rendered dashboard entry pages (`/dashboard`, `/dashboard/config`, `/dashboard/performance`) and switched the root app metadata to a title template so direct loads and client transitions use the same suffix format. + - Coverage lives in `web/tests/lib/page-titles.test.ts` and `web/tests/components/layout/dashboard-title-sync.test.tsx`. diff --git a/web/src/app/dashboard/config/page.tsx b/web/src/app/dashboard/config/page.tsx index 7d9c1be09..eb8515e0e 100644 --- a/web/src/app/dashboard/config/page.tsx +++ b/web/src/app/dashboard/config/page.tsx @@ -1,10 +1,11 @@ import type { Metadata } from 'next'; import { ConfigEditor } from '@/components/dashboard/config-editor'; +import { createPageMetadata } from '@/lib/page-titles'; -export const metadata: Metadata = { - title: 'Config Editor', - description: 'Manage your bot configuration settings.', -}; +export const metadata: Metadata = createPageMetadata( + 'Bot Config', + 'Manage your bot configuration settings.', +); /** * Page component that renders the dashboard configuration editor. diff --git a/web/src/app/dashboard/page.tsx b/web/src/app/dashboard/page.tsx index fa974f267..77d7db03f 100644 --- a/web/src/app/dashboard/page.tsx +++ b/web/src/app/dashboard/page.tsx @@ -1,5 +1,12 @@ +import type { Metadata } from 'next'; import { AnalyticsDashboard } from '@/components/dashboard/analytics-dashboard'; import { ErrorBoundary } from '@/components/ui/error-boundary'; +import { createPageMetadata } from '@/lib/page-titles'; + +export const metadata: Metadata = createPageMetadata( + 'Overview', + 'Monitor bot analytics and dashboard health at a glance.', +); export default function DashboardPage() { return ( diff --git a/web/src/app/dashboard/performance/page.tsx b/web/src/app/dashboard/performance/page.tsx index a41c3a9de..c4d545334 100644 --- a/web/src/app/dashboard/performance/page.tsx +++ b/web/src/app/dashboard/performance/page.tsx @@ -1,4 +1,11 @@ +import type { Metadata } from 'next'; import { PerformanceDashboard } from '@/components/dashboard/performance-dashboard'; +import { createPageMetadata } from '@/lib/page-titles'; + +export const metadata: Metadata = createPageMetadata( + 'Performance', + 'Inspect bot uptime, latency, and resource trends.', +); export default function PerformancePage() { return ; diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 307fbca42..9aa22d2a0 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from 'next'; import { Inter, JetBrains_Mono } from 'next/font/google'; import { Providers } from '@/components/providers'; +import { APP_TITLE } from '@/lib/page-titles'; import './globals.css'; const inter = Inter({ @@ -14,7 +15,10 @@ const jetbrainsMono = JetBrains_Mono({ }); export const metadata: Metadata = { - title: 'Volvox Bot - AI-Powered Discord Bot', + title: { + default: APP_TITLE, + template: `%s - ${APP_TITLE}`, + }, description: 'The AI-powered Discord bot for modern communities. Moderation, AI chat, dynamic welcomes, spam detection, and a fully configurable web dashboard.', }; diff --git a/web/src/components/layout/dashboard-shell.tsx b/web/src/components/layout/dashboard-shell.tsx index d03e9fdfc..1cf9f7ad9 100644 --- a/web/src/components/layout/dashboard-shell.tsx +++ b/web/src/components/layout/dashboard-shell.tsx @@ -1,3 +1,4 @@ +import { DashboardTitleSync } from './dashboard-title-sync'; import { Header } from './header'; import { ServerSelector } from './server-selector'; import { Sidebar } from './sidebar'; @@ -14,6 +15,7 @@ interface DashboardShellProps { export function DashboardShell({ children }: DashboardShellProps) { return (
+
diff --git a/web/src/components/layout/dashboard-title-sync.tsx b/web/src/components/layout/dashboard-title-sync.tsx new file mode 100644 index 000000000..363ec07d6 --- /dev/null +++ b/web/src/components/layout/dashboard-title-sync.tsx @@ -0,0 +1,15 @@ +'use client'; + +import { usePathname } from 'next/navigation'; +import { useEffect } from 'react'; +import { getDashboardDocumentTitle } from '@/lib/page-titles'; + +export function DashboardTitleSync() { + const pathname = usePathname(); + + useEffect(() => { + document.title = getDashboardDocumentTitle(pathname); + }, [pathname]); + + return null; +} diff --git a/web/src/lib/page-titles.ts b/web/src/lib/page-titles.ts new file mode 100644 index 000000000..77d8c6b3b --- /dev/null +++ b/web/src/lib/page-titles.ts @@ -0,0 +1,110 @@ +import type { Metadata } from 'next'; + +export const APP_TITLE = 'Volvox.Bot - AI Powered Discord Bot'; + +interface DashboardTitleMatcher { + matches: (pathname: string) => boolean; + title: string; +} + +const dashboardTitleMatchers: DashboardTitleMatcher[] = [ + { + matches: (pathname) => pathname.startsWith('/dashboard/members/'), + title: 'Member Details', + }, + { + matches: (pathname) => pathname.startsWith('/dashboard/conversations/'), + title: 'Conversation Details', + }, + { + matches: (pathname) => pathname.startsWith('/dashboard/tickets/'), + title: 'Ticket Details', + }, + { + matches: (pathname) => pathname === '/dashboard', + title: 'Overview', + }, + { + matches: (pathname) => pathname.startsWith('/dashboard/moderation'), + title: 'Moderation', + }, + { + matches: (pathname) => pathname.startsWith('/dashboard/temp-roles'), + title: 'Temp Roles', + }, + { + matches: (pathname) => pathname.startsWith('/dashboard/ai'), + title: 'AI Chat', + }, + { + matches: (pathname) => pathname.startsWith('/dashboard/members'), + title: 'Members', + }, + { + matches: (pathname) => pathname.startsWith('/dashboard/conversations'), + title: 'Conversations', + }, + { + matches: (pathname) => pathname.startsWith('/dashboard/tickets'), + title: 'Tickets', + }, + { + matches: (pathname) => pathname.startsWith('/dashboard/config'), + title: 'Bot Config', + }, + { + matches: (pathname) => pathname.startsWith('/dashboard/audit-log'), + title: 'Audit Log', + }, + { + matches: (pathname) => pathname.startsWith('/dashboard/performance'), + title: 'Performance', + }, + { + matches: (pathname) => pathname.startsWith('/dashboard/logs'), + title: 'Logs', + }, + { + matches: (pathname) => pathname.startsWith('/dashboard/settings'), + title: 'Settings', + }, +]; + +function normalizePathname(pathname: string | null | undefined): string | null { + if (!pathname) { + return null; + } + + const trimmedPathname = + pathname.endsWith('/') && pathname !== '/' ? pathname.slice(0, -1) : pathname; + return trimmedPathname || '/'; +} + +export function formatDocumentTitle(pageTitle?: string | null): string { + return pageTitle ? `${pageTitle} - ${APP_TITLE}` : APP_TITLE; +} + +export function getDashboardPageTitle(pathname: string | null | undefined): string | null { + const normalizedPathname = normalizePathname(pathname); + if (!normalizedPathname) { + return null; + } + + const matchedRoute = dashboardTitleMatchers.find(({ matches }) => matches(normalizedPathname)); + return matchedRoute?.title ?? null; +} + +export function getDashboardDocumentTitle(pathname: string | null | undefined): string { + return formatDocumentTitle(getDashboardPageTitle(pathname)); +} + +export function createPageMetadata(title: string, description?: string): Metadata { + if (!description) { + return { title }; + } + + return { + title, + description, + }; +} diff --git a/web/tests/components/layout/dashboard-shell.test.tsx b/web/tests/components/layout/dashboard-shell.test.tsx index 5c858caf2..39d46a8f4 100644 --- a/web/tests/components/layout/dashboard-shell.test.tsx +++ b/web/tests/components/layout/dashboard-shell.test.tsx @@ -1,20 +1,24 @@ -import { describe, it, expect, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; // Mock child components — DashboardShell is now a server component -vi.mock("@/components/layout/header", () => ({ +vi.mock('@/components/layout/header', () => ({ Header: () =>
Header
, })); -vi.mock("@/components/layout/sidebar", () => ({ +vi.mock('@/components/layout/dashboard-title-sync', () => ({ + DashboardTitleSync: () =>
, +})); + +vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => , })); -vi.mock("@/components/layout/server-selector", () => ({ +vi.mock('@/components/layout/server-selector', () => ({ ServerSelector: () =>
Servers
, })); -import { DashboardShell } from "@/components/layout/dashboard-shell"; +import { DashboardShell } from '@/components/layout/dashboard-shell'; describe("DashboardShell", () => { it("renders header, sidebar, and content", () => { @@ -24,6 +28,7 @@ describe("DashboardShell", () => { , ); expect(screen.getByTestId("header")).toBeInTheDocument(); + expect(screen.getByTestId("dashboard-title-sync")).toBeInTheDocument(); expect(screen.getByTestId("sidebar")).toBeInTheDocument(); expect(screen.getByTestId("content")).toBeInTheDocument(); }); diff --git a/web/tests/components/layout/dashboard-title-sync.test.tsx b/web/tests/components/layout/dashboard-title-sync.test.tsx new file mode 100644 index 000000000..0fd20270d --- /dev/null +++ b/web/tests/components/layout/dashboard-title-sync.test.tsx @@ -0,0 +1,53 @@ +import { render, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { APP_TITLE } from '@/lib/page-titles'; + +let mockPathname = '/dashboard'; + +vi.mock('next/navigation', () => ({ + usePathname: () => mockPathname, +})); + +import { DashboardTitleSync } from '@/components/layout/dashboard-title-sync'; + +describe('DashboardTitleSync', () => { + beforeEach(() => { + mockPathname = '/dashboard'; + document.title = ''; + }); + + it('sets the dashboard title from the current route', async () => { + mockPathname = '/dashboard/members'; + + render(); + + await waitFor(() => { + expect(document.title).toBe('Members - Volvox.Bot - AI Powered Discord Bot'); + }); + }); + + it('updates the title when the route changes', async () => { + const { rerender } = render(); + + await waitFor(() => { + expect(document.title).toBe('Overview - Volvox.Bot - AI Powered Discord Bot'); + }); + + mockPathname = '/dashboard/tickets/42'; + rerender(); + + await waitFor(() => { + expect(document.title).toBe('Ticket Details - Volvox.Bot - AI Powered Discord Bot'); + }); + }); + + it('falls back to the app title for unknown routes', async () => { + mockPathname = '/dashboard/something-weird'; + + render(); + + await waitFor(() => { + expect(document.title).toBe(APP_TITLE); + }); + }); +}); diff --git a/web/tests/lib/page-titles.test.ts b/web/tests/lib/page-titles.test.ts new file mode 100644 index 000000000..b161f0155 --- /dev/null +++ b/web/tests/lib/page-titles.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { + APP_TITLE, + createPageMetadata, + formatDocumentTitle, + getDashboardDocumentTitle, + getDashboardPageTitle, +} from '@/lib/page-titles'; + +describe('page titles', () => { + it('formats dashboard tab titles with the shared app title suffix', () => { + expect(formatDocumentTitle('Members')).toBe('Members - Volvox.Bot - AI Powered Discord Bot'); + }); + + it('maps dashboard routes to the expected tab titles', () => { + expect(getDashboardPageTitle('/dashboard')).toBe('Overview'); + expect(getDashboardPageTitle('/dashboard/members')).toBe('Members'); + expect(getDashboardPageTitle('/dashboard/members/123')).toBe('Member Details'); + expect(getDashboardPageTitle('/dashboard/conversations/abc')).toBe('Conversation Details'); + expect(getDashboardPageTitle('/dashboard/tickets/42')).toBe('Ticket Details'); + expect(getDashboardPageTitle('/dashboard/unknown')).toBeNull(); + }); + + it('builds complete document titles from dashboard routes', () => { + expect(getDashboardDocumentTitle('/dashboard/audit-log')).toBe( + 'Audit Log - Volvox.Bot - AI Powered Discord Bot', + ); + expect(getDashboardDocumentTitle('/dashboard/unknown')).toBe(APP_TITLE); + }); + + it('creates Next metadata objects with optional descriptions', () => { + expect(createPageMetadata('Performance')).toEqual({ title: 'Performance' }); + expect(createPageMetadata('Bot Config', 'Manage settings')).toEqual({ + title: 'Bot Config', + description: 'Manage settings', + }); + }); +});