Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
9 changes: 5 additions & 4 deletions web/src/app/dashboard/config/page.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
7 changes: 7 additions & 0 deletions web/src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down
7 changes: 7 additions & 0 deletions web/src/app/dashboard/performance/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <PerformanceDashboard />;
Expand Down
6 changes: 5 additions & 1 deletion web/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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.',
};
Expand Down
2 changes: 2 additions & 0 deletions web/src/components/layout/dashboard-shell.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { DashboardTitleSync } from './dashboard-title-sync';
import { Header } from './header';
import { ServerSelector } from './server-selector';
import { Sidebar } from './sidebar';
Expand All @@ -14,6 +15,7 @@ interface DashboardShellProps {
export function DashboardShell({ children }: DashboardShellProps) {
return (
<div className="flex min-h-screen flex-col">
<DashboardTitleSync />
<Header />

<div className="flex flex-1">
Expand Down
15 changes: 15 additions & 0 deletions web/src/components/layout/dashboard-title-sync.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
110 changes: 110 additions & 0 deletions web/src/lib/page-titles.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
17 changes: 11 additions & 6 deletions web/tests/components/layout/dashboard-shell.test.tsx
Original file line number Diff line number Diff line change
@@ -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 data-testid="header">Header</header>,
}));

vi.mock("@/components/layout/sidebar", () => ({
vi.mock('@/components/layout/dashboard-title-sync', () => ({
DashboardTitleSync: () => <div data-testid="dashboard-title-sync" />,
}));

vi.mock('@/components/layout/sidebar', () => ({
Sidebar: () => <nav data-testid="sidebar">Sidebar</nav>,
}));

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

import { DashboardShell } from "@/components/layout/dashboard-shell";
import { DashboardShell } from '@/components/layout/dashboard-shell';

describe("DashboardShell", () => {
it("renders header, sidebar, and content", () => {
Expand All @@ -24,6 +28,7 @@ describe("DashboardShell", () => {
</DashboardShell>,
);
expect(screen.getByTestId("header")).toBeInTheDocument();
expect(screen.getByTestId("dashboard-title-sync")).toBeInTheDocument();
expect(screen.getByTestId("sidebar")).toBeInTheDocument();
expect(screen.getByTestId("content")).toBeInTheDocument();
});
Expand Down
53 changes: 53 additions & 0 deletions web/tests/components/layout/dashboard-title-sync.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<DashboardTitleSync />);

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(<DashboardTitleSync />);

await waitFor(() => {
expect(document.title).toBe('Overview - Volvox.Bot - AI Powered Discord Bot');
});

mockPathname = '/dashboard/tickets/42';
rerender(<DashboardTitleSync />);

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(<DashboardTitleSync />);

await waitFor(() => {
expect(document.title).toBe(APP_TITLE);
});
});
});
38 changes: 38 additions & 0 deletions web/tests/lib/page-titles.test.ts
Original file line number Diff line number Diff line change
@@ -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',
});
});
});
Loading