moadim 1.2.0

Loop engine for AI agents — routines over REST, MCP, and a built-in web UI
import { useState } from "react";
import { icalFeedUrl, type RoutineResponse } from "../../api/hooks";
import { CAL_MONTHS, GRID_CELLS, WEEKDAYS, monthStart, occurrencesPerDay } from "../../lib/schedule";
import { isRoutineSnoozed } from "./filter";
import { useToasts } from "../../shell/toasts";

export interface RoutineCalendarProps {
  routines: RoutineResponse[];
  loading: boolean;
  onEdit: (id: string) => void;
}

interface Hit {
  id: string;
  title: string;
  count: number;
  snoozed: boolean;
}

/** Month-calendar view of upcoming routine fire times. */
export function RoutineCalendar({ routines, loading, onEdit }: RoutineCalendarProps) {
  const [offset, setOffset] = useState(0);
  const { addToast } = useToasts();

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

  const onSubscribe = () => {
    const url = `${window.location.origin}${icalFeedUrl()}`;
    navigator.clipboard
      .writeText(url)
      .then(() => addToast("Calendar feed URL copied", "ok"))
      .catch(() => addToast("Copy failed", "err"));
  };

  const today = new Date();
  today.setHours(0, 0, 0, 0);
  const first = monthStart(today, offset);
  const gridStart = new Date(first);
  gridStart.setDate(gridStart.getDate() - first.getDay());

  const calNow = new Date();
  const cells: Hit[][] = Array.from({ length: GRID_CELLS }, () => []);
  let scheduled = 0;
  for (const r of routines.filter((r) => r.enabled)) {
    const counts = occurrencesPerDay(r.schedule, gridStart);
    if (counts === undefined) continue;
    scheduled++;
    const snoozed = isRoutineSnoozed(r, calNow);
    counts.forEach((c, i) => {
      if (c > 0) cells[i]?.push({ id: r.id, title: r.title, count: c, snoozed });
    });
  }

  const monthLabel = `${CAL_MONTHS[first.getMonth()]} ${first.getFullYear()}`;

  return (
    <div className="cal-wrap">
      <div className="cal-nav">
        <button type="button" className="btn-refresh" title="Previous month" aria-label="Previous month" onClick={() => setOffset((o) => o - 1)}>
          ‹
        </button>
        <div className="cal-month">{monthLabel}</div>
        <button type="button" className="btn-refresh" title="Next month" aria-label="Next month" onClick={() => setOffset((o) => o + 1)}>
          ›
        </button>
        <button type="button" className="btn btn-ghost btn-sm" onClick={() => setOffset(0)}>
          TODAY
        </button>
        <button type="button" className="btn btn-ghost btn-sm" title="Copy the routines.ics feed URL" onClick={onSubscribe}>
          SUBSCRIBE
        </button>
      </div>
      {scheduled === 0 ? (
        <div className="empty">
          <div className="empty-icon">🗓</div>
          <div className="empty-msg">NOTHING SCHEDULED</div>
          <div className="empty-sub">enabled routines with a valid schedule appear here</div>
        </div>
      ) : (
        <>
          <div className="cal-weekdays">
            {WEEKDAYS.map((d) => (
              <div className="cal-weekday" key={d}>
                {d}
              </div>
            ))}
          </div>
          <div className="cal-grid">
            {cells.map((hits, i) => {
              const date = new Date(gridStart);
              date.setDate(date.getDate() + i);
              let cls = "cal-day";
              if (date.getMonth() !== first.getMonth()) cls += " other-month";
              if (date.getTime() === today.getTime()) cls += " today";
              return (
                <div className={cls} key={i}>
                  <div className="cal-daynum">{date.getDate()}</div>
                  <div className="cal-hits">
                    {hits.slice(0, 4).map((hit, idx) => {
                      const label = hit.count > 1 ? `${hit.title} ×${hit.count}` : hit.title;
                      let chipCls = "cal-chip clickable";
                      if (hit.snoozed) chipCls += " snoozed";
                      return (
                        <div
                          className={chipCls}
                          title={label}
                          key={`${hit.id}-${idx}`}
                          onClick={() => onEdit(hit.id)}
                        >
                          {label}
                        </div>
                      );
                    })}
                    {hits.length > 4 && <div className="cal-more">+{hits.length - 4} more</div>}
                  </div>
                </div>
              );
            })}
          </div>
        </>
      )}
    </div>
  );
}