// app.jsx — assembles the full Flowboard prototype

// Walk parentTaskId chains across a task array and stamp the correct
// `depth` on each row (capped at 4 to match migration 039). The
// server's `depth` column is the source of truth for back-end
// guards, but late-merged subtask rows can land before any
// bootstrap recomputation has run — so we re-derive here in O(n)
// using a Map cache so renderers always see consistent indents.
function _recomputeTaskDepth(tasks) {
  if (!Array.isArray(tasks) || !tasks.length) return;
  const byId = new Map();
  for (const t of tasks) if (t && t.id) byId.set(t.id, t);
  const cache = new Map();
  function depthOf(t, seen) {
    if (!t || !t.parentTaskId) return 0;
    if (cache.has(t.id)) return cache.get(t.id);
    if (seen && seen.has(t.id)) return 0;
    const parent = byId.get(t.parentTaskId);
    if (!parent) return 0;
    const s = seen || new Set();
    s.add(t.id);
    const d = Math.min(4, depthOf(parent, s) + 1);
    cache.set(t.id, d);
    return d;
  }
  for (const t of tasks) {
    if (!t || !t.id) continue;
    const computed = depthOf(t);
    if ((t.depth || 0) !== computed) {
      t.depth = computed;
    }
  }
}
// Expose globally so other modules / dev console can trigger a
// resync if they ever need to.
if (typeof window !== "undefined") {
  window._recomputeTaskDepth = _recomputeTaskDepth;
}

const LS_KEYS = {
  project: "fb.activeProjectId",
  tab:     "fb.activeTab",
  view:    "fb.activeView",
};
function lsGet(key, fallback) {
  try {
    const v = window.localStorage.getItem(key);
    return v === null || v === undefined ? fallback : v;
  } catch (_) { return fallback; }
}
function lsSet(key, value) {
  try {
    if (value === null || value === undefined) window.localStorage.removeItem(key);
    else window.localStorage.setItem(key, String(value));
  } catch (_) { /* ignore (private mode / quota) */ }
}

function FlowboardApp({ variant = "groups", startView = "myWork", startTaskId = null }) {
  const [tasks, setTasks] = React.useState(ALL_TASKS);
  const [sprints, setSprints] = React.useState(SPRINTS);  const [tab, setTab] = React.useState(() => lsGet(LS_KEYS.tab, "sprint"));
  const [activeSprintId, setActiveSprintId] = React.useState("s24");
  const [collapsed, setCollapsed] = React.useState(new Set());
  const [selected, setSelected] = React.useState(new Set());
  const [drawerTask, setDrawerTask] = React.useState(null);
  const [drawerOpen, setDrawerOpen] = React.useState(false);
  // Keep the drawer's task in sync with the live tasks array. Without
  // this, the drawer captures the task object once when openTask()
  // runs, then never reflects subsequent edits (assigning an owner,
  // changing status, etc.) until the user closes + reopens the drawer
  // or refreshes — the "owner only shows up after refresh" bug.
  // We re-find by id every time `tasks` ticks; if the live row is the
  // same reference we skip the setState so this doesn't trigger an
  // extra render every time `tasks` updates from an unrelated row.
  React.useEffect(() => {
    if (!drawerTask || !drawerTask.id) return;
    const fresh = tasks.find(t => t.id === drawerTask.id);
    if (fresh && fresh !== drawerTask) {
      setDrawerTask(fresh);
    }
  }, [tasks, drawerTask]);
  const [fullPageTaskId, setFullPageTaskId] = React.useState(startTaskId);
  const [nextId, setNextId] = React.useState(100);
  const [startOpen, setStartOpen] = React.useState(false);
  const [planOpen, setPlanOpen] = React.useState(false);
  const [completeOpen, setCompleteOpen] = React.useState(false);
  const [newTaskOpen, setNewTaskOpen] = React.useState(false);
  const [newEpicOpen, setNewEpicOpen] = React.useState(false);
  const [newProjectOpen, setNewProjectOpen] = React.useState(false);
  const [, bumpEpics] = React.useState(0); // forces re-render when window.EPICS mutates
  const [, bumpProjects] = React.useState(0); // forces re-render when window.PROJECTS mutates
  const [paletteOpen, setPaletteOpen] = React.useState(false);
  const [notifPanelOpen, setNotifPanelOpen] = React.useState(false);
  // Live unread badge — subscribes to the notify-client store and
  // re-renders whenever a new notification arrives (or one is read).
  const [notifUnread, setNotifUnread] = React.useState(
    () => (window.flowboardNotify ? window.flowboardNotify.state().unread : 0)
  );
  React.useEffect(() => {
    if (!window.flowboardNotify) return;
    return window.flowboardNotify.subscribe(s => setNotifUnread(s.unread || 0));
  }, []);
  const [whatsNewOpen, setWhatsNewOpen] = React.useState(false);
  // Bug-report modal — one piece of root state so the floating button
  // and any other "report a bug" entry point share it.
  const [bugReportOpen, setBugReportOpen] = React.useState(false);

  // Mobile navigation drawer — at <=760px the sidebar is off-canvas
  // and the topbar shows a hamburger. The state lives here (not in
  // Sidebar/Topbar) so the scrim, the body class, and any sidebar
  // item click can all read/clear it from one place.
  const [mobileNavOpen, setMobileNavOpen] = React.useState(false);
  React.useEffect(() => {
    const cls = "is-mobile-nav-open";
    if (mobileNavOpen) document.body.classList.add(cls);
    else document.body.classList.remove(cls);
    return () => document.body.classList.remove(cls);
  }, [mobileNavOpen]);
  // Auto-close the mobile nav whenever the user navigates somewhere.
  // Wired below in a useEffect on [view, activeProjectId] once those
  // state hooks are declared. (No-op on desktop since the sidebar is
  // statically visible there.)
  // Project to delete (null = modal closed). Set by the trash button in
  // ProjectHeader; the modal asks the user to type the project name.
  const [deleteProjectId, setDeleteProjectId] = React.useState(null);
  const [projectSettingsOpen, setProjectSettingsOpen] = React.useState(false);
  // Bumped after we mutate PROJECTS in place (project settings modal,
  // realtime project patches) so dependent renders see the new values.
  const [, _bumpProjectsTick] = React.useState(0);
  // Task currently being closed late — when populated, CompleteTaskModal
  // opens. Set by updateTask() when status moves to done on an overdue
  // task; cleared by the modal's onClose / onConfirm.
  const [lateCompleteTask, setLateCompleteTask] = React.useState(null);
  // Pending due-date change awaiting a reason — when populated, the
  // DueChangeModal opens to ask "why did the due date change?". Set
  // by updateTask() when patch.due is mutating an existing date to
  // a different non-null date; cleared by the modal's onClose /
  // onConfirm. Shape: { task, oldDue, newDue }.
  const [pendingDueChange, setPendingDueChange] = React.useState(null);
  const [toast, setToast] = React.useState(null);
  const toastTimerRef = React.useRef(null);

  // NEW — user management state
  const [people, setPeople] = React.useState(PEOPLE);
  const [access, setAccess] = React.useState(PROJECT_ACCESS);
  // Initial currentUserId comes from the actually-logged-in user (or the
  // person they're acting as). The previous hardcoded "ay" meant every
  // login showed Aya's tasks regardless of who you signed in as.
  const [currentUserId, setCurrentUserId] = React.useState(() => {
    try {
      const u = (window.api && window.api.getUser && window.api.getUser()) || null;
      return (u && u.id) || "ay";
    } catch { return "ay"; }
  });
  // Republish on window for global modules (priority-modal.jsx,
  // future cross-cutting helpers) that can't accept React props.
  React.useEffect(() => {
    try { window.__currentUserId = currentUserId; } catch {}
  }, [currentUserId]);
  const [view, setView] = React.useState(() => lsGet(LS_KEYS.view, startView));   // project | admin
  // Deep-link state for the People module — the main app sidebar
  // exposes every People sub-tab so the user lands directly on
  // Attendance / Hours / etc. Persisted so refreshing keeps you on
  // the same tab. Different default for managers vs employees.
  const [peopleTab, setPeopleTab] = React.useState(() => lsGet(LS_KEYS.peopleTab || "fb.peopleTab", null));
  React.useEffect(() => {
    try { window.localStorage.setItem("fb.peopleTab", peopleTab || ""); } catch {}
  }, [peopleTab]);
  // Support Center active sub-tab (dashboard / tickets / customers /
  // canned / kb / reports / settings / team) — persisted so reload
  // keeps the agent on the same screen.
  const [supportTab, setSupportTab] = React.useState(() => lsGet("fb.supportTab", null));
  React.useEffect(() => {
    try { window.localStorage.setItem("fb.supportTab", supportTab || ""); } catch {}
  }, [supportTab]);
  const [activeProjectId, setActiveProjectId] = React.useState(() => lsGet(LS_KEYS.project, "checkout"));
  // Auto-close the mobile nav drawer whenever the user navigates.
  // Sidebar items call setView() / setActiveProjectId() — both
  // changes mean "user just went somewhere" so the drawer should
  // dismiss itself. No-op on desktop (sidebar is statically visible).
  React.useEffect(() => { setMobileNavOpen(false); }, [view, activeProjectId]);
  const [inviteOpen, setInviteOpen] = React.useState(false);
  const [projectAccessModal, setProjectAccessModal] = React.useState(null); // projectId or null
  const [peek, setPeek] = React.useState(null); // {person, anchor}
  // Story drawer — open when user clicks a story chip on a task
  // (set by setOpenStoryId(<storyId>); null = closed). Story creation
  // is done from the same modal flow.
  const [openStoryId, setOpenStoryId] = React.useState(null);
  const [newStoryOpen, setNewStoryOpen] = React.useState(false);
  // Multi-workspace — owner-only "Create workspace" modal.
  const [newWorkspaceOpen, setNewWorkspaceOpen] = React.useState(false);
  // ── My Work page state ─────────────────────────────────────────
  const [mwQuery, setMwQuery] = React.useState("");
  const [mwProjects, setMwProjects] = React.useState([]);
  const [mwStatuses, setMwStatuses] = React.useState([]);

  // Persist UI selections across refreshes
  React.useEffect(() => { lsSet(LS_KEYS.project, activeProjectId); }, [activeProjectId]);
  React.useEffect(() => { lsSet(LS_KEYS.tab, tab); }, [tab]);
  React.useEffect(() => { lsSet(LS_KEYS.view, view); }, [view]);

  // If the saved project no longer exists (renamed/deleted), fall back
  React.useEffect(() => {
    if (!Array.isArray(PROJECTS) || PROJECTS.length === 0) return;
    const exists = PROJECTS.some(p => p.id === activeProjectId);
    if (!exists) setActiveProjectId(PROJECTS[0].id);
  }, [PROJECTS.length]);

  // Realtime: when the SSE stream fires `flowboard:tasks:patched`, the
  // global window.ALL_TASKS array has already been mutated in-place by
  // realtime.js. We just need to copy the new array reference into local
  // state so React picks up the change. Crucially, this does NOT remount
  // the component tree — the user's drawer, modals, scroll position, and
  // any in-flight typing all survive untouched. Only the row(s) whose
  // data actually changed re-render (thanks to React's diffing).
  React.useEffect(() => {
    function onPatched() {
      // Defensive id-dedupe — guards against a backend regression
      // (multi-workspace JOIN) or a race between SSE patch + initial
      // bootstrap where the same task lands twice in window.ALL_TASKS.
      // Cheap O(n) — keeps the first occurrence, which preserves order.
      const raw = Array.isArray(window.ALL_TASKS) ? window.ALL_TASKS : [];
      const seen = new Set();
      const deduped = [];
      for (const t of raw) {
        if (!t || !t.id || seen.has(t.id)) continue;
        seen.add(t.id);
        deduped.push(t);
      }
      setTasks(deduped);
    }
    window.addEventListener("flowboard:tasks:patched", onPatched);
    return () => window.removeEventListener("flowboard:tasks:patched", onPatched);
  }, []);

  // Tab-title badge — show "(N) Flowboard" in the browser tab when the
  // signed-in user has tasks due today or overdue. Recomputes whenever
  // the tasks state changes (so realtime patches and local edits both
  // update it) and resets cleanly to "Flowboard" when the queue empties.
  React.useEffect(() => {
    const BASE = "ZeroProject";
    const isDue = window.isDueOrOverdue;
    if (typeof isDue !== "function") {
      document.title = BASE;
      return;
    }
    let count = 0;
    for (const t of tasks) {
      if (!t || !t.owners) continue;
      if (!t.owners.includes(currentUserId)) continue;
      if (isDue(t)) count++;
    }
    document.title = count > 0 ? `(${count}) ${BASE}` : BASE;
  }, [tasks, currentUserId]);

  React.useEffect(() => {
    const onOpen = () => setPaletteOpen(true);
    window.addEventListener("palette:open", onOpen);
    const onKey = (e) => {
      // Shift + N → open the New Task modal.
      // We require Shift so the shortcut never fires while the user is
      // typing a lowercase "n" in any field (input, textarea, select, or
      // contentEditable description / titles / comments).
      const isN = (e.key === "N" || e.key === "n") && e.shiftKey;
      if (!isN) return;
      if (e.metaKey || e.ctrlKey || e.altKey) return;

      // Bail if the user is editing text anywhere — Shift+N inside a text
      // field should still produce the character "N" rather than open a modal.
      const ae = document.activeElement;
      const tag = (ae?.tagName || "").toLowerCase();
      if (tag === "input" || tag === "textarea" || tag === "select") return;
      if (ae && ae.isContentEditable) return;
      const t = e.target;
      if (t && (t.isContentEditable || (t.closest && t.closest('[contenteditable="true"]')))) return;

      // Don't open a second modal on top of an existing one.
      if (document.querySelector(".modal-backdrop")) return;

      e.preventDefault();
      setNewTaskOpen(true);
    };
    window.addEventListener("keydown", onKey);
    return () => {
      window.removeEventListener("palette:open", onOpen);
      window.removeEventListener("keydown", onKey);
    };
  }, []);

  const [filters, setFilters] = React.useState(() => {
    // hideDone is persisted across reloads — strong personal preference
    // (some users want their boards lean, others want the full picture).
    // Default for new users is ON: as soon as a task is marked done it
    // strikes through and stays visible THIS session, then hides on the
    // next reload (see `completedThisSession` below for the per-tab
    // exception). Existing users keep whatever they chose previously.
    // groupBy is also persisted so the user's choice between Epic and
    // User-story grouping survives a refresh — switching between the
    // two on every page load would be jarring.
    // Everything else resets per-session to avoid surprise stale filters.
    let hideDone = true; // default ON for new users
    try {
      const stored = localStorage.getItem("fb.filters.hideDone");
      if (stored === "0") hideDone = false;
      else if (stored === "1") hideDone = true;
    } catch {}
    let groupBy = "epic";
    try {
      const g = localStorage.getItem("fb.filters.groupBy");
      if (g === "epic" || g === "story") groupBy = g;
    } catch {}
    return {
      q: "", people: [], statuses: [], priorities: [], types: [], sort: "default",
      hidden: [], mine: false, hideDone,
      // groupBy: "epic" (legacy default) | "story"
      groupBy,
      // reviewMine: when true and groupBy === "story", only render story
      // groups where the current user is a pending reviewer. Drives the
      // toolbar "N reviews waiting" badge.
      reviewMine: false,
    };
  });
  // Persist hideDone whenever it flips, so it survives reload + tab close.
  React.useEffect(() => {
    try { localStorage.setItem("fb.filters.hideDone", filters.hideDone ? "1" : "0"); } catch {}
  }, [filters.hideDone]);
  React.useEffect(() => {
    try { localStorage.setItem("fb.filters.groupBy", filters.groupBy || "epic"); } catch {}
  }, [filters.groupBy]);

  // ── Completed-this-session tracking ───────────────────────────
  // Set of task ids the *current* user flipped to status:"done" in
  // this browser tab. The Hide-done filter below treats these as a
  // "stay visible (struck through) until the next reload" exception
  // so the user can see what they just finished without it vanishing
  // the moment they release the mouse. Persisted in sessionStorage
  // (not localStorage) so it clears on tab close / page reload —
  // exactly the trigger the user described as "next visit, hide it".
  //
  // Subtask rule: when a done subtask's TOP ancestor is also done,
  // the whole subtree hides on reload. While the top ancestor is
  // still live, the done child stays visible regardless of session
  // membership (the filter walks the parent chain — see `filtered`
  // memo). The session set keeps the just-flipped task visible IN
  // ADDITION TO that rule for top-level tasks.
  const [completedThisSession, setCompletedThisSession] = React.useState(() => {
    try {
      const raw = sessionStorage.getItem("fb.doneThisSession");
      if (raw) {
        const arr = JSON.parse(raw);
        if (Array.isArray(arr)) return new Set(arr);
      }
    } catch {}
    return new Set();
  });
  function markCompletedThisSession(taskId) {
    if (!taskId) return;
    setCompletedThisSession(prev => {
      if (prev.has(taskId)) return prev;
      const next = new Set(prev);
      next.add(taskId);
      try { sessionStorage.setItem("fb.doneThisSession", JSON.stringify([...next])); } catch {}
      return next;
    });
  }
  function unmarkCompletedThisSession(taskId) {
    if (!taskId) return;
    setCompletedThisSession(prev => {
      if (!prev.has(taskId)) return prev;
      const next = new Set(prev);
      next.delete(taskId);
      try { sessionStorage.setItem("fb.doneThisSession", JSON.stringify([...next])); } catch {}
      return next;
    });
  }
  function clearCompletedThisSession() {
    setCompletedThisSession(new Set());
    try { sessionStorage.removeItem("fb.doneThisSession"); } catch {}
  }
  // BG_CFFB278E71 — "the hide done filter not working".
  // Symptom: user marks tasks done while Hide-done is ON. The session
  // reprieve keeps those rows visible (intentional). User then clicks
  // Show done → Hide done expecting them to disappear, but they don't:
  // the session Set survives the toggle, so the filter appears broken.
  //
  // Fix: every time the user EXPLICITLY turns Hide-done ON, wipe the
  // session reprieve. The session-completion feature still works for
  // tasks completed AFTER the toggle, which is the original intent.
  const _prevHideDoneRef = React.useRef(filters.hideDone);
  React.useEffect(() => {
    if (filters.hideDone && !_prevHideDoneRef.current) {
      clearCompletedThisSession();
    }
    _prevHideDoneRef.current = filters.hideDone;
  }, [filters.hideDone]);
  // Expose on window so the drawer, bulk-actions, and lateness modal
  // can mark a task as just-completed without having to thread the
  // helper through every component prop chain. The helpers are
  // re-published on every render so stale closures inside long-lived
  // modals always hit the current state setter.
  React.useEffect(() => {
    window.markCompletedThisSession   = markCompletedThisSession;
    window.unmarkCompletedThisSession = unmarkCompletedThisSession;
  });

  // Subtasks are always shown alongside their parents in the table.
  // Bootstrap ships only top-level rows (to keep initial payload
  // lean), so this effect fetches the children whenever the active
  // project changes and merges them into local state. The merge is
  // id-deduped + parent/name-aware so optimistic temp rows don't
  // race the canonical fetch.
  React.useEffect(() => {
    if (!activeProjectId) return;
    let cancelled = false;
    (async () => {
      try {
        const rows = await api.tasks.list({ project_id: activeProjectId, include_subtasks: 1 });
        if (cancelled) return;
        const norm = (typeof window !== "undefined" && typeof window.normalizeTask === "function")
          ? window.normalizeTask
          : (r => r);
        // Diagnostic visibility — log what came back, and how many of
        const subtaskRows = (rows || []).filter(r => r && r.parent_task_id);
        // Functional setState so the merge sees the latest tasks
        // state (avoids stale-closure dups when an optimistic
        // create races a project switch).
        setTasks(prev => {
          const have = new Set(prev.map(t => t.id));
          const fresh = subtaskRows
            .filter(r => !have.has(r.id))
            .map(r => ({ ...norm(r), position: Number(r.position || 0) }));
          if (!fresh.length) return prev;
          const merged = [...prev, ...fresh];
          // Recompute depth across the merged list — these subtasks
          // ARE the rows that need depth set right (server's depth
          // column may be missing / stale for them). The same
          // chain-walk we do at bootstrap, re-applied after the
          // subtasks land. Without this every freshly-loaded
          // subtask row would render at depth 0 (no indent) until
          // the next page refresh.
          _recomputeTaskDepth(merged);
          if (Array.isArray(window.ALL_TASKS)) {
            for (const f of fresh) {
              if (!window.ALL_TASKS.find(t => t.id === f.id)) window.ALL_TASKS.push(f);
            }
            // Also push the depth fix into ALL_TASKS so any view
            // that reads from the global (Home, MyWork, etc.) gets
            // the same indent calculation.
            _recomputeTaskDepth(window.ALL_TASKS);
          }
          return merged;
        });
      } catch (e) {
        // Silent — the table just won't have subtasks until the next
        // project switch / reload. We log to the console for devs.
        const msg = (e && e.body && e.body.message) || (e && e.message) || "network error";
        console.warn("[subtasks] fetch failed:", msg);
      }
    })();
    return () => { cancelled = true; };
  }, [activeProjectId]);

  // Shared write-error handler. Decides whether the error is transient
  // (network blip / 5xx → park in outbox and retry on reconnect) or
  // permanent (4xx like forbidden / validation / not-found → roll back
  // the optimistic state and surface the real error to the user).
  // Without this split, every failure would be queued forever — see
  // the comment block in outbox.js.
  function _isTransientError(e) {
    if (window.flowboardOutbox && typeof window.flowboardOutbox.isTransient === "function") {
      return window.flowboardOutbox.isTransient(e);
    }
    if (!e) return true;
    const s = Number(e.status);
    if (!Number.isFinite(s) || s === 0) return true;
    if (s >= 500) return true;
    if (s === 408 || s === 429) return true;
    return false;
  }
  function handleWriteError(e, opts) {
    // opts: { kind, payload, label, rollback }
    const transient = _isTransientError(e);
    if (transient && window.flowboardOutbox) {
      window.flowboardOutbox.enqueue(opts.kind, opts.payload, { label: opts.label });
      showToast("Saved offline — will retry");
      return;
    }
    // Permanent error or no outbox available: undo the optimistic state
    // so the UI matches the server, then show the actual error.
    if (typeof opts.rollback === "function") {
      try { opts.rollback(); } catch (rollbackErr) { console.warn("[write] rollback threw:", rollbackErr); }
    }
    const msg = (e && e.message) || "Couldn't save change";
    showToast(msg + (transient ? "" : ""), 4000);
  }

  // When the outbox finally gives up on an entry (permanent rejection,
  // wrong-user, or max attempts exceeded), reconcile the local state by
  // refetching the affected task. Without this, the optimistic edit
  // would stay applied locally even though the server rejected it.
  React.useEffect(() => {
    function onRejected(ev) {
      const detail = ev && ev.detail;
      if (!detail || !detail.entry) return;
      const entry = detail.entry;
      const reason = detail.reason || {};
      const taskId = entry.payload && entry.payload.id;
      // Toast something the user can act on.
      const reasonText =
        reason.reason === "wrong_user"
          ? "Sign-in changed — discarded a queued edit from the previous user"
          : reason.giveUp
          ? "Couldn't sync after several tries — change reverted"
          : reason.error === "forbidden"
          ? "You don't have permission to make that change — reverted"
          : reason.error === "not_found"
          ? "That item was deleted on the server — reverted"
          : "Couldn't save offline change — reverted";
      showToast(reasonText, 4500);
      // Best-effort reconcile: refetch the single task and write it back
      // into ALL_TASKS so the UI lines up with reality. tasks.remove
      // doesn't have a row to refetch, so we just refresh the workspace
      // for that case if anything's still local.
      if (entry.kind === "tasks.remove") {
        if (typeof window.flowboardLoad === "function") {
          window.flowboardLoad().catch(() => {});
        }
        return;
      }
      if (taskId && window.api && window.api.tasks && window.api.tasks.get) {
        window.api.tasks.get(taskId)
          .then(raw => {
            if (!raw) return;
            const flat = {
              ...raw,
              project_name : (raw.project && raw.project.name)  || raw.project_name  || null,
              project_color: (raw.project && raw.project.color) || raw.project_color || null,
              epic_title   : (raw.epic && raw.epic.title)       || raw.epic_title    || null,
              epic_color   : (raw.epic && raw.epic.color)       || raw.epic_color    || null,
              sprint_label : (raw.sprint && raw.sprint.label)   || raw.sprint_label  || null,
            };
            const norm = window.normalizeTask ? window.normalizeTask(flat) : flat;
            if (Array.isArray(window.ALL_TASKS)) {
              const i = window.ALL_TASKS.findIndex(t => t.id === taskId);
              if (i !== -1) window.ALL_TASKS[i] = norm;
              else window.ALL_TASKS.push(norm);
            }
            setTasks(ts => {
              const i = ts.findIndex(t => t.id === taskId);
              if (i === -1) return [...ts, norm];
              const next = ts.slice();
              next[i] = { ...next[i], ...norm };
              return next;
            });
          })
          .catch(() => {
            // 404 — the row is genuinely gone. Drop it locally too.
            if (Array.isArray(window.ALL_TASKS)) {
              const i = window.ALL_TASKS.findIndex(t => t.id === taskId);
              if (i !== -1) window.ALL_TASKS.splice(i, 1);
            }
            setTasks(ts => ts.filter(t => t.id !== taskId));
          });
      }
    }
    window.addEventListener("flowboard:outbox:rejected", onRejected);
    return () => window.removeEventListener("flowboard:outbox:rejected", onRejected);
  }, []);

  function moveTask(id, body) {
    // body can include: { status, sprint_id, epic_id, position }
    // Optimistically reflect ALL changes locally — including position — so the
    // reorder shows up immediately without waiting for a refetch.
    const prevTasks = tasks;
    const prevAll = Array.isArray(window.ALL_TASKS) ? window.ALL_TASKS.slice() : null;
    setTasks(ts => {
      let next = ts.map(t => {
        if (t.id !== id) return t;
        const patched = { ...t };
        if ("status"    in body) patched.status  = body.status;
        if ("sprint_id" in body) patched.sprint  = body.sprint_id;
        if ("epic_id"   in body) patched.epicId  = body.epic_id;
        return patched;
      });
      if ("position" in body) {
        const moved = next.find(t => t.id === id);
        if (moved) {
          const sameBucket = (t) =>
            t.status === moved.status &&
            (t.projectId || null) === (moved.projectId || null) &&
            !t.parent_task_id;
          next = next.filter(t => t.id !== id);
          const bucketTasks = next.filter(sameBucket);
          const targetIdx = Math.max(0, Math.min(body.position, bucketTasks.length));
          const insertBefore = bucketTasks[targetIdx];
          let globalIdx;
          if (!insertBefore) {
            let lastBucketIdx = -1;
            next.forEach((t, i) => { if (sameBucket(t)) lastBucketIdx = i; });
            globalIdx = lastBucketIdx + 1;
          } else {
            globalIdx = next.indexOf(insertBefore);
          }
          next.splice(globalIdx, 0, moved);
        }
      }
      return next;
    });
    if (window.api && typeof id === "string" && !id.startsWith("tmp_")) {
      // Mirror the move into ALL_TASKS so any global reader sees it.
      if (Array.isArray(window.ALL_TASKS)) {
        const i = window.ALL_TASKS.findIndex(t => t.id === id);
        if (i !== -1) {
          const cur = window.ALL_TASKS[i];
          const next = { ...cur };
          if ("status"    in body) next.status = body.status;
          if ("sprint_id" in body) next.sprint = body.sprint_id;
          if ("epic_id"   in body) next.epicId = body.epic_id;
          window.ALL_TASKS[i] = next;
        }
      }
      // Suppress the SSE echo this call triggers — we already applied the
      // optimistic update locally, no need to refetch + re-render.
      if (window.flowboardRealtime) window.flowboardRealtime.markLocalEdit(id);
      api.tasks.move(id, body)
        .then(() => showToast("Reordered"))
        .catch((e) => {
          handleWriteError(e, {
            kind: "tasks.move", payload: { id, body },
            label: "Move " + id,
            rollback: () => {
              setTasks(prevTasks);
              if (prevAll) window.ALL_TASKS = prevAll;
            },
          });
        });
    }
  }
  // Drag-to-reparent. parentId === null promotes the task to a
  // top-level row. parentId === '<id>' makes it a subtask of that
  // task. Backend inherits the new parent's epic/sprint/user_story.
  function reparentTask(id, parentId) {
    if (!id) return;
    const prevTasks = tasks;
    const prevAll = Array.isArray(window.ALL_TASKS) ? window.ALL_TASKS.slice() : null;
    setTasks(ts => ts.map(t => t.id === id ? { ...t, parentTaskId: parentId || null } : t));
    if (Array.isArray(window.ALL_TASKS)) {
      const i = window.ALL_TASKS.findIndex(t => t.id === id);
      if (i !== -1) {
        window.ALL_TASKS[i] = { ...window.ALL_TASKS[i], parentTaskId: parentId || null };
      }
    }
    if (window.flowboardRealtime) window.flowboardRealtime.markLocalEdit(id);
    api.tasks.patch(id, { parent_task_id: parentId || null })
      .then(() => {
        showToast(parentId ? "Made subtask" : "Promoted to top-level");
      })
      .catch((e) => {
        const msg = (e && e.body && e.body.message) || (e && e.message) || "network error";
        showToast("Reparent failed: " + msg);
        setTasks(prevTasks);
        if (prevAll) window.ALL_TASKS = prevAll;
      });
  }

  function deleteTask(id) {
    const prev = tasks;
    // Snapshot the row before we drop it. Used by the Undo handler
    // below to put it back in the same place without a refetch.
    const removedTask = tasks.find(t => t.id === id);
    const removedAllIdx = Array.isArray(window.ALL_TASKS)
      ? window.ALL_TASKS.findIndex(t => t.id === id) : -1;
    const removedFromAll = removedAllIdx >= 0 ? window.ALL_TASKS[removedAllIdx] : null;

    setTasks(ts => ts.filter(t => t.id !== id));
    // Drop from ALL_TASKS too so My Work / dashboards / etc. lose the row.
    if (Array.isArray(window.ALL_TASKS) && removedAllIdx !== -1) {
      window.ALL_TASKS.splice(removedAllIdx, 1);
    }
    if (window.api && typeof id === "string" && !id.startsWith("tmp_")) {
      if (window.flowboardRealtime) window.flowboardRealtime.markLocalEdit(id);
      api.tasks.remove(id)
        .then(() => {
          // Undo handler — restores the row locally + asks the server
          // to pull it back out of the bin. The server's
          // /tasks/:id/restore endpoint already exists for the Bin
          // view; we just call it eagerly when the user hits Undo.
          let undone = false;
          function undo() {
            if (undone) return;
            undone = true;
            // Local restore — put the task back where it was so the
            // UI feels instant. Order isn't preserved precisely but
            // the table re-sorts by position anyway.
            if (removedTask) setTasks(ts => [...ts, removedTask]);
            if (removedFromAll && Array.isArray(window.ALL_TASKS)) {
              window.ALL_TASKS.splice(removedAllIdx, 0, removedFromAll);
            }
            if (window.flowboardRealtime) window.flowboardRealtime.markLocalEdit(id);
            (api.tasks.restore ? api.tasks.restore(id) : api.bin.restoreTask(id))
              .then(() => showToast(`"${(removedTask && removedTask.name) || "Task"}" restored`))
              .catch((e) => {
                // Server restore failed — roll the local restore back
                // and surface the error so the user knows.
                setTasks(ts => ts.filter(t => t.id !== id));
                if (Array.isArray(window.ALL_TASKS)) {
                  const i2 = window.ALL_TASKS.findIndex(t => t.id === id);
                  if (i2 !== -1) window.ALL_TASKS.splice(i2, 1);
                }
                showToast("Couldn't undo: " + ((e && e.message) || "network error"), 4000);
              });
          }
          // 5-second action toast. Click Undo within the window to
          // restore; let it expire and the bin will hold the task
          // for the usual 30 days.
          showToast({
            msg: `"${(removedTask && removedTask.name) || "Task"}" moved to Bin`,
            ms: 5000,
            action: { label: "Undo", onClick: undo },
          }, 5000);
        })
        .catch((e) => {
          // 404 on delete = the row is already gone server-side (almost
          // always because a parent's FK cascade just nuked it as part
          // of a bulk delete). Treat it as success: don't rollback, don't
          // toast. Without this, bulk-selecting a parent + its descendants
          // produces a storm of "Couldn't save change" toasts after the
          // first DELETE cascades the rest away.
          const status = Number(e && e.status);
          if (status === 404) {
            if (Array.isArray(window.ALL_TASKS)) {
              const k = window.ALL_TASKS.findIndex(t => t.id === id);
              if (k !== -1) window.ALL_TASKS.splice(k, 1);
            }
            return;
          }
          handleWriteError(e, {
            kind: "tasks.remove", payload: { id },
            label: "Delete " + id,
            rollback: () => setTasks(prev),
          });
        });
    }
  }
  function updateTask(id, patch) {
    // Late-completion intercept — if the user is closing an overdue task
    // (status → done) without already supplying an outcome, open the
    // CompleteTaskModal so they can log a delay reason. The modal's
    // onConfirm calls back into updateTask with outcome:"late"|"ontime"
    // (and delayReason / delayNote / completedAt), which bypasses this
    // branch on the second pass.
    if (patch && patch.status === "done" && !("outcome" in patch)
        && typeof window.isOverdueNow === "function") {
      const cur = tasks.find(t => t.id === id);
      if (cur && cur.status !== "done" && window.isOverdueNow(cur)) {
        setLateCompleteTask(cur);
        return;
      }
    }
    // Priority-conflict intercept — when the LOCAL user is bumping
    // a task to high/critical, route through requestPriorityChange
    // so they see the assignee's existing load + cap (Phase 1+2 of
    // cross-PC priority management). The helper returns:
    //   true             → proceed with the patch as-is
    //   false            → user cancelled; abort silently
    //   "downgrade-medium" → user picked the "set as medium instead"
    //                       escape hatch; rewrite the patch and recurse
    // Only fires on direct prio bumps (not on assignee changes or
    // status flips). Resubmits from the modal itself skip the
    // intercept via `_priorityChecked` so we don't loop.
    if (patch && "prio" in patch && !patch._priorityChecked
        && (patch.prio === "high" || patch.prio === "critical")
        && typeof window.requestPriorityChange === "function") {
      const cur = tasks.find(t => t.id === id);
      if (cur && cur.prio !== patch.prio) {
        const ownersForCheck = patch.owners || cur.owners || [];
        const taskForCheck = { ...cur, owners: ownersForCheck };
        window.requestPriorityChange(taskForCheck, patch.prio, { currentUserId })
          .then((decision) => {
            if (decision === false) return; // cancel
            if (decision === "downgrade-medium") {
              updateTask(id, { ...patch, prio: "medium", _priorityChecked: true });
              return;
            }
            updateTask(id, { ...patch, _priorityChecked: true });
          })
          .catch(() => updateTask(id, { ...patch, _priorityChecked: true }));
        return;
      }
    }
    // Due-date change reason intercept — when an EXISTING due date
    // changes to a different non-null date, open DueChangeModal so
    // the user logs WHY. The modal's onConfirm re-calls updateTask
    // with the same `due` plus `dueChangeReason` + `dueChangeNote`,
    // which the second pass passes through verbatim (the intercept
    // re-fires only when `dueChangeReason` is absent). Cases that
    // SKIP the modal:
    //   * First-time set     (prev.due null/empty/"—")
    //   * Clearing the date  (patch.due null/empty/"—")
    //   * No-op (same date)
    //   * Recurring-template / system writes (patch._noDueReason)
    //   * Resubmit from the modal itself (dueChangeReason present)
    if (patch && "due" in patch
        && !("dueChangeReason" in patch)
        && !patch._noDueReason) {
      const cur = tasks.find(t => t.id === id);
      const oldDue = cur && cur.due;
      const newDue = patch.due;
      const isEmpty = (v) => !v || v === "—" || v === "";
      const changed = !isEmpty(oldDue) && !isEmpty(newDue) && oldDue !== newDue;
      if (changed && cur) {
        setPendingDueChange({ task: cur, oldDue, newDue });
        return;
      }
    }
    // Mark / unmark "completed this session" when the LOCAL user
    // flips a task to or away from done. This pairs with the Hide-
    // done filter below — just-completed tasks stay visible (with
    // strike-through) this session, then hide on the next reload.
    // Remote SSE-driven status changes don't pass through this
    // function, so they correctly DON'T get the session reprieve.
    if (patch && "status" in patch) {
      const prev = tasks.find(t => t.id === id);
      const wasDone = !!(prev && prev.status === "done");
      const becomingDone = patch.status === "done";
      if (becomingDone && !wasDone) markCompletedThisSession(id);
      else if (wasDone && !becomingDone) unmarkCompletedThisSession(id);
    }
    // Snapshot for rollback if any API call fails.
    const prevTasks = tasks;
    // Optimistic UI update — patch React state…
    setTasks(ts => ts.map(t => t.id === id ? { ...t, ...patch } : t));
    // …AND mutate window.ALL_TASKS in place. The realtime echo
    // suppression deliberately drops our own SSE event, which means
    // ALL_TASKS would otherwise stay stale until a remote edit arrived.
    // Keeping it in lockstep means any view that still reads from the
    // global (older code paths, side-rail computations, etc.) sees the
    // edit immediately.
    if (Array.isArray(window.ALL_TASKS)) {
      const idx = window.ALL_TASKS.findIndex(t => t.id === id);
      if (idx !== -1) window.ALL_TASKS[idx] = { ...window.ALL_TASKS[idx], ...patch };
    }
    // Persist to API (best-effort — failures revert via error toast)
    if (window.api && typeof id === "string" && !id.startsWith("tmp_")) {
      // Suppress the SSE echo our own write will trigger — keeps the
      // optimistic state intact and avoids a redundant re-render.
      if (window.flowboardRealtime) window.flowboardRealtime.markLocalEdit(id);
      // Owners go through a dedicated PUT /tasks/:id/assignees endpoint.
      if ("owners" in patch && Array.isArray(patch.owners)) {
        api.tasks.assignees(id, patch.owners)
          .then(() => showToast("Owners updated"))
          .catch((e) => {
            handleWriteError(e, {
              kind: "tasks.assignees", payload: { id, userIds: patch.owners },
              label: "Owner change · " + id,
              rollback: () => setTasks(prevTasks),
            });
          });
      }
      const apiPatch = {};
      if ("name" in patch)         apiPatch.name        = patch.name;
      if ("desc" in patch)         apiPatch.description = patch.desc;
      if ("_description" in patch) apiPatch.description = patch._description;
      if ("status" in patch)       apiPatch.status      = patch.status;
      if ("prio" in patch)       apiPatch.priority    = patch.prio;
      if ("points" in patch)     apiPatch.points      = patch.points;
      if ("due" in patch)        apiPatch.due_date    = patch.due === "—" ? null : patch.due;
      // Due-change reason metadata. Server folds these into the
      // 'due' task_activity entry's body + meta so the drawer's
      // activity feed renders "due moved Apr 24 → May 2 · reason:
      // Scope grew". Only sent when the DueChangeModal collected
      // them — direct date sets (first-time / clearing) skip the
      // modal so these fields aren't present.
      if ("dueChangeReason" in patch && patch.dueChangeReason) {
        apiPatch.due_change_reason = patch.dueChangeReason;
      }
      if ("dueChangeNote" in patch && patch.dueChangeNote) {
        apiPatch.due_change_note = patch.dueChangeNote;
      }
      if ("epicId" in patch)     apiPatch.epic_id     = patch.epicId || null;
      if ("sprint" in patch)     apiPatch.sprint_id   = patch.sprint || null;
      // Task type — task / bug / chore / spike. The drawer's
      // TaskTypeChip writes patch.type when the user re-classifies a
      // row (e.g. "this turned out to be a bug"). Without this
      // mapping the local UI flipped the chip but the PATCH dropped
      // `type` on the floor, so the change vanished on next reload —
      // exactly the "task → bug not reflecting" bug reported.
      if ("type" in patch)       apiPatch.type        = patch.type || null;
      // Lateness fields — these are written when CompleteTaskModal
      // confirms a late close. Without these mappings the local UI
      // shows the late badge but the backend never persists the
      // outcome / reason, so dashboards under-count late deliveries.
      if ("outcome"     in patch) apiPatch.outcome      = patch.outcome     || null;
      if ("delayReason" in patch) apiPatch.delay_reason = patch.delayReason || null;
      if ("delayNote"   in patch) apiPatch.delay_note   = patch.delayNote   || null;
      if ("completedAt" in patch) apiPatch.completed_at = patch.completedAt || null;
      // Content-calendar channel tag (Instagram / YouTube / Blog / …).
      // Only meaningful when the parent project has content mode on,
      // but the API accepts it on any task harmlessly.
      if ("channel"     in patch) apiPatch.channel      = patch.channel     || null;
      // User-story link. The drawer / table both write this with the
      // server's snake_case key OR the camelCase mirror — accept either
      // so callers don't need to remember the convention. Without this
      // mapping, the optimistic state flips but the PATCH never carries
      // user_story_id, and the link silently disappears on next reload.
      if ("user_story_id" in patch || "userStoryId" in patch) {
        const next = ("user_story_id" in patch) ? patch.user_story_id : patch.userStoryId;
        apiPatch.user_story_id = next || null;
      }
      if (Object.keys(apiPatch).length) {
        // Pick a friendly toast label for the field that changed (first one wins)
        const labelFor = {
          name: "Renamed",
          description: "Description saved",
          status: "Status updated",
          priority: "Priority updated",
          points: "Points updated",
          due_date: "Due date updated",
          epic_id: "Moved to epic",
          sprint_id: "Sprint updated",
          channel: "Channel updated",
        };
        const firstField = Object.keys(apiPatch)[0];
        const okMsg = labelFor[firstField] || "Saved";
        api.tasks.patch(id, apiPatch)
          .then(() => showToast(okMsg))
          .catch((e) => {
            handleWriteError(e, {
              kind: "tasks.patch", payload: { id, body: apiPatch },
              label: (labelFor[firstField] || "Edit") + " · " + id,
              rollback: () => setTasks(prevTasks),
            });
          });
      }
    }
  }
  async function addTask({ projectId: pickedProjectId, name, sprint, epicId, userStoryId, desc, type, owners, prio, status, points, due, subtasks, updated, epicTitle, epicColor, channel }) {
    const epic = epicId ? EPICS.find(e => e.id === epicId) : null;
    // Project resolution priority:
    //   1. explicit projectId on the payload — set by the New Task
    //      modal's project picker when the user chose one (or from
    //      defaults.projectId when the caller pinned it);
    //   2. the epic's project (inline-add inside an epic group);
    //   3. the dashboard's active project — fallback for legacy
    //      callers that never passed projectId;
    //   4. "checkout" as a final safety net so the optimistic insert
    //      doesn't end up with NULL.
    const projectId = pickedProjectId || (epic && epic.project_id) || activeProjectId || "checkout";
    const projectMeta = (Array.isArray(PROJECTS) ? PROJECTS : []).find(p => p.id === projectId) || null;

    const body = {
      project_id: projectId,
      name,
      description: desc || null,
      status: status || "todo",
      priority: prio || "medium",
      points: points ?? 3,
      due_date: (due && due !== "—") ? due : null,
      // Task classification (task / bug / chore / spike). Without this the
      // server stored the column default and a task created as a "bug"
      // reverted to "task" on the next reload. Backend tolerates the
      // column being absent on older deploys.
      type: type || "task",
      epic_id: epicId || null,
      sprint_id: sprint || null,
      // Optional parent user-story (migration 024). Backend tolerates
      // the column being absent on older deploys.
      user_story_id: userStoryId || null,
      // Default to unassigned — owners must be explicitly chosen.
      owners: Array.isArray(owners) ? owners : [],
      // Optional content-calendar channel tag. The server handles
      // the column-missing case gracefully when migration 021 hasn't
      // run yet, so it's safe to always pass through.
      channel: channel || null,
    };

    // Optimistic insert with a temp id, swapped for the server id when the POST returns.
    const tempId = "tmp_" + Date.now();
    const optimistic = {
      id: tempId, name,
      status: body.status,
      prio: body.priority,
      owners: body.owners,
      due: body.due_date || "—",
      points: body.points,
      sprint: body.sprint_id,
      updated: updated || "now",
      subtasks: subtasks || 0,
      type: type || "task",
      desc: body.description || "",
      epicId: body.epic_id,
      epicTitle: epicTitle || epic?.title || null,
      epicColor: epicColor || epic?.color || null,
      userStoryId: body.user_story_id,
      // Project metadata — required so the project-scoped filter
      // (projectTasks) doesn't immediately filter the new row out.
      projectId,
      projectName: projectMeta?.name || null,
      projectColor: projectMeta?.color || null,
      // Sensible defaults for fields the rest of the UI reads.
      reviewers: [],
      labels: [],
      attachments: [],
      comments: 0,
      subtasks_done: 0,
      channel: body.channel || null,
    };
    setTasks(ts => [...ts, optimistic]);

    // Extract any inline data: image from the description BEFORE we
    // send it. Two reasons:
    //   1. A base64-encoded screenshot is ~200 KB even after the
    //      client-side resize. Five inline images = 1 MB of description.
    //      The tasks.description column maxes out at 65 KB on
    //      deployments where migration 040 (TEXT → MEDIUMTEXT) hasn't
    //      run, so the INSERT fails with "Data too long" and the user
    //      gets a useless "image too large" toast.
    //   2. Inline base64 in the description means images live inside
    //      task JSON — they can't be downloaded, shared, or re-used.
    //      Promoting them to first-class attachments fixes that.
    //
    // Strategy: parse <img src="data:image/..."> tags out, convert
    // each to a Blob, and after the task is created, upload them
    // as attachments. The description retains a clean placeholder so
    // the order/context is preserved if the user reopens the drawer.
    function _extractInlineImages(desc) {
      if (typeof desc !== "string" || !/src=["']data:image\//i.test(desc)) {
        return { cleanedDesc: desc, files: [] };
      }
      const files = [];
      let i = 0;
      // Greedy non-anchor regex for `<img ... src="data:image/<mime>;base64,<data>" ...>`.
      // mime + base64 capture so we can reconstruct a File with a sensible
      // filename and content-type for the attachment table.
      const cleanedDesc = desc.replace(
        /<img\b[^>]*\bsrc=["']data:(image\/[a-z+.-]+);base64,([^"']+)["'][^>]*>/gi,
        (_match, mime, b64) => {
          try {
            // Decode the base64 once. atob throws on malformed input —
            // catch so a single bad image doesn't tank the whole create.
            const bytes = atob(b64);
            const buf = new Uint8Array(bytes.length);
            for (let j = 0; j < bytes.length; j++) buf[j] = bytes.charCodeAt(j);
            const ext = mime.split("/")[1].split("+")[0].replace("jpeg", "jpg");
            const fname = "pasted-" + Date.now() + "-" + (i + 1) + "." + ext;
            files.push(new File([buf], fname, { type: mime }));
            i++;
            // Inline text marker so the user knows an image was here,
            // visible if they edit the description later. The Attachment
            // tab is where the actual image lives now.
            return "📎 " + fname;
          } catch {
            return ""; // drop malformed tag silently
          }
        }
      );
      return { cleanedDesc, files };
    }
    const { cleanedDesc, files: pendingAttachments } = _extractInlineImages(body.description);
    if (pendingAttachments.length) body.description = cleanedDesc;

    try {
      const r = await api.tasks.create(body);
      // Mark the new id so the SSE echo of our own create is dropped —
      // we already have the row locally; no need for realtime.js to GET
      // it again and trigger another setTasks.
      if (window.flowboardRealtime) window.flowboardRealtime.markLocalEdit(r.id);
      // Swap the temp id for the real one returned by the server, keeping our
      // local fields (projectId etc.) so the row stays visible. If an SSE
      // echo already pushed a r.id row, collapse to one (drop the tempId)
      // so the cross-project list doesn't render the same task twice.
      setTasks(ts => {
        const hasReal = ts.some(t => t.id === r.id);
        if (hasReal) return ts.filter(t => t.id !== tempId);
        return ts.map(t => t.id === tempId ? { ...t, id: r.id } : t);
      });

      // Upload the extracted images as attachments now that we have
      // a real task id. Run in parallel; failure of one doesn't tank
      // the others. The task itself is already created at this point,
      // so a partial attach failure is recoverable (user can re-attach
      // from the drawer's Attachment tab).
      let attachedCount = 0, failedCount = 0;
      if (pendingAttachments.length && api.tasks && api.tasks.uploadAttachment) {
        await Promise.all(pendingAttachments.map(async (f) => {
          try { await api.tasks.uploadAttachment(r.id, f); attachedCount++; }
          catch { failedCount++; }
        }));
      }

      // Tailored toast that tells the user whether the images landed
      // inline or were auto-attached. Avoids the previous confusing
      // "image too large" warning when the images actually went through
      // just fine as attachments.
      if (pendingAttachments.length) {
        const n = attachedCount;
        const lbl = n === 1 ? "1 image" : `${n} images`;
        if (failedCount > 0) {
          showToast(`Task created · ${lbl} attached (${failedCount} failed)`, 4500);
        } else {
          showToast(`Task created · ${lbl} attached as files`, 3500);
        }
      } else {
        showToast(`Task created${sprint ? " in sprint" : " in backlog"}`);
      }
    } catch (e) {
      // Roll back the optimistic insert.
      setTasks(ts => ts.filter(t => t.id !== tempId));
      const status = Number(e && e.status);
      const msg = (e && e.message) || "";
      const looksTooLarge =
        status === 413 ||
        /payload\s*too\s*large/i.test(msg) ||
        /data\s*too\s*long/i.test(msg) ||
        /entity\s*too\s*large/i.test(msg);
      // This branch should now be rare — extracted inline images are
      // already in pendingAttachments by this point, so the description
      // body is small. Kept as a safety net for unexpected payloads.
      if (looksTooLarge) {
        showToast("That description is too large — try removing some content and re-uploading any images as files.", 5500);
      } else {
        showToast("Couldn't create task: " + (msg || "network error"));
      }
    }
  }

  // Inline "+ Subtask" handler. The toolbar's Show-subtasks toggle
  // is auto-enabled here so the new subtask is visible immediately
  // after creation (the user just demonstrated they want to see them).
  // Inherits parent's epic/sprint/user_story server-side, so we
  // don't pass those explicitly.
  async function addSubtask({ name, parent_task_id, parentTask }) {
    if (!name || !parent_task_id) return;
    const parent = parentTask || (Array.isArray(window.ALL_TASKS)
      ? window.ALL_TASKS.find(t => t.id === parent_task_id) : null);
    if (!parent) return;
    const tempId = "tmp_" + Date.now();
    const optimistic = {
      id: tempId,
      name,
      status: "todo",
      prio: "medium",
      owners: [],
      due: "—",
      points: 0,
      sprint: parent.sprint || null,
      sprintLabel: parent.sprintLabel || null,
      updated: "now",
      subtasks: 0,
      type: "task",
      desc: "",
      epicId: parent.epicId || null,
      epicTitle: parent.epicTitle || null,
      epicColor: parent.epicColor || null,
      userStoryId: parent.userStoryId || null,
      parentTaskId: parent_task_id,
      projectId: parent.projectId,
      projectName: parent.projectName || null,
      projectColor: parent.projectColor || null,
      reviewers: [],
      labels: [],
      attachments: [],
      comments: 0,
      subtasks_done: 0,
      channel: parent.channel || null,
      // Nested-subtask depth — parent + 1, capped at 4. Without this
      // a freshly-created subtask renders at depth 0 (no indent)
      // until the next page refresh, even though the server will
      // store the correct depth via the INSERT path in tasks.routes.
      depth: Math.min(4, (Number(parent.depth) || 0) + 1),
    };
    setTasks(ts => [...ts, optimistic]);
    if (Array.isArray(window.ALL_TASKS)) window.ALL_TASKS.push(optimistic);

    try {
      const body = {
        project_id: parent.projectId,
        name,
        parent_task_id,
        // Backend auto-inherits epic/sprint/user_story from parent,
        // but pass them along too so the row renders correctly even
        // before the next bootstrap reload.
      };
      const r = await api.tasks.create(body);
      if (window.flowboardRealtime) window.flowboardRealtime.markLocalEdit(r.id);
      // Swap temp id → real id on both lists. If the SSE echo got
      // there first we'd already have a r.id row sitting next to
      // the tempId — collapse to one by removing the tempId entry
      // when a real-id sibling already exists, and otherwise just
      // rename the tempId in place. Same defense in window.ALL_TASKS.
      setTasks(ts => {
        const hasReal = ts.some(t => t.id === r.id);
        if (hasReal) return ts.filter(t => t.id !== tempId);
        return ts.map(t => t.id === tempId ? { ...t, id: r.id } : t);
      });
      if (Array.isArray(window.ALL_TASKS)) {
        const realIdx = window.ALL_TASKS.findIndex(t => t.id === r.id);
        const tempIdx = window.ALL_TASKS.findIndex(t => t.id === tempId);
        if (realIdx >= 0 && tempIdx >= 0 && realIdx !== tempIdx) {
          window.ALL_TASKS.splice(tempIdx, 1);
        } else if (tempIdx >= 0) {
          window.ALL_TASKS[tempIdx] = { ...window.ALL_TASKS[tempIdx], id: r.id };
        }
      }
      // Bump parent's subtask count optimistically.
      setTasks(ts => ts.map(t => t.id === parent_task_id
        ? { ...t, subtasks: (t.subtasks || 0) + 1 } : t));
      showToast(`Subtask created under "${parent.name}"`);
    } catch (e) {
      setTasks(ts => ts.filter(t => t.id !== tempId));
      if (Array.isArray(window.ALL_TASKS)) {
        const idx = window.ALL_TASKS.findIndex(t => t.id === tempId);
        if (idx >= 0) window.ALL_TASKS.splice(idx, 1);
      }
      const msg = (e && e.body && e.body.message) || (e && e.message) || "network error";
      showToast("Couldn't create subtask: " + msg);
    }
  }

  async function addEpic({ title, color, project_id, owner_id }) {
    const projectId = project_id || activeProjectId || "checkout";
    const r = await api.epics.create({
      project_id: projectId,
      title,
      color: color || "#a25ddc",
      owner_id: owner_id || null,
    });
    const newEpic = {
      id: r.id,
      title: r.title || title,
      color: r.color || color || "#a25ddc",
      project_id: r.project_id || projectId,
      owner_id: r.owner_id || owner_id || null,
      status: r.status || "active",
      progress: 0,
      tasks: [],
    };
    if (Array.isArray(window.EPICS)) {
      window.EPICS.push(newEpic);
    } else {
      window.EPICS = [newEpic];
    }
    bumpEpics(n => n + 1);
    showToast(`Epic “${newEpic.title}” created`);
    return newEpic;
  }

  // Patch an epic — used by the inline rename / color-pick affordances
  // on the epic header. Optimistic: mutate window.EPICS and bump the
  // re-render counter immediately, roll back on server error.
  async function updateEpic(id, patch) {
    if (!id) return;
    const list = Array.isArray(window.EPICS) ? window.EPICS : [];
    const cur = list.find(e => e.id === id);
    if (!cur) return;
    const prev = { ...cur };
    Object.assign(cur, patch);
    bumpEpics(n => n + 1);
    try {
      await api.epics.patch(id, patch);
      // Cascade the colour change to every task that displays this epic
      // (the row's left rail derives from epicColor on the task object).
      if ("color" in patch && Array.isArray(window.ALL_TASKS)) {
        for (const t of window.ALL_TASKS) {
          if (t.epicId === id || t.epic_id === id) t.epicColor = patch.color;
        }
        setTasks(ts => ts.map(t => (t.epicId === id) ? { ...t, epicColor: patch.color } : t));
      }
      showToast(`Epic updated`);
    } catch (e) {
      Object.assign(cur, prev);
      bumpEpics(n => n + 1);
      showToast("Couldn't update epic: " + ((e && e.message) || "network error"), 4000);
    }
  }

  // Delete an epic — children stay (their epic_id is set NULL on the
  // server, so they reappear under the "No epic" group). Confirm via
  // fbConfirm so it matches the rest of the app's destructive flows.
  async function deleteEpic(id) {
    if (!id) return;
    const list = Array.isArray(window.EPICS) ? window.EPICS : [];
    const cur = list.find(e => e.id === id);
    if (!cur) return;
    const childCount = (Array.isArray(window.ALL_TASKS) ? window.ALL_TASKS : [])
      .filter(t => (t.epicId === id || t.epic_id === id) && !t.parentTaskId).length;
    const ask = window.fbConfirm
      ? window.fbConfirm({
          title: `Delete epic "${cur.title}"?`,
          body: childCount
            ? `${childCount} task${childCount === 1 ? "" : "s"} will move to "No epic". The tasks themselves are kept.`
            : `This epic has no tasks. It'll be removed permanently.`,
          confirmLabel: "Delete epic",
          danger: true,
        })
      : Promise.resolve(window.confirm(`Delete epic "${cur.title}"?`));
    Promise.resolve(ask)
      .then(async (ok) => {
        if (!ok) return;
        // Optimistic: drop from EPICS + null out tasks' epicId
        const idx = list.findIndex(e => e.id === id);
        if (idx >= 0) list.splice(idx, 1);
        if (Array.isArray(window.ALL_TASKS)) {
          for (const t of window.ALL_TASKS) {
            if (t.epicId === id) { t.epicId = null; t.epicTitle = null; t.epicColor = null; }
          }
          setTasks(ts => ts.map(t => t.epicId === id
            ? { ...t, epicId: null, epicTitle: null, epicColor: null } : t));
        }
        bumpEpics(n => n + 1);
        try {
          await api.epics.remove(id);
          showToast(`Epic “${cur.title}” deleted`);
        } catch (e) {
          // Roll back: re-insert at original position with original data
          if (idx >= 0) list.splice(idx, 0, cur);
          bumpEpics(n => n + 1);
          showToast("Couldn't delete epic: " + ((e && e.message) || "network error"), 4000);
        }
      })
      .catch((e) => console.warn("[deleteEpic] confirm threw:", e));
  }
  async function addProject({ name, color, visibility, workspace_id }) {
    const r = await api.projects.create({
      name,
      color: color || "#0073ea",
      visibility: visibility || "workspace",
      workspace_id: workspace_id || undefined,
    });
    const newProject = {
      id: r.id,
      name,
      color: color || "#0073ea",
      count: 0,
      // Keep the workspace link on the local row so the sidebar
      // groups it under the right header before the next reload.
      workspaceId: r.workspace_id || workspace_id || 1,
    };
    if (Array.isArray(window.PROJECTS)) {
      window.PROJECTS.push(newProject);
    } else {
      window.PROJECTS = [newProject];
    }
    // Update PROJECT_ACCESS so the current user is owner of the new project
    if (window.PROJECT_ACCESS) {
      window.PROJECT_ACCESS[r.id] = {
        visibility: visibility || "workspace",
        owner: currentUserId,
        members: [{ id: currentUserId, role: "owner" }],
      };
      setAccess({ ...window.PROJECT_ACCESS });
    }
    bumpProjects(n => n + 1);
    setActiveProjectId(r.id);
    setView("project");
    showToast(`Project “${name}” created`);
    return newProject;
  }

  function toggleEpic(id) { setCollapsed(s => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; }); }
  function onSelect(id, on) { setSelected(s => { const n = new Set(s); on ? n.add(id) : n.delete(id); return n; }); }
  function openTask(t) { setDrawerTask(t); setDrawerOpen(true); }

  // ── Navigation handler for notifications ────────────────────────
  // Bell + desktop pop-ups dispatch `flowboard:nav` with a link like
  // "/tasks/t_abc", "/support/TK_xxx", "/bugs/BG_xxx". We translate
  // those links into the actual view + drawer the user expected when
  // they clicked. Without this, clicking a notification visually did
  // nothing — the row got marked read but the user stayed put.
  React.useEffect(() => {
    function onNav(e) {
      const link = e && e.detail && e.detail.link;
      if (!link) return;
      let m;
      if ((m = /^\/tasks\/([^/?#]+)/.exec(link))) {
        const taskId = m[1];
        const task = (window.ALL_TASKS || []).find(t => t.id === taskId)
                  || (Array.isArray(tasks) ? tasks.find(t => t.id === taskId) : null);
        if (task) {
          if (task.projectId && task.projectId !== activeProjectId) {
            setActiveProjectId(task.projectId);
          }
          setView("project");
          openTask(task);
        } else if (window.api && window.api.tasks && window.api.tasks.get) {
          // Fall back to a one-shot fetch if the task isn't in the
          // local cache yet (e.g. SSE delivered a notif for a row our
          // bootstrap didn't include).
          window.api.tasks.get(taskId).then(raw => {
            if (!raw) return;
            const flat = {
              ...raw,
              projectId: raw.project_id,
              projectName: (raw.project && raw.project.name) || raw.project_name || null,
              projectColor: (raw.project && raw.project.color) || raw.project_color || null,
            };
            if (flat.projectId && flat.projectId !== activeProjectId) {
              setActiveProjectId(flat.projectId);
            }
            setView("project");
            openTask(flat);
          }).catch(() => {});
        }
      } else if (/^\/support\b/.test(link)) {
        setView("support");
      } else if (/^\/bugs\b/.test(link)) {
        setView("bugs");
      }
    }
    window.addEventListener("flowboard:nav", onNav);
    return () => window.removeEventListener("flowboard:nav", onNav);
  }, [tasks, activeProjectId]);

  // ── Deep link via URL hash ─────────────────────────────────────
  // The drawer's "Copy link" button writes URLs of the form
  //   <origin>/<path>#/tasks/<id>
  // Pasting that into a fresh tab lands on the app shell with the
  // matching hash. We re-broadcast it as a flowboard:nav event the
  // first time `tasks` is populated AND on every hash change after,
  // so the existing nav handler does the heavy lifting (project
  // switch + drawer open + fallback fetch). Idempotent — replays of
  // the same hash are a no-op because the drawer is already on the
  // right task.
  React.useEffect(() => {
    function fireHash(consume) {
      try {
        const h = window.location.hash || "";
        const m = /^#(\/(tasks|support|bugs)\/[^?]+)/.exec(h) || /^#(\/(tasks|support|bugs))(?:[?#].*)?$/.exec(h);
        if (m) {
          window.dispatchEvent(new CustomEvent("flowboard:nav", { detail: { link: m[1] } }));
          // Strip the hash after consuming it on initial mount so
          // refreshing the page doesn't keep snapping the user back
          // to this task. Live hashchange events DON'T strip — those
          // come from the user clicking a fresh link, which they
          // expect to stick. history.replaceState avoids adding a
          // history entry.
          if (consume) {
            try {
              const u = new URL(window.location.href);
              u.hash = "";
              window.history.replaceState(null, "", u.toString());
            } catch {}
          }
        }
      } catch {}
    }
    function onHash() { fireHash(false); }
    window.addEventListener("hashchange", onHash);
    // Initial fire — wait until the bootstrap has populated tasks
    // so the lookup in the nav handler can succeed without a
    // round-trip. The handler's own fetch fallback covers the
    // race when the task isn't in the local cache yet. Pass
    // consume=true so the hash is cleared once we've routed it.
    if (Array.isArray(tasks) && tasks.length) fireHash(true);
    return () => window.removeEventListener("hashchange", onHash);
  }, [tasks && tasks.length > 0]);

  async function startSprint({ label, goal, team, dates, taskIds }) {
    // Snapshot for rollback if any API call fails.
    const prevSprints = sprints;
    const prevTasks = tasks;

    const tempId = "tmp_sp_" + Date.now();
    // Optimistic: insert sprint at the top, move chosen tasks into it.
    setSprints(ss => [{ id: tempId, label, dates, active: true, team, goal }, ...ss]);
    setTasks(ts => ts.map(t => taskIds.includes(t.id) ? { ...t, sprint: tempId } : t));
    setActiveSprintId(tempId);
    setStartOpen(false);
    setTab("sprint");

    try {
      // 1. Create the sprint server-side
      const r = await api.sprints.create({
        project_id: activeProjectId || null,
        label, dates: dates || null, team: team || null,
        active: true,
      });
      const realId = r.id;

      // 2. Swap temp id for real id in local state
      setSprints(ss => ss.map(s => s.id === tempId ? { ...s, id: realId } : s));
      setTasks(ts => ts.map(t => t.sprint === tempId ? { ...t, sprint: realId } : t));
      setActiveSprintId(realId);

      // 3. Move the chosen tasks into the sprint server-side (skip optimistic temp_ ids)
      const persistableIds = taskIds.filter(id => typeof id === "string" && !id.startsWith("tmp_"));
      await Promise.all(persistableIds.map(id =>
        api.tasks.patch(id, { sprint_id: realId }).catch(() => null)
      ));

      showToast(`${label} started — ${taskIds.length} task${taskIds.length === 1 ? "" : "s"} committed`);
    } catch (e) {
      // Rollback the optimistic state
      setSprints(prevSprints);
      setTasks(prevTasks);
      showToast("Couldn't start sprint: " + (e.message || "network error"), 4000);
    }
  }

  // Plan an upcoming sprint — same shape as startSprint, but stays inactive
  // until someone presses "Activate". Tasks pulled in leave the backlog.
  async function planSprint({ label, goal, team, dates, taskIds, startDate }) {
    const prevSprints = sprints;
    const prevTasks = tasks;

    const tempId = "tmp_sp_" + Date.now();
    setSprints(ss => [
      ...ss,
      { id: tempId, label, dates, active: false, completed: false, planned: true,
        team, goal, project_id: activeProjectId || null, startDate: startDate || null },
    ]);
    setTasks(ts => ts.map(t => taskIds.includes(t.id) ? { ...t, sprint: tempId } : t));
    setPlanOpen(false);

    try {
      const r = await api.sprints.create({
        project_id: activeProjectId || null,
        label, dates: dates || null, team: team || null,
        active: false,
      });
      const realId = r.id;
      setSprints(ss => ss.map(s => s.id === tempId ? { ...s, id: realId } : s));
      setTasks(ts => ts.map(t => t.sprint === tempId ? { ...t, sprint: realId } : t));

      const persistable = taskIds.filter(id => typeof id === "string" && !id.startsWith("tmp_"));
      await Promise.all(persistable.map(id =>
        api.tasks.patch(id, { sprint_id: realId }).catch(() => null)
      ));

      showToast(`${label} planned — ${taskIds.length} task${taskIds.length === 1 ? "" : "s"} queued`);
    } catch (e) {
      setSprints(prevSprints);
      setTasks(prevTasks);
      showToast("Couldn't plan sprint: " + (e.message || "network error"), 4000);
    }
  }

  // Flip an upcoming sprint live and switch the view to it.
  async function activateSprint(id) {
    const sprint = sprints.find(s => s.id === id);
    if (!sprint) return;
    const prev = sprints;
    setSprints(ss => ss.map(s => s.id === id ? { ...s, active: true, planned: false } : s));
    setActiveSprintId(id);
    setTab("sprint");
    try {
      if (typeof id === "string" && !id.startsWith("tmp_")) {
        await api.sprints.patch(id, { active: true });
      }
      showToast(`${sprint.label} is now active`);
    } catch (e) {
      setSprints(prev);
      showToast("Couldn't activate sprint: " + (e.message || "network error"), 4000);
    }
  }

  async function completeSprint({ carryover }) {
    const sprintId = activeSprintId;
    const sprintBeingCompleted = sprints.find(s => s.id === sprintId);
    const incomplete = tasks.filter(t => t.sprint === sprintId && t.status !== "done");
    const nextSid = sprints.find(s => s.active && s.id !== sprintId)?.id
                 || sprints.find(s => !s.active && s.id !== sprintId)?.id
                 || null;

    // Snapshot for rollback
    const prevSprints = sprints;
    const prevTasks = tasks;

    // Optimistic local update
    setTasks(ts => ts.map(t => {
      if (t.sprint !== sprintId || t.status === "done") return t;
      if (carryover === "next")    return { ...t, sprint: nextSid };
      if (carryover === "backlog") return { ...t, sprint: null };
      return t;
    }));
    setSprints(ss => ss.map(s => s.id === sprintId ? { ...s, active: false, completed: true } : s));
    // Pick the next active sprint to land on. Scope to THIS project so
    // we don't surface another project's sprint after completing one.
    const projId = activeProject?.id;
    const stillActive = sprints.filter(s =>
      s.active && s.id !== sprintId && (!projId || s.project_id === projId)
    );
    setActiveSprintId(stillActive[0]?.id || nextSid || sprintId);
    setCompleteOpen(false);

    try {
      // 1. Mark the sprint inactive server-side (only if it has a real id)
      if (typeof sprintId === "string" && !sprintId.startsWith("tmp_")) {
        await api.sprints.patch(sprintId, { active: false });
      }

      // 2. Re-home each incomplete task — only if carryover wasn't "archive"
      if (carryover === "next" || carryover === "backlog") {
        const targetSid = carryover === "next" ? nextSid : null;
        const persistable = incomplete.filter(t => typeof t.id === "string" && !t.id.startsWith("tmp_"));
        await Promise.all(persistable.map(t =>
          api.tasks.patch(t.id, { sprint_id: targetSid }).catch(() => null)
        ));
      }

      const where = carryover === "next" ? "next sprint" : carryover === "backlog" ? "backlog" : "archive";
      showToast(`${sprintBeingCompleted?.label || "Sprint"} completed — ${incomplete.length} task${incomplete.length === 1 ? "" : "s"} moved to ${where}`);
    } catch (e) {
      // Rollback optimistic update
      setSprints(prevSprints);
      setTasks(prevTasks);
      setActiveSprintId(sprintId);
      showToast("Couldn't complete sprint: " + (e.message || "network error"), 4000);
    }
  }
  function showToast(msg, ms = 2400) {
    if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); toastTimerRef.current = null; }
    setToast(msg);
    toastTimerRef.current = setTimeout(() => { setToast(null); toastTimerRef.current = null; }, ms);
  }

  // Expose showToast globally so modules outside the FlowboardApp
  // closure (chat bubble, plug-ins, anything mounted via window) can
  // surface user-visible errors with the app's standard toast UI.
  React.useEffect(() => {
    window.fbToast = showToast;
    return () => { if (window.fbToast === showToast) window.fbToast = null; };
  }, []);

  function onActingAsChange(id) {
    setCurrentUserId(id);
    // If current project is not visible to new user, switch to a visible one
    const me = people.find(p => p.id === id);
    const canSeeAll = me && (me.wsRole === "owner" || me.wsRole === "admin");
    const cur = access[activeProjectId];
    const canSee = !cur || cur.visibility === "workspace" || canSeeAll || cur.members.some(m => m.id === id);
    if (!canSee) {
      const firstOk = PROJECTS.find(p => {
        const a = access[p.id];
        return !a || a.visibility === "workspace" || canSeeAll || a.members.some(m => m.id === id);
      });
      if (firstOk) setActiveProjectId(firstOk.id);
    }
    showToast(`Now acting as ${me?.name.split(" ")[0]} — ${WS_ROLES.find(r => r.id === me?.wsRole)?.label}`);
  }

  function onAvatarPeek(person, anchor) { setPeek({ person, anchor }); }

  // Workspace owners + admins can delete any project; everyone else needs
  // project-owner role on the current project. Mirrors backend rule in
  // routes/projects.routes.js DELETE /:id.
  const _me = (people || []).find(p => p.id === currentUserId);
  const _wsRole = (_me && _me.wsRole) || "member";
  const _projAccess = (access && access[activeProjectId]) || null;
  // Co-owners get the same permission as Owners. The server-side
  // middleware/role.js aliases co_owner → owner; this client-side
  // check needs the same alias so the Delete-project / Settings
  // buttons appear for co-owners too.
  const _isProjectOwner = _projAccess
    && _projAccess.members.some(m => m.id === currentUserId && (m.role === "owner" || m.role === "co_owner"));
  const canDeleteCurrentProject =
    _wsRole === "admin" || _wsRole === "owner" || !!_isProjectOwner;

  async function deleteProjectConfirmed(projectId) {
    if (!projectId) return;
    const proj = (PROJECTS || []).find(p => p.id === projectId);
    try {
      await api.projects.remove(projectId);
      // Remove from the global list, drop tasks belonging to it, and
      // re-route to the first remaining project so the user isn't stuck
      // on a now-deleted route.
      if (Array.isArray(window.PROJECTS)) {
        const idx = window.PROJECTS.findIndex(p => p.id === projectId);
        if (idx !== -1) window.PROJECTS.splice(idx, 1);
      }
      if (Array.isArray(window.ALL_TASKS)) {
        for (let i = window.ALL_TASKS.length - 1; i >= 0; i--) {
          if (window.ALL_TASKS[i].projectId === projectId) window.ALL_TASKS.splice(i, 1);
        }
      }
      setTasks(ts => ts.filter(t => t.projectId !== projectId));
      const next = (window.PROJECTS || []).find(p => p.id !== projectId);
      if (next) setActiveProjectId(next.id);
      setDeleteProjectId(null);
      showToast(`Deleted project "${proj?.name || projectId}"`);
    } catch (e) {
      const msg = (e && e.message) || "network error";
      showToast(`Couldn't delete project: ${msg}`, 4000);
    }
  }

  const activeProject = PROJECTS.find(p => p.id === activeProjectId) || PROJECTS[0];
  // Sprints are strictly scoped to a single project. A sprint with no
  // project_id is treated as orphaned and hidden from every project's
  // tab strip — `New sprint` always stamps the active project's id, so
  // any null-project sprint is legacy data that the user wouldn't want
  // bleeding into unrelated boards.
  const projectSprints = React.useMemo(() => {
    if (!activeProject) return [];
    return sprints.filter(s => s.project_id === activeProject.id);
  }, [sprints, activeProject]);
  // If the currently active sprint isn't in the new project's sprint list,
  // pick a sensible default (an active sprint in the project, then any sprint,
  // otherwise null so the tab quietly shows zero).
  React.useEffect(() => {
    if (!activeProject) return;
    if (projectSprints.find(s => s.id === activeSprintId)) return;
    const fallback = projectSprints.find(s => s.active)?.id
                  || projectSprints[0]?.id
                  || null;
    if (fallback !== activeSprintId) setActiveSprintId(fallback);
  }, [activeProject, projectSprints, activeSprintId]);
  const activeSprintIds = projectSprints.filter(s => s.active).map(s => s.id);
  // Tasks scoped to the active project (everything below filters from this set).
  const projectTasks = React.useMemo(() => {
    const list = !activeProject ? tasks : tasks.filter(t => (t.projectId || null) === activeProject.id);
    // Defensive dedup-by-id. The showSubtasks merge uses an
    // id-based filter that is normally enough — but this is a
    // last-line backstop in case a race ever slips through (e.g.
    // SSE echo + bootstrap arriving at the same moment, or a
    // doubly-fired useEffect during fast-refresh dev). Without it
    // a single stray duplicate id can render the same row over
    // and over inside the parent → child interleave.
    const seen = new Set();
    const out = [];
    for (const t of list) {
      if (!t || !t.id) continue;
      if (seen.has(t.id)) continue;
      seen.add(t.id);
      out.push(t);
    }
    return out;
  }, [tasks, activeProject]);
  const projectEpics = React.useMemo(() => {
    if (!activeProject) return EPICS;
    return EPICS.filter(e => (e.project_id || e.projectId || null) === activeProject.id);
  }, [activeProject, EPICS.length]);

  const filtered = React.useMemo(() => {
    // Quick lookup so subtasks can borrow their parent's grouping
    // for filter purposes — keeps a child slotted under the same
    // parent on whatever tab the parent appears on.
    const byId = new Map(projectTasks.map(t => [t.id, t]));
    function effectiveSprint(t) {
      if (t.parentTaskId) {
        const p = byId.get(t.parentTaskId);
        if (p) return p.sprint || null;
      }
      return t.sprint || null;
    }
    let list;
    if (tab === "sprint")        list = projectTasks.filter(t => effectiveSprint(t) === activeSprintId);
    else if (tab === "backlog")  list = projectTasks.filter(t => !effectiveSprint(t));
    else if (tab === "tasks")    list = projectTasks; // all tasks in this project
    else                         list = projectTasks;
    // Kanban never shows subtasks — they're not first-class cards.
    // Table tabs (sprint / backlog / tasks) always show them.
    if (tab === "kanban") {
      list = list.filter(t => !t.parentTaskId);
    }
    if (filters.mine && currentUserId) {
      list = list.filter(t => Array.isArray(t.owners) && t.owners.includes(currentUserId));
    }
    // Hide-done — session-aware filter persisted to localStorage.
    // When ON (the default), a task whose status is "done" hides
    // UNLESS one of these exceptions applies:
    //   1. The current user flipped it to done in THIS tab (it's
    //      in `completedThisSession`). Keeps the just-completed task
    //      visible (struck-through) so the user sees what they just
    //      finished before it disappears on the next reload.
    //   2. It's a subtask whose TOP ancestor isn't done yet (or whose
    //      top ancestor was just flipped done this session). The user
    //      wanted to keep done children visible under a still-active
    //      parent, and hide the whole subtree only once the top of
    //      the tree is also closed.
    // The ancestor walk caps at depth 6 — defensive against any
    // accidental cycle in parent_task_id chains.
    if (filters.hideDone) {
      const byId = new Map(projectTasks.map(t => [t.id, t]));
      function topAncestor(t) {
        let cur = t;
        let safety = 6;
        while (cur && cur.parentTaskId && safety-- > 0) {
          const parent = byId.get(cur.parentTaskId);
          if (!parent) break;
          cur = parent;
        }
        return cur;
      }
      list = list.filter(t => {
        if (t.status !== "done") return true;
        if (completedThisSession.has(t.id)) return true;
        if (t.parentTaskId) {
          const top = topAncestor(t);
          if (top && top.status !== "done") return true;
          if (top && completedThisSession.has(top.id)) return true;
        }
        return false;
      });
    }
    const q = filters.q.trim().toLowerCase();
    if (q) list = list.filter(t => t.name.toLowerCase().includes(q) || (t.epicTitle && t.epicTitle.toLowerCase().includes(q)));
    if (filters.people.length) list = list.filter(t => t.owners.some(o => filters.people.includes(o)));
    // Creator filter — same shape as people/owners but checks the
    // task's createdBy field (normalised from tasks.created_by by
    // data.jsx). Empty array == no creator filter.
    if (Array.isArray(filters.creators) && filters.creators.length) {
      list = list.filter(t => t.createdBy && filters.creators.includes(t.createdBy));
    }
    if (filters.statuses.length) list = list.filter(t => filters.statuses.includes(t.status));
    if (filters.priorities.length) list = list.filter(t => filters.priorities.includes(t.prio));
    // Type filter (task / bug / chore / spike) — added per BG_02E81DCE4B.
    // Empty array = no type filter. Default-type rows have `type` either
    // "task" or null/undefined; normalise to "task" so the filter doesn't
    // accidentally exclude legacy rows when the user picks "Task".
    if (Array.isArray(filters.types) && filters.types.length) {
      list = list.filter(t => filters.types.includes(t.type || "task"));
    }
    const sortDef = SORT_OPTIONS.find(s => s.id === filters.sort);
    if (sortDef && sortDef.fn) list = [...list].sort(sortDef.fn);
    return list;
  }, [projectTasks, tab, activeSprintId, sprints, filters, currentUserId, completedThisSession]);

  // Compute how many done top-level tasks the Hide-done filter is
  // currently suppressing, for the toolbar's "Show done · N hidden"
  // badge. Walks the same ancestor rules as the filter above so the
  // count and the UI agree perfectly.
  const hiddenDoneCount = React.useMemo(() => {
    if (!filters.hideDone) return 0;
    const byId = new Map(projectTasks.map(t => [t.id, t]));
    function topAncestor(t) {
      let cur = t;
      let safety = 6;
      while (cur && cur.parentTaskId && safety-- > 0) {
        const parent = byId.get(cur.parentTaskId);
        if (!parent) break;
        cur = parent;
      }
      return cur;
    }
    let n = 0;
    for (const t of projectTasks) {
      if (t.status !== "done") continue;
      if (completedThisSession.has(t.id)) continue;
      if (t.parentTaskId) {
        const top = topAncestor(t);
        if (top && top.status !== "done") continue;
        if (top && completedThisSession.has(top.id)) continue;
      }
      n++;
    }
    return n;
  }, [projectTasks, filters.hideDone, completedThisSession]);

  // Tab badges respect the hide-done toggle so the count never says "12"
  // while the table only shows 4 rows. When the toggle is off we count
  // every task in the bucket, including done.
  const _alive = (t) => !filters.hideDone || t.status !== "done";
  const sprintCount = projectTasks.filter(t => t.sprint === activeSprintId && _alive(t)).length;
  const backlogCount = projectTasks.filter(t => !t.sprint && _alive(t)).length;
  const tasksCount = projectTasks.filter(_alive).length;
  // The chosen sprint must come from the project's own sprint list, otherwise
  // (e.g. a brand-new project with no sprint yet) it falls back to "no sprint".
  const active = projectSprints.find(s => s.id === activeSprintId) || null;
  const activeSprintTasks = projectTasks.filter(t => t.sprint === activeSprintId);

  // Sidebar CRM badge — number of leads with reminders due today or overdue.
  const crmBadge = React.useMemo(() => {
    const list = (typeof getLeads === "function" ? getLeads() : []) || [];
    let today = 0, overdue = 0;
    for (const l of list) {
      if (typeof leadOverdue === "function" && leadOverdue(l)) overdue++;
      else if (typeof leadDueToday === "function" && leadDueToday(l)) today++;
    }
    return { today, overdue };
  }, [view, currentUserId, tasks]);

  const crumbs = view === "ownerDash"
    ? [WORKSPACE.name, "Owner dashboard"]
    : view === "reviews"
    ? [WORKSPACE.name, "Reviews"]
    : view === "admin"
    ? [WORKSPACE.name, "Admin", "People & permissions"]
    : view === "myWork"
      ? [WORKSPACE.name, "Home"]
      : view === "crm"
        ? [WORKSPACE.name, "CRM", "Leads"]
        : view === "bin"
        ? [WORKSPACE.name, "Bin"]
        : view === "notes"
        ? [WORKSPACE.name, "Personal"]
    : view === "apiTester"
        ? [WORKSPACE.name, "API Tester"]
    : view === "vault"
        ? [WORKSPACE.name, "Vault"]
    : view === "automations"
        ? [WORKSPACE.name, "Automations"]
    : view === "announcements"
        ? [WORKSPACE.name, "Announcements"]
        : view === "support"
        ? [WORKSPACE.name, "Support center"]
      : view === "bugs"
        ? [WORKSPACE.name, "Bug reports"]
      : view === "home"
        ? [WORKSPACE.name, "Home"]
        : ["Team Tabsyst", activeProject?.name || "Project",
            tab === "sprint" ? active?.label : tab === "backlog" ? "Backlog" : tab === "tasks" ? "Tasks" : "Kanban"];

  return (
    <div className="app" style={{ position: "relative", overflow: "hidden" }}>
      <Sidebar activeProject={view === "project" ? activeProjectId : null}
               onProjectChange={(id) => { setActiveProjectId(id); setView("project"); }}
               currentUserId={currentUserId}
               access={access}
               onOpenHome={() => setView("home")}
               homeActive={view === "home"}
               onOpenAdmin={() => setView("admin")}
               adminActive={view === "admin"}
               onOpenServerMonitor={(_me && _me.wsRole === "owner")
                                       ? () => setView("serverMonitor") : null}
               serverMonitorActive={view === "serverMonitor"}
               onOpenOwnerDash={(_me && (_me.wsRole === "admin" || _me.wsRole === "owner"))
                                  ? () => setView("ownerDash") : null}
               ownerDashActive={view === "ownerDash"}
               onOpenReviews={() => {
                 // The standalone Reviews tab is being phased out in
                 // favor of in-table sign-off (group-by Story + the
                 // Reviews badge in the toolbar). Click on the sidebar
                 // entry now switches to the active project's table
                 // and flips the grouping to Story. The "only my
                 // reviews" filter starts OFF — the user opts in via
                 // the Reviews badge in the toolbar. Auto-on was
                 // surprising because the filter then stayed on
                 // across project switches and reloads.
                 setFilters(prev => ({ ...prev, groupBy: "story", reviewMine: false }));
                 setView("project");
               }}
               reviewsActive={view === "project" && filters.groupBy === "story"}
               onOpenMyWork={() => setView("myWork")}
               myWorkActive={view === "myWork"}
               onOpenCRM={() => setView("crm")}
               crmActive={view === "crm"}
               crmBadge={crmBadge}
               onAddWorkspace={() => setNewWorkspaceOpen(true)}
               onOpenBin={() => setView("bin")}
               binActive={view === "bin"}
               onOpenNotes={() => setView("notes")}
               notesActive={view === "notes"}
               onOpenApiTester={() => setView("apiTester")}
               apiTesterActive={view === "apiTester"}
               onOpenVault={() => setView("vault")}
               vaultActive={view === "vault"}
               onOpenAutomations={() => setView("automations")}
               automationsActive={view === "automations"}
               onOpenAnnouncements={() => setView("announcements")}
               announcementsActive={view === "announcements"}
               onOpenSupport={(_me && (_me.wsRole === "admin" || _me.wsRole === "owner"
                                || (_me.moduleAccess && _me.moduleAccess.support === true)))
                                ? () => setView("support") : null}
               supportActive={view === "support"}
               supportTab={supportTab}
               onSupportTabChange={(id) => { setSupportTab(id); setView("support"); }}
               onOpenBugs={() => setView("bugs")}
               bugsActive={view === "bugs"}
               onOpenPeople={() => setView("people")}
               peopleActive={view === "people"}
               peopleTab={peopleTab}
               onPeopleTabChange={(id) => { setPeopleTab(id); setView("people"); }}
               peopleIsManager={(_me && (_me.wsRole === "owner" || _me.wsRole === "admin")) || false}
               onOpenWhatsNew={() => setWhatsNewOpen(true)}
               onAddProject={() => setNewProjectOpen(true)}/>
      {/* Scrim — only visible at <=760px when the body class is set.
          Tap absorbs and closes the off-canvas sidebar. Renders on
          desktop too but is invisible (CSS gates pointer-events on
          body.is-mobile-nav-open), so it's free. */}
      <div className="mobile-nav-scrim" onClick={() => setMobileNavOpen(false)}/>
      <div className="main">
        <Topbar crumbs={crumbs}
          onMobileNavToggle={() => setMobileNavOpen(o => !o)}
          searchValue={filters.q}
          onSearch={(q) => setFilters(f => ({ ...f, q }))}
          onOpenPalette={() => setPaletteOpen(true)}
          onBellClick={() => setNotifPanelOpen(v => !v)}
          bellCount={notifUnread}
          currentUserId={currentUserId}
          onActingAsChange={onActingAsChange}
          people={people}
          onAvatarPeek={onAvatarPeek}/>

        {view === "ownerDash" ? (
          (typeof OwnerDashboardView !== "undefined")
            ? <OwnerDashboardView/>
            : <div className="boot-splash"><div className="label">Owner dashboard module unavailable.</div></div>
        ) : view === "reviews" ? (
          (typeof ReviewsView !== "undefined")
            ? <ReviewsView
                currentUserId={currentUserId}
                people={people}
                onOpenStory={(sid) => setOpenStoryId(sid)}/>
            : <div className="boot-splash"><div className="label">Reviews module unavailable.</div></div>
        ) : view === "serverMonitor" ? (
          (typeof ServerMonitorView !== "undefined")
            ? <ServerMonitorView/>
            : <div className="boot-splash"><div className="label">Server monitor unavailable.</div></div>
        ) : view === "admin" ? (
          // People & permissions is owner-only. Guard the deep-link too
          // so an admin who types /admin (or lands here from a stale
          // hash) can't reach the role/deactivate/module-access UI.
          ((_me && _me.wsRole === "owner")
            ? <AdminPeoplePage
                people={people} setPeople={setPeople}
                access={access} setAccess={setAccess}
                onToast={showToast}
                onOpenProjectAccess={(person) => {
                  // Pick first project this person is a member of (to demo)
                  const entry = Object.entries(access).find(([, a]) => a.members.some(m => m.id === person.id));
                  setProjectAccessModal(entry ? entry[0] : PROJECTS[0].id);
                }}
                onOpenInvite={() => setInviteOpen(true)}/>
            : <div className="boot-splash">
                <div className="label" style={{ maxWidth: 440, textAlign: "center", lineHeight: 1.5 }}>
                  <b>People &amp; permissions is owner-only</b><br/>
                  Only the workspace owner can manage roles, deactivate
                  users, and set module access.
                </div>
              </div>)
        ) : view === "myWork" ? (
          <MyTasksView onOpen={openTask}
            currentUserId={currentUserId}
            tasksFromApp={tasks}
            onUpdate={updateTask}
            onDelete={deleteTask}
            query={mwQuery} setQuery={setMwQuery}
            projects={mwProjects} setProjects={setMwProjects}
            statuses={mwStatuses} setStatuses={setMwStatuses}/>
        ) : view === "crm" ? (
          (typeof CRMView !== "undefined")
            ? <CRMView currentUserId={currentUserId} people={people}/>
            : <div className="boot-splash"><div className="label">CRM module unavailable.</div></div>
        ) : view === "bin" ? (
          (typeof BinView !== "undefined")
            ? <BinView/>
            : <div className="boot-splash"><div className="label">Bin module unavailable.</div></div>
        ) : view === "notes" ? (
          (typeof NotesView !== "undefined")
            ? <NotesView/>
            : <div className="boot-splash"><div className="label">Personal notes module unavailable.</div></div>
        ) : view === "apiTester" ? (
          (typeof ApiTesterView !== "undefined")
            ? <ApiTesterView/>
            : <div className="boot-splash"><div className="label">API Tester module unavailable.</div></div>
        ) : view === "vault" ? (
          (typeof VaultView !== "undefined")
            ? <VaultView currentUserId={currentUserId} people={people} onToast={showToast}/>
            : <div className="boot-splash"><div className="label">Vault module unavailable.</div></div>
        ) : view === "automations" ? (
          (typeof AutomationsView !== "undefined")
            ? <AutomationsView/>
            : <div className="boot-splash"><div className="label">Automations module unavailable.</div></div>
        ) : view === "announcements" ? (
          (typeof AnnouncementsAdminView !== "undefined")
            ? <AnnouncementsAdminView/>
            : <div className="boot-splash"><div className="label">Announcements module unavailable.</div></div>
        ) : view === "support" ? (
          (typeof SupportView !== "undefined")
            ? <SupportView currentUserId={currentUserId}
                           activeTab={supportTab}
                           onTabChange={setSupportTab}/>
            : <div className="boot-splash"><div className="label">Support module unavailable.</div></div>
        ) : view === "bugs" ? (
          (typeof BugsView !== "undefined")
            ? <BugsView currentUserId={currentUserId} me={_me}/>
            : <div className="boot-splash"><div className="label">Bug reports module unavailable.</div></div>
        ) : view === "people" ? (
          // Role gate — owners/admins land on the manager shell;
          // members get the employee "My time" shell. Guests
          // (external collaborators) are blocked entirely — the
          // backend also returns 403 for every /api/people/* call
          // when ws_role = 'guest'. Per-user module access (set by
          // admins via Admin → Module access) can also turn People
          // OFF for a member — keep that gate symmetrical so deep
          // links don't bypass the sidebar gate.
          // The tab is controlled by the main app sidebar (deep
          // links to /people/<tab>) — hideOwnSidebar suppresses the
          // duplicate in-module sidebar so we only have one nav.
          (() => {
            const role = (_me && _me.wsRole) || "member";
            if (role === "guest") {
              return (
                <div className="boot-splash">
                  <div className="label" style={{ maxWidth: 460, textAlign: "center", lineHeight: 1.5 }}>
                    <b>People module is for workspace employees</b><br/>
                    External collaborators don't have access to the team
                    roster, attendance, or leave data. Ask your workspace
                    owner to upgrade you to a member role if you need this.
                  </div>
                </div>
              );
            }
            // Per-user module-access flag. Owners always have it.
            const ma = _me && _me.moduleAccess;
            if (role !== "owner" && ma && ma.people === false) {
              return (
                <div className="boot-splash">
                  <div className="label" style={{ maxWidth: 460, textAlign: "center", lineHeight: 1.5 }}>
                    <b>People module is turned off for your account</b><br/>
                    An admin can re-enable it from Admin → People & permissions →
                    your row → Module access.
                  </div>
                </div>
              );
            }
            const isManager = role === "owner" || role === "admin";
            const activeTab = peopleTab
              || (isManager ? "overview" : "day");
            if (isManager && typeof PpManagerApp !== "undefined") {
              return <PpManagerApp
                       activeTab={activeTab}
                       onTabChange={setPeopleTab}
                       hideOwnSidebar/>;
            }
            if (typeof PpEmployeeApp !== "undefined") {
              return <PpEmployeeApp
                       activeTab={activeTab}
                       onTabChange={setPeopleTab}
                       hideOwnSidebar/>;
            }
            return <div className="boot-splash"><div className="label">People module unavailable.</div></div>;
          })()
        ) : view === "home" ? (
          (typeof HomeView !== "undefined")
            ? <HomeView
                currentUserId={currentUserId}
                onNavigate={(target, opts = {}) => {
                  if (target === "project") {
                    if (opts.projectId) setActiveProjectId(opts.projectId);
                    setView("project");
                  } else if (target === "myWork" || target === "support" || target === "bugs" || target === "crm" || target === "admin") {
                    setView(target);
                  }
                }}
                onOpenTask={(t) => {
                  // Jump to the task's project + open the drawer.
                  if (t.projectId && t.projectId !== activeProjectId) setActiveProjectId(t.projectId);
                  setView("project");
                  openTask(t);
                }}
                onOpenBugReport={() => setBugReportOpen(true)}
              />
            : <div className="boot-splash"><div className="label">Home module unavailable.</div></div>
        ) : (
          <>
            <ProjectHeader activeTab={tab} onTab={setTab}
                           projectId={activeProjectId}
                           projectTitle={activeProject?.name}
                           projectColor={activeProject?.color}
                           activeSprintId={activeSprintId} onSprintChange={setActiveSprintId}
                           sprints={projectSprints}
                           onStartSprint={() => setStartOpen(true)}
                           onPlanSprint={() => setPlanOpen(true)}
                           onActivateSprint={activateSprint}
                           sprintCount={sprintCount} backlogCount={backlogCount}
                           tasksCount={tasksCount} kanbanCount={projectTasks.length}
                           onManageAccess={() => setProjectAccessModal(activeProjectId)}
                           memberIds={(access[activeProjectId]?.members || []).map(m => m.id)}
                           canDeleteProject={canDeleteCurrentProject}
                           canEditProject={canDeleteCurrentProject}
                           onOpenSettings={() => setProjectSettingsOpen(true)}
                           onDeleteProject={() => setDeleteProjectId(activeProjectId)}/>
            {/* Toolbar renders on every tab — including kanban. The
                kanban's column lists are computed from `filtered`, so
                every filter (search / person / status / priority /
                Hide done / My tasks) flows through to the cards. The
                Hide-columns and Sort controls don't visually affect
                the kanban (its columns are status-fixed and ordering
                is drag-driven), but they stay harmless and consistent. */}
            <BoardToolbar sprintMode={tab === "sprint"} activeSprint={active}
                          backlogMode={tab === "backlog" || tab === "tasks"}
                          kanbanMode={tab === "kanban"}
                          onStartSprint={() => setStartOpen(true)}
                          onPlanSprint={() => setPlanOpen(true)}
                          onCompleteSprint={() => setCompleteOpen(true)}
                          onNewTask={() => setNewTaskOpen(true)}
                          onNewEpic={() => setNewEpicOpen(true)}
                          onNewStory={() => setNewStoryOpen(true)}
                          filters={filters} setFilters={setFilters}
                          hiddenDoneCount={hiddenDoneCount}
                          currentUserId={currentUserId}
                          projectAccess={access}
                          activeProjectId={activeProjectId}/>
            {tab === "kanban"
              ? <KanbanView tasks={filtered} onOpen={openTask} onMove={moveTask}/>
              : tab === "calendar"
              ? <CalendarView tasks={filtered}
                              onOpen={openTask}
                              onUpdate={updateTask}
                              onAddTask={addTask}
                              currentUserId={currentUserId}
                              projectAccess={access}
                              activeProjectId={activeProjectId}/>
              : <TableView tasks={filtered} epics={projectEpics}
                           groupBy={filters.groupBy}
                           reviewMine={filters.reviewMine}
                           onOpenStory={(id) => setOpenStoryId(id)}
                           onToast={showToast}
                           collapsed={collapsed} onToggle={toggleEpic}
                           onUpdate={updateTask} onMove={moveTask} onReparent={reparentTask} onOpen={openTask}
                           onDelete={deleteTask}
                           onUpdateEpic={updateEpic}
                           onDeleteEpic={deleteEpic}
                           selected={selected} onSelect={onSelect}
                           onAddTask={addTask}
                           onAddSubtask={addSubtask}
                           defaultSprint={tab === "sprint" ? activeSprintId : null}
                           hideSprintCol={tab === "sprint"}
                           sprints={projectSprints}
                           hidden={filters.hidden}
                           projectAccess={access}
                           currentUserId={currentUserId}/>}
          </>
        )}
      </div>

      <TaskDrawer task={drawerTask} open={drawerOpen} onClose={() => setDrawerOpen(false)} onUpdate={updateTask} sprints={sprints}
                  epics={projectEpics}
                  projectAccess={access}
                  currentUserId={currentUserId} onToast={showToast}
                  onDelete={(id) => { deleteTask(id); setDrawerOpen(false); }}
                  onOpenFullView={(t) => { setFullPageTaskId(t.id); setDrawerOpen(false); }}
                  onOpenStory={(id) => { setDrawerOpen(false); setOpenStoryId(id); }}
                  onOpenTask={(t) => { setDrawerTask(t); }}/>

      {typeof BulkActionBar !== "undefined" && (
        <BulkActionBar
          selected={selected}
          allTasks={tasks}
          /* People dropdown is scoped to the active project's members
             — bulk operations only run on tasks within the active
             project, so showing every workspace user is just noise. */
          people={(() => {
            const ids = new Set(((access[activeProjectId] && access[activeProjectId].members) || []).map(m => m.id));
            return ids.size ? people.filter(p => ids.has(p.id)) : people;
          })()}
          epics={typeof EPICS !== "undefined" ? EPICS : []}
          sprints={projectSprints}
          onUpdate={updateTask}
          onDelete={deleteTask}
          onClear={() => setSelected(new Set())}/>
      )}

      {fullPageTaskId && (
        <div style={{ position: "absolute", inset: 0, zIndex: 100, background: "var(--bg-app)" }}>
          <TaskDetailPage
            task={tasks.find(t => t.id === fullPageTaskId)}
            onBack={() => setFullPageTaskId(null)}
            onUpdate={updateTask}/>
        </div>
      )}

      {typeof NotificationsPanel !== "undefined" && (
        <NotificationsPanel open={notifPanelOpen}
          onClose={() => setNotifPanelOpen(false)}
          onOpenFull={() => setNotifPanelOpen(false)}/>
      )}

      {/* Floating "Report bug" button — visible on every internal page,
          hidden on the bugs page itself (avoids the duplicate-action
          confusion of having the button overlay its own list). */}
      {typeof BugFloatingButton !== "undefined" && view !== "bugs" && (
        <BugFloatingButton onOpen={() => setBugReportOpen(true)}/>
      )}

      {/* 1:1 personal chat side-bubble — visible on every internal page.
          Self-contained: handles its own state, polling, and SSE updates.
          Hidden for guest (external) workspace members: they shouldn't
          be able to DM internal staff or see the workspace roster. */}
      {typeof ChatBubbleApp !== "undefined"
        && (!(_me && _me.wsRole === "guest")) && (
        <ChatBubbleApp/>
      )}
      {bugReportOpen && typeof BugReportModal !== "undefined" && (
        <BugReportModal
          open={bugReportOpen}
          onClose={() => setBugReportOpen(false)}
          onCreated={(bid) => {
            setBugReportOpen(false);
            showToast(`🐞 Reported · admins notified · ${bid}`);
          }}/>
      )}

      {whatsNewOpen && typeof WhatsNewModal !== "undefined" && (
        <WhatsNewModal onClose={() => setWhatsNewOpen(false)}/>
      )}

      {deleteProjectId && (
        <DeleteProjectModal
          project={(PROJECTS || []).find(p => p.id === deleteProjectId)}
          taskCount={(window.ALL_TASKS || []).filter(t => t.projectId === deleteProjectId).length}
          onClose={() => setDeleteProjectId(null)}
          onConfirm={() => deleteProjectConfirmed(deleteProjectId)}/>
      )}

      {/* Per-project settings (gear icon in the project header). */}
      {typeof ProjectSettingsModal !== "undefined" && (
        <ProjectSettingsModal
          open={projectSettingsOpen && !!activeProject}
          project={activeProject}
          onClose={() => setProjectSettingsOpen(false)}
          onSaved={() => {
            // PROJECTS was mutated in place by the modal's optimistic
            // update — bump the tick so dependent renders re-read it.
            _bumpProjectsTick(n => n + 1);
          }}/>
      )}

      {/* Delay-reason prompt — opens whenever an overdue task is being
          marked done. Reuses the same CompleteTaskModal as the drawer
          flow, so behaviour and visuals stay consistent. */}
      {lateCompleteTask && typeof CompleteTaskModal !== "undefined" && (
        <CompleteTaskModal
          open={true}
          task={lateCompleteTask}
          todayLabel={typeof todayMonDay === "function" ? todayMonDay() : undefined}
          onClose={() => setLateCompleteTask(null)}
          onConfirm={(patch) => {
            const id = lateCompleteTask.id;
            setLateCompleteTask(null);
            // The patch already includes status/outcome/completedAt/delay*,
            // so the intercept above won't fire again on this pass.
            updateTask(id, patch);
          }}/>
      )}

      {/* Due-date change reason modal — opens whenever an existing
          due date is being changed to a different non-null date.
          The intercept in updateTask() bounces the patch here; on
          confirm we re-call updateTask with the date plus the
          collected reason/note metadata, which sails through the
          intercept's resubmit guard. Cancel just clears state
          without writing — the date stays at its original value. */}
      {pendingDueChange && typeof DueChangeModal !== "undefined" && (
        <DueChangeModal
          open={true}
          task={pendingDueChange.task}
          oldDue={pendingDueChange.oldDue}
          newDue={pendingDueChange.newDue}
          onClose={() => setPendingDueChange(null)}
          onConfirm={(patch) => {
            const id = pendingDueChange.task.id;
            setPendingDueChange(null);
            updateTask(id, patch);
          }}/>
      )}

      <CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)}
        onNavigate={(item) => {
          if (item.kind === "task") { const t = ALL_TASKS.find(x => x.id === item.id) || item.task; if (t) openTask(t); }
          else if (item.kind === "epic") { setTab("sprint"); setFilters(f => ({ ...f, q: item.title })); }
          else if (item.kind === "person") { setFilters(f => ({ ...f, people: [item.id] })); }
          else if (item.kind === "project") { setActiveProjectId(item.id); setView("project"); setTab("sprint"); }
          else if (item.kind === "sprint") { setActiveSprintId(item.id); setTab("sprint"); }
          else if (item.kind === "action") {
            if (item.id === "new-sprint") setStartOpen(true);
            else if (item.id === "new-task") setNewTaskOpen(true);
            else if (item.id === "go-sprint") setTab("sprint");
            else if (item.id === "go-backlog") setTab("backlog");
            else if (item.id === "go-kanban") setTab("kanban");
            else if (item.id === "go-mywork") { setView("project"); setTab("home"); }
            else if (item.id === "invite") setInviteOpen(true);
          }
        }}/>

      <StartSprintModal open={startOpen} onClose={() => setStartOpen(false)}
        tasks={projectTasks.filter(t => !t.sprint)}
        onStart={startSprint}
        suggestedLabel={`Sprint ${26 + sprints.filter(s => s.completed).length}`}/>
      {typeof PlanSprintModal !== "undefined" && (
        <PlanSprintModal open={planOpen} onClose={() => setPlanOpen(false)}
          tasks={projectTasks.filter(t => !t.sprint)}
          onPlan={planSprint}
          suggestedLabel={`Sprint ${27 + sprints.filter(s => s.completed).length + projectSprints.filter(s => !s.active && !s.completed).length}`}/>
      )}
      <CompleteSprintModal open={completeOpen} onClose={() => setCompleteOpen(false)}
        sprint={active} tasks={activeSprintTasks} onComplete={completeSprint}/>

      <NewTaskModal open={newTaskOpen} onClose={() => setNewTaskOpen(false)}
        activeSprintId={activeSprintId}
        sprints={projectSprints}
        epics={projectEpics}
        projectAccess={access}
        activeProjectId={activeProjectId}
        defaults={{ sprintId: tab === "sprint" ? activeSprintId : (tab === "tasks" ? "" : null) }}
        onCreate={(payload) => {
          addTask({ ...payload, sprint: payload.sprint });
        }}
        onCreateEpic={(payload) => addEpic({ ...payload, project_id: activeProjectId })}/>

      {typeof NewEpicModal !== "undefined" && (
        <NewEpicModal open={newEpicOpen} onClose={() => setNewEpicOpen(false)}
          projects={typeof PROJECTS !== "undefined" ? PROJECTS : []}
          defaultProjectId={activeProjectId}
          defaultOwnerId={currentUserId}
          people={people}
          onCreate={addEpic}/>
      )}

      {typeof NewProjectModal !== "undefined" && (
        <NewProjectModal open={newProjectOpen}
          onClose={() => setNewProjectOpen(false)}
          defaultWorkspaceId={activeProject ? (activeProject.workspaceId || 1) : null}
          onCreate={addProject}/>
      )}

      {typeof NewWorkspaceModal !== "undefined" && (
        <NewWorkspaceModal open={newWorkspaceOpen}
          onClose={() => setNewWorkspaceOpen(false)}
          onCreated={(ws) => {
            // Push into the in-memory globals so the sidebar lights
            // up before the next bootstrap reload picks it up.
            try {
              if (Array.isArray(window.WORKSPACES)) {
                window.WORKSPACES.push({
                  id: Number(ws.id),
                  name: ws.name,
                  plan: ws.plan || "",
                  seatsUsed: Number(ws.seats_used || 0),
                  seatsTotal: Number(ws.seats_total || 0),
                  billingCycle: ws.billing_cycle || "monthly",
                  nextBill: ws.next_bill || "—",
                });
              }
            } catch {}
            showToast(`Workspace “${ws.name}” created`);
          }}/>
      )}

      <InviteModal open={inviteOpen} onClose={() => setInviteOpen(false)}
        onInvite={async ({ name: typedName, emails, wsRole, projects, msg }) => {
          const colors = ["#5559df","#0086c0","#bb3354","#fdab3d","#00c875"];
          const created = [];
          let failed = 0;
          // When the admin typed a display name AND there's a single
          // email, use it verbatim. Multi-email invites always derive
          // each name from the email so we don't apply the same name
          // to several different people.
          const useTyped = !!(typedName && typedName.trim() && emails.length === 1);
          for (let i = 0; i < emails.length; i++) {
            const em = emails[i];
            const derivedName = em.split("@")[0].replace(/[\._]/g, " ").replace(/\b\w/g, c => c.toUpperCase());
            const name = useTyped ? typedName.trim() : derivedName;
            const color = colors[i % colors.length];
            try {
              if (window.api) {
                const r = await api.users.create({ name, email: em, ws_role: wsRole, color });
                created.push({
                  id: r.id, name, email: em, color,
                  title: "—", team: "—",
                  wsRole, status: "invited", joined: "—", lastActive: "—",
                });
              } else {
                created.push({
                  id: "inv" + Date.now() + i, name, email: em, color,
                  title: "—", team: "—",
                  wsRole, status: "invited", joined: "—", lastActive: "—",
                });
              }
            } catch (e) {
              failed++;
              console.error("Invite failed for", em, e);
            }
          }

          if (created.length) {
            setPeople(ps => [...ps, ...created]);
            setAccess(prev => {
              const out = { ...prev };
              projects.forEach(pid => {
                if (!out[pid]) return;
                out[pid] = { ...out[pid], members: [...out[pid].members, ...created.map(p => ({ id: p.id, role: "editor" }))] };
              });
              return out;
            });
          }

          setInviteOpen(false);

          if (created.length && !failed) {
            showToast(`Invite${created.length > 1 ? "s" : ""} sent to ${created.length} ${created.length > 1 ? "people" : "person"}`);
          } else if (created.length && failed) {
            showToast(`Invited ${created.length}; ${failed} failed`, 4000);
          } else {
            showToast(`Couldn't send invites — try again`, 4000);
          }
        }}/>

      <ProjectAccessModal open={!!projectAccessModal} onClose={() => setProjectAccessModal(null)}
        projectId={projectAccessModal} access={access} setAccess={setAccess} people={people}
        onToast={showToast}/>

      {peek && <UserPeek person={peek.person} anchor={peek.anchor} access={access}
                          onClose={() => setPeek(null)}
                          onOpenAdmin={() => { setPeek(null); setView("admin"); }}/>}

      {/* Story drawer — opens when a user clicks the story chip on
          any task. Persisted in URL via simple state for now. */}
      {openStoryId && typeof StoryDrawer !== "undefined" && (
        <StoryDrawer
          storyId={openStoryId}
          currentUserId={currentUserId}
          people={people}
          onClose={() => setOpenStoryId(null)}
          onTaskOpen={(taskId) => {
            const t = (window.ALL_TASKS || []).find(x => x.id === taskId);
            if (t) {
              setOpenStoryId(null);
              openTask(t);
            }
          }}/>
      )}

      {/* New-story modal — surfaced from the project header (see
          ProjectHeader's story button) and from any future "+ Story"
          entry point. */}
      {newStoryOpen && typeof NewStoryModal !== "undefined" && (
        <NewStoryModal
          open={true}
          defaultProjectId={activeProjectId}
          people={people}
          onClose={() => setNewStoryOpen(false)}
          onCreated={(s) => {
            setNewStoryOpen(false);
            showToast(`Story created: "${s.title}"`);
            setOpenStoryId(s.id);
          }}/>
      )}

      {/* Toast — handles either a plain string OR an object payload
          { msg, action: { label, onClick }, ms }. The object form is
          used by the 5-sec undo flow. Rendering the object directly as
          a child throws "Objects are not valid as a React child", so
          we always normalise here. The Undo chip's onClick is wrapped
          in try/catch so a thrown undo handler can't crash the toast. */}
      {toast && (() => {
        const isObj = toast && typeof toast === "object" && !Array.isArray(toast);
        const text = isObj ? toast.msg : toast;
        const action = isObj ? toast.action : null;
        const ms = isObj ? toast.ms : null;
        return (
          <div className="fb-toast">
            <span>{text}</span>
            {action && action.label && (
              <button type="button" className="fb-toast-action"
                      onClick={() => {
                        try { action.onClick && action.onClick(); }
                        catch (e) { console.warn("[toast-action] threw:", e); }
                      }}>
                {action.label}
              </button>
            )}
            {action && ms ? (
              <span className="fb-toast-progress" style={{ animationDuration: ms + "ms" }}/>
            ) : null}
          </div>
        );
      })()}
    </div>
  );
}

function MyTasksApp() {
  const [drawerTask, setDrawerTask] = React.useState(null);
  const [drawerOpen, setDrawerOpen] = React.useState(false);
  const [query, setQuery] = React.useState("");
  const [paletteOpen, setPaletteOpen] = React.useState(false);
  React.useEffect(() => {
    const onOpen = () => setPaletteOpen(true);
    window.addEventListener("palette:open", onOpen);
    return () => window.removeEventListener("palette:open", onOpen);
  }, []);
  const [mtProjects, setMtProjects] = React.useState([]);
  const [mtStatuses, setMtStatuses] = React.useState([]);
  const openTask = (t) => {
    const full = ALL_TASKS.find(x => x.name === t.name) || {
      ...t, epicId: EPICS[0].id, owners: ["ay","mj"], subtasks: 3,
      epicTitle: t.epic, epicColor: t.epicColor,
    };
    setDrawerTask(full); setDrawerOpen(true);
  };
  return (
    <div className="app" style={{ position: "relative", overflow: "hidden" }}>
      <Sidebar activeProject={null} currentUserId="ay" access={PROJECT_ACCESS}/>
      <div className="main">
        <Topbar crumbs={["My Work"]} searchValue={query} onSearch={setQuery} onOpenPalette={() => setPaletteOpen(true)} currentUserId="ay" people={PEOPLE}/>
        <MyTasksView onOpen={openTask}
          query={query} setQuery={setQuery}
          projects={mtProjects} setProjects={setMtProjects}
          statuses={mtStatuses} setStatuses={setMtStatuses}/>
      </div>
      <TaskDrawer task={drawerTask} open={drawerOpen} onClose={() => setDrawerOpen(false)} projectAccess={access}/>
      <CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)}
        onNavigate={(item) => {
          if (item.kind === "task") { const full = ALL_TASKS.find(x => x.id === item.id) || item.task; if (full) openTask(full); }
          else if (item.kind === "person") { setQuery(item.title); }
        }}/>
    </div>
  );
}

Object.assign(window, { FlowboardApp, MyTasksApp });
