// ── Edit Plan (v1013) ───────────────────────────────────────────────────────
// Every note left on one edit review, turned into a ranked plan of work.
//
//   Notes       every note on the chosen edit, in timecode order, big frames
//   Priorities  a BOARD of the ranked notes only — columns by priority or by
//               status, your pick. Unranked notes never appear here.
//   Stats       what the notes actually say, counted
//   Plan        a producer's one-sheet: nine numbered MOVES in the order to do
//               them, plus the fix-together batches. Reasons fold away until
//               asked for, so it reads as a plan and not a data dump.
//
// Nothing here invents storage. A note IS a `video_comments` row, and it already
// carries its own frame (`frame_url`, ffmpeg-extracted at `timecode_seconds`) and
// its own shot (`shot_id_detected`, read off that frame). This page adds only two
// fields — `priority` and `task_status` — via PATCH /api/video-comments/:id.
//
// The popup player is the app-wide `window.__openReview(reviewId, seconds, opts)`
// (GlobalReviewModal, mounted once in App.jsx). We do not build a second one; we
// pass it `{ shotId }` so it shows only the notes for the shot you clicked.

const EP_STATUSES = [
  { key: "pending",     label: "Pending",     short: "Pending" },
  { key: "in_progress", label: "In Progress", short: "Doing"   },
  { key: "for_review",  label: "For Review",  short: "Review"  },
  { key: "completed",   label: "Completed",   short: "Done"    },
];
const EP_STATUS_LABEL = EP_STATUSES.reduce((a, s) => (a[s.key] = s.label, a), {});
// A row that has never been touched reads as "pending" — that way the 189 rows
// already in the DB needed no backfill write.
const epStatus = (c) => {
  const s = String((c && c.task_status) || "").trim();
  return EP_STATUS_LABEL[s] ? s : "pending";
};
const epPriority = (c) => {
  const n = Number(c && c.priority);
  return (n === 1 || n === 2 || n === 3) ? n : 0;   // 0 = unranked
};

const epFmtTC = (s) => {
  const t = Math.max(0, Math.floor(Number(s) || 0));
  const m = Math.floor(t / 60);
  return m + ":" + String(t % 60).padStart(2, "0");
};
const epFmtDate = (v) => {
  if (!v) return "";
  const d = new Date(String(v).includes("T") ? v : String(v).replace(" ", "T") + "Z");
  if (isNaN(d.getTime())) return String(v).slice(0, 10);
  return d.toLocaleDateString(undefined, { day: "numeric", month: "short" });
};
// Same hash the Review page uses for comment avatars, so a person is the same
// colour on both pages. Kept local because reviewGradient is module-scope in
// ReviewPage.jsx and never exported.
const epHue = (s) => {
  let h = 0;
  for (const c of String(s || "")) h = (h * 31 + c.charCodeAt(0)) | 0;
  return Math.abs(h) % 360;
};
const epUserColor = (name) => "oklch(0.55 0.10 " + epHue(name || "system") + ")";
const epOpenShot = (id) => { if (id && window.__nav && window.__nav.openShot) window.__nav.openShot(id); };
// v1026 — open the Generate page already loaded with this shot. Same hand-off the
// shot modal's Generate pill uses: stash the id, pick a sensible mode from the
// shot's stage, fire the event for an already-mounted page, then navigate.
const epOpenGenerate = (id) => {
  if (!id) return;
  try {
    const shots = (window.__appData && window.__appData.shots) || [];
    const shot = shots.find(x => x.id === id);
    const STAGE_TO_MODE = {
      "PENDING": "grid", "PROMPT": "grid", "FIRST-PASS": "grid",
      "CONCEPT-WIP": "single", "CONCEPT-APPROVED": "video",
      "VIDEO-WIP": "video", "VIDEO-APPROVED": "video",
      "UPSCALED": "video", "ARCHIVE": "grid",
    };
    const stage = (shot && window.getCurrentStage) ? window.getCurrentStage(shot) : "PENDING";
    window.__pendingGenerateShotId = id;
    window.__pendingGenerateMode = STAGE_TO_MODE[stage] || "grid";
    try { window.dispatchEvent(new CustomEvent("paradise-pending-generate-shot")); } catch (_) {}
    ((window.__nav && window.__nav.setView) || window.__navigate)("generate");
  } catch (_) {}
};
// v1030 - the one-click assignee is whoever holds the EDITOR role, resolved at
// runtime from the crew list. Deliberately NOT a hardcoded user id: if Marta is
// ever replaced the button follows the role and nothing here needs editing.
// crew.json members can carry a role with no DB id, so an id is required - the
// server validates assigned_to_id against the users table and would 400 on one.
const epFindEditor = () => {
  const crew = (window.__appData && window.__appData.crew) || [];
  // /api/crew mixes two kinds of row: real DB users, stamped with a "u:<id>" id
  // (server.js ~5313), and static crew.json entries with no DB user behind them.
  // Only the first kind can own a note — the server validates assigned_to_id
  // against the users table — so match the "u:" shape and return the BARE id.
  // Role case varies in the payload ("Editor"), hence the toLowerCase.
  const m = crew.find(c => String(c.role || "").toLowerCase() === "editor"
    && /^u:\d+$/.test(String(c.id || "")));
  if (!m) return null;
  const name = String(m.name || "").trim() || "the editor";
  return { id: String(m.id).slice(2), name: name, first: name.split(" ")[0] };
};
const epCanGenerate = () => (window.hasPerm && window.hasPerm("generate_assets"))
  || (window.__currentUser && window.__currentUser.role === "admin");

// v1089 — SEARCH the notes. Hugo: "i need a search function in the notes of the edit plan page
// so i can filter by words in the notes." Every word typed must be in the note (a "quoted
// phrase" counts as one word); upper / lower case and accents do not matter. The shot number
// counts too, so "SH0190" finds that shot's notes. The author's name does NOT: "mark" has to
// find Mark Twain, not every note Markus wrote (the Notes by chips already filter by person).
const epFold = (s) => String(s == null ? "" : s).normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
const epSearchTerms = (q) => {
  const out = [];
  String(q || "").replace(/"([^"]+)"|(\S+)/g, (m, phrase, word) => {
    const t = epFold(phrase || word).trim();
    if (t) out.push(t);
    return m;
  });
  return out;
};
const epMatchesSearch = (n, terms) => {
  if (!terms || !terms.length) return true;
  const hay = epFold(String((n && n.body) || "") + " " + String((n && n.shot_id_detected) || ""));
  return terms.every(t => hay.indexOf(t) >= 0);
};
// Marks each search word in a note's text. The marks ignore case only; the filter above also
// ignores accents, so a word typed without its accent still finds the note, just unmarked.
const epHighlight = (text, terms) => {
  const s = String(text == null ? "" : text);
  if (!terms || !terms.length || !s) return s;
  const re = new RegExp("(" + terms.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|") + ")", "gi");
  return s.split(re).map((p, i) => (i % 2 ? <mark key={i} className="ep-hl">{p}</mark> : p));
};

// ── One note row, shared by every tab ───────────────────────────────────────
function EditPlanRow({ note, reviewId, canRank, canStatus, canAssign, editor, onPatch, busy, compact, terms }) {
  const tu = window.thumbUrl || ((u) => u);
  // v1030 - editing a note rewrites what somebody SAID, so only its author or an
  // admin may. The server enforces the same rule; this only hides the affordance.
  const me = window.__currentUser || null;
  const canEditText = !!me && (String(me.role) === "admin"
    || (note.user_id != null && String(me.id) === String(note.user_id)));
  const [editing, setEditing] = React.useState(false);
  const [draft, setDraft] = React.useState("");
  const [editH, setEditH] = React.useState(null);
  const textRef = React.useRef(null);
  const taRef = React.useRef(null);
  const shot = /^SH\d{4}$/.test(String(note.shot_id_detected || "")) ? note.shot_id_detected : null;
  const pri = epPriority(note);
  const st = epStatus(note);
  const colour = epUserColor(note.user_name);

  const assignedTo = String(note.assigned_to_id || "");
  const onEditorList = !!editor && assignedTo === String(editor.id);

  const beginEdit = () => {
    if (!canEditText || busy) return;
    // Freeze the paragraph's CURRENT height and hand it to the textarea, so
    // swapping one for the other cannot move anything below (invariant #20).
    const h = textRef.current ? textRef.current.getBoundingClientRect().height : 0;
    setEditH(h > 24 ? Math.round(h) : null);
    setDraft(String(note.body || ""));
    setEditing(true);
  };
  const commitEdit = () => {
    const txt = draft.trim();
    setEditing(false);
    if (!txt || txt === String(note.body || "").trim()) return;   // nothing changed
    onPatch(note.id, { body: txt });
  };
  React.useEffect(() => {
    if (editing && taRef.current) { taRef.current.focus(); taRef.current.select(); }
  }, [editing]);

  // Pass the shot through, so the popup shows ONLY this shot's notes.
  const openAt = () => {
    if (window.__openReview) {
      window.__openReview(reviewId, Number(note.timecode_seconds) || 0, { shotId: shot, focusId: note.id });
    }
  };

  // The thumbnail loads EAGER, not lazy. Lazy images in this list never start
  // loading at all (measured: 0 of 147, while an eager clone of the very same
  // URL loads fine) — the stall this codebase has hit before. The frames are
  // ~5 KB WebPs the server already has cached.
  const thumbW = compact ? 320 : 560;

  return (
    <li className={"ep-row is-" + st + (pri ? " ep-row--p" + pri : "") + (compact ? " is-compact" : "")}
      style={{ "--ep-user": colour }}>
      {note.is_task ? (
        /* v1031 - a to-do is not pinned to a frame. Same box so it lines up with
           the note cards beside it, but nothing to play and no timecode. */
        <div className="ep-thumb is-task">
          <span className="ep-thumb-task">
            <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
              <path d="M3 6.5 5 8.5 9 4.5"/><path d="M3 14.5 5 16.5 9 12.5"/><path d="M13 6.5h8"/><path d="M13 14.5h8"/>
            </svg>
            To do
          </span>
        </div>
      ) : (
        <button type="button" className="ep-thumb" onClick={openAt}
          title={"Play the edit at " + epFmtTC(note.timecode_seconds)}>
          {note.frame_url
            ? <img src={tu(note.frame_url, thumbW)} alt="" loading="eager"/>
            : <span className="ep-thumb-ph">▶</span>}
          <span className="ep-thumb-tc">{epFmtTC(note.timecode_seconds)}</span>
        </button>
      )}

      <div className="ep-body">
        <div className="ep-meta">
          {note.is_task
            ? <span className="ep-shot-chip is-task">Task</span>
            : shot
            ? <button type="button" className="ep-shot-chip" onClick={() => epOpenShot(shot)} title={"Open " + shot}>{shot}</button>
            : <span className="ep-shot-chip is-none">no shot</span>}
          <span className="ep-who" title={note.user_name || ""}>
            <span className="ep-who-dot"/>{note.user_name || "—"}
          </span>
          <span className="ep-date">{epFmtDate(note.created_at)}</span>
          {note.reply_count ? <span className="ep-replies">{note.reply_count} repl{note.reply_count === 1 ? "y" : "ies"}</span> : null}
          {note.resolved ? <span className="ep-resolved">resolved</span> : null}
        </div>
        {editing ? (
          <textarea ref={taRef} className="ep-text-edit" value={draft}
            style={editH ? { height: editH + "px" } : undefined}
            onChange={(e) => setDraft(e.target.value)}
            onBlur={commitEdit}
            onKeyDown={(e) => {
              if (e.key === "Escape") { e.preventDefault(); setEditing(false); }
              // Enter saves. Shift+Enter is a new line - these notes are short.
              if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); commitEdit(); }
            }}/>
        ) : (
          <p ref={textRef} className={"ep-text" + (canEditText ? " is-editable" : "")}
            onClick={beginEdit}
            title={canEditText ? "Click to edit this note" : undefined}>{epHighlight(note.body, terms)}</p>
        )}
        {/* v1026/v1030 - one click to the shot, to Generate, or onto the editor's
            list. ALWAYS rendered, even with no shot and no editor, so no card can
            end up a different height from its neighbour (invariant #20). */}
        <div className="ep-actions">
          {shot ? (
            <React.Fragment>
              <button type="button" className="ep-act" onClick={() => epOpenShot(shot)}
                title={"Open " + shot}>
                <svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                  <rect x="3" y="4" width="18" height="14" rx="2"/><path d="M3 9h18"/>
                </svg>
                Open shot
              </button>
              {epCanGenerate() && (
                <button type="button" className="ep-act ep-act--gen" onClick={() => epOpenGenerate(shot)}
                  title={"Generate for " + shot}>
                  <svg viewBox="0 0 24 24" width="11" height="11" fill="currentColor" stroke="none" aria-hidden="true">
                    <path d="M13 2 4 14h6l-1 8 9-12h-6l1-8z"/>
                  </svg>
                  Generate
                </button>
              )}
            </React.Fragment>
          ) : <span className="ep-act-none">{note.is_task ? "" : "no shot linked"}</span>}
          {editor && canAssign && (
            /* The LABEL never changes - only the colour and the icon - so this
               button cannot change width and re-wrap the row onto two lines. */
            <button type="button"
              className={"ep-act ep-act--assign" + (onEditorList ? " is-on" : "")}
              disabled={busy}
              title={onEditorList
                ? "On " + editor.name + "'s list - click to take it off"
                : "Add this note to " + editor.name + "'s list"}
              onClick={() => onPatch(note.id, { assigned_to_id: onEditorList ? null : String(editor.id) })}>
              <svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                {onEditorList
                  ? <path d="M4 12.5 9 17.5 20 6.5"/>
                  : <React.Fragment><path d="M12 5v14"/><path d="M5 12h14"/></React.Fragment>}
              </svg>
              {editor.first}
            </button>
          )}
        </div>
      </div>

      <div className="ep-controls">
        {/* Two labelled columns. Every button is always rendered at a fixed
            size — pressing one only recolours it, so nothing below moves. */}
        <div className="ep-ctl-col">
          <span className="ep-ctl-label">Priority</span>
          <div className="ep-pri" role="group" aria-label="Priority">
            {[1, 2, 3].map(n => (
              <button key={n} type="button"
                className={"ep-pri-btn ep-pri-btn--" + n + (pri === n ? " is-on" : "")}
                disabled={!canRank || busy}
                title={canRank
                  ? (pri === n ? "Clear this ranking" : "Priority " + n)
                  : "Only a ranker can set priority"}
                onClick={() => onPatch(note.id, { priority: pri === n ? null : n })}>
                {n}
              </button>
            ))}
          </div>
        </div>
        <div className="ep-ctl-col">
          <span className="ep-ctl-label">Status</span>
          <div className="ep-status" role="group" aria-label="Status">
            {EP_STATUSES.map(s => (
              <button key={s.key} type="button"
                className={"ep-status-btn ep-status-btn--" + s.key + (st === s.key ? " is-on" : "")}
                disabled={!canStatus || busy}
                title={canStatus ? s.label : "You can't change status"}
                onClick={() => onPatch(note.id, { task_status: s.key })}>
                {s.short}
              </button>
            ))}
          </div>
        </div>
      </div>
    </li>
  );
}

// ── v1046 — the progress donut ─────────────────────────────────────────────
// Geometry MEASURED off the real Overview ring rather than invented: viewBox
// 0 0 200 200, r=78, stroke-width 11, ROUND caps, gradient strokes. v1045 used
// r26 with an 11-wide stroke, which is nearly 3x chunkier in proportion — which
// is exactly why Hugo said it looked nothing like it. Gradient stops are lifted
// verbatim from TopBar.jsx so the two rings are the same material.
const EP_ARC_GRAD = {
  pending:     [["0%", "color-mix(in srgb, var(--card-border) 55%, transparent)"], ["100%", "color-mix(in srgb, var(--tan-3) 70%, transparent)"]],
  in_progress: [["0%", "var(--arc-notes-lo)"], ["55%", "var(--arc-notes-mid)"], ["100%", "var(--arc-notes-hi)"]],
  for_review:  [["0%", "var(--arc-review-lo)"], ["55%", "var(--arc-review-mid)"], ["100%", "var(--arc-review-hi)"]],
  completed:   [["0%", "var(--arc-status-lo)"], ["55%", "var(--arc-status-mid)"], ["100%", "var(--arc-status-hi)"]],
};
function EpDonut({ counts, total, size }) {
  const R = 78, C = 2 * Math.PI * R;
  let acc = 0;
  const arcs = [];
  EP_STATUSES.forEach(st => {
    const n = counts[st.key] || 0;
    if (!n || !total) return;
    const len = (n / total) * C;
    arcs.push({ key: st.key, len: len, off: -acc });
    acc += len;
  });
  const pct = total ? Math.round(((counts.completed || 0) / total) * 100) : 0;
  return (
    <div className="ep-donut" style={{ width: size, height: size }}>
      <svg viewBox="0 0 200 200" width={size} height={size} shapeRendering="geometricPrecision">
        <defs>
          {Object.keys(EP_ARC_GRAD).map(k => (
            <linearGradient key={k} id={"epArc-" + k} x1="0" y1="0" x2="0" y2="1">
              {EP_ARC_GRAD[k].map(([o, c]) => <stop key={o} offset={o} stopColor={c}/>)}
            </linearGradient>
          ))}
        </defs>
        <circle className="ep-donut-track" cx="100" cy="100" r={R}/>
        <g transform="rotate(-90 100 100)">
          {arcs.map(a => (
            <circle key={a.key} className="ep-donut-arc"
              cx="100" cy="100" r={R} stroke={"url(#epArc-" + a.key + ")"}
              strokeDasharray={a.len + " " + (C - a.len)}
              strokeDashoffset={a.off}/>
          ))}
        </g>
      </svg>
      <span className="ep-donut-pct">{pct}<i>%</i></span>
    </div>
  );
}

// ── A shot chip with the plan's read of it in the tooltip ───────────────────
function EditPlanShotChip({ s }) {
  return (
    <button type="button"
      className={"ep-pchip" + (s.impact >= 5 ? " is-critical" : "") + (s.effort <= 2 ? " is-cheap" : "")}
      onClick={() => epOpenShot(s.shot)}
      title={s.shot + " · " + s.notes + " note" + (s.notes === 1 ? "" : "s") + " · effort " + s.effort + "/5 · impact " + s.impact + "/5\n" + s.why}>
      {s.shot}
      {s.notes > 1 && <sup>{s.notes}</sup>}
    </button>
  );
}

// ── The page ────────────────────────────────────────────────────────────────
function EditPlanPage() {
  const fetcher = window.authFetch || fetch;
  const hasPerm = window.hasPerm || (() => true);

  const [tab, setTab] = React.useState(() => {
    try { return localStorage.getItem("filmtracker.editplan.tab") || "notes"; } catch (e) { return "notes"; }
  });
  const [boardBy, setBoardBy] = React.useState(() => {
    try { return localStorage.getItem("filmtracker.editplan.boardby") || "priority"; } catch (e) { return "priority"; }
  });
  const [reviews, setReviews] = React.useState([]);
  const [reviewId, setReviewId] = React.useState(() => {
    try { const v = Number(localStorage.getItem("filmtracker.editplan.review")); return v || null; } catch (e) { return null; }
  });
  const [notes, setNotes] = React.useState([]);
  const [analysis, setAnalysis] = React.useState(null);
  const [openWhy, setOpenWhy] = React.useState({});     // { [categoryKey]: true }
  const [loading, setLoading] = React.useState(true);
  const [busyId, setBusyId] = React.useState(null);
  const [err, setErr] = React.useState("");
  const [showResolved, setShowResolved] = React.useState(false);
  // v1017 — filter by who wrote the note. Applies to the whole page, not just
  // this tab, so the board, the counts and the stats all follow the same set.
  const [authorFilter, setAuthorFilter] = React.useState(() => {
    try { return localStorage.getItem("filmtracker.editplan.author") || "all"; } catch (e) { return "all"; }
  });
  // v1022 — filter by PRIORITY. "all" | "1" | "2" | "3" | "0" (unranked) |
  // "ranked" (anything Markus has given a number). Page-wide, like the author
  // filter, so the Notes list, the board and the Stats all agree.
  const [priFilter, setPriFilter] = React.useState(() => {
    try { return localStorage.getItem("filmtracker.editplan.pri") || "all"; } catch (e) { return "all"; }
  });
  // v1048 - "mine" | "editor" | "all". A SCOPE, not a filter: it answers "whose
  // plan is this", so it drives the stat bar and the priority bars too. New
  // storage key on purpose — anyone holding the old "all" would otherwise stay
  // on the mixed list this change exists to get rid of.
  const [assignFilter, setAssignFilter] = React.useState(() => {
    try {
      const saved = localStorage.getItem("filmtracker.editplan.scope");
      if (saved === "mine" || saved === "editor" || saved === "all") return saved;
    } catch (e) {}
    const me = window.__currentUser;
    return (me && String(me.role) === "editor") ? "editor" : "mine";
  });

  // v1089 — the search box on the filter row (see epSearchTerms). NOT saved between visits
  // on purpose: a search left over from last time would read as notes that went missing.
  const [query, setQuery] = React.useState("");
  const searchTerms = React.useMemo(() => epSearchTerms(query), [query]);
  const searchRef = React.useRef(null);

  const canRank = hasPerm("rank_review_notes");
  const canStatus = hasPerm("comment_on_shots");
  // Handing work out is the same call as ranking it, so it shares that gate.
  const canAssign = canRank;
  // Recomputed each render on purpose: the crew arrives with /api/data, after
  // this component first mounts, so a useMemo with no deps would freeze it null.
  const editor = epFindEditor();
  const editorId = editor ? editor.id : null;

  React.useEffect(() => { try { localStorage.setItem("filmtracker.editplan.tab", tab); } catch (e) {} }, [tab]);
  React.useEffect(() => { try { localStorage.setItem("filmtracker.editplan.boardby", boardBy); } catch (e) {} }, [boardBy]);
  React.useEffect(() => { try { localStorage.setItem("filmtracker.editplan.author", authorFilter); } catch (e) {} }, [authorFilter]);
  React.useEffect(() => { try { localStorage.setItem("filmtracker.editplan.pri", priFilter); } catch (e) {} }, [priFilter]);
  React.useEffect(() => { try { localStorage.setItem("filmtracker.editplan.scope", assignFilter); } catch (e) {} }, [assignFilter]);
  React.useEffect(() => {
    if (reviewId) { try { localStorage.setItem("filmtracker.editplan.review", String(reviewId)); } catch (e) {} }
  }, [reviewId]);

  // ── the review list. Only ones that actually carry notes are worth showing.
  React.useEffect(() => {
    let alive = true;
    fetcher("/api/video-reviews")
      .then(r => r.ok ? r.json() : null)
      .then(d => {
        if (!alive) return;
        const all = (d && Array.isArray(d.reviews)) ? d.reviews : [];
        const withNotes = all.filter(r => Number(r.comment_count) > 0);
        // uploaded_at is stored in two formats (ISO and "YYYY-MM-DD HH:MM:SS"),
        // so a raw string sort puts them in the wrong order — parse to a date.
        const ts = (r) => {
          const v = String(r.uploaded_at || "");
          const d = new Date(v.includes("T") ? v : v.replace(" ", "T") + "Z");
          return isNaN(d.getTime()) ? 0 : d.getTime();
        };
        withNotes.sort((a, b) => ts(b) - ts(a));
        setReviews(withNotes);
        setReviewId(cur => (cur && withNotes.some(r => r.id === cur)) ? cur
          : (withNotes.length ? withNotes[0].id : null));
        if (!withNotes.length) setLoading(false);
      })
      .catch(() => { if (alive) { setErr("Couldn't load the edits."); setLoading(false); } });
    return () => { alive = false; };
  }, []);

  // ── the written plan (a data file, served through the API)
  React.useEffect(() => {
    let alive = true;
    fetcher("/api/edit-plan/analysis")
      .then(r => r.ok ? r.json() : null)
      .then(d => { if (alive) setAnalysis((d && d.analysis) || null); })
      .catch(() => { if (alive) setAnalysis(null); });
    return () => { alive = false; };
  }, []);

  // ── the notes for the chosen review
  const loadSeq = React.useRef(0);
  const load = React.useCallback(() => {
    if (!reviewId) { setNotes([]); setLoading(false); return; }
    const seq = ++loadSeq.current;
    setErr("");
    // ?tasks=1 - the Edit Plan is the only caller that wants the standalone
    // to-dos; the Review page must keep seeing notes on frames only.
    fetcher("/api/video-reviews/" + reviewId + "?tasks=1")
      .then(r => r.ok ? r.json() : Promise.reject(new Error("load failed")))
      .then(d => {
        if (seq !== loadSeq.current) return;               // a newer load already won
        const all = Array.isArray(d.comments) ? d.comments : [];
        // Replies live in the same table, pointing at their parent. They are
        // never rows in this plan — a reply is part of its parent's thread.
        const replies = {};
        all.forEach(c => { if (c.parent_comment_id) replies[c.parent_comment_id] = (replies[c.parent_comment_id] || 0) + 1; });
        setNotes(all.filter(c => !c.parent_comment_id).map(c => ({ ...c, reply_count: replies[c.id] || 0 })));
        setLoading(false);
      })
      .catch(() => { if (seq === loadSeq.current) { setErr("Couldn't load the notes."); setLoading(false); } });
  }, [reviewId]);

  React.useEffect(() => { setLoading(true); load(); }, [load]);

  // ── live: any review change anywhere re-pulls this list, so two people
  // ranking at the same time see each other within the SSE tick.
  React.useEffect(() => {
    const onSse = (e) => {
      const t = e && e.detail && e.detail.type;
      if (t === "review_changed" || t === "note_added") load();
    };
    window.addEventListener("paradise-sse", onSse);
    return () => window.removeEventListener("paradise-sse", onSse);
  }, [load]);

  // v1031 - add a job to the plan that was never a note on a frame.
  const [taskDraft, setTaskDraft] = React.useState("");
  const [taskPri, setTaskPri] = React.useState(1);
  const [taskBusy, setTaskBusy] = React.useState(false);

  // ── one write path for both fields
  const patch = React.useCallback((id, fields) => {
    setBusyId(id);
    setErr("");
    // Optimistic: the row moves the instant you click. The SSE echo re-pulls the
    // server's own copy a moment later, so a rejected write corrects itself.
    setNotes(list => list.map(n => n.id === id ? { ...n, ...fields } : n));
    fetcher("/api/video-comments/" + id, {
      method: "PATCH",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(fields),
    })
      .then(r => r.ok ? r.json() : r.json().then(j => Promise.reject(new Error(j.error || "save failed"))))
      .then(d => {
        if (d && d.comment) setNotes(list => list.map(n => n.id === id ? { ...n, ...d.comment } : n));
      })
      .catch((e) => { setErr(e.message || "Couldn't save that."); load(); })
      .then(() => setBusyId(null));
  }, [load]);

  const addTask = React.useCallback(() => {
    const txt = taskDraft.trim();
    if (!txt || !reviewId || taskBusy) return;
    setTaskBusy(true);
    setErr("");
    fetcher("/api/edit-plan/task", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ video_review_id: reviewId, body: txt, priority: taskPri }),
    })
      .then(r => r.ok ? r.json() : r.json().then(j => Promise.reject(new Error(j.error || "save failed"))))
      .then(() => { setTaskDraft(""); load(); })
      .catch((e) => setErr(e.message || "Couldn't add that task."))
      .then(() => setTaskBusy(false));
  }, [taskDraft, taskPri, reviewId, taskBusy, load]);

  // ── derived
  // Built from the UNFILTERED list on purpose: selecting one person must not make
  // the other chips disappear.
  const authors = React.useMemo(() => {
    const by = {};
    (showResolved ? notes : notes.filter(n => !n.resolved)).forEach(n => {
      const key = String(n.user_id != null ? n.user_id : (n.user_name || "?"));
      if (!by[key]) by[key] = { key, name: n.user_name || "—", n: 0 };
      by[key].n++;
    });
    return Object.values(by).sort((a, b) => b.n - a.n);
  }, [notes, showResolved]);
  const authorOf = (n) => String(n.user_id != null ? n.user_id : (n.user_name || "?"));

  // v1048 - the single scope test. Everything that answers "whose plan" runs
  // through this, so the list, the counts and the progress can never disagree.
  const _epInScope = React.useCallback((n) => {
    if (!editorId) return true;                       // nobody holds the editor role — one plan
    const onEditors = String(n.assigned_to_id || "") === editorId;
    return assignFilter === "editor" ? onEditors
         : assignFilter === "mine"   ? !onEditors
         : true;
  }, [assignFilter, editorId]);

  const visible = React.useMemo(() => {
    const open = showResolved ? notes : notes.filter(n => !n.resolved);
    const byWho = authorFilter === "all" ? open : open.filter(n => authorOf(n) === authorFilter);
    // v1048 - the SCOPE. Applied before the priority filter so the two stack:
    // "P1 on Marta's list" and "P1 on mine" are both real views.
    const byList = byWho.filter(_epInScope);
    const byPri = priFilter === "all" ? byList
      : priFilter === "ranked" ? byList.filter(n => epPriority(n) > 0)
      : byList.filter(n => String(epPriority(n)) === priFilter);
    // v1089 — the search narrows the view last. Like the chips, it never moves the progress
    // bar or the scope counts: those read `notes`, not `visible`.
    return searchTerms.length ? byPri.filter(n => epMatchesSearch(n, searchTerms)) : byPri;
  }, [notes, showResolved, authorFilter, priFilter, _epInScope, searchTerms]);

  // Counts for the chips come from the author-filtered set but IGNORE the
  // priority filter, so picking P1 never makes the P2 chip disappear.
  const priChipCounts = React.useMemo(() => {
    const open = showResolved ? notes : notes.filter(n => !n.resolved);
    const byWho = authorFilter === "all" ? open : open.filter(n => authorOf(n) === authorFilter);
    const c = { all: byWho.length, ranked: 0, 1: 0, 2: 0, 3: 0, 0: 0 };
    byWho.forEach(n => { const pr = epPriority(n); c[pr]++; if (pr > 0) c.ranked++; });
    return c;
  }, [notes, showResolved, authorFilter]);
  // Counts the editor's list from the author-filtered set but IGNORES the list
  // filter itself, so the chip never reads 0 while you are looking at it.
  const editorListCount = React.useMemo(() => {
    if (!editorId) return 0;
    const open = showResolved ? notes : notes.filter(n => !n.resolved);
    const byWho = authorFilter === "all" ? open : open.filter(n => authorOf(n) === authorFilter);
    return byWho.filter(n => String(n.assigned_to_id || "") === editorId).length;
  }, [notes, showResolved, authorFilter, editorId]);

  // v1048 — one count per scope. Deliberately blind to the author and priority
  // chips: these say how big each PLAN is, not how big the current view is.
  const scopeCounts = React.useMemo(() => {
    const open = showResolved ? (notes || []) : (notes || []).filter(n => !n.resolved);
    let mine = 0, ed = 0;
    open.forEach(n => {
      if (editorId && String(n.assigned_to_id || "") === editorId) ed++; else mine++;
    });
    return { mine: mine, editor: ed, all: open.length };
  }, [notes, showResolved, editorId]);

  const byTimecode = React.useMemo(
    () => visible.slice().sort((a, b) => (Number(a.timecode_seconds) || 0) - (Number(b.timecode_seconds) || 0)),
    [visible]
  );
  const statusCounts = React.useMemo(() => {
    const c = { pending: 0, in_progress: 0, for_review: 0, completed: 0 };
    visible.forEach(n => { c[epStatus(n)]++; });
    return c;
  }, [visible]);
  const priCounts = React.useMemo(() => {
    const c = { 1: 0, 2: 0, 3: 0, 0: 0 };
    visible.forEach(n => { c[epPriority(n)]++; });
    return c;
  }, [visible]);

  // Ranked only — the board never shows unranked notes.
  const ranked = React.useMemo(() => visible.filter(n => epPriority(n) > 0), [visible]);

  const columns = React.useMemo(() => {
    const byTC = (a, b) => (Number(a.timecode_seconds) || 0) - (Number(b.timecode_seconds) || 0);
    if (boardBy === "status") {
      return EP_STATUSES.map(s => ({
        key: s.key, title: s.label, tone: "s-" + s.key,
        rows: ranked.filter(n => epStatus(n) === s.key).sort((a, b) => (epPriority(a) - epPriority(b)) || byTC(a, b)),
      }));
    }
    // v1028 — sort by TIMECODE ONLY. It used to sort by status first, so marking a
    // note "Doing" flung it behind all sixty "Pending" ones — 61 cards down the
    // page, which reads as the card vanishing. A card must stay exactly where it
    // is when you press a control on it; only its colour changes.
    return [1, 2, 3].map(p => ({
      key: "p" + p, title: "Priority " + p + (p === 1 ? " · do first" : p === 3 ? " · last" : ""), tone: "p" + p,
      rows: ranked.filter(n => epPriority(n) === p).sort(byTC),
    }));
  }, [ranked, boardBy]);

  // ── PDF ─────────────────────────────────────────────────────────────────
  // The same approach the Presentations page uses: build one self-contained HTML
  // document, drop it into a hidden iframe and print it. No extra library needed.
  // Chrome names a saved PDF from the TOP-LEVEL document title (a hidden iframe's
  // <title> is ignored), so that is swapped in for the duration and put back.
  //
  // Thumbnails are inlined as data URIs FIRST. A print window will not wait for
  // network images, and half a sheet of grey boxes is worse than no sheet.
  const [pdfBusy, setPdfBusy] = React.useState(false);
  const exportPdf = React.useCallback(async () => {
    if (pdfBusy) return;
    setPdfBusy(true);
    const rows = columns.flatMap(c => c.rows);
    const esc = (t) => String(t == null ? "" : t)
      .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
    try {
      const tu = window.thumbUrl || ((u) => u);
      const cache = new Map();
      const toData = async (url) => {
        if (!url) return null;
        if (cache.has(url)) return cache.get(url);
        try {
          const abs = new URL(tu(url, 400), location.origin).href;
          const blob = await fetch(abs).then(r => r.ok ? r.blob() : null);
          if (!blob) { cache.set(url, null); return null; }
          const d = await new Promise(res => {
            const fr = new FileReader();
            fr.onload = () => res(fr.result); fr.onerror = () => res(null);
            fr.readAsDataURL(blob);
          });
          cache.set(url, d); return d;
        } catch (_) { cache.set(url, null); return null; }
      };
      const queue = rows.slice();
      await Promise.all(Array.from({ length: 6 }, async () => {
        while (queue.length) { const n = queue.shift(); n.__pdfImg = await toData(n.frame_url); }
      }));

      const PRI_LABEL = { 1: "Priority 1 \u2014 do first", 2: "Priority 2", 3: "Priority 3 \u2014 last" };
      const PRI_COLOUR = { 1: "#B4462F", 2: "#C89B33", 3: "#6E8B4E" };
      const groups = [1, 2, 3].map(pr => ({
        p: pr,
        rows: rows.filter(n => epPriority(n) === pr)
          .sort((a, b) => (Number(a.timecode_seconds) || 0) - (Number(b.timecode_seconds) || 0)),
      })).filter(g => g.rows.length);

      // One <table> per group: Chrome repeats a <thead> on every printed page, so
      // the priority header follows its rows across a break instead of orphaning.
      const rowHtml = (n) => {
        const shot = /^SH\d{4}$/.test(String(n.shot_id_detected || "")) ? n.shot_id_detected : "\u2014";
        const img = n.__pdfImg
          ? '<img src="' + n.__pdfImg + '" alt="">'
          : '<span class="ph"></span>';
        return '<tr>'
          + '<td class="c-thumb"><div class="thumb">' + img + '</div></td>'
          + '<td class="c-body">'
          +   '<div class="line1"><span class="shot">' + esc(shot) + '</span>'
          +     '<span class="tc">' + esc(epFmtTC(n.timecode_seconds)) + '</span>'
          +     '<span class="who">' + esc(n.user_name || "") + '</span>'
          +     '<span class="when">' + esc(epFmtDate(n.created_at)) + '</span></div>'
          +   '<div class="note">' + esc(n.body) + '</div>'
          + '</td>'
          + '<td class="c-status"><span class="tag s-' + esc(epStatus(n)) + '">'
          +   esc(EP_STATUS_LABEL[epStatus(n)]) + '</span></td>'
          + '</tr>';
      };
      const body = groups.map(g =>
        '<table class="grp">'
        // table-layout:fixed takes its column widths from the FIRST ROW, and that
        // row is a single <th colspan="3"> — which gave no per-column widths, so
        // the three columns split equally (260/260/260) and the frame ballooned to
        // 248px. An explicit colgroup is the correct way to size a fixed table.
        + '<colgroup><col style="width:21%"><col style="width:65%"><col style="width:14%"></colgroup>'
        + '<thead><tr><th colspan="3" style="border-color:' + PRI_COLOUR[g.p] + '">'
        +   '<span style="color:' + PRI_COLOUR[g.p] + '">' + esc(PRI_LABEL[g.p]) + '</span>'
        +   '<span class="n">' + g.rows.length + ' note' + (g.rows.length === 1 ? '' : 's') + '</span>'
        + '</th></tr></thead>'
        + '<tbody>' + g.rows.map(rowHtml).join("") + '</tbody></table>').join("");

      const title = (current && (current.title || ("Edit " + current.id))) || "Edit";
      const stamp = new Date().toLocaleDateString(undefined, { day: "numeric", month: "long", year: "numeric" });
      // v1054 - WHOSE LIST. Same three-way scope the page is showing, spelled out
      // in words, because the sheet leaves the app and nothing else on it says.
      // When no one holds the editor role there is only one plan, so the label is
      // dropped entirely rather than printing a distinction that does not exist.
      const scopeWords = !editorId ? ""
        : assignFilter === "editor" ? (editor.first || "The editor") + "'s list"
        : assignFilter === "mine"   ? "My list"
        :                             "Everyone's list";
      // Short token for the FILENAME - Chrome names the saved PDF from the
      // top-level document title, so this is what tells two exports apart on disk.
      const scopeTok = !editorId ? ""
        : assignFilter === "editor" ? (editor.first || "Editor")
        : assignFilter === "mine"   ? "Mine"
        :                             "All";
      const docTitle = "Priorities - " + (scopeTok ? scopeTok + " - " : "")
        + title.replace(/[^\w. -]+/g, "") + " - " + new Date().toISOString().slice(0, 10);
      // Describe what is IN the document, not what the board holds — exporting a
      // P1-filtered view and then printing "0 P2 \u00b7 0 P3" reads like an error.
      const counts = groups.map(g => g.rows.length + " P" + g.p).join("  \u00b7  ");
      const whoLine = authorFilter === "all" ? ""
        : "  \u00b7  only " + esc(((authors.find(a => a.key === authorFilter) || {}).name) || "");
      // v1089 — a search narrows the sheet too, so the sheet says so.
      const searchLine = searchTerms.length ? "  \u00b7  matching \u201c" + esc(query.trim()) + "\u201d" : "";

      const css = [
        // The horizontal inset comes from the BODY, not from @page. With a side
        // page-margin the page box shrinks to ~680px while the iframe lays the
        // document out at the full 794px paper width — so the right-hand column
        // was pushed off the sheet every time. Zero side margin keeps the page box
        // and the iframe the same width; body padding supplies the visual margin.
        // 17 Sep 2026 - the top/bottom gap lives in the document too. Chrome's print
        // dialog remembers "Margins: None", which forces every @page margin to 0, so
        // the old "14mm 0 16mm" vanished and every page hugged the paper edge. The
        // sheet is one table whose repeating thead/tfoot rows make the gap on every
        // page (same pattern as the AI footprint statement, src/footprintStatement.js).
        "@page { size: A4 portrait; margin: 0; }",
        "* { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; box-sizing: border-box; }",
        "html, body { margin:0; padding:0; width:100%; overflow-x:hidden; }",
        "body { padding-left:15mm; padding-right:15mm; }",
        "table.sheet { width:100%; border-collapse:collapse; table-layout:fixed; }",
        "table.sheet > thead { display:table-header-group; break-inside:avoid; }",
        "table.sheet > tfoot { display:table-footer-group; break-inside:avoid; }",
        "table.sheet > * > tr > td { padding:0; border:0; vertical-align:top; }",
        ".gap-top { height:14mm; } .gap-bot { height:16mm; }",
        "img, table { max-width:100%; }",
        "body { font-family: Inter, 'SF Pro Text', system-ui, sans-serif; color:#241F17; background:#fff;",
        "       font-size:10pt; line-height:1.45; -webkit-font-smoothing:antialiased; }",
        /* masthead */
        "header { margin:0 0 18px; }",
        ".eyebrow { font-size:7.5pt; font-weight:700; letter-spacing:.22em; text-transform:uppercase; color:#8A8577; margin-bottom:5px; }",
        "h1 { font-family:'Bebas Neue',Impact,sans-serif; font-weight:400; font-size:23pt; line-height:1.02;",
        "     margin:0; letter-spacing:.015em; color:#241F17; }",
        ".sub { margin-top:7px; padding-top:7px; border-top:1px solid #241F17; font-size:8pt; color:#6B665A;",
        "       display:flex; justify-content:space-between; gap:16px; flex-wrap:wrap; }",
        /* groups - scoped to table.grp: an unscoped tr rule would stop the sheet row
           from breaking, an unscoped td border would rule a line under the top gap */
        "table.grp { width:100%; table-layout:fixed; border-collapse:collapse; margin:0 0 20px; }",
        "table.grp > thead { display:table-header-group; }",        /* repeat on every page */
        "table.grp tr { break-inside:avoid; page-break-inside:avoid; }",
        "table.grp th { text-align:left; padding:10px 0 6px; border-bottom:2px solid; ",
        "     font-size:8.5pt; font-weight:700; letter-spacing:.15em; text-transform:uppercase; }",
        "table.grp th .n { float:right; font-family:'JetBrains Mono',monospace; font-weight:500; color:#8A8577;",
        "        letter-spacing:.04em; text-transform:none; }",
        "table.grp td { padding:11px 0; border-bottom:1px solid #EAE4D4; vertical-align:top; }",
        /* columns */
        /* percentages, not pixels: with table-layout:fixed these are exact and the
           table can never be wider than the printable area. Written as td.c-* so
           they still outrank "table.grp td" above for the side padding. */
        "table.grp td.c-thumb { width:22%; padding-right:12px; }",
        "table.grp td.c-body { width:64%; }",
        "table.grp td.c-status { width:14%; padding-left:10px; text-align:right; }",
        ".thumb { width:100%; aspect-ratio:16/9; border-radius:3px; overflow:hidden; background:#EFEADB;",
        "         border:1px solid #DFD8C4; }",
        ".thumb img { width:100%; height:100%; object-fit:cover; display:block; }",
        ".thumb .ph { display:block; width:100%; height:100%; }",
        /* the note */
        ".line1 { margin-bottom:3px; }",
        ".shot { font-family:'JetBrains Mono',monospace; font-size:9pt; font-weight:700; color:#3F5220; margin-right:9px; }",
        ".tc { font-family:'JetBrains Mono',monospace; font-size:8.5pt; color:#241F17; margin-right:12px; }",
        ".who { font-size:8pt; color:#6B665A; margin-right:9px; }",
        ".when { font-size:8pt; color:#A39D8C; }",
        ".note { font-size:10pt; line-height:1.5; overflow-wrap:anywhere; }",
        /* status tag */
        ".tag { display:inline-block; width:100%; max-width:72px; padding:3px 4px; border-radius:3px; border:1px solid #D6CFBB;",
        "       text-align:center;",
        "       font-size:6.5pt; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:#8A8577; }",
        ".tag.s-in_progress { border-color:#C89B33; color:#8A6D1F; background:#FBF3DF; }",
        ".tag.s-for_review  { border-color:#4A6F8C; color:#3C5C76; background:#EEF3F7; }",
        ".tag.s-completed   { border-color:#6E8B4E; color:#3F5220; background:#F0F4E9; }",
        /* footer */
        "footer { margin-top:6px; padding-top:8px; border-top:1px solid #EAE4D4; font-size:7pt; color:#A39D8C; }",
      ].join("\n");

      const html = "<!doctype html><html><head><meta charset=\"utf-8\"><title>" + esc(docTitle) + "</title>"
        + "<link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Inter:wght@400;500;700&family=JetBrains+Mono:wght@500;700&display=swap\">"
        + "<style>" + css + "</style></head><body>"
        + "<table class=\"sheet\"><thead><tr><td><div class=\"gap-top\"></div></td></tr></thead>"
        + "<tfoot><tr><td><div class=\"gap-bot\"></div></td></tr></tfoot><tbody><tr><td>"
        + "<header><div class=\"eyebrow\">Edit priorities"
        +   (scopeWords ? " \u00b7 " + esc(scopeWords) : "") + "</div>"
        + "<h1>" + esc(title) + "</h1>"
        + "<div class=\"sub\"><span>" + esc(stamp) + "</span>"
        + "<span>" + rows.length + " ranked note" + (rows.length === 1 ? "" : "s")
        + "&nbsp;&nbsp;\u00b7&nbsp;&nbsp;" + esc(counts) + whoLine + searchLine + "</span></div></header>"
        + (body || "<p>Nothing is ranked yet.</p>")
        + "<footer>Film Tracker" + (scopeWords ? " \u00b7 " + esc(scopeWords) : "")
        +   " \u00b7 ranked notes only \u00b7 unranked notes are not shown</footer>"
        + "</td></tr></tbody></table></body></html>";

      const iframe = document.createElement("iframe");
      iframe.setAttribute("aria-hidden", "true");
      // A4 at 96dpi. It MUST have real page dimensions: printing from a 1x1
      // iframe made Chrome lay the document out against a 1px viewport, which is
      // why everything ran off the right edge. Parked off-screen rather than
      // shrunk, so the user never sees it.
      iframe.style.cssText = "position:fixed;left:-10000px;top:0;width:794px;height:1123px;border:0;opacity:0;pointer-events:none;";
      document.body.appendChild(iframe);
      const prevTitle = document.title;
      document.title = docTitle;
      try {
        await new Promise(res => { iframe.onload = () => res(); iframe.srcdoc = html; });
        const win = iframe.contentWindow, doc = iframe.contentDocument;
        try { if (doc.fonts) await doc.fonts.ready; } catch (_) {}
        await new Promise(r => setTimeout(r, 350));
        win.focus(); win.print();
      } finally {
        document.title = prevTitle;
        setTimeout(() => { try { iframe.remove(); } catch (_) {} }, 1500);
      }
    } catch (e) {
      if (window.__toast) window.__toast("Couldn't build the PDF", false);
    } finally {
      rows.forEach(n => { delete n.__pdfImg; });   // drop the data URIs
      setPdfBusy(false);
    }
  }, [columns, current, priCounts, authorFilter, authors, pdfBusy, assignFilter, editorId, editor, searchTerms, query]);

  // v1023 — an empty column is only worth showing when you are looking at the
  // whole board. The moment a filter narrows it, empty columns are just noise.
  const liveColumns = React.useMemo(() => {
    const filtering = priFilter !== "all" || authorFilter !== "all" || searchTerms.length > 0;
    const withRows = columns.filter(c => c.rows.length);
    return filtering ? withRows : columns;
  }, [columns, priFilter, authorFilter, searchTerms]);

  // ── Stats
  const stats = React.useMemo(() => {
    const authors = {};
    const perShot = {};
    let withShot = 0, withFrame = 0, replies = 0, longest = 0;
    visible.forEach(n => {
      const a = n.user_name || "—";
      authors[a] = (authors[a] || 0) + 1;
      const s = /^SH\d{4}$/.test(String(n.shot_id_detected || "")) ? n.shot_id_detected : null;
      if (s) { withShot++; perShot[s] = (perShot[s] || 0) + 1; }
      if (n.frame_url) withFrame++;
      replies += n.reply_count || 0;
      longest = Math.max(longest, Number(n.timecode_seconds) || 0);
    });
    const span = Math.max(longest, 1);
    const BUCKETS = 12;
    const hist = new Array(BUCKETS).fill(0);
    visible.forEach(n => {
      const i = Math.min(BUCKETS - 1, Math.floor(((Number(n.timecode_seconds) || 0) / span) * BUCKETS));
      hist[i]++;
    });
    return {
      total: visible.length,
      resolved: notes.filter(n => n.resolved).length,
      withShot, withFrame, replies,
      shots: Object.keys(perShot).length,
      authors: Object.keys(authors).map(k => ({ name: k, n: authors[k] })).sort((a, b) => b.n - a.n),
      hotspots: Object.keys(perShot).map(k => ({ shot: k, n: perShot[k] })).sort((a, b) => b.n - a.n || a.shot.localeCompare(b.shot)).slice(0, 12),
      hist, span,
    };
  }, [visible, notes]);

  // ── the written plan, grouped into moves
  const planGroups = React.useMemo(() => {
    if (!analysis || !Array.isArray(analysis.shots)) return [];
    const cats = analysis.categories || [];
    return cats.map(c => {
      const rows = analysis.shots.filter(s => (s.categories || []).indexOf(c.key) >= 0)
        // Easiest wins first: biggest impact for the least effort.
        .sort((a, b) => (b.impact - a.impact) || (a.effort - b.effort) || a.shot.localeCompare(b.shot));
      const avg = (k) => rows.length ? rows.reduce((t, r) => t + (Number(r[k]) || 0), 0) / rows.length : 0;
      return { ...c, rows, effort: avg("effort"), impact: avg("impact"),
        notes: rows.reduce((t, r) => t + (Number(r.notes) || 0), 0) };
    }).filter(g => g.rows.length);
  }, [analysis]);

  // What the shots NEED — the headline the Stats tab was missing.
  const planMeta = React.useMemo(() => {
    if (!analysis || !Array.isArray(analysis.shots)) return null;
    const shots = analysis.shots;
    const inCat = (k) => shots.filter(s => (s.categories || []).indexOf(k) >= 0).length;
    return {
      total: shots.length,
      cheap: shots.filter(s => Number(s.effort) <= 2).length,
      redo: inCat("redo"),
      accuracy: inCat("accuracy"),
      question: inCat("question"),
      regen: shots.filter(s => Number(s.effort) >= 4).length,
      byCat: (analysis.categories || []).map(c => ({ ...c, n: inCat(c.key) })),
    };
  }, [analysis]);

  const current = reviews.find(r => r.id === reviewId) || null;
  // v1036 — come back to where you left off. Keyed by REVIEW **and** TAB: the four
  // tabs are wildly different lengths, so one shared number would drop you
  // somewhere meaningless the moment you switched.
  const scrollRef = React.useRef(null);
  const scrollSlot = String(reviewId || "none") + ":" + tab;
  const restoredFor = React.useRef(null);
  const saveRaf = React.useRef(0);
  const readScrolls = () => {
    try { return JSON.parse(localStorage.getItem("filmtracker.editplan.scroll") || "{}"); }
    catch (e) { return {}; }
  };
  // Throttled to one write per ~100ms — a scroll fires dozens of events a second
  // and localStorage is synchronous. A TIMER, not requestAnimationFrame: rAF is
  // dead while a tab is hidden, and this pair has to behave the same either way.
  const onScroll = React.useCallback(() => {
    if (saveRaf.current) return;
    saveRaf.current = setTimeout(() => {
      saveRaf.current = 0;
      const el = scrollRef.current;
      if (!el) return;
      try {
        const all = readScrolls();
        all[scrollSlot] = Math.round(el.scrollTop);
        localStorage.setItem("filmtracker.editplan.scroll", JSON.stringify(all));
      } catch (e) {}
    }, 100);
  }, [scrollSlot]);
  React.useEffect(() => () => { if (saveRaf.current) clearTimeout(saveRaf.current); }, []);

  // Restore ONCE per slot, and only after the rows are really in the DOM: setting
  // scrollTop against a container that has not been filled yet silently clamps to
  // 0, which is exactly the bug this is fixing. Wait for the content to be tall
  // enough, then jump — with a frame cap so a genuinely shorter list gives up.
  React.useEffect(() => {
    if (loading) return;
    if (restoredFor.current === scrollSlot) return;   // already placed; don't fight the user
    const want = Number(readScrolls()[scrollSlot] || 0);
    if (!want) { restoredFor.current = scrollSlot; return; }
    // setTimeout, NOT requestAnimationFrame. Instrumented and proved: the effect
    // ran with everything it needed — loading false, 172 rows, the saved value
    // present — and still never scrolled, because rAF does not fire while the tab
    // is hidden, which is exactly the case when you come BACK to a page. A timer
    // runs either way.
    let tries = 0, timer = 0;
    const go = () => {
      const e2 = scrollRef.current;
      if (!e2) return;
      // Only jump once the content is ACTUALLY tall enough. Setting scrollTop on a
      // container that is still short clamps it to 0 — and marking the slot restored
      // before that check let one doomed attempt poison every later one.
      if (e2.scrollHeight - e2.clientHeight >= want) {
        e2.scrollTop = want;
        restoredFor.current = scrollSlot;            // done only now that it stuck
        return;
      }
      if (++tries > 60) { restoredFor.current = scrollSlot; return; }   // ~2s: the list really is shorter
      timer = setTimeout(go, 32);
    };
    go();
    return () => { if (timer) clearTimeout(timer); };
  }, [loading, scrollSlot, visible.length]);

  // v1045 — the pinned bar reads the WHOLE plan, on purpose: it ignores the
  // author / priority / list filters. A progress figure that jumped every time
  // you pressed a filter chip would be useless as progress.
  const planStats = React.useMemo(() => {
    // v1048 - scoped, so the headline and the priority bars describe the plan you
    // are actually looking at. Still ignores the author and priority chips: those
    // narrow a view, they do not change whose plan it is.
    // Respects Show resolved, exactly as the list and the scope chips do — the
    // headline and the chip beside it must never disagree about the same plan.
    const all = (showResolved ? (notes || []) : (notes || []).filter(n => !n.resolved))
      .filter(_epInScope);
    const c = { pending: 0, in_progress: 0, for_review: 0, completed: 0 };
    // v1046 — Hugo: "i need to see the progress bar per priority". Ranked only:
    // an unranked note has no priority to make progress against, so folding it
    // into P1/P2/P3 would understate every one of them.
    const pri = { 1: { done: 0, total: 0 }, 2: { done: 0, total: 0 }, 3: { done: 0, total: 0 } };
    all.forEach(n => {
      const k = epStatus(n);
      if (c[k] != null) c[k]++;
      const p = epPriority(n);
      if (pri[p]) { pri[p].total++; if (k === "completed") pri[p].done++; }
    });
    const total = all.length;
    const done = c.completed;
    return { counts: c, pri: pri, total: total, done: done, left: total - done,
             pct: total ? Math.round((done / total) * 100) : 0 };
  }, [notes, showResolved, _epInScope]);

  const rowProps = { reviewId, canRank, canStatus, canAssign, editor, onPatch: patch, terms: searchTerms };
  const shotIndex = React.useMemo(() => {
    const m = {};
    ((analysis && analysis.shots) || []).forEach(s => { m[s.shot] = s; });
    return m;
  }, [analysis]);

  const TABS = [
    { key: "notes",  label: "Notes",      n: visible.length },
    { key: "board",  label: "Priorities", n: ranked.length },
    { key: "stats",  label: "Stats",      n: stats.shots },
    { key: "plan",   label: "Plan",       n: planMeta ? planMeta.total : 0 },
  ];

  // v1036 — the scroller is THIS section, not the inner .ep-scroll. Measured
  // in-browser: .ep-scroll's scrollHeight equals its clientHeight (range 0)
  // while the section carries the full 34,000px of rows.
  return (
    <section className="view-page edit-plan-view" ref={scrollRef} onScroll={onScroll}>
      {/* v1046 — the panel's own sticky header. Bleeds out through the panel's
          20px padding to its edges and carries the panel's colour, so scrolled
          cards disappear cleanly BEHIND it instead of showing in a gap above it
          (which is what made v1045 read as floating "over" the page). */}
      <div className="ep-statbar">
        <EpDonut counts={planStats.counts} total={planStats.total} size={92}/>
        <div className="ep-statbar-main">
          <div className="ep-statbar-head">
            <b>{planStats.done}</b> done <span>of</span> <b>{planStats.total}</b>
          </div>
          <div className="ep-statbar-sub">
            {planStats.left ? planStats.left + " still to do" : "nothing left — the plan is clear"}
            {editor ? (assignFilter === "editor" ? " · " + editor.first + "'s list"
                     : assignFilter === "mine"   ? " · my list"
                     : " · both plans") : ""}
          </div>
          <div className="ep-statbar-chips">
            {EP_STATUSES.map(st => (
              <span key={st.key} className={"ep-statchip ep-statchip--" + st.key}
                title={planStats.counts[st.key] + " " + st.label}>
                <i/><b>{planStats.counts[st.key]}</b> {st.short}
              </span>
            ))}
          </div>
        </div>
        <div className="ep-statbar-pri">
          {[1, 2, 3].map(p => {
            const d = planStats.pri[p] || { done: 0, total: 0 };
            const pc = d.total ? Math.round((d.done / d.total) * 100) : 0;
            return (
              <div key={p} className={"ep-prirow ep-prirow--p" + p}
                title={"Priority " + p + ": " + d.done + " of " + d.total + " done"}>
                <span className="ep-prirow-tag">P{p}</span>
                <span className="ep-prirow-track">
                  <span className="ep-prirow-fill" style={{ width: pc + "%" }}/>
                </span>
                <span className="ep-prirow-num">{d.done}<i>/{d.total}</i></span>
              </div>
            );
          })}
        </div>
      </div>

      <div className="vp-head">
        <div>
          <div className="vp-eyebrow">EDIT PLAN</div>
          <div className="vp-title">
            {loading ? "Loading…"
              : current ? (visible.length + " note" + (visible.length === 1 ? "" : "s") + " on " + (current.title || ("Edit " + current.id)))
              : "No edit has notes yet"}
          </div>
        </div>
        <div className="vp-tabs">
          {TABS.map(t => (
            <button key={t.key} className={"vp-tab" + (tab === t.key ? " is-active" : "")} onClick={() => setTab(t.key)}>
              {t.label} <span className="vp-tab-count">{t.n}</span>
            </button>
          ))}
          <div className="vp-tabs-actions">
            {reviews.length > 1 && (
              <select className="ep-review-picker" value={reviewId || ""}
                onChange={(e) => setReviewId(Number(e.target.value))}>
                {reviews.map(r => (
                  <option key={r.id} value={r.id}>
                    {(r.title || ("Edit " + r.id)) + " · " + r.comment_count + " notes"}
                  </option>
                ))}
              </select>
            )}
            {/* v1021 — one button that does the whole job: read the edit if it
                has never been read, then fill every shot still missing an image.
                The same thing the automatic sweep does, for when you don't want
                to wait for it. */}
            {(!window.hasPerm || window.hasPerm("upload_assets")) && window.EditStillButton && (
              <window.EditStillButton all label="Fill blank shots from the edit"/>
            )}
            <button type="button" className={"ep-toggle" + (showResolved ? " is-on" : "")}
              onClick={() => setShowResolved(v => !v)}
              title="Notes already marked resolved on the Review page">
              {showResolved ? "Hiding nothing" : "Show resolved"}
            </button>
          </div>
        </div>
      </div>

      {/* v1060 - per-shot frame extraction from the cut. Returns null until the
          server reports a job, so this costs nothing until it is used. */}
      {(!window.hasPerm || window.hasPerm("upload_assets")) && window.EditFramesBar && (
        <window.EditFramesBar reviewId={reviewId}/>
      )}

      {/* Always rendered, even when empty, so switching tabs never shifts the
          content below it. */}
      <div className="ep-summary">
        <span className="ep-sum-item"><b>{priCounts[1]}</b> P1</span>
        <span className="ep-sum-item"><b>{priCounts[2]}</b> P2</span>
        <span className="ep-sum-item"><b>{priCounts[3]}</b> P3</span>
        <span className="ep-sum-item is-muted"><b>{priCounts[0]}</b> unranked</span>
        <span className="ep-sum-sep"/>
        {EP_STATUSES.map(s => (
          <span key={s.key} className="ep-sum-item"><b>{statusCounts[s.key]}</b> {s.label}</span>
        ))}
        <span className="ep-sum-err">{err}</span>
      </div>

      {/* Always rendered, even with one author, so the rows below never move. */}
      <div className="ep-authors">
        <span className="ep-ctl-label">Notes by</span>
        <button type="button" className={"ep-seg" + (authorFilter === "all" ? " is-on" : "")}
          onClick={() => setAuthorFilter("all")}>
          Everyone<span className="ep-seg-n">{authors.reduce((t, a) => t + a.n, 0)}</span>
        </button>
        {authors.map(a => (
          <button key={a.key} type="button"
            className={"ep-seg ep-seg--who" + (authorFilter === a.key ? " is-on" : "")}
            style={{ "--ep-user": epUserColor(a.name) }}
            title={"Only " + a.name}
            onClick={() => setAuthorFilter(f => f === a.key ? "all" : a.key)}>
            <span className="ep-who-dot"/>{a.name}<span className="ep-seg-n">{a.n}</span>
          </button>
        ))}

        <span className="ep-sum-sep"/>
        <span className="ep-ctl-label">Priority</span>
        {[["all", "All", null], ["ranked", "Ranked", null],
          ["1", "P1", 1], ["2", "P2", 2], ["3", "P3", 3], ["0", "Unranked", null]].map(([k, lbl, pr]) => (
          <button key={k} type="button"
            className={"ep-seg" + (pr ? " ep-seg--p" + pr : "") + (priFilter === k ? " is-on" : "")}
            title={k === "ranked" ? "Everything Markus has given a number"
              : k === "0" ? "Notes nobody has ranked yet"
              : k === "all" ? "Every note" : "Priority " + pr + " only"}
            onClick={() => setPriFilter(f => f === k ? "all" : k)}>
            {lbl}<span className="ep-seg-n">{priChipCounts[k === "all" ? "all" : k === "ranked" ? "ranked" : k]}</span>
          </button>
        ))}

        {/* v1048 — WHOSE PLAN. A scope, not a filter: it drives the board, the
            counts and the progress bar together, so ranking happens inside one
            plan and the two never pollute each other. Only rendered once the
            crew has loaded and somebody actually holds the editor role. */}
        {editor && (
          <React.Fragment>
            <span className="ep-sum-sep"/>
            <span className="ep-ctl-label">Plan</span>
            {[["mine", "Mine"], ["editor", editor.first], ["all", "All"]].map(([k, label]) => (
              <button key={k} type="button"
                className={"ep-seg ep-seg--assign" + (assignFilter === k ? " is-on" : "")}
                title={k === "mine" ? "Everything NOT on " + editor.name + "'s list — your own work, ranked on its own"
                     : k === "editor" ? "Only " + editor.name + "'s list, ranked on its own"
                     : "Both plans together"}
                onClick={() => setAssignFilter(k)}>
                {label}<span className="ep-seg-n">{scopeCounts[k]}</span>
              </button>
            ))}
          </React.Fragment>
        )}
      </div>

      {/* v1031 - always rendered, on every tab, so switching tabs cannot move
          the list underneath it. */}
      <div className="ep-addtask">
        <input type="text" className="ep-addtask-input" value={taskDraft}
          placeholder={canStatus ? "Add a job to this plan…" : "You can't add tasks to this plan"}
          disabled={!reviewId || !canStatus || taskBusy}
          onChange={(e) => setTaskDraft(e.target.value)}
          onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addTask(); } }}/>
        <span className="ep-ctl-label">Priority</span>
        <div className="ep-pri" role="group" aria-label="Priority for the new task">
          {[1, 2, 3].map(n => (
            <button key={n} type="button"
              className={"ep-pri-btn ep-pri-btn--" + n + (taskPri === n ? " is-on" : "")}
              disabled={!canStatus || taskBusy}
              title={"Add it at priority " + n}
              onClick={() => setTaskPri(n)}>{n}</button>
          ))}
        </div>
        <button type="button" className="ep-seg ep-addtask-go"
          disabled={!reviewId || !canStatus || taskBusy || !taskDraft.trim()}
          onClick={addTask}>{taskBusy ? "Adding…" : "Add task"}</button>
        {/* v1089b — at the right end of the add-task row: on the filter row it wrapped onto a
            second line, and the page squeezed that row back to one line while the list was
            long, so the box sat under this row and could not be clicked. This row's input
            stretches, so the box always fits here.
            v1089 — SEARCH the notes: every word must be in the note, a "quoted phrase"
            counts as one word, case and accents are ignored, the shot number counts too.
            It narrows the view like the chips; the progress bar never moves. Fixed widths
            inside, so typing never moves anything (invariant #20). Esc clears. */}
        <label className={"ep-search" + (searchTerms.length ? " is-on" : "")}
          title={'Search the note text. Every word must match; put a phrase in "quotes". Esc clears.'}>
          <svg className="ep-search-icon" viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
            <circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>
          </svg>
          <input ref={searchRef} type="search" className="ep-search-input" value={query}
            placeholder="Search the notes…" aria-label="Search the notes"
            onChange={(e) => setQuery(e.target.value)}
            onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); setQuery(""); } }}/>
          <span className="ep-search-n" aria-live="polite">{searchTerms.length ? visible.length + " found" : ""}</span>
          <button type="button" className={"ep-search-clear" + (query ? "" : " is-hidden")}
            tabIndex={query ? 0 : -1} aria-label="Clear the search" title="Clear the search"
            onClick={(e) => { e.preventDefault(); setQuery(""); if (searchRef.current) searchRef.current.focus(); }}>×</button>
        </label>
      </div>

      <div className="vp-scroll ep-scroll">
        {!loading && !visible.length && tab !== "plan" && (
          <div className="ep-empty">
            {searchTerms.length
              ? "Nothing matches \u201c" + query.trim() + "\u201d" + (priFilter !== "all" || authorFilter !== "all" ? " with the chips you picked" : "") + ". Press \u00d7 in the search box to clear it."
              : assignFilter === "editor" && editor
              ? "Nothing is on " + editor.name + "'s list yet. Add notes with the " + editor.first + " button on each card."
              : priFilter !== "all"
              ? "Nothing at that priority" + (authorFilter === "all" ? "" : " from that person") + ". Press All to see the rest."
              : authorFilter === "all"
                ? "No notes on this edit" + (showResolved ? "" : " that are still open") + "."
                : "Nothing from that person" + (showResolved ? "" : " that is still open") + ". Press Everyone to see the rest."}
          </div>
        )}

        {tab === "notes" && (
          <ul className="ep-list">
            {byTimecode.map(n => <EditPlanRow key={n.id} note={n} busy={busyId === n.id} {...rowProps}/>)}
          </ul>
        )}

        {tab === "board" && (
          <div className="ep-board-wrap">
            <div className="ep-board-head">
              <span className="ep-ctl-label">Columns by</span>
              {[["priority", "Priority"], ["status", "Status"]].map(([k, label]) => (
                <button key={k} type="button"
                  className={"ep-seg" + (boardBy === k ? " is-on" : "")}
                  onClick={() => setBoardBy(k)}>{label}</button>
              ))}
              <span className="ep-board-note">Unranked notes are not shown here — rank them on the Notes tab.</span>
              <button type="button" className="ep-seg ep-pdf-btn" disabled={pdfBusy || !ranked.length}
                title={ranked.length ? "Print the ranked list, or save it as a PDF" : "Nothing is ranked yet"}
                onClick={exportPdf}>
                <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                  <path d="M6 9V3h12v6"/><path d="M6 18H4a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="7" rx="1"/>
                </svg>
                {pdfBusy ? "Building…" : "PDF"}
              </button>
            </div>
            {!ranked.length
              ? <div className="ep-empty">{searchTerms.length
                  ? "No ranked note matches \u201c" + query.trim() + "\u201d."
                  : "Nothing is ranked yet. Give a note a 1, 2 or 3 on the Notes tab and it appears here."}</div>
              : liveColumns.length === 1
                // v1023 — one group left (you filtered to P1, say). Columns are
                // pointless then: 61 cards in a skinny column with two empty ones
                // beside it wastes the whole screen. Reflow into a grid that uses
                // the full width instead.
                ? (
                  <div className={"ep-onegroup ep-onegroup--" + liveColumns[0].tone}>
                    <div className="ep-col-head">
                      <span className="ep-col-title">{liveColumns[0].title}</span>
                      <span className="ep-col-count">{liveColumns[0].rows.length}</span>
                    </div>
                    <ul className="ep-grid">
                      {liveColumns[0].rows.map(n => (
                        <EditPlanRow key={n.id} note={n} busy={busyId === n.id} compact {...rowProps}/>
                      ))}
                    </ul>
                  </div>
                )
                : (
                  <div className="ep-board">
                    {liveColumns.map(col => (
                      <div key={col.key} className={"ep-col ep-col--" + col.tone}>
                        <div className="ep-col-head">
                          <span className="ep-col-title">{col.title}</span>
                          <span className="ep-col-count">{col.rows.length}</span>
                        </div>
                        <ul className="ep-list ep-list--col">
                          {col.rows.map(n => (
                            <EditPlanRow key={n.id} note={n} busy={busyId === n.id} compact {...rowProps}/>
                          ))}
                        </ul>
                        {!col.rows.length && <div className="ep-col-empty">Nothing here.</div>}
                      </div>
                    ))}
                  </div>
                )}
          </div>
        )}

        {tab === "stats" && (
          <div className="ep-stats">
            <div className="ep-stat-cards">
              {[
                ["Notes",           stats.total,    "still open on this edit"],
                ["Shots touched",   stats.shots,    "distinct shots with a note"],
                ["Full redos",      planMeta ? planMeta.redo : "—",  "regenerate from scratch", "is-redo"],
                ["No render",       planMeta ? planMeta.cheap : "—", "fixed in the edit or the sound", "is-cheap"],
                ["Ranked",          ranked.length,  "have a 1, 2 or 3"],
                ["Resolved",        stats.resolved, "already ticked off"],
              ].map(([label, n, sub, tone]) => (
                <div key={label} className={"ep-stat-card" + (tone ? " " + tone : "")}>
                  <div className="ep-stat-n">{n}</div>
                  <div className="ep-stat-label">{label}</div>
                  <div className="ep-stat-sub">{sub}</div>
                </div>
              ))}
            </div>

            <div className="ep-stat-cols">
              {planMeta && (
                <div className="ep-stat-block">
                  <div className="ep-stat-title">What the shots need</div>
                  <ul className="ep-bars">
                    {planMeta.byCat.map(c => (
                      <li key={c.key}>
                        <button type="button" className="ep-bar-label ep-bar-label--btn"
                          onClick={() => setTab("plan")} title="Open the plan">
                          <span className={"ep-cat-dot ep-cat-dot--" + c.key}/>{c.label}
                        </button>
                        <span className="ep-bar-track">
                          <span className={"ep-bar-fill ep-bar-fill--" + c.key} style={{
                            width: Math.round((c.n / Math.max(1, planMeta.total)) * 100) + "%",
                          }}/>
                        </span>
                        <span className="ep-bar-n">{c.n}</span>
                      </li>
                    ))}
                  </ul>
                  <div className="ep-stat-foot">{planMeta.total} shots · a shot can sit in two buckets</div>
                </div>
              )}

              <div className="ep-stat-block">
                <div className="ep-stat-title">Where the notes fall</div>
                <div className="ep-hist">
                  {stats.hist.map((n, i) => {
                    const max = Math.max.apply(null, stats.hist.concat([1]));
                    return (
                      <div key={i} className="ep-hist-col" title={n + " notes around " + epFmtTC((i + 0.5) / stats.hist.length * stats.span)}>
                        <div className="ep-hist-bar" style={{ height: Math.round((n / max) * 100) + "%" }}/>
                        <span className="ep-hist-n">{n}</span>
                      </div>
                    );
                  })}
                </div>
                <div className="ep-hist-axis"><span>0:00</span><span>{epFmtTC(stats.span)}</span></div>
              </div>

              <div className="ep-stat-block">
                <div className="ep-stat-title">Who wrote them</div>
                <ul className="ep-bars">
                  {stats.authors.map(a => (
                    <li key={a.name}>
                      <span className="ep-bar-label"><span className="ep-who-dot" style={{ "--ep-user": epUserColor(a.name) }}/>{a.name}</span>
                      <span className="ep-bar-track">
                        <span className="ep-bar-fill" style={{
                          width: Math.round((a.n / Math.max(1, stats.total)) * 100) + "%",
                          background: epUserColor(a.name),
                        }}/>
                      </span>
                      <span className="ep-bar-n">{a.n}</span>
                    </li>
                  ))}
                </ul>
              </div>

              <div className="ep-stat-block">
                <div className="ep-stat-title">Shots with the most notes</div>
                <ul className="ep-bars">
                  {stats.hotspots.map(h => (
                    <li key={h.shot}>
                      <button type="button" className="ep-bar-label ep-bar-label--btn" onClick={() => epOpenShot(h.shot)}>
                        {h.shot}
                      </button>
                      <span className="ep-bar-track">
                        <span className="ep-bar-fill" style={{
                          width: Math.round((h.n / Math.max(1, stats.hotspots[0] ? stats.hotspots[0].n : 1)) * 100) + "%",
                        }}/>
                      </span>
                      <span className="ep-bar-n">{h.n}</span>
                    </li>
                  ))}
                </ul>
              </div>
            </div>
          </div>
        )}

        {tab === "plan" && (
          !analysis || !planMeta
            ? <div className="ep-empty">No written plan for this edit yet.</div>
            : (
              <div className="ep-plan">
                {/* ── where to start ─────────────────────────────────────── */}
                <header className="ep-hero">
                  <div className="ep-hero-nums">
                    <div className="ep-hero-stat is-cheap">
                      <b>{planMeta.cheap}</b>
                      <span>shots fixed with<br/>no render at all</span>
                    </div>
                    <div className="ep-hero-stat is-accuracy">
                      <b>{planMeta.accuracy}</b>
                      <span>cultural accuracy<br/>binding, not optional</span>
                    </div>
                    <div className="ep-hero-stat is-redo">
                      <b>{planMeta.redo}</b>
                      <span>full regenerations<br/>leave till last</span>
                    </div>
                    <div className="ep-hero-stat is-parked">
                      <b>{planMeta.question}</b>
                      <span>parked on a<br/>decision</span>
                    </div>
                  </div>
                  <div className="ep-hero-copy">
                    <p className="ep-headline">{analysis.headline}</p>
                    <p className="ep-order-note">{analysis.order_note}</p>
                    <p className="ep-fineprint">
                      {planMeta.total} shots · written {analysis.generated_at} · independent of Markus's 1-3 ranking ·
                      effort 1 = minutes in the edit, 5 = a full regeneration · impact 1 = polish, 5 = the film is wrong without it
                    </p>
                  </div>
                </header>

                {/* ── fix together ───────────────────────────────────────── */}
                {Array.isArray(analysis.batches) && analysis.batches.length > 0 && (
                  <section className="ep-sec">
                    <div className="ep-sec-head">
                      <span className="ep-sec-kicker">Fix together</span>
                      <span className="ep-sec-title">One job, several shots</span>
                      <span className="ep-sec-count">{analysis.batches.length} batches</span>
                    </div>
                    <div className="ep-batches">
                      {analysis.batches.map((b, i) => (
                        <article key={b.tag} className={"ep-batch" + (b.tag === "twain-face" ? " is-wide" : "")} style={{ "--i": i }}>
                          <div className="ep-batch-head">
                            <span className="ep-batch-label">{b.label}</span>
                            <span className="ep-batch-n">{b.shots.length} shots</span>
                          </div>
                          <p className="ep-batch-why">{b.why}</p>
                          <div className="ep-chips">
                            {b.shots.map(id => shotIndex[id]
                              ? <EditPlanShotChip key={id} s={shotIndex[id]}/>
                              : <button key={id} type="button" className="ep-pchip is-quiet" onClick={() => epOpenShot(id)}>{id}</button>)}
                          </div>
                          {b.tag === "twain-face" && analysis.twain_shots && (
                            <div className="ep-batch-more">
                              <span className="ep-batch-more-label">+{(analysis.twain_shots.no_notes_same_face || []).length} more share his face, no note yet</span>
                              <div className="ep-chips">
                                {(analysis.twain_shots.no_notes_same_face || []).map(id => (
                                  <button key={id} type="button" className="ep-pchip is-quiet" onClick={() => epOpenShot(id)}>{id}</button>
                                ))}
                              </div>
                            </div>
                          )}
                        </article>
                      ))}
                    </div>
                  </section>
                )}

                {/* ── the moves, in order ────────────────────────────────── */}
                <section className="ep-sec">
                  <div className="ep-sec-head">
                    <span className="ep-sec-kicker">The plan</span>
                    <span className="ep-sec-title">{planGroups.length} moves, in the order to do them</span>
                  </div>
                  <ol className="ep-moves">
                    {planGroups.map((g, i) => {
                      const open = !!openWhy[g.key];
                      return (
                        <li key={g.key} className={"ep-move ep-move--" + g.key} style={{ "--i": i }}>
                          <div className="ep-move-num">
                            <span className="ep-move-digit">{String(i + 1).padStart(2, "0")}</span>
                            <span className="ep-move-kicker">{g.short}</span>
                          </div>
                          <div className="ep-move-body">
                            <div className="ep-move-head">
                              <h3 className="ep-move-title">{g.verb}</h3>
                              <span className="ep-move-count">{g.rows.length} shot{g.rows.length === 1 ? "" : "s"} · {g.notes} note{g.notes === 1 ? "" : "s"}</span>
                              <div className="ep-meter" title={"Average effort " + g.effort.toFixed(1) + " of 5 · average impact " + g.impact.toFixed(1) + " of 5"}>
                                <span className="ep-meter-row"><em>cost</em><i><b style={{ width: (g.effort / 5 * 100) + "%" }} className="is-cost"/></i></span>
                                <span className="ep-meter-row"><em>payoff</em><i><b style={{ width: (g.impact / 5 * 100) + "%" }} className="is-payoff"/></i></span>
                              </div>
                            </div>
                            <p className="ep-move-blurb">{g.blurb}</p>
                            <div className="ep-chips ep-chips--move">
                              {g.rows.map(s => <EditPlanShotChip key={s.shot} s={s}/>)}
                            </div>
                            <button type="button" className={"ep-why-toggle" + (open ? " is-on" : "")}
                              onClick={() => setOpenWhy(o => ({ ...o, [g.key]: !o[g.key] }))}>
                              {open ? "Hide the reasons" : "Why each shot"}
                            </button>
                            {open && (
                              <ul className="ep-why">
                                {g.rows.map(s => (
                                  <li key={s.shot} className="ep-why-row">
                                    <button type="button" className="ep-pchip" onClick={() => epOpenShot(s.shot)}>{s.shot}</button>
                                    <span className="ep-why-dots" title={"effort " + s.effort + " · impact " + s.impact}>
                                      <span className="ep-dots ep-dots--effort">{[1, 2, 3, 4, 5].map(k => <i key={k} className={k <= s.effort ? "is-on" : ""}/>)}</span>
                                      <span className="ep-dots ep-dots--impact">{[1, 2, 3, 4, 5].map(k => <i key={k} className={k <= s.impact ? "is-on" : ""}/>)}</span>
                                    </span>
                                    <span className="ep-why-text">{s.why}</span>
                                  </li>
                                ))}
                              </ul>
                            )}
                          </div>
                        </li>
                      );
                    })}
                  </ol>
                </section>

                {Array.isArray(analysis.unlinked_notes) && analysis.unlinked_notes.length > 0 && (
                  <section className="ep-sec ep-sec--quiet">
                    <div className="ep-sec-head">
                      <span className="ep-sec-kicker">No shot detected</span>
                      <span className="ep-sec-title">The frame had no shot number to read</span>
                      <span className="ep-sec-count">{analysis.unlinked_notes.length}</span>
                    </div>
                    <ul className="ep-unlinked">
                      {analysis.unlinked_notes.map(u => (
                        <li key={u.at}><span className="ep-unlinked-at">{u.at}</span><span>{u.body}</span></li>
                      ))}
                    </ul>
                  </section>
                )}
              </div>
            )
        )}
      </div>
    </section>
  );
}

window.EditPlanPage = EditPlanPage;
