All files / src/components/schedules ScheduleManager.tsx

60% Statements 18/30
80.64% Branches 25/31
52.94% Functions 9/17
64.28% Lines 18/28

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172                                                                12x 12x 2x     12x         12x 10x 10x 20x   10x     12x 14x   12x                                         14x                                               3x         1x 1x   1x                                   2x                                                                                              
import { useState, useMemo, type JSX } from "react";
import type { PersonaData, ScheduleData, ScheduleUpdate } from "../../hooks/types.js";
import { Button } from "../display/Button.js";
import { ConfirmDialog } from "../display/index.js";
import { formatRelativeTime, formatCountdown } from "../../utils/time.js";
import styles from "./ScheduleManager.module.scss";
 
/** Props for the ScheduleManager component. */
export interface ScheduleManagerProps {
  /** All schedules. */
  schedules: ScheduleData[];
  /** All personas — used to resolve persona names. */
  personas: PersonaData[];
  /** Callback to delete a schedule. */
  onDeleteSchedule: (scheduleId: string) => Promise<void>;
  /** Callback to toggle a schedule's enabled state. */
  onToggleEnabled: (scheduleId: string, fields: ScheduleUpdate) => Promise<unknown>;
  /** Navigate to the new schedule page. */
  onNavigateToNew: () => void;
  /** Navigate to a schedule's detail page for editing. */
  onNavigateToSchedule: (scheduleId: string) => void;
}
 
/** Schedule list view — shows cards and navigates to detail pages for create/edit. */
export function ScheduleManager({
  schedules,
  personas,
  onDeleteSchedule,
  onToggleEnabled,
  onNavigateToNew,
  onNavigateToSchedule,
}: ScheduleManagerProps): JSX.Element {
  const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
  const scheduleToDelete = confirmDelete
    ? schedules.find((s) => s.id === confirmDelete)
    : undefined;
 
  const handleDelete = async (id: string): Promise<void> => {
    await onDeleteSchedule(id);
    setConfirmDelete(null);
  };
 
  const personaNameMap = useMemo(() => {
    const map = new Map<string, string>();
    for (const p of personas) {
      map.set(p.id, p.name);
    }
    return map;
  }, [personas]);
 
  const resolvePersonaName = (personaId: string): string =>
    personaNameMap.get(personaId) ?? personaId;
 
  return (
    <div className={styles.container}>
      <div className={styles.header}>
        <h2>Schedules</h2>
        <Button
          variant="primary"
          size="md"
          onClick={onNavigateToNew}
          data-testid="schedule-new-button"
        >
          + New Schedule
        </Button>
      </div>
 
      {schedules.length === 0 ? (
        <p className={styles.empty} data-testid="schedule-empty-state">
          No schedules yet. Create one to run tasks on a recurring cadence.
        </p>
      ) : (
        <div className={styles.list}>
          {schedules.map((s) => (
            <div
              key={s.id}
              className={styles.card}
              data-testid={`schedule-card-${s.id}`}
              onClick={() => onNavigateToSchedule(s.id)}
              role="button"
              tabIndex={0}
              onKeyDown={(e) => {
                Iif (e.currentTarget === e.target && (e.key === "Enter" || e.key === " ")) {
                  e.preventDefault();
                  onNavigateToSchedule(s.id);
                }
              }}
            >
              <div className={styles.cardHeader}>
                <span className={styles.cardTitle}>
                  <strong>{s.title}</strong>
                  <span
                    className={`${styles.statusBadge} ${s.enabled ? styles.enabled : styles.disabled}`}
                    data-testid={`schedule-status-badge-${s.id}`}
                  >
                    {s.enabled ? "Enabled" : "Disabled"}
                  </span>
                </span>
                <div className={styles.cardActions} onClick={(e) => e.stopPropagation()}>
                  <Button
                    variant="ghost"
                    size="sm"
                    onClick={() => {
                      const toggle = async (): Promise<void> => {
                        await onToggleEnabled(s.id, { enabled: !s.enabled });
                      };
                      toggle().catch(() => undefined);
                    }}
                    data-testid={`schedule-toggle-${s.id}`}
                    title={s.enabled ? "Disable schedule" : "Enable schedule"}
                  >
                    {s.enabled ? "Disable" : "Enable"}
                  </Button>
                  <Button
                    variant="ghost"
                    size="sm"
                    onClick={() => onNavigateToSchedule(s.id)}
                    data-testid={`schedule-edit-${s.id}`}
                  >
                    Edit
                  </Button>
                  <Button
                    variant="ghost"
                    size="sm"
                    onClick={() => setConfirmDelete(s.id)}
                    data-testid={`schedule-delete-${s.id}`}
                  >
                    Delete
                  </Button>
                </div>
              </div>
 
              <div className={styles.cardMeta}>
                <span data-testid={`schedule-expression-${s.id}`}>{s.scheduleExpression}</span>
                {s.personaId && (
                  <span data-testid={`schedule-persona-${s.id}`}>
                    Persona: {resolvePersonaName(s.personaId)}
                  </span>
                )}
                <span data-testid={`schedule-last-run-${s.id}`}>
                  Last run: {s.lastRunAt ? formatRelativeTime(s.lastRunAt) : "Never"}
                </span>
                {s.enabled && s.nextRunAt ? (
                  <span data-testid={`schedule-next-run-${s.id}`}>
                    Next run: {formatCountdown(s.nextRunAt)}
                  </span>
                ) : null}
                {s.runCount > 0 && (
                  <span data-testid={`schedule-run-count-${s.id}`}>Runs: {s.runCount}</span>
                )}
              </div>
            </div>
          ))}
        </div>
      )}
 
      <ConfirmDialog
        isOpen={confirmDelete !== null}
        title="Delete Schedule?"
        description={`"${scheduleToDelete?.title ?? ""}" will be permanently removed. Tasks already created by this schedule will not be affected.`}
        confirmLabel="Delete"
        onConfirm={() => {
          Iif (confirmDelete) {
            handleDelete(confirmDelete).catch(() => undefined);
          }
        }}
        onCancel={() => setConfirmDelete(null)}
      />
    </div>
  );
}