"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useSession } from "next-auth/react"; import Link from "next/link"; import type { Route } from "next"; import { motion, useAnimationControls } from "framer-motion"; import { trpc } from "~/lib/trpc/client.js"; function relativeTime(date: Date): string { const now = Date.now(); const diff = now - date.getTime(); const seconds = Math.floor(diff / 1000); if (seconds < 60) return "just now"; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m ago`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h ago`; const days = Math.floor(hours / 24); if (days < 30) return `${days}d ago`; const months = Math.floor(days / 30); return `${months}mo ago`; } type TabKey = "all" | "tasks" | "reminders"; export function NotificationBell() { const [open, setOpen] = useState(false); const [activeTab, setActiveTab] = useState("all"); const ref = useRef(null); const bellRef = useRef(null); const dropdownRef = useRef(null); const [dropdownPos, setDropdownPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 }); const { data: session, status } = useSession(); const isAuthenticated = status === "authenticated" && !!session?.user?.email; const badgeControls = useAnimationControls(); const prevUnreadRef = useRef(null); const utils = trpc.useUtils(); const { data: unreadCount = 0 } = trpc.notification.unreadCount.useQuery(undefined, { enabled: isAuthenticated, refetchInterval: 60_000, retry: false, }); const { data: taskCounts } = trpc.notification.taskCounts.useQuery(undefined, { enabled: isAuthenticated, refetchInterval: 60_000, retry: false, }); // Bounce badge when unread count increases useEffect(() => { if (prevUnreadRef.current !== null && unreadCount > prevUnreadRef.current) { void badgeControls.start({ scale: [1, 1.3, 1], transition: { duration: 0.3, ease: "easeInOut" }, }); } prevUnreadRef.current = unreadCount; }, [unreadCount, badgeControls]); const openTaskCount = (taskCounts?.open ?? 0) + (taskCounts?.inProgress ?? 0); const { data: notifications = [] } = trpc.notification.list.useQuery( { limit: 20 }, { enabled: open && isAuthenticated && activeTab === "all", retry: false }, ); const { data: tasks = [] } = trpc.notification.listTasks.useQuery( { status: "OPEN", limit: 10 }, { enabled: open && isAuthenticated && activeTab === "tasks", retry: false }, ); const { data: reminders = [] } = trpc.notification.listReminders.useQuery( { limit: 10 }, { enabled: open && isAuthenticated && activeTab === "reminders", retry: false }, ); const markRead = trpc.notification.markRead.useMutation({ onSuccess: () => { void utils.notification.unreadCount.invalidate(); void utils.notification.list.invalidate(); }, }); const updateTaskStatus = trpc.notification.updateTaskStatus.useMutation({ onSuccess: () => { void utils.notification.taskCounts.invalidate(); void utils.notification.listTasks.invalidate(); void utils.notification.unreadCount.invalidate(); void utils.notification.list.invalidate(); }, }); // Compute dropdown position when opening const updatePosition = useCallback(() => { if (!bellRef.current) return; const rect = bellRef.current.getBoundingClientRect(); const panelHeight = 440; // approximate max height let top = rect.top; // If it would overflow the bottom, flip upward if (top + panelHeight > window.innerHeight) { top = Math.max(8, window.innerHeight - panelHeight - 8); } setDropdownPos({ top, left: rect.right + 8 }); }, []); useEffect(() => { if (open) updatePosition(); }, [open, updatePosition]); // Close dropdown on outside click useEffect(() => { if (!open) return; function handleClick(e: MouseEvent) { const target = e.target as Node; if ( ref.current && !ref.current.contains(target) && dropdownRef.current && !dropdownRef.current.contains(target) ) { setOpen(false); } } document.addEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick); }, [open]); function handleMarkAllRead() { if (!isAuthenticated) return; markRead.mutate({}); } function handleMarkOne(id: string) { if (!isAuthenticated) return; markRead.mutate({ id }); } function handleTaskAction(id: string, taskStatus: string) { updateTaskStatus.mutate({ id, status: taskStatus as "OPEN" | "IN_PROGRESS" | "DONE" | "DISMISSED" }); } const tabs: { key: TabKey; label: string }[] = [ { key: "all", label: "All" }, { key: "tasks", label: "Tasks" }, { key: "reminders", label: "Reminders" }, ]; return (
{/* Bell button */} {/* Dropdown panel — rendered via portal to escape sidebar overflow */} {open && createPortal( {/* Header */}
Notifications {unreadCount > 0 && activeTab === "all" && ( )}
{/* Tabs */}
{tabs.map((tab) => ( ))}
{/* Content */}
{activeTab === "all" && ( <> {notifications.length === 0 ? (
No notifications yet
) : ( notifications.map((n) => { const isUnread = n.readAt === null; return ( ); }) )} )} {activeTab === "tasks" && ( <> {tasks.length === 0 ? (
No open tasks
) : ( tasks.map((t) => (

{t.title}

{t.dueDate && (

Due: {new Date(t.dueDate).toLocaleDateString("en-GB")}

)}
)) )} )} {activeTab === "reminders" && ( <> {reminders.length === 0 ? (
No reminders
) : ( reminders.map((r) => (

{r.title}

{r.body && (

{r.body}

)}

{r.nextRemindAt ? new Date(r.nextRemindAt).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit", }) : relativeTime(new Date(r.createdAt))} {r.recurrence && ( {r.recurrence} )}

)) )} )}
{/* Footer */}
setOpen(false)} className="block text-center text-xs font-medium text-brand-600 dark:text-brand-400 hover:underline" > View all →
, document.body, )}
); }