feat: notification center frontend core (PR-3)

- Bell icon in icon registry (Phosphor Bell)
- NotificationProvider context with polling (15s unread / 30s list)
- useNotifications() hook with optimistic updates
- NotificationCenter component: bell + badge + dropdown panel
- NotificationItem component: severity icon, title, relative time, actions
- AppShell integration: mount in header-actions, hidden on mobile
- CSS styles: dropdown, items, unread/read states, empty state
- formatRelativeTime utility (custom, no new deps)
- 25 frontend tests (9 hook + 6 item + 10 center)

Quality gates: vitest 25 passed, tsc clean, eslint clean
This commit is contained in:
2026-05-29 13:14:50 +02:00
parent cbd3436ff7
commit 2bec205a30
15 changed files with 1974 additions and 402 deletions
+79 -72
View File
@@ -9,7 +9,9 @@ import { useSessions } from "../state/sessions";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { EventProvider } from "../state/events";
import { ToastProvider } from "../state/toast";
import { NotificationProvider } from "../state/notifications";
import { EventToastBridge } from "./event-toast-bridge";
import { NotificationCenter } from "./notification-center";
import { Icon } from "./icon";
import { MobileNav } from "./mobile-nav";
import type { IconName } from "../utils/icons";
@@ -79,10 +81,12 @@ export const AppShell = () => {
return (
<EventProvider>
<ToastProvider>
<EventToastBridge />
<div className="shell mobile-terminal-shell">
<Outlet />
</div>
<NotificationProvider>
<EventToastBridge />
<div className="shell mobile-terminal-shell">
<Outlet />
</div>
</NotificationProvider>
</ToastProvider>
</EventProvider>
);
@@ -91,79 +95,82 @@ export const AppShell = () => {
return (
<EventProvider>
<ToastProvider>
<EventToastBridge />
<div className="shell">
<header className="shell-header">
<Link className="brand" to="/">
Headquarter
</Link>
<div className="header-actions">
<Link className="user-chip" to="/profile">
{user?.name ?? "User"}
<NotificationProvider>
<EventToastBridge />
<div className="shell">
<header className="shell-header">
<Link className="brand" to="/">
Headquarter
</Link>
<button
className="ghost-button"
onClick={() => {
void logout();
}}
type="button"
>
<Icon name="logout" size="sm" />
Logout
</button>
<div className="header-actions">
<NotificationCenter isMobileTerminal={isMobileTerminal} />
<Link className="user-chip" to="/profile">
{user?.name ?? "User"}
</Link>
<button
className="ghost-button"
onClick={() => {
void logout();
}}
type="button"
>
<Icon name="logout" size="sm" />
Logout
</button>
</div>
</header>
<div className="shell-body">
{!isMobile && (
<aside className="shell-nav" aria-label="Primary navigation">
{NAV_ITEMS.map((item) => {
const activeCount = sessions.filter(
(s) => s.status === "running",
).length;
return (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
isActive ? "nav-item nav-item-active" : "nav-item"
}
end={item.to === "/"}
>
<Icon name={item.icon} size="sm" />
{item.label}
{item.badge === "sessions" && activeCount > 0 && (
<span className="nav-badge">{activeCount}</span>
)}
</NavLink>
);
})}
{sessions.length > 0 && (
<>
<div className="nav-divider" />
<div className="nav-section-title">Live sessions</div>
{sessions.map((session) => (
<SessionItem key={session.id} session={session} />
))}
</>
)}
</aside>
)}
<main className={`shell-content ${isMobile ? "mobile" : ""}`}>
<Outlet />
</main>
</div>
</header>
<div className="shell-body">
{!isMobile && (
<aside className="shell-nav" aria-label="Primary navigation">
{NAV_ITEMS.map((item) => {
const activeCount = sessions.filter(
(s) => s.status === "running",
).length;
return (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
isActive ? "nav-item nav-item-active" : "nav-item"
}
end={item.to === "/"}
>
<Icon name={item.icon} size="sm" />
{item.label}
{item.badge === "sessions" && activeCount > 0 && (
<span className="nav-badge">{activeCount}</span>
)}
</NavLink>
);
})}
{sessions.length > 0 && (
<>
<div className="nav-divider" />
<div className="nav-section-title">Live sessions</div>
{sessions.map((session) => (
<SessionItem key={session.id} session={session} />
))}
</>
)}
</aside>
{isMobile && (
<MobileNav
sessionCount={
sessions.filter((s) => s.status === "running").length
}
/>
)}
<main className={`shell-content ${isMobile ? "mobile" : ""}`}>
<Outlet />
</main>
</div>
{isMobile && (
<MobileNav
sessionCount={
sessions.filter((s) => s.status === "running").length
}
/>
)}
</div>
</NotificationProvider>
</ToastProvider>
</EventProvider>
);
+157 -148
View File
@@ -1,167 +1,176 @@
import React from "react";
import {
House,
Folder,
GitBranch,
Gear,
User,
SignOut,
Plus,
PencilSimple,
Trash,
FloppyDisk,
X,
ArrowsClockwise,
Copy,
MagnifyingGlass,
List,
Check,
Warning,
Info,
Spinner,
GitCommit,
GitMerge,
ClockCounterClockwise,
ArrowDown,
ArrowUp,
File,
FileText,
Image,
Binary,
Code,
ArrowSquareOut,
Play,
Stop,
Terminal,
ArrowLeft,
DotsSixVertical,
House,
Folder,
GitBranch,
Gear,
User,
SignOut,
Plus,
PencilSimple,
Trash,
FloppyDisk,
X,
ArrowsClockwise,
Copy,
MagnifyingGlass,
List,
Check,
Warning,
Info,
Spinner,
GitCommit,
GitMerge,
ClockCounterClockwise,
ArrowDown,
ArrowUp,
File,
FileText,
Image,
Binary,
Code,
ArrowSquareOut,
Play,
Stop,
Terminal,
ArrowLeft,
DotsSixVertical,
Bell,
} from "@phosphor-icons/react";
export type IconName =
| "dashboard"
| "projects"
| "repositories"
| "settings"
| "profile"
| "logout"
| "add"
| "edit"
| "delete"
| "save"
| "cancel"
| "refresh"
| "copy"
| "search"
| "menu"
| "close"
| "success"
| "error"
| "warning"
| "info"
| "loading"
| "branch"
| "commit"
| "merge"
| "history"
| "pull"
| "push"
| "fetch"
| "file"
| "folder"
| "code"
| "document"
| "image"
| "binary"
| "external"
| "play"
| "stop"
| "terminal"
| "arrow-left"
| "drag";
| "dashboard"
| "projects"
| "repositories"
| "settings"
| "profile"
| "logout"
| "add"
| "edit"
| "delete"
| "save"
| "cancel"
| "refresh"
| "copy"
| "search"
| "menu"
| "close"
| "success"
| "error"
| "warning"
| "info"
| "loading"
| "branch"
| "commit"
| "merge"
| "history"
| "pull"
| "push"
| "fetch"
| "file"
| "folder"
| "code"
| "document"
| "image"
| "binary"
| "external"
| "play"
| "stop"
| "terminal"
| "arrow-left"
| "drag"
| "bell";
const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone" }>> = {
dashboard: House,
projects: Folder,
repositories: GitBranch,
settings: Gear,
profile: User,
logout: SignOut,
add: Plus,
edit: PencilSimple,
delete: Trash,
save: FloppyDisk,
cancel: X,
refresh: ArrowsClockwise,
copy: Copy,
search: MagnifyingGlass,
menu: List,
close: X,
success: Check,
error: X,
warning: Warning,
info: Info,
loading: Spinner,
branch: GitBranch,
commit: GitCommit,
merge: GitMerge,
history: ClockCounterClockwise,
pull: ArrowDown,
push: ArrowUp,
fetch: ArrowsClockwise,
file: File,
folder: Folder,
code: Code,
document: FileText,
image: Image,
binary: Binary,
external: ArrowSquareOut,
play: Play,
stop: Stop,
terminal: Terminal,
"arrow-left": ArrowLeft,
drag: DotsSixVertical,
const iconMap: Record<
IconName,
React.ComponentType<{
size?: number | string;
weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone";
}>
> = {
dashboard: House,
projects: Folder,
repositories: GitBranch,
settings: Gear,
profile: User,
logout: SignOut,
add: Plus,
edit: PencilSimple,
delete: Trash,
save: FloppyDisk,
cancel: X,
refresh: ArrowsClockwise,
copy: Copy,
search: MagnifyingGlass,
menu: List,
close: X,
success: Check,
error: X,
warning: Warning,
info: Info,
loading: Spinner,
branch: GitBranch,
commit: GitCommit,
merge: GitMerge,
history: ClockCounterClockwise,
pull: ArrowDown,
push: ArrowUp,
fetch: ArrowsClockwise,
file: File,
folder: Folder,
code: Code,
document: FileText,
image: Image,
binary: Binary,
external: ArrowSquareOut,
play: Play,
stop: Stop,
terminal: Terminal,
"arrow-left": ArrowLeft,
drag: DotsSixVertical,
bell: Bell,
};
export interface IconProps {
name: IconName;
size?: "sm" | "md" | "lg" | "xl";
color?: string;
weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone";
className?: string;
ariaLabel?: string;
name: IconName;
size?: "sm" | "md" | "lg" | "xl";
color?: string;
weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone";
className?: string;
ariaLabel?: string;
}
const sizeMap: Record<NonNullable<IconProps["size"]>, number> = {
sm: 16,
md: 20,
lg: 24,
xl: 32,
sm: 16,
md: 20,
lg: 24,
xl: 32,
};
export const Icon: React.FC<IconProps> = ({
name,
size = "md",
color,
weight = "regular",
className,
ariaLabel,
name,
size = "md",
color,
weight = "regular",
className,
ariaLabel,
}) => {
const IconComponent = iconMap[name];
const sizeValue = sizeMap[size];
const IconComponent = iconMap[name];
const sizeValue = sizeMap[size];
if (!IconComponent) {
return null;
}
if (!IconComponent) {
return null;
}
return (
<span
className={`icon icon-${size}${className ? ` ${className}` : ""}`}
style={{ color }}
aria-label={ariaLabel}
aria-hidden={!ariaLabel}
role="img"
>
<IconComponent size={sizeValue} weight={weight} />
</span>
);
return (
<span
className={`icon icon-${size}${className ? ` ${className}` : ""}`}
style={{ color }}
aria-label={ariaLabel}
aria-hidden={!ariaLabel}
role="img"
>
<IconComponent size={sizeValue} weight={weight} />
</span>
);
};
@@ -0,0 +1,156 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { NotificationCenter } from "./notification-center";
import { NotificationProvider } from "../state/notifications";
vi.mock("../api/notifications", () => ({
getNotifications: vi.fn(),
getUnreadCount: vi.fn(),
markNotificationRead: vi.fn(),
markAllNotificationsRead: vi.fn(),
dismissNotification: vi.fn(),
}));
import { getNotifications, getUnreadCount } from "../api/notifications";
const mockedGetNotifications = vi.mocked(getNotifications);
const mockedGetUnreadCount = vi.mocked(getUnreadCount);
const makeNotification = (id: string, overrides?: Record<string, unknown>) => ({
id,
user_id: "user-1",
category: "instance",
severity: "info" as const,
title: `Notification ${id}`,
message: null,
source_type: null,
source_id: null,
metadata: {},
read_at: null,
dismissed_at: null,
created_at: "2026-05-29T10:00:00Z",
...overrides,
});
function wrapper({ children }: { children: React.ReactNode }) {
return <NotificationProvider>{children}</NotificationProvider>;
}
describe("NotificationCenter", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
mockedGetNotifications.mockResolvedValue({
items: [],
total: 0,
limit: 20,
offset: 0,
});
mockedGetUnreadCount.mockResolvedValue(0);
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
cleanup();
});
it("renders bell icon", () => {
render(<NotificationCenter />, { wrapper });
expect(
screen.getByRole("button", { name: /notifications/i }),
).toBeInTheDocument();
});
it("shows badge when unread count > 0", async () => {
mockedGetUnreadCount.mockResolvedValue(3);
render(<NotificationCenter />, { wrapper });
await vi.advanceTimersByTimeAsync(100);
expect(screen.getByText("3")).toBeInTheDocument();
});
it("hides badge when unread count is 0", () => {
render(<NotificationCenter />, { wrapper });
expect(screen.queryByText("0")).not.toBeInTheDocument();
});
it("opens dropdown on bell click", () => {
render(<NotificationCenter />, { wrapper });
fireEvent.click(screen.getByRole("button", { name: /notifications/i }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
it("closes dropdown on outside click", () => {
render(
<div>
<div data-testid="outside">Outside</div>
<NotificationCenter />
</div>,
{ wrapper },
);
fireEvent.click(screen.getByRole("button", { name: /notifications/i }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
fireEvent.mouseDown(screen.getByTestId("outside"));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("closes dropdown on escape", () => {
render(<NotificationCenter />, { wrapper });
fireEvent.click(screen.getByRole("button", { name: /notifications/i }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("renders empty state when no notifications", () => {
render(<NotificationCenter />, { wrapper });
fireEvent.click(screen.getByRole("button", { name: /notifications/i }));
expect(screen.getByText("No notifications")).toBeInTheDocument();
});
it("renders notification items", async () => {
mockedGetNotifications.mockResolvedValue({
items: [makeNotification("1"), makeNotification("2")],
total: 2,
limit: 20,
offset: 0,
});
render(<NotificationCenter />, { wrapper });
fireEvent.click(screen.getByRole("button", { name: /notifications/i }));
await vi.advanceTimersByTimeAsync(100);
expect(screen.getByText("Notification 1")).toBeInTheDocument();
expect(screen.getByText("Notification 2")).toBeInTheDocument();
});
it("calls markAllRead on footer button click", async () => {
mockedGetNotifications.mockResolvedValue({
items: [makeNotification("1")],
total: 1,
limit: 20,
offset: 0,
});
render(<NotificationCenter />, { wrapper });
fireEvent.click(screen.getByRole("button", { name: /notifications/i }));
await vi.advanceTimersByTimeAsync(100);
fireEvent.click(screen.getByRole("button", { name: /mark all as read/i }));
const { markAllNotificationsRead: mockMarkAll } = await import(
"../api/notifications"
);
expect(vi.mocked(mockMarkAll)).toHaveBeenCalled();
});
it("refreshes list immediately on open", async () => {
render(<NotificationCenter />, { wrapper });
fireEvent.click(screen.getByRole("button", { name: /notifications/i }));
await vi.advanceTimersByTimeAsync(100);
expect(mockedGetNotifications).toHaveBeenCalled();
});
});
@@ -0,0 +1,124 @@
import { useEffect, useRef } from "react";
import { useNotifications } from "../hooks/use-notifications";
import { NotificationItem } from "./notification-item";
import { Icon } from "./icon";
interface NotificationCenterProps {
isMobileTerminal?: boolean;
}
export function NotificationCenter({
isMobileTerminal = false,
}: NotificationCenterProps) {
const {
notifications,
unreadCount,
markRead,
markAllRead,
dismiss,
refreshList,
isDropdownOpen,
setIsDropdownOpen,
} = useNotifications();
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isDropdownOpen) return;
const handleMouseDown = (e: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target as Node)
) {
setIsDropdownOpen(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setIsDropdownOpen(false);
}
};
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [isDropdownOpen, setIsDropdownOpen]);
useEffect(() => {
if (isDropdownOpen) {
void refreshList();
}
}, [isDropdownOpen, refreshList]);
if (isMobileTerminal) {
return null;
}
const badgeText = unreadCount > 99 ? "99+" : String(unreadCount);
return (
<div className="notification-center">
<button
type="button"
className="notification-bell"
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
aria-label="Notifications"
aria-haspopup="dialog"
aria-expanded={isDropdownOpen}
>
<Icon name="bell" size="md" />
{unreadCount > 0 && (
<span className="nav-badge notification-badge">{badgeText}</span>
)}
</button>
{isDropdownOpen && (
<div
ref={dropdownRef}
role="dialog"
aria-label="Notifications"
className="notification-dropdown"
>
<div className="notification-dropdown-header">
<span>Notifications</span>
</div>
<ul className="notification-list">
{notifications.length === 0 ? (
<li className="notification-empty">No notifications</li>
) : (
notifications.map((n) => (
<NotificationItem
key={n.id}
notification={n}
onMarkRead={markRead}
onDismiss={dismiss}
/>
))
)}
</ul>
{notifications.length > 0 && (
<div className="notification-dropdown-footer">
<button
type="button"
className="notification-mark-all"
onClick={() => {
void markAllRead();
}}
>
Mark all as read
</button>
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,104 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { NotificationItem } from "./notification-item";
afterEach(() => {
cleanup();
});
const makeNotification = (overrides?: Record<string, unknown>) => ({
id: "1",
user_id: "user-1",
category: "instance",
severity: "info" as const,
title: "Container started",
message: null,
source_type: null,
source_id: null,
metadata: {},
read_at: null,
dismissed_at: null,
created_at: "2026-05-29T10:00:00Z",
...overrides,
});
describe("NotificationItem", () => {
it("displays title and relative time", () => {
render(
<NotificationItem
notification={makeNotification()}
onMarkRead={vi.fn()}
onDismiss={vi.fn()}
/>,
);
expect(screen.getByText("Container started")).toBeInTheDocument();
expect(screen.getByText(/ago|just now/)).toBeInTheDocument();
});
it("applies unread styling when read_at is null", () => {
render(
<NotificationItem
notification={makeNotification({ read_at: null })}
onMarkRead={vi.fn()}
onDismiss={vi.fn()}
/>,
);
const row = screen.getByRole("listitem");
expect(row.className).toContain("notification-item--unread");
});
it("applies read styling when read_at is set", () => {
render(
<NotificationItem
notification={makeNotification({ read_at: "2026-05-29T10:01:00Z" })}
onMarkRead={vi.fn()}
onDismiss={vi.fn()}
/>,
);
const row = screen.getByRole("listitem");
expect(row.className).toContain("notification-item--read");
});
it("calls onMarkRead when mark read clicked", () => {
const onMarkRead = vi.fn();
render(
<NotificationItem
notification={makeNotification()}
onMarkRead={onMarkRead}
onDismiss={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /mark read/i }));
expect(onMarkRead).toHaveBeenCalledWith("1");
});
it("calls onDismiss when dismiss clicked", () => {
const onDismiss = vi.fn();
render(
<NotificationItem
notification={makeNotification()}
onMarkRead={vi.fn()}
onDismiss={onDismiss}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /dismiss/i }));
expect(onDismiss).toHaveBeenCalledWith("1");
});
it("displays severity icon", () => {
render(
<NotificationItem
notification={makeNotification({ severity: "error" })}
onMarkRead={vi.fn()}
onDismiss={vi.fn()}
/>,
);
expect(screen.getByRole("img", { hidden: true })).toBeInTheDocument();
});
});
@@ -0,0 +1,64 @@
import { Icon } from "./icon";
import { formatRelativeTime } from "../utils/time";
import type { NotificationItem as NotificationItemType } from "../api/notifications";
export interface NotificationItemProps {
notification: NotificationItemType;
onMarkRead: (id: string) => void;
onDismiss: (id: string) => void;
}
import type { IconName } from "../utils/icons";
const severityIconMap: Record<string, IconName> = {
info: "info",
warning: "warning",
error: "error",
success: "success",
};
export function NotificationItem({
notification,
onMarkRead,
onDismiss,
}: NotificationItemProps) {
const isUnread = notification.read_at === null;
const iconName = severityIconMap[notification.severity] ?? "info";
return (
<li
role="listitem"
className={`notification-item ${isUnread ? "notification-item--unread" : "notification-item--read"}`}
>
<div className="notification-item-icon">
<Icon name={iconName} size="md" />
</div>
<div className="notification-item-content">
<div className="notification-item-title">{notification.title}</div>
<div className="notification-item-time">
{formatRelativeTime(notification.created_at)}
</div>
</div>
<div className="notification-item-actions">
{isUnread && (
<button
type="button"
className="notification-item-action"
onClick={() => onMarkRead(notification.id)}
aria-label="Mark read"
>
Mark read
</button>
)}
<button
type="button"
className="notification-item-action"
onClick={() => onDismiss(notification.id)}
aria-label="Dismiss"
>
Dismiss
</button>
</div>
</li>
);
}