// table.jsx — Project table view with Epic groups, sprint/backlog tabs, inline edits

function ProjectHeader({ activeTab, onTab, projectId, projectTitle, projectColor, activeSprintId, onSprintChange, sprints, onStartSprint, onPlanSprint, onActivateSprint, sprintCount, backlogCount, tasksCount, kanbanCount, onManageAccess, memberIds, canDeleteProject, onDeleteProject, canEditProject, onOpenSettings }) {
  const [sprintMenu, setSprintMenu] = React.useState(null);
  const allSprints = sprints || SPRINTS;
  const active = allSprints.find(s => s.id === activeSprintId);
  const activeSprints = allSprints.filter(s => s.active);
  const upcomingSprints = allSprints.filter(s => !s.active && !s.completed);
  const pastSprints = allSprints.filter(s => !!s.completed);
  const hasAnySprints = allSprints.length > 0;
  const sprintLabel = active?.label || (hasAnySprints ? "Sprint" : "No active sprint");

  // ── Per-user favorite star ──────────────────────────────────────
  // Reads window.MY_FAVORITES (set by data.jsx). The state is in
  // `isFav` so an SSE / cross-tab update via flowboard:favorites:changed
  // re-renders only this header instead of the whole app.
  const [isFav, setIsFav] = React.useState(() =>
    (typeof window !== "undefined" && Array.isArray(window.MY_FAVORITES))
      ? window.MY_FAVORITES.includes(projectId) : false
  );
  React.useEffect(() => {
    function sync() {
      const ids = (typeof window !== "undefined" && Array.isArray(window.MY_FAVORITES))
        ? window.MY_FAVORITES : [];
      setIsFav(!!projectId && ids.includes(projectId));
    }
    sync();
    window.addEventListener("flowboard:favorites:changed", sync);
    return () => window.removeEventListener("flowboard:favorites:changed", sync);
  }, [projectId]);
  function toggleFav(e) {
    e.preventDefault();
    e.stopPropagation();
    if (!projectId) return;
    if (typeof window.flowboardToggleFavorite === "function") {
      window.flowboardToggleFavorite(projectId);
    }
  }
  return (
    <div className="project-header">
      <div className="project-title-row">
        <div className="project-title">{projectTitle || "Project"}</div>
        <button type="button"
                className={"project-fav-btn" + (isFav ? " is-on" : "")}
                onClick={toggleFav}
                aria-pressed={isFav}
                title={isFav ? "Unstar this project" : "Star this project — it'll show in your sidebar Favorites"}
                style={{
                  background: "transparent", border: 0, cursor: projectId ? "pointer" : "default",
                  padding: 4, marginLeft: 6,
                  color: isFav ? "#f59e0b" : "var(--ink-muted)",
                  display: "inline-flex", alignItems: "center",
                }}>
          {/* Filled star when favorited, outline otherwise. We swap
              the SVG fill so the same Icons.Star renders both states
              without an extra icon dependency. */}
          {isFav ? (
            <svg width="18" height="18" viewBox="0 0 24 24" fill="#f59e0b" stroke="#f59e0b" strokeWidth="1.6" strokeLinejoin="round" aria-hidden="true">
              <polygon points="12,2 15.09,8.26 22,9.27 17,14.14 18.18,21.02 12,17.77 5.82,21.02 7,14.14 2,9.27 8.91,8.26"/>
            </svg>
          ) : (
            <Icons.Star size={18}/>
          )}
        </button>
        <div className="project-people">
          {/* Real project members — was hardcoded to a fake set of six
              ids that showed the same faces on every project. Now
              driven by the project's actual member list, with the
              project's owner kept first when present. */}
          {Array.isArray(memberIds) && memberIds.length > 0 && (
            <AvatarStack ids={memberIds} max={5}/>
          )}
          <button className="btn-invite" onClick={onManageAccess}
                  title="Add a teammate to this project">
            <Icons.Plus size={14}/>
            {Array.isArray(memberIds) && memberIds.length > 0 ? "Add" : "Add member"}
          </button>
          {(canEditProject || canDeleteProject) && (
            <button className="btn-project-delete"
                    onClick={onOpenSettings}
                    title="Project settings"
                    style={{ background: "transparent", color: "var(--ink-muted)" }}>
              {/* Inline gear icon — Icons doesn't ship one */}
              <svg width="14" height="14" viewBox="0 0 24 24" fill="none"
                   stroke="currentColor" strokeWidth="2"
                   strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                <circle cx="12" cy="12" r="3"/>
                <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9c.34.31.85.49 1.51.51H21a2 2 0 0 1 0 4h-.09c-.66.02-1.17.2-1.51.51z"/>
              </svg>
            </button>
          )}
          {canDeleteProject && (
            <button className="btn-project-delete"
                    onClick={onDeleteProject}
                    title="Delete project (admin only)">
              <Icons.Trash size={14}/>
            </button>
          )}
        </div>
      </div>
      <div className="project-tabs">
        <button className={`project-tab ${activeTab === "sprint" ? "is-active" : ""}`}
                onClick={(e) => activeTab === "sprint" ? setSprintMenu(e.currentTarget) : onTab("sprint")}>
          <Icons.Lightning/> {sprintLabel}
          <span className="tab-badge">{sprintCount}</span>
          <Icons.ChevronDn size={12} style={{ marginLeft: 2, opacity: .6 }}/>
        </button>
        {sprintMenu && (
          <Popover anchor={sprintMenu} onClose={() => setSprintMenu(null)}>
            <div style={{ minWidth: 280 }}>
              {activeSprints.length > 0 && (
                <>
                  <div style={{ fontSize: 11, color: "var(--ink-muted)", padding: "6px 10px 4px", fontWeight: 600, letterSpacing: ".06em", textTransform: "uppercase" }}>Active</div>
                  {activeSprints.map(s => (
                    <div key={s.id} className="popover-item" onClick={() => { onSprintChange(s.id); setSprintMenu(null); }}>
                      <Icons.Lightning size={12} style={{ color: "var(--brand)" }}/>
                      <div style={{ flex: 1 }}>
                        <div style={{ fontWeight: 600 }}>{s.label}</div>
                        <div style={{ fontSize: 11, color: "var(--ink-muted)" }}>{s.team || "Team"} · {s.dates || "—"}</div>
                      </div>
                      {s.id === activeSprintId && <Icons.Check size={13} style={{ color: "var(--brand)" }}/>}
                    </div>
                  ))}
                </>
              )}

              {upcomingSprints.length > 0 && (
                <>
                  {activeSprints.length > 0 && <div style={{ height: 1, background: "var(--border)", margin: "4px 0" }}/>}
                  <div style={{ fontSize: 11, color: "var(--ink-muted)", padding: "6px 10px 4px", fontWeight: 600, letterSpacing: ".06em", textTransform: "uppercase" }}>Upcoming</div>
                  {upcomingSprints.map(s => (
                    <div key={s.id} className="popover-item" style={{ alignItems: "center" }}
                         onClick={() => { onSprintChange(s.id); setSprintMenu(null); }}>
                      <Icons.Calendar size={12} style={{ color: "var(--ink-muted)" }}/>
                      <div style={{ flex: 1 }}>
                        <div style={{ fontWeight: 600 }}>{s.label}</div>
                        <div style={{ fontSize: 11, color: "var(--ink-muted)" }}>
                          {s.team || "Team"} · {s.dates || "Not scheduled"}
                        </div>
                      </div>
                      {onActivateSprint && (
                        <button
                          className="btn btn-primary"
                          style={{ padding: "3px 8px", fontSize: 11, fontWeight: 600 }}
                          onClick={(e) => { e.stopPropagation(); setSprintMenu(null); onActivateSprint(s.id); }}>
                          Activate
                        </button>
                      )}
                      {s.id === activeSprintId && <Icons.Check size={13} style={{ color: "var(--brand)" }}/>}
                    </div>
                  ))}
                </>
              )}

              {pastSprints.length > 0 && (
                <>
                  <div style={{ height: 1, background: "var(--border)", margin: "4px 0" }}/>
                  <div style={{ fontSize: 11, color: "var(--ink-muted)", padding: "6px 10px 4px", fontWeight: 600, letterSpacing: ".06em", textTransform: "uppercase" }}>Past</div>
                  {pastSprints.map(s => (
                    <div key={s.id} className="popover-item"
                         onClick={() => { onSprintChange(s.id); setSprintMenu(null); }}>
                      <Icons.Check size={12} style={{ color: "var(--ink-faint)" }}/>
                      <div style={{ flex: 1 }}>
                        <div style={{ fontWeight: 600, color: "var(--ink-muted)" }}>{s.label}</div>
                        <div style={{ fontSize: 11, color: "var(--ink-muted)" }}>{s.dates || "—"}</div>
                      </div>
                      {s.id === activeSprintId && <Icons.Check size={13} style={{ color: "var(--brand)" }}/>}
                    </div>
                  ))}
                </>
              )}

              {!hasAnySprints && (
                <div style={{ padding: "10px 12px", fontSize: 12, color: "var(--ink-muted)", maxWidth: 260 }}>
                  This project doesn't have a sprint yet. Start one now or plan one for later.
                </div>
              )}

              <div style={{ height: 1, background: "var(--border)", margin: "4px 0" }}/>
              <div className="popover-item" style={{ color: "var(--brand)", fontWeight: 600 }}
                   onClick={() => { setSprintMenu(null); onStartSprint && onStartSprint(); }}>
                <Icons.Lightning size={12}/> Start new sprint
              </div>
              {onPlanSprint && (
                <div className="popover-item" style={{ color: "var(--ink-body)", fontWeight: 600 }}
                     onClick={() => { setSprintMenu(null); onPlanSprint(); }}>
                  <Icons.Calendar size={12}/> Plan upcoming sprint
                </div>
              )}
            </div>
          </Popover>
        )}
        <button className={`project-tab ${activeTab === "backlog" ? "is-active" : ""}`} onClick={() => onTab("backlog")}>
          <Icons.Inbox/> Backlog
          <span className="tab-badge">{backlogCount}</span>
        </button>
        <button className={`project-tab ${activeTab === "tasks" ? "is-active" : ""}`} onClick={() => onTab("tasks")}>
          <Icons.Table/> Tasks
          <span className="tab-badge">{tasksCount}</span>
        </button>
        <button className={`project-tab ${activeTab === "kanban" ? "is-active" : ""}`} onClick={() => onTab("kanban")}>
          <Icons.Board/> Kanban
          <span className="tab-badge">{kanbanCount}</span>
        </button>
        <button className={`project-tab ${activeTab === "calendar" ? "is-active" : ""}`} onClick={() => onTab("calendar")}>
          <Icons.Calendar/> Calendar
        </button>
        <button className="project-tab">
          <Icons.Timeline/> Timeline
        </button>
        <button className="project-tab" style={{ marginLeft: "auto", color: "var(--ink-muted)" }}>
          <Icons.Plus/> Add view
        </button>
      </div>
    </div>
  );
}

// Columns that can be hidden via the Hide menu. "name" is always shown.
const HIDEABLE_COLS = [
  { id: "owners",  label: "Owner" },
  { id: "status",  label: "Status" },
  { id: "prio",    label: "Priority" },
  { id: "due",     label: "Due date" },
  { id: "sprint",  label: "Sprint" },
  { id: "points",  label: "Points" },
  { id: "updated", label: "Updated" },
];

const SORT_OPTIONS = [
  { id: "default",  label: "Default order",       fn: null },
  { id: "name-asc", label: "Name A → Z",          fn: (a, b) => a.name.localeCompare(b.name) },
  { id: "name-dsc", label: "Name Z → A",          fn: (a, b) => b.name.localeCompare(a.name) },
  { id: "prio-dsc", label: "Priority (high → low)", fn: (a, b) => PRIO_RANK[a.prio] - PRIO_RANK[b.prio] },
  { id: "prio-asc", label: "Priority (low → high)", fn: (a, b) => PRIO_RANK[b.prio] - PRIO_RANK[a.prio] },
  { id: "pts-dsc",  label: "Points (high → low)", fn: (a, b) => b.points - a.points },
  { id: "pts-asc",  label: "Points (low → high)", fn: (a, b) => a.points - b.points },
  { id: "due-asc",  label: "Due date (soonest)",  fn: (a, b) => dueRank(a.due) - dueRank(b.due) },
  { id: "upd-dsc",  label: "Recently updated",    fn: (a, b) => updRank(a.updated) - updRank(b.updated) },
];
const PRIO_RANK = { critical: 0, high: 1, medium: 2, low: 3 };
function dueRank(s) {
  if (!s || s === "—") return 9999;
  // Seed data uses "Apr 12" / "May 02" / "Today" / "Tomorrow".
  // New format may include trailing " @ H:MM AM/PM" — strip before matching.
  if (s === "Today") return -1;
  if (s === "Tomorrow") return 0;
  const head = String(s).split(" @ ")[0].trim();
  const m = /^([A-Za-z]{3})\s+(\d{1,2})$/.exec(head);
  if (!m) return 9999;
  const mo = { Jan:0,Feb:1,Mar:2,Apr:3,May:4,Jun:5,Jul:6,Aug:7,Sep:8,Oct:9,Nov:10,Dec:11 }[m[1]] ?? 12;
  // Time bumps the rank within the day so morning sorts before evening.
  let timeBoost = 0;
  const tail = String(s).split(" @ ")[1];
  if (tail) {
    const tm = /^(\d{1,2}):(\d{2})\s*([AP])M$/i.exec(tail.trim());
    if (tm) {
      let hh = parseInt(tm[1], 10) % 12;
      if (/p/i.test(tm[3])) hh += 12;
      const mm = parseInt(tm[2], 10);
      timeBoost = (hh * 60 + mm) / (24 * 60); // 0..<1
    }
  }
  return mo * 31 + parseInt(m[2], 10) + timeBoost;
}
function updRank(s) {
  if (!s) return 9999;
  if (s === "now") return 0;
  const m = /^(\d+)([hdw])$/.exec(s);
  if (!m) return 9999;
  const n = parseInt(m[1], 10);
  return n * ({ h: 1, d: 24, w: 24 * 7 }[m[2]]);
}

function ToolbarButton({ icon: Icon, label, active, count, onClick }) {
  return (
    <button className={`btn ${active ? "btn-active" : ""}`} onClick={onClick}>
      <Icon/> {label}
      {count ? <span className="btn-count">{count}</span> : null}
    </button>
  );
}

const EMPTY_FILTERS = { q: "", people: [], creators: [], statuses: [], priorities: [], types: [], sort: "default", hidden: [], mine: false };

function BoardToolbar({
  sprintMode, activeSprint, onStartSprint, onPlanSprint, onCompleteSprint, backlogMode,
  kanbanMode = false,
  filters = EMPTY_FILTERS, setFilters = () => {}, onNewTask, onNewEpic, onNewStory,
  hiddenDoneCount = 0,
  currentUserId = null, projectAccess, activeProjectId,
}) {
  // Person-filter is scoped to the project's members. Anyone selected
  // who's no longer a member sticks around so the active filter chip
  // doesn't silently disappear.
  const _accessMap = projectAccess || (typeof window !== "undefined" ? window.PROJECT_ACCESS : null) || {};
  const _projMembers = activeProjectId && _accessMap[activeProjectId]
    ? new Set((_accessMap[activeProjectId].members || []).map(m => m.id))
    : null;
  const _filterablePeople = _projMembers
    ? PEOPLE.filter(p => _projMembers.has(p.id) || (filters.people || []).includes(p.id))
    : PEOPLE;
  const [open, setOpen] = React.useState(null); // {key, anchor}
  const close = () => setOpen(null);
  const openFor = (key) => (e) => setOpen({ key, anchor: e.currentTarget });

  const f = filters;
  const searchCount  = f.q ? 1 : 0;
  const personCount  = f.people.length;
  const creatorCount = Array.isArray(f.creators) ? f.creators.length : 0;
  const filterCount  = f.statuses.length + f.priorities.length;
  const sortActive   = f.sort !== "default";
  const hideCount    = f.hidden.length;

  return (
    <div className="board-toolbar">
      <button className="btn btn-primary btn-split" onClick={onNewTask} title="New task (Shift+N)">
        <span className="btn-main">New task</span>
        <span className="btn-caret"><Icons.ChevronDn size={12}/></span>
      </button>
      {onNewEpic && (
        <button className="btn" onClick={onNewEpic} title="Create a new epic">
          <Icons.Plus size={13}/> New epic
        </button>
      )}
      {onNewStory && (
        <button className="btn" onClick={onNewStory}
                title="Create a user story — a group of related tasks reviewed together">
          <Icons.Plus size={13}/> New story
        </button>
      )}
      <div className="toolbar-divider"/>

      {currentUserId && (
        <button
          className={"btn btn-mine" + (f.mine ? " is-on" : "")}
          onClick={() => setFilters({ ...f, mine: !f.mine })}
          title={f.mine ? "Showing only your tasks — click to show all" : "Show only tasks assigned to you"}
        >
          <Icons.Users size={13}/> My tasks
          {f.mine && <Icons.Check size={11}/>}
        </button>
      )}

      {/* Hide-done — session-aware preference, persisted across
          reloads. ON by default. Just-completed tasks (this tab)
          AND done subtasks under a still-open top parent stay
          visible regardless of the toggle — the visibility rule
          lives in app.jsx `filtered`. The button shows the count
          of rows currently being suppressed so the user knows
          something's hidden. Click flips the toggle: when ON,
          flipping reveals everything; when OFF, the next click
          hides done again. */}
      <button
        className={"btn btn-mine" + (f.hideDone ? " is-on" : "")}
        onClick={() => setFilters({ ...f, hideDone: !f.hideDone })}
        title={f.hideDone
          ? (hiddenDoneCount > 0
              ? `${hiddenDoneCount} done task${hiddenDoneCount === 1 ? "" : "s"} hidden — click to show them`
              : "Done tasks hide on next reload — click to keep them visible")
          : "Hide done tasks on next reload (just-completed stay visible)"}
      >
        <Icons.Check size={13}/>
        {f.hideDone ? "Show done" : "Hide done"}
        {f.hideDone && hiddenDoneCount > 0 && (
          <span className="btn-count">{hiddenDoneCount}</span>
        )}
        {!f.hideDone && <Icons.Check size={11}/>}
      </button>

      {/* Subtasks are always shown alongside their parents in the
          table (use the per-row chevron to collapse a particular
          parent's children). The kanban still hides subtasks because
          they're not first-class cards there — that filter lives in
          app.jsx's `filtered` memo, not here. */}

      {/* "By epic" / "By story" toggle was dropped in May 2026 —
          stories now render as inline rows in the task table
          regardless of grouping (see StoryRow in this file). The old
          "Reviews" badge that filtered to stories-waiting-on-me
          also went away because the same affordance is now the
          Approve / Request changes buttons that appear on the story
          row itself when the current user is a listed reviewer. */}

      {/* Type filter — quick-pill for "Bugs only" + a chevron that
          opens the full multi-select dropdown (Task / Bug / Chore /
          Spike) for power users. Bug BG_02E81DCE4B — Nandana
          asked for a one-click bug filter; the dropdown is the
          natural generalization. The pill flips into a Bug-only
          shortcut on single click; chevron opens picker for the
          rest. */}
      {(() => {
        const types = Array.isArray(f.types) ? f.types : [];
        const bugsOnly = types.length === 1 && types[0] === "bug";
        const toggleBugsOnly = () => {
          if (bugsOnly) setFilters({ ...f, types: [] });
          else setFilters({ ...f, types: ["bug"] });
        };
        return (
          <button type="button"
                  className={"btn btn-mine btn-type-bug" + (bugsOnly ? " is-on" : "")}
                  onClick={toggleBugsOnly}
                  title={bugsOnly
                    ? "Showing only bugs — click to clear"
                    : "Show only bug-type tasks (one click)"}>
            <span aria-hidden="true" style={{ fontSize: 13 }}>🐞</span>
            {bugsOnly ? "Bugs only" : "Bugs"}
            {bugsOnly && <Icons.Check size={11}/>}
          </button>
        );
      })()}
      <ToolbarButton icon={Icons.Type || Icons.Filter} label="Type"
                     count={(Array.isArray(f.types) ? f.types.length : 0)}
                     active={open?.key === "type" || (Array.isArray(f.types) && f.types.length > 0)}
                     onClick={openFor("type")}/>

      <ToolbarButton icon={Icons.Search} label="Search" count={searchCount} active={open?.key === "search" || searchCount > 0} onClick={openFor("search")}/>
      <ToolbarButton icon={Icons.Users}  label="Person" count={personCount} active={open?.key === "person" || personCount > 0} onClick={openFor("person")}/>
      {/* Creator filter — narrow tasks to those raised by a given
          person (e.g. "show me everything I created"). Same UI as the
          Person picker, drawing from the same project-member list. */}
      <ToolbarButton icon={Icons.Plus}   label="Creator" count={creatorCount} active={open?.key === "creator" || creatorCount > 0} onClick={openFor("creator")}/>
      <ToolbarButton icon={Icons.Filter} label="Filter" count={filterCount} active={open?.key === "filter" || filterCount > 0} onClick={openFor("filter")}/>
      {/* Sort + Hide-columns are table-only — they have no visible
          effect on the kanban (cards are drag-positioned within their
          status column, and the kanban has no toggleable columns). */}
      {!kanbanMode && (
        <ToolbarButton icon={Icons.Sort}   label="Sort"   active={open?.key === "sort" || sortActive} onClick={openFor("sort")}/>
      )}
      {!kanbanMode && (
        <ToolbarButton icon={Icons.Eye}    label="Hide"   count={hideCount} active={open?.key === "hide" || hideCount > 0} onClick={openFor("hide")}/>
      )}

      {(searchCount + personCount + creatorCount + filterCount + (sortActive ? 1 : 0) + hideCount + (f.mine ? 1 : 0) + (f.hideDone ? 1 : 0) + (f.reviewMine ? 1 : 0) + (Array.isArray(f.types) ? f.types.length : 0)) > 0 && (
        <button className="btn btn-link-reset" onClick={() => setFilters({
          // Preserve groupBy across Reset — it's a persistent layout
          // choice, not a per-session filter, so blowing it away on
          // Reset would surprise users.
          q: "", people: [], creators: [], statuses: [], priorities: [], types: [], sort: "default", hidden: [],
          mine: false, hideDone: false, reviewMine: false,
          groupBy: f.groupBy || "epic",
        })}>
          <Icons.Close size={12}/> Reset
        </button>
      )}

      {open?.key === "search" && (
        <Popover anchor={open.anchor} onClose={close}>
          <div style={{ padding: 8, width: 260 }}>
            <div style={{ fontSize: 11, color: "var(--ink-muted)", fontWeight: 600, letterSpacing: ".06em", textTransform: "uppercase", padding: "2px 4px 6px" }}>Search tasks</div>
            <input autoFocus value={f.q}
              onChange={(e) => setFilters({ ...f, q: e.target.value })}
              placeholder="Search by name or epic…"
              style={{ width: "100%", padding: "7px 10px", border: "1px solid var(--border)", borderRadius: 6, fontSize: 13, outline: "none" }}
              onKeyDown={(e) => { if (e.key === "Enter") close(); }}/>
            {f.q && (
              <div style={{ marginTop: 8, fontSize: 11, color: "var(--ink-muted)", padding: "0 4px" }}>
                Matching <b style={{ color: "var(--ink-body)" }}>“{f.q}”</b> — press Enter to apply
              </div>
            )}
          </div>
        </Popover>
      )}

      {open?.key === "person" && (
        <Popover anchor={open.anchor} onClose={close}>
          <div style={{ maxHeight: 260, overflow: "auto", minWidth: 200 }}>
            <div style={{ fontSize: 11, color: "var(--ink-muted)", fontWeight: 600, letterSpacing: ".06em", textTransform: "uppercase", padding: "8px 10px 4px" }}>Filter by person</div>
            {_filterablePeople.map(p => {
              const on = f.people.includes(p.id);
              return (
                <div key={p.id} className="popover-item" onClick={() => {
                  const next = on ? f.people.filter(x => x !== p.id) : [...f.people, p.id];
                  setFilters({ ...f, people: next });
                }}>
                  <Avatar person={p} size="sm"/>
                  <span style={{ flex: 1 }}>{p.name}</span>
                  {on && <Icons.Check size={13} style={{ color: "var(--brand)" }}/>}
                </div>
              );
            })}
            {f.people.length > 0 && (
              <>
                <div style={{ height: 1, background: "var(--border)", margin: "4px 0" }}/>
                <div className="popover-item" style={{ color: "var(--ink-muted)" }} onClick={() => setFilters({ ...f, people: [] })}>
                  <Icons.Close size={12}/> Clear person filter
                </div>
              </>
            )}
          </div>
        </Popover>
      )}

      {/* Creator popover — multi-select of project members. A
          quick "Just me" row at the top lets a user filter to their
          own raised tasks in one click without having to find
          themselves in the list. Empty selection == "show all". */}
      {open?.key === "creator" && (
        <Popover anchor={open.anchor} onClose={close}>
          <div style={{ maxHeight: 280, overflow: "auto", minWidth: 220 }}>
            <div style={{ fontSize: 11, color: "var(--ink-muted)", fontWeight: 600, letterSpacing: ".06em", textTransform: "uppercase", padding: "8px 10px 4px" }}>
              Filter by creator
            </div>
            {currentUserId && (() => {
              const creators = Array.isArray(f.creators) ? f.creators : [];
              const onlyMe = creators.length === 1 && creators[0] === currentUserId;
              return (
                <div className="popover-item" onClick={() => {
                  setFilters({ ...f, creators: onlyMe ? [] : [currentUserId] });
                }}>
                  <span style={{ fontWeight: 600, flex: 1 }}>Created by me</span>
                  {onlyMe && <Icons.Check size={13} style={{ color: "var(--brand)" }}/>}
                </div>
              );
            })()}
            <div style={{ height: 1, background: "var(--border)", margin: "4px 0" }}/>
            {_filterablePeople.map(p => {
              const creators = Array.isArray(f.creators) ? f.creators : [];
              const on = creators.includes(p.id);
              return (
                <div key={p.id} className="popover-item" onClick={() => {
                  const next = on ? creators.filter(x => x !== p.id) : [...creators, p.id];
                  setFilters({ ...f, creators: next });
                }}>
                  <Avatar person={p} size="sm"/>
                  <span style={{ flex: 1 }}>{p.name}</span>
                  {on && <Icons.Check size={13} style={{ color: "var(--brand)" }}/>}
                </div>
              );
            })}
            {creatorCount > 0 && (
              <>
                <div style={{ height: 1, background: "var(--border)", margin: "4px 0" }}/>
                <div className="popover-item" style={{ color: "var(--ink-muted)" }} onClick={() => setFilters({ ...f, creators: [] })}>
                  <Icons.Close size={12}/> Clear creator filter
                </div>
              </>
            )}
          </div>
        </Popover>
      )}

      {/* Type-filter popover — multi-select across the four task types.
          Empty selection == "show all". The Bug shortcut pill above is
          a convenience over the same `filters.types` field. */}
      {open?.key === "type" && (
        <Popover anchor={open.anchor} onClose={close}>
          <div style={{ minWidth: 200 }}>
            <div style={{ fontSize: 11, color: "var(--ink-muted)", fontWeight: 600, letterSpacing: ".06em", textTransform: "uppercase", padding: "8px 10px 4px" }}>
              Filter by type
            </div>
            {(typeof TASK_TYPES !== "undefined" ? TASK_TYPES : []).map(t => {
              const current = Array.isArray(f.types) ? f.types : [];
              const on = current.includes(t.id);
              return (
                <div key={t.id} className="popover-item" onClick={() => {
                  const next = on
                    ? current.filter(x => x !== t.id)
                    : [...current, t.id];
                  setFilters({ ...f, types: next });
                }}>
                  <span style={{ fontSize: 14, width: 18, textAlign: "center" }}>{t.emoji}</span>
                  <span style={{ flex: 1 }}>{t.label}</span>
                  {on && <Icons.Check size={13} style={{ color: "var(--brand)" }}/>}
                </div>
              );
            })}
            {Array.isArray(f.types) && f.types.length > 0 && (
              <>
                <div style={{ height: 1, background: "var(--border)", margin: "4px 0" }}/>
                <div className="popover-item" style={{ color: "var(--ink-muted)" }}
                     onClick={() => setFilters({ ...f, types: [] })}>
                  <Icons.Close size={12}/> Clear type filter
                </div>
              </>
            )}
          </div>
        </Popover>
      )}

      {open?.key === "filter" && (
        <Popover anchor={open.anchor} onClose={close}>
          <div style={{ minWidth: 220 }}>
            <div style={{ fontSize: 11, color: "var(--ink-muted)", fontWeight: 600, letterSpacing: ".06em", textTransform: "uppercase", padding: "8px 10px 4px" }}>Status</div>
            {STATUSES.map(s => {
              const on = f.statuses.includes(s.id);
              return (
                <div key={s.id} className="popover-item" onClick={() => {
                  const next = on ? f.statuses.filter(x => x !== s.id) : [...f.statuses, s.id];
                  setFilters({ ...f, statuses: next });
                }}>
                  <StatusPill status={s.id} fill={false}/>
                  <span style={{ flex: 1 }}/>
                  {on && <Icons.Check size={13} style={{ color: "var(--brand)" }}/>}
                </div>
              );
            })}
            <div style={{ height: 1, background: "var(--border)", margin: "4px 0" }}/>
            <div style={{ fontSize: 11, color: "var(--ink-muted)", fontWeight: 600, letterSpacing: ".06em", textTransform: "uppercase", padding: "4px 10px" }}>Priority</div>
            {PRIORITIES.map(p => {
              const on = f.priorities.includes(p.id);
              return (
                <div key={p.id} className="popover-item" onClick={() => {
                  const next = on ? f.priorities.filter(x => x !== p.id) : [...f.priorities, p.id];
                  setFilters({ ...f, priorities: next });
                }}>
                  <PriorityPill prio={p.id} fill={false}/>
                  <span style={{ flex: 1 }}/>
                  {on && <Icons.Check size={13} style={{ color: "var(--brand)" }}/>}
                </div>
              );
            })}
          </div>
        </Popover>
      )}

      {open?.key === "sort" && (
        <Popover anchor={open.anchor} onClose={close}>
          <div style={{ minWidth: 200 }}>
            {SORT_OPTIONS.map(s => (
              <div key={s.id} className="popover-item" onClick={() => {
                setFilters({ ...f, sort: s.id });
                close();
              }}>
                <span style={{ width: 14, display: "inline-flex" }}>
                  {f.sort === s.id && <Icons.Check size={13} style={{ color: "var(--brand)" }}/>}
                </span>
                <span style={{ flex: 1 }}>{s.label}</span>
              </div>
            ))}
          </div>
        </Popover>
      )}

      {open?.key === "hide" && (
        <Popover anchor={open.anchor} onClose={close}>
          <div style={{ minWidth: 180 }}>
            <div style={{ fontSize: 11, color: "var(--ink-muted)", fontWeight: 600, letterSpacing: ".06em", textTransform: "uppercase", padding: "8px 10px 4px" }}>Show columns</div>
            {HIDEABLE_COLS.map(c => {
              const on = !f.hidden.includes(c.id);
              return (
                <div key={c.id} className="popover-item" onClick={() => {
                  const next = on ? [...f.hidden, c.id] : f.hidden.filter(x => x !== c.id);
                  setFilters({ ...f, hidden: next });
                }}>
                  <span style={{ width: 16, height: 16, borderRadius: 3, border: "1.5px solid var(--border-strong)",
                    background: on ? "var(--brand)" : "white", borderColor: on ? "var(--brand)" : "var(--border-strong)",
                    display: "inline-flex", alignItems: "center", justifyContent: "center", color: "white" }}>
                    {on && <Icons.Check size={11}/>}
                  </span>
                  <span style={{ flex: 1 }}>{c.label}</span>
                </div>
              );
            })}
          </div>
        </Popover>
      )}

      {sprintMode && activeSprint && (
        <>
          <div className="toolbar-divider"/>
          <button className="btn" style={{ background: "#eef3ff", color: "#0060b9", fontWeight: 600 }}>
            <Icons.Lightning/> {activeSprint.label} · {activeSprint.dates}
          </button>
          {onCompleteSprint && (
            <button className="btn" onClick={onCompleteSprint} style={{ fontWeight: 600 }}>
              <Icons.Check size={13}/> Complete sprint
            </button>
          )}
        </>
      )}
      {backlogMode && onStartSprint && (
        <>
          <div className="toolbar-divider"/>
          <button className="btn btn-primary" onClick={onStartSprint} style={{ background: "var(--brand)" }}>
            <Icons.Lightning/> Start new sprint
          </button>
          {onPlanSprint && (
            <button className="btn" onClick={onPlanSprint} title="Queue work for a future sprint without activating it now">
              <Icons.Calendar size={13}/> Plan upcoming
            </button>
          )}
        </>
      )}
      <div className="toolbar-right">
        <button className="btn"><Icons.More/></button>
      </div>
    </div>
  );
}

// ── Mini calendar for the due-date cell ─────────────────────────
const MONTH_FULL  = ["January","February","March","April","May","June","July","August","September","October","November","December"];
const MONTH_SHORT = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];

// Due-date string format
//   ""    | "—"               → no date
//   "Apr 28"                  → date only (legacy / default)
//   "Apr 28 @ 4:30 PM"        → date + time
// The " @ " separator is the canonical hint that a time portion follows.
// All parsers below tolerate either form so existing rows keep working.

function _splitDueString(s) {
  if (!s || s === "—") return { datePart: "", timePart: "" };
  const idx = String(s).indexOf(" @ ");
  if (idx < 0) return { datePart: String(s), timePart: "" };
  return { datePart: String(s).slice(0, idx), timePart: String(s).slice(idx + 3).trim() };
}

function parseDateLoose(s) {
  // Backwards-compat: callers pass the full due string (with optional time);
  // strip the time portion before parsing.
  const { datePart } = _splitDueString(s);
  if (!datePart) return null;
  const m = datePart.match(/^([A-Za-z]+)\s+(\d{1,2})(?:,\s*(\d{4}))?/);
  if (!m) return null;
  const idx = MONTH_SHORT.findIndex(x => x.toLowerCase() === m[1].slice(0, 3).toLowerCase());
  if (idx < 0) return null;
  const yr = m[3] ? parseInt(m[3], 10) : new Date().getFullYear();
  return new Date(yr, idx, parseInt(m[2], 10));
}

// Returns { hh, mm } 24h, or null if no time present.
function parseDueTime(s) {
  const { timePart } = _splitDueString(s);
  if (!timePart) return null;
  const m = /^(\d{1,2}):(\d{2})(?:\s*(AM|PM))?$/i.exec(timePart);
  if (!m) return null;
  let hh = parseInt(m[1], 10);
  const mm = parseInt(m[2], 10);
  const ampm = (m[3] || "").toUpperCase();
  if (ampm === "PM" && hh < 12) hh += 12;
  if (ampm === "AM" && hh === 12) hh = 0;
  if (hh < 0 || hh > 23 || mm < 0 || mm > 59) return null;
  return { hh, mm };
}

function _fmt12(hh, mm) {
  const ampm = hh >= 12 ? "PM" : "AM";
  const h12 = ((hh + 11) % 12) + 1;
  return `${h12}:${String(mm).padStart(2, "0")} ${ampm}`;
}
function formatDateShort(d) { return `${MONTH_SHORT[d.getMonth()]} ${d.getDate()}`; }
function formatDueWithTime(d, hh, mm) {
  const datePart = formatDateShort(d);
  if (hh == null || mm == null) return datePart;
  return `${datePart} @ ${_fmt12(hh, mm)}`;
}
// Expose the helpers globally so other modules (drawer, mytasks, task-detail)
// can render time consistently.
window.parseDateLoose    = parseDateLoose;
window.parseDueTime      = parseDueTime;
window.formatDateShort   = formatDateShort;
window.formatDueWithTime = formatDueWithTime;

function MiniCalendar({ value, onChange, onClear }) {
  const today = new Date();
  const init = parseDateLoose(value) || today;
  const [view, setView] = React.useState({ y: init.getFullYear(), m: init.getMonth() });
  const sel = parseDateLoose(value);
  const initTime = parseDueTime(value);
  // Time state lives inside the picker — empty string means "no time".
  const [time, setTime] = React.useState(initTime ? `${String(initTime.hh).padStart(2,"0")}:${String(initTime.mm).padStart(2,"0")}` : "");

  const firstDow = new Date(view.y, view.m, 1).getDay();
  const daysInMonth = new Date(view.y, view.m + 1, 0).getDate();
  const cells = [];
  for (let i = 0; i < firstDow; i++) cells.push(null);
  for (let d = 1; d <= daysInMonth; d++) cells.push(d);

  const isToday    = (d) => d === today.getDate() && view.m === today.getMonth() && view.y === today.getFullYear();
  const isSelected = (d) => sel && d === sel.getDate() && view.m === sel.getMonth() && view.y === sel.getFullYear();

  function _emit(d, t) {
    if (!t) {
      onChange(formatDateShort(d));
      return;
    }
    const m = /^(\d{1,2}):(\d{2})$/.exec(t);
    if (!m) { onChange(formatDateShort(d)); return; }
    onChange(formatDueWithTime(d, parseInt(m[1], 10), parseInt(m[2], 10)));
  }
  function pick(d) {
    _emit(new Date(view.y, view.m, d), time);
  }
  function pickToday() {
    _emit(new Date(), time);
  }
  function onTimeChange(e) {
    const next = e.target.value || "";
    setTime(next);
    // If we already have a date selected, persist the new time straight away.
    if (sel) _emit(sel, next);
  }
  function clearTime() {
    setTime("");
    if (sel) _emit(sel, "");
  }
  function step(delta) {
    setView(v => {
      const m = v.m + delta;
      if (m < 0)  return { y: v.y - 1, m: 11 };
      if (m > 11) return { y: v.y + 1, m: 0  };
      return { y: v.y, m };
    });
  }

  return (
    <div className="mini-cal" onMouseDown={(e) => e.stopPropagation()}>
      <div className="mini-cal-head">
        <button type="button" className="mini-cal-nav" onClick={() => step(-1)} aria-label="Previous month">‹</button>
        <span className="mini-cal-title">{MONTH_FULL[view.m]} {view.y}</span>
        <button type="button" className="mini-cal-nav" onClick={() => step(1)} aria-label="Next month">›</button>
      </div>
      <div className="mini-cal-grid">
        {["S","M","T","W","T","F","S"].map((d, i) => <div key={`dow-${i}`} className="mini-cal-dow">{d}</div>)}
        {cells.map((d, i) => (
          d === null
            ? <div key={`b-${i}`}/>
            : <button key={`d-${d}`} type="button"
                      className={`mini-cal-day ${isToday(d) ? "is-today" : ""} ${isSelected(d) ? "is-selected" : ""}`}
                      onClick={() => pick(d)}>{d}</button>
        ))}
      </div>
      <div className="mini-cal-time">
        <label className="mini-cal-time-label">
          <Icons.Clock size={11}/> Time <span style={{ color: "var(--ink-muted)", fontWeight: 400 }}>(optional)</span>
        </label>
        <div className="mini-cal-time-row">
          <input type="time"
                 className="mini-cal-time-input"
                 value={time}
                 onChange={onTimeChange}
                 disabled={!sel}
                 title={sel ? "Set a specific time" : "Pick a date first"}/>
          {time && (
            <button type="button" className="mini-cal-time-clear" onClick={clearTime} title="Clear time">
              <Icons.Close size={10}/>
            </button>
          )}
        </div>
      </div>
      <div className="mini-cal-foot">
        <button type="button" className="mini-cal-foot-btn" onClick={pickToday}>Today</button>
        <button type="button" className="mini-cal-foot-btn is-clear" onClick={() => { setTime(""); onClear && onClear(); }}>Clear</button>
      </div>
    </div>
  );
}

// Single task row with inline-editable status, priority, owner, date.
// Wrapped in React.memo at the bottom so unchanged rows don't re-run
// their full render every time the parent state ticks. With ~hundreds
// of rows on My Work, that was the perf bottleneck on status edits.
function TaskRowImpl({ task, onUpdate, onOpen, onDelete, onSelect, selected, hideSprintCol, hidden = EMPTY_SET, sprints, onAddSubtask, childCount = 0, collapsed = false, onToggleCollapse,
                  onDragStart, onDragOverRow, onDropOnRow, dropIndicator, isDragging, isJustDropped, showProjectPill, projectAccess, currentUserId }) {
  // Resolve "me" — prefer the prop, fall back to api.getUser() so the
  // row stays usable in standalone demos that don't pass it down.
  const _me = currentUserId
    || (typeof window !== "undefined" && window.api && window.api.getUser && window.api.getUser() && window.api.getUser().id)
    || null;
  // Owner picker is scoped to the task's project members. Currently-
  // assigned users are kept in the list even if they're no longer
  // members so the row never silently drops them. Falls back to the
  // full PEOPLE list when projectAccess isn't supplied (e.g. legacy
  // callers / standalone demos) so the picker stays usable.
  const _accessMap = projectAccess || (typeof window !== "undefined" ? window.PROJECT_ACCESS : null) || {};
  const _taskProjectId = task.projectId || task.project_id || null;
  const _projMemberIds = _taskProjectId
    ? new Set(((_accessMap[_taskProjectId] && _accessMap[_taskProjectId].members) || []).map(m => m.id))
    : null;
  const _assignablePeople = _projMemberIds
    ? PEOPLE.filter(p => _projMemberIds.has(p.id) || (task.owners || []).includes(p.id))
    : PEOPLE;
  const [editing, setEditing] = React.useState(null); // {field, anchor}
  const statusClose = () => setEditing(null);

  const sprintList = sprints || SPRINTS;
  const sprintObj = sprintList.find(s => s.id === task.sprint);
  // Real overdue check (used to be a hardcoded "Apr 0X | 1X" regex
   // that rotted as time moved past April). isOverdueNow lives in
   // lateness.jsx and answers: due in the past + not done.
  const overdue = typeof isOverdueNow === "function" ? isOverdueNow(task) : false;
  const showSprint = !hideSprintCol && !hidden.has("sprint");

  // Row celebration when status transitions to "done"
  const prevStatus = React.useRef(task.status);
  const [rowCelebrate, setRowCelebrate] = React.useState(false);
  React.useEffect(() => {
    if (prevStatus.current !== "done" && task.status === "done") {
      setRowCelebrate(true);
      const t = setTimeout(() => setRowCelebrate(false), 1100);
      prevStatus.current = task.status;
      return () => clearTimeout(t);
    }
    prevStatus.current = task.status;
  }, [task.status]);

  const dropStyle = dropIndicator === "above" ? { boxShadow: "inset 0 3px 0 var(--brand)" }
                  : dropIndicator === "below" ? { boxShadow: "inset 0 -3px 0 var(--brand)" }
                  : dropIndicator === "into"  ? { boxShadow: "inset 0 0 0 2px var(--brand)", background: "rgba(0,115,234,.06)" }
                  : null;

  const isSubtask = !!task.parentTaskId;
  const cls = [
    selected ? "is-selected" : "",
    isDragging ? "is-dragging-row" : "",
    isJustDropped ? "is-just-dropped-row" : "",
    rowCelebrate ? "is-row-celebrate" : "",
    isSubtask ? "is-subtask-row" : "",
  ].filter(Boolean).join(" ");

  // For subtask rows, look up the parent in window.ALL_TASKS so we
  // can render a "↳ in <parent name>" chip. The chip is also
  // clickable: it opens the parent's drawer. We do this lookup at
  // render time (cheap — parents are cached in the same array)
  // rather than threading the parent through every prop chain.
  let parentTask = null;
  if (isSubtask && typeof window !== "undefined" && Array.isArray(window.ALL_TASKS)) {
    parentTask = window.ALL_TASKS.find(x => x && x.id === task.parentTaskId) || null;
  }

  return (
    <tr
      className={cls}
      style={dropStyle}
      draggable
      onDragStart={(e) => {
        e.dataTransfer.effectAllowed = "move";
        e.dataTransfer.setData("text/plain", task.id);
        onDragStart && onDragStart(task);
      }}
      onDragOver={(e) => {
        if (!onDragOverRow) return;
        e.preventDefault();
        e.stopPropagation();
        e.dataTransfer.dropEffect = "move";
        // Drag is REORDER by default — split the row 50/50 into
        // above/below. The "into" reparent gesture used to occupy the
        // middle 40% of every row's height, which caused tasks to get
        // accidentally pulled into a neighbour as a subtask during
        // normal reorder drags. We now require an explicit modifier:
        // hold Shift (or Ctrl/Meta) on dragover/drop to reparent.
        // Subtask rows already never accept "into" (we don't want
        // deeper nesting via drag — use the + Subtask button).
        const r = e.currentTarget.getBoundingClientRect();
        const ratio = (e.clientY - r.top) / Math.max(1, r.height);
        const wantsInto = (e.shiftKey || e.ctrlKey || e.metaKey) && !task.parentTaskId;
        let side;
        if (wantsInto && ratio > 0.20 && ratio < 0.80) {
          side = "into";
        } else {
          side = ratio < 0.5 ? "above" : "below";
        }
        onDragOverRow(task, side);
      }}
      onDrop={(e) => {
        if (!onDropOnRow) return;
        e.preventDefault();
        e.stopPropagation();
        const r = e.currentTarget.getBoundingClientRect();
        const ratio = (e.clientY - r.top) / Math.max(1, r.height);
        const wantsInto = (e.shiftKey || e.ctrlKey || e.metaKey) && !task.parentTaskId;
        let side;
        if (wantsInto && ratio > 0.20 && ratio < 0.80) {
          side = "into";
        } else {
          side = ratio < 0.5 ? "above" : "below";
        }
        onDropOnRow(e, task, side);
      }}
    >
      <td className="row-grip" style={{ width: 18, padding: 0, color: "var(--ink-faint)", textAlign: "center", cursor: "grab" }}
          title="Drag to reorder">
        <Icons.Grip size={14}/>
      </td>
      <td className="row-check">
        <input type="checkbox" checked={selected || false} onChange={(e) => onSelect(task.id, e.target.checked)}/>
      </td>
      {/* depth → indent. Each level adds ~22px of left padding so
          nested subtasks read as a clear tree. depth=0 (top-level)
          gets no extra padding so the visual rhythm matches every
          other table. The CSS var lets CSS pick up the value
          without re-running the React layout pass. */}
      <td className="cell-name" onClick={() => onOpen(task)}
          style={(task.depth || 0) > 0
            ? { "--task-depth": (task.depth || 0),
                paddingLeft: "calc(14px + (var(--task-depth, 0) * 22px))" }
            : null}>
        <div className="name-wrap">
          {isSubtask ? (
            <span className="subtask-glyph" aria-hidden="true" title="Subtask">↳</span>
          ) : childCount > 0 && onToggleCollapse ? (
            <button type="button"
                    className="subtask-toggle"
                    onClick={(e) => { e.stopPropagation(); onToggleCollapse(task.id); }}
                    title={collapsed
                      ? `Expand to show ${childCount} subtask${childCount === 1 ? "" : "s"}`
                      : `Collapse ${childCount} subtask${childCount === 1 ? "" : "s"}`}
                    aria-expanded={!collapsed}>
              <Icons.ChevronRt className={"subtask-toggle-chev" + (collapsed ? "" : " is-open")} size={14}/>
            </button>
          ) : (
            // Spacer keeps every row's title cell aligned even when
            // there's no chevron to render (childless top-level row).
            <span className="subtask-toggle subtask-toggle-spacer" aria-hidden="true"/>
          )}
          {showProjectPill && task.projectName && (
            <span className="row-project-pill"
                  style={{ "--proj-color": task.projectColor || "#a3a8b6" }}
                  title={task.projectName}>
              <span className="row-project-pill-dot"/>
              {task.projectName}
            </span>
          )}
          {/* Long titles used to wrap and break row alignment in the
              cross-project My Work table. The .name-text class
              ellipsises overflow so every row stays the same height,
              and the title attr surfaces the full string on hover for
              anyone who needs to read it without opening the drawer.
              Empty-title rows (rare data oddity — usually a subtask
              that was created without a name) get a muted "(Untitled)"
              fallback so they're still visible and clickable; without
              this, the row appeared blank and users couldn't open the
              drawer to fix the title. */}
          {task.name && task.name.trim()
            ? <span className="name-text" title={task.name}>{task.name}</span>
            : <span className="name-text name-text-untitled" title="No title — click the row to set one">(Untitled)</span>}
          {/* Type marker — small inline chip after the task name. Only
              rendered for non-default types (bug / chore / spike) so the
              vast majority of rows (plain tasks) stay visually clean.
              Bug BG_02E81DCE4B — Nandana asked for a Bug tag visible
              wherever tasks render. Kanban gets a bigger chip; here on
              the table the marker is compact. */}
          {(() => {
            const tm = (typeof taskTypeMeta === "function") ? taskTypeMeta(task) : null;
            if (!tm || tm.id === "task") return null;
            return (
              <span className={`row-type-tag row-type-${tm.id}`} title={tm.label}>
                <span className="row-type-tag-emoji">{tm.emoji}</span>
                {tm.label}
              </span>
            );
          })()}
          {/* Subtask "in <parent>" chip — keeps the parent-child link
              visible even after sort/filter scatters the rows. Click
              opens the parent task's drawer in place. */}
          {isSubtask && parentTask && (
            <button type="button"
                    className="subtask-parent-chip"
                    title={"Open parent: " + parentTask.name}
                    onClick={(e) => { e.stopPropagation(); onOpen(parentTask); }}>
              in {parentTask.name}
            </button>
          )}
          {task.subtasks > 0 && <span className="subtask-count">{task.subtasks}</span>}
          {/* Recurring marker — visible on the template AND every
              materialised instance, so the user can spot which rows
              are part of a series at a glance. The drawer's Repeat
              field is the place to actually edit / stop / pause. */}
          {(task.recurrenceRuleId || task.isRecurringTemplate) && (
            <span className="recur-glyph" aria-hidden="true"
                  title={task.isRecurringTemplate
                    ? "Series template — Repeat field in the drawer controls all future occurrences"
                    : "Created by a recurring task series"}>↻</span>
          )}
          {/* Two badges, two intents:
              · OverdueBadge — task is currently late (not yet done) → red, pulses.
              · LateBadge    — task was completed late (already shipped) → amber.
              They're mutually exclusive (LateBadge only renders when status==="done"
              AND it shipped late; OverdueBadge needs status!=="done"). */}
          {typeof OverdueBadge !== "undefined" && <OverdueBadge task={task} size="tiny"/>}
          {typeof LateBadge    !== "undefined" && <LateBadge    task={task} size="tiny"/>}
          {typeof QaRequiredMark !== "undefined" && task.qaRequired && task.status !== "qa" && <QaRequiredMark required={true}/>}
          {typeof QaReopenBadge !== "undefined" && <QaReopenBadge count={task.reopenCount} size="sm"/>}
          {/* Hover-only "+ Subtask" — visible on any row whose depth
              is below the cap. depth=4 is the max; we hide the button
              there since the server would reject the insert anyway. */}
          {onAddSubtask && (task.depth || 0) < 4 && (
            <button type="button"
                    className="row-subtask-btn"
                    title="Add a subtask under this task"
                    onClick={(e) => { e.stopPropagation(); onAddSubtask(task.id); }}>
              <Icons.Plus size={12}/> Subtask
            </button>
          )}
          {/* Hover-only delete affordance — keeps rows clean at rest, but
              gives a one-click "move to bin" without opening the drawer. */}
          {onDelete && (
            <button type="button"
                    className="row-delete-btn"
                    title="Move task to Bin (kept for 30 days)"
                    onClick={(e) => {
                      e.stopPropagation();
                      // Same pattern as the drawer's delete: prefer the
                      // in-app modal but fall back to native confirm; use
                      // a Promise chain so an error in the confirm flow
                      // can't leak as an unhandled rejection.
                      const ask = window.fbConfirm
                        ? window.fbConfirm({
                            title: `Move "${task.name}" to Bin?`,
                            body: "It'll stay there for 30 days — restore anytime from the Bin in the sidebar, or use the Undo toast that pops up after.",
                            confirmLabel: "Move to Bin",
                            danger: true,
                            icon: "🗑",
                          })
                        : Promise.resolve(window.confirm(`Move "${task.name}" to Bin?`));
                      Promise.resolve(ask)
                        .then((ok) => { if (ok) onDelete(task.id); })
                        .catch((err) => console.warn("[row] confirm failed:", err));
                    }}>
              <Icons.Trash size={13}/>
            </button>
          )}
        </div>
      </td>
      {!hidden.has("owners") && (
      <td className="cell-center" onClick={(e) => setEditing({ field: "owners", anchor: e.currentTarget })}>
        <div className="cell-owners" style={{ cursor: "pointer" }}>
          {task.owners.length
            ? <AvatarStack ids={task.owners} max={3}/>
            : <span style={{ color: "#a3a8b6", fontSize: 12, fontStyle: "italic" }}>+ Assign</span>}
        </div>
        {editing?.field === "owners" && (
          <Popover anchor={editing.anchor} onClose={statusClose}>
            <div style={{ maxHeight: 240, overflow: "auto", minWidth: 200 }}>
              {_assignablePeople.length === 0 && (
                <div style={{ padding: "10px 12px", fontSize: 12, color: "var(--ink-muted)" }}>
                  This project has no members yet. Add someone from the project header
                  (<b>+ Add member</b>) and they'll show up here.
                </div>
              )}
              {_me && !task.owners.includes(_me) && (
                <>
                  <div className="popover-item"
                       style={{ color: "var(--brand)", fontWeight: 600 }}
                       onClick={(e) => {
                         e.stopPropagation();
                         onUpdate(task.id, { owners: [...task.owners, _me] });
                         statusClose();
                       }}>
                    <Icons.Plus size={13}/>
                    <span style={{ flex: 1 }}>Assign to me</span>
                  </div>
                  <div style={{ height: 1, background: "var(--border)", margin: "4px 0" }}/>
                </>
              )}
              {_assignablePeople.map(p => {
                const on = task.owners.includes(p.id);
                return (
                  <div key={p.id} className="popover-item" onClick={(e) => {
                    e.stopPropagation();
                    const next = on ? task.owners.filter(o => o !== p.id) : [...task.owners, p.id];
                    onUpdate(task.id, { owners: next });
                  }}>
                    <Avatar person={p} size="sm"/>
                    <span style={{ flex: 1 }}>{p.name}</span>
                    {on && <Icons.Check size={13} style={{ color: "var(--brand)" }}/>}
                  </div>
                );
              })}
              {task.owners.length > 0 && (
                <>
                  <div style={{ height: 1, background: "var(--border)", margin: "4px 0" }}/>
                  <div className="popover-item" style={{ color: "var(--ink-muted)" }} onClick={(e) => {
                    e.stopPropagation();
                    onUpdate(task.id, { owners: [] });
                    statusClose();
                  }}>
                    <Icons.Close size={12}/>
                    <span style={{ flex: 1 }}>Unassign all</span>
                  </div>
                </>
              )}
            </div>
          </Popover>
        )}
      </td>
      )}
      {!hidden.has("status") && (
      <td className="cell-center" onClick={(e) => setEditing({ field: "status", anchor: e.currentTarget })}>
        <StatusPill status={task.status}/>
        {editing?.field === "status" && (
          <Popover anchor={editing.anchor} onClose={statusClose}>
            {STATUSES.map(s => (
              <div key={s.id} className="popover-item" onClick={(e) => {
                e.stopPropagation();
                if (s.id === "done" && task.status !== "done") {
                  const el = e.currentTarget;
                  el.classList.add("is-celebrating");
                  setTimeout(() => { onUpdate(task.id, { status: s.id }); statusClose(); }, 520);
                } else {
                  onUpdate(task.id, { status: s.id });
                  statusClose();
                }
              }}>
                <span className={`pill pill-sm ${s.cls}`} style={{ minWidth: 80 }}>{s.label}</span>
                {s.id === "done" && (
                  <span className="popover-spark" aria-hidden="true">
                    <span/><span/><span/><span/><span/><span/>
                  </span>
                )}
              </div>
            ))}
          </Popover>
        )}
      </td>
      )}
      {!hidden.has("prio") && (
      <td className="cell-center" onClick={(e) => setEditing({ field: "prio", anchor: e.currentTarget })}>
        <PriorityPill prio={task.prio}/>
        {editing?.field === "prio" && (
          <Popover anchor={editing.anchor} onClose={statusClose}>
            {PRIORITIES.map(s => (
              <div key={s.id} className="popover-item" onClick={(e) => { e.stopPropagation(); onUpdate(task.id, { prio: s.id }); statusClose(); }}>
                <span className={`pill pill-sm ${s.cls}`} style={{ minWidth: 70 }}>{s.label}</span>
              </div>
            ))}
          </Popover>
        )}
      </td>
      )}
      {!hidden.has("due") && (
      <td className={`cell-date cell-text ${overdue ? "is-overdue" : ""} ${task.status === "done" ? "is-done" : ""}`}
          style={{ textAlign: "center", cursor: "pointer" }}
          onClick={(e) => setEditing({ field: "due", anchor: e.currentTarget })}>
        <span className="due-cell-label">
          {task.due && task.due !== "—" ? task.due : <span className="due-empty"><Icons.Calendar size={11}/> Set date</span>}
        </span>
        {editing?.field === "due" && (
          <Popover anchor={editing.anchor} onClose={statusClose}>
            <MiniCalendar
              value={task.due}
              onChange={(d) => { onUpdate(task.id, { due: d }); statusClose(); }}
              onClear={() => { onUpdate(task.id, { due: "—" }); statusClose(); }}
            />
          </Popover>
        )}
      </td>
      )}
      {showSprint && (
      <td className="cell-text" style={{ textAlign: "center" }}>
        {(() => {
          if (!sprintObj) {
            return <span className="sprint-tag no-sprint" title="Not in any sprint — backlog">
              <Icons.Inbox size={10}/> No sprint
            </span>;
          }
          if (sprintObj.completed) {
            return <span className="sprint-tag is-past" title={`Past sprint • ${sprintObj.label}`}>
              <Icons.Check size={10}/> {sprintObj.label}
            </span>;
          }
          if (sprintObj.active) {
            return <span className="sprint-tag is-active" title={`Active sprint • ${sprintObj.label}`}>
              <Icons.Lightning size={10}/> {sprintObj.label}
            </span>;
          }
          return <span className="sprint-tag is-upcoming" title={`Upcoming sprint • ${sprintObj.label}`}>
            <Icons.Calendar size={10}/> {sprintObj.label}
          </span>;
        })()}
      </td>
      )}
      {!hidden.has("points") && (
      <td className="cell-text" style={{ textAlign: "center" }}>
        <span className="points">{task.points}</span>
      </td>
      )}
      {!hidden.has("updated") && (
      <td className="cell-text updated-cell" style={{ justifyContent: "center" }}>
        {task.updated} ago
      </td>
      )}
    </tr>
  );
}

// Memoized export. Custom comparator skips re-renders unless something
// the row actually displays has changed — task object identity (we mutate
// only the patched task via .map(), so unchanged rows keep their ref),
// selection, drag/drop visuals, or the column-visibility settings.
// Callback identities (onUpdate / onOpen / etc.) are deliberately ignored
// because they're recreated each parent render but always close over the
// same handler logic.
const TaskRow = React.memo(TaskRowImpl, (prev, next) => {
  return prev.task           === next.task
      && prev.selected       === next.selected
      && prev.dropIndicator  === next.dropIndicator
      && prev.isDragging     === next.isDragging
      && prev.isJustDropped  === next.isJustDropped
      && prev.hideSprintCol  === next.hideSprintCol
      && prev.hidden         === next.hidden
      && prev.sprints        === next.sprints
      && prev.showProjectPill === next.showProjectPill
      && prev.childCount     === next.childCount
      && prev.collapsed      === next.collapsed;
});

const EMPTY_SET = new Set();

// Mini progress summary bar (doneCount/total with segments per status)
function StatusSummary({ tasks }) {
  const counts = {};
  tasks.forEach(t => counts[t.status] = (counts[t.status] || 0) + 1);
  const total = tasks.length;
  return (
    <div className="epic-status-summary">
      {STATUSES.map(s => {
        const n = counts[s.id] || 0;
        if (!n) return null;
        const pct = (n / total) * 100;
        return <span key={s.id} className="seg" style={{ width: `${pct}%`, background: `var(--status-${s.id})` }} title={`${s.label}: ${n}`}/>;
      })}
    </div>
  );
}

// ── StoryRow ──────────────────────────────────────────────────────
// A user story rendered inline as a row in the project task table.
// Sits at the position of its first child task (built upstream by
// EpicGroup's orderedRows memo) and shows: 📖 + title + review-state
// pill + progress (done/total). Its chevron collapses/expands the
// indented child tasks (same `collapsedParents` set as task subtasks
// — the key is "story_<id>" so namespaces don't collide). Hover-only
// Approve / Request changes buttons surface for listed reviewers when
// the story is at review or changes_req.
//
// Clicking the row's name area opens the story drawer (onOpenStory).
function StoryRow({ story, childCount, doneCount, colspan, collapsed, onToggleCollapse, onOpenStory, onUpdate, currentUserId, onToast }) {
  const stMeta = (typeof STORY_STATUSES !== "undefined" && Array.isArray(STORY_STATUSES))
    ? STORY_STATUSES.find(s => s.id === story.status)
    : null;
  const stLabel = (stMeta && stMeta.label) || (story.status || "Backlog");
  const stCls   = (stMeta && stMeta.cls)   || "pill-backlog";

  const reviewerIds = Array.isArray(story.reviewers) ? story.reviewers : [];
  const isReviewer  = !!(currentUserId && reviewerIds.includes(currentUserId));
  const inReview    = story.status === "review";
  const needsReview = isReviewer && (story.status === "review" || story.status === "changes_req");

  // Mirrors the StoryGroup approve/requestChanges helpers but acts at
  // row scope. Same inline-note popover the group header uses.
  const [acting, setActing] = React.useState(false);
  const [reqNoteAnchor, setReqNoteAnchor] = React.useState(null);
  const [reqNoteText, setReqNoteText] = React.useState("");

  // Patch the live window.USER_STORIES array in place so the parent
  // EpicGroup's orderedRows memo (which reads from it) sees the new
  // status on next render. Previous behavior: the API succeeded but
  // the table kept showing the OLD status forever — the user clicked
  // Approve, saw the success toast, but the "Changes Requested" pill
  // and the action buttons stayed visible. Local-first patch + SSE
  // event dispatch keeps the UI honest even if the EventSource is
  // disconnected.
  function _patchStoryInPlace(updated) {
    if (!updated || !updated.id) return;
    try {
      const all = window.USER_STORIES;
      if (!Array.isArray(all)) return;
      const idx = all.findIndex(s => s && s.id === updated.id);
      if (idx === -1) return;
      // Shallow merge so any extra fields the response includes (e.g.
      // reverted_task_ids on request_changes) tag along without
      // clobbering local-only fields.
      all[idx] = Object.assign({}, all[idx], updated);
      // Mirror the SSE event so the rest of the app gets the same
      // refresh signal it would have from the server's event bus.
      try {
        window.dispatchEvent(new CustomEvent("flowboard:rt:stories.updated", {
          detail: { id: updated.id, status: updated.status, local: true },
        }));
      } catch {}
    } catch {}
  }
  async function approve(e) {
    if (e) e.stopPropagation();
    if (acting) return;
    setActing(true);
    try {
      const updated = await window.api.stories.approve(story.id);
      _patchStoryInPlace(updated || { id: story.id, status: "done" });
      onToast && onToast("Approved — thanks for the sign-off.");
    } catch (err) {
      const msg = (err && err.body && (err.body.message || err.body.error)) || (err && err.message) || "network error";
      onToast && onToast("Couldn't approve: " + msg, 4500);
    } finally { setActing(false); }
  }
  async function runRequestChanges(note) {
    if (acting) return;
    setActing(true); setReqNoteAnchor(null);
    try {
      const updated = await window.api.stories.requestChanges(story.id, note || "");
      _patchStoryInPlace(updated || { id: story.id, status: "changes_req" });
      // Reverted child tasks come back to "in_progress" on the server,
      // but the local task list won't know unless we refresh. Fire a
      // background refetch so the rows flip back to To Do without a
      // manual reload.
      if (typeof window.flowboardLoad === "function") {
        window.flowboardLoad().catch(() => {});
      }
      onToast && onToast(
        note ? "Changes requested — owners notified."
             : "Changes requested — child tasks reverted to To Do.",
        4000
      );
    } catch (err) {
      const msg = (err && err.body && (err.body.message || err.body.error)) || (err && err.message) || "network error";
      onToast && onToast("Couldn't request changes: " + msg, 4500);
    } finally { setActing(false); setReqNoteText(""); }
  }
  function requestChanges(e) {
    if (e) e.stopPropagation();
    if (acting) return;
    if (e && e.shiftKey) { runRequestChanges(""); return; }
    setReqNoteText("");
    setReqNoteAnchor(e ? e.currentTarget : null);
  }

  // Layout: chevron + emoji + title + status pill + progress in the
  // name cell. Approve / Request changes buttons live in the TAIL
  // cell (which spans all remaining columns) so they get the right-
  // side real estate and don't clip the title. Earlier version
  // crammed them into the name cell and the title got over-ellipsized
  // while the buttons were cut off at the cell boundary.
  const rightSpan = Math.max(1, (colspan || 10) - 3);

  return (
    <tr className={"story-row" + (needsReview ? " needs-review" : "")}>
      <td className="row-grip" aria-hidden="true"></td>
      <td className="row-check" aria-hidden="true"></td>
      <td className="cell-name story-row-name"
          onClick={() => onOpenStory && onOpenStory(story.id)}>
        <div className="name-wrap">
          {childCount > 0 ? (
            <button type="button"
                    className="subtask-toggle"
                    onClick={(e) => { e.stopPropagation(); onToggleCollapse(); }}
                    title={collapsed
                      ? `Expand ${childCount} task${childCount === 1 ? "" : "s"}`
                      : `Collapse ${childCount} task${childCount === 1 ? "" : "s"}`}
                    aria-expanded={!collapsed}>
              <Icons.ChevronRt className={"subtask-toggle-chev" + (collapsed ? "" : " is-open")} size={14}/>
            </button>
          ) : (
            <span className="subtask-toggle subtask-toggle-spacer" aria-hidden="true"/>
          )}
          <span className="story-row-emoji" aria-hidden="true">📖</span>
          <span className="name-text story-row-title" title={story.title}>{story.title || "(Untitled story)"}</span>
          <span className={`pill pill-sm ${stCls} story-row-status`} title={`Story status · ${stLabel}`}>
            {stLabel}
          </span>
          {childCount > 0 && (
            <span className="story-row-progress" title={`${doneCount} of ${childCount} done`}>
              {doneCount}/{childCount}
            </span>
          )}
        </div>
      </td>
      <td colSpan={rightSpan} className="story-row-tail">
        {/* Approve / Request changes — pinned to the right of the row.
            Visible only when this user is a listed reviewer AND the
            story wants their attention. Spans the empty data columns
            (status / priority / due / sprint / pts / updated) so the
            buttons get full breathing room and never get clipped. */}
        {needsReview && (
          <div className="story-row-actions" onClick={(e) => e.stopPropagation()}>
            <button className="story-row-action-btn is-approve"
                    type="button"
                    disabled={acting}
                    onClick={approve}
                    title="Approve & mark done">
              <Icons.Check size={11}/> Approve
            </button>
            <button className="story-row-action-btn is-reject"
                    type="button"
                    disabled={acting || !inReview}
                    onClick={requestChanges}
                    title={inReview
                      ? "Request changes — reverts every Done child task back to To Do. Shift+click to skip the note."
                      : "Already in changes_req"}>
              <Icons.Close size={11}/> Request changes
            </button>
          </div>
        )}
      </td>

      {/* Note popover for Request changes — single click opens, ⌘/Ctrl
          + Enter submits, Esc cancels. Shift-clicking the button on the
          row bypasses this entirely (power-user shortcut). */}
      {reqNoteAnchor && typeof Popover !== "undefined" && (
        <Popover anchor={reqNoteAnchor} onClose={() => setReqNoteAnchor(null)}>
          <div style={{ padding: 10, minWidth: 280 }}
               onClick={(e) => e.stopPropagation()}>
            <div style={{ fontSize: 11, fontWeight: 700, letterSpacing: ".05em", textTransform: "uppercase", color: "var(--ink-muted)", marginBottom: 6 }}>
              Request changes
            </div>
            <div style={{ fontSize: 12, color: "var(--ink-body)", lineHeight: 1.4, marginBottom: 8 }}>
              Every Done child task reverts to To Do. Owners are notified.
            </div>
            <textarea
              autoFocus
              value={reqNoteText}
              onChange={(e) => setReqNoteText(e.target.value)}
              onKeyDown={(e) => {
                if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
                  e.preventDefault();
                  runRequestChanges(reqNoteText.trim());
                }
                if (e.key === "Escape") { e.preventDefault(); setReqNoteAnchor(null); }
              }}
              placeholder="What needs to change? (optional)"
              style={{
                width: "100%", minHeight: 56, padding: "7px 8px",
                border: "1px solid var(--border)", borderRadius: 6,
                fontFamily: "inherit", fontSize: 12.5, resize: "vertical",
                outline: "none",
              }}/>
            <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 8 }}>
              <span style={{ fontSize: 10.5, color: "var(--ink-faint)" }}>⌘/Ctrl + Enter to send</span>
              <div style={{ display: "flex", gap: 6 }}>
                <button type="button" className="btn"
                        onClick={() => setReqNoteAnchor(null)}>Cancel</button>
                <button type="button" className="btn"
                        disabled={acting}
                        onClick={() => runRequestChanges(reqNoteText.trim())}
                        style={{ background: "#e2445c", borderColor: "#c0223a", color: "white", fontWeight: 700 }}>
                  Request & revert
                </button>
              </div>
            </div>
          </div>
        </Popover>
      )}
    </tr>
  );
}

function EpicGroup({ epic, tasks, collapsed, onToggle, onUpdate, onOpen, onDelete, onUpdateEpic, onDeleteEpic, selected, onSelect, onAddTask, onAddSubtask, defaultSprint, hideSprintCol, hidden = EMPTY_SET, sprints,
                    onDragStart, onDragOverRow, onDropOnRow, dropTarget, draggingId, justDroppedId, showProjectPill, projectAccess, currentUserId,
                    onOpenStory, onToast }) {
  // Which parent's "+ Subtask" inline input is currently open. Single
  // active input at a time keeps the table tidy.
  const [subtaskFor, setSubtaskFor] = React.useState(null);

  // Per-parent expand/collapse for subtask groups. Persisted to
  // localStorage as a comma-list of parent ids that are CURRENTLY
  // COLLAPSED — defaulting to expanded keeps the "I want to see
  // everything" behavior for first-time users without clutter.
  const [collapsedParents, setCollapsedParents] = React.useState(() => {
    try {
      const raw = localStorage.getItem("fb.collapsedParents");
      if (!raw) return new Set();
      return new Set(JSON.parse(raw));
    } catch { return new Set(); }
  });
  function toggleParentCollapse(parentId) {
    setCollapsedParents(prev => {
      const next = new Set(prev);
      next.has(parentId) ? next.delete(parentId) : next.add(parentId);
      try { localStorage.setItem("fb.collapsedParents", JSON.stringify([...next])); } catch {}
      return next;
    });
  }

  // Render-bump counter that ticks whenever a story is created /
  // updated / deleted (either locally from a StoryRow's approve /
  // requestChanges handler, or from the SSE event bus when another
  // user changes a story we're showing). Included in the orderedRows
  // memo deps below so the table re-renders even though
  // window.USER_STORIES is mutated in place (React can't detect
  // mutation on its own). Previous behavior: Approve → toast said
  // "approved" → server changed status to done → but the story row
  // kept showing "Changes Requested" until full page reload.
  const [storyTick, setStoryTick] = React.useState(0);
  React.useEffect(() => {
    function onStoryUpdate() { setStoryTick(t => t + 1); }
    window.addEventListener("flowboard:rt:stories.updated", onStoryUpdate);
    window.addEventListener("flowboard:rt:stories.created", onStoryUpdate);
    window.addEventListener("flowboard:rt:stories.deleted", onStoryUpdate);
    return () => {
      window.removeEventListener("flowboard:rt:stories.updated", onStoryUpdate);
      window.removeEventListener("flowboard:rt:stories.created", onStoryUpdate);
      window.removeEventListener("flowboard:rt:stories.deleted", onStoryUpdate);
    };
  }, []);

  const done = tasks.filter(t => t.status === "done").length;
  const pct = tasks.length ? Math.round((done / tasks.length) * 100) : 0;
  const showSprint = !hideSprintCol && !hidden.has("sprint");
  // Build the visible column set so colspan + colgroup + headers stay in sync.
  const cols = [
    { id: "_grip",   w: 18 },
    { id: "_check",  w: 34 },
    { id: "_name",   w: null },
    { id: "owners",  w: 100, label: "Owner" },
    { id: "status",  w: 130, label: "Status" },
    { id: "prio",    w: 110, label: "Priority" },
    { id: "due",     w: 90,  label: "Due" },
    { id: "sprint",  w: 110, label: "Sprint" },
    { id: "points",  w: 70,  label: "Pts" },
    { id: "updated", w: 100, label: "Updated" },
  ].filter(c => {
    if (c.id === "sprint") return showSprint;
    if (c.id.startsWith("_")) return true;
    return !hidden.has(c.id);
  });
  const colspan = cols.length;
  const isNoEpic = epic.id === null;

  // ── Tristate "select all in this epic" checkbox ─────────────────
  // checked       — every task in this group is in `selected`
  // indeterminate — some but not all
  // unchecked     — none
  // Click toggles the whole group: select all when any unselected,
  // otherwise clear. We stop event propagation on the wrapper so
  // clicking the checkbox doesn't also fire the header's onToggle
  // (which would collapse the group right as you tried to use it).
  // Tree-order the visible tasks so each subtask renders directly
  // beneath its parent (instead of being scattered by sort/position
  // across the table). Orphans — subtasks whose parent isn't in the
  // visible set, e.g. the parent was filtered out — keep their
  // natural position so they don't disappear from the user's view.
  //
  // Per-parent collapse: if a parent's id is in `collapsedParents`,
  // its children are skipped from `orderedTasks` and the parent's
  // chevron will render in the "collapsed" rotation. Children are
  // counted regardless so the chevron can show a "(N)" hint.
  //
  // Nested subtasks: the recursion below walks the descendant tree
  // depth-first so a depth-3 task renders right under its depth-2
  // parent, which renders under depth-1, etc. The `haveKids` map
  // counts DIRECT children only (matches the existing tooltip text)
  // but a collapsed ancestor hides ALL descendants, not just direct
  // children — the depth-first walk skips the whole subtree when we
  // hit a collapsed parent. depth=4 is the cap from migration 039.
  // Build the visible row list — a mix of task rows and story rows
  // (synthetic, computed here). Stories appear inline as a parent row
  // and their child tasks are indented one level beneath, exactly how
  // a parent-task → subtask relationship renders. Empty-story buckets
  // are skipped so the table doesn't gain ghost rows for stories with
  // no tasks visible in this epic. Implements the office-workflow
  // request "bring user stories into the task table under their tasks".
  //
  // Row shape:
  //   { kind: 'task', task }                             — normal task
  //   { kind: 'story', story, childCount, doneCount }   — synthetic
  //
  // Collapse: story collapse uses the SAME `collapsedParents` set as
  // task subtasks. Key is "story_<id>" so the namespace can't collide
  // with task ids (which are "t_<hex>"). One ux-consistent toggle.
  const { orderedRows, hasChildren } = React.useMemo(() => {
    const ids = new Set(tasks.map(t => t.id));
    const tasksById = new Map(tasks.map(t => [t.id, t]));
    const childrenOf = new Map();
    for (const t of tasks) {
      if (t.parentTaskId && ids.has(t.parentTaskId)) {
        const arr = childrenOf.get(t.parentTaskId) || [];
        arr.push(t);
        childrenOf.set(t.parentTaskId, arr);
      }
    }

    // Effective story id — a subtask inherits its parent's story when
    // not set directly. Mirrors the server-side parent inheritance for
    // epic / sprint / story.
    function effectiveStoryOf(t) {
      if (t.userStoryId) return t.userStoryId;
      if (t.parentTaskId) {
        const p = tasksById.get(t.parentTaskId);
        if (p && p.userStoryId) return p.userStoryId;
      }
      return null;
    }

    // Bucket tasks by story id ("_none" = no story).
    const byStory = new Map();
    for (const t of tasks) {
      const sid = effectiveStoryOf(t) || "_none";
      if (!byStory.has(sid)) byStory.set(sid, []);
      byStory.get(sid).push(t);
    }

    const out = [];
    const haveKids = new Map();
    // Dedup guard. The previous version walked each story bucket AND
    // the orphan bucket independently, and a task that crossed bucket
    // boundaries (e.g. a task with `userStoryId` whose PARENT has no
    // story) would get emitted twice — once via its parent's recursion
    // in the orphan walk, and once as a story-root in the story walk.
    // The React reconciler complains with "two children with the same
    // key" and the row visibly renders twice. Tracking emitted ids
    // and short-circuiting prevents both bugs in one line.
    const emittedTaskIds = new Set();
    function emitTask(t, extraDepth) {
      if (emittedTaskIds.has(t.id)) return;
      emittedTaskIds.add(t.id);
      // Shallow-clone with the additive depth so we don't mutate the
      // shared task object (some tasks live in window.ALL_TASKS too).
      const view = extraDepth
        ? Object.assign({}, t, { depth: (t.depth || 0) + extraDepth })
        : t;
      out.push({ kind: 'task', task: view });
      const kids = childrenOf.get(t.id) || [];
      if (kids.length) haveKids.set(t.id, kids.length);
      if (collapsedParents.has(t.id)) return;
      for (const k of kids) emitTask(k, extraDepth);
    }

    // Determine which stories appear, preserving global USER_STORIES
    // order so reordering stories outside this table stays stable.
    const allStories = (typeof window !== "undefined" && Array.isArray(window.USER_STORIES))
      ? window.USER_STORIES : [];
    const presentStoryIds = new Set(Array.from(byStory.keys()).filter(k => k !== "_none"));
    const orderedStoryIds = [];
    const seen = new Set();
    for (const s of allStories) {
      if (presentStoryIds.has(s.id)) {
        orderedStoryIds.push(s.id);
        seen.add(s.id);
      }
    }
    // Defensive: stories that aren't in the global list (loaded yet)
    // still surface so their tasks aren't orphaned.
    for (const sid of presentStoryIds) {
      if (!seen.has(sid)) orderedStoryIds.push(sid);
    }

    // Emit story rows + their indented children.
    for (const sid of orderedStoryIds) {
      const story = allStories.find(s => s.id === sid)
        || { id: sid, title: "Story", status: "backlog", reviewers: [] };
      const stTasks = byStory.get(sid) || [];
      const stIds = new Set(stTasks.map(t => t.id));
      const doneCount = stTasks.filter(t => t.status === "done").length;
      out.push({
        kind: 'story',
        story,
        childCount: stTasks.length,
        doneCount,
      });
      // Track story child count too so the chevron's "(N)" hint works
      // identically to task-parent chevrons.
      if (stTasks.length) haveKids.set("story_" + sid, stTasks.length);
      if (collapsedParents.has("story_" + sid)) continue;
      // Emit only "root" tasks of this story — children of those tasks
      // come along via emitTask's recursion. A task is a root inside
      // this story if its parent isn't in the same story bucket.
      for (const t of stTasks) {
        if (t.parentTaskId && stIds.has(t.parentTaskId)) continue;
        emitTask(t, 1);  // +1 indent under the story row
      }
    }

    // Orphan (no-story) tasks — emit at the original depth so they
    // sit flush with the table left edge like before.
    const noneTasks = byStory.get("_none") || [];
    const noneIds = new Set(noneTasks.map(t => t.id));
    for (const t of noneTasks) {
      if (t.parentTaskId && noneIds.has(t.parentTaskId)) continue;
      emitTask(t, 0);
    }

    return { orderedRows: out, hasChildren: haveKids };
    // storyTick is included so a local Approve / Request-Changes call
    // (which mutates window.USER_STORIES in place and dispatches
    // flowboard:rt:stories.updated) actually re-runs this memo — React
    // can't otherwise notice that the global array was mutated.
  }, [tasks, collapsedParents, storyTick]);

  // Backwards-compat alias for existing references to orderedTasks
  // (selection counters, bulk-select, etc). Filters the row list down
  // to just the task entries so existing callers see the same shape.
  const orderedTasks = React.useMemo(
    () => orderedRows.filter(r => r.kind === 'task').map(r => r.task),
    [orderedRows]
  );

  const selectedInGroup = orderedTasks.reduce((n, t) => n + (selected.has(t.id) ? 1 : 0), 0);
  const allSelected  = orderedTasks.length > 0 && selectedInGroup === orderedTasks.length;
  const someSelected = selectedInGroup > 0 && !allSelected;
  const groupCheckRef = React.useRef(null);
  React.useEffect(() => {
    // React doesn't expose `indeterminate` as a JSX attribute; the
    // DOM property has to be set imperatively after every render.
    if (groupCheckRef.current) groupCheckRef.current.indeterminate = someSelected;
  }, [someSelected, allSelected, tasks.length]);
  function onToggleGroup(e) {
    e.stopPropagation();
    const next = !allSelected;   // any state other than "all" → select all
    for (const t of orderedTasks) {
      // Only flip rows whose state actually needs to change so we
      // don't re-fire onSelect for already-correct rows.
      if (selected.has(t.id) !== next) onSelect(t.id, next);
    }
  }

  return (
    <div className={`epic-group ${collapsed ? "is-collapsed" : ""} ${isNoEpic ? "is-no-epic" : ""}`}
         style={{ "--epic-color": epic.color }}>
      <div className="epic-header" onClick={onToggle}>
        <Icons.ChevronDn className="epic-chevron"/>
        <label className="epic-select-wrap"
               title={allSelected ? "Clear selection" : `Select all ${tasks.length} tasks in "${epic.title}"`}
               onClick={(e) => e.stopPropagation()}>
          <input
            ref={groupCheckRef}
            type="checkbox"
            className="epic-select"
            checked={allSelected}
            disabled={tasks.length === 0}
            onChange={onToggleGroup}
            aria-label={`Select all tasks in ${epic.title}`}/>
        </label>
        <span className="epic-title" style={isNoEpic ? { fontStyle: "italic", color: "#676879" } : null}>
          {epic.title}
        </span>
        <span className="epic-count">
          {tasks.length} task{tasks.length === 1 ? "" : "s"}
          {selectedInGroup > 0 && (
            <span className="epic-selected-count"> · {selectedInGroup} selected</span>
          )}
        </span>
        {!isNoEpic && (
          <div className="epic-progress">
            <StatusSummary tasks={tasks}/>
            <span style={{ fontWeight: 600, color: epic.color }}>{pct}%</span>
          </div>
        )}
        {/* Edit / Delete kebab — only on real epics (not the synthetic
            "No epic" group), and only when the parent has wired the
            handlers (i.e. the user has edit access on the project). */}
        {!isNoEpic && (onUpdateEpic || onDeleteEpic) && (
          <EpicMenu epic={epic} taskCount={tasks.length}
                    onRename={onUpdateEpic ? (title) => onUpdateEpic(epic.id, { title }) : null}
                    onColor={onUpdateEpic ? (color) => onUpdateEpic(epic.id, { color }) : null}
                    onDelete={onDeleteEpic ? () => onDeleteEpic(epic.id) : null}/>
        )}
      </div>
      <div className="table-wrap">
        <table className="t">
          <colgroup>
            {cols.map(c => <col key={c.id} style={c.w ? { width: c.w } : null}/>)}
          </colgroup>
          <thead>
            <tr>
              {cols.map(c => (
                c.id === "_grip" ? <th key={c.id}></th>
                : c.id === "_check" ? <th key={c.id}></th>
                : c.id === "_name" ? <th key={c.id}>Task</th>
                : <th key={c.id} className="th-center">{c.label}</th>
              ))}
            </tr>
          </thead>
          <tbody>
            {orderedRows.map((row) => {
              if (row.kind === 'story') {
                const sid = row.story.id;
                return (
                  <StoryRow
                    key={"story_" + sid}
                    story={row.story}
                    childCount={row.childCount}
                    doneCount={row.doneCount}
                    colspan={colspan}
                    collapsed={collapsedParents.has("story_" + sid)}
                    onToggleCollapse={() => toggleParentCollapse("story_" + sid)}
                    onOpenStory={onOpenStory}
                    onUpdate={onUpdate}
                    currentUserId={currentUserId}
                    onToast={onToast}/>
                );
              }
              const t = row.task;
              return (
                <React.Fragment key={t.id}>
                  <TaskRow task={t}
                           onUpdate={onUpdate} onOpen={onOpen} onDelete={onDelete}
                           onSelect={onSelect} selected={selected.has(t.id)}
                           hideSprintCol={hideSprintCol} hidden={hidden}
                           sprints={sprints}
                           onDragStart={onDragStart}
                           onDragOverRow={onDragOverRow}
                           onDropOnRow={onDropOnRow}
                           dropIndicator={dropTarget?.id === t.id ? dropTarget.side : null}
                           isDragging={draggingId === t.id}
                           isJustDropped={justDroppedId === t.id}
                           showProjectPill={showProjectPill}
                           projectAccess={projectAccess}
                           currentUserId={currentUserId}
                           onAddSubtask={onAddSubtask ? (id) => setSubtaskFor(id) : null}
                           childCount={hasChildren.get(t.id) || 0}
                           collapsed={collapsedParents.has(t.id)}
                           onToggleCollapse={toggleParentCollapse}/>
                  {/* Inline "+ Subtask" composer — only when this row's
                      button has been clicked. Saving fires onAddSubtask
                      with the parent's id; the input closes either way
                      (submit OR blur). */}
                  {subtaskFor === t.id && (
                    <SubtaskAddRow
                      parentName={t.name}
                      colspan={colspan}
                      onAdd={(name) => {
                        if (onAddSubtask) onAddSubtask({ name, parent_task_id: t.id, parentTask: t });
                        setSubtaskFor(null);
                      }}
                      onCancel={() => setSubtaskFor(null)}/>
                  )}
                </React.Fragment>
              );
            })}
            {/* Render the live "+ Add task" composer ONLY when an
                onAddTask handler is wired. The previous fallback
                rendered a static, non-clickable "+ Add task" pseudo-row
                whenever the parent (e.g. MyTasksView on Home) passed
                onAddTask=null, which looked like a real button but did
                nothing — confusing affordance. Hiding it entirely is
                cleaner; tasks should be added from the project view
                where the project / epic context is set. */}
            {onAddTask && (
              <AddTaskRow onAdd={(p) => onAddTask({ ...p, epicId: epic.id })}
                          defaultSprint={defaultSprint}
                          colspan={colspan}
                          placeholder={isNoEpic ? "Add a quick task…" : "Add task (press Enter)…"}/>
            )}
          </tbody>
        </table>
      </div>
    </div>
  );
}

// ──────────────────────────────────────────────────────────────────
// StoryGroup — alternative grouping where each row sits under the
// user-story it belongs to. The header doubles as the review surface:
// status pill, reviewer avatars, and inline Approve / Request-changes
// buttons (visible only to listed reviewers when status is review).
//
// Mirrors EpicGroup's internal layout (drag/drop, subtask collapse,
// add-task row) so switching grouping doesn't lose any per-row
// affordances. The only material difference is the header content.
// ──────────────────────────────────────────────────────────────────
function StoryGroup({ story, tasks, collapsed, onToggle, onUpdate, onOpen, onDelete, selected, onSelect, onAddTask, onAddSubtask, defaultSprint, hideSprintCol, hidden = EMPTY_SET, sprints,
                     onDragStart, onDragOverRow, onDropOnRow, dropTarget, draggingId, justDroppedId, showProjectPill, projectAccess, currentUserId, onOpenStory, onToast }) {
  const [subtaskFor, setSubtaskFor] = React.useState(null);
  const [collapsedParents, setCollapsedParents] = React.useState(() => {
    try {
      const raw = localStorage.getItem("fb.collapsedParents");
      if (!raw) return new Set();
      return new Set(JSON.parse(raw));
    } catch { return new Set(); }
  });
  function toggleParentCollapse(parentId) {
    setCollapsedParents(prev => {
      const next = new Set(prev);
      next.has(parentId) ? next.delete(parentId) : next.add(parentId);
      try { localStorage.setItem("fb.collapsedParents", JSON.stringify([...next])); } catch {}
      return next;
    });
  }
  const done = tasks.filter(t => t.status === "done").length;
  const pct = tasks.length ? Math.round((done / tasks.length) * 100) : 0;
  const showSprint = !hideSprintCol && !hidden.has("sprint");
  const cols = [
    { id: "_grip",   w: 18 },
    { id: "_check",  w: 34 },
    { id: "_name",   w: null },
    { id: "owners",  w: 100, label: "Owner" },
    { id: "status",  w: 130, label: "Status" },
    { id: "prio",    w: 110, label: "Priority" },
    { id: "due",     w: 90,  label: "Due" },
    { id: "sprint",  w: 110, label: "Sprint" },
    { id: "points",  w: 70,  label: "Pts" },
    { id: "updated", w: 100, label: "Updated" },
  ].filter(c => {
    if (c.id === "sprint") return showSprint;
    if (c.id.startsWith("_")) return true;
    return !hidden.has(c.id);
  });
  const colspan = cols.length;
  const isNoStory = !story;

  // ── Tree-order tasks (parent then children, with collapse) ──
  const { orderedTasks, hasChildren } = React.useMemo(() => {
    const ids = new Set(tasks.map(t => t.id));
    const childrenOf = new Map();
    for (const t of tasks) {
      if (t.parentTaskId && ids.has(t.parentTaskId)) {
        const arr = childrenOf.get(t.parentTaskId) || [];
        arr.push(t);
        childrenOf.set(t.parentTaskId, arr);
      }
    }
    // Same nested-subtask depth-first emit as EpicGroup — collapsing
    // a parent hides its full descendant subtree.
    const out = [];
    const haveKids = new Map();
    function emit(t) {
      out.push(t);
      const kids = childrenOf.get(t.id) || [];
      if (kids.length) haveKids.set(t.id, kids.length);
      if (collapsedParents.has(t.id)) return;
      for (const k of kids) emit(k);
    }
    for (const t of tasks) {
      if (t.parentTaskId && ids.has(t.parentTaskId)) continue;
      emit(t);
    }
    return { orderedTasks: out, hasChildren: haveKids };
  }, [tasks, collapsedParents]);

  const selectedInGroup = orderedTasks.reduce((n, t) => n + (selected.has(t.id) ? 1 : 0), 0);
  const allSelected  = orderedTasks.length > 0 && selectedInGroup === orderedTasks.length;
  const someSelected = selectedInGroup > 0 && !allSelected;
  const groupCheckRef = React.useRef(null);
  React.useEffect(() => {
    if (groupCheckRef.current) groupCheckRef.current.indeterminate = someSelected;
  }, [someSelected, allSelected, tasks.length]);
  function onToggleGroup(e) {
    e.stopPropagation();
    const next = !allSelected;
    for (const t of orderedTasks) {
      if (selected.has(t.id) !== next) onSelect(t.id, next);
    }
  }

  // ── Story-specific bits ─────────────────────────────────────────
  const stMeta = story
    ? ((typeof STORY_STATUSES !== "undefined" ? STORY_STATUSES : []).find(x => x.id === story.status)
        || { id: "backlog", label: "Backlog", cls: "pill-backlog" })
    : null;
  const reviewerIds = (story && Array.isArray(story.reviewers)) ? story.reviewers : [];
  const isReviewer  = !!(story && currentUserId && reviewerIds.includes(currentUserId));
  const inReview    = !!(story && story.status === "review");
  const wantsAttn   = !!(story && (story.status === "review" || story.status === "changes_req"));
  const needsMyReview = isReviewer && wantsAttn;

  // Approve / Request-changes flow — wired straight to api.stories so
  // the user never leaves the table to sign off. Optimistic status
  // update so the pill flips immediately; SSE patches it back into
  // shape if the server says otherwise.
  const [acting, setActing] = React.useState(false);
  async function approve() {
    if (!story || acting) return;
    setActing(true);
    try {
      await window.api.stories.approve(story.id);
      onToast && onToast("Approved — thanks for the sign-off.");
    } catch (e) {
      const msg = (e && e.body && (e.body.message || e.body.error)) || (e && e.message) || "network error";
      onToast && onToast("Couldn't approve: " + msg, 4500);
    } finally { setActing(false); }
  }
  // Two-flavor entry-point. The button now opens a small inline note
  // popover instead of a blocking window.prompt. The async runner is
  // shared. Empty note = "no comment, just revert"; Enter submits.
  const [reqNoteAnchor, setReqNoteAnchor] = React.useState(null);
  const [reqNoteText, setReqNoteText] = React.useState("");
  async function runRequestChanges(note) {
    if (!story || acting) return;
    setActing(true);
    setReqNoteAnchor(null);
    try {
      await window.api.stories.requestChanges(story.id, note || "");
      onToast && onToast(
        note
          ? "Changes requested — child tasks reverted, owners notified."
          : "Changes requested — child tasks reverted to To Do.",
        4000
      );
    } catch (e) {
      const msg = (e && e.body && (e.body.message || e.body.error)) || (e && e.message) || "network error";
      onToast && onToast("Couldn't request changes: " + msg, 4500);
    } finally { setActing(false); setReqNoteText(""); }
  }
  function requestChanges(e) {
    if (!story || acting) return;
    // Click without modifier: open the note popover.
    // Hold shift while clicking: skip the note, send immediately. Power-user shortcut.
    if (e && e.shiftKey) { runRequestChanges(""); return; }
    setReqNoteText("");
    setReqNoteAnchor(e ? e.currentTarget : null);
  }

  // Yellow accent on the entire group when this user has a pending
  // sign-off — keeps the visual call-to-action without forcing a
  // separate Reviews tab.
  const groupClass = "epic-group story-group"
    + (collapsed ? " is-collapsed" : "")
    + (isNoStory ? " is-no-epic" : "")
    + (needsMyReview ? " needs-review" : "");
  const groupColor = story ? "#d97706" : "#a3a8b6";  // amber for stories, grey for "no story"

  return (
    <div className={groupClass} style={{ "--epic-color": groupColor }}>
      <div className="epic-header" onClick={onToggle} style={needsMyReview ? { borderLeft: "3px solid #f59e0b" } : null}>
        <Icons.ChevronDn className="epic-chevron"/>
        <label className="epic-select-wrap"
               title={allSelected ? "Clear selection" : `Select all ${tasks.length} tasks in this story`}
               onClick={(e) => e.stopPropagation()}>
          <input
            ref={groupCheckRef}
            type="checkbox"
            className="epic-select"
            checked={allSelected}
            disabled={tasks.length === 0}
            onChange={onToggleGroup}
            aria-label={`Select all tasks in ${story ? story.title : "no story"}`}/>
        </label>
        <span className="epic-title"
              style={isNoStory ? { fontStyle: "italic", color: "#676879" } : { display: "inline-flex", alignItems: "center", gap: 8 }}
              onClick={(e) => {
                if (story && onOpenStory) { e.stopPropagation(); onOpenStory(story.id); }
              }}>
          {!isNoStory && <span aria-hidden="true">📖</span>}
          {story ? story.title : "No story"}
        </span>
        {/* Status pill (lives where the epic colour-pct sits) */}
        {story && stMeta && (
          <span className={`pill pill-sm ${stMeta.cls}`} style={{ marginLeft: 4 }} title={`Story status · ${stMeta.label}`}>
            {stMeta.label}
          </span>
        )}
        {/* Reviewer avatars — small, with tooltip listing names. Helps
            non-reviewers see who has the call without opening the drawer. */}
        {story && reviewerIds.length > 0 && typeof AvatarStack !== "undefined" && (
          <span title={`Reviewers: ${reviewerIds.map(id => (PEOPLE.find(p => p.id === id)?.name) || id).join(", ")}`}
                style={{ display: "inline-flex", alignItems: "center" }}
                onClick={(e) => e.stopPropagation()}>
            <AvatarStack ids={reviewerIds} max={3} size="sm"/>
          </span>
        )}
        <span className="epic-count">
          {tasks.length} task{tasks.length === 1 ? "" : "s"}
          {selectedInGroup > 0 && (
            <span className="epic-selected-count"> · {selectedInGroup} selected</span>
          )}
        </span>
        {!isNoStory && (
          <div className="epic-progress">
            <StatusSummary tasks={tasks}/>
            <span style={{ fontWeight: 600, color: groupColor }}>{pct}%</span>
          </div>
        )}
        {/* Inline review actions — only when this user is a listed
            reviewer AND the story is at review/changes_req. Hidden
            otherwise to keep non-reviewer rows clean. */}
        {needsMyReview && (
          <div onClick={(e) => e.stopPropagation()}
               style={{ display: "inline-flex", gap: 6, marginLeft: "auto" }}>
            <button className="btn"
                    disabled={acting}
                    onClick={approve}
                    title="Approve this story for sign-off"
                    style={{ background: "rgba(34,197,94,.10)", borderColor: "rgba(34,197,94,.35)", color: "#166534", fontWeight: 600 }}>
              <Icons.Check size={12}/> Approve
            </button>
            <button className="btn"
                    disabled={acting || !inReview}
                    onClick={requestChanges}
                    title={inReview
                      ? "Request changes — reverts every Done child task back to To Do. Hold Shift + click to skip the note."
                      : "Already in changes_req"}
                    style={{ background: "rgba(226,68,92,.10)", borderColor: "rgba(226,68,92,.35)", color: "#8a1024", fontWeight: 600 }}>
              <Icons.Close size={12}/> Request changes
            </button>
          </div>
        )}
        {/* Note popover for Request changes — inline replacement for
            the old blocking window.prompt. Empty note + Enter sends
            with no message; typed note + Enter sends with it. Shift+
            clicking the button bypasses this entirely. */}
        {reqNoteAnchor && typeof Popover !== "undefined" && (
          <Popover anchor={reqNoteAnchor} onClose={() => setReqNoteAnchor(null)}>
            <div style={{ padding: 10, minWidth: 280 }}
                 onClick={(e) => e.stopPropagation()}>
              <div style={{ fontSize: 11, fontWeight: 700, letterSpacing: ".05em", textTransform: "uppercase", color: "var(--ink-muted)", marginBottom: 6 }}>
                Request changes
              </div>
              <div style={{ fontSize: 12, color: "var(--ink-body)", lineHeight: 1.4, marginBottom: 8 }}>
                Every Done child task reverts to To Do. Owners are
                notified with this note (or just "changes requested" if
                you leave it blank).
              </div>
              <textarea
                autoFocus
                value={reqNoteText}
                onChange={(e) => setReqNoteText(e.target.value)}
                onKeyDown={(e) => {
                  if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
                    e.preventDefault();
                    runRequestChanges(reqNoteText.trim());
                  }
                  if (e.key === "Escape") {
                    e.preventDefault();
                    setReqNoteAnchor(null);
                  }
                }}
                placeholder="What needs to change? (optional)"
                style={{
                  width: "100%", minHeight: 56, padding: "7px 8px",
                  border: "1px solid var(--border)", borderRadius: 6,
                  fontFamily: "inherit", fontSize: 12.5, resize: "vertical",
                  outline: "none",
                }}/>
              <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 8 }}>
                <span style={{ fontSize: 10.5, color: "var(--ink-faint)" }}>
                  ⌘/Ctrl + Enter to send
                </span>
                <div style={{ display: "flex", gap: 6 }}>
                  <button type="button" className="btn"
                          onClick={() => setReqNoteAnchor(null)}>Cancel</button>
                  <button type="button" className="btn"
                          disabled={acting}
                          onClick={() => runRequestChanges(reqNoteText.trim())}
                          style={{ background: "#e2445c", borderColor: "#c0223a", color: "white", fontWeight: 700 }}>
                    Request & revert
                  </button>
                </div>
              </div>
            </div>
          </Popover>
        )}
      </div>
      <div className="table-wrap">
        <table className="t">
          <colgroup>
            {cols.map(c => <col key={c.id} style={c.w ? { width: c.w } : null}/>)}
          </colgroup>
          <thead>
            <tr>
              {cols.map(c => (
                c.id === "_grip" ? <th key={c.id}></th>
                : c.id === "_check" ? <th key={c.id}></th>
                : c.id === "_name" ? <th key={c.id}>Task</th>
                : <th key={c.id} className="th-center">{c.label}</th>
              ))}
            </tr>
          </thead>
          <tbody>
            {orderedTasks.map(t => (
              <React.Fragment key={t.id}>
                <TaskRow task={t}
                         onUpdate={onUpdate} onOpen={onOpen} onDelete={onDelete}
                         onSelect={onSelect} selected={selected.has(t.id)}
                         hideSprintCol={hideSprintCol} hidden={hidden}
                         sprints={sprints}
                         onDragStart={onDragStart}
                         onDragOverRow={onDragOverRow}
                         onDropOnRow={onDropOnRow}
                         dropIndicator={dropTarget?.id === t.id ? dropTarget.side : null}
                         isDragging={draggingId === t.id}
                         isJustDropped={justDroppedId === t.id}
                         showProjectPill={showProjectPill}
                         projectAccess={projectAccess}
                         currentUserId={currentUserId}
                         onAddSubtask={onAddSubtask ? (id) => setSubtaskFor(id) : null}
                         childCount={hasChildren.get(t.id) || 0}
                         collapsed={collapsedParents.has(t.id)}
                         onToggleCollapse={toggleParentCollapse}/>
                {subtaskFor === t.id && (
                  <SubtaskAddRow
                    parentName={t.name}
                    colspan={colspan}
                    onAdd={(name) => {
                      if (onAddSubtask) onAddSubtask({ name, parent_task_id: t.id, parentTask: t });
                      setSubtaskFor(null);
                    }}
                    onCancel={() => setSubtaskFor(null)}/>
                )}
              </React.Fragment>
            ))}
            {/* Live add-task composer — pre-links the new task to this
                story. Hidden when no onAddTask handler is wired (same
                rationale as EpicGroup above: the static fallback row
                looked like a real button on read-only views like
                MyTasks but did nothing). */}
            {onAddTask && (
              <AddTaskRow
                onAdd={(p) => onAddTask({ ...p, userStoryId: story ? story.id : null })}
                defaultSprint={defaultSprint}
                colspan={colspan}
                placeholder={isNoStory ? "Add a quick task (no story)…" : `Add task to "${story.title}"…`}/>
            )}
          </tbody>
        </table>
      </div>
    </div>
  );
}

// Inline "+ Subtask" composer row — rendered directly under a parent
// task by EpicGroup when the user hits the parent row's "+ Subtask"
// button. Visually mirrors AddTaskRow but with the subtask indent +
// ↳ glyph so users see they're typing into a child slot.
function SubtaskAddRow({ onAdd, onCancel, colspan = 9, parentName = "" }) {
  const [name, setName] = React.useState("");
  const inputRef = React.useRef(null);
  React.useEffect(() => {
    setTimeout(() => inputRef.current?.focus(), 30);
  }, []);
  function submit() {
    const n = name.trim();
    if (!n) { onCancel && onCancel(); return; }
    onAdd(n);
  }
  return (
    <tr className="add-row is-subtask-add-row">
      <td colSpan={colspan} style={{ paddingLeft: 28 }}>
        <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
          <span className="subtask-glyph" aria-hidden="true">↳</span>
          <input
            ref={inputRef}
            value={name}
            onChange={(e) => setName(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === "Enter") { e.preventDefault(); submit(); }
              if (e.key === "Escape") { e.preventDefault(); onCancel && onCancel(); }
            }}
            onBlur={submit}
            placeholder={parentName ? `New subtask of "${parentName}" — press Enter` : "New subtask — press Enter"}
            style={{
              flex: 1, border: "none", outline: "none", background: "transparent",
              font: "inherit", color: "inherit", padding: 0,
            }}
          />
        </div>
      </td>
    </tr>
  );
}

function AddTaskRow({ onAdd, defaultSprint, colspan = 9, placeholder = "Add task (press Enter)…" }) {
  const [name, setName] = React.useState("");
  const submit = () => {
    const n = name.trim();
    if (!n) return;
    onAdd({ name: n, sprint: defaultSprint });
    setName("");
  };
  return (
    <tr className="add-row">
      <td colSpan={colspan}>
        <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
          <Icons.Plus size={14}/>
          <input
            value={name}
            onChange={(e) => setName(e.target.value)}
            onKeyDown={(e) => { if (e.key === "Enter") submit(); }}
            onBlur={submit}
            placeholder={placeholder}
            style={{
              flex: 1, border: "none", outline: "none", background: "transparent",
              font: "inherit", color: "inherit", padding: 0,
            }}
          />
        </div>
      </td>
    </tr>
  );
}

function TableView({ tasks, epics, collapsed, onToggle, onUpdate, onMove, onReparent, onOpen, onDelete, onUpdateEpic, onDeleteEpic, selected, onSelect, onAddTask, onAddSubtask, defaultSprint, hideSprintCol, hidden, sprints, showProjectPill, projectAccess, currentUserId, groupBy = "epic", reviewMine = false, onOpenStory, onToast }) {
  // Stories now render as inline rows inside their epic group (see
  // EpicGroup's orderedRows memo). The legacy `groupBy === "story"`
  // section-header layout is dead code kept below for emergency
  // rollback, but never reached — we force the dispatch to "epic" so
  // every project view shows the unified hierarchy:
  //   Epic header → Story row → indented child tasks
  // The toolbar's old "By story" toggle was dropped in lockstep.
  groupBy = "epic";
  // If a task's epic isn't in the active project's `epics` (e.g. cross-project
  // epic, or the epic list hasn't been populated for this project), synthesize
  // an epic group from the task's own epicTitle/epicColor so the task still
  // renders. Without this fallback, such tasks silently disappear from the
  // table — they show up in counts and in Kanban (which doesn't group by
  // epic) but never in Tasks/Sprint/Backlog tabs.
  const knownEpicIds = new Set(epics.map(e => e.id));
  const orphanEpics = {};
  for (const t of tasks) {
    if (t.epicId && !knownEpicIds.has(t.epicId)) {
      if (!orphanEpics[t.epicId]) {
        orphanEpics[t.epicId] = {
          id: t.epicId,
          title: t.epicTitle || "Untitled epic",
          color: t.epicColor || "#a25ddc",
        };
      }
    }
  }
  const groups = [...epics, ...Object.values(orphanEpics), NO_EPIC];
  const hiddenSet = hidden instanceof Set ? hidden : new Set(hidden || []);
  const anyTasks = tasks.length > 0;

  const [dropTarget, setDropTarget] = React.useState(null); // { id, side }
  const [draggingId, setDraggingId] = React.useState(null);
  const [justDroppedId, setJustDroppedId] = React.useState(null);
  const draggingRef = React.useRef(null);
  const flashTimer = React.useRef(null);

  function endDrag() {
    setDropTarget(null);
    setDraggingId(null);
    draggingRef.current = null;
  }
  function celebrate(id) {
    setJustDroppedId(id);
    if (flashTimer.current) clearTimeout(flashTimer.current);
    flashTimer.current = setTimeout(() => {
      setJustDroppedId(j => j === id ? null : j);
      flashTimer.current = null;
    }, 700);
  }
  function handleDragStart(task) {
    draggingRef.current = task;
    setDraggingId(task.id);
    setDropTarget(null);
  }
  function handleDragOverRow(task, side) {
    if (draggingId === task.id) return;
    setDropTarget({ id: task.id, side });
  }
  function handleDropOnRow(e, target, side) {
    const sourceId = e.dataTransfer.getData("text/plain") || draggingRef.current?.id;
    endDrag();
    if (!sourceId || sourceId === target.id) return;
    const source = tasks.find(t => t.id === sourceId);
    if (!source) return;

    // ── "into" — drag-to-reparent. Make the dragged task a subtask
    // of the drop target. Cycle protection: target must not currently
    // be a (recursive) subtask of source. We walk up target's parent
    // chain to be safe — the loop bounds prevent infinite recursion
    // even on bad data.
    if (side === "into") {
      // Already a subtask of this target — no-op.
      if (source.parentTaskId === target.id) return;
      let cursor = target.parentTaskId;
      let cycle = false;
      for (let i = 0; i < 16 && cursor; i++) {
        if (cursor === source.id) { cycle = true; break; }
        const parent = tasks.find(t => t.id === cursor);
        cursor = parent ? parent.parentTaskId : null;
      }
      if (cycle) {
        celebrate(sourceId);
        return;
      }
      onReparent && onReparent(sourceId, target.id);
      celebrate(sourceId);
      return;
    }

    // ── above / below — existing reorder behavior. We also strip
    // the parentTaskId if the user drags a subtask out next to a
    // non-subtask peer (effectively "promote") because the new
    // sibling neighborhood implies top-level placement.
    const colTasks = tasks.filter(t => t.status === target.status);
    const colIds = colTasks.map(t => t.id).filter(id => id !== sourceId);
    let idx = colIds.indexOf(target.id);
    if (idx === -1) idx = colIds.length;
    if (side === "below") idx += 1;
    const body = { position: idx };
    if (source.status !== target.status) body.status = target.status;
    if ((source.epicId || null) !== (target.epicId || null)) body.epic_id = target.epicId || null;
    // If target is a top-level task, make sure source is also
    // top-level (promotes a dragged subtask to root).
    if (!target.parentTaskId && source.parentTaskId) {
      onReparent && onReparent(sourceId, null);
    } else if (target.parentTaskId && source.parentTaskId !== target.parentTaskId) {
      // Dragged onto an existing subtask — share the same parent.
      onReparent && onReparent(sourceId, target.parentTaskId);
    }
    onMove && onMove(sourceId, body);
    celebrate(sourceId);
  }

  return (
    <div className="board-scroll"
         onDragEnd={endDrag}>
      {!anyTasks && (
        <div style={{
          margin: "40px auto", maxWidth: 420, textAlign: "center",
          color: "var(--ink-muted)", padding: "32px 20px",
          border: "1px dashed var(--border-strong)", borderRadius: 10, background: "white",
        }}>
          <div style={{ fontSize: 32, marginBottom: 8 }}>🔍</div>
          <div style={{ fontWeight: 600, color: "var(--ink-body)", marginBottom: 4 }}>No tasks match your filters</div>
          <div style={{ fontSize: 13 }}>Try clearing search, person, or status/priority filters.</div>
        </div>
      )}
      {/* Lookup map for parent inheritance — so a subtask whose own
          epic_id is null still groups under its parent's epic, and
          ends up rendered directly beneath the parent row instead
          of stacked at the bottom in "Quick tasks". */}
      {(() => null)()}

      {/* Story grouping — alternative layout where each row sits under
          its user-story parent. Story headers double as the review
          sign-off surface (Approve / Request changes inline) so the
          team doesn't have to bounce to a separate Reviews tab. */}
      {groupBy === "story" ? (() => {
        const allStories = (typeof window !== "undefined" && Array.isArray(window.USER_STORIES))
          ? window.USER_STORIES : [];
        const tasksById = new Map(tasks.map(t => [t.id, t]));
        // Subtasks inherit the parent's story when not set directly
        // (mirrors the server-side parent inheritance for epic/sprint).
        const effectiveStoryOf = (t) => {
          if (t.userStoryId) return t.userStoryId;
          if (t.parentTaskId) {
            const p = tasksById.get(t.parentTaskId);
            if (p && p.userStoryId) return p.userStoryId;
          }
          return null;
        };
        // Build story-id → tasks
        const buckets = new Map();
        for (const t of tasks) {
          const sid = effectiveStoryOf(t);
          const arr = buckets.get(sid || "_none") || [];
          arr.push(t);
          buckets.set(sid || "_none", arr);
        }
        // Order: stories that exist in this project first, then "No story"
        const projectStories = allStories.filter(s => buckets.has(s.id));
        const noneTasks = buckets.get("_none") || [];
        const elements = [];
        for (const s of projectStories) {
          const stTasks = buckets.get(s.id) || [];
          if (reviewMine) {
            // Filter to stories where currentUserId is in reviewers AND
            // the story is at a stage that wants their attention.
            const isReviewer = Array.isArray(s.reviewers) && currentUserId && s.reviewers.includes(currentUserId);
            const wantsAttention = s.status === "review" || s.status === "changes_req" || s.status === "in_progress";
            if (!(isReviewer && wantsAttention)) continue;
          }
          elements.push(
            <StoryGroup key={s.id} story={s} tasks={stTasks}
                        collapsed={collapsed.has("story_" + s.id)}
                        onToggle={() => onToggle("story_" + s.id)}
                        onUpdate={onUpdate} onOpen={onOpen} onDelete={onDelete}
                        selected={selected} onSelect={onSelect}
                        onAddTask={onAddTask} onAddSubtask={onAddSubtask}
                        defaultSprint={defaultSprint}
                        hideSprintCol={hideSprintCol} hidden={hiddenSet}
                        sprints={sprints}
                        onDragStart={handleDragStart}
                        onDragOverRow={handleDragOverRow}
                        onDropOnRow={handleDropOnRow}
                        dropTarget={dropTarget}
                        draggingId={draggingId}
                        justDroppedId={justDroppedId}
                        showProjectPill={showProjectPill}
                        projectAccess={projectAccess}
                        currentUserId={currentUserId}
                        onOpenStory={onOpenStory}
                        onToast={onToast}/>
          );
        }
        // "No story" tail bucket — only when not filtering to "my reviews".
        if (!reviewMine && noneTasks.length > 0) {
          elements.push(
            <StoryGroup key="_no_story" story={null} tasks={noneTasks}
                        collapsed={collapsed.has("story__none")}
                        onToggle={() => onToggle("story__none")}
                        onUpdate={onUpdate} onOpen={onOpen} onDelete={onDelete}
                        selected={selected} onSelect={onSelect}
                        onAddTask={onAddTask} onAddSubtask={onAddSubtask}
                        defaultSprint={defaultSprint}
                        hideSprintCol={hideSprintCol} hidden={hiddenSet}
                        sprints={sprints}
                        onDragStart={handleDragStart}
                        onDragOverRow={handleDragOverRow}
                        onDropOnRow={handleDropOnRow}
                        dropTarget={dropTarget}
                        draggingId={draggingId}
                        justDroppedId={justDroppedId}
                        showProjectPill={showProjectPill}
                        projectAccess={projectAccess}
                        currentUserId={currentUserId}
                        onOpenStory={onOpenStory}
                        onToast={onToast}/>
          );
        }
        if (elements.length === 0) {
          return (
            <div style={{
              margin: "32px auto", maxWidth: 460, textAlign: "center",
              color: "var(--ink-muted)", padding: "28px 20px",
              border: "1px dashed var(--border-strong)", borderRadius: 10, background: "white",
            }}>
              <div style={{ fontSize: 30, marginBottom: 8 }}>📖</div>
              <div style={{ fontWeight: 600, color: "var(--ink-body)", marginBottom: 4 }}>
                {reviewMine ? "No stories waiting on you" : "No user stories yet"}
              </div>
              <div style={{ fontSize: 13 }}>
                {reviewMine
                  ? "When a story moves to review and you're a listed reviewer, it'll show up here."
                  : "Create one with the New story button — group related tasks under a single review surface."}
              </div>
            </div>
          );
        }
        return elements;
      })() : groups.map(epic => {
        const tasksById = new Map(tasks.map(t => [t.id, t]));
        const effectiveEpicOf = (t) => {
          if (t.parentTaskId) {
            const p = tasksById.get(t.parentTaskId);
            if (p) return p.epicId || null;
          }
          return t.epicId || null;
        };
        const epicTasks = tasks.filter(t => effectiveEpicOf(t) === epic.id);
        // Always show named epics; only show "No epic" group if it has tasks OR add-task is enabled
        if (!epicTasks.length && epic.id !== null) return null;
        if (!epicTasks.length && epic.id === null && !onAddTask) return null;
        return (
          <EpicGroup key={epic.id || "_none"} epic={epic} tasks={epicTasks}
                     collapsed={collapsed.has(epic.id || "_none")}
                     onToggle={() => onToggle(epic.id || "_none")}
                     onUpdate={onUpdate} onOpen={onOpen} onDelete={onDelete}
                     onUpdateEpic={onUpdateEpic} onDeleteEpic={onDeleteEpic}
                     selected={selected} onSelect={onSelect}
                     onAddTask={onAddTask} onAddSubtask={onAddSubtask}
                     defaultSprint={defaultSprint}
                     hideSprintCol={hideSprintCol} hidden={hiddenSet}
                     sprints={sprints}
                     onDragStart={handleDragStart}
                     onDragOverRow={handleDragOverRow}
                     onDropOnRow={handleDropOnRow}
                     dropTarget={dropTarget}
                     draggingId={draggingId}
                     justDroppedId={justDroppedId}
                     showProjectPill={showProjectPill}
                     projectAccess={projectAccess}
                     currentUserId={currentUserId}
                     onOpenStory={onOpenStory}
                     onToast={onToast}/>
        );
      })}
    </div>
  );
}

// ── EpicMenu ──────────────────────────────────────────────────
// Kebab dropdown on each epic header. Three actions:
//   · Rename  — inline text input, Enter saves, Escape cancels
//   · Color   — palette of nine swatches matching NewEpicModal
//   · Delete  — fires onDelete (parent runs fbConfirm with child count)
// Hover-only by default so the header stays calm; appears on focus
// for keyboard users via :focus-within on the wrapper.
function EpicMenu({ epic, taskCount, onRename, onColor, onDelete }) {
  const [anchor, setAnchor] = React.useState(null);
  const [mode, setMode] = React.useState(null); // null | 'rename' | 'color'
  const [title, setTitle] = React.useState(epic.title || "");
  const inputRef = React.useRef(null);
  const open = !!anchor;
  const COLORS = [
    { id: "purple", hex: "#a25ddc" }, { id: "blue",   hex: "#0073ea" },
    { id: "teal",   hex: "#0086c0" }, { id: "green",  hex: "#00c875" },
    { id: "yellow", hex: "#fdab3d" }, { id: "red",    hex: "#e2445c" },
    { id: "pink",   hex: "#ff5ac4" }, { id: "indigo", hex: "#5559df" },
    { id: "gray",   hex: "#676879" },
  ];

  React.useEffect(() => { setTitle(epic.title || ""); }, [epic.id, epic.title]);

  // Esc dismisses the dropdown. Click-outside is handled by the
  // portal-rendered <Popover/> below — we explicitly do NOT do our
  // own outside-click listening here so the popover content (rename
  // input, color swatches) stays interactive without fighting two
  // handlers. (Earlier inline dropdown was clipped by .epic-group's
  // `overflow: hidden` whenever the epic was collapsed — switching
  // to Popover renders into document.body and escapes that clip.)
  React.useEffect(() => {
    if (!open) return;
    function onKey(e) {
      if (e.key === "Escape") { setAnchor(null); setMode(null); }
    }
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [open]);

  React.useEffect(() => {
    if (mode === "rename" && inputRef.current) {
      inputRef.current.focus();
      inputRef.current.select();
    }
  }, [mode]);

  function close() { setAnchor(null); setMode(null); }
  function commitRename() {
    const next = (title || "").trim();
    if (next && next !== epic.title && onRename) onRename(next);
    close();
  }

  const Pop = (typeof window !== "undefined" && window.Popover) ? window.Popover : null;

  return (
    <span className="epic-menu"
          onClick={(e) => e.stopPropagation()}
          style={{ position: "relative", marginLeft: 6 }}>
      <button type="button" className="epic-menu-btn"
              aria-label={"Manage epic " + epic.title}
              title="Edit epic"
              onClick={(e) => {
                if (open) { close(); }
                else { setAnchor(e.currentTarget); setMode(null); }
              }}
              style={{
                width: 24, height: 24, borderRadius: 6,
                background: "transparent", border: "none", cursor: "pointer",
                color: "var(--ink-muted, #676879)", fontSize: 15, lineHeight: 1,
                display: "inline-flex", alignItems: "center", justifyContent: "center",
              }}>⋯</button>
      {open && Pop && (
        <Pop anchor={anchor} onClose={close}>
          <div className="epic-menu-pop"
               style={{ minWidth: 180, padding: 4 }}>
          {mode === null && (
            <>
              {onRename && (
                <button type="button" className="epic-menu-item"
                        onClick={() => setMode("rename")}
                        style={_epicMenuItemStyle()}>
                  ✎ Rename
                </button>
              )}
              {onColor && (
                <button type="button" className="epic-menu-item"
                        onClick={() => setMode("color")}
                        style={_epicMenuItemStyle()}>
                  <span style={{
                    display: "inline-block", width: 10, height: 10, borderRadius: 999,
                    background: epic.color, marginRight: 8, verticalAlign: "middle",
                  }}/>
                  Change color
                </button>
              )}
              {onDelete && (
                <>
                  <div style={{ height: 1, background: "var(--border, #e6e9ef)", margin: "4px 0" }}/>
                  <button type="button" className="epic-menu-item"
                          onClick={() => { onDelete(); close(); }}
                          style={{ ..._epicMenuItemStyle(), color: "#b41f37" }}>
                    🗑 Delete epic
                    {taskCount > 0 && (
                      <span style={{ marginLeft: 6, fontSize: 10.5, color: "var(--ink-muted)", fontWeight: 500 }}>
                        ({taskCount} task{taskCount === 1 ? "" : "s"})
                      </span>
                    )}
                  </button>
                </>
              )}
            </>
          )}
          {mode === "rename" && (
            <div style={{ padding: 6, minWidth: 200 }}>
              <div style={{ fontSize: 10.5, color: "var(--ink-muted)", fontWeight: 700,
                            textTransform: "uppercase", letterSpacing: ".05em",
                            marginBottom: 4 }}>Rename epic</div>
              <input ref={inputRef} type="text" value={title}
                     onChange={(e) => setTitle(e.target.value)}
                     onKeyDown={(e) => {
                       if (e.key === "Enter") { e.preventDefault(); commitRename(); }
                       if (e.key === "Escape") { e.preventDefault(); close(); }
                     }}
                     style={{ width: "100%", padding: "6px 8px", fontSize: 13,
                              border: "1px solid var(--border)", borderRadius: 6,
                              fontFamily: "inherit", color: "var(--ink-strong)" }}/>
              <div style={{ display: "flex", gap: 6, marginTop: 8, justifyContent: "flex-end" }}>
                <button type="button" onClick={close}
                        style={_epicMenuSecondaryBtn()}>Cancel</button>
                <button type="button" onClick={commitRename} className="btn-primary"
                        style={{ padding: "5px 12px", fontSize: 12, fontWeight: 600,
                                 borderRadius: 6, cursor: "pointer", border: "none",
                                 background: "var(--brand, #a25ddc)", color: "white" }}>
                  Save
                </button>
              </div>
            </div>
          )}
          {mode === "color" && (
            <div style={{ padding: 6, minWidth: 180 }}>
              <div style={{ fontSize: 10.5, color: "var(--ink-muted)", fontWeight: 700,
                            textTransform: "uppercase", letterSpacing: ".05em",
                            marginBottom: 6 }}>Pick a color</div>
              <div style={{ display: "grid", gridTemplateColumns: "repeat(5, 1fr)", gap: 6 }}>
                {COLORS.map(c => (
                  <button key={c.id} type="button"
                          title={c.id}
                          onClick={() => { onColor(c.hex); close(); }}
                          style={{
                            width: 28, height: 28, borderRadius: 6,
                            background: c.hex,
                            border: c.hex === epic.color
                              ? "2px solid var(--ink-strong, #0f1729)"
                              : "1px solid rgba(0,0,0,.08)",
                            cursor: "pointer",
                          }}/>
                ))}
              </div>
              <div style={{ display: "flex", justifyContent: "flex-end", marginTop: 8 }}>
                <button type="button" onClick={() => setMode(null)}
                        style={_epicMenuSecondaryBtn()}>Back</button>
              </div>
            </div>
          )}
          </div>
        </Pop>
      )}
    </span>
  );
}
function _epicMenuItemStyle() {
  return {
    display: "block", width: "100%", textAlign: "left",
    padding: "7px 10px", border: "none", background: "transparent",
    cursor: "pointer", borderRadius: 6,
    font: "inherit", fontSize: 13, color: "var(--ink-strong, #0f1729)",
  };
}
function _epicMenuSecondaryBtn() {
  return {
    padding: "5px 11px", fontSize: 12, fontWeight: 600,
    background: "transparent", border: "1px solid var(--border, #e6e9ef)",
    borderRadius: 6, color: "var(--ink-body)", cursor: "pointer",
    fontFamily: "inherit",
  };
}

Object.assign(window, { ProjectHeader, BoardToolbar, TableView, EpicGroup, StoryGroup, StoryRow, TaskRow, AddTaskRow, MiniCalendar, parseDateLoose, formatDateShort, EpicMenu });

// Lightweight CSS for the story group's review highlight + add-row
// styling. Loaded once per session.
if (typeof document !== "undefined" && !document.getElementById("story-group-css")) {
  const s = document.createElement("style");
  s.id = "story-group-css";
  s.textContent = `
    .story-group .epic-header { background: rgba(217,119,6,.06); }
    .story-group.needs-review .epic-header {
      background: rgba(245,158,11,.10);
      box-shadow: inset 4px 0 0 #f59e0b;
    }
    .story-group .epic-title { cursor: pointer; }
    .story-group .epic-title:hover { text-decoration: underline; }
  `;
  document.head.appendChild(s);
}
