From 3e682d15d2779ced47ccd4df48ab2746c27c3533 Mon Sep 17 00:00:00 2001 From: testvalue Date: Tue, 24 Mar 2026 21:49:31 -0400 Subject: [PATCH 01/14] feat(notifications): expands error store to notification store with severity and read state --- src/app/index.css | 64 +++++++++++ src/app/lib/errors.ts | 103 +++++++++++++---- tests/lib/errors.test.ts | 220 +++++++++++++++++++++++++++++++++++- tests/services/poll.test.ts | 2 + 4 files changed, 367 insertions(+), 22 deletions(-) diff --git a/src/app/index.css b/src/app/index.css index 9c0f8146..e6db9168 100644 --- a/src/app/index.css +++ b/src/app/index.css @@ -1,3 +1,67 @@ @import "tailwindcss"; @custom-variant dark (&:where(.dark, .dark *)); + +/* ── Notification animations ──────────────────────────────────────────────── */ + +@keyframes toast-slide-in { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} + +@keyframes toast-slide-out { + from { transform: translateX(0); opacity: 1; } + to { transform: translateX(100%); opacity: 0; } +} + +@keyframes drawer-slide-in { + from { transform: translateX(100%); } + to { transform: translateX(0); } +} + +@keyframes drawer-slide-out { + from { transform: translateX(0); } + to { transform: translateX(100%); } +} + +@keyframes overlay-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes overlay-fade-out { + from { opacity: 1; } + to { opacity: 0; } +} + +@utility animate-toast-in { + animation: toast-slide-in 0.3s ease-out forwards; +} + +@utility animate-toast-out { + animation: toast-slide-out 0.3s ease-in forwards; +} + +@utility animate-drawer-in { + animation: drawer-slide-in 0.3s ease-out forwards; +} + +@utility animate-drawer-out { + animation: drawer-slide-out 0.3s ease-in forwards; +} + +@utility animate-overlay-in { + animation: overlay-fade-in 0.3s ease-out forwards; +} + +@utility animate-overlay-out { + animation: overlay-fade-out 0.3s ease-in forwards; +} + +@media (prefers-reduced-motion: reduce) { + .animate-toast-in, .animate-toast-out, + .animate-drawer-in, .animate-drawer-out, + .animate-overlay-in, .animate-overlay-out { + animation: none; + } +} diff --git a/src/app/lib/errors.ts b/src/app/lib/errors.ts index 9e24ff98..97880cdd 100644 --- a/src/app/lib/errors.ts +++ b/src/app/lib/errors.ts @@ -1,44 +1,105 @@ import { createSignal } from "solid-js"; -export interface AppError { +export type NotificationSeverity = "error" | "warning" | "info"; + +export interface AppNotification { id: string; source: string; message: string; timestamp: number; retryable: boolean; + severity: NotificationSeverity; + read: boolean; } -const [errors, setErrors] = createSignal([]); +// Backward-compat alias +export type AppError = AppNotification; + +const MAX_NOTIFICATIONS = 50; + +const [notifications, setNotifications] = createSignal([]); -let errorCounter = 0; +let notificationCounter = 0; -export function getErrors(): AppError[] { - return errors(); +// Cycle tracking for poll reconciliation +let _cycleTracking: Set | null = null; + +export function startCycleTracking(): void { + _cycleTracking = new Set(); } -export function pushError(source: string, message: string, retryable = false): void { - // Intentional design: deduplicate by source to prevent banner spam from repeated - // rate limit retries or polling cycles. Trade-off: if two different errors share - // a source (e.g., "search" for both "incomplete" and "capped"), only the latest - // message survives. This is acceptable because the latest error is the most actionable. - setErrors((prev) => { - const existing = prev.find((e) => e.source === source); +export function endCycleTracking(): Set { + const result = _cycleTracking ?? new Set(); + _cycleTracking = null; + return result; +} + +export function pushNotification( + source: string, + message: string, + severity: NotificationSeverity, + retryable = false +): void { + // Record source in cycle tracking even for no-ops + if (_cycleTracking) _cycleTracking.add(source); + + setNotifications((prev) => { + const existing = prev.find((n) => n.source === source); if (existing) { - return prev.map((e) => - e.source === source - ? { ...e, message, timestamp: Date.now(), retryable } - : e - ); + if (existing.message === message) { + // Same source + same message: no-op — prevents toast spam + return prev; + } + // Same source + different message: update, reset read, update timestamp + return prev + .map((n) => + n.source === source + ? { ...n, message, severity, retryable, read: false, timestamp: Date.now() } + : n + ) + .slice(-MAX_NOTIFICATIONS); } - const id = `err-${++errorCounter}-${Date.now()}`; - return [...prev, { id, source, message, timestamp: Date.now(), retryable }]; + const id = `notif-${++notificationCounter}-${Date.now()}`; + return [...prev, { id, source, message, timestamp: Date.now(), retryable, severity, read: false }].slice( + -MAX_NOTIFICATIONS + ); }); } +export function pushError(source: string, message: string, retryable = false): void { + pushNotification(source, message, "error", retryable); +} + export function dismissError(id: string): void { - setErrors((prev) => prev.filter((e) => e.id !== id)); + setNotifications((prev) => prev.filter((n) => n.id !== id)); +} + +export function dismissNotificationBySource(source: string): void { + setNotifications((prev) => prev.filter((n) => n.source !== source)); +} + +export function markAllAsRead(): void { + setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))); +} + +export function getUnreadCount(): number { + return notifications().filter((n) => !n.read).length; +} + +export function getNotifications(): AppNotification[] { + return notifications(); +} + +// Backward-compat alias +export function getErrors(): AppNotification[] { + return notifications(); +} + +export function clearNotifications(): void { + setNotifications([]); } +// Backward-compat alias export function clearErrors(): void { - setErrors([]); + clearNotifications(); } diff --git a/tests/lib/errors.test.ts b/tests/lib/errors.test.ts index bd7df50e..605b0b34 100644 --- a/tests/lib/errors.test.ts +++ b/tests/lib/errors.test.ts @@ -1,9 +1,17 @@ import { describe, it, expect, beforeEach } from "vitest"; import { getErrors, + getNotifications, + getUnreadCount, pushError, + pushNotification, dismissError, + dismissNotificationBySource, + markAllAsRead, clearErrors, + clearNotifications, + startCycleTracking, + endCycleTracking, } from "../../src/app/lib/errors"; import { createRoot } from "solid-js"; @@ -11,6 +19,8 @@ import { createRoot } from "solid-js"; // to prevent state leaking between tests. beforeEach(() => { clearErrors(); + // Also end any stale cycle tracking + endCycleTracking(); }); describe("getErrors", () => { @@ -22,6 +32,17 @@ describe("getErrors", () => { }); }); +describe("getNotifications", () => { + it("returns same signal as getErrors", () => { + createRoot((dispose) => { + pushError("api", "test"); + expect(getNotifications()).toHaveLength(1); + expect(getErrors()).toHaveLength(1); + dispose(); + }); + }); +}); + describe("pushError", () => { it("adds an error with auto-generated id and timestamp", () => { createRoot((dispose) => { @@ -73,6 +94,94 @@ describe("pushError", () => { dispose(); }); }); + + it("creates notification with severity 'error'", () => { + createRoot((dispose) => { + pushError("api", "Error happened"); + expect(getErrors()[0].severity).toBe("error"); + dispose(); + }); + }); + + it("creates notification with read: false", () => { + createRoot((dispose) => { + pushError("api", "Error happened"); + expect(getErrors()[0].read).toBe(false); + dispose(); + }); + }); +}); + +describe("pushNotification", () => { + it("creates notification with correct severity and read: false", () => { + createRoot((dispose) => { + pushNotification("search", "Results may be incomplete", "warning"); + const notifs = getNotifications(); + expect(notifs).toHaveLength(1); + expect(notifs[0].source).toBe("search"); + expect(notifs[0].message).toBe("Results may be incomplete"); + expect(notifs[0].severity).toBe("warning"); + expect(notifs[0].read).toBe(false); + dispose(); + }); + }); + + it("supports info severity", () => { + createRoot((dispose) => { + pushNotification("graphql", "Using REST fallback", "info", true); + const notifs = getNotifications(); + expect(notifs[0].severity).toBe("info"); + expect(notifs[0].retryable).toBe(true); + dispose(); + }); + }); + + it("deduplicates by source: resets read and updates timestamp when message changes", () => { + createRoot((dispose) => { + pushNotification("search", "First message", "warning"); + const firstNotif = getNotifications()[0]; + // Mark as read to verify it gets reset + markAllAsRead(); + expect(getNotifications()[0].read).toBe(true); + + pushNotification("search", "Updated message", "warning"); + const notifs = getNotifications(); + expect(notifs).toHaveLength(1); + expect(notifs[0].message).toBe("Updated message"); + expect(notifs[0].read).toBe(false); // reset to unread + expect(notifs[0].id).toBe(firstNotif.id); // same id preserved + dispose(); + }); + }); + + it("dedup with same message: no-op — timestamp and read state unchanged", () => { + createRoot((dispose) => { + pushNotification("search", "Same message", "warning"); + markAllAsRead(); + const firstTimestamp = getNotifications()[0].timestamp; + + pushNotification("search", "Same message", "warning"); + const notifs = getNotifications(); + expect(notifs).toHaveLength(1); + expect(notifs[0].read).toBe(true); // NOT reset — same message is no-op + expect(notifs[0].timestamp).toBe(firstTimestamp); // timestamp unchanged + dispose(); + }); + }); + + it("FIFO cap: push 51 notifications, oldest is dropped", () => { + createRoot((dispose) => { + for (let i = 0; i < 51; i++) { + pushNotification(`source-${i}`, `Message ${i}`, "info"); + } + const notifs = getNotifications(); + expect(notifs).toHaveLength(50); + // Oldest (source-0) should be dropped, newest (source-50) should be present + expect(notifs.find((n) => n.source === "source-0")).toBeUndefined(); + expect(notifs.find((n) => n.source === "source-50")).toBeDefined(); + dispose(); + }); + }); }); describe("dismissError", () => { @@ -110,7 +219,60 @@ describe("dismissError", () => { }); }); -describe("clearErrors", () => { +describe("dismissNotificationBySource", () => { + it("removes all notifications with given source", () => { + createRoot((dispose) => { + pushNotification("search", "Warning 1", "warning"); + pushNotification("graphql", "Info 1", "info"); + dismissNotificationBySource("search"); + const notifs = getNotifications(); + expect(notifs).toHaveLength(1); + expect(notifs[0].source).toBe("graphql"); + dispose(); + }); + }); + + it("is a no-op for unknown source", () => { + createRoot((dispose) => { + pushNotification("search", "Warning", "warning"); + dismissNotificationBySource("nonexistent"); + expect(getNotifications()).toHaveLength(1); + dispose(); + }); + }); +}); + +describe("markAllAsRead", () => { + it("sets all read to true and getUnreadCount returns 0", () => { + createRoot((dispose) => { + pushNotification("a", "msg1", "error"); + pushNotification("b", "msg2", "warning"); + pushNotification("c", "msg3", "info"); + expect(getUnreadCount()).toBe(3); + markAllAsRead(); + expect(getUnreadCount()).toBe(0); + expect(getNotifications().every((n) => n.read)).toBe(true); + dispose(); + }); + }); +}); + +describe("getUnreadCount", () => { + it("returns count of unread notifications", () => { + createRoot((dispose) => { + pushNotification("a", "msg1", "error"); + pushNotification("b", "msg2", "warning"); + expect(getUnreadCount()).toBe(2); + markAllAsRead(); + expect(getUnreadCount()).toBe(0); + pushNotification("c", "msg3", "info"); + expect(getUnreadCount()).toBe(1); + dispose(); + }); + }); +}); + +describe("clearErrors / clearNotifications", () => { it("removes all errors", () => { createRoot((dispose) => { pushError("api", "Error 1"); @@ -128,4 +290,60 @@ describe("clearErrors", () => { dispose(); }); }); + + it("clearNotifications also clears the store", () => { + createRoot((dispose) => { + pushNotification("a", "msg", "info"); + clearNotifications(); + expect(getNotifications()).toHaveLength(0); + dispose(); + }); + }); +}); + +describe("cycle tracking", () => { + it("startCycleTracking + endCycleTracking tracks pushed sources", () => { + createRoot((dispose) => { + startCycleTracking(); + pushNotification("search", "Results incomplete", "warning"); + pushNotification("graphql", "REST fallback", "info"); + const tracked = endCycleTracking(); + expect(tracked.has("search")).toBe(true); + expect(tracked.has("graphql")).toBe(true); + dispose(); + }); + }); + + it("tracks same-message no-ops (still records source)", () => { + createRoot((dispose) => { + pushNotification("search", "Same message", "warning"); + startCycleTracking(); + // Same message push is a no-op in dedup, but source should still be tracked + pushNotification("search", "Same message", "warning"); + const tracked = endCycleTracking(); + expect(tracked.has("search")).toBe(true); + dispose(); + }); + }); + + it("endCycleTracking is safe to call twice (returns empty Set on second call)", () => { + createRoot((dispose) => { + startCycleTracking(); + pushNotification("a", "msg", "info"); + endCycleTracking(); + const second = endCycleTracking(); + expect(second.size).toBe(0); // returns empty Set when tracking already ended + dispose(); + }); + }); + + it("tracks nothing when not started", () => { + createRoot((dispose) => { + // No startCycleTracking call + pushNotification("a", "msg", "info"); + const result = endCycleTracking(); + expect(result.size).toBe(0); + dispose(); + }); + }); }); diff --git a/tests/services/poll.test.ts b/tests/services/poll.test.ts index d181ff14..1999b9e8 100644 --- a/tests/services/poll.test.ts +++ b/tests/services/poll.test.ts @@ -398,6 +398,8 @@ describe("createPollCoordinator", () => { message: "Rate limited", timestamp: Date.now(), retryable: true, + severity: "error" as const, + read: false, }; // Swap getErrors to return a pre-existing error for this test From 75447da3f49b721dd3ba5ff3eba548ebb30b7013 Mon Sep 17 00:00:00 2001 From: testvalue Date: Tue, 24 Mar 2026 21:51:53 -0400 Subject: [PATCH 02/14] feat(notifications): adds ToastContainer with auto-dismiss --- src/app/components/shared/ToastContainer.tsx | 199 ++++++++++++++++++ .../components/shared/ToastContainer.test.tsx | 131 ++++++++++++ 2 files changed, 330 insertions(+) create mode 100644 src/app/components/shared/ToastContainer.tsx create mode 100644 tests/components/shared/ToastContainer.test.tsx diff --git a/src/app/components/shared/ToastContainer.tsx b/src/app/components/shared/ToastContainer.tsx new file mode 100644 index 00000000..68b2634c --- /dev/null +++ b/src/app/components/shared/ToastContainer.tsx @@ -0,0 +1,199 @@ +import { createEffect, createSignal, For, onCleanup } from "solid-js"; +import { + getNotifications, + type AppNotification, + type NotificationSeverity, +} from "../../lib/errors"; + +// Severity configuration — shared with NotificationDrawer (imported there after C4) +export interface SeverityConfig { + path: string; + secondaryPath?: string; + iconClass: string; + borderClass: string; + bgClass: string; + textClass: string; + borderColorClass: string; +} + +export function severityConfig(severity: NotificationSeverity): SeverityConfig { + switch (severity) { + case "error": + return { + path: "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z", + iconClass: "text-red-500 dark:text-red-400", + borderClass: "border-l-red-400", + bgClass: "bg-red-50 dark:bg-red-900/30", + textClass: "text-red-800 dark:text-red-200", + borderColorClass: "border-red-200 dark:border-red-800", + }; + case "warning": + return { + path: "M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495z", + secondaryPath: + "M10 12a.75.75 0 01-.75-.75v-3.5a.75.75 0 011.5 0v3.5A.75.75 0 0110 12zm0 3a1 1 0 100-2 1 1 0 000 2z", + iconClass: "text-yellow-500 dark:text-yellow-400", + borderClass: "border-l-yellow-400", + bgClass: "bg-yellow-50 dark:bg-yellow-900/30", + textClass: "text-yellow-800 dark:text-yellow-200", + borderColorClass: "border-yellow-200 dark:border-yellow-800", + }; + case "info": + return { + path: "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z", + iconClass: "text-blue-500 dark:text-blue-400", + borderClass: "border-l-blue-400", + bgClass: "bg-blue-50 dark:bg-blue-900/30", + textClass: "text-blue-800 dark:text-blue-200", + borderColorClass: "border-blue-200 dark:border-blue-800", + }; + } +} + +// Placeholder — will be replaced with import from NotificationDrawer after C4 +// Uses a module-level mutable reference so ToastContainer.test can override it +export let getMutedSources: () => Set = () => new Set(); + +interface ToastItem { + notification: AppNotification; + dismissing: boolean; +} + +export default function ToastContainer() { + // seenTimestamps: notification ID → last-seen timestamp (detect new/updated) + const seenTimestamps = new Map(); + // lastToastedAt: source → timestamp of last toast shown (cooldown tracking) + const lastToastedAt = new Map(); + // visibleToasts: IDs of toasts currently on screen + const [visibleToasts, setVisibleToasts] = createSignal>(new Map()); + // pending timeout handles: notification ID → timeout handle + const timeouts = new Map>(); + // dismissing animation timeouts + const dismissingTimeouts = new Map>(); + + const COOLDOWN_MS = 60_000; + const animDelay = () => + window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 300; + + function removeToast(id: string) { + setVisibleToasts((prev) => { + const next = new Map(prev); + next.delete(id); + return next; + }); + } + + function startDismissAnimation(id: string) { + // Switch to dismiss animation + setVisibleToasts((prev) => { + const next = new Map(prev); + const item = next.get(id); + if (item) next.set(id, { ...item, dismissing: true }); + return next; + }); + // Remove after animation + const t = setTimeout(() => removeToast(id), animDelay()); + dismissingTimeouts.set(id, t); + } + + function dismissToast(id: string) { + // Clear auto-dismiss timeout + const autoTimeout = timeouts.get(id); + if (autoTimeout !== undefined) { + clearTimeout(autoTimeout); + timeouts.delete(id); + } + startDismissAnimation(id); + } + + function scheduleAutoDismiss(notification: AppNotification) { + const delay = notification.severity === "error" ? 10_000 : 5_000; + const t = setTimeout(() => { + timeouts.delete(notification.id); + startDismissAnimation(notification.id); + }, delay); + timeouts.set(notification.id, t); + } + + createEffect(() => { + const notifs = getNotifications(); + for (const notif of notifs) { + const lastSeen = seenTimestamps.get(notif.id); + const isNew = lastSeen === undefined; + const isUpdated = lastSeen !== undefined && notif.timestamp > lastSeen; + + if (!isNew && !isUpdated) continue; + + // Always update seenTimestamps + seenTimestamps.set(notif.id, notif.timestamp); + + // Check suppression conditions + const lastToasted = lastToastedAt.get(notif.source); + const inCooldown = lastToasted !== undefined && Date.now() - lastToasted < COOLDOWN_MS; + const muted = getMutedSources().has(notif.source); + + if (inCooldown || muted) continue; + + // Show toast + lastToastedAt.set(notif.source, Date.now()); + setVisibleToasts((prev) => { + const next = new Map(prev); + next.set(notif.id, { notification: notif, dismissing: false }); + return next; + }); + scheduleAutoDismiss(notif); + } + }); + + onCleanup(() => { + for (const t of timeouts.values()) clearTimeout(t); + for (const t of dismissingTimeouts.values()) clearTimeout(t); + }); + + return ( +
+ + {(item) => { + const cfg = severityConfig(item.notification.severity); + return ( + + ); + }} + +
+ ); +} diff --git a/tests/components/shared/ToastContainer.test.tsx b/tests/components/shared/ToastContainer.test.tsx new file mode 100644 index 00000000..6f0a9382 --- /dev/null +++ b/tests/components/shared/ToastContainer.test.tsx @@ -0,0 +1,131 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { render, screen, fireEvent } from "@solidjs/testing-library"; +import { + pushNotification, + clearNotifications, +} from "../../../src/app/lib/errors"; +import ToastContainer from "../../../src/app/components/shared/ToastContainer"; + +beforeEach(() => { + clearNotifications(); + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("ToastContainer", () => { + it("renders no toasts when notification store is empty", () => { + const { container } = render(() => ); + expect(container.querySelectorAll("[role='alert']")).toHaveLength(0); + }); + + it("renders a toast when pushNotification is called", () => { + render(() => ); + pushNotification("api", "Something failed", "error"); + expect(screen.getByRole("alert")).toBeDefined(); + expect(screen.getByRole("alert").textContent).toContain("api"); + expect(screen.getByRole("alert").textContent).toContain("Something failed"); + }); + + it("shows source and message in toast", () => { + render(() => ); + pushNotification("search", "Results incomplete", "warning"); + const alert = screen.getByRole("alert"); + expect(alert.textContent).toContain("search"); + expect(alert.textContent).toContain("Results incomplete"); + }); + + it("applies bg-red-50 class for error severity", () => { + render(() => ); + pushNotification("api", "Error happened", "error"); + const alert = screen.getByRole("alert"); + expect(alert.className).toContain("bg-red-50"); + }); + + it("applies bg-yellow-50 class for warning severity", () => { + render(() => ); + pushNotification("search", "Warning here", "warning"); + const alert = screen.getByRole("alert"); + expect(alert.className).toContain("bg-yellow-50"); + }); + + it("applies bg-blue-50 class for info severity", () => { + render(() => ); + pushNotification("graphql", "Info message", "info"); + const alert = screen.getByRole("alert"); + expect(alert.className).toContain("bg-blue-50"); + }); + + it("shows (will retry) for retryable notifications", () => { + render(() => ); + pushNotification("api", "Network error", "error", true); + expect(screen.getByRole("alert").textContent).toContain("(will retry)"); + }); + + it("does not show (will retry) for non-retryable notifications", () => { + render(() => ); + pushNotification("api", "Not found", "error", false); + expect(screen.getByRole("alert").textContent).not.toContain("(will retry)"); + }); + + it("manual dismiss starts dismiss animation and removes toast after delay", () => { + const { container } = render(() => ); + pushNotification("api", "Error", "error"); + const dismissBtn = screen.getByLabelText("Dismiss notification"); + fireEvent.click(dismissBtn); + // Should switch to animate-toast-out + const alert = container.querySelector("[role='alert']"); + expect(alert?.className).toContain("animate-toast-out"); + // After 300ms, toast should be removed + vi.advanceTimersByTime(300); + expect(container.querySelectorAll("[role='alert']")).toHaveLength(0); + }); + + it("auto-dismisses error toasts after 10 seconds", () => { + const { container } = render(() => ); + pushNotification("api", "Error", "error"); + expect(container.querySelectorAll("[role='alert']")).toHaveLength(1); + // At 9999ms, still visible + vi.advanceTimersByTime(9999); + expect(container.querySelectorAll("[role='alert']")).toHaveLength(1); + // At 10s + animation delay (300ms), should be gone + vi.advanceTimersByTime(1 + 300); + expect(container.querySelectorAll("[role='alert']")).toHaveLength(0); + }); + + it("auto-dismisses warning/info toasts after 5 seconds", () => { + const { container } = render(() => ); + pushNotification("search", "Warning", "warning"); + expect(container.querySelectorAll("[role='alert']")).toHaveLength(1); + vi.advanceTimersByTime(4999); + expect(container.querySelectorAll("[role='alert']")).toHaveLength(1); + vi.advanceTimersByTime(1 + 300); + expect(container.querySelectorAll("[role='alert']")).toHaveLength(0); + }); + + it("cooldown: no new toast within 60s for same source with different message", () => { + const { container } = render(() => ); + pushNotification("api", "First error", "error"); + expect(container.querySelectorAll("[role='alert']")).toHaveLength(1); + + // Manually dismiss so toast is gone from screen + const dismissBtn = screen.getByLabelText("Dismiss notification"); + fireEvent.click(dismissBtn); + vi.advanceTimersByTime(300); + expect(container.querySelectorAll("[role='alert']")).toHaveLength(0); + + // Push different message within 60s — should NOT show new toast (cooldown) + pushNotification("api", "Second error", "error"); + expect(container.querySelectorAll("[role='alert']")).toHaveLength(0); + + // Advance past cooldown (60s) + vi.advanceTimersByTime(60_001); + + // Push again — should show toast now + pushNotification("api", "Third error", "error"); + expect(container.querySelectorAll("[role='alert']")).toHaveLength(1); + }); +}); From fd04abe63927033b7beb01c39ba0f3f88f60e5b5 Mon Sep 17 00:00:00 2001 From: testvalue Date: Tue, 24 Mar 2026 21:53:38 -0400 Subject: [PATCH 03/14] feat(notifications): adds NotificationDrawer slide-out panel --- .../components/shared/NotificationDrawer.tsx | 198 ++++++++++++++++++ src/app/components/shared/ToastContainer.tsx | 7 +- .../shared/NotificationDrawer.test.tsx | 164 +++++++++++++++ 3 files changed, 365 insertions(+), 4 deletions(-) create mode 100644 src/app/components/shared/NotificationDrawer.tsx create mode 100644 tests/components/shared/NotificationDrawer.test.tsx diff --git a/src/app/components/shared/NotificationDrawer.tsx b/src/app/components/shared/NotificationDrawer.tsx new file mode 100644 index 00000000..bdf4a115 --- /dev/null +++ b/src/app/components/shared/NotificationDrawer.tsx @@ -0,0 +1,198 @@ +import { createEffect, createSignal, For, onCleanup, Show } from "solid-js"; +import { + getNotifications, + markAllAsRead, + clearNotifications, + dismissError, + type NotificationSeverity, +} from "../../lib/errors"; +import { relativeTime } from "../../lib/format"; +import { severityConfig } from "./ToastContainer"; + +// Module-level muted sources — session only, resets on page reload +export const mutedSources = new Set(); + +export type { NotificationSeverity }; +export { severityConfig }; + +interface NotificationDrawerProps { + open: boolean; + onClose: () => void; +} + +export default function NotificationDrawer(props: NotificationDrawerProps) { + const [visible, setVisible] = createSignal(false); + const [closing, setClosing] = createSignal(false); + let closeTimeoutHandle: ReturnType | undefined; + let closeButtonRef: HTMLButtonElement | undefined; + + const animDelay = () => + window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 300; + + createEffect(() => { + if (props.open) { + // Clear any pending close timeout on re-open + if (closeTimeoutHandle !== undefined) { + clearTimeout(closeTimeoutHandle); + closeTimeoutHandle = undefined; + } + setClosing(false); + setVisible(true); + } else { + if (visible()) { + setClosing(true); + closeTimeoutHandle = setTimeout(() => { + setVisible(false); + closeTimeoutHandle = undefined; + }, animDelay()); + } + } + }); + + onCleanup(() => { + if (closeTimeoutHandle !== undefined) clearTimeout(closeTimeoutHandle); + }); + + // Escape key handler + createEffect(() => { + if (!visible()) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") props.onClose(); + }; + document.addEventListener("keydown", handler); + onCleanup(() => document.removeEventListener("keydown", handler)); + }); + + function handleDismissAll() { + const current = getNotifications(); + for (const n of current) mutedSources.add(n.source); + clearNotifications(); + } + + return ( + + <> + {/* Overlay */} +