moadim 1.7.4

Loop engine for AI agents — routines over REST, MCP, and a built-in web UI
import { useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { reltime } from "../lib/cronUtils";
import { unreadCount, type NotificationEntry } from "../lib/notificationLog";

export interface NotificationCenterProps {
  entries: NotificationEntry[];
  onMarkAllRead: () => void;
  onClear: () => void;
}

/**
 * Header bell icon + unread badge + dropdown inbox of recent fleet-wide run failures — the
 * persistent, revisitable counterpart to `NotifyToggle`'s transient desktop notification.
 * Closes on outside click or Escape, matching the shell's other dismissible panels.
 */
export function NotificationCenter({ entries, onMarkAllRead, onClear }: NotificationCenterProps) {
  const [open, setOpen] = useState(false);
  const rootRef = useRef<HTMLDivElement>(null);
  const unread = unreadCount(entries);

  useEffect(() => {
    if (!open) return;
    const onDocMouseDown = (e: MouseEvent) => {
      if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
    };
    const onKeyDown = (e: KeyboardEvent) => {
      if (e.key === "Escape") setOpen(false);
    };
    document.addEventListener("mousedown", onDocMouseDown);
    document.addEventListener("keydown", onKeyDown);
    return () => {
      document.removeEventListener("mousedown", onDocMouseDown);
      document.removeEventListener("keydown", onKeyDown);
    };
  }, [open]);

  return (
    <div className="notif-root" ref={rootRef}>
      <button
        type="button"
        className="icon-btn notif-bell"
        title={unread > 0 ? `${unread} unread failure${unread === 1 ? "" : "s"}` : "Notifications"}
        aria-label={unread > 0 ? `${unread} unread failure${unread === 1 ? "" : "s"}` : "Notifications"}
        aria-haspopup="true"
        aria-expanded={open}
        onClick={() => setOpen((o) => !o)}
      >
        🔔
        {unread > 0 && <span className="notif-badge">{unread > 99 ? "99+" : unread}</span>}
      </button>
      {open && (
        <div className="notif-panel" role="menu">
          <div className="notif-panel-hd">
            <span className="notif-panel-title">NOTIFICATIONS</span>
            <div className="notif-panel-acts">
              {unread > 0 && (
                <button type="button" className="btn btn-ghost btn-sm" onClick={onMarkAllRead}>
                  MARK READ
                </button>
              )}
              {entries.length > 0 && (
                <button type="button" className="btn btn-ghost btn-sm" onClick={onClear}>
                  CLEAR
                </button>
              )}
            </div>
          </div>
          {entries.length === 0 ? (
            <div className="notif-empty">No notifications yet</div>
          ) : (
            <ul className="notif-list">
              {entries.map((e) => (
                <li key={e.id} className={e.read ? "notif-item" : "notif-item unread"}>
                  <Link to={`/routines?history=${encodeURIComponent(e.routineId)}`} onClick={() => setOpen(false)}>
                    <span className="notif-item-title">{e.routineTitle}</span>
                    <span className="notif-item-msg">{e.message}</span>
                    <span className="notif-item-time">{reltime(e.atSecs)}</span>
                  </Link>
                </li>
              ))}
            </ul>
          )}
        </div>
      )}
    </div>
  );
}