moadim 3.2.2

Loop engine for AI agents — routines over REST, MCP, and a built-in web UI
import { useMemo, useState, type CSSProperties } from "react";
import type { RoutineResponse } from "../../api/hooks";
import { scheduleList } from "../../lib/schedule";
import { healthBadge, healthBadgeClass, healthTooltip, triggerButtonTitle } from "./filter";
import { routineHealth } from "./filter";
import { NextRunCell } from "./RoutineRow";

interface TreeRoutineActions {
  onEdit: (id: string) => void;
  onClone: (id: string) => void;
  onDelete: (id: string, title: string) => void;
  onMove: (id: string) => void;
  onToggle: (id: string, enabled: boolean) => void;
  onTrigger: (id: string) => void;
  onLogs: (id: string) => void;
  onHistory: (id: string) => void;
  onFlags: (id: string) => void;
}

export interface RoutineFilesystemTreeProps extends TreeRoutineActions {
  routines: RoutineResponse[];
  loading: boolean;
  filterActive: boolean;
  now: Date;
  onClearFilters: () => void;
}

interface FolderNode {
  name: string;
  path: string;
  folders: Map<string, FolderNode>;
  routines: RoutineResponse[];
}

function routinePath(routine: RoutineResponse): string {
  const slug = routine.slug?.trim() || routine.title.trim() || routine.id;
  const folder = routine.folder?.trim();
  return folder ? `${folder}/${slug}` : slug;
}

function buildTree(routines: readonly RoutineResponse[]): FolderNode {
  const root: FolderNode = { name: "", path: "", folders: new Map(), routines: [] };
  for (const routine of routines) {
    const parts = (routine.folder ?? "").split("/").map((x) => x.trim()).filter(Boolean);
    let node = root;
    for (const part of parts) {
      const path = node.path === "" ? part : `${node.path}/${part}`;
      let child = node.folders.get(part);
      if (child === undefined) {
        child = { name: part, path, folders: new Map(), routines: [] };
        node.folders.set(part, child);
      }
      node = child;
    }
    node.routines.push(routine);
  }
  sortNode(root);
  return root;
}

function sortNode(node: FolderNode) {
  node.routines.sort((a, b) => routinePath(a).localeCompare(routinePath(b)) || a.id.localeCompare(b.id));
  for (const child of node.folders.values()) sortNode(child);
}

function childFolders(node: FolderNode): FolderNode[] {
  return [...node.folders.values()].sort((a, b) => a.name.localeCompare(b.name));
}

function depthStyle(depth: number): CSSProperties {
  return { "--depth": depth } as CSSProperties;
}

export function RoutineFilesystemTree({ routines, loading, filterActive, now, onClearFilters, ...actions }: RoutineFilesystemTreeProps) {
  const root = useMemo(() => buildTree(routines), [routines]);
  const [collapsed, setCollapsed] = useState<Set<string>>(new Set());

  const toggleFolder = (path: string) => {
    setCollapsed((prev) => {
      const next = new Set(prev);
      if (!next.delete(path)) next.add(path);
      return next;
    });
  };

  if (loading) {
    return (
      <div className="routine-fs-wrap">
        <div className="empty"><div className="spinner" /></div>
      </div>
    );
  }

  if (routines.length === 0) {
    return (
      <div className="routine-fs-wrap">
        <div className="empty">
          <div className="empty-icon">{filterActive ? "⊘" : "⧗"}</div>
          <div className="empty-msg">{filterActive ? "NO ROUTINES MATCH" : "NO ROUTINES SCHEDULED"}</div>
          <div className="empty-sub">
            {filterActive ? (
              <button type="button" className="btn btn-ghost btn-sm" onClick={onClearFilters}>CLEAR FILTERS</button>
            ) : (
              "press + NEW ROUTINE to create one"
            )}
          </div>
        </div>
      </div>
    );
  }

  return (
    <div className="routine-fs-wrap">
      <div className="routine-fs-summary">{routines.length} routine(s) shown by filesystem location</div>
      <div className="routine-fs-tree" role="tree" aria-label="Routine filesystem">
        {root.routines.map((routine) => <RoutineFile key={routine.id} routine={routine} now={now} depth={0} {...actions} />)}
        {childFolders(root).map((folder) => (
          <FolderBranch key={folder.path} node={folder} now={now} depth={0} collapsed={collapsed} onToggle={toggleFolder} actions={actions} />
        ))}
      </div>
    </div>
  );
}

function FolderBranch({ node, now, depth, collapsed, onToggle, actions }: { node: FolderNode; now: Date; depth: number; collapsed: Set<string>; onToggle: (path: string) => void; actions: TreeRoutineActions }) {
  const isCollapsed = collapsed.has(node.path);
  const count = countRoutines(node);
  return (
    <div className="routine-fs-branch">
      <div className="routine-fs-folder" role="treeitem" aria-expanded={!isCollapsed} aria-label={`${node.name} folder, ${count} routine${count === 1 ? "" : "s"}`} style={depthStyle(depth)}>
        <button type="button" className="routine-fs-folder-toggle" aria-label={`${isCollapsed ? "Expand" : "Collapse"} ${node.name} folder`} onClick={() => onToggle(node.path)}>
          <span className="routine-fs-caret">{isCollapsed ? "▸" : "▾"}</span>
          <span className="routine-fs-icon">📁</span>
          <span className="routine-fs-folder-name">{node.name}</span>
          <span className="routine-fs-count">{count}</span>
        </button>
      </div>
      {!isCollapsed && (
        <div role="group">
          {node.routines.map((routine) => <RoutineFile key={routine.id} routine={routine} now={now} depth={depth + 1} {...actions} />)}
          {childFolders(node).map((folder) => <FolderBranch key={folder.path} node={folder} now={now} depth={depth + 1} collapsed={collapsed} onToggle={onToggle} actions={actions} />)}
        </div>
      )}
    </div>
  );
}

function countRoutines(node: FolderNode): number {
  let total = node.routines.length;
  for (const child of node.folders.values()) total += countRoutines(child);
  return total;
}

function RoutineFile({ routine, now, depth, onEdit, onClone, onDelete, onMove, onToggle, onTrigger, onLogs, onHistory, onFlags }: { routine: RoutineResponse; now: Date; depth: number } & TreeRoutineActions) {
  const path = routinePath(routine);
  const health = routineHealth(routine, now);
  const schedules = scheduleList(routine);
  return (
    <div className="routine-fs-file" role="treeitem" aria-label={`${path} routine`} style={depthStyle(depth)}>
      <div className="routine-fs-file-main">
        <span className="routine-fs-icon">📄</span>
        <div className="routine-fs-file-copy">
          <div className="routine-fs-file-title">{routine.title}</div>
          <div className="routine-fs-file-path" title={routine.rel_path}>{path}</div>
        </div>
      </div>
      <div className="routine-fs-meta"><span>{schedules.join(" · ")}</span><NextRunCell routine={routine} now={now} /></div>
      <div className="routine-fs-status"><span className={healthBadgeClass(health)} title={healthTooltip(routine, health)}>{healthBadge(health)}</span><label className="toggle"><input type="checkbox" checked={routine.enabled} onChange={(e) => onToggle(routine.id, e.target.checked)} /><div className="toggle-track" /></label></div>
      <div className="routine-fs-actions">
        <button type="button" className="act-btn run" aria-label="Run now" title={triggerButtonTitle(routine)} disabled={!routine.enabled || routine.power_saving} onClick={() => onTrigger(routine.id)}>RUN</button>
        <button type="button" className="act-btn" onClick={() => onLogs(routine.id)}>Logs</button>
        <button type="button" className="act-btn" onClick={() => onHistory(routine.id)}>History</button>
        <button type="button" className="act-btn" onClick={() => onFlags(routine.id)}>Flags</button>
        <button type="button" className="act-btn" onClick={() => onEdit(routine.id)}>Edit</button>
        <button type="button" className="act-btn" onClick={() => onMove(routine.id)}>Move folder</button>
        <button type="button" className="act-btn" onClick={() => onClone(routine.id)}>Clone</button>
        <button type="button" className="act-btn danger" onClick={() => onDelete(routine.id, routine.title)}>Delete</button>
      </div>
    </div>
  );
}