moadim 1.3.0

Loop engine for AI agents — routines over REST, MCP, and a built-in web UI
import { useCallback, useEffect, 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 { useToasts } from "../../shell/toasts";
import { AttentionTable } from "./AttentionTable";
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`'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);

  // 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);
  }, []);

  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>
      <RecentRunsTable runs={runsQuery.data ?? []} loading={runsQuery.isLoading} />
    </div>
  );
}