Skip to content

Commit febddc7

Browse files
BillChiricoBill Chirico
andauthored
Update dashboard tab titles to use shared suffix (#262)
* Sync dashboard tab titles * fix(test): use single quotes in dashboard-shell test mocks --------- Co-authored-by: Bill Chirico <[email protected]>
1 parent a6e76d0 commit febddc7

11 files changed

Lines changed: 261 additions & 11 deletions

File tree

CLAUDE.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,11 @@ See [AGENTS.md](./AGENTS.md) for full project context, architecture, and coding
2424
- Refactored config feature presentation to reusable `SettingsFeatureCard` pattern (header + master toggle + Basic/Advanced blocks).
2525
- Kept save contract unchanged: global save/discard, diff-modal confirmation, per-section PATCH batching, and partial-failure behavior.
2626
- Updated config editor tests from stale autosave assumptions to explicit manual-save workspace behavior and added coverage for category switching/search/dirty badges.
27+
28+
## Session Notes (2026-03-07)
29+
30+
- Dashboard browser titles now sync with dashboard route changes:
31+
- Added shared title helpers in `web/src/lib/page-titles.ts` with the canonical app title string `Volvox.Bot - AI Powered Discord Bot`.
32+
- 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.
33+
- 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.
34+
- Coverage lives in `web/tests/lib/page-titles.test.ts` and `web/tests/components/layout/dashboard-title-sync.test.tsx`.

web/src/app/dashboard/config/page.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import type { Metadata } from 'next';
22
import { ConfigEditor } from '@/components/dashboard/config-editor';
3+
import { createPageMetadata } from '@/lib/page-titles';
34

4-
export const metadata: Metadata = {
5-
title: 'Config Editor',
6-
description: 'Manage your bot configuration settings.',
7-
};
5+
export const metadata: Metadata = createPageMetadata(
6+
'Bot Config',
7+
'Manage your bot configuration settings.',
8+
);
89

910
/**
1011
* Page component that renders the dashboard configuration editor.

web/src/app/dashboard/page.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
1+
import type { Metadata } from 'next';
12
import { AnalyticsDashboard } from '@/components/dashboard/analytics-dashboard';
23
import { ErrorBoundary } from '@/components/ui/error-boundary';
4+
import { createPageMetadata } from '@/lib/page-titles';
5+
6+
export const metadata: Metadata = createPageMetadata(
7+
'Overview',
8+
'Monitor bot analytics and dashboard health at a glance.',
9+
);
310

411
export default function DashboardPage() {
512
return (

web/src/app/dashboard/performance/page.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1+
import type { Metadata } from 'next';
12
import { PerformanceDashboard } from '@/components/dashboard/performance-dashboard';
3+
import { createPageMetadata } from '@/lib/page-titles';
4+
5+
export const metadata: Metadata = createPageMetadata(
6+
'Performance',
7+
'Inspect bot uptime, latency, and resource trends.',
8+
);
29

310
export default function PerformancePage() {
411
return <PerformanceDashboard />;

web/src/app/layout.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Metadata } from 'next';
22
import { Inter, JetBrains_Mono } from 'next/font/google';
33
import { Providers } from '@/components/providers';
4+
import { APP_TITLE } from '@/lib/page-titles';
45
import './globals.css';
56

67
const inter = Inter({
@@ -14,7 +15,10 @@ const jetbrainsMono = JetBrains_Mono({
1415
});
1516

1617
export const metadata: Metadata = {
17-
title: 'Volvox Bot - AI-Powered Discord Bot',
18+
title: {
19+
default: APP_TITLE,
20+
template: `%s - ${APP_TITLE}`,
21+
},
1822
description:
1923
'The AI-powered Discord bot for modern communities. Moderation, AI chat, dynamic welcomes, spam detection, and a fully configurable web dashboard.',
2024
};

web/src/components/layout/dashboard-shell.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { DashboardTitleSync } from './dashboard-title-sync';
12
import { Header } from './header';
23
import { ServerSelector } from './server-selector';
34
import { Sidebar } from './sidebar';
@@ -14,6 +15,7 @@ interface DashboardShellProps {
1415
export function DashboardShell({ children }: DashboardShellProps) {
1516
return (
1617
<div className="flex min-h-screen flex-col">
18+
<DashboardTitleSync />
1719
<Header />
1820

1921
<div className="flex flex-1">
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
'use client';
2+
3+
import { usePathname } from 'next/navigation';
4+
import { useEffect } from 'react';
5+
import { getDashboardDocumentTitle } from '@/lib/page-titles';
6+
7+
export function DashboardTitleSync() {
8+
const pathname = usePathname();
9+
10+
useEffect(() => {
11+
document.title = getDashboardDocumentTitle(pathname);
12+
}, [pathname]);
13+
14+
return null;
15+
}

web/src/lib/page-titles.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import type { Metadata } from 'next';
2+
3+
export const APP_TITLE = 'Volvox.Bot - AI Powered Discord Bot';
4+
5+
interface DashboardTitleMatcher {
6+
matches: (pathname: string) => boolean;
7+
title: string;
8+
}
9+
10+
const dashboardTitleMatchers: DashboardTitleMatcher[] = [
11+
{
12+
matches: (pathname) => pathname.startsWith('/dashboard/members/'),
13+
title: 'Member Details',
14+
},
15+
{
16+
matches: (pathname) => pathname.startsWith('/dashboard/conversations/'),
17+
title: 'Conversation Details',
18+
},
19+
{
20+
matches: (pathname) => pathname.startsWith('/dashboard/tickets/'),
21+
title: 'Ticket Details',
22+
},
23+
{
24+
matches: (pathname) => pathname === '/dashboard',
25+
title: 'Overview',
26+
},
27+
{
28+
matches: (pathname) => pathname.startsWith('/dashboard/moderation'),
29+
title: 'Moderation',
30+
},
31+
{
32+
matches: (pathname) => pathname.startsWith('/dashboard/temp-roles'),
33+
title: 'Temp Roles',
34+
},
35+
{
36+
matches: (pathname) => pathname.startsWith('/dashboard/ai'),
37+
title: 'AI Chat',
38+
},
39+
{
40+
matches: (pathname) => pathname.startsWith('/dashboard/members'),
41+
title: 'Members',
42+
},
43+
{
44+
matches: (pathname) => pathname.startsWith('/dashboard/conversations'),
45+
title: 'Conversations',
46+
},
47+
{
48+
matches: (pathname) => pathname.startsWith('/dashboard/tickets'),
49+
title: 'Tickets',
50+
},
51+
{
52+
matches: (pathname) => pathname.startsWith('/dashboard/config'),
53+
title: 'Bot Config',
54+
},
55+
{
56+
matches: (pathname) => pathname.startsWith('/dashboard/audit-log'),
57+
title: 'Audit Log',
58+
},
59+
{
60+
matches: (pathname) => pathname.startsWith('/dashboard/performance'),
61+
title: 'Performance',
62+
},
63+
{
64+
matches: (pathname) => pathname.startsWith('/dashboard/logs'),
65+
title: 'Logs',
66+
},
67+
{
68+
matches: (pathname) => pathname.startsWith('/dashboard/settings'),
69+
title: 'Settings',
70+
},
71+
];
72+
73+
function normalizePathname(pathname: string | null | undefined): string | null {
74+
if (!pathname) {
75+
return null;
76+
}
77+
78+
const trimmedPathname =
79+
pathname.endsWith('/') && pathname !== '/' ? pathname.slice(0, -1) : pathname;
80+
return trimmedPathname || '/';
81+
}
82+
83+
export function formatDocumentTitle(pageTitle?: string | null): string {
84+
return pageTitle ? `${pageTitle} - ${APP_TITLE}` : APP_TITLE;
85+
}
86+
87+
export function getDashboardPageTitle(pathname: string | null | undefined): string | null {
88+
const normalizedPathname = normalizePathname(pathname);
89+
if (!normalizedPathname) {
90+
return null;
91+
}
92+
93+
const matchedRoute = dashboardTitleMatchers.find(({ matches }) => matches(normalizedPathname));
94+
return matchedRoute?.title ?? null;
95+
}
96+
97+
export function getDashboardDocumentTitle(pathname: string | null | undefined): string {
98+
return formatDocumentTitle(getDashboardPageTitle(pathname));
99+
}
100+
101+
export function createPageMetadata(title: string, description?: string): Metadata {
102+
if (!description) {
103+
return { title };
104+
}
105+
106+
return {
107+
title,
108+
description,
109+
};
110+
}

web/tests/components/layout/dashboard-shell.test.tsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,24 @@
1-
import { describe, it, expect, vi } from "vitest";
2-
import { render, screen } from "@testing-library/react";
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { render, screen } from '@testing-library/react';
33

44
// Mock child components — DashboardShell is now a server component
5-
vi.mock("@/components/layout/header", () => ({
5+
vi.mock('@/components/layout/header', () => ({
66
Header: () => <header data-testid="header">Header</header>,
77
}));
88

9-
vi.mock("@/components/layout/sidebar", () => ({
9+
vi.mock('@/components/layout/dashboard-title-sync', () => ({
10+
DashboardTitleSync: () => <div data-testid="dashboard-title-sync" />,
11+
}));
12+
13+
vi.mock('@/components/layout/sidebar', () => ({
1014
Sidebar: () => <nav data-testid="sidebar">Sidebar</nav>,
1115
}));
1216

13-
vi.mock("@/components/layout/server-selector", () => ({
17+
vi.mock('@/components/layout/server-selector', () => ({
1418
ServerSelector: () => <div data-testid="server-selector">Servers</div>,
1519
}));
1620

17-
import { DashboardShell } from "@/components/layout/dashboard-shell";
21+
import { DashboardShell } from '@/components/layout/dashboard-shell';
1822

1923
describe("DashboardShell", () => {
2024
it("renders header, sidebar, and content", () => {
@@ -24,6 +28,7 @@ describe("DashboardShell", () => {
2428
</DashboardShell>,
2529
);
2630
expect(screen.getByTestId("header")).toBeInTheDocument();
31+
expect(screen.getByTestId("dashboard-title-sync")).toBeInTheDocument();
2732
expect(screen.getByTestId("sidebar")).toBeInTheDocument();
2833
expect(screen.getByTestId("content")).toBeInTheDocument();
2934
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { render, waitFor } from '@testing-library/react';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
import { APP_TITLE } from '@/lib/page-titles';
4+
5+
let mockPathname = '/dashboard';
6+
7+
vi.mock('next/navigation', () => ({
8+
usePathname: () => mockPathname,
9+
}));
10+
11+
import { DashboardTitleSync } from '@/components/layout/dashboard-title-sync';
12+
13+
describe('DashboardTitleSync', () => {
14+
beforeEach(() => {
15+
mockPathname = '/dashboard';
16+
document.title = '';
17+
});
18+
19+
it('sets the dashboard title from the current route', async () => {
20+
mockPathname = '/dashboard/members';
21+
22+
render(<DashboardTitleSync />);
23+
24+
await waitFor(() => {
25+
expect(document.title).toBe('Members - Volvox.Bot - AI Powered Discord Bot');
26+
});
27+
});
28+
29+
it('updates the title when the route changes', async () => {
30+
const { rerender } = render(<DashboardTitleSync />);
31+
32+
await waitFor(() => {
33+
expect(document.title).toBe('Overview - Volvox.Bot - AI Powered Discord Bot');
34+
});
35+
36+
mockPathname = '/dashboard/tickets/42';
37+
rerender(<DashboardTitleSync />);
38+
39+
await waitFor(() => {
40+
expect(document.title).toBe('Ticket Details - Volvox.Bot - AI Powered Discord Bot');
41+
});
42+
});
43+
44+
it('falls back to the app title for unknown routes', async () => {
45+
mockPathname = '/dashboard/something-weird';
46+
47+
render(<DashboardTitleSync />);
48+
49+
await waitFor(() => {
50+
expect(document.title).toBe(APP_TITLE);
51+
});
52+
});
53+
});

0 commit comments

Comments
 (0)