import { useMemo, useState } from 'react'; import { Bell, BellOff, Info, AlertTriangle, AlertOctagon, X, Trash2, SlidersHorizontal, CheckCheck, } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { SegmentedControl } from '@/components/ui/segmented-control'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { cn } from '@/lib/utils'; import type { NotificationCategory, NotificationItem } from './dashboard/types'; import type { Node } from '@/context/NodeContext'; import { CATEGORY_LABELS } from '@/lib/notificationCategories'; const NODE_FILTER_ALL = 'all' as const; const CATEGORY_FILTER_ALL = 'all' as const; type NotifFilter = 'all' | 'unread' | 'alerts'; type NodeFilter = typeof NODE_FILTER_ALL | number; type CategoryFilter = typeof CATEGORY_FILTER_ALL | NotificationCategory; const FILTER_LABEL_CLASS = 'font-mono text-[10px] uppercase tracking-[0.14em]'; const FILTER_TRIGGER_CLASS = `h-7 w-[140px] border-card-border bg-card px-2 text-stat-subtitle shadow-none focus:ring-0 ${FILTER_LABEL_CLASS}`; type LevelConfig = { icon: LucideIcon; iconClass: string; railClass: string; }; const LEVEL_CONFIG: Record = { info: { icon: Info, iconClass: 'text-brand', railClass: 'bg-brand' }, warning: { icon: AlertTriangle, iconClass: 'text-warning', railClass: 'bg-warning' }, error: { icon: AlertOctagon, iconClass: 'text-destructive', railClass: 'bg-destructive' }, }; const DAY_MS = 86_400_000; const HOUR_MS = 3_600_000; const MINUTE_MS = 60_000; type GroupLabel = 'Today' | 'Yesterday' | 'This week' | 'Earlier'; const GROUP_ORDER: GroupLabel[] = ['Today', 'Yesterday', 'This week', 'Earlier']; function startOfDay(d: Date): number { const c = new Date(d); c.setHours(0, 0, 0, 0); return c.getTime(); } function groupByDay(items: NotificationItem[]): { label: GroupLabel; items: NotificationItem[] }[] { const today = startOfDay(new Date()); const yesterday = today - DAY_MS; const weekStart = today - 6 * DAY_MS; const buckets: Record = { Today: [], Yesterday: [], 'This week': [], Earlier: [], }; for (const item of items) { const ts = item.timestamp; if (ts >= today) buckets.Today.push(item); else if (ts >= yesterday) buckets.Yesterday.push(item); else if (ts >= weekStart) buckets['This week'].push(item); else buckets.Earlier.push(item); } return GROUP_ORDER.map((label) => ({ label, items: buckets[label] })).filter( (g) => g.items.length > 0, ); } function formatRelative(ms: number): string { const diff = Date.now() - ms; if (diff < MINUTE_MS) return 'just now'; if (diff < HOUR_MS) return `${Math.round(diff / MINUTE_MS)}m ago`; if (diff < DAY_MS) return `${Math.round(diff / HOUR_MS)}h ago`; if (diff < 2 * DAY_MS) return 'yesterday'; return new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } const USER_OP_CATEGORIES = new Set([ 'deploy_success', 'stack_started', 'stack_stopped', 'stack_restarted', 'image_update_applied', ]); function isUserInitiatedSuccess(n: NotificationItem): boolean { return n.level === 'info' && n.category !== undefined && USER_OP_CATEGORIES.has(n.category) && n.actor_username != null && n.actor_username !== 'system'; } function applyFilter( items: NotificationItem[], filter: NotifFilter, nodeFilter: NodeFilter, categoryFilter: CategoryFilter, ): NotificationItem[] { let result = items.filter(n => !isUserInitiatedSuccess(n)); if (filter === 'unread') result = result.filter((n) => !n.is_read); else if (filter === 'alerts') result = result.filter((n) => n.level === 'warning' || n.level === 'error'); if (nodeFilter !== NODE_FILTER_ALL) result = result.filter((n) => n.nodeId === nodeFilter); if (categoryFilter !== CATEGORY_FILTER_ALL) result = result.filter((n) => n.category === categoryFilter); return result; } interface NotificationPanelProps { notifications: NotificationItem[]; nodes: Node[]; onMarkAllRead: () => void; onClearAll: () => void; onDelete: (notif: NotificationItem) => void; onNavigate?: (notif: NotificationItem) => void; } export function NotificationPanel({ notifications, nodes, onMarkAllRead, onClearAll, onDelete, onNavigate, }: NotificationPanelProps) { const [filter, setFilter] = useState('all'); const [nodeFilter, setNodeFilter] = useState(NODE_FILTER_ALL); const [categoryFilter, setCategoryFilter] = useState(CATEGORY_FILTER_ALL); const [open, setOpen] = useState(false); const [showFilters, setShowFilters] = useState(false); const hasActiveFilters = nodeFilter !== NODE_FILTER_ALL || categoryFilter !== CATEGORY_FILTER_ALL; const unreadCount = useMemo( () => notifications.filter((n) => !n.is_read).length, [notifications], ); const remoteNodeIds = useMemo(() => { const ids = new Set(); for (const n of nodes) if (n.type === 'remote') ids.add(n.id); return ids; }, [nodes]); const showNodeFilter = nodes.length > 1; // Derive the effective filter at render time so a removed node falls back // to "all" without needing a state-syncing effect (which the // react-hooks/set-state-in-effect rule forbids). const effectiveNodeFilter: NodeFilter = nodeFilter === NODE_FILTER_ALL || nodes.some((n) => n.id === nodeFilter) ? nodeFilter : NODE_FILTER_ALL; const filtered = useMemo( () => applyFilter(notifications, filter, effectiveNodeFilter, categoryFilter), [notifications, filter, effectiveNodeFilter, categoryFilter], ); const groups = useMemo(() => groupByDay(filtered), [filtered]); const filterOptions = useMemo( () => [ { value: 'all' as const, label: 'All' }, { value: 'unread' as const, label: 'Unread', badge: unreadCount > 0 ? unreadCount : undefined, }, { value: 'alerts' as const, label: 'Alerts' }, ], [unreadCount], ); const bellBadge = unreadCount > 0 ? (