/* global React, Donut */

const RCIcon = {
  chevL: <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M15 6l-6 6 6 6"/></svg>,
  chevR: <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M9 6l6 6-6 6"/></svg>,
};

const SCHED_DOW = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
const SCHED_MONTHS = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
// v07zz55 — Real system date instead of the May 6 2026 demo anchor.
// The hardcoded date made every calendar view show TODAY on the wrong
// day; the schedule's Phase 1 bar pointed at the wrong week; and the
// MilestonesCard "in N days" never matched reality.
const SCHED_TODAY = (() => { const d = new Date(); d.setHours(0,0,0,0); return d; })();

function startOfWeek(d) {
  const x = new Date(d);
  const dow = x.getDay(); // 0=Sun
  const offset = (dow === 0 ? -6 : 1 - dow); // Mon-anchored
  x.setDate(x.getDate() + offset);
  x.setHours(0, 0, 0, 0);
  return x;
}
function addDays(d, n) {
  const x = new Date(d);
  x.setDate(x.getDate() + n);
  return x;
}
function sameDay(a, b) {
  return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}
function dayKey(d) {
  return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
}
function dayLabel(d) {
  return `${SCHED_DOW[d.getDay()]} — ${SCHED_MONTHS[d.getMonth()]} ${d.getDate()}`;
}

// Deterministic per-date schedule slots — looks plausible, varies day to day.
// v07zx — Hugo: hardcoded SLOT_TEMPLATES are gone. The schedule
// card now reads real events from /api/calendar/events (ICS feed
// + milestones). Empty days render an empty state instead of
// pretending there's a "Daily standup" booked. The component
// keeps a per-week cache so changing days doesn't refetch.

// v07zz60 — Hugo: "Calendar panel, why is today's day not centered?"
// Anchor the 5-day strip so TODAY sits at index 2 (middle slot)
// instead of always rendering Mon-Fri. weekAnchor is now the
// leftmost visible day; shiftWeek steps by 5 days so navigation
// pages cleanly through the calendar without overlap.
function centeredAnchor(d) {
  const x = new Date(d);
  x.setDate(x.getDate() - 2);
  x.setHours(0, 0, 0, 0);
  return x;
}

// v07zz77 — Hugo: "this calendar panel on overview page flickers when
// going back on the overview page, where the Sync now row appears, so
// we need to set it up so it's always there somehow". Root cause:
// calStatus starts at { configured: null }, so on every re-mount the
// "Sync now" row disappears, probeCalendar fires, and ~300 ms later
// the row re-appears → visible reflow. Module-level cache survives
// re-mounts within the same browser session so a tab-switch back to
// Overview re-uses the previously-known status synchronously and the
// row never disappears.
let _CAL_STATUS_CACHE = null;
// v07zz78 — same pattern for the day-rows. eventsByDate started as
// {} on every mount which made each re-visit show "No events" rows
// for ~200 ms before the fetch landed. Keyed by week-anchor ISO so
// each visible week reuses its previous result.
const _CAL_EVENTS_CACHE = new Map();
function ScheduleCard() {
  const [weekAnchor, setWeekAnchor] = React.useState(() => centeredAnchor(SCHED_TODAY));
  const [selectedDay, setSelectedDay] = React.useState(() => new Date(SCHED_TODAY));
  const slotsRef = React.useRef(null);
  // v07zx — Real events for the current visible week, keyed by
  // YYYY-MM-DD. Re-fetched when the week shifts; nothing fancier
  // than that (no per-month cache yet).
  // v07zz78 — Seed from the per-week cache so re-mounts at the same
  // week-anchor render the previously-fetched events synchronously.
  // The background fetch still runs to refresh.
  const _weekKey = `${weekAnchor.getFullYear()}-${weekAnchor.getMonth() + 1}-${weekAnchor.getDate()}`;
  const [eventsByDate, setEventsByDate] = React.useState(
    () => _CAL_EVENTS_CACHE.get(_weekKey) || {}
  );
  const slotsForDate = React.useCallback((d) => {
    const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
    return eventsByDate[key] || [];
  }, [eventsByDate]);
  // v01y — Calendar connection status. Probes /api/calendar/milestones
  // on mount; when configured=false the panel shows a 'Connect Google
  // Calendar' callout that links to Settings; when configured=true it
  // shows the last sync time + a 'Sync now' button.
  // v07zz77 — Seed from the module-level cache so a re-mount of this
  // component (e.g. tabbing back to Overview) shows the previously-
  // known status synchronously — no flicker while the background
  // probe re-confirms.
  const [calStatus, setCalStatus] = React.useState(
    _CAL_STATUS_CACHE || { configured: null, milestones: 0, lastSync: null }
  );
  const [syncing, setSyncing] = React.useState(false);
  const probeCalendar = React.useCallback(() => {
    const fetcher = window.authFetch || fetch;
    // v07zx — Check BOTH endpoints: milestones (OAuth path) AND
    // events (ICS path). Hugo had ICS connected but probeCalendar
    // only checked the OAuth-based isConfigured() so the card kept
    // saying "Connect Google Calendar". Either source counts now.
    Promise.all([
      fetcher("/api/calendar/milestones").then(r => r.ok ? r.json() : null).catch(() => null),
      fetcher("/api/calendar/events").then(r => r.ok ? r.json() : null).catch(() => null),
    ]).then(([m, e]) => {
      const lastSync = (m && m.milestones && m.milestones[0] && m.milestones[0].synced_at) || null;
      const configured = !!((m && m.configured) || (e && e.configured));
      const next = {
        configured,
        milestones: (m && m.milestones && m.milestones.length) || 0,
        lastSync,
      };
      _CAL_STATUS_CACHE = next;   // v07zz77 — survive re-mounts
      setCalStatus(next);
    });
  }, []);
  React.useEffect(() => { probeCalendar(); }, [probeCalendar]);

  // v07zx — Fetch real events for the visible week. Range covers
  // Mon 00:00 → next Mon 00:00 so the 5-day strip is covered with
  // some weekend buffer.
  // v07zz51 — Pull into a useCallback so the Sync now button can
  // re-trigger the fetch + actually refresh the visible week.
  // v07zz72 — Audit fix: rapid week-shift clicks could race because
  // each fetchWeekEvents kicked off a new promise but the previous
  // one's resolution could still land AFTER the newer one, scrambling
  // eventsByDate. Track the latest weekAnchor via a ref; resolutions
  // for a stale anchor are dropped on the floor.
  const _weekReqRef = React.useRef(0);
  const fetchWeekEvents = React.useCallback(() => {
    const fetcher = window.authFetch || fetch;
    const fmt = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
    const startStr = fmt(weekAnchor);
    const endDate = new Date(weekAnchor);
    endDate.setDate(endDate.getDate() + 7);
    const endStr = fmt(endDate);
    const reqId = ++_weekReqRef.current;
    const weekKeyAtFire = `${weekAnchor.getFullYear()}-${weekAnchor.getMonth() + 1}-${weekAnchor.getDate()}`;
    return fetcher(`/api/calendar/events?start=${startStr}&end=${endStr}`)
      .then(r => r.ok ? r.json() : null)
      .then(d => {
        if (reqId !== _weekReqRef.current) return; // stale — newer request landed first
        if (!d || !Array.isArray(d.events)) return;
        const byDate = {};
        for (const ev of d.events) {
          if (!ev.date) continue;
          if (!byDate[ev.date]) byDate[ev.date] = [];
          byDate[ev.date].push(ev);
        }
        // v07zz78 — cache the result keyed by the week we fired for,
        // so a re-mount at this week reads back the same data
        // synchronously and skips the empty-rows flicker.
        _CAL_EVENTS_CACHE.set(weekKeyAtFire, byDate);
        setEventsByDate(byDate);
      })
      .catch(() => {});
  }, [weekAnchor]);
  React.useEffect(() => { fetchWeekEvents(); }, [fetchWeekEvents]);
  // v07zz78 — When the user shifts to a different week we re-seed
  // from the per-week cache immediately so the day rows reflect the
  // cached result while the background fetch confirms. Without this,
  // shifting week always flashed "No events" placeholders for ~200
  // ms even when the previous render had data.
  React.useEffect(() => {
    const cached = _CAL_EVENTS_CACHE.get(_weekKey);
    if (cached) setEventsByDate(cached);
  }, [_weekKey]);
  const triggerSync = () => {
    if (syncing) return;
    setSyncing(true);
    const fetcher = window.authFetch || fetch;
    // v07zz51 — Sync now did "nothing visible" because the success path
    // only refreshed calStatus (the "Last synced: X" label). The week
    // strip didn't re-fetch, so newly imported events weren't visible
    // until Hugo manually shifted the week or reloaded the page. Now
    // we also re-pull eventsByDate after a successful sync.
    fetcher("/api/calendar/sync", { method: "POST" })
      .then(r => r.ok ? r.json() : null)
      .then(() => Promise.all([probeCalendar(), fetchWeekEvents()]))
      .catch(() => {})
      .finally(() => setSyncing(false));
  };
  const fmtSyncTime = (s) => {
    if (!s) return "never";
    // v07zz56 — Hugo: "Sync Now says Last synced : 10h ago instead of
    // right now". Root cause: SQLite's datetime('now') stores UTC
    // without a 'Z' suffix ('2026-05-28 02:19:31'). new Date() parsed
    // that as LOCAL time, then compared to Date.now() (UTC). For a
    // user in UTC+10 the result was ~10h off. Normalise to ISO+Z so
    // JS parses as UTC.
    let str = String(s);
    if (!str.includes("T") && /^\d{4}-\d{2}-\d{2} /.test(str)) str = str.replace(" ", "T") + "Z";
    const d = new Date(str);
    if (Number.isNaN(d.getTime())) return s;
    const diff = (Date.now() - d.getTime()) / 1000;
    if (diff < 60)        return "just now";
    if (diff < 3600)      return `${Math.floor(diff / 60)} min ago`;
    if (diff < 86400)     return `${Math.floor(diff / 3600)}h ago`;
    return d.toLocaleDateString();
  };

  const days = Array.from({ length: 5 }, (_, i) => addDays(weekAnchor, i)); // Mon–Fri
  const monthStart = days[0];
  const monthEnd = days[4];
  const monthLabel = monthStart.getMonth() === monthEnd.getMonth()
    ? `${SCHED_MONTHS[monthStart.getMonth()]} ${monthStart.getFullYear()}`
    : `${SCHED_MONTHS[monthStart.getMonth()]} — ${SCHED_MONTHS[monthEnd.getMonth()]} ${monthEnd.getFullYear()}`;

  const shiftWeek = (delta) => {
    // v07zz60 — Step by 5 days so the strip pages cleanly with no
    // overlap. (Used to be 7, but with the centered-on-today
    // anchor a 7-day step would re-show some days.)
    setWeekAnchor(prev => addDays(prev, delta * 5));
  };

  // Scroll only the inner slots container — never bubble to parents.
  React.useEffect(() => {
    const container = slotsRef.current;
    if (!container) return;
    const target = container.querySelector(`[data-day="${dayKey(selectedDay)}"]`);
    if (!target) return;
    const cTop = container.getBoundingClientRect().top;
    const tTop = target.getBoundingClientRect().top;
    const next = container.scrollTop + (tTop - cTop);
    container.scrollTo({ top: Math.max(0, next), behavior: "smooth" });
  }, [selectedDay, weekAnchor]);

  const onPickDay = (d) => {
    // v07zz60 — Picking a day outside the visible 5-day strip
    // re-centers the strip on that day so the click never
    // disappears from view.
    const inWindow = days.some(x => sameDay(x, d));
    if (!inWindow) setWeekAnchor(centeredAnchor(d));
    setSelectedDay(d);
  };

  // Hugo update — Connect Google Calendar opens as a full-page modal
  // (via portal), same pattern as the shot/asset popups. The connect
  // button checks /api/calendar/auth-status first so it can surface
  // a clear in-modal error when OAuth isn't configured server-side.
  const [showConnectPopup, setShowConnectPopup] = React.useState(false);
  const [connectError, setConnectError] = React.useState(null);
  const [connecting, setConnecting] = React.useState(false);
  // Reset error whenever the modal opens/closes.
  React.useEffect(() => { if (!showConnectPopup) { setConnectError(null); setConnecting(false); } }, [showConnectPopup]);
  const onClickConnect = () => {
    setConnecting(true);
    setConnectError(null);
    const fetcher = window.authFetch || fetch;
    fetcher("/api/calendar/auth-status")
      .then(r => r.ok ? r.json() : null)
      .then(d => {
        setConnecting(false);
        if (d && d.configured) {
          // OAuth wired — navigate to the auth endpoint which 302s to Google.
          window.location.href = "/api/calendar/auth";
        } else {
          setConnectError("Google OAuth isn't configured on this server yet. Ask the admin to set CAL_GOOGLE_CLIENT_ID and CAL_GOOGLE_CLIENT_SECRET in .env and restart.");
        }
      })
      .catch(err => {
        setConnecting(false);
        setConnectError("Couldn't reach the server: " + (err.message || "unknown error"));
      });
  };

  return (
    <section className="rc-card glass schedule-card">
      {/* v03d — CALENDAR header removed; the month/year is now the
          topmost element. Week navigation buttons sit inline with the
          month label. */}
      <div className="sched-month-row">
        <div className="sched-month">{monthLabel.toUpperCase()}</div>
        <div className="rc-nav">
          <button className="rc-iconbtn" aria-label="Previous week" onClick={() => shiftWeek(-1)}>{RCIcon.chevL}</button>
          <button className="rc-iconbtn" aria-label="Next week" onClick={() => shiftWeek(1)}>{RCIcon.chevR}</button>
        </div>
      </div>
      {/* v01y / v03d — connection callout. configured=false → show the
          'No calendar connected' inline callout; clicking the button
          opens an in-panel popup (v03d) rather than navigating to
          Settings directly. */}
      {calStatus.configured === false && (
        <div className="cal-connect-callout">
          <span>No calendar connected</span>
          <button
            type="button"
            className="cal-connect-btn"
            onClick={() => setShowConnectPopup(true)}
          >Connect Google Calendar →</button>
        </div>
      )}
      {showConnectPopup && (() => {
        // Hugo update — render the connect modal via portal at
        // #modal-root so the backdrop covers the entire viewport,
        // matching the shot/asset/documents modals.
        const portalRoot = document.getElementById("modal-root") || document.body;
        const close = () => setShowConnectPopup(false);
        return ReactDOM.createPortal((
          <div className="modal-backdrop cal-connect-modal-backdrop" onClick={close}>
            <div className="cal-connect-modal glass" onClick={(e) => e.stopPropagation()}>
              <button
                type="button"
                className="cal-connect-x"
                onClick={close}
                aria-label="Dismiss"
              >×</button>
              <div className="cal-connect-popup-title">Connect Google Calendar</div>
              <div className="cal-connect-popup-body">
                Click below to authorise access to your Google Calendar. You will be redirected to Google and back automatically.
              </div>
              {connectError && (
                <div className="cal-connect-error" role="alert">{connectError}</div>
              )}
              <button
                type="button"
                className="cal-connect-go cal-connect-primary"
                onClick={onClickConnect}
                disabled={connecting}
              >{connecting ? "Checking…" : "Connect with Google"}</button>
              <div className="cal-connect-footnote">You can disconnect at any time in Settings.</div>
            </div>
          </div>
        ), portalRoot);
      })()}
      {/* v07zz77 — Hugo: "set it up so it's always there somehow".
          The row used to render only when configured===true, so it
          disappeared on every re-mount until the probe re-resolved.
          Now it ALWAYS renders. While the probe is still in flight
          OR the calendar isn't configured, the button shows "Sync
          now" disabled with a muted "Last synced: —" label, so the
          layout stays put. Combined with the _CAL_STATUS_CACHE
          module-level seed above, re-mounts are silent. */}
      <div className="cal-status-row">
        <span className="cal-status-text">
          Last synced: {calStatus.configured === true
            ? fmtSyncTime(calStatus.lastSync)
            : (calStatus.configured === null ? "—" : "never")}
        </span>
        <button
          type="button"
          className="cal-sync-btn"
          onClick={triggerSync}
          disabled={syncing || calStatus.configured !== true}
        >{syncing ? "Syncing…" : "Sync now"}</button>
      </div>
      <div className="week-strip">
        {days.map(d => {
          const isToday = sameDay(d, SCHED_TODAY);
          const isSelected = sameDay(d, selectedDay);
          return (
            <button
              key={dayKey(d)}
              className={"week-cell" + (isToday ? " today" : "") + (isSelected ? " selected" : "")}
              onClick={() => onPickDay(d)}
              aria-label={dayLabel(d)}
            >
              <div className="wc-dow">{SCHED_DOW[d.getDay()]}</div>
              <div className="wc-num">{d.getDate()}</div>
            </button>
          );
        })}
      </div>
      <div className="time-slots time-slots--scrollable" ref={slotsRef}>
        {days.map(d => {
          const slots = slotsForDate(d);
          const isSelected = sameDay(d, selectedDay);
          const isToday = sameDay(d, SCHED_TODAY);
          return (
            <div className={"sched-day" + (isSelected ? " is-selected" : "")} data-day={dayKey(d)} key={dayKey(d)}>
              <div className="sched-day-head">
                <span>{dayLabel(d)}</span>
                {isToday && <span className="sched-day-tag">TODAY</span>}
              </div>
              {slots.length === 0 ? (
                <div className="time-slot time-slot--empty">
                  <div className="ts-time">—</div>
                  <div className="ts-body">
                    <div className="ts-title" style={{ color: "var(--ink-muted)", fontStyle: "italic" }}>No events</div>
                  </div>
                </div>
              ) : slots.map((s, i) => {
                // v07zz56 — Render in the user's profile timezone when
                // available (users.tz from /api/auth/me). Falls back to
                // browser local. SQLite/ICS UTC arrives via `s.utc`;
                // s.time stays as a fallback for older payloads.
                let display = s.time || "—";
                try {
                  if (s.utc) {
                    const userTz = (window.__currentUser && window.__currentUser.tz) || undefined;
                    const d = new Date(s.utc);
                    if (!Number.isNaN(d.getTime())) {
                      display = d.toLocaleTimeString("en-GB", {
                        hour: "2-digit", minute: "2-digit",
                        timeZone: userTz,
                      });
                    }
                  }
                } catch (_) {}
                // v07zz66 — Hugo: "Calnedar panel on overview page,
                // doesnt look like I can click on the the meetings
                // here, that needs to be the same as on the schedule
                // page." Make the slot a button that opens the same
                // event modal used on the Schedule page. We dispatch
                // a window event so any open ScheduleView intercepts
                // it — otherwise we fall back to opening the URL.
                const openMeeting = () => {
                  try {
                    window.dispatchEvent(new CustomEvent("paradise-open-event", { detail: { ...s, date: dayKey(d) } }));
                  } catch (_) {}
                  if (s.url) window.open(s.url, "_blank", "noreferrer,noopener");
                };
                return (
                  <button type="button"
                    className={"time-slot" + (s.url ? " time-slot--clickable" : "")}
                    key={s.id || (s.time + s.title + i)}
                    onClick={openMeeting}
                    style={{textAlign:"left", background:"transparent", border:"none", cursor: s.url ? "pointer" : "default", width:"100%", padding:0}}>
                    <div className="ts-time">{display}</div>
                    <div className="ts-body">
                      <div className="ts-title"><span className="ts-dot" style={{background: s.color}}/>{s.title}</div>
                      {s.sub && <div className="ts-sub">{s.sub}</div>}
                      {s.url && <div className="ts-join" style={{fontSize: "var(--fs-10)", color:"var(--leaf-deep)", fontWeight: "var(--fw-semi)", marginTop:2}}>Click to join ↗</div>}
                    </div>
                  </button>
                );
              })}
            </div>
          );
        })}
      </div>
    </section>
  );
}

function MiniDonut({ pct = 17, size = 110 }) {
  const r = (size - 12) / 2;
  const c = 2 * Math.PI * r;
  const dash = (pct / 100) * c;
  return (
    <svg viewBox={`0 0 ${size} ${size}`} width={size} height={size}>
      <defs>
        <linearGradient id="miniDonutFill" x1="0" y1="0" x2="1" y2="1">
          <stop offset="0%" stopColor="var(--amber)"/>
          <stop offset="100%" stopColor="var(--leaf)"/>
        </linearGradient>
      </defs>
      <circle cx={size/2} cy={size/2} r={r} fill="none" stroke="color-mix(in srgb, var(--ink-muted) 18%, transparent)" strokeWidth="9"/>
      <circle cx={size/2} cy={size/2} r={r} fill="none" stroke="var(--cream-29)" strokeWidth="7"/>
      <circle cx={size/2} cy={size/2} r={r} fill="none"
        stroke="url(#miniDonutFill)" strokeWidth="7" strokeLinecap="round"
        strokeDasharray={`${dash} ${c}`}
        transform={`rotate(-90 ${size/2} ${size/2})`}/>
    </svg>
  );
}

function MiniDonutColored({ pct = 17, size = 110, color }) {
  const r = (size - 12) / 2;
  const c = 2 * Math.PI * r;
  const dash = (pct / 100) * c;
  const stroke = color || "url(#miniDonutFill)";
  return (
    <svg viewBox={`0 0 ${size} ${size}`} width={size} height={size}>
      <defs>
        <linearGradient id="miniDonutFill" x1="0" y1="0" x2="1" y2="1">
          <stop offset="0%" stopColor="var(--amber)"/>
          <stop offset="100%" stopColor="var(--leaf)"/>
        </linearGradient>
      </defs>
      <circle cx={size/2} cy={size/2} r={r} fill="none" stroke="color-mix(in srgb, var(--ink-muted) 18%, transparent)" strokeWidth="9"/>
      <circle cx={size/2} cy={size/2} r={r} fill="none" stroke="var(--cream-29)" strokeWidth="7"/>
      <circle cx={size/2} cy={size/2} r={r} fill="none"
        stroke={stroke} strokeWidth="7" strokeLinecap="round"
        strokeDasharray={`${dash} ${c}`}
        transform={`rotate(-90 ${size/2} ${size/2})`}/>
    </svg>
  );
}

function MultiArcDonut({ rows, size = 124, focusId = null }) {
  // Flat multi-arc donut — colored arcs on a dark track, no shadows, no glass
  // highlights. Shorter arcs sit on top of longer ones (sorted desc by pct).
  const r = (size - 18) / 2;
  const c = 2 * Math.PI * r;
  const off = (p) => c * (1 - p / 100);
  const sorted = [...rows].sort((a, b) => b.pct - a.pct);
  const cx = size / 2;
  const cy = size / 2;
  return (
    <svg viewBox={`0 0 ${size} ${size}`} width={size} height={size}>
      <circle cx={cx} cy={cy} r={r} fill="none" stroke="color-mix(in srgb, var(--ink-muted) 22%, transparent)" strokeWidth="9"/>
      {sorted.map(arc => {
        const dim = focusId && arc.id !== focusId;
        return (
          <circle key={arc.id} cx={cx} cy={cy} r={r} fill="none" stroke={arc.color}
                  strokeWidth="9" strokeLinecap="round"
                  strokeDasharray={c} strokeDashoffset={off(arc.pct)}
                  opacity={dim ? 0.18 : 0.95}
                  transform={`rotate(-90 ${cx} ${cy})`}/>
        );
      })}
    </svg>
  );
}

function PipelineOverviewCard() {
  const data = window.__appData || {};
  const shots = data.shots || [];
  const total = shots.length || 1;
  const stageDone = (s, k) => s && s.stage_status && s.stage_status[k] === "done";
  const countAt = (k) => shots.filter(s => stageDone(s, k)).length;
  const firstPass       = countAt("first_pass");
  const conceptWip      = shots.filter(s => stageDone(s, "refinement") && !stageDone(s, "hero")).length;
  const conceptApproved = countAt("hero");
  const videoWip        = shots.filter(s => stageDone(s, "video_prompt") && !stageDone(s, "video")).length;
  const videoApproved   = countAt("video");
  const upscaled        = countAt("upscale");
  const pending         = total - firstPass;
  const pctOf = (n) => Math.round((n / total) * 100);
  const rows = [
    { id: "first_pass",  label: "First Pass",  count: firstPass,       pct: pctOf(firstPass),       color: "var(--sand-2)" },
    { id: "concept_wip", label: "Concept WIP", count: conceptWip,      pct: pctOf(conceptWip),      color: "var(--gold-16)" },
    { id: "concept",     label: "Hero",        count: conceptApproved, pct: pctOf(conceptApproved), color: "var(--leaf)" },
    { id: "video_wip",   label: "Video WIP",   count: videoWip,        pct: pctOf(videoWip),        color: "var(--teal-bright)" },
    { id: "video",       label: "Completed",   count: videoApproved,   pct: pctOf(videoApproved),   color: "var(--leaf-bright)" },
    { id: "upscale",     label: "4K Upscaled", count: upscaled,        pct: pctOf(upscaled),        color: "var(--badge-review)" },
  ];
  const [active, setActive] = React.useState(null);
  const focused = active ? rows.find(r => r.id === active) : null;

  // Project health rows.
  const archiveCount = shots.filter(s => s.is_archive).length;
  const inFlight = firstPass - conceptApproved; // stuck in refinement
  const blocked = pending; // not yet first-passed
  const hoursPerShotEst = 2.2;
  const burnedEstimate = Math.round((upscaled * hoursPerShotEst * 1.4) + (conceptApproved * hoursPerShotEst * 1.0) + (firstPass * hoursPerShotEst * 0.6));
  // 🔒 v07zz278 — no hardcoded contract figure in client source. labor_commitment
  // is stripped from /api/schedule for non-budget roles; fall back to a shot-scope
  // estimate (never the real contracted hours) so the burn % stays sensible.
  const labor = (data.schedule && data.schedule.labor_commitment) || Math.max(1, Math.round(total * 3.3));
  const burnPct = Math.min(100, Math.round((burnedEstimate / labor) * 100));

  return (
    <section className="rc-card glass pipeline-card">
      <div className="rc-head">
        <div className="rc-title">PIPELINE OVERVIEW</div>
        {focused && <button className="pipe-clear" onClick={() => setActive(null)}>All ✕</button>}
      </div>
      <div className="pipe-row">
        <div className="pipe-donut-wrap">
          <MultiArcDonut rows={rows} focusId={active}/>
          <div className="pipe-donut-center">
            <div className="pipe-donut-num">{focused ? focused.count : upscaled}</div>
            <div className="pipe-donut-cap">{focused ? focused.label.toUpperCase() : "DELIVERED"}</div>
          </div>
        </div>
        <div className="pipe-legend">
          {rows.map(r => {
            const isActive = active === r.id;
            const dimmed = active && !isActive;
            return (
              <button key={r.id}
                className={"pipe-row-item" + (isActive ? " is-active" : "") + (dimmed ? " is-dimmed" : "")}
                onClick={() => setActive(prev => prev === r.id ? null : r.id)}>
                <span className="pipe-dot" style={{background: r.color, boxShadow: `0 0 8px ${r.color}80`}}/>
                <span className="pipe-label">{r.label}</span>
                <span className="pipe-count">{r.count}</span>
                <span className="pipe-pct">{r.pct}%</span>
              </button>
            );
          })}
        </div>
      </div>

      {/* Project health rows — info NOT shown in the topbar */}
      <div className="pipe-health">
        <div className="pipe-health-row">
          <span className="pipe-health-label">In flight (refinement)</span>
          <span className="pipe-health-value">{inFlight} shots</span>
        </div>
        <div className="pipe-health-row">
          <span className="pipe-health-label">Blocked / not started</span>
          <span className="pipe-health-value pipe-health-warn">{blocked} shots</span>
        </div>
        <div className="pipe-health-row">
          <span className="pipe-health-label">Archive</span>
          <span className="pipe-health-value">{archiveCount} shots</span>
        </div>
        <div className="pipe-health-row pipe-health-row--burn">
          <span className="pipe-health-label">Hours burned (est.)</span>
          <span className="pipe-health-value">
            <strong>{burnedEstimate}</strong>
            <span className="pipe-health-faint"> / {labor} h</span>
          </span>
        </div>
        <div className="pipe-burn-track">
          <div className="pipe-burn-fill" style={{width: `${burnPct}%`}}/>
        </div>
      </div>
    </section>
  );
}

/* MilestonesCard — upcoming key project events from the schedule. Filters
   out weekly check-ins (too noisy) and shows the next 4 meaningful dates
   (phases / milestones / checkpoints / reviews / deliveries). Each row has
   a date pill, type badge, and event title — clean producer-level glance. */
function MilestonesCard() {
  const data = window.__appData || {};
  const events = (data.schedule && data.schedule.events) || [];
  // v07zz66 — Hugo: "Milestones panel on overview page, only shows
  // Phase 01 and then Final delivery, not sure what happened to all
  // the other milestones." The card was only sourcing from
  // schedule.events. Schedule reorganization moved most key dates
  // into the dedicated milestones[] array (payment milestones) and
  // phases[].start. Pull from ALL THREE so the panel shows phase
  // starts, payment milestones, custom events + deliveries in one
  // unified upcoming list.
  const milestones = (data.schedule && data.schedule.milestones) || [];
  const phases = (data.schedule && data.schedule.phases) || [];
  const milestoneEvents = milestones
    .filter(m => m && m.date)
    .map(m => ({ date: m.date, type: "milestone", label: m.name, lead: "Producer" }));
  const phaseBeginEvents = phases
    .filter(p => p && p.start && p.id !== "p0") // skip Phase 0 (build phase)
    .map(p => ({ date: p.start, type: "phase", label: `${p.name} begins`, lead: "Hugo" }));
  const combinedEvents = [...events, ...milestoneEvents, ...phaseBeginEvents];
  const today = SCHED_TODAY;
  const isUpcoming = (e) => new Date(e.date + "T00:00:00") >= today;
  const isKey = (e) => e.type !== "weekly";
  // Dedupe by date+label so a phase that's also a milestone doesn't
  // appear twice.
  const seen = new Set();
  const sortedUpcoming = combinedEvents
    .filter(e => isUpcoming(e) && isKey(e))
    .sort((a, b) => a.date.localeCompare(b.date))
    .filter(e => {
      const k = `${e.date}|${(e.label || "").toLowerCase()}`;
      if (seen.has(k)) return false;
      seen.add(k);
      return true;
    });
  // Split into "this week" (within the next 7 days) and "coming up" (everything
  // beyond that). The next big step is whatever's at the top.
  const weekCutoff = new Date(today);
  weekCutoff.setDate(weekCutoff.getDate() + 7);
  const thisWeek = sortedUpcoming.filter(e => new Date(e.date + "T00:00:00") <= weekCutoff);
  const comingUp = sortedUpcoming.filter(e => new Date(e.date + "T00:00:00") > weekCutoff).slice(0, 6);

  // Map event types → tint + abbreviation for the type badge.
  // 15 Sep 2026 — tokens, not literals, so a preset recolours the badges (styles/tokens.css --badge-*).
  const TYPE_STYLE = {
    milestone:  { color: "var(--ok-deep)", bg: "color-mix(in srgb, var(--ok) 20%, transparent)", label: "Milestone" },
    phase:      { color: "var(--badge-phase-ink)", bg: "color-mix(in srgb, var(--badge-phase) 20%, transparent)", label: "Phase" },
    checkpoint: { color: "color-mix(in srgb, var(--teal) 55%, var(--ink))", bg: "color-mix(in srgb, var(--teal) 20%, transparent)", label: "Checkpoint" },
    review:     { color: "var(--badge-review-ink)", bg: "color-mix(in srgb, var(--badge-review) 22%, transparent)", label: "Review" },
    delivery:   { color: "color-mix(in srgb, var(--ok-deep) 85%, var(--mix-dark))", bg: "color-mix(in srgb, var(--leaf-bright) 22%, transparent)", label: "Delivery" },
    kickoff:    { color: "var(--badge-kickoff-ink)", bg: "color-mix(in srgb, var(--badge-kickoff) 22%, transparent)", label: "Kickoff" },
    default:    { color: "var(--badge-default-ink)", bg: "color-mix(in srgb, var(--badge-default) 16%, transparent)", label: "Event" },
  };
  // Format "2026-05-17" → "MAY 17"
  const fmtDate = (iso) => {
    const d = new Date(iso + "T00:00:00");
    const m = SCHED_MONTHS[d.getMonth()];
    return `${m} ${d.getDate()}`;
  };
  // Days from today until the event.
  const daysUntil = (iso) => {
    const d = new Date(iso + "T00:00:00");
    return Math.round((d - today) / 86400000);
  };

  const renderRow = (e, i) => {
    const style = TYPE_STYLE[e.type] || TYPE_STYLE.default;
    const days = daysUntil(e.date);
    const dayCap = days === 0 ? "today" : days === 1 ? "tomorrow" : `in ${days} days`;
    return (
      <div key={i} className="milestone-row">
        <div className="milestone-date">
          <div className="milestone-date-text">{fmtDate(e.date)}</div>
          <div className="milestone-date-rel">{dayCap}</div>
        </div>
        <div className="milestone-body">
          <span className="milestone-type" style={{color: style.color, background: style.bg}}>{style.label}</span>
          <div className="milestone-label">{e.label}</div>
          <div className="milestone-lead">— {e.lead}</div>
        </div>
      </div>
    );
  };

  // v07zz54 — Current phase indicator. Hugo: "we should see which Phase
  // we're in, i'm sure that used to be the case." Once Phase 1 has
  // begun, "Phase 1 begins" drops out of upcoming → without this card
  // there's no way to know which phase is active.
  const currentPhase = phases.find(p => {
    if (!p.start || !p.end) return false;
    const start = new Date(p.start + "T00:00:00").getTime();
    const end = new Date(p.end + "T23:59:59").getTime();
    const now = today.getTime();
    return now >= start && now <= end;
  });

  return (
    <section className="rc-card glass milestones-card">
      <div className="rc-head">
        <div className="rc-title">MILESTONES</div>
        <div className="milestones-count">{thisWeek.length + comingUp.length} upcoming</div>
      </div>

      {currentPhase && (
        <div className="milestones-section" style={{ marginBottom: 12 }}>
          <div className="milestones-section-head">CURRENT PHASE</div>
          <div className="milestones-list">
            <div className="milestone-row">
              <div className="milestone-date">
                <div className="milestone-date-text" style={{ color: currentPhase.color || "var(--ok)" }}>
                  {currentPhase.id ? currentPhase.id.toUpperCase().replace(/^P/, "P") : ""}
                </div>
                <div className="milestone-date-rel">
                  {(() => {
                    const end = new Date(currentPhase.end + "T00:00:00").getTime();
                    const left = Math.max(0, Math.round((end - today.getTime()) / 86400000));
                    return left === 0 ? "ends today" : left === 1 ? "1 day left" : `${left} days left`;
                  })()}
                </div>
              </div>
              <div className="milestone-body">
                <span className="milestone-type" style={{ color: "var(--ok-deep)", background: "color-mix(in srgb, var(--ok) 28%, transparent)" }}>
                  Active
                </span>
                <div className="milestone-label">{currentPhase.name || currentPhase.id}</div>
                <div className="milestone-lead">— {currentPhase.start} → {currentPhase.end}</div>
              </div>
            </div>
          </div>
        </div>
      )}

      {sortedUpcoming.length === 0 && !currentPhase && (
        <div className="milestones-empty">No upcoming milestones.</div>
      )}

      {thisWeek.length > 0 && (
        <div className="milestones-section">
          <div className="milestones-section-head">THIS WEEK</div>
          <div className="milestones-list">
            {thisWeek.map(renderRow)}
          </div>
        </div>
      )}

      {comingUp.length > 0 && (
        <div className="milestones-section">
          <div className="milestones-section-head">COMING UP</div>
          <div className="milestones-list">
            {comingUp.map(renderRow)}
          </div>
        </div>
      )}
    </section>
  );
}

function _PipelineOverviewCard_unused() {
  const data = window.__appData || {};
  const shots = data.shots || [];
  const total = shots.length || 1;
  const stageDone = (s, k) => s && s.stage_status && s.stage_status[k] === "done";
  const countAt = (k) => shots.filter(s => stageDone(s, k)).length;

  // 4 telescoping stages mirroring the topbar donut
  const firstPass = countAt("first_pass");
  const concept   = countAt("hero");
  const rendering = shots.filter(s => stageDone(s, "video_prompt") && !stageDone(s, "video")).length;
  const completed = countAt("video");

  const pct = (n) => Math.round((n / total) * 100);
  const stages = [
    { id: "first_pass", label: "First Pass", count: firstPass, pct: pct(firstPass), color: "var(--sand)", from: "var(--tan)", to: "var(--paper-2)" },
    { id: "concept",    label: "Concept",    count: concept,   pct: pct(concept),   color: "var(--amber)", from: "var(--amber-deep)", to: "var(--amber-pale)" },
    { id: "rendering",  label: "Rendering",  count: rendering, pct: pct(rendering), color: "var(--teal)", from: "var(--teal-deep)", to: "var(--teal-pale)" },
    { id: "completed",  label: "Completed",  count: completed, pct: pct(completed), color: "var(--leaf)", from: "var(--leaf-deep)", to: "var(--leaf-pale)" },
  ];

  // Velocity sparkline — last 7 days fake but plausible: completed grows, first-pass stable
  const spark = [
    { d: "Mon", fp: 92, hero: 24, vid: 8 },
    { d: "Tue", fp: 96, hero: 28, vid: 10 },
    { d: "Wed", fp: 102, hero: 30, vid: 14 },
    { d: "Thu", fp: 108, hero: 32, vid: 18 },
    { d: "Fri", fp: 116, hero: 34, vid: 22 },
    { d: "Sat", fp: 120, hero: 36, vid: 28 },
    { d: "Sun", fp: 124, hero: 38, vid: 32 },
  ];
  const sparkMax = 130;

  return (
    <section className="rc-card glass pipeline-card">
      <div className="rc-head">
        <div className="rc-title">PIPELINE OVERVIEW</div>
        <div className="pipe-total"><strong>{total}</strong> shots</div>
      </div>

      {/* Stage progress bars — proper named stages, no cropping */}
      <div className="pipe-stages">
        {stages.map(s => (
          <div key={s.id} className="pipe-stage">
            <div className="pipe-stage-head">
              <span className="pipe-stage-dot" style={{background: `linear-gradient(135deg, ${s.from}, ${s.to})`}}/>
              <span className="pipe-stage-label">{s.label}</span>
              <span className="pipe-stage-count">{s.count}</span>
              <span className="pipe-stage-pct">{s.pct}%</span>
            </div>
            <div className="pipe-stage-track">
              <div className="pipe-stage-fill"
                style={{
                  width: `${s.pct}%`,
                  background: `linear-gradient(90deg, ${s.from}, ${s.to})`,
                }}
              />
            </div>
          </div>
        ))}
      </div>

      {/* 7-day velocity */}
      <div className="pipe-velocity">
        <div className="pipe-vel-head">
          <span className="pipe-vel-label">7-DAY VELOCITY</span>
          <span className="pipe-vel-stat">
            <span className="pipe-vel-leaf">+8</span> completed this week
          </span>
        </div>
        <svg className="pipe-spark" viewBox="0 0 280 56" preserveAspectRatio="none">
          {/* completed area */}
          <polyline
            fill="none"
            stroke="var(--leaf)"
            strokeWidth="1.8"
            strokeLinejoin="round"
            points={spark.map((d, i) => `${(i / (spark.length-1)) * 280},${56 - (d.vid / sparkMax) * 50}`).join(" ")}
          />
          {/* concept line */}
          <polyline
            fill="none"
            stroke="var(--amber)"
            strokeWidth="1.6"
            strokeOpacity="0.7"
            strokeLinejoin="round"
            points={spark.map((d, i) => `${(i / (spark.length-1)) * 280},${56 - (d.hero / sparkMax) * 50}`).join(" ")}
          />
          {/* first pass line */}
          <polyline
            fill="none"
            stroke="var(--tan)"
            strokeWidth="1.4"
            strokeOpacity="0.6"
            strokeLinejoin="round"
            points={spark.map((d, i) => `${(i / (spark.length-1)) * 280},${56 - (d.fp / sparkMax) * 50}`).join(" ")}
          />
        </svg>
        <div className="pipe-vel-axis">
          {spark.map(d => <span key={d.d}>{d.d[0]}</span>)}
        </div>
      </div>
    </section>
  );
}

// Right column: Calendar, upcoming Milestones, and per-sequence Shot Breakdown.
// Pipeline Overview lives in the topbar (4 stage columns + interactive donut).
const RIGHT_DEFAULT = ["schedule", "milestones", "shot-breakdown"];

function RightColumn() {
  const [bump, setBump] = React.useState(0);
  React.useEffect(() => {
    const fn = () => setBump(b => b + 1);
    window.addEventListener("paradise-layout-reset", fn);
    return () => window.removeEventListener("paradise-layout-reset", fn);
  }, []);

  const PANELS = {
    "schedule":          <ScheduleCard/>,
    "milestones":        <MilestonesCard/>,
    // pipeline-overview removed — moved into the topbar.
    "shot-breakdown":    <ShotBreakdownCard shots={(window.__appData && window.__appData.shots) || []} sequences={(window.__appData && window.__appData.sequences) || []}/>,
  };

  const { order, getProps } = window.useSortableZone("right", RIGHT_DEFAULT, { axis: "y" });

  return (
    <aside className="right-column sortable-zone-right" key={bump}>
      {order.map(id => (
        <window.SortablePanel key={id} {...getProps(id)}>
          {PANELS[id]}
        </window.SortablePanel>
      ))}
    </aside>
  );
}

function LeftQueueColumn() {
  return (
    <aside className="left-queue-column">
      <ShotQueueCard/>
    </aside>
  );
}
Object.assign(window, { LeftQueueColumn });

Object.assign(window, { RightColumn, ScheduleCard, PipelineOverviewCard, MiniDonut });
