moadim 1.7.4

Loop engine for AI agents — routines over REST, MCP, and a built-in web UI
import { useCallback, useEffect, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useAllRuns, useLockStatus, useRoutines, useTriggerRoutine, useUnlock } from "../../api/hooks";
import { GlobalLockBanner } from "../../components/GlobalLockBanner";
import { loadRefreshToken, refreshMs, RefreshControl, saveRefreshToken, type RefreshToken } from "../../components/RefreshControl";
import {
  fireFailureNotification,
  freshFailures,
  loadNotifyFailures,
  requestNotifyPermission,
  saveNotifyFailures,
  snapshotRunStatuses,
  type RunStatusSnapshot,
} from "../../lib/failureNotify";
import { useToasts } from "../../shell/toasts";
import { AttentionTable } from "./AttentionTable";
import { NotifyToggle } from "./NotifyToggle";
import { attentionItems, computeKpis, nextRunSummary, sourcesOf, upcomingRuns } from "./overviewLogic";
import { OverviewStats } from "./OverviewStats";
import { RecentRunsTable } from "./RecentRunsTable";
import { UpcomingTable } from "./UpcomingTable";

/** How many of the most recent runs across the fleet the overview panel shows. */
const RECENT_RUNS_LIMIT = 8;

/** How often the live "now" advances so countdowns re-render between fetches. */
const TICK_MS = 10_000;

/**
 * The OVERVIEW landing page: a single-pane operations summary that
 * aggregates routines into KPI tiles and one merged "upcoming runs"
 * schedule. Direct port of `ui/src/overview.rs (removed)`'s `OverviewPage` shell — all
 * KPI/merge/attention math lives in the host-tested `overviewLogic.ts`; this
 * component is a thin shell that maps fetched records into `SchedSource`s
 * and renders the result.
 */
export function OverviewPage() {
  const queryClient = useQueryClient();
  const { addToast } = useToasts();

  const routinesQuery = useRoutines();
  const runsQuery = useAllRuns(RECENT_RUNS_LIMIT);
  const lockQuery = useLockStatus();
  const unlock = useUnlock();
  const trigger = useTriggerRoutine();

  const [now, setNow] = useState(() => new Date());
  const [refreshToken, setRefreshToken] = useState<RefreshToken>(loadRefreshToken);
  const [notifyEnabled, setNotifyEnabled] = useState(loadNotifyFailures);
  const prevRunStatuses = useRef<RunStatusSnapshot | null>(null);

  // Advance "now" so countdowns re-render between fetches.
  useEffect(() => {
    const id = setInterval(() => setNow(new Date()), TICK_MS);
    return () => clearInterval(id);
  }, []);

  // Auto-refresh loop, re-armed when the interval changes. A single
  // invalidation covers routines, lock status, and recent runs — all three
  // hooks share the "routines" query-key prefix.
  useEffect(() => {
    const ms = refreshMs(refreshToken);
    if (ms === undefined) return;
    const id = setInterval(() => {
      void queryClient.invalidateQueries({ queryKey: ["routines"] });
    }, ms);
    return () => clearInterval(id);
  }, [refreshToken, queryClient]);

  const handleSetRefreshToken = useCallback((token: RefreshToken) => {
    saveRefreshToken(token);
    setRefreshToken(token);
  }, []);

  // Watches each poll of the fleet's recent runs for fresh failures and fires a desktop
  // notification per run. Seeds a baseline snapshot on the first tick after enabling so
  // failures already in view don't spam on toggle-on — only later transitions surface.
  useEffect(() => {
    if (!notifyEnabled) {
      prevRunStatuses.current = null;
      return;
    }
    const runsData = runsQuery.data;
    if (runsData === undefined) return;
    if (prevRunStatuses.current === null) {
      prevRunStatuses.current = snapshotRunStatuses(runsData);
      return;
    }
    for (const failed of freshFailures(runsData, prevRunStatuses.current)) {
      fireFailureNotification(failed);
    }
    prevRunStatuses.current = snapshotRunStatuses(runsData);
  }, [runsQuery.data, notifyEnabled]);

  const handleToggleNotify = useCallback(
    (next: boolean) => {
      if (!next) {
        saveNotifyFailures(false);
        setNotifyEnabled(false);
        return;
      }
      void requestNotifyPermission().then((perm) => {
        if (perm === "granted") {
          saveNotifyFailures(true);
          setNotifyEnabled(true);
        } else {
          addToast(
            perm === "unsupported"
              ? "Desktop notifications aren't supported in this browser"
              : "Notification permission was denied",
            "err",
          );
        }
      });
    },
    [addToast],
  );

  const handleTrigger = useCallback(
    (id: string) => {
      trigger.mutate(id, {
        onSuccess: () => addToast("Triggered", "ok"),
        onError: (err) => addToast(`Trigger failed: ${err instanceof Error ? err.message : String(err)}`, "err"),
      });
    },
    [trigger, addToast],
  );

  const handleUnlock = useCallback(() => {
    unlock.mutate("all");
  }, [unlock]);

  const sources = sourcesOf(routinesQuery.data ?? [], now);
  const kpis = computeKpis(sources, now);
  const attention = attentionItems(sources, now);
  const runs = upcomingRuns(sources, now);
  const nextRun = nextRunSummary(runs, now);
  const updatedAtMs = Math.max(routinesQuery.dataUpdatedAt, runsQuery.dataUpdatedAt, lockQuery.dataUpdatedAt);
  const loadError = routinesQuery.error instanceof Error ? routinesQuery.error.message : undefined;

  return (
    <div>
      <h1 className="page-title">Overview</h1>
      <GlobalLockBanner status={lockQuery.data} onUnlock={handleUnlock} />
      <OverviewStats kpis={kpis} nextRun={nextRun} />
      {attention.length > 0 && (
        <>
          <div className="section-hd">
            <span className="section-label attn">NEEDS ATTENTION</span>
          </div>
          <AttentionTable items={attention} />
        </>
      )}
      <div className="section-hd">
        <span className="section-label">UPCOMING RUNS</span>
        <div className="section-acts">
          <RefreshControl token={refreshToken} updatedAtMs={updatedAtMs} onChange={handleSetRefreshToken} />
        </div>
      </div>
      <UpcomingTable runs={runs} now={now} loading={routinesQuery.isLoading} error={loadError} onTrigger={handleTrigger} />
      <div className="section-hd">
        <span className="section-label">RECENT RUNS</span>
        <div className="section-acts">
          <NotifyToggle enabled={notifyEnabled} onToggle={handleToggleNotify} />
        </div>
      </div>
      <RecentRunsTable runs={runsQuery.data ?? []} loading={runsQuery.isLoading} />
    </div>
  );
}