/* global React, ReactDOM, RELEASE_DATA, LINEAR_WORKSPACE */
const { useState, useMemo, useEffect, useRef, useCallback } = React;

// ---------- helpers ----------
const PRODUCT_LABEL = { VF: "PredictGo", PG: "Predict Gaming" };
const PRODUCT_SHORT = { VF: "VF", PG: "PG" };
const LINEAR_WS = (typeof LINEAR_WORKSPACE !== "undefined" ? LINEAR_WORKSPACE : "singdm");
const PM_DISPLAY_KEY = "release-notes-pm-display"; // localStorage — toggle for editors/admins to hide PM features
const WORKER_URL = "https://release-notes-sync.jmlin.workers.dev";
const GUEST_PWD_KEY = "release-notes-guest-pwd";       // sessionStorage — today's date string used as password
const GUEST_DATA_KEY = "release-notes-guest-data";     // sessionStorage — cached release data from Worker
const LANG_KEY = "release-notes-lang";                 // localStorage — 'zh' | 'en'
const SIDEBAR_KEY = "release-notes-sidebar";           // localStorage — '0' 收合 / '1' 展開

// i18n dictionary for the most visible UI strings (rest stays as-is)
const I18N = {
  zh: {
    pageTitle: "版本發行紀錄",
    pageSubtitle: "PredictGo 與 Predict Gaming 的完整發版歷程 — 搜尋、篩選、追蹤每一個功能落地的版本。",
    searchPlaceholder: "搜尋關鍵字：例如「推薦碼」「錢包」「Mines」「提款」...",
    productAll: "全部產品", yearAll: "全部年份", typeAll: "全部類型",
    chipAll: "全部", chipLatest: "最新版本", chipHotfix: "含 Hotfix", chipFeature: "含新功能",
    typeNew: "新功能", typeFix: "修復", typeHotfix: "Hotfix",
    resultsLabel: "results", sortNewest: "最新優先",
    daysOverdue: (n) => `已逾期 ${n} 天`,
    daysLater: (n) => `${n} 天後發版`,
    releaseToday: "今天發版", releaseTomorrow: "明天發版",
    plannedPrefix: "預計",
    upcomingTotal: (n) => `${n} 個進行中`,
    inputPwdLabel: "輸入密碼",
    enterBtn: "進入",
    verifyingBtn: "驗證中...",
    pwdError: "密碼錯誤",
    magicLinkToggle: "用 Email Magic Link 登入",
    magicLinkPlaceholder: "you@example.com",
    magicLinkHint: "寄一封登入連結到你的信箱（限團隊成員）",
    magicLinkSendBtn: "寄送登入連結",
    magicLinkSending: "發送中...",
    magicLinkSentTitle: "登入連結已寄出",
    magicLinkSentHint: "點擊登入連結",
    magicLinkSentValid: "（連結 60 分鐘內有效）",
    magicLinkChangeEmail: "換一個信箱",
    loginInternalTool: "Internal Tool",
    loginBrandTitle: "Release Notes",
    emptyTitle: "找不到符合條件的版本",
    emptyNoMatch: "no match for",
    emptyClearFilters: "try clearing filters",
    logoutPrefix: "登出",
    productLabel: "產品", yearLabel: "年份", typeLabel: "類型",
    upcomingTitle: "預計發版",
    upcomingTabAll: "全部", upcomingTabVF: "PredictGo", upcomingTabPG: "Predict Gaming",
    statusCompleted: "已完成", statusStarted: "進行中", statusUnstarted: "待開始",
    noUpcoming: "目前沒有預計發版項目",
    whatsNew: "新功能 / 改善",
    bugFixes: "Bug Fixes",
    copyBtn: "複製",
    copiedBtn: "已複製",
  },
  en: {
    pageTitle: "Release Notes",
    pageSubtitle: "Complete release history of PredictGo and Predict Gaming — search, filter, and track every shipped feature.",
    searchPlaceholder: "Search keywords: e.g. \"referral code\", \"wallet\", \"Mines\", \"withdrawal\"...",
    productAll: "All Products", yearAll: "All Years", typeAll: "All Types",
    chipAll: "All", chipLatest: "Latest", chipHotfix: "Has Hotfix", chipFeature: "Has Feature",
    typeNew: "New", typeFix: "Fix", typeHotfix: "Hotfix",
    resultsLabel: "results", sortNewest: "Newest First",
    daysOverdue: (n) => `Overdue ${n} days`,
    daysLater: (n) => `Ships in ${n} days`,
    releaseToday: "Ships today", releaseTomorrow: "Ships tomorrow",
    plannedPrefix: "Planned",
    upcomingTotal: (n) => `${n} in progress`,
    inputPwdLabel: "Enter Password",
    enterBtn: "Enter",
    verifyingBtn: "Verifying...",
    pwdError: "Incorrect password",
    magicLinkToggle: "Sign in via Email Magic Link",
    magicLinkPlaceholder: "you@example.com",
    magicLinkHint: "We'll email a sign-in link (team members only)",
    magicLinkSendBtn: "Send sign-in link",
    magicLinkSending: "Sending...",
    magicLinkSentTitle: "Sign-in link sent",
    magicLinkSentHint: "to open the sign-in link",
    magicLinkSentValid: "(link valid for 60 minutes)",
    magicLinkChangeEmail: "Use a different email",
    loginInternalTool: "Internal Tool",
    loginBrandTitle: "Release Notes",
    emptyTitle: "No matching releases found",
    emptyNoMatch: "no match for",
    emptyClearFilters: "try clearing filters",
    logoutPrefix: "Log out",
    productLabel: "Product", yearLabel: "Year", typeLabel: "Type",
    upcomingTitle: "Upcoming Releases",
    upcomingTabAll: "All", upcomingTabVF: "PredictGo", upcomingTabPG: "Predict Gaming",
    statusCompleted: "Completed", statusStarted: "In Progress", statusUnstarted: "To Do",
    noUpcoming: "No upcoming releases",
    whatsNew: "What's New",
    bugFixes: "Bug Fixes",
    copyBtn: "Copy",
    copiedBtn: "Copied",
  },
};

// Daily guest password = today's date in Taipei, formatted as MMDD (e.g. 2026-05-04 → "0504")
const todayPasswordTPE = () => {
  const ymd = new Date().toLocaleDateString("en-CA", { timeZone: "Asia/Taipei" });
  return ymd.slice(5).replace("-", "");
};

// ─── Supabase client ───
const supabase = window.supabase.createClient(
  window.SUPABASE_URL,
  window.SUPABASE_ANON_KEY,
  {
    auth: {
      detectSessionInUrl: true,
      persistSession: true,
      autoRefreshToken: true,
    },
  }
);

// Audit log helper — fire-and-forget
function logAudit(action, target_type = null, target_id = null, metadata = null) {
  supabase.from("audit_log").insert({ action, target_type, target_id, metadata }).then(({ error }) => {
    if (error) console.warn("audit_log insert failed:", error.message);
  });
}

function yearOf(d) { return d.slice(0, 4); }

function getTypeTags(r) {
  const tags = [];
  if (r.hotfix) tags.push("hotfix");
  if ((r.whatsNew?.length ?? 0) > 0) tags.push("new");
  if ((r.bugFixes?.length ?? 0) > 0) tags.push("fix");
  return tags;
}

function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }

// Highlight matches inside a string, returns React fragment
function Highlight({ text, query }) {
  if (!query) return <>{text}</>;
  const re = new RegExp(`(${escapeRe(query)})`, "gi");
  const parts = String(text).split(re);
  return (
    <>
      {parts.map((p, i) =>
        re.test(p) && p.toLowerCase() === query.toLowerCase()
          ? <mark key={i} className="hl">{p}</mark>
          : <React.Fragment key={i}>{p}</React.Fragment>
      )}
    </>
  );
}

function matchesQuery(r, q) {
  if (!q) return true;
  const tickets = [
    ...(r.parentTicket ? [r.parentTicket] : []),
    ...(r.whatsNew || []).flatMap(i => i.tickets || []),
    ...(r.bugFixes || []).flatMap(i => i.tickets || []),
  ];
  const hay = [
    r.version, r.title, PRODUCT_LABEL[r.product],
    ...(r.whatsNew || []).flatMap(i => [i.name, i.desc, ...(i.bullets || [])]),
    ...(r.bugFixes || []).flatMap(i => [i.name, i.desc, ...(i.bullets || [])]),
    ...tickets,
  ].join(" \u0001 ").toLowerCase();
  return hay.includes(q.toLowerCase());
}

// ---------- Ticket chip (PM mode only) ----------
function TicketChip({ id, product }) {
  const tint = product === "VF" ? "tint-vf" : "tint-pg";
  return (
    <a
      href={`https://linear.app/${LINEAR_WS}/issue/${id}`}
      target="_blank"
      rel="noopener noreferrer"
      onClick={(e) => e.stopPropagation()}
      className={`${tint} mono rounded px-1.5 py-0.5 text-[10.5px] font-semibold tracking-wide hover:brightness-125 transition whitespace-nowrap`}
      title={`Open ${id} in Linear`}
    >
      {id}
    </a>
  );
}

// ---------- icons (inline, minimal) ----------
const Icon = {
  Search: (p) => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}>
      <circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />
    </svg>
  ),
  Chev: (p) => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}>
      <path d="m6 9 6 6 6-6" />
    </svg>
  ),
  X: (p) => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}>
      <path d="M18 6 6 18M6 6l12 12" />
    </svg>
  ),
  SortDesc: (p) => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}>
      <path d="M3 6h13M3 12h9M3 18h5"/><path d="m19 6 0 12"/><path d="m15 14 4 4 4-4"/>
    </svg>
  ),
  SortAsc: (p) => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}>
      <path d="M3 6h5M3 12h9M3 18h13"/><path d="m19 18 0-12"/><path d="m15 10 4-4 4 4"/>
    </svg>
  ),
  Dot: (p) => (
    <svg viewBox="0 0 10 10" {...p}><circle cx="5" cy="5" r="4" fill="currentColor"/></svg>
  ),
  Sun: (p) => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}>
      <circle cx="12" cy="12" r="4"/>
      <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
    </svg>
  ),
  Moon: (p) => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}>
      <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
    </svg>
  ),
  Calendar: (p) => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}>
      <rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
    </svg>
  ),
  Doc: (p) => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}>
      <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
    </svg>
  ),
  Copy: (p) => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}>
      <rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
    </svg>
  ),
  Check: (p) => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" {...p}>
      <polyline points="20 6 9 17 4 12"/>
    </svg>
  ),
  Users: (p) => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}>
      <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/>
      <path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>
    </svg>
  ),
  History: (p) => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}>
      <path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l3.5 2"/>
    </svg>
  ),
  Away: (p) => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}>
      <path d="M13 8c0-2.76-2.46-5-5.5-5S2 5.24 2 8h2l1-1 1 1h4"/>
      <path d="M13 7.14A5.82 5.82 0 0 1 16.5 6c3.04 0 5.5 2.24 5.5 5h-3l-1-1-1 1h-3"/>
      <path d="M5.89 9.71c-2.15 2.15-2.3 5.06-.35 7.01l4.24-4.24.7-.7.71-.71 2.12-2.12c-1.95-1.96-4.86-1.8-7.02.35z"/>
      <path d="M11 15.5c.5 2.5-.17 4.5-1 6.5h4c2-5.5-.5-12-1-14"/>
    </svg>
  ),
  Link: (p) => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}>
      <path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7"/>
      <path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"/>
    </svg>
  ),
  Inbox: (p) => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}>
      <path d="M22 12h-6l-2 3h-4l-2-3H2"/>
      <path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>
    </svg>
  ),
  Ban: (p) => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}>
      <circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/>
    </svg>
  ),
  Alert: (p) => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}>
      <path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
      <path d="M12 9v4M12 17h.01"/>
    </svg>
  ),
  Clip: (p) => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}>
      <path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
    </svg>
  ),
};

/**
 * 狀態訊息。成功／失敗一律用 icon 表示 —— 網頁任何位置都不使用 emoji 或 ✓✗ 符號當視覺，
 * 見 CLAUDE.md「網頁視覺鐵律」。
 */
function FlashMsg({ msg, size = "text-[12px]" }) {
  if (!msg) return null;
  const ok = msg.type === "ok";
  const I = ok ? Icon.Check : Icon.X;
  return (
    <span className={`inline-flex items-start gap-1.5 mono ${size} ${ok ? "text-success" : "text-red-400"}`}>
      <I className="w-3.5 h-3.5 shrink-0 mt-px" />
      <span className="min-w-0 break-words">{msg.text}</span>
    </span>
  );
}

// ---------- Theme management ----------
const THEME_KEY = "release-notes-theme";

function getInitialTheme() {
  const saved = localStorage.getItem(THEME_KEY);
  if (saved === "light" || saved === "dark") return saved;
  return window.matchMedia?.("(prefers-color-scheme: light)").matches ? "light" : "dark";
}

function applyTheme(theme) {
  if (theme === "light") document.body.classList.add("theme-light");
  else document.body.classList.remove("theme-light");
}

function ThemeToggle({ theme, onToggle }) {
  const isDark = theme === "dark";
  return (
    <button
      onClick={onToggle}
      aria-label={isDark ? "切換至淺色模式" : "切換至深色模式"}
      title={isDark ? "切換至淺色模式" : "切換至深色模式"}
      className="flex items-center justify-center w-9 h-9 rounded-lg border border-border bg-surface hover:border-borderStrong text-fg2 hover:text-fg transition-colors"
    >
      {isDark
        ? <Icon.Sun className="w-4 h-4" />
        : <Icon.Moon className="w-4 h-4" />}
    </button>
  );
}

function LangToggle({ lang, onToggle }) {
  const next = lang === "zh" ? "EN" : "中";
  const label = lang === "zh" ? "Switch to English" : "切換至中文";
  return (
    <button
      onClick={onToggle}
      aria-label={label}
      title={label}
      className="flex items-center justify-center w-9 h-9 rounded-lg border border-border bg-surface hover:border-borderStrong text-fg2 hover:text-fg transition-colors text-xs font-semibold"
    >
      {next}
    </button>
  );
}

// ---------- sidebar ----------
function Sidebar({ counts, filters, setFilters, open }) {
  const FilterGroup = ({ title, items, value, onChange }) => (
    <div className="mb-5">
      <div className="px-2.5 mb-1.5 text-[10.5px] font-medium uppercase tracking-[0.12em] text-fg3">{title}</div>
      <div className="flex flex-col gap-0.5">
        {items.map(it => {
          const active = value === it.key;
          return (
            <button
              key={it.key}
              onClick={() => onChange(it.key)}
              className={`filter-row ${active ? "active" : ""} flex items-center justify-between text-left px-2.5 py-1.5 rounded-md text-[13px] text-fg2`}
            >
              <span className="flex items-center gap-2">
                {it.dot && <span className="inline-block w-1.5 h-1.5 rounded-full" style={{background: it.dot}} />}
                <span className={active ? "text-fg" : ""}>{it.label}</span>
              </span>
              {it.count != null && (
                <span className="mono text-[11px] text-fg3">{it.count}</span>
              )}
            </button>
          );
        })}
      </div>
    </div>
  );

  return (
    <aside className={`${open ? "w-[224px] border-r border-border" : "w-0"} shrink-0 h-screen sticky top-0 overflow-y-auto overflow-x-hidden hidden lg:block transition-[width] duration-150`}>
      <div className="p-4 w-[224px]">
        {/* logo */}
        <div className="logo-grad rounded-lg border border-border p-3 mb-5">
          <div className="flex items-center gap-2 mb-1.5">
            <BrandMark className="w-5 h-5" rx={7} />
            <div className="text-[12px] font-semibold tracking-tight">Release Notes</div>
          </div>
          <div className="text-[10px] text-fg2 leading-snug mono whitespace-nowrap">
            PredictGo <span className="text-fg3">×</span> Predict Gaming
          </div>
        </div>

        <FilterGroup
          title="產品"
          value={filters.product}
          onChange={(v) => setFilters(f => ({ ...f, product: v }))}
          items={[
            { key: "all", label: "全部產品", count: counts.product.all },
            { key: "VF",  label: "PredictGo", dot: "rgb(var(--vf-rgb))", count: counts.product.VF },
            { key: "PG",  label: "Predict Gaming", dot: "rgb(var(--pg-rgb))", count: counts.product.PG },
          ]}
        />

        <FilterGroup
          title="年份"
          value={filters.year}
          onChange={(v) => setFilters(f => ({ ...f, year: v }))}
          items={[
            { key: "all",  label: "全部年份", count: counts.year.all },
            { key: "2026", label: "2026",    count: counts.year["2026"] || 0 },
            { key: "2025", label: "2025",    count: counts.year["2025"] || 0 },
          ]}
        />

        <FilterGroup
          title="類型"
          value={filters.type}
          onChange={(v) => setFilters(f => ({ ...f, type: v }))}
          items={[
            { key: "all",    label: "全部類型", count: counts.type.all },
            { key: "new",    label: "新功能",   dot: "var(--type-new)", count: counts.type.new },
            { key: "fix",    label: "修復",     dot: "var(--type-fix)", count: counts.type.fix },
            { key: "hotfix", label: "Hotfix",   dot: "var(--type-hot)", count: counts.type.hotfix },
          ]}
        />

        <div className="hair pt-4 mt-1 px-2.5 text-[10.5px] text-fg3 leading-relaxed mono">
          {counts.product.all} versions tracked<br/>
          last update <span className="text-fg2">{counts.lastUpdate}</span>
        </div>
      </div>
    </aside>
  );
}

// ---------- search bar ----------
function SearchBar({ query, setQuery, inputRef, t }) {
  return (
    <div className="relative">
      <div className="absolute left-4 top-1/2 -translate-y-1/2 text-fg3">
        <Icon.Search className="w-[18px] h-[18px]" />
      </div>
      <input
        ref={inputRef}
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder={t ? t("searchPlaceholder") : "搜尋關鍵字：例如「推薦碼」「錢包」「Mines」「提款」..."}
        className="w-full h-12 pl-11 pr-24 bg-surface border border-border rounded-xl text-[15px] text-fg placeholder:text-fg3 outline-none focus:border-borderStrong transition-colors"
      />
      <div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1.5 pointer-events-none">
        {query ? (
          <button
            onClick={() => setQuery("")}
            className="pointer-events-auto p-1.5 rounded-md hover:bg-surface2 text-fg3 hover:text-fg2 transition-colors"
            aria-label="Clear"
          ><Icon.X className="w-4 h-4" /></button>
        ) : (
          <>
            <span className="kbd">⌘</span>
            <span className="kbd">K</span>
          </>
        )}
      </div>
    </div>
  );
}

// ---------- keyword summary ----------
function KeywordSummary({ query, hits, onJump }) {
  if (!query || hits.length === 0) return null;
  return (
    <div className="mt-3 rounded-xl border border-border bg-surface px-4 py-3">
      <div className="flex flex-wrap items-center gap-2 text-sm text-fg2">
        <span>
          「<span className="text-fg font-medium">{query}</span>」曾在
          <span className="mono text-fg mx-1">{hits.length}</span>
          個版本出現過：
        </span>
        <div className="flex flex-wrap gap-1.5">
          {hits.map(r => (
            <button
              key={r.id}
              onClick={() => onJump(r.id)}
              className={`chip ${r.product === "VF" ? "tint-vf" : "tint-pg"} rounded-full px-2.5 py-0.5 text-[12px] mono hover:brightness-125`}
            >
              {PRODUCT_SHORT[r.product]} {r.version}
            </button>
          ))}
        </div>
      </div>
    </div>
  );
}

// ---------- chips ----------
function QuickChips({ value, onChange, sort, setSort, visibleCount }) {
  const chips = [
    { key: "all",     label: "全部版本" },
    { key: "latest",  label: "最新版本" },
    { key: "hotfix",  label: "含 Hotfix" },
    { key: "new",     label: "含新功能" },
  ];
  return (
    <div className="flex items-center gap-2 flex-wrap">
      <div className="flex items-center gap-1.5 flex-wrap">
        {chips.map(c => {
          const active = value === c.key;
          return (
            <button
              key={c.key}
              onClick={() => onChange(c.key)}
              className={`chip rounded-full px-3 h-8 text-[13px] border
                ${active
                  ? "grad-btn"
                  : "bg-surface text-fg2 border-border hover:text-fg hover:border-borderStrong"}`}
            >
              {c.label}
            </button>
          );
        })}
      </div>
      <div className="flex-1" />
      <div className="flex items-center gap-2 text-[12px] text-fg3">
        <span className="mono">{visibleCount} results</span>
        <button
          onClick={() => setSort(sort === "desc" ? "asc" : "desc")}
          className="flex items-center gap-1.5 h-8 px-2.5 rounded-full border border-border bg-surface text-fg2 hover:text-fg hover:border-borderStrong transition-colors"
          title="切換排序"
        >
          {sort === "desc" ? <Icon.SortDesc className="w-3.5 h-3.5" /> : <Icon.SortAsc className="w-3.5 h-3.5" />}
          <span className="text-[12px]">{sort === "desc" ? "最新優先" : "最舊優先"}</span>
        </button>
      </div>
    </div>
  );
}

// ---------- brand mark（和瀏覽器分頁 favicon 同一個圖形）----------
let brandMarkSeq = 0;
function BrandMark({ className = "w-5 h-5", rx = 7 }) {
  const idRef = useRef(null);
  if (idRef.current === null) idRef.current = `bm-${++brandMarkSeq}`;
  const id = idRef.current;
  return (
    <svg viewBox="0 0 32 32" className={className} aria-hidden="true">
      <defs>
        <linearGradient id={id} x1="0" y1="0" x2="1" y2="1">
          <stop offset="0" stopColor="rgb(var(--vf-rgb))" />
          <stop offset="1" stopColor="rgb(var(--pg-rgb))" />
        </linearGradient>
      </defs>
      <rect width="32" height="32" rx={rx} fill={`url(#${id})`} />
      <g fill="#ffffff">
        <rect x="7" y="7" width="18" height="4" rx="2" />
        <rect x="7" y="14" width="18" height="4" rx="2" />
        <rect x="7" y="21" width="11" height="4" rx="2" />
      </g>
    </svg>
  );
}

// ---------- version card ----------
function VersionCard({ r, query, open, onToggle, flash, cardRef, pmMode }) {
  const tint = r.product === "VF" ? "tint-vf" : "tint-pg";

  return (
    <article
      ref={cardRef}
      className={`rounded-xl border border-border bg-surface overflow-hidden ${open ? "card-open" : ""} ${flash ? "flash" : ""}`}
    >
      {/* header */}
      <button
        onClick={onToggle}
        className="w-full text-left px-5 py-4 flex items-center gap-4 trace-hover transition-colors"
      >
        {/* LEFT block: badge + version + title + hotfix */}
        <div className="flex items-center gap-3 min-w-0 flex-1 flex-wrap">
          <span className={`${tint} rounded-md px-2 py-0.5 text-[11px] font-semibold mono tracking-wide`}>
            {PRODUCT_SHORT[r.product]}
          </span>
          <span className="mono text-[13px] text-fg font-medium">{r.version}</span>
          <span className="text-fg3">·</span>
          <h3 className="text-[15px] text-fg font-medium truncate min-w-0">
            <Highlight text={r.title} query={query} />
          </h3>
          {r.hotfix && (
            <span className="tint-hot rounded px-1.5 py-0.5 text-[10.5px] font-semibold mono uppercase tracking-wider">Hotfix</span>
          )}
          {pmMode && r.parentTicket && (
            <TicketChip id={r.parentTicket} product={r.product} />
          )}
        </div>

        {/* RIGHT block: date + chev */}
        <div className="flex items-center gap-3 shrink-0">
          <span className="mono text-[12.5px] text-fg2">{r.date}</span>
          <span className={`chev ${open ? "open" : ""} text-fg3`}>
            <Icon.Chev className="w-4 h-4" />
          </span>
        </div>
      </button>

      {/* body */}
      <div className={`card-body ${open ? "open" : ""}`}>
        <div className="inner">
          <div className="px-5 pb-5 pt-1">
            <div className="hair mb-4" />
            {r.whatsNew?.length > 0 && (
              <Section
                color="var(--type-new)"
                title="What's New"
                count={r.whatsNew.length}
                items={r.whatsNew}
                query={query}
                product={r.product}
                pmMode={pmMode}
                subVersionDates={r.subVersionDates}
              />
            )}
            {r.bugFixes?.length > 0 && (
              <Section
                color="var(--type-fix)"
                title="Bug Fixes"
                count={r.bugFixes.length}
                items={r.bugFixes}
                query={query}
                product={r.product}
                pmMode={pmMode}
                compact={r.whatsNew?.length > 0}
                subVersionDates={r.subVersionDates}
              />
            )}
          </div>
        </div>
      </div>
    </article>
  );
}

// ---------- Tab Bar ----------
// 分頁 icon — 統一線性風格（16px，currentColor 描邊，自動吃亮/暗色）
const TAB_SVG = {
  history: <path d="M12 3 3 7.5l9 4.5 9-4.5L12 3Z M3 12l9 4.5 9-4.5 M3 16.5 12 21l9-4.5" />,
  upcoming: <g><circle cx="12" cy="12" r="8.5" /><path d="M12 16V8M8.5 11.5 12 8l3.5 3.5" /></g>,
  roadmap: <g><path d="M3.5 6.5h7M3.5 12h13M3.5 17.5h9" /><circle cx="18" cy="6.5" r="1.6" /><circle cx="14" cy="17.5" r="1.6" /></g>,
  schedule: <g><rect x="3.5" y="5" width="17" height="15.5" rx="2" /><path d="M3.5 9.5h17M8 3.5v3M16 3.5v3" /></g>,
  qa: <g><path d="M9.5 3h5M10.5 3v5L6.2 16.4a2 2 0 0 0 1.8 3h8a2 2 0 0 0 1.8-3L13.5 8V3" /><path d="M8.3 14h7.4" /></g>,
  admin: <g><circle cx="12" cy="8" r="3.4" /><path d="M5.5 20a6.5 6.5 0 0 1 13 0" /></g>,
  draft: <g><path d="M5 3.5h9l5 5v12a1.5 1.5 0 0 1-1.5 1.5h-12A1.5 1.5 0 0 1 4 20.5v-15A2 2 0 0 1 5 3.5Z" /><path d="M13.5 3.6V9h5.4" /><path d="M8.4 13.5h7.2M8.4 17h4.6" /></g>,
  relay: <g><path d="M21 4.5 2.8 11.3l5.4 1.9L18 6.6l-7.4 8.2.3 5.4 2.7-3.7 4.4 3.2z" /></g>,
};
function TabIcon({ k }) {
  return (
    <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
         strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
      {TAB_SVG[k]}
    </svg>
  );
}

function TabBar({ active, onChange, upcomingCount, roadmapCount, historyCount, pmUnlocked, isAdmin, canSeeQa, draftCount = 0, relayUnread = 0 }) {
  const tabs = [
    { key: "history",  label: "Release History", count: historyCount },
    { key: "upcoming", label: "Upcoming Releases", count: upcomingCount },
    { key: "roadmap",  label: "Roadmap", count: roadmapCount },
    ...(pmUnlocked ? [{ key: "schedule", label: "Schedule", count: null, pmOnly: true }] : []),
    ...(pmUnlocked ? [{ key: "draft", label: "草稿審核", count: draftCount || null, pmOnly: true }] : []),
    ...(pmUnlocked ? [{ key: "relay", label: "TG 中繼", count: relayUnread || null, pmOnly: true }] : []),
    ...(canSeeQa  ? [{ key: "qa",       label: "QA",       count: null, qaOnly: true }] : []),
    ...(isAdmin    ? [{ key: "admin",    label: "Admin",    count: null, adminOnly: true }] : []),
  ];
  const current = tabs.find(t => t.key === active) || tabs[0];
  // 分頁數量已經到 8 個，手機橫向捲的分頁列要一直左右撥才找得到人，
  // 而且在畫面最上緣、單手構不到。手機改成底部膠囊鈕 + 彈出全清單。
  const [sheetOpen, setSheetOpen] = useState(false);

  const badge = (t, activeStyle) => (
    <>
      {t.count != null && (
        <span className={`mono text-[11px] px-1.5 py-0.5 rounded ${activeStyle ? "bg-surface2 text-fg2" : "text-fg3"}`}>
          {t.count}
        </span>
      )}
      {t.pmOnly && <span className="tint-fix mono text-[10px] px-1.5 py-0.5 rounded font-semibold uppercase">PM</span>}
      {t.qaOnly && <span className="tint-new mono text-[10px] px-1.5 py-0.5 rounded font-semibold uppercase">QA</span>}
      {t.adminOnly && <span className="tint-hot mono text-[10px] px-1.5 py-0.5 rounded font-semibold uppercase">ADMIN</span>}
    </>
  );

  return (
    <>
    <div className="hidden lg:flex items-center gap-1 border-b border-border overflow-x-auto no-scrollbar">
      {tabs.map(t => {
        const isActive = active === t.key;
        return (
          <button
            key={t.key}
            onClick={() => onChange(t.key)}
            className={`relative shrink-0 whitespace-nowrap flex items-center gap-2 px-3 lg:px-4 py-2.5 text-[13.5px] font-medium transition-colors
              ${isActive ? "text-fg" : "text-fg3 hover:text-fg2"}`}
          >
            <TabIcon k={t.key} />
            <span>{t.label}</span>
            {badge(t, isActive)}
            {isActive && (
              <span className="absolute left-0 right-0 -bottom-px h-0.5 grad-fill" />
            )}
          </button>
        );
      })}
    </div>

    {/* ───── 手機：底部膠囊鈕 ───── */}
    <button
      onClick={() => setSheetOpen(true)}
      style={{ bottom: "max(16px, env(safe-area-inset-bottom))" }}
      className="lg:hidden fixed left-1/2 -translate-x-1/2 z-40 grad-ring
                 flex items-center gap-2 pl-4 pr-3 h-11 rounded-full
                 bg-surface border border-transparent shadow-lg shadow-black/30 text-fg text-[13.5px] font-medium
                 active:scale-[0.98] transition-transform max-w-[88vw]">
      <TabIcon k={current.key} />
      <span className="truncate">{current.label}</span>
      {badge(current, true)}
      <Icon.Chev className="w-4 h-4 rotate-180 text-fg3 shrink-0" />
    </button>

    {/* ───── 手機：彈出的完整分頁清單 ───── */}
    {sheetOpen && (
      <div className="lg:hidden fixed inset-0 z-50" role="dialog" aria-modal="true">
        <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setSheetOpen(false)} />
        <div style={{ paddingBottom: "max(16px, env(safe-area-inset-bottom))" }}
             className="absolute inset-x-0 bottom-0 bg-surface border-t border-border rounded-t-2xl
                        max-h-[80vh] overflow-y-auto">
          <div className="sticky top-0 bg-surface pt-2.5 pb-1.5">
            <div className="h-1 w-10 rounded-full bg-borderStrong/60 mx-auto" />
          </div>
          <div className="px-2 pb-2 flex flex-col">
            {tabs.map(t => {
              const on = t.key === active;
              return (
                <button key={t.key}
                  onClick={() => { onChange(t.key); setSheetOpen(false); }}
                  className={`flex items-center gap-3 px-3 h-12 rounded-xl text-[14px] transition-colors
                    ${on ? "grad-tint text-fg font-semibold" : "text-fg2 active:bg-surface2/60"}`}>
                  <TabIcon k={t.key} />
                  <span className="flex-1 text-left truncate">{t.label}</span>
                  {badge(t, on)}
                  {on && <Icon.Check className="w-4 h-4 text-fg3 shrink-0" />}
                </button>
              );
            })}
          </div>
        </div>
      </div>
    )}
    </>
  );
}

// ═══════════════════════════════════════════════════════════════════
// QA 進測看板 — admin 專屬。資料由 worker 每日算好存進 Supabase，這裡讀 /qa-board。
const QA_LANES = [
  { key: "risk",  name: "帶病上線 — code 已在 main（正式站），但 QA 尚未通過", color: "var(--qa-risk)", bg: "var(--qa-risk-bg)", att: true,  pill: "帶病上線" },
  { key: "fail",  name: "退回 — QA 未通過，已打回 RD",       color: "var(--qa-risk)", bg: "var(--qa-risk-bg)", att: true,  pill: "退回" },
  { key: "wait",  name: "待測 — 等待 QA 認領",               color: "var(--qa-wait)", bg: "var(--qa-wait-bg)", att: false, pill: "待測" },
  { key: "test",  name: "測試中 — QA 進行中",                color: "var(--qa-test)", bg: "var(--qa-test-bg)", att: false, pill: "測試中" },
  { key: "ready", name: "待發版 — QA 通過，等待上正式站",     color: "var(--qa-pass)", bg: "var(--qa-pass-bg)", att: false, pill: "待發版" },
  { key: "done",  name: "已上線 · 監測中",                   color: "var(--qa-mon)",  bg: "var(--qa-mon-bg)",  att: false, pill: "監測中" },
];
const QA_STAGE_LABEL = { branch: "分支", uat: "UAT", dev: "DEV", main: "MAIN" };
const QA_VERDICT_COLOR = { "已上正式": "var(--qa-pass)", "QA測試": "var(--qa-test)", "UAT": "var(--qa-wait)", "開發中": "var(--qa-test)" };

function QaTicketRow({ t }) {
  const [open, setOpen] = useState(false);
  const lane = QA_LANES.find((l) => l.key === t.lane) || QA_LANES[5];
  const vColor = t.verdict ? (QA_VERDICT_COLOR[t.verdict] || "var(--qa-none)") : "var(--qa-none)";
  const stageColor = (s) => (s === "pass" ? "var(--qa-pass)" : s === "risk" ? "var(--qa-risk)" : "var(--qa-none)");
  const stageText = { pass: "已到", risk: "未驗上線", none: "未到", unknown: "查無" };
  const [copiedId, setCopiedId] = useState(false);
  const [copiedFull, setCopiedFull] = useState(false);
  const doCopy = (text, setFlag) => { try { navigator.clipboard.writeText(text); } catch (e) { /* ignore */ } setFlag(true); setTimeout(() => setFlag(false), 1000); };
  return (
    <div className="relative group mb-1.5">
      <button onClick={() => doCopy(`${t.id} ${t.title}`, setCopiedFull)} title="複製 單號+標題"
        className="absolute -left-9 top-1/2 -translate-y-1/2 w-7 h-7 flex items-center justify-center rounded-lg border border-border bg-surface text-fg3 shadow-sm opacity-0 group-hover:opacity-100 hover:text-fg hover:border-borderStrong transition-opacity"
        style={copiedFull ? { color: "var(--qa-pass)", borderColor: "var(--qa-pass)" } : {}}>
        {copiedFull
          ? <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5" /></svg>
          : <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="12" height="12" rx="2" /><path d="M5 15V5a2 2 0 0 1 2-2h10" /></svg>}
      </button>
      <div className={`rounded-lg border border-border bg-surface overflow-hidden ${lane.att ? "border-l-2" : ""}`}
           style={lane.att ? { borderLeftColor: lane.color } : {}}>
      <button onClick={() => setOpen((o) => !o)}
              className="w-full flex items-center gap-2.5 px-3.5 py-3 text-left hover:bg-surface2 transition-colors">
        <span className="mono text-[13.5px] font-bold shrink-0 cursor-pointer rounded px-1 -mx-1 hover:underline" title="點擊複製單號"
              onClick={(e) => { e.stopPropagation(); doCopy(t.id, setCopiedId); }}
              style={{ color: copiedId ? "var(--qa-pass)" : vColor }}>{t.id}</span>
        <span className="text-[14.5px] text-fg truncate flex-1 min-w-0">{t.title}</span>
        {t.version && <span className="mono text-[11.5px] text-fg3 bg-surface2 px-2 py-0.5 rounded shrink-0">v{t.version}</span>}
        {t.slipped && <span className="mono text-[11px] font-bold px-2 py-0.5 rounded shrink-0"
                            style={{ background: "var(--qa-wait-bg)", color: "var(--qa-wait)" }}>↩ {t.origVersion}→{t.version}</span>}
        <span className="text-[11px] font-bold px-2 py-0.5 rounded shrink-0 bg-surface2" style={{ color: vColor }}>
          {t.verdict || "查無"}
        </span>
        <span className="text-[11.5px] font-bold px-2.5 py-0.5 rounded-full shrink-0"
              style={{ background: lane.bg, color: lane.color }}>{lane.pill}</span>
        <span className="text-fg3 shrink-0" style={{ transform: open ? "rotate(90deg)" : "none", transition: ".2s" }}>›</span>
      </button>
      {open && (
        <div className="px-4 pb-4 pt-1 border-t border-border">
          {t.risk && (
            <div className="rounded-md px-3 py-2 text-[11.5px] font-semibold mb-3 flex gap-2"
                 style={{ background: "var(--qa-risk-bg)", border: "1px solid var(--qa-risk)", color: "var(--qa-risk)" }}>
              <Icon.Alert className="w-3.5 h-3.5 shrink-0 mt-px" />
              <span>此票 code 已在 main（正式站），但 Linear 狀態為「{t.status}」— 上線未經 QA 驗證</span>
            </div>
          )}
          {t.slipped && (
            <div className="rounded-md px-3 py-2 text-[11.5px] font-semibold mb-3 flex gap-2"
                 style={{ background: "var(--qa-wait-bg)", border: "1px solid var(--qa-wait)", color: "var(--qa-wait)" }}>
              ↩ 延期：原訂 v{t.origVersion} 未如期出測，延至 v{t.version} 二次進測（當前目標版本以 v{t.version} 為準）
            </div>
          )}
          <div className="flex flex-wrap gap-x-5 gap-y-1 text-[11.5px] text-fg3 mb-3">
            <span>負責：<b className="text-fg">{t.assignee || "未指派"}</b></span>
            <span>類型：<b className="text-fg">{t.type === "-" ? "未標" : t.type}</b></span>
            <span>Linear 狀態：<b className="text-fg">{t.status}</b></span>
            <span>最後更新：<b className="text-fg">{t.updatedAt}</b></span>
          </div>
          <div className="flex items-start mb-1">
            {["branch", "uat", "dev", "main"].map((n, i) => {
              const s = (t.stages && t.stages[n]) || "unknown";
              const c = stageColor(s);
              return (
                <React.Fragment key={n}>
                  <div className="flex flex-col items-center gap-1.5 shrink-0" style={{ width: 78 }}>
                    <div className="w-8 h-8 rounded-full flex items-center justify-center text-[13px] font-bold"
                         style={{ border: `2px ${s === "none" || s === "unknown" ? "dashed" : "solid"} ${c}`, color: c, background: c + "18" }}>
                      {s === "pass"
                        ? <Icon.Check className="w-4 h-4" />
                        : s === "risk" ? "!" : s === "unknown" ? "?" : "·"}
                    </div>
                    <div className="text-[10px] font-bold text-fg3">{QA_STAGE_LABEL[n]}</div>
                    <div className="text-[9.5px] font-semibold" style={{ color: c }}>{stageText[s]}</div>
                  </div>
                  {i < 3 && <div className="flex-1 h-0.5 mt-4 rounded" style={{
                    background: ((t.stages && (t.stages[["branch","uat","dev","main"][i+1]] === "pass" || t.stages[["branch","uat","dev","main"][i+1]] === "risk"))) ? "var(--qa-pass)" : "rgb(var(--border-rgb) / var(--border-alpha))"
                  }} />}
                </React.Fragment>
              );
            })}
          </div>
          <a href={t.url} target="_blank" rel="noopener"
             className="inline-block mt-2 text-[11px] font-semibold" style={{ color: "var(--vf-rgb)" }}>
            在 Linear 開啟 ↗
          </a>
        </div>
      )}
      </div>
    </div>
  );
}

function QaBoard() {
  const [state, setState] = useState({ loading: true, error: null, data: null });
  const [prod, setProd] = useState("ALL");
  const [ver, setVer] = useState("ALL");
  const [pos, setPos] = useState("ALL");
  const [assignee, setAssignee] = useState("ALL");
  const [laneF, setLaneF] = useState("ALL");
  const [q, setQ] = useState("");
  const [sortBy, setSortBy] = useState("version");
  const [preset, setPreset] = useState(null);
  const [view, setView] = useState("lane");
  const [refreshing, setRefreshing] = useState(false);
  const [copied, setCopied] = useState(false);

  const doRefresh = async () => {
    setRefreshing(true);
    try {
      const { data: { session } } = await supabase.auth.getSession();
      const token = session?.access_token;
      const res = await fetch(`${WORKER_URL}/qa-board/refresh`, { method: "POST", headers: { Authorization: `Bearer ${token}` } });
      const j = await res.json();
      if (j.ok) setState({ loading: false, error: null, data: j });
    } catch (e) { /* 保留舊資料 */ }
    setRefreshing(false);
  };

  useEffect(() => {
    (async () => {
      try {
        const { data: { session } } = await supabase.auth.getSession();
        const token = session?.access_token;
        if (!token) throw new Error("尚未登入");
        const res = await fetch(`${WORKER_URL}/qa-board`, { headers: { Authorization: `Bearer ${token}` } });
        const j = await res.json();
        if (!j.ok) throw new Error(j.error || "讀取失敗");
        setState({ loading: false, error: null, data: j });
      } catch (e) {
        setState({ loading: false, error: String(e.message || e), data: null });
      }
    })();
  }, []);

  if (state.loading) return <div className="text-fg3 text-sm py-16 text-center">載入 QA 看板中…</div>;
  if (state.error) return <div className="text-fg3 text-sm py-16 text-center">無法載入：{state.error}</div>;

  const { tickets = [], stats = {}, generatedAt } = state.data;

  // 篩選選項（從資料動態產生）
  const SEL = "text-[12.5px] bg-surface border border-border rounded-lg px-2.5 py-1.5 text-fg2 cursor-pointer";
  const versions = [...new Set(tickets.map((t) => t.version).filter(Boolean))]
    .sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
  const assignees = [...new Set(tickets.map((t) => t.assignee).filter(Boolean))].sort();
  const POS_OPTS = ["已上正式", "QA測試", "UAT", "開發中"];

  // 快捷篩選（一按套用組合條件）
  const latestVer = versions[0] || null;
  const PRESETS = [
    { key: "qa_nover",        label: "進 QA · 無版號",         desc: "在待測/測試中、但沒壓版號", pred: (t) => ["wait", "test"].includes(t.lane) && !t.version },
    { key: "prod_pass_nover", label: "上正式 · QA過 · 無版號", desc: "已上 main + QA通過、但沒壓版號", pred: (t) => (t.pos || []).includes("main") && ["ready", "done"].includes(t.lane) && !t.version },
    { key: "risk",            label: "帶病上線",               desc: "已上 main 但 QA 未過", pred: (t) => t.lane === "risk" },
    { key: "latest",          label: `最新進測版本${latestVer ? " v" + latestVer : ""}`, desc: "當前最新版號的所有票", pred: (t) => t.version && t.version === latestVer },
    { key: "monitoring",      label: "監測中 · 舊→新",         desc: "監測中的票，停滯久的排前面", pred: (t) => t.lane === "done", sort: "stale" },
    { key: "no_branch",       label: "找不到分支",             desc: "GitLab 掃不到對應分支/MR（查無 code）", pred: (t) => !t.envKnown },
    // 以下僅供 KPI 卡片點擊使用，不顯示為快捷 chip
    { key: "slipped", label: "延期票", chip: false, pred: (t) => t.slipped },
    { key: "onmain",  label: "已上正式", chip: false, pred: (t) => (t.pos || []).includes("main") },
    { key: "indev",   label: "開發中", chip: false, pred: (t) => t.verdict === "開發中" },
    { key: "known",   label: "已對到 code", chip: false, pred: (t) => t.envKnown },
  ];
  const activePreset = PRESETS.find((p) => p.key === preset) || null;

  // 套用篩選（preset 啟用時以 preset 條件為主，仍可疊產品篩選）
  const rows = tickets.filter((t) => {
    if (prod !== "ALL" && t.product !== prod) return false;
    if (activePreset) return activePreset.pred(t);
    if (ver === "__none" && t.version) return false;
    if (ver !== "ALL" && ver !== "__none" && t.version !== ver) return false;
    if (pos !== "ALL" && (t.verdict || "查無") !== pos) return false;
    if (assignee !== "ALL" && (t.assignee || "未指派") !== assignee) return false;
    if (laneF !== "ALL" && t.lane !== laneF) return false;
    if (q.trim() && !`${t.id} ${t.title}`.toLowerCase().includes(q.trim().toLowerCase())) return false;
    return true;
  });

  // 排序（preset 有指定就用它，否則用手動排序）
  const effSort = (activePreset && activePreset.sort) || sortBy;
  const sortFn = {
    version: (a, b) => String(b.version).localeCompare(String(a.version), undefined, { numeric: true }) || (b.updatedAt < a.updatedAt ? -1 : 1),
    stale:   (a, b) => (a.updatedAt < b.updatedAt ? -1 : 1),
    updated: (a, b) => (b.updatedAt < a.updatedAt ? -1 : 1),
    id:      (a, b) => a.id.localeCompare(b.id, undefined, { numeric: true }),
  }[effSort];

  const anyFilter = prod !== "ALL" || ver !== "ALL" || pos !== "ALL" || assignee !== "ALL" || laneF !== "ALL" || q.trim();
  const resetFilters = () => { setProd("ALL"); setVer("ALL"); setPos("ALL"); setAssignee("ALL"); setLaneF("ALL"); setQ(""); };
  const pickPreset = (k) => { resetFilters(); setPreset((cur) => (cur === k ? null : k)); };

  // 一鍵複製：依當下篩選 + 檢視，排成 TG 文字。每票只留「票號 + 標題」，票號嵌 Linear 連結（HTML）。
  const copyText = async () => {
    const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
    const now = new Date().toLocaleString("zh-TW", { hour12: false }).replace(/:\d\d$/, "");
    const parts = [];
    if (activePreset) parts.push(activePreset.label);
    else {
      if (prod !== "ALL") parts.push(prod);
      if (ver !== "ALL") parts.push(ver === "__none" ? "無版號" : "v" + ver);
      if (pos !== "ALL") parts.push(pos);
      if (laneF !== "ALL") parts.push((QA_LANES.find((l) => l.key === laneF) || {}).pill || laneF);
      if (assignee !== "ALL") parts.push(assignee);
      if (q.trim()) parts.push(`「${q.trim()}」`);
    }
    const fdesc = parts.length ? parts.join(" · ") : "全部";
    const groups = view === "version"
      ? [...new Set(rows.map((t) => t.version))]
          .sort((a, b) => { if (a === b) return 0; if (!a) return 1; if (!b) return -1; return b.localeCompare(a, undefined, { numeric: true }); })
          .map((v) => ({ name: v ? "v" + v : "無版號", items: rows.filter((t) => t.version === v).sort(sortFn) }))
      : QA_LANES.map((L) => ({ name: L.pill, items: rows.filter((t) => t.lane === L.key).sort(sortFn) }));

    const plain = [`QA 進測狀態｜${fdesc}｜${rows.length} 張`, `更新 ${now}`, ""];
    const html = [`QA 進測狀態｜${esc(fdesc)}｜${rows.length} 張`, `更新 ${now}`, ""];
    for (const g of groups) {
      if (!g.items.length) continue;
      plain.push(`【${g.name}】${g.items.length}`);
      html.push(`<b>【${esc(g.name)}】${g.items.length}</b>`);
      for (const t of g.items) {
        plain.push(`${t.id} ${t.title}\nhttps://linear.app/${LINEAR_WS}/issue/${t.id}`);
        html.push(`<a href="${esc(t.url)}">${t.id}</a> ${esc(t.title)}`);
      }
      plain.push(""); html.push("");
    }
    const plainStr = plain.join("\n").trim();
    const htmlStr = html.join("<br>").trim();
    try {
      if (navigator.clipboard && window.ClipboardItem) {
        await navigator.clipboard.write([new window.ClipboardItem({
          "text/html": new Blob([htmlStr], { type: "text/html" }),
          "text/plain": new Blob([plainStr], { type: "text/plain" }),
        })]);
      } else {
        await navigator.clipboard.writeText(plainStr);
      }
      setCopied(true); setTimeout(() => setCopied(false), 1800);
    } catch (e) {
      try { await navigator.clipboard.writeText(plainStr); setCopied(true); setTimeout(() => setCopied(false), 1800); } catch (_) { /* ignore */ }
    }
  };

  const kpis = [
    { n: stats.risk || 0, l: "帶病上線", c: "var(--qa-risk)", pk: "risk" },
    { n: stats.slipped || 0, l: "延期", c: "var(--qa-wait)", pk: "slipped" },
    { n: stats.onMain || 0, l: "已上正式", c: "var(--qa-pass)", pk: "onmain" },
    { n: stats.inDev || 0, l: "開發中", c: "var(--qa-test)", pk: "indev" },
    { n: (stats.total || 0) - (stats.known || 0), l: "查無 code", c: "var(--qa-none)", pk: "no_branch" },
  ];

  return (
    <div>
      <div className="flex items-center justify-between flex-wrap gap-2 mb-4">
        <h2 className="text-xl font-bold text-fg">QA 進測看板</h2>
        <div className="flex items-center gap-3">
          <span className="text-[11px] text-fg3">
            Linear + GitLab 實測 · 更新 {generatedAt ? new Date(generatedAt).toLocaleString("zh-TW", { hour12: false }) : "—"}
          </span>
          <button onClick={doRefresh} disabled={refreshing} title="重新抓 Linear + GitLab 重算"
                  className="inline-flex items-center gap-1.5 text-[11.5px] font-semibold text-fg2 hover:text-fg border border-border rounded-lg px-2.5 py-1 transition-colors disabled:opacity-50">
            <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
                 strokeLinecap="round" strokeLinejoin="round" className={refreshing ? "animate-spin" : ""}>
              <path d="M20.5 12a8.5 8.5 0 1 1-2.5-6" />
              <path d="M20.5 4v5h-5" />
            </svg>
            {refreshing ? "重算中…" : "重新整理"}
          </button>
        </div>
      </div>

      {stats.risk > 0 && (
        <div className="rounded-lg px-4 py-3 mb-4 text-[12.5px]" style={{ background: "var(--qa-risk-bg)", border: "1px solid var(--qa-risk)", color: "var(--qa-risk)" }}>
          <b>{stats.risk} 張帶病上線</b>：code 已在 main（正式站），但 Linear QA 狀態尚未通過。
          {stats.slipped > 0 && <span> 另有 <b style={{ color: "var(--qa-wait)" }}>{stats.slipped}</b> 張延期票。</span>}
        </div>
      )}

      <div className="grid grid-cols-2 md:grid-cols-5 gap-2.5 mb-5">
        {kpis.map((k) => (
          <button key={k.l} onClick={() => pickPreset(k.pk)}
            title={preset === k.pk ? "再點一次取消篩選" : `只看「${k.l}」的票`}
            className={`text-left rounded-lg border p-3 relative overflow-hidden transition-shadow hover:shadow-sm cursor-pointer
              ${preset === k.pk ? "bg-surface2" : "bg-surface border-border"}`}
            style={preset === k.pk ? { borderColor: k.c, borderWidth: "1.5px" } : {}}>
            <div className="absolute left-0 top-0 bottom-0 w-0.5" style={{ background: k.c }} />
            <div className="text-[30px] font-bold leading-none" style={{ color: k.c }}>{k.n}</div>
            <div className="text-[13px] text-fg2 font-semibold mt-2">{k.l}</div>
          </button>
        ))}
      </div>

      {/* 快捷篩選 */}
      <div className="flex flex-wrap items-center gap-1.5 mb-2.5">
        <span className="text-[11px] text-fg3 mr-0.5">快捷</span>
        {PRESETS.filter((p) => p.chip !== false).map((p) => (
          <button key={p.key} onClick={() => pickPreset(p.key)} title={p.desc}
                  className={`text-[12px] font-semibold px-2.5 py-1 rounded-full border transition-colors
                    ${preset === p.key ? "grad-btn" : "bg-surface border-border text-fg2 hover:text-fg hover:border-borderStrong"}`}>
            {p.label}
          </button>
        ))}
      </div>

      {/* 篩選 + 排序列 */}
      <div className="flex flex-wrap items-center gap-2 mb-3">
        <div className="inline-flex bg-surface2 border border-border rounded-lg p-0.5">
          {["ALL", "VF", "PG", "Pheme"].map((p) => (
            <button key={p} onClick={() => setProd(p)}
                    className={`text-[12px] font-semibold px-2.5 py-1 rounded-md transition-colors
                      ${prod === p ? "bg-surface text-fg shadow-sm" : "text-fg3 hover:text-fg2"}`}>
              {p === "ALL" ? "全部" : p}
            </button>
          ))}
        </div>

        {activePreset && (
          <span className="text-[12px] text-fg2 bg-surface2 border border-border rounded-lg px-3 py-1.5">
            快捷篩選：<b className="text-fg">{activePreset.label}</b>
            <button onClick={() => setPreset(null)} className="ml-2 text-fg3 hover:text-fg" title="取消快捷篩選"><Icon.X className="w-3.5 h-3.5" /></button>
          </span>
        )}

        {!activePreset && (<>
        <select value={ver} onChange={(e) => setVer(e.target.value)} className={SEL} title="版本">
          <option value="ALL">全部版本</option>
          {versions.map((v) => <option key={v} value={v}>v{v}</option>)}
          <option value="__none">無版號</option>
        </select>

        <select value={pos} onChange={(e) => setPos(e.target.value)} className={SEL} title="環境位置">
          <option value="ALL">全部位置</option>
          {POS_OPTS.map((p) => <option key={p} value={p}>{p}</option>)}
          <option value="查無">查無 code</option>
        </select>

        <select value={laneF} onChange={(e) => setLaneF(e.target.value)} className={SEL} title="狀態">
          <option value="ALL">全部狀態</option>
          {QA_LANES.map((L) => <option key={L.key} value={L.key}>{L.pill}</option>)}
        </select>

        <select value={assignee} onChange={(e) => setAssignee(e.target.value)} className={SEL} title="負責人">
          <option value="ALL">全部負責人</option>
          {assignees.map((a) => <option key={a} value={a}>{a}</option>)}
        </select>

        <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="搜尋 票號 / 標題"
               className="text-[12.5px] bg-surface border border-border rounded-lg px-3 py-1.5 text-fg placeholder:text-fg3 w-44" />
        </>)}

        <div className="flex items-center gap-1.5 ml-auto">
          <span className="text-[11px] text-fg3">檢視</span>
          <div className="inline-flex bg-surface2 border border-border rounded-lg p-0.5">
            {[["lane","風險"],["version","版號"]].map(([k,lbl]) => (
              <button key={k} onClick={() => setView(k)}
                      className={`text-[12px] font-semibold px-2.5 py-1 rounded-md transition-colors
                        ${view === k ? "bg-surface text-fg shadow-sm" : "text-fg3 hover:text-fg2"}`}>
                {lbl}
              </button>
            ))}
          </div>
          <span className="text-[11px] text-fg3 ml-1.5">排序</span>
          <select value={effSort} disabled={!!(activePreset && activePreset.sort)}
                  onChange={(e) => setSortBy(e.target.value)} className={`${SEL} disabled:opacity-50`} title="排序">
            <option value="version">版本（新→舊）</option>
            <option value="stale">停滯天數（久→新）</option>
            <option value="updated">更新時間（新→舊）</option>
            <option value="id">票號</option>
          </select>
        </div>
      </div>

      <div className="flex items-center gap-3 mb-4 text-[11px] text-fg3">
        <span>顯示 <b className="text-fg2">{rows.length}</b> / {tickets.length} 張</span>
        {(anyFilter || preset) && <button onClick={() => { resetFilters(); setPreset(null); }} className="text-fg2 underline hover:text-fg">清除</button>}
        <button onClick={copyText} disabled={!rows.length}
                className="ml-auto inline-flex items-center gap-1.5 text-[11.5px] font-semibold text-fg2 hover:text-fg border border-border rounded-lg px-2.5 py-1 transition-colors disabled:opacity-40">
          {copied
            ? <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" style={{ color: "var(--qa-pass)" }}><path d="M20 6 9 17l-5-5" /></svg>
            : <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="12" height="12" rx="2" /><path d="M5 15V5a2 2 0 0 1 2-2h10" /></svg>}
          {copied ? "已複製" : "一鍵複製"}
        </button>
      </div>

      {rows.length === 0 && (
        <div className="text-fg3 text-sm py-12 text-center">
          沒有符合篩選條件的票
          {anyFilter && <button onClick={resetFilters} className="ml-2 text-fg2 underline">清除篩選</button>}
        </div>
      )}

      {view === "lane" && QA_LANES.map((L) => {
        const lr = rows.filter((t) => t.lane === L.key).sort(sortFn);
        if (!lr.length) return null;
        return (
          <div key={L.key} className="mb-4">
            <div className="flex items-center gap-2 mb-2">
              <span className="w-2 h-2 rounded-sm" style={{ background: L.color }} />
              <span className="text-[14px] font-bold" style={{ color: L.att ? L.color : "inherit" }}>{L.name}</span>
              <span className="text-[11px] text-fg3 bg-surface2 px-2 py-0.5 rounded-full">{lr.length}</span>
              <span className="flex-1 h-px bg-border" />
            </div>
            {lr.map((t) => <QaTicketRow key={t.id} t={t} />)}
          </div>
        );
      })}

      {view === "version" && (() => {
        const vers = [...new Set(rows.map((t) => t.version))].sort((a, b) => {
          if (a === b) return 0; if (!a) return 1; if (!b) return -1;
          return b.localeCompare(a, undefined, { numeric: true });
        });
        return vers.map((v) => {
          const vr = rows.filter((t) => t.version === v).sort(sortFn);
          if (!vr.length) return null;
          const riskN = vr.filter((t) => t.lane === "risk").length;
          const doneN = vr.filter((t) => t.pos && t.pos.includes("main")).length;
          return (
            <div key={v || "none"} className="mb-4">
              <div className="flex items-center gap-2 mb-2">
                <span className="mono text-[14px] font-bold text-fg">{v ? "v" + v : "無版號"}</span>
                <span className="text-[11px] text-fg3 bg-surface2 px-2 py-0.5 rounded-full">{vr.length}</span>
                {riskN > 0 && <span className="text-[10.5px] font-bold px-2 py-0.5 rounded-full" style={{ background: "var(--qa-risk-bg)", color: "var(--qa-risk)" }}>帶病上線 {riskN}</span>}
                <span className="text-[11px] text-fg3">已上正式 {doneN}/{vr.length}</span>
                <span className="flex-1 h-px bg-border" />
              </div>
              {vr.map((t) => <QaTicketRow key={t.id} t={t} />)}
            </div>
          );
        });
      })()}

      <p className="text-[11px] text-fg3 mt-6 pt-3 border-t border-border leading-relaxed">
        環境軸實掃 GitLab group <code className="bg-surface2 px-1 rounded">gm5462110</code> 分支與 merged MR，用合併圖推算位置。
        流程 <code className="bg-surface2 px-1 rounded">分支 → uat → dev → main</code>；uat=最新功能、dev=QA測試站、main=正式站。
        延期票（≥2 個 Release label）只計入最新版 release note。此頁僅 admin 可見。
      </p>
    </div>
  );
}

// ═══════════════════════════════════════════════════════════════════
// Admin Panel — only visible/accessible when role === 'admin'
// ═══════════════════════════════════════════════════════════════════

const ROLE_META = {
  admin:  { label: "Admin",  cls: "tint-hot",  desc: "完整權限：管理使用者、所有功能" },
  pm:     { label: "PM",     cls: "tint-vf",   desc: "可編輯 Schedule、看 Linear 票號 / Notion 連結" },
  qa:     { label: "QA",     cls: "tint-new",  desc: "可看 release notes + QA 進測看板" },
  viewer: { label: "Viewer", cls: "tint-none", desc: "只能看 release notes，無 PM 功能" },
  // 'editor' 角色保留 enum 但 UI 不再選用（與 pm 同權限，視為相容舊資料）
  editor: { label: "Editor", cls: "tint-vf",   desc: "（舊角色，等同 PM）", hidden: true },
};

function fmtDateTime(s) {
  if (!s) return "—";
  const d = new Date(s);
  const yy = d.getFullYear();
  const mm = String(d.getMonth() + 1).padStart(2, "0");
  const dd = String(d.getDate()).padStart(2, "0");
  const hh = String(d.getHours()).padStart(2, "0");
  const mi = String(d.getMinutes()).padStart(2, "0");
  return `${yy}-${mm}-${dd} ${hh}:${mi}`;
}

function EditUserModal({ user, onClose, onSave, currentUserId }) {
  const [role, setRole] = useState(user.role);
  const [isActive, setIsActive] = useState(user.is_active);
  const [saving, setSaving] = useState(false);
  const [error, setError] = useState(null);
  const isSelf = user.id === currentUserId;

  const submit = async () => {
    if (saving) return;
    setSaving(true);
    setError(null);
    const { data, error } = await supabase
      .from("profiles")
      .update({ role, is_active: isActive })
      .eq("id", user.id)
      .select()
      .single();
    setSaving(false);
    if (error) {
      setError(error.message);
      return;
    }
    if (data) {
      logAudit("update_user", "user", user.id, {
        email: user.email,
        role_from: user.role, role_to: role,
        active_from: user.is_active, active_to: isActive,
      });
      onSave(data);
    }
  };

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm px-5"
         onClick={onClose}>
      <div className="bg-surface border border-border rounded-2xl p-6 w-full max-w-[440px]"
           onClick={(e) => e.stopPropagation()}>
        <div className="flex items-center justify-between mb-1">
          <h3 className="text-fg text-[15px] font-semibold">編輯使用者</h3>
          <button onClick={onClose} className="text-fg3 hover:text-fg">
            <Icon.X className="w-4 h-4" />
          </button>
        </div>
        <div className="text-fg3 text-[12px] mono mb-5">{user.email}</div>

        <div>
          <label className="block text-fg2 text-[12px] mb-2">角色</label>
          <div className="flex flex-col gap-2">
            {Object.entries(ROLE_META)
              .filter(([key, meta]) => !meta.hidden || role === key) // hide deprecated unless user already has it
              .map(([key, meta]) => (
              <button key={key}
                onClick={() => setRole(key)}
                disabled={isSelf && key !== "admin"}
                title={isSelf && key !== "admin" ? "不能改自己的 admin 角色（避免鎖死）" : ""}
                className={`text-left rounded-lg p-3 border transition-colors ${
                  role === key
                    ? "border-borderStrong bg-surface2"
                    : "border-border hover:border-borderStrong hover:bg-surface2"
                } ${isSelf && key !== "admin" ? "opacity-40 cursor-not-allowed" : ""}`}>
                <div className="flex items-center gap-2 mb-0.5">
                  <span className={`${meta.cls} mono text-[10.5px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded`}>
                    {meta.label}
                  </span>
                  {role === key && (
                    <span className="text-fg3 text-[11px] inline-flex items-center gap-1">
                      <Icon.Check className="w-3 h-3" />目前選擇
                    </span>
                  )}
                </div>
                <div className="text-fg3 text-[12px]">{meta.desc}</div>
              </button>
            ))}
          </div>
        </div>

        <div className="mt-5 pt-5 border-t border-border">
          <label className="flex items-center justify-between cursor-pointer">
            <div>
              <div className="text-fg text-[13.5px] font-medium">啟用帳號</div>
              <div className="text-fg3 text-[12px] mt-0.5">關閉後使用者下次重新整理時無法存取</div>
            </div>
            <button
              onClick={() => !isSelf && setIsActive(!isActive)}
              disabled={isSelf}
              title={isSelf ? "不能停用自己" : ""}
              className={`relative w-11 h-6 rounded-full transition-colors
                ${isActive ? "bg-success" : "bg-surface2 border border-border"}
                ${isSelf ? "opacity-50 cursor-not-allowed" : ""}`}>
              <span className={`absolute top-0.5 w-5 h-5 rounded-full bg-bg transition-transform
                ${isActive ? "translate-x-[22px]" : "translate-x-0.5"}`} />
            </button>
          </label>
        </div>

        {error && (
          <div className="mt-4 text-red-400 text-[12px]">儲存失敗：{error}</div>
        )}

        <div className="flex items-center gap-2 mt-5">
          <div className="flex-1" />
          <button onClick={onClose}
            className="rounded-lg px-4 h-9 text-[12.5px] border border-border bg-surface text-fg2 hover:text-fg hover:border-borderStrong transition-colors">
            取消
          </button>
          <button onClick={submit} disabled={saving}
            className="rounded-lg px-4 h-9 text-[12.5px] grad-btn font-semibold hover:opacity-90 disabled:opacity-50 transition-opacity">
            {saving ? "儲存中..." : "儲存"}
          </button>
        </div>
      </div>
    </div>
  );
}

// ═══════════════════════════════════════════════════════════════════
// PM 休假自動化 UI（A1 休假模式 / 待決佇列，A5 Release Note 草稿審核）
// 這一段是後加的，刻意獨立成自己的元件，不動原有的渲染邏輯。
// ═══════════════════════════════════════════════════════════════════

/** 帶 Bearer token 打 Worker 的小工具；失敗直接 throw，呼叫端 catch 顯示訊息 */
async function pmApi(path, opts = {}) {
  const { data: { session } } = await supabase.auth.getSession();
  const token = session?.access_token;
  if (!token) throw new Error("尚未登入或 session 已失效，請重新整理");
  const headers = { Authorization: `Bearer ${token}`, ...(opts.headers || {}) };
  if (opts.body) headers["Content-Type"] = "application/json";
  const res = await fetch(`${WORKER_URL}${path}`, { ...opts, headers });
  const ct = res.headers.get("content-type") || "";
  const data = ct.includes("application/json")
    ? await res.json()
    : { ok: false, error: `HTTP ${res.status}` };
  if (!data.ok) throw new Error(data.error || `HTTP ${res.status}`);
  return data;
}

// 通知類型 —— 決定休假期間哪一位代理人收到哪一種訊息
const NOTIFY_KINDS = [
  { key: "triage",         label: "待確認需求",   hint: "TG 群組疑似需求，附「開單／略過」按鈕" },
  { key: "ticket_created", label: "開單成功",     hint: "bot 開了一張票的通知" },
  { key: "error",          label: "系統錯誤",     hint: "開單失敗等技術問題" },
  { key: "decision",       label: "決策提醒",     hint: "cycle 指派超額" },
  { key: "digest",         label: "Cycle digest", hint: "每日 10:00 的 cycle 狀態" },
  { key: "stale",          label: "停滯票提醒",   hint: "新票超過 24h 沒 triage" },
  { key: "release",        label: "版本草稿待審", hint: "週一產出的 release note 草稿" },
];

const KIND_LABEL = Object.fromEntries(NOTIFY_KINDS.map(k => [k.key, k.label]));

/** bot 送出的訊息帶 HTML 標籤（parse_mode: HTML）。這裡還原成純文字顯示，
 *  刻意不用 dangerouslySetInnerHTML —— 內容含使用者在 TG 打的字，不冒這個險。 */
function stripTags(html) {
  return String(html || "")
    .replace(/<[^>]+>/g, "")
    .replace(/&lt;/g, "<").replace(/&gt;/g, ">")
    .replace(/&quot;/g, '"').replace(/&#39;/g, "'")
    .replace(/&amp;/g, "&");
}

const EMPTY_ABSENCE = {
  active: false, from: "", to: "", deputies: [],
  fallback_tg_id: "", mirror_to_pm: true,
};

function daysBetween(a, b) {
  return Math.ceil((Date.parse(b) - Date.parse(a)) / 86400000);
}

// ---------- A1 休假模式 ----------
function HolidayPanel() {
  const [abs, setAbs] = useState(EMPTY_ABSENCE);
  const [inbox, setInbox] = useState([]);
  const [onlyOpen, setOnlyOpen] = useState(true);
  const [loading, setLoading] = useState(true);
  const [saving, setSaving] = useState(false);
  const [msg, setMsg] = useState(null);

  const flash = (type, text) => { setMsg({ type, text }); setTimeout(() => setMsg(null), 6000); };

  const loadInbox = useCallback(async (open) => {
    try {
      const d = await pmApi(`/pm-inbox${open ? "?open=1" : ""}`);
      setInbox(d.items || []);
    } catch (e) { console.error("pm-inbox:", e.message); }
  }, []);

  useEffect(() => {
    let cancelled = false;
    (async () => {
      setLoading(true);
      try {
        const d = await pmApi("/absence");
        if (!cancelled) setAbs({ ...EMPTY_ABSENCE, ...(d.absence || {}) });
      } catch (e) {
        if (!cancelled) flash("err", e.message);
      }
      await loadInbox(onlyOpen);
      if (!cancelled) setLoading(false);
    })();
    return () => { cancelled = true; };
  }, []);

  useEffect(() => { loadInbox(onlyOpen); }, [onlyOpen, loadInbox]);

  const patch = (k, v) => setAbs(prev => ({ ...prev, [k]: v }));
  const patchDeputy = (i, k, v) =>
    setAbs(prev => ({ ...prev, deputies: prev.deputies.map((d, n) => n === i ? { ...d, [k]: v } : d) }));
  const toggleScope = (i, kind) =>
    setAbs(prev => ({
      ...prev,
      deputies: prev.deputies.map((d, n) => {
        if (n !== i) return d;
        const cur = Array.isArray(d.scope) ? d.scope : [];
        return { ...d, scope: cur.includes(kind) ? cur.filter(x => x !== kind) : [...cur, kind] };
      }),
    }));
  const addDeputy = () =>
    setAbs(prev => ({ ...prev, deputies: [...prev.deputies, { name: "", tg_id: "", scope: [] }] }));
  const removeDeputy = (i) =>
    setAbs(prev => ({ ...prev, deputies: prev.deputies.filter((_, n) => n !== i) }));

  const save = async () => {
    if (saving) return;
    setSaving(true);
    try {
      const d = await pmApi("/absence", { method: "POST", body: JSON.stringify(abs) });
      setAbs({ ...EMPTY_ABSENCE, ...(d.absence || {}) });
      flash("ok", d.absence.active ? "休假模式已開啟 —— 通知會改送代理人" : "已存檔（目前是正常模式）");
    } catch (e) { flash("err", e.message); }
    finally { setSaving(false); }
  };

  const markHandled = async (id) => {
    try {
      await pmApi("/pm-inbox/mark", { method: "POST", body: JSON.stringify({ ids: [id] }) });
      setInbox(prev => onlyOpen ? prev.filter(x => x.id !== id) : prev.map(x => x.id === id ? { ...x, handled: true } : x));
    } catch (e) { flash("err", e.message); }
  };

  const today = new Date().toLocaleDateString("en-CA", { timeZone: "Asia/Taipei" });
  const inWindow = abs.active && (!abs.from || today >= abs.from) && (!abs.to || today <= abs.to);
  const daysLeft = abs.to ? daysBetween(today, abs.to) : null;

  if (loading) return <div className="text-fg3 text-[13px]">載入中…</div>;

  return (
    <div className="flex flex-col gap-5">
      {/* 狀態橫幅 */}
      <div className={`rounded-xl border px-4 py-3 flex items-center gap-3 flex-wrap
        ${inWindow ? "border-hotfix/50 bg-hotfix/10" : "border-border bg-surface"}`}>
        <span className={inWindow ? "text-hotfix" : "text-success"}>
          {inWindow ? <Icon.Away className="w-[18px] h-[18px]" /> : <Icon.Dot className="w-2.5 h-2.5" />}
        </span>
        <div className="min-w-0">
          <div className="text-fg text-[14px] font-semibold">
            {inWindow ? "休假模式進行中" : "正常模式"}
          </div>
          <div className="text-fg3 text-[12px] mt-0.5">
            {inWindow
              ? <>通知改送代理人{daysLeft != null && daysLeft >= 0 ? `，還剩 ${daysLeft} 天` : ""}</>
              : <>所有通知照常私訊 PM 本人</>}
          </div>
        </div>
        <div className="flex-1" />
        {abs.active && !inWindow && (
          <span className="text-[11.5px] text-fg3 mono">已勾選啟用，但今天不在 {abs.from || "—"} ~ {abs.to || "—"} 區間內</span>
        )}
      </div>

      {/* 設定 */}
      <div className="bg-surface border border-border rounded-xl overflow-hidden">
        <div className="px-4 py-3 border-b border-border">
          <h3 className="text-fg text-[14px] font-semibold">休假設定</h3>
          <p className="text-fg3 text-[11.5px] mt-1">
            改完按「儲存設定」即時生效，不需要重新部署任何程式。系統最多延遲 1 分鐘（快取）。
          </p>
        </div>

        <div className="p-4 flex flex-col gap-4">
          <label className="flex items-center gap-2.5 cursor-pointer">
            <input type="checkbox" checked={!!abs.active}
              onChange={e => patch("active", e.target.checked)}
              className="w-4 h-4 accent-current" />
            <span className="text-fg text-[13.5px] font-medium">啟用休假模式</span>
            <span className="text-fg3 text-[11.5px]">（還要今天落在下面的日期區間內才會真的生效）</span>
          </label>

          <div className="flex items-center gap-3 flex-wrap">
            <div className="flex flex-col gap-1">
              <span className="text-fg3 text-[11px] mono uppercase tracking-wider">起</span>
              <input type="date" value={abs.from || ""} onChange={e => patch("from", e.target.value)}
                className="h-8 px-2.5 bg-surface2 border border-border rounded-md text-fg text-[12.5px] outline-none focus:border-borderStrong" />
            </div>
            <div className="flex flex-col gap-1">
              <span className="text-fg3 text-[11px] mono uppercase tracking-wider">迄</span>
              <input type="date" value={abs.to || ""} onChange={e => patch("to", e.target.value)}
                className="h-8 px-2.5 bg-surface2 border border-border rounded-md text-fg text-[12.5px] outline-none focus:border-borderStrong" />
            </div>
          </div>

          {/* 代理人 */}
          <div className="flex flex-col gap-3">
            <div className="flex items-center gap-2">
              <span className="text-fg text-[13px] font-semibold">代理人</span>
              <span className="text-fg3 text-[11.5px]">勾選的類型才會送給這個人</span>
              <div className="flex-1" />
              <button onClick={addDeputy}
                className="text-[11.5px] font-semibold text-fg2 hover:text-fg border border-border rounded-lg px-2.5 py-1 transition-colors">
                ＋ 新增代理人
              </button>
            </div>

            {abs.deputies.length === 0 && (
              <div className="text-fg3 text-[12.5px] border border-dashed border-border rounded-lg px-4 py-6 text-center">
                還沒有代理人。休假期間沒有代理人的話，通知會退回給 PM 本人。
              </div>
            )}

            {abs.deputies.map((d, i) => (
              <div key={i} className="border border-border rounded-lg p-3 flex flex-col gap-3 bg-surface2/40">
                <div className="flex items-center gap-2 flex-wrap">
                  <input type="text" value={d.name || ""} placeholder="姓名（例：CCC）"
                    onChange={e => patchDeputy(i, "name", e.target.value)}
                    className="h-9 sm:h-8 px-2.5 bg-surface2 border border-border rounded-md text-fg text-[12.5px] outline-none focus:border-borderStrong w-full sm:w-[150px]" />
                  <input type="text" value={d.tg_id || ""} placeholder="TG numeric user id"
                    onChange={e => patchDeputy(i, "tg_id", e.target.value)}
                    className="h-9 sm:h-8 px-2.5 bg-surface2 border border-border rounded-md text-fg text-[12.5px] mono outline-none focus:border-borderStrong w-full sm:w-[200px]" />
                  <div className="hidden sm:block flex-1" />
                  <button onClick={() => removeDeputy(i)}
                    className="text-[11.5px] text-red-400 hover:text-red-300 border border-border rounded-lg px-2.5 py-1 transition-colors">
                    移除
                  </button>
                </div>
                <div className="flex items-center gap-1.5 flex-wrap">
                  {NOTIFY_KINDS.map(k => {
                    const on = Array.isArray(d.scope) && d.scope.includes(k.key);
                    return (
                      <button key={k.key} title={k.hint} onClick={() => toggleScope(i, k.key)}
                        className={`text-[11px] px-2 py-1 rounded-md border transition-colors
                          ${on ? "border-transparent grad-tint text-fg" : "border-border text-fg3 hover:text-fg2"}`}>
                        {k.label}
                      </button>
                    );
                  })}
                </div>
              </div>
            ))}
          </div>

          <div className="flex items-center gap-3 flex-wrap">
            <div className="flex flex-col gap-1">
              <span className="text-fg3 text-[11px] mono uppercase tracking-wider">Fallback TG id</span>
              <input type="text" value={abs.fallback_tg_id || ""} placeholder="沒人符合類型時送給誰"
                onChange={e => patch("fallback_tg_id", e.target.value)}
                className="h-9 sm:h-8 px-2.5 bg-surface2 border border-border rounded-md text-fg text-[12.5px] mono outline-none focus:border-borderStrong w-full sm:w-[220px]" />
            </div>
            <label className="flex items-center gap-2 cursor-pointer sm:mt-5">
              <input type="checkbox" checked={!!abs.mirror_to_pm}
                onChange={e => patch("mirror_to_pm", e.target.checked)}
                className="w-4 h-4 accent-current" />
              <span className="text-fg2 text-[12.5px]">同時也送一份給 PM（測試期建議開，正式休假關掉）</span>
            </label>
          </div>

          <div className="flex items-center gap-3">
            <button onClick={save} disabled={saving}
              className="text-[12.5px] font-semibold text-fg border border-borderStrong rounded-lg px-4 py-1.5 transition-colors hover:bg-surface2 disabled:opacity-50">
              {saving ? "儲存中…" : "儲存設定"}
            </button>
            {msg && (
              <FlashMsg msg={msg} size="text-[12px]" />
            )}
          </div>
        </div>
      </div>

      {/* 待決佇列 */}
      <div className="bg-surface border border-border rounded-xl overflow-hidden">
        <div className="px-4 py-3 border-b border-border flex items-center gap-3 flex-wrap">
          <h3 className="text-fg text-[14px] font-semibold">待決佇列</h3>
          <span className="text-fg3 text-[11.5px]">所有原本要私訊 PM 的通知都會留一份在這</span>
          <div className="flex-1" />
          <label className="flex items-center gap-2 cursor-pointer">
            <input type="checkbox" checked={onlyOpen} onChange={e => setOnlyOpen(e.target.checked)}
              className="w-3.5 h-3.5 accent-current" />
            <span className="text-fg2 text-[12px]">只看未處理</span>
          </label>
          <span className="text-fg3 text-[11.5px] mono">{inbox.length}</span>
        </div>
        <div className="divide-y divide-border max-h-[560px] overflow-y-auto">
          {inbox.map(item => (
            <div key={item.id} className="px-4 py-3 flex flex-col sm:flex-row items-start gap-2 sm:gap-3 text-[12.5px]">
              <div className="flex items-center gap-2 shrink-0 sm:contents">
                <span className="mono text-[11px] text-fg3 shrink-0 sm:w-[110px]">{fmtDateTime(item.created_at)}</span>
                <span className="tint-fix mono rounded px-1.5 py-0.5 text-[10.5px] font-semibold whitespace-nowrap shrink-0">
                  {KIND_LABEL[item.kind] || item.kind}
                </span>
              </div>
              <div className="min-w-0 w-full sm:flex-1">
                <div className="text-fg2 whitespace-pre-wrap break-words leading-relaxed">
                  {stripTags(item.body)}
                </div>
                <div className="text-fg3 text-[11px] mt-1 mono">
                  {item.during_absence ? "休假期間 · " : ""}送達 {(item.sent_to || []).length} 人
                  {item.handled && item.handled_by ? ` · 已由 ${item.handled_by} 處理` : ""}
                </div>
              </div>
              {!item.handled && (
                <button onClick={() => markHandled(item.id)}
                  className="text-[11.5px] text-fg2 hover:text-fg border border-border rounded-lg px-2.5 py-1 shrink-0 transition-colors">
                  標記已處理
                </button>
              )}
            </div>
          ))}
          {inbox.length === 0 && (
            <div className="px-4 py-8 text-center text-fg3 text-[13px]">
              {onlyOpen ? "沒有待處理的項目" : "尚無紀錄"}
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

// ---------- 群組綁定（哪個 TG 群組開到哪個 Linear team）----------
//
// 這頁存在的原因：以前這份設定要手寫 JSON、還要自己抄 chat_id，
// 抄錯一個字就整份設定失效，而且失效時是靜默的（票照開，只是開錯 team）。
// 這裡把 chat_id 藏起來，只讓人用下拉選單挑。

const BIND_EMPTY = { products: [], format: "standard" };

/** 一筆設定可能是新寫法 products: [] 或舊寫法 product: "PG"，統一攤平成陣列 */
function bindProducts(entry) {
  if (!entry) return [];
  const src = Array.isArray(entry.products) ? entry.products : (entry.product ? [entry.product] : []);
  return src.map(p => String(p || "").toUpperCase()).filter(Boolean);
}

/** config 可能用 chat_id 或群組名稱當 key，兩種都要對得到 */
function bindOf(config, chat) {
  const entry = config[String(chat.chat_id)] || (chat.title ? config[String(chat.title).trim()] : null);
  if (!entry) return BIND_EMPTY;
  return { products: bindProducts(entry), format: entry.format || "standard" };
}

function ChatBindingPanel() {
  const [config, setConfig] = useState({});
  const [chats, setChats] = useState([]);
  const [products, setProducts] = useState([]);
  const [formats, setFormats] = useState([]);
  const [loading, setLoading] = useState(true);
  const [saving, setSaving] = useState(false);
  const [dirty, setDirty] = useState(false);
  const [msg, setMsg] = useState(null);

  const flash = (type, text) => { setMsg({ type, text }); setTimeout(() => setMsg(null), 6000); };

  const load = useCallback(async () => {
    setLoading(true);
    try {
      const d = await pmApi("/chat-config");
      setConfig(d.config || {});
      setChats(d.chats || []);
      // 舊版 worker 的清單裡有一個 code="" 的「不指定」選項，多選介面用不到，濾掉
      setProducts((d.products || []).filter(p => p && p.code));
      setFormats(d.formats || []);
      setDirty(false);
    } catch (e) {
      flash("err", e.message);
    }
    setLoading(false);
  }, []);

  useEffect(() => { load(); }, [load]);

  // 一次寫兩個 key：chat_id（精準）＋ 群組名稱（備援）。
  // 群組從一般群升級成超級群時 chat_id 會變，名稱那把才救得回來。
  const setBind = (chat, patchObj) => {
    setConfig(prev => {
      const next = { ...bindOf(prev, chat), ...patchObj };
      const products = (next.products || []).filter(Boolean);
      const format = next.format || "standard";
      const out = { ...prev };
      const idKey = String(chat.chat_id);
      const titleKey = String(chat.title || "").trim();
      if (products.length === 0 && format === "standard") {
        delete out[idKey];
        if (titleKey) delete out[titleKey];
      } else {
        out[idKey] = { products, format, note: titleKey };
        if (titleKey) out[titleKey] = { products, format, note: "名稱備援" };
      }
      return out;
    });
    setDirty(true);
  };

  /** 點一下切換某個產品在不在候選清單裡 */
  const toggleProduct = (chat, code) => {
    const cur = bindOf(config, chat).products;
    setBind(chat, {
      products: cur.includes(code) ? cur.filter(c => c !== code) : [...cur, code],
    });
  };

  const dropOrphan = (key) => {
    setConfig(prev => { const out = { ...prev }; delete out[key]; return out; });
    setDirty(true);
  };

  const save = async () => {
    setSaving(true);
    try {
      const r = await pmApi("/chat-config", { method: "POST", body: JSON.stringify({ config }) });
      setConfig(r.config || {});
      setDirty(false);
      flash("ok", `已儲存，${r.entries} 筆綁定生效中（下一則訊息就會照新設定走）`);
    } catch (e) {
      flash("err", e.message);
    }
    setSaving(false);
  };

  // 設定裡有、但不在 bot 可見群組清單裡的 key（手動加的、或群組已解散）
  const known = new Set();
  chats.forEach(c => {
    known.add(String(c.chat_id));
    if (c.title) known.add(String(c.title).trim());
  });
  const orphans = Object.keys(config).filter(k => !known.has(k));
  const boundCount = chats.filter(c => {
    const b = bindOf(config, c);
    return b.products.length > 0 || b.format !== "standard";
  }).length;

  if (loading) return <div className="text-fg3 text-[13px]">載入中…</div>;

  return (
    <div className="flex flex-col gap-5">
      <div className="bg-surface border border-border rounded-xl overflow-hidden">
        <div className="px-4 py-3 border-b border-border flex items-center gap-3 flex-wrap">
          <div className="min-w-0">
            <h3 className="text-fg text-[14px] font-semibold">群組 → Linear team 綁定</h3>
            <p className="text-fg3 text-[11.5px] mt-1 leading-relaxed">
              選<b className="text-fg2">一個</b> team：該群的票<b className="text-fg2">一律</b>開到那裡，AI 說什麼都不算。<br />
              選<b className="text-fg2">多個</b>：AI 只能在你挑的這幾個裡面判斷，絕不會開到清單外。
              都不選＝完全交給 AI。儲存後立刻生效，不用重新部署。
            </p>
          </div>
          <div className="flex-1" />
          <span className="text-fg3 text-[11.5px] mono shrink-0">{boundCount} / {chats.length} 已綁定</span>
          <button onClick={load}
            className="text-[11.5px] font-semibold text-fg2 hover:text-fg border border-border rounded-lg px-2.5 py-1 transition-colors shrink-0">
            重新整理
          </button>
        </div>

        <div className="divide-y divide-border">
          {chats.map(c => {
            const b = bindOf(config, c);
            const bound = b.products.length > 0;
            return (
              <div key={c.chat_id} className="px-4 py-3 flex flex-col sm:flex-row sm:items-center gap-3">
                <div className="min-w-0 sm:min-w-[200px] flex-1">
                  <div className="flex items-center gap-2">
                    <span className={`text-[13.5px] font-medium truncate ${bound ? "text-fg" : "text-fg2"}`}>
                      {c.title || "(未命名群組)"}
                    </span>
                    {bound && (
                      <span className="tint-fix mono rounded px-1.5 py-0.5 text-[10px] font-semibold shrink-0">
                        {b.products.length === 1 ? "已綁定" : `限 ${b.products.length} 個 team`}
                      </span>
                    )}
                  </div>
                  <div className="text-fg3 text-[11px] mono mt-0.5">
                    {c.chat_id}
                    {c.last_message_at ? ` · 最後訊息 ${fmtDateTime(c.last_message_at)}` : " · 尚無訊息"}
                  </div>
                </div>

                <div className="flex flex-col gap-1">
                  <span className="text-fg3 text-[10.5px] mono uppercase tracking-wider">
                    可開到哪些 team{b.products.length > 1 ? "（AI 在其中判斷）" : ""}
                  </span>
                  <div className="flex items-center gap-1.5 flex-wrap min-h-[32px]">
                    {products.map(p => {
                      const on = b.products.includes(p.code);
                      return (
                        <button key={p.code} onClick={() => toggleProduct(c, p.code)}
                          title={p.label}
                          className={`text-[11.5px] px-2.5 py-1 rounded-md border transition-colors
                            ${on ? "border-transparent grad-tint text-fg font-semibold" : "border-border text-fg3 hover:text-fg2"}`}>
                          {on && <Icon.Check className="w-3 h-3 inline-block mr-1 -mt-px" />}{p.code}
                        </button>
                      );
                    })}
                    {b.products.length === 0 && (
                      <span className="text-fg3 text-[11px] ml-1">未選＝讓 AI 判斷</span>
                    )}
                  </div>
                </div>

                <div className="flex flex-col gap-1">
                  <span className="text-fg3 text-[10.5px] mono uppercase tracking-wider">票面格式</span>
                  <select value={b.format || "standard"}
                    onChange={e => setBind(c, { format: e.target.value })}
                    className="h-9 sm:h-8 px-2 bg-surface2 border border-border rounded-md text-fg text-[12.5px] outline-none focus:border-borderStrong w-full sm:w-[280px]">
                    {formats.map(f => <option key={f.code} value={f.code}>{f.label}</option>)}
                  </select>
                </div>
              </div>
            );
          })}

          {chats.length === 0 && (
            <div className="px-4 py-8 text-center text-fg3 text-[13px] leading-relaxed">
              還沒看到任何群組。<br />
              群組要有人<b className="text-fg2">說過話</b>，bot 才會記錄到它——把 bot 拉進群後隨便發一則訊息，再按「重新整理」。
            </div>
          )}
        </div>
      </div>

      {orphans.length > 0 && (
        <div className="bg-surface border border-border rounded-xl overflow-hidden">
          <div className="px-4 py-3 border-b border-border">
            <h3 className="text-fg text-[14px] font-semibold">其他設定</h3>
            <p className="text-fg3 text-[11.5px] mt-1">
              這些 key 不在上面的群組清單裡——可能是名稱備援、手動加的，或群組已經解散。留著不影響運作。
            </p>
          </div>
          <div className="divide-y divide-border">
            {orphans.map(k => (
              <div key={k} className="px-4 py-2.5 flex items-center gap-3 text-[12.5px]">
                <span className="mono text-fg2 min-w-0 flex-1 truncate">{k}</span>
                <span className="text-fg3 mono text-[11.5px]">
                  {bindProducts(config[k]).join(" / ") || "—"} · {config[k]?.format || "standard"}
                </span>
                <button onClick={() => dropOrphan(k)}
                  className="text-[11.5px] text-red-400 hover:text-red-300 border border-border rounded-lg px-2.5 py-1 transition-colors shrink-0">
                  移除
                </button>
              </div>
            ))}
          </div>
        </div>
      )}

      <div className="flex items-center gap-3 flex-wrap">
        <button onClick={save} disabled={saving || !dirty}
          className="text-[12.5px] font-semibold text-fg border border-borderStrong rounded-lg px-4 py-1.5 transition-colors hover:bg-surface2 disabled:opacity-40">
          {saving ? "儲存中…" : dirty ? "儲存綁定" : "已儲存"}
        </button>
        {msg && (
          <FlashMsg msg={msg} size="text-[12px]" />
        )}
        {dirty && !msg && <span className="text-fg3 text-[11.5px]">有未儲存的變更</span>}
      </div>
    </div>
  );
}

// ---------- A5 Release Note 草稿審核 ----------
const DRAFT_PRODUCT_LABEL = { VF: "PredictGo", PG: "Predict Gaming" };

function DraftItemRow({ item, onChange, onRemove, product }) {
  return (
    <div className="border border-border rounded-lg p-3 flex flex-col gap-2 bg-surface2/40">
      <div className="flex items-center gap-2">
        <input type="text" value={item.name || ""} placeholder="條目標題"
          onChange={e => onChange({ ...item, name: e.target.value })}
          className="flex-1 h-8 px-2.5 bg-surface2 border border-border rounded-md text-fg text-[13px] outline-none focus:border-borderStrong" />
        <button onClick={onRemove}
          className="text-[11.5px] text-red-400 hover:text-red-300 border border-border rounded-lg px-2.5 py-1 transition-colors shrink-0">
          刪除
        </button>
      </div>
      <textarea value={item.desc || ""} rows={2} placeholder="一到兩句說明（可留空）"
        onChange={e => onChange({ ...item, desc: e.target.value })}
        className="w-full px-2.5 py-1.5 bg-surface2 border border-border rounded-md text-fg2 text-[12.5px] outline-none focus:border-borderStrong resize-y" />
      {(item.tickets || []).length > 0 && (
        <div className="flex items-center gap-1.5 flex-wrap">
          <span className="text-fg3 text-[11px]">來源</span>
          {(item.tickets || []).map(t => <TicketChip key={t} id={t} product={product} />)}
        </div>
      )}
    </div>
  );
}

function DraftCard({ draft, onChanged, onFlash }) {
  const [d, setD] = useState(draft);
  const [busy, setBusy] = useState(false);
  const [announcement, setAnnouncement] = useState(null);
  const [confirming, setConfirming] = useState(false);

  useEffect(() => { setD(draft); }, [draft.id]);

  const wn = d.whats_new || [];
  const bf = d.bug_fixes || [];
  const total = wn.length + bf.length;

  const setList = (key, list) => setD(prev => ({ ...prev, [key]: list }));
  const patchItem = (key, i, v) => setList(key, (d[key] || []).map((x, n) => n === i ? v : x));
  const removeItem = (key, i) => setList(key, (d[key] || []).filter((_, n) => n !== i));
  const addItem = (key) => setList(key, [...(d[key] || []), { name: "", desc: "", tickets: [] }]);

  const save = async () => {
    if (busy) return;
    setBusy(true);
    try {
      await pmApi("/release-draft/save", {
        method: "POST",
        body: JSON.stringify({
          id: d.id, title: d.title, version: d.version, date: d.date,
          whats_new: wn, bug_fixes: bf, hotfix: !!d.hotfix,
        }),
      });
      onFlash("ok", "草稿已儲存");
    } catch (e) { onFlash("err", e.message); }
    finally { setBusy(false); }
  };

  const publish = async () => {
    if (busy) return;
    setBusy(true);
    try {
      await pmApi("/release-draft/save", {
        method: "POST",
        body: JSON.stringify({
          id: d.id, title: d.title, version: d.version, date: d.date,
          whats_new: wn, bug_fixes: bf, hotfix: !!d.hotfix,
        }),
      });
      const r = await pmApi("/release/publish", { method: "POST", body: JSON.stringify({ id: d.id }) });
      setAnnouncement(r.announcement || "");
      setConfirming(false);
      onFlash("ok", `${d.product} v${d.version} 已發布`);
      onChanged();
    } catch (e) { onFlash("err", e.message); setConfirming(false); }
    finally { setBusy(false); }
  };

  const discard = async () => {
    if (busy) return;
    if (!window.confirm(`確定要捨棄 ${d.product} v${d.version} 的草稿？（已發布的版本不受影響）`)) return;
    setBusy(true);
    try {
      await pmApi("/release-draft/discard", { method: "POST", body: JSON.stringify({ id: d.id }) });
      onFlash("ok", "草稿已捨棄");
      onChanged();
    } catch (e) { onFlash("err", e.message); }
    finally { setBusy(false); }
  };

  const sendTg = async () => {
    if (busy || !announcement) return;
    setBusy(true);
    try {
      await pmApi("/release/announce", { method: "POST", body: JSON.stringify({ text: announcement }) });
      onFlash("ok", "公告已發到 TG 群組");
    } catch (e) { onFlash("err", e.message); }
    finally { setBusy(false); }
  };

  const accent = d.product === "VF" ? "rgb(var(--vf-rgb))" : "rgb(var(--pg-rgb))";

  return (
    <div className="bg-surface border border-border rounded-xl overflow-hidden">
      <div className="px-4 py-3 border-b border-border flex items-center gap-3 flex-wrap"
           style={{ borderLeft: `3px solid ${accent}` }}>
        <span className="tint-hot mono text-[10px] px-1.5 py-0.5 rounded font-semibold uppercase">草稿</span>
        <span className="text-fg text-[14px] font-semibold">{DRAFT_PRODUCT_LABEL[d.product] || d.product}</span>
        <input type="text" value={d.version || ""} onChange={e => setD(p => ({ ...p, version: e.target.value }))}
          className="h-8 lg:h-7 w-[90px] px-2 bg-surface2 border border-border rounded-md text-fg text-[12.5px] mono outline-none focus:border-borderStrong" />
        <input type="date" value={(d.date || "").slice(0, 10)} onChange={e => setD(p => ({ ...p, date: e.target.value }))}
          className="h-8 lg:h-7 px-2 bg-surface2 border border-border rounded-md text-fg text-[12px] outline-none focus:border-borderStrong" />
        <div className="flex-1" />
        <span className="text-fg3 text-[11.5px] mono">{total} 項</span>
      </div>

      <div className="p-4 flex flex-col gap-5">
        <input type="text" value={d.title || ""} placeholder="版本標題"
          onChange={e => setD(p => ({ ...p, title: e.target.value }))}
          className="h-9 px-3 bg-surface2 border border-border rounded-md text-fg text-[13.5px] outline-none focus:border-borderStrong" />

        {[["whats_new", "What's New", wn], ["bug_fixes", "Bug Fixes", bf]].map(([key, label, list]) => (
          <div key={key} className="flex flex-col gap-2">
            <div className="flex items-center gap-2">
              <span className="text-fg text-[13px] font-semibold">{label}</span>
              <span className="text-fg3 text-[11.5px] mono">{list.length}</span>
              <div className="flex-1" />
              <button onClick={() => addItem(key)}
                className="text-[11.5px] font-semibold text-fg2 hover:text-fg border border-border rounded-lg px-2.5 py-1 transition-colors">
                ＋ 手動新增
              </button>
            </div>
            {list.length === 0 && <div className="text-fg3 text-[12px] italic">（沒有項目）</div>}
            {list.map((it, i) => (
              <DraftItemRow key={i} item={it} product={d.product}
                onChange={v => patchItem(key, i, v)}
                onRemove={() => removeItem(key, i)} />
            ))}
          </div>
        ))}

        <div className="flex items-center gap-2.5 flex-wrap pt-1">
          <button onClick={save} disabled={busy}
            className="text-[12.5px] font-semibold text-fg2 hover:text-fg border border-border rounded-lg px-3.5 py-1.5 transition-colors disabled:opacity-50">
            儲存草稿
          </button>
          {!confirming ? (
            <button onClick={() => setConfirming(true)} disabled={busy || total === 0}
              className="text-[12.5px] font-semibold text-fg border border-borderStrong rounded-lg px-3.5 py-1.5 transition-colors hover:bg-surface2 disabled:opacity-50">
              發布
            </button>
          ) : (
            <span className="flex items-center gap-2 text-[12px] flex-wrap">
              <span className="text-fg2">即將發布 <b className="text-fg">{total}</b> 項給全體內部人員，確定？</span>
              <button onClick={publish} disabled={busy}
                className="text-[12px] font-semibold text-success border border-success/50 rounded-lg px-3 py-1 hover:bg-success/10 transition-colors disabled:opacity-50">
                {busy ? "發布中…" : "確定發布"}
              </button>
              <button onClick={() => setConfirming(false)}
                className="text-[12px] text-fg3 hover:text-fg2 px-2">取消</button>
            </span>
          )}
          <div className="hidden sm:block flex-1" />
          <button onClick={discard} disabled={busy}
            className="text-[12px] text-red-400 hover:text-red-300 border border-border rounded-lg px-3 py-1.5 transition-colors disabled:opacity-50">
            捨棄草稿
          </button>
        </div>

        {announcement && (
          <div className="border border-success/40 bg-success/5 rounded-lg p-3 flex flex-col gap-2">
            <div className="text-fg text-[13px] font-semibold">TG 版本更新公告（發布動作是分開的，確認文案後再送）</div>
            <textarea value={announcement} rows={10}
              onChange={e => setAnnouncement(e.target.value)}
              className="w-full px-3 py-2 bg-surface2 border border-border rounded-md text-fg2 text-[12.5px] mono outline-none focus:border-borderStrong resize-y" />
            <div className="flex items-center gap-2">
              <button onClick={() => navigator.clipboard?.writeText(announcement)}
                className="text-[12px] text-fg2 hover:text-fg border border-border rounded-lg px-3 py-1.5 transition-colors">
                複製文案
              </button>
              <button onClick={sendTg} disabled={busy}
                className="text-[12px] font-semibold text-fg border border-borderStrong rounded-lg px-3 py-1.5 hover:bg-surface2 transition-colors disabled:opacity-50">
                發送到 TG 群組
              </button>
            </div>
          </div>
        )}
      </div>
    </div>
  );
}

function ReleaseDraftPanel() {
  const [drafts, setDrafts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [generating, setGenerating] = useState(false);
  const [msg, setMsg] = useState(null);

  const flash = (type, text) => { setMsg({ type, text }); setTimeout(() => setMsg(null), 7000); };

  const load = useCallback(async () => {
    setLoading(true);
    const { data, error } = await supabase
      .from("releases").select("*").eq("status", "draft").order("product");
    if (error) flash("err", error.message);
    setDrafts(data || []);
    setLoading(false);
  }, []);

  useEffect(() => { load(); }, [load]);

  const generate = async () => {
    if (generating) return;
    setGenerating(true);
    try {
      const r = await pmApi("/release-draft/generate", { method: "POST", body: JSON.stringify({}) });
      const warn = (r.results || []).filter(x => x.warning).map(x => `${x.product}：${x.warning}`);
      flash(warn.length ? "err" : "ok",
        warn.length ? warn.join("；") : "草稿已產出");
      await load();
    } catch (e) { flash("err", e.message); }
    finally { setGenerating(false); }
  };

  return (
    <section className="mt-5 mb-4 flex flex-col gap-5">
      <div className="rounded-xl border border-border bg-surface px-4 py-3 flex items-center gap-3 flex-wrap">
        <span className="text-[11.5px] mono uppercase tracking-wider text-fg3">Release Note 草稿</span>
        <span className="text-fg2 text-[12.5px]">
          每週一 09:30 自動從 Linear 的 <span className="mono text-fg">[vX.X.0]</span> parent issue 產出草稿
        </span>
        <div className="flex-1" />
        <button onClick={generate} disabled={generating}
          className="inline-flex items-center gap-1.5 text-[11.5px] font-semibold text-fg2 hover:text-fg border border-border rounded-lg px-2.5 py-1 transition-colors disabled:opacity-50">
          {generating ? "產生中…（AI 潤稿需要一點時間）" : "立即產生草稿"}
        </button>
      </div>

      {msg && (
        <FlashMsg msg={msg} size="text-[12.5px]" />
      )}

      {loading && <div className="text-fg3 text-[13px]">載入中…</div>}

      {!loading && drafts.length === 0 && (
        <div className="border border-dashed border-border rounded-xl px-6 py-12 text-center">
          <div className="text-fg2 text-[14px] font-medium">目前沒有待審核的草稿</div>
          <div className="text-fg3 text-[12.5px] mt-2">
            確認 Linear 上有標題像 <span className="mono">[v1.9.0] 錢包畫面優化</span> 的 parent issue，再按右上「立即產生草稿」
          </div>
        </div>
      )}

      {!loading && drafts.map(d => (
        <DraftCard key={d.id} draft={d} onChanged={load} onFlash={flash} />
      ))}
    </section>
  );
}

// ═══════════════════════════════════════════════════════════════════
// TG 中繼 —— PM 沒有 Telegram 帳號時，在網站上讀群組訊息並代發回覆
//
// 限制（Telegram Bot API 決定的）：
//   · 只看得到 @SGPMTicketBot 已加入的群組
//   · 看不到 bot 加入之前的歷史訊息
//   · 代發的訊息顯示成 bot 發的，靠前綴標示是誰
// ═══════════════════════════════════════════════════════════════════

/** 取原始 Response（附件用，不走 pmApi 的 JSON 解析） */
async function pmApiRaw(path) {
  const { data: { session } } = await supabase.auth.getSession();
  const token = session?.access_token;
  if (!token) throw new Error("尚未登入或 session 已失效");
  const res = await fetch(`${WORKER_URL}${path}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res;
}

function relayTime(iso) {
  try {
    const d = new Date(iso);
    const now = new Date();
    const sameDay = d.toDateString() === now.toDateString();
    const hh = String(d.getHours()).padStart(2, "0");
    const mm = String(d.getMinutes()).padStart(2, "0");
    if (sameDay) return `${hh}:${mm}`;
    return `${d.getMonth() + 1}/${d.getDate()} ${hh}:${mm}`;
  } catch { return ""; }
}

/** 附件：用帶授權的 fetch 取回後轉 blob URL，token 不會出現在網址列 */
function RelayMedia({ item }) {
  const [url, setUrl] = useState(null);
  const [err, setErr] = useState(null);
  const [loading, setLoading] = useState(false);

  const load = async () => {
    if (url || loading) return;
    setLoading(true);
    try {
      const res = await pmApiRaw(`/tg-relay/media?file_id=${encodeURIComponent(item.file_id)}`);
      const blob = await res.blob();
      setUrl(URL.createObjectURL(blob));
    } catch (e) { setErr(e.message); }
    finally { setLoading(false); }
  };

  useEffect(() => {
    if (item.type === "photo" || item.type === "sticker") load();
    return () => { if (url) URL.revokeObjectURL(url); };
    // eslint-disable-next-line
  }, [item.file_id]);

  const label = { photo: "圖片", video: "影片", document: "檔案", voice: "語音", sticker: "貼圖" }[item.type] || "附件";

  if (err) return <div className="text-[11.5px] text-red-400 mt-1">［{label}取不到：{err}］</div>;
  if (url && (item.type === "photo" || item.type === "sticker")) {
    return <img src={url} alt={label} className="mt-1.5 rounded-lg w-auto max-w-full sm:max-w-[280px] max-h-[280px] border border-border" />;
  }
  if (item.type === "photo" || item.type === "sticker") {
    return <div className="text-[11.5px] text-fg3 mt-1">［{label}載入中…］</div>;
  }
  return (
    <button onClick={load} disabled={loading}
      className="mt-1.5 text-[11.5px] text-fg2 hover:text-fg border border-border rounded-md px-2 py-1 transition-colors">
      {url ? <a href={url} download={item.filename || label} className="underline">下載 {item.filename || label}</a>
           : loading ? "取得中…"
           : <span className="inline-flex items-center gap-1"><Icon.Clip className="w-3 h-3" />{item.filename || label}（點擊取得）</span>}
    </button>
  );
}

function RelayMessage({ m, onReply, byId }) {
  const mine = m.is_from_pm;
  const quoted = m.reply_to_message_id ? byId[m.reply_to_message_id] : null;
  return (
    <div className={`group flex flex-col gap-0.5 px-3 py-2 rounded-lg ${mine ? "bg-surface2/70" : "hover:bg-surface2/40"} transition-colors`}>
      <div className="flex items-baseline gap-2">
        <span className={`text-[12.5px] font-semibold ${mine ? "text-success" : "text-fg"}`}>
          {m.sender_name || "未知"}
        </span>
        {m.sender_username && <span className="text-fg3 text-[11px] mono">@{m.sender_username}</span>}
        <span className="text-fg3 text-[11px] mono">{relayTime(m.sent_at)}</span>
        {m.edited && <span className="text-fg3 text-[10.5px]">（已編輯）</span>}
        <div className="flex-1" />
        <button onClick={() => onReply(m)}
          className="opacity-0 group-hover:opacity-100 text-[11px] text-fg3 hover:text-fg transition-opacity">
          回覆
        </button>
      </div>
      {quoted && (
        <div className="border-l-2 border-borderStrong pl-2 my-0.5 text-[11.5px] text-fg3 truncate">
          ↳ {quoted.sender_name}：{(quoted.text || "").slice(0, 60)}
        </div>
      )}
      {m.text && (
        <div className="text-fg2 text-[13px] whitespace-pre-wrap break-words leading-relaxed">{m.text}</div>
      )}
      {(m.media || []).map((it, i) => <RelayMedia key={i} item={it} />)}
    </div>
  );
}

function TgRelayPanel() {
  const [chats, setChats] = useState([]);
  const [active, setActive] = useState(null);
  // 手機沒有空間並排「群組列表 + 對話」，改成兩段式：先選群組，點進去才看對話。
  // 桌機不受影響（lg 以上兩欄照舊）。
  const [mobileOpen, setMobileOpen] = useState(false);
  const [messages, setMessages] = useState([]);
  const [loading, setLoading] = useState(true);
  const [text, setText] = useState("");
  const [replyTo, setReplyTo] = useState(null);
  const [sending, setSending] = useState(false);
  const [msg, setMsg] = useState(null);
  const bottomRef = useRef(null);

  const flash = (type, t) => { setMsg({ type, text: t }); setTimeout(() => setMsg(null), 6000); };

  const loadChats = useCallback(async () => {
    try {
      const d = await pmApi("/tg-relay/chats");
      setChats(d.chats || []);
      return d.chats || [];
    } catch (e) { flash("err", e.message); return []; }
  }, []);

  const loadMessages = useCallback(async (chatId, after) => {
    const qs = `?chat_id=${encodeURIComponent(chatId)}${after ? `&after=${encodeURIComponent(after)}` : ""}`;
    const d = await pmApi(`/tg-relay/messages${qs}`);
    return d.messages || [];
  }, []);

  // 初次載入
  useEffect(() => {
    (async () => {
      setLoading(true);
      const cs = await loadChats();
      if (cs.length) setActive(cs[0].chat_id);
      setLoading(false);
    })();
  }, [loadChats]);

  // 切換群組
  useEffect(() => {
    if (!active) return;
    let cancelled = false;
    (async () => {
      try {
        const ms = await loadMessages(active);
        if (!cancelled) {
          setMessages(ms);
          setReplyTo(null);
          await pmApi("/tg-relay/read", { method: "POST", body: JSON.stringify({ chat_id: active }) });
          setChats(prev => prev.map(c => c.chat_id === active ? { ...c, unread: 0 } : c));
        }
      } catch (e) { if (!cancelled) flash("err", e.message); }
    })();
    return () => { cancelled = true; };
  }, [active, loadMessages]);

  // 輪詢：新訊息 12 秒、群組列表 45 秒
  useEffect(() => {
    if (!active) return;
    const tick = setInterval(async () => {
      try {
        const last = messages.length ? messages[messages.length - 1].sent_at : null;
        if (!last) return;
        const fresh = await loadMessages(active, last);
        if (fresh.length) {
          setMessages(prev => {
            const seen = new Set(prev.map(m => `${m.chat_id}:${m.message_id}`));
            return [...prev, ...fresh.filter(m => !seen.has(`${m.chat_id}:${m.message_id}`))];
          });
        }
      } catch (_) { /* 輪詢失敗不吵人 */ }
    }, 12000);
    const tock = setInterval(loadChats, 45000);
    return () => { clearInterval(tick); clearInterval(tock); };
  }, [active, messages, loadMessages, loadChats]);

  useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages.length]);

  const byId = useMemo(() => {
    const m = {};
    for (const x of messages) m[x.message_id] = x;
    return m;
  }, [messages]);

  const send = async () => {
    const body = text.trim();
    if (!body || sending || !active) return;
    setSending(true);
    try {
      await pmApi("/tg-relay/send", {
        method: "POST",
        body: JSON.stringify({
          chat_id: active,
          text: body,
          reply_to_message_id: replyTo ? replyTo.message_id : undefined,
          thread_id: replyTo ? replyTo.thread_id : undefined,
        }),
      });
      setText("");
      setReplyTo(null);
      const last = messages.length ? messages[messages.length - 1].sent_at : null;
      const fresh = await loadMessages(active, last);
      if (fresh.length) setMessages(prev => [...prev, ...fresh]);
    } catch (e) { flash("err", e.message); }
    finally { setSending(false); }
  };

  // 手機鍵盤的 Enter 是換行，不該拿來送出 —— 打到一半就飛出去比少一個快捷鍵糟糕得多
  const isTouch = typeof window !== "undefined" &&
    window.matchMedia && window.matchMedia("(pointer: coarse)").matches;

  const onKeyDown = (e) => {
    // 桌機：Enter 送出、Shift+Enter 換行；觸控裝置一律換行，只能按「送出」
    if (isTouch) return;
    if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
      e.preventDefault();
      send();
    }
  };

  const activeChat = chats.find(c => c.chat_id === active);

  if (loading) return <div className="mt-5 text-fg3 text-[13px]">載入中…</div>;

  if (!chats.length) {
    return (
      <section className="mt-5">
        <div className="border border-dashed border-border rounded-xl px-6 py-12 text-center">
          <div className="text-fg2 text-[14px] font-medium">還沒有收到任何群組訊息</div>
          <div className="text-fg3 text-[12.5px] mt-2 leading-relaxed">
            @SGPMTicketBot 只看得到自己已加入的群組，而且<b className="text-fg2">讀不到加入之前的歷史訊息</b>。<br />
            等群組裡有人發言之後，這裡就會開始出現內容。
          </div>
        </div>
      </section>
    );
  }

  return (
    <section className="mt-5 mb-4">
      <div className="rounded-xl border border-border bg-surface px-4 py-2.5 mb-4 flex items-center gap-3 flex-wrap">
        <span className="text-[11.5px] mono uppercase tracking-wider text-fg3">TG 中繼</span>
        <span className="text-fg3 text-[11.5px]">
          由 @SGPMTicketBot 代收代發 · 你的回覆會顯示成 bot 發言並標上「JM」
        </span>
        <div className="flex-1" />
        {msg && (
          <FlashMsg msg={msg} size="text-[12px]" />
        )}
      </div>

      <div className="flex flex-col lg:flex-row gap-4 items-stretch lg:items-start">
        {/* 群組列表 —— 手機上點進某群後就讓位給對話 */}
        <div className={`${mobileOpen ? "hidden lg:block" : "block"} w-full lg:w-[230px] shrink-0 bg-surface border border-border rounded-xl overflow-hidden`}>
          <div className="px-3 py-2.5 border-b border-border text-fg text-[13px] font-semibold">
            群組（{chats.length}）
          </div>
          <div className="divide-y divide-border max-h-[45vh] lg:max-h-[560px] overflow-y-auto">
            {chats.map(c => {
              const on = c.chat_id === active;
              return (
                <button key={c.chat_id} onClick={() => { setActive(c.chat_id); setMobileOpen(true); }}
                  className={`w-full text-left px-3 py-3 lg:py-2.5 transition-colors ${on ? "grad-tint" : "hover:bg-surface2/50"}`}>
                  <div className="flex items-center gap-2">
                    <span className={`text-[12.5px] font-medium truncate ${on ? "text-fg" : "text-fg2"}`}>
                      {c.title || c.chat_id}
                    </span>
                    {c.unread > 0 && (
                      <span className="ml-auto shrink-0 mono text-[10px] px-1.5 py-0.5 rounded-full bg-red-500/20 text-red-400 font-semibold">
                        {c.unread > 99 ? "99+" : c.unread}
                      </span>
                    )}
                  </div>
                  <div className="text-fg3 text-[11px] truncate mt-0.5">{c.last_preview || "—"}</div>
                </button>
              );
            })}
          </div>
        </div>

        {/* 訊息流 */}
        <div className={`${mobileOpen ? "flex" : "hidden lg:flex"} flex-1 min-w-0 bg-surface border border-border rounded-xl overflow-hidden flex-col`}>
          <div className="px-3 lg:px-4 py-2.5 border-b border-border flex items-center gap-2">
            <button onClick={() => setMobileOpen(false)} title="回群組列表"
              className="lg:hidden shrink-0 -ml-1 p-1 text-fg3 hover:text-fg transition-colors">
              <Icon.Chev className="w-4 h-4 rotate-90" />
            </button>
            <span className="text-fg text-[13.5px] font-semibold truncate">
              {activeChat?.title || "—"}
            </span>
            <span className="text-fg3 text-[11px] mono hidden sm:inline">{active}</span>
          </div>

          <div className="flex-1 overflow-y-auto max-h-[60vh] lg:max-h-[520px] px-1.5 py-2 flex flex-col gap-0.5">
            {messages.map(m => (
              <RelayMessage key={`${m.chat_id}:${m.message_id}`} m={m} byId={byId} onReply={setReplyTo} />
            ))}
            {messages.length === 0 && (
              <div className="text-fg3 text-[12.5px] text-center py-10">這個群組還沒有記錄到訊息</div>
            )}
            <div ref={bottomRef} />
          </div>

          {/* 回覆框 */}
          <div className="border-t border-border p-3 flex flex-col gap-2">
            {replyTo && (
              <div className="flex items-center gap-2 text-[11.5px] bg-surface2 rounded-md px-2.5 py-1.5">
                <span className="text-fg3">回覆</span>
                <span className="text-fg2 truncate">
                  {replyTo.sender_name}：{(replyTo.text || "").slice(0, 50)}
                </span>
                <div className="flex-1" />
                <button onClick={() => setReplyTo(null)} className="text-fg3 hover:text-fg" title="取消引用"><Icon.X className="w-3.5 h-3.5" /></button>
              </div>
            )}
            <div className="flex items-end gap-2">
              <textarea
                value={text}
                onChange={e => setText(e.target.value)}
                onKeyDown={onKeyDown}
                rows={2}
                placeholder={isTouch ? "輸入回覆內容…" : "輸入回覆內容…（Enter 送出，Shift+Enter 換行）"}
                className="flex-1 px-3 py-2 bg-surface2 border border-border rounded-lg text-fg text-[13px] outline-none focus:border-borderStrong resize-y"
              />
              <button onClick={send} disabled={sending || !text.trim()}
                className="text-[12.5px] font-semibold text-fg border border-borderStrong rounded-lg px-4 py-2 transition-colors hover:bg-surface2 disabled:opacity-40 shrink-0">
                {sending ? "送出中…" : "送出"}
              </button>
            </div>
            <div className="text-fg3 text-[11px]">
              送出後群組看到的是：<span className="mono text-fg2">💬 JM：{text.trim().slice(0, 30) || "…"}</span>
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

// 淺色品牌底（例如金色）配白字看不清楚，依底色亮度改用深色字
function brandTextColor(hex) {
  const m = /^#?([0-9a-f]{6})$/i.exec(String(hex || "").trim());
  if (!m) return "white";
  const n = parseInt(m[1], 16);
  const lin = c => { c /= 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); };
  const L = 0.2126 * lin((n >> 16) & 255) + 0.7152 * lin((n >> 8) & 255) + 0.0722 * lin(n & 255);
  return L > 0.3 ? "#18181b" : "white";
}

// Linear label → 品牌：brand:ALL = 通用（[]）；brand:XXX = 指定品牌；沒有 brand label = undefined（未分類）
function brandsFromLabels(labels) {
  const codes = [];
  for (const l of labels || []) {
    const m = /^brand\s*:\s*([A-Za-z0-9_-]+)$/i.exec(String(l).trim());
    if (m) codes.push(m[1].toUpperCase());
  }
  if (codes.length === 0) return undefined;
  if (codes.includes("ALL")) return [];
  return [...new Set(codes)];
}

// 網站上手動設定過的優先；沒有才看 Linear label
function effectiveBrands(ticketId, labels, manualMap) {
  if (manualMap && Object.prototype.hasOwnProperty.call(manualMap, ticketId)) {
    return { brands: manualMap[ticketId], source: "manual" };
  }
  const fromLabel = brandsFromLabels(labels);
  return fromLabel === undefined ? { brands: undefined, source: null } : { brands: fromLabel, source: "label" };
}

// ═══════════════════════════════════════════════════════════════════
// BrandMappingPanel — admin 品牌對應
// ═══════════════════════════════════════════════════════════════════
function BrandMappingPanel() {
  const [registry, setRegistry] = useState([]);
  const [mappings, setMappings] = useState({}); // ticket_id → brands[]
  const [tickets, setTickets] = useState([]);   // all roadmap tickets
  const [loading, setLoading] = useState(true);
  const [saveMsg, setSaveMsg] = useState(null);
  const [filter, setFilter] = useState("all");  // "all" | "unmapped" | brand code
  const [query, setQuery] = useState("");
  const [showAddBrand, setShowAddBrand] = useState(false);

  const loadAll = async () => {
    setLoading(true);
    const { data: { session } } = await supabase.auth.getSession();
    const token = session?.access_token;
    if (!token) { setLoading(false); return; }
    const [brandsRes, cyclesRes] = await Promise.all([
      fetch(`${WORKER_URL}/admin/brands`, { headers: { Authorization: `Bearer ${token}` } }),
      supabase.from("cycles").select("id, product, planned_version, cycle_title, release_date, is_current, issues").order("release_date", { ascending: false }),
    ]);
    const brandsData = await brandsRes.json().catch(() => ({}));
    if (brandsData.ok) {
      setRegistry(brandsData.registry || []);
      const m = {};
      for (const row of brandsData.mappings || []) m[row.ticket_id] = row.brands || [];
      setMappings(m);
    }
    if (cyclesRes.data) {
      const all = [];
      const seen = new Set();
      for (const c of cyclesRes.data) {
        for (const it of (c.issues || [])) {
          if (seen.has(it.id)) continue;
          seen.add(it.id);
          all.push({
            id: it.id,
            title: it.title,
            product: c.product,
            plannedVersion: c.planned_version,
            releaseDate: c.release_date,
            parentTicket: it.parentTicket,
            labels: it.labels || [],
          });
        }
      }
      // sort by id (VF-XXX / PG-XXX) desc so newest tickets first
      all.sort((a, b) => {
        const na = parseInt((a.id.match(/\d+/) || [0])[0], 10);
        const nb = parseInt((b.id.match(/\d+/) || [0])[0], 10);
        if (a.product !== b.product) return a.product.localeCompare(b.product);
        return nb - na;
      });
      setTickets(all);
    }
    setLoading(false);
  };

  useEffect(() => { loadAll(); }, []);

  const flashSave = (type, text) => {
    setSaveMsg({ type, text });
    setTimeout(() => setSaveMsg(null), 3000);
  };

  // brands 傳 null = 清除手動設定，改依 Linear label
  const saveMapping = async (ticket_id, brands) => {
    const { data: { session } } = await supabase.auth.getSession();
    const token = session?.access_token;
    if (!token) { flashSave("err", "尚未登入"); return; }
    const had = Object.prototype.hasOwnProperty.call(mappings, ticket_id);
    const prev = mappings[ticket_id];
    const put = (m, v, keep) => { const n = { ...m }; if (keep) n[ticket_id] = v; else delete n[ticket_id]; return n; };
    setMappings(m => put(m, brands, brands !== null));
    const res = await fetch(`${WORKER_URL}/admin/brand-mapping`, {
      method: "POST",
      headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
      body: JSON.stringify({ updates: [{ ticket_id, brands }] }),
    });
    const data = await res.json().catch(() => ({}));
    if (!data.ok) {
      setMappings(m => put(m, prev, had));
      flashSave("err", data.error || "儲存失敗");
    } else {
      flashSave("ok", brands === null ? `${ticket_id} 已改回依 Linear label` : `${ticket_id} 已更新`);
    }
  };

  const toggleBrand = (ticket_id, brandCode, current) => {
    const cur = current || [];
    const next = cur.includes(brandCode)
      ? cur.filter(b => b !== brandCode)
      : [...cur, brandCode];
    saveMapping(ticket_id, next);
  };

  const setGeneric = (ticket_id) => {
    saveMapping(ticket_id, []);
  };

  const addBrand = async (code, name, color) => {
    const { data: { session } } = await supabase.auth.getSession();
    const token = session?.access_token;
    if (!token) { flashSave("err", "尚未登入"); return; }
    const res = await fetch(`${WORKER_URL}/admin/brands`, {
      method: "POST",
      headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
      body: JSON.stringify({ code, name, color, sort_order: 100, active: true }),
    });
    const data = await res.json().catch(() => ({}));
    if (!data.ok) {
      flashSave("err", data.error || "新增失敗");
      return false;
    }
    flashSave("ok", `新增品牌 ${code}`);
    await loadAll();
    return true;
  };

  const activeRegistry = registry.filter(b => b.active);
  const eff = new Map(tickets.map(t => [t.id, effectiveBrands(t.id, t.labels, mappings)]));
  const brandsOf = id => eff.get(id)?.brands;

  // 篩選 tickets
  const filtered = tickets.filter(t => {
    if (query) {
      const q = query.toLowerCase();
      if (!t.id.toLowerCase().includes(q) && !(t.title || "").toLowerCase().includes(q)) return false;
    }
    if (filter === "all") return true;
    const brands = brandsOf(t.id);
    if (filter === "unmapped") return !brands;
    if (filter === "generic") return brands && brands.length === 0;
    return brands && brands.includes(filter);
  });

  const unmappedCount = tickets.filter(t => !brandsOf(t.id)).length;

  return (
    <section>
      {/* 品牌管理列 */}
      <div className="bg-surface border border-border rounded-xl p-4 mb-4">
        <div className="flex items-center justify-between mb-3">
          <h3 className="text-fg text-[14px] font-semibold">品牌管理</h3>
          <button
            onClick={() => setShowAddBrand(true)}
            className="text-[12px] font-semibold text-fg2 hover:text-fg border border-border rounded-lg px-2.5 py-1"
          >
            + 新增品牌
          </button>
        </div>
        <div className="flex flex-wrap gap-2">
          {activeRegistry.map(b => (
            <span
              key={b.code}
              className="inline-flex items-center gap-1.5 text-[12px] px-2.5 py-1 rounded border border-border bg-surface2"
              style={{ borderLeftColor: b.color, borderLeftWidth: 3 }}
            >
              <span className="mono text-fg font-semibold">{b.code}</span>
              <span className="text-fg2">{b.name}</span>
            </span>
          ))}
          {activeRegistry.length === 0 && (
            <span className="text-fg3 text-[12px] italic">尚未新增品牌</span>
          )}
        </div>
      </div>

      {/* 篩選 + 搜尋 */}
      <div className="bg-surface border border-border rounded-xl p-4 mb-4 flex items-center gap-3 flex-wrap">
        <span className="text-fg3 text-[11.5px] uppercase tracking-wider font-semibold shrink-0">篩選</span>
        {[
          { key: "all", label: `全部`, count: tickets.length },
          { key: "unmapped", label: `未分類`, count: unmappedCount, hot: true },
          { key: "generic", label: `通用`, count: tickets.filter(t => brandsOf(t.id)?.length === 0).length },
          ...activeRegistry.map(b => ({
            key: b.code,
            label: b.code,
            color: b.color,
            count: tickets.filter(t => brandsOf(t.id)?.includes(b.code)).length,
          })),
        ].map(chip => {
          const active = filter === chip.key;
          return (
            <button
              key={chip.key}
              onClick={() => setFilter(chip.key)}
              className={`text-[12px] font-semibold px-2.5 py-1 rounded-lg border transition-colors ${
                active
                  ? "grad-btn"
                  : "text-fg2 hover:text-fg border-border"
              }`}
              style={active && chip.color ? { background: chip.color, borderColor: chip.color, color: brandTextColor(chip.color) } : undefined}
            >
              {chip.hot && chip.count > 0 && (
                <span className="mr-1 inline-block w-1.5 h-1.5 rounded-full bg-red-500 align-middle" />
              )}
              {chip.label} <span className="mono text-[11px] opacity-70">{chip.count}</span>
            </button>
          );
        })}
        <div className="flex-1" />
        <input
          type="text"
          value={query}
          onChange={e => setQuery(e.target.value)}
          placeholder="搜尋票號 / 標題..."
          className="h-8 px-3 bg-surface2 border border-border rounded-md text-fg text-[12.5px] outline-none focus:border-borderStrong min-w-[220px]"
        />
      </div>

      {saveMsg && (
        <div className="mb-3">
          <FlashMsg msg={saveMsg} size="text-[12px]" />
        </div>
      )}

      {/* 票列表 */}
      <div className="bg-surface border border-border rounded-xl overflow-hidden">
        <div className="px-4 py-3 border-b border-border flex items-center justify-between">
          <h3 className="text-fg text-[14px] font-semibold">票列表</h3>
          <span className="text-fg3 text-[11.5px] mono">{filtered.length} / {tickets.length} 張</span>
        </div>
        <div className="divide-y divide-border max-h-[720px] overflow-y-auto">
          {filtered.map(t => {
            const { brands, source } = eff.get(t.id);
            const isUnmapped = !brands;
            const isGeneric = brands && brands.length === 0;
            return (
              <div key={t.id} className="px-4 py-3 flex items-start gap-3">
                <div className="min-w-0 flex-1">
                  <div className="flex items-center gap-2 flex-wrap">
                    {isUnmapped && (
                      <span className="inline-block w-1.5 h-1.5 rounded-full bg-red-500 shrink-0" title="未分類" />
                    )}
                    <span className="mono text-[11.5px] text-fg3 shrink-0">{t.id}</span>
                    <span className="text-fg3 text-[10.5px] mono">{t.plannedVersion || "—"}</span>
                    <span className="text-fg text-[13px] font-medium">{stripTicketTitle(t.title)}</span>
                    {source === "label" && (
                      <span className="mono text-[10px] text-fg3 border border-border rounded px-1"
                            title="依 Linear 上的 brand label 自動歸類">Linear</span>
                    )}
                    {source === "manual" && (
                      <span className="flex items-center gap-1 text-[10px] text-fg3">
                        <span className="border border-border rounded px-1">手動</span>
                        <button onClick={() => saveMapping(t.id, null)}
                          className="underline underline-offset-2 hover:text-fg"
                          title="刪除網站上的手動設定，改依 Linear label 歸類">清除</button>
                      </span>
                    )}
                  </div>
                </div>
                <div className="flex items-center gap-1.5 shrink-0 flex-wrap justify-end">
                  <button
                    onClick={() => setGeneric(t.id)}
                    className={`text-[11px] font-semibold px-2 py-1 rounded border transition-colors ${
                      isGeneric
                        ? "grad-btn"
                        : "text-fg3 hover:text-fg border-border"
                    }`}
                    title="通用（所有品牌都算）"
                  >
                    通用
                  </button>
                  {activeRegistry.map(b => {
                    const on = (brands || []).includes(b.code);
                    return (
                      <button
                        key={b.code}
                        onClick={() => toggleBrand(t.id, b.code, brands)}
                        className={`text-[11px] font-semibold px-2 py-1 rounded border transition-colors mono ${
                          on ? "" : "border-border text-fg3 hover:text-fg"}`}
                        style={on ? { background: b.color, borderColor: b.color, color: brandTextColor(b.color) } : undefined}
                      >
                        {b.code}
                      </button>
                    );
                  })}
                </div>
              </div>
            );
          })}
          {filtered.length === 0 && (
            <div className="px-4 py-8 text-center text-fg3 text-[13px]">
              {query ? `無符合「${query}」的票` : "無符合的票"}
            </div>
          )}
        </div>
      </div>

      {showAddBrand && (
        <AddBrandModal
          onClose={() => setShowAddBrand(false)}
          onAdd={async (code, name, color) => {
            const ok = await addBrand(code, name, color);
            if (ok) setShowAddBrand(false);
          }}
        />
      )}
    </section>
  );
}

function AddBrandModal({ onClose, onAdd }) {
  const [code, setCode] = useState("");
  const [name, setName] = useState("");
  const [color, setColor] = useState("#6f9bc4");
  const [busy, setBusy] = useState(false);
  const submit = async () => {
    if (!code.trim() || !name.trim() || busy) return;
    setBusy(true);
    await onAdd(code.trim().toUpperCase(), name.trim(), color);
    setBusy(false);
  };
  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm px-5" onClick={onClose}>
      <div className="bg-surface border border-border rounded-2xl p-6 w-full max-w-[400px]" onClick={e => e.stopPropagation()}>
        <div className="flex items-center justify-between mb-4">
          <h3 className="text-fg text-[15px] font-semibold">新增品牌</h3>
          <button onClick={onClose} className="text-fg3 hover:text-fg"><Icon.X className="w-4 h-4" /></button>
        </div>
        <div className="space-y-3">
          <div>
            <label className="block text-fg2 text-[12px] mb-1">代碼（大寫、英數）</label>
            <input type="text" value={code} onChange={e => setCode(e.target.value.toUpperCase())} placeholder="例：TOPPREG"
              className="w-full h-9 px-3 bg-surface2 border border-border rounded-md text-fg text-[13px] outline-none focus:border-borderStrong mono" />
          </div>
          <div>
            <label className="block text-fg2 text-[12px] mb-1">顯示名稱</label>
            <input type="text" value={name} onChange={e => setName(e.target.value)} placeholder="例：TOPPREG US"
              className="w-full h-9 px-3 bg-surface2 border border-border rounded-md text-fg text-[13px] outline-none focus:border-borderStrong" />
          </div>
          <div>
            <label className="block text-fg2 text-[12px] mb-1">Chip 顏色</label>
            <div className="flex items-center gap-2">
              <input type="color" value={color} onChange={e => setColor(e.target.value)}
                className="h-9 w-14 rounded-md border border-border cursor-pointer bg-surface2" />
              <input type="text" value={color} onChange={e => setColor(e.target.value)}
                className="flex-1 h-9 px-3 bg-surface2 border border-border rounded-md text-fg text-[13px] outline-none focus:border-borderStrong mono" />
            </div>
          </div>
        </div>
        <div className="flex justify-end gap-2 mt-5">
          <button onClick={onClose} className="text-[13px] text-fg3 hover:text-fg px-3 py-1.5">取消</button>
          <button onClick={submit} disabled={!code.trim() || !name.trim() || busy}
            className="text-[13px] font-semibold text-bg bg-fg rounded-lg px-4 py-1.5 disabled:opacity-40">
            {busy ? "新增中…" : "新增"}
          </button>
        </div>
      </div>
    </div>
  );
}

function AdminPanel({ currentUserId }) {
  const [section, setSection] = useState("users"); // "users" | "audit"
  const [users, setUsers] = useState([]);
  const [audit, setAudit] = useState([]);
  const [lastSync, setLastSync] = useState(null);
  const [loading, setLoading] = useState(true);
  const [editingUser, setEditingUser] = useState(null);
  const [auditFilter, setAuditFilter] = useState("");
  const [syncing, setSyncing] = useState(false);
  const [syncMsg, setSyncMsg] = useState(null);

  const fetchUsers = async () => {
    const { data, error } = await supabase
      .from("profiles")
      .select("*")
      .order("created_at", { ascending: false });
    if (error) console.error("fetchUsers:", error.message);
    return data || [];
  };
  const fetchAudit = async () => {
    const { data, error } = await supabase
      .from("audit_log")
      .select("*")
      .order("created_at", { ascending: false })
      .limit(200);
    if (error) console.error("fetchAudit:", error.message);
    return data || [];
  };
  const fetchLastSync = async () => {
    const { data } = await supabase
      .from("settings")
      .select("value, updated_at")
      .eq("key", "linear_last_sync")
      .maybeSingle();
    return data;
  };

  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    Promise.all([fetchUsers(), fetchAudit(), fetchLastSync()]).then(([u, a, s]) => {
      if (cancelled) return;
      setUsers(u);
      setAudit(a);
      setLastSync(s);
      setLoading(false);
    });
    return () => { cancelled = true; };
  }, []);

  const handleUserSaved = (updated) => {
    setUsers(prev => prev.map(u => u.id === updated.id ? updated : u));
    setEditingUser(null);
    // Also refresh audit log to show the change
    fetchAudit().then(setAudit);
  };

  const handleManualSync = async () => {
    if (syncing) return;
    setSyncing(true);
    setSyncMsg(null);
    try {
      const { data: { session } } = await supabase.auth.getSession();
      const token = session?.access_token;
      if (!token) throw new Error("尚未登入或 session 失效");
      const res = await fetch(WORKER_URL, {
        method: "POST",
        headers: { Authorization: `Bearer ${token}` },
      });
      const ct = res.headers.get("content-type") || "";
      const data = ct.includes("application/json") ? await res.json() : { ok: false, error: `HTTP ${res.status}` };
      if (data.ok) {
        setSyncMsg({ type: "ok", text: `同步完成：${data.cycles} cycles · ${data.dueDates} due dates` });
        const [ls, a] = await Promise.all([fetchLastSync(), fetchAudit()]);
        setLastSync(ls);
        setAudit(a);
      } else {
        setSyncMsg({ type: "err", text: data.error || "同步失敗（未知錯誤）" });
      }
    } catch (e) {
      setSyncMsg({ type: "err", text: `${e.message || e}` });
    } finally {
      setSyncing(false);
      setTimeout(() => setSyncMsg(null), 6000);
    }
  };

  const filteredAudit = audit.filter(a => {
    if (!auditFilter) return true;
    const q = auditFilter.toLowerCase();
    return (a.actor_email || "").toLowerCase().includes(q)
      || (a.action || "").toLowerCase().includes(q)
      || (a.target_id || "").toLowerCase().includes(q);
  });

  return (
    <section className="mt-5 mb-4">
      {/* Linear sync status banner */}
      <div className="rounded-xl border border-border bg-surface px-4 py-3 mb-5 flex flex-col gap-2">
        <div className="flex items-center gap-3 flex-wrap">
          <span className="text-[11.5px] mono uppercase tracking-wider text-fg3">Linear Sync</span>
          {lastSync?.value ? (
            <>
              <span className="text-fg2 text-[12.5px]">
                最後同步：<span className="mono text-fg">{fmtDateTime(lastSync.value.timestamp)}</span>
              </span>
              <span className="text-fg3 text-[11.5px]">·</span>
              <span className="text-fg3 text-[11.5px]">
                {lastSync.value.cycles} cycles, {lastSync.value.due_dates} due dates
              </span>
            </>
          ) : (
            <span className="text-fg3 text-[12.5px] italic">尚未執行過自動同步</span>
          )}
          <div className="flex-1" />
          <span className="text-fg3 text-[11px] mono hidden sm:inline">排程：平日 09:00 / 15:00 (TPE)</span>
          <button
            onClick={handleManualSync}
            disabled={syncing}
            title="馬上抓 Linear 資料寫進 Supabase"
            className="inline-flex items-center gap-1.5 text-[11.5px] font-semibold text-fg2 hover:text-fg border border-border rounded-lg px-2.5 py-1 transition-colors disabled:opacity-50"
          >
            <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
                 strokeLinecap="round" strokeLinejoin="round" className={syncing ? "animate-spin" : ""}>
              <path d="M20.5 12a8.5 8.5 0 1 1-2.5-6" />
              <path d="M20.5 4v5h-5" />
            </svg>
            {syncing ? "同步中…" : "重新整理"}
          </button>
        </div>
        {syncMsg && (
          <FlashMsg msg={syncMsg} size="text-[12px]" />
        )}
      </div>

      {/* sub-tabs */}
      <div className="flex items-center gap-1 mb-5 border-b border-border overflow-x-auto no-scrollbar">
        {[
          { key: "users", label: "使用者", icon: Icon.Users, count: users.length },
          { key: "audit", label: "操作紀錄", icon: Icon.History, count: audit.length },
          { key: "holiday", label: "休假模式", icon: Icon.Away, count: null },
          { key: "chatbind", label: "群組綁定", icon: Icon.Link, count: null },
          { key: "brand", label: "品牌對應", icon: Icon.Doc, count: null },
        ].map(t => {
          const active = section === t.key;
          return (
            <button key={t.key} onClick={() => setSection(t.key)}
              className={`relative shrink-0 whitespace-nowrap flex items-center gap-2 px-3 lg:px-4 py-2.5 text-[13.5px] font-medium transition-colors
                ${active ? "text-fg" : "text-fg3 hover:text-fg2"}`}>
              {t.icon && <t.icon className="w-4 h-4 shrink-0" />}
              <span>{t.label}</span>
              {t.count != null && (
                <span className={`mono text-[11px] px-1.5 py-0.5 rounded ${active ? "bg-surface2 text-fg2" : "text-fg3"}`}>
                  {t.count}
                </span>
              )}
              {active && <span className="absolute left-0 right-0 -bottom-px h-0.5 grad-fill" />}
            </button>
          );
        })}
      </div>

      {loading && <div className="text-fg3 text-[13px]">載入中...</div>}

      {!loading && section === "users" && (
        <div className="bg-surface border border-border rounded-xl overflow-hidden">
          <div className="px-4 py-3 border-b border-border flex items-center justify-between">
            <h3 className="text-fg text-[14px] font-semibold">使用者列表</h3>
            <span className="text-fg3 text-[11.5px] mono">{users.length} 個帳號</span>
          </div>
          <div className="divide-y divide-border">
            {users.map(u => {
              const meta = ROLE_META[u.role] || ROLE_META.viewer;
              const isCurrent = u.id === currentUserId;
              return (
                <button key={u.id}
                  onClick={() => setEditingUser(u)}
                  className="w-full text-left px-4 py-3 trace-hover transition-colors flex items-center gap-3">
                  <div className="min-w-0 flex-1">
                    <div className="flex items-center gap-2 flex-wrap">
                      <span className="text-fg text-[13.5px] font-medium truncate">{u.email}</span>
                      {isCurrent && <span className="text-fg3 text-[11px]">(你)</span>}
                      {!u.is_active && <span className="rounded px-1.5 py-0.5 text-[10px] font-semibold mono uppercase border border-red-500/40 text-red-400">停用</span>}
                    </div>
                    <div className="text-fg3 text-[11.5px] mono mt-0.5">
                      建立於 {fmtDateTime(u.created_at)}
                    </div>
                  </div>
                  <span className={`${meta.cls} mono text-[10.5px] font-semibold uppercase tracking-wider px-2 py-1 rounded shrink-0`}>
                    {meta.label}
                  </span>
                  <Icon.Chev className="w-4 h-4 text-fg3 -rotate-90 shrink-0" />
                </button>
              );
            })}
            {users.length === 0 && (
              <div className="px-4 py-8 text-center text-fg3 text-[13px]">尚無使用者</div>
            )}
          </div>
        </div>
      )}

      {!loading && section === "audit" && (
        <div className="bg-surface border border-border rounded-xl overflow-hidden">
          <div className="px-4 py-3 border-b border-border flex items-center justify-between gap-3 flex-wrap">
            <h3 className="text-fg text-[14px] font-semibold shrink-0">操作紀錄（最近 200 筆）</h3>
            <input type="text" value={auditFilter}
              onChange={e => setAuditFilter(e.target.value)}
              placeholder="搜尋 email / action / target..."
              className="h-8 px-3 bg-surface2 border border-border rounded-md text-fg text-[12.5px] outline-none focus:border-borderStrong min-w-[200px]" />
          </div>
          <div className="divide-y divide-border max-h-[640px] overflow-y-auto">
            {filteredAudit.map(a => (
              <div key={a.id} className="px-4 py-2.5 text-[12.5px] flex flex-col sm:flex-row items-start gap-1.5 sm:gap-3">
                <div className="flex items-center gap-2 shrink-0 sm:contents">
                  <span className="mono text-[11px] text-fg3 shrink-0 sm:w-[120px]">{fmtDateTime(a.created_at)}</span>
                  <span className="tint-fix mono rounded px-1.5 py-0.5 text-[10.5px] font-semibold whitespace-nowrap shrink-0">
                    {a.action}
                  </span>
                </div>
                <div className="min-w-0 w-full sm:flex-1 text-fg2">
                  <span className="text-fg">{a.actor_email || "system"}</span>
                  {a.target_type && (
                    <>
                      <span className="text-fg3"> · {a.target_type}</span>
                      {a.target_id && <span className="mono text-fg3"> {a.target_id}</span>}
                    </>
                  )}
                  {a.metadata && Object.keys(a.metadata).length > 0 && (
                    <div className="text-fg3 text-[11px] mt-0.5 mono">
                      {Object.entries(a.metadata).map(([k, v]) => (
                        <span key={k} className="mr-3">{k}: {String(v).slice(0, 40)}</span>
                      ))}
                    </div>
                  )}
                </div>
              </div>
            ))}
            {filteredAudit.length === 0 && (
              <div className="px-4 py-8 text-center text-fg3 text-[13px]">
                {auditFilter ? "無符合的紀錄" : "尚無操作紀錄"}
              </div>
            )}
          </div>
        </div>
      )}

      {!loading && section === "holiday" && <HolidayPanel />}

      {!loading && section === "chatbind" && <ChatBindingPanel />}

      {!loading && section === "brand" && <BrandMappingPanel />}

      {editingUser && (
        <EditUserModal
          user={editingUser}
          onClose={() => setEditingUser(null)}
          onSave={handleUserSaved}
          currentUserId={currentUserId}
        />
      )}
    </section>
  );
}

// ---------- Schedule Calendar (Editor / Admin only) ----------

const EVENT_CATEGORIES = {
  milestone: { label: "重要事件時程", color: "rgb(234, 179, 8)" },  // gold
  meeting:   { label: "會議",  color: "rgb(168, 85, 247)" },  // purple
  demo:      { label: "Demo",  color: "rgb(var(--success-rgb))" },
  freeze:    { label: "凍結日",  color: "rgb(var(--hotfix-rgb))" },
  deadline:  { label: "Deadline",  color: "rgb(239, 68, 68)" },   // red
  other:     { label: "其他",  color: "rgb(var(--fg2-rgb))" },
};

// 事件類別 icon — 統一線性風格，自動套用該類別顏色
const EVENT_SVG = {
  milestone: <path d="M5 22V3.5M5 4.5h11.5l-2.4 3.3L16.5 11H5" />,
  meeting: <g><circle cx="8.5" cy="8.8" r="2.8" /><path d="M3.2 20a5.3 5.3 0 0 1 10.6 0" /><circle cx="16.8" cy="9.6" r="2.1" /><path d="M15.7 14.4A4.5 4.5 0 0 1 20.8 19" /></g>,
  demo: <g><rect x="3" y="4.2" width="18" height="12" rx="1.6" /><path d="M8.5 20.3h7M12 16.2v4.1" /><path d="m10.6 8.2 3.3 2-3.3 2z" fill="currentColor" stroke="none" /></g>,
  freeze: <path d="M12 2.5v19M3.9 7 20.1 16.5M20.1 7 3.9 16.5M12 6.2 9.6 4.4M12 6.2l2.4-1.8M12 17.8 9.6 19.6M12 17.8l2.4 1.8" />,
  deadline: <g><circle cx="12" cy="13" r="7.2" /><path d="M12 9.6V13l2.3 1.6" /><path d="M5.3 4.4 2.8 6.8M18.7 4.4l2.5 2.4" /></g>,
  other: <g><path d="M12 21.5s6.4-5.7 6.4-10.4a6.4 6.4 0 1 0-12.8 0c0 4.7 6.4 10.4 6.4 10.4Z" /><circle cx="12" cy="10.6" r="2.2" /></g>,
};
function EventIcon({ cat, size = 14, tinted = true }) {
  const meta = EVENT_CATEGORIES[cat] || EVENT_CATEGORIES.other;
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
         strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"
         className="shrink-0 inline-block" style={tinted ? { color: meta.color } : undefined}>
      {EVENT_SVG[cat] || EVENT_SVG.other}
    </svg>
  );
}

// Custom events are now stored in Supabase `custom_events` table.
// localStorage cache is kept only for the offline fallback / migration scenario.
async function fetchCustomEvents() {
  const { data, error } = await supabase
    .from("custom_events")
    .select("*")
    .order("date", { ascending: true });
  if (error) {
    console.error("fetchCustomEvents:", error.message);
    return [];
  }
  return data || [];
}

async function upsertCustomEvent(event, userId) {
  // event has: id (optional, uuid), date, title, category, description
  const payload = {
    date: event.date,
    title: event.title,
    category: event.category,
    description: event.description || null,
  };
  if (event.id && /^[0-9a-f-]{36}$/i.test(event.id)) {
    // existing UUID → update
    const { data, error } = await supabase
      .from("custom_events")
      .update(payload)
      .eq("id", event.id)
      .select()
      .single();
    return { data, error };
  }
  // new → insert (let DB generate UUID)
  const { data, error } = await supabase
    .from("custom_events")
    .insert({ ...payload, created_by: userId || null })
    .select()
    .single();
  return { data, error };
}

async function deleteCustomEvent(id) {
  const { error } = await supabase.from("custom_events").delete().eq("id", id);
  return { error };
}

async function batchInsertCustomEvents(rows, userId) {
  const payload = rows.map(r => ({
    date: r.date,
    title: r.title,
    category: r.category,
    description: r.description || null,
    created_by: userId || null,
  }));
  const { data, error } = await supabase.from("custom_events").insert(payload).select();
  return { data, error };
}

// Generate milestone events from upcoming release cycles.
// For each unique releaseDate, creates: (1) release-day milestone, (2) freeze-day milestone (previous day).
function generateMilestonesFromCycles() {
  const out = [];
  const seenReleaseDates = new Set();
  ((typeof window !== "undefined" && window.NEXT_RELEASES) || []).forEach(c => {
    if (!c.releaseDate) return;
    if (seenReleaseDates.has(c.releaseDate)) return;
    seenReleaseDates.add(c.releaseDate);

    const release = new Date(c.releaseDate + "T00:00:00");
    const freeze = new Date(release.getTime() - 86400000);
    const freezeStr = `${freeze.getFullYear()}-${pad2(freeze.getMonth()+1)}-${pad2(freeze.getDate())}`;

    // Find all products releasing on this date
    const products = (window.NEXT_RELEASES || [])
      .filter(x => x.releaseDate === c.releaseDate)
      .map(x => `${x.product === "VF" ? "PredictGo" : "Predict Gaming"} ${x.plannedVersion}`);
    const versionLabel = products.join(" + ");

    out.push({
      id: `seed-freeze-${c.releaseDate}`,
      date: freezeStr,
      title: `${c.plannedVersion} Release Freeze`,
      category: "milestone",
      description: "凍結日，不再收新需求進此版本。隔天發版。",
    });
    out.push({
      id: `seed-release-${c.releaseDate}`,
      date: c.releaseDate,
      title: `${c.plannedVersion} 雙產品同步上線`,
      category: "milestone",
      description: versionLabel,
    });
  });
  return out;
}

function buildScheduleEvents(customEvents) {
  const map = {};
  const push = (dateStr, ev) => {
    if (!dateStr) return;
    (map[dateStr] = map[dateStr] || []).push(ev);
  };
  ((typeof window !== "undefined" && window.RELEASE_DATA) || []).forEach(r => {
    push(r.date, {
      kind: "release",
      product: r.product,
      version: r.version,
      title: r.title,
      hotfix: r.hotfix,
      isFuture: false,
    });
  });
  ((typeof window !== "undefined" && window.NEXT_RELEASES) || []).forEach(c => {
    push(c.releaseDate, {
      kind: "upcoming",
      product: c.product,
      version: c.plannedVersion,
      title: `${c.issues?.length || 0} 張票規劃中`,
      hotfix: false,
      isFuture: true,
      issueCount: c.issues?.length || 0,
      isCurrent: c.isCurrent,
    });
  });
  ((typeof window !== "undefined" && window.LINEAR_DUE_DATES) || []).forEach(t => {
    push(t.dueDate, {
      kind: "linear",
      id: t.id,
      product: t.product,
      title: t.title,
      status: t.status,
      statusType: t.statusType,
      priority: t.priority,
      assignee: t.assignee,
      url: t.url,
    });
  });
  (customEvents || []).forEach(c => {
    push(c.date, {
      kind: "custom",
      id: c.id,
      category: c.category || "other",
      title: c.title,
      description: c.description || "",
    });
  });
  return map;
}

function CustomEventModal({ initialData, onSave, onClose, onDelete }) {
  const [date, setDate] = useState(initialData?.date || "");
  const [title, setTitle] = useState(initialData?.title || "");
  const [category, setCategory] = useState(initialData?.category || "milestone");
  const [description, setDescription] = useState(initialData?.description || "");
  const editing = !!initialData?.id;

  const submit = () => {
    if (!date || !title.trim()) return;
    onSave({
      id: initialData?.id || `evt-${Date.now()}`,
      date,
      title: title.trim(),
      category,
      description: description.trim(),
    });
  };

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm px-5"
         onClick={onClose}>
      <div className="bg-surface border border-border rounded-2xl p-6 w-full max-w-[440px]"
           onClick={(e) => e.stopPropagation()}>
        <div className="flex items-center justify-between mb-4">
          <h3 className="text-fg text-[15px] font-semibold">
            {editing ? "編輯事件" : "新增事件"}
          </h3>
          <button onClick={onClose} className="text-fg3 hover:text-fg">
            <Icon.X className="w-4 h-4" />
          </button>
        </div>

        <div className="flex flex-col gap-3.5">
          <div>
            <label className="block text-fg2 text-[12px] mb-1.5">日期</label>
            <input type="date" value={date} onChange={e => setDate(e.target.value)}
              className="w-full h-10 px-3 bg-surface2 border border-border rounded-lg text-fg mono text-[13px] outline-none focus:border-borderStrong"/>
          </div>

          <div>
            <label className="block text-fg2 text-[12px] mb-1.5">標題</label>
            <input type="text" value={title} onChange={e => setTitle(e.target.value)}
              placeholder="例：Demo 給站長"
              className="w-full h-10 px-3 bg-surface2 border border-border rounded-lg text-fg text-[13px] outline-none focus:border-borderStrong"/>
          </div>

          <div>
            <label className="block text-fg2 text-[12px] mb-1.5">類型</label>
            <div className="flex flex-wrap gap-1.5">
              {Object.entries(EVENT_CATEGORIES).map(([key, meta]) => (
                <button key={key}
                  onClick={() => setCategory(key)}
                  className={`chip rounded-md px-2.5 h-8 text-[12px] border inline-flex items-center gap-1.5
                    ${category === key
                      ? "grad-btn"
                      : "bg-surface2 text-fg2 border-border hover:text-fg hover:border-borderStrong"}`}>
                  <EventIcon cat={key} size={15} />
                  {meta.label}
                </button>
              ))}
            </div>
          </div>

          <div>
            <label className="block text-fg2 text-[12px] mb-1.5">備註（選填）</label>
            <textarea value={description} onChange={e => setDescription(e.target.value)}
              rows={2}
              placeholder="細節、地點、與會者..."
              className="w-full px-3 py-2 bg-surface2 border border-border rounded-lg text-fg text-[12.5px] outline-none focus:border-borderStrong resize-none"/>
          </div>
        </div>

        <div className="flex items-center gap-2 mt-5">
          {editing && (
            <button onClick={() => onDelete(initialData.id)}
              className="rounded-lg px-3 h-9 text-[12.5px] border border-border bg-surface text-red-400 hover:bg-red-500/10 transition-colors">
              刪除
            </button>
          )}
          <div className="flex-1" />
          <button onClick={onClose}
            className="rounded-lg px-4 h-9 text-[12.5px] border border-border bg-surface text-fg2 hover:text-fg hover:border-borderStrong transition-colors">
            取消
          </button>
          <button onClick={submit}
            disabled={!date || !title.trim()}
            className="rounded-lg px-4 h-9 text-[12.5px] grad-btn font-semibold hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed transition-opacity">
            {editing ? "儲存" : "新增"}
          </button>
        </div>
      </div>
    </div>
  );
}

function pad2(n) { return String(n).padStart(2, "0"); }
function fmtDate(d) { return `${d.getFullYear()}-${pad2(d.getMonth()+1)}-${pad2(d.getDate())}`; }
function sameMonth(a, b) { return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth(); }

// ---------- Schedule Board（取代原本的月曆）----------
//
// 為什麼不是月曆：發版節奏的單位是「週」不是「日」。一個月只有 8 件事，
// 塞進 35 個格子等於 97% 是空白，所以沒人想打開。這裡改成三塊：
//   1 本週焦點 —— 現在最該關心什麼
//   2 發版節奏 —— 接下來六週會發生什麼
//   3 承諾軸   —— 跟老闆講好的大方向還剩多少
// 前兩塊完全由既有資料推導，不需要手動建任何事件。

const WEEK_COUNT = 6;

// 批次測一輪的基礎窗 + 單量級距。
// 為什麼不是固定天數：實測票在 Ready for QA 的停留 0.8～13.5 天都有。
// 為什麼不是「票數 ÷ 日產能」：QA 是一批一批測，不是連續消化佇列。
// 這組參數之後會用 qa_batch_stats 的實測結果自動校準，先給保守值。
const QA_WINDOW_TIERS = [
  { max: 8, days: 4 },
  { max: 15, days: 5 },
  { max: Infinity, days: 6 },
];
const qaWindowDays = (n) => (QA_WINDOW_TIERS.find((t) => n <= t.max) || { days: 6 }).days;

const MS_DAY = 86400000;
function parseYMD(s) {
  if (!s) return null;
  const [y, m, d] = String(s).split("-").map(Number);
  if (!y || !m || !d) return null;
  return new Date(y, m - 1, d);
}
function addDays(d, n) { const x = new Date(d); x.setDate(x.getDate() + n); return x; }
function startOfWeekMon(d) {
  const x = new Date(d);
  x.setHours(0, 0, 0, 0);
  x.setDate(x.getDate() - ((x.getDay() + 6) % 7));   // 週一為一週之始
  return x;
}
function diffDays(a, b) { return Math.round((a - b) / MS_DAY); }
const mdLabel = (d) => `${d.getMonth() + 1}/${d.getDate()}`;

// Linear 狀態 → QA 看板語意。名稱判定，不用 state.type ——
// 他們的 workflow 把整條 QA 管線都設成 completed 型別。
const QA_BUCKETS = [
  { key: "ready", label: "待發版", color: "var(--qa-pass)", names: ["Ready for Release", "Monitoring", "Done"] },
  { key: "test",  label: "測試中", color: "var(--qa-test)", names: ["In QA"] },
  { key: "fail",  label: "退回",   color: "var(--qa-risk)", names: ["QA Failed", "QA Issue"] },
  { key: "wait",  label: "待測",   color: "var(--qa-wait)", names: ["Ready for QA"] },
  { key: "dev",   label: "開發中", color: "var(--qa-none)", names: [] },
];
const QA_BUCKET_OF = (status) =>
  (QA_BUCKETS.find((b) => b.names.includes(status)) || QA_BUCKETS[QA_BUCKETS.length - 1]).key;

function qaTally(issues) {
  const t = { ready: 0, test: 0, fail: 0, wait: 0, dev: 0, total: 0 };
  for (const it of issues || []) {
    t[QA_BUCKET_OF(it.status)]++;
    t.total++;
  }
  return t;
}

// 把 cycles 依發版日收成「一天一筆」。以日期為 key 而不是產品 ——
// 加第四、第五個產品時只是多一個 chip，版面邏輯不用動。
function groupReleasesByDate(cycles) {
  const map = new Map();
  for (const c of cycles || []) {
    if (!c.releaseDate) continue;
    if (!map.has(c.releaseDate)) map.set(c.releaseDate, { date: c.releaseDate, products: [], issues: [] });
    const g = map.get(c.releaseDate);
    g.products.push({ product: c.product, version: c.plannedVersion, count: (c.issues || []).length, isCurrent: c.isCurrent });
    g.issues.push(...(c.issues || []));
  }
  return [...map.values()].sort((a, b) => a.date.localeCompare(b.date));
}

/* ───────────────────── 1 本週焦點 ───────────────────── */

function GateCard({ icon, label, date, state, note }) {
  const tone = state === "done" ? { c: "var(--qa-pass)", cls: "opacity-60 border-border" }
    : state === "now" ? { c: "var(--qa-wait)", cls: "border-[color:var(--qa-wait)]" }
    : state === "next" ? { c: "var(--qa-risk)", cls: "border-[color:var(--qa-risk)]" }
    : { c: "rgb(var(--fg3-rgb))", cls: "border-border" };
  return (
    <div className={`flex-1 min-w-[118px] rounded-xl border bg-surface2 px-3 py-2.5 ${tone.cls}`}>
      <div className="flex items-center gap-1.5 text-[10.5px] text-fg3">
        <GateIcon kind={icon} color={tone.c} />{label}
      </div>
      <div className="mono text-[15px] font-semibold mt-0.5">{date}</div>
      <div className="text-[11px] mt-px" style={{ color: tone.c }}>{note}</div>
    </div>
  );
}

function GateIcon({ kind, color, size = 11 }) {
  const paths = {
    qa:     <path d="M4 12.5 9.5 18 20 6.5" />,
    freeze: <path d="M12 2.5v19M3.9 7l16.2 9.5M20.1 7 3.9 16.5" />,
    ship:   <path d="M5 22V3.5M5 4.5h11.5l-2.4 3.3L16.5 11H5" />,
    goal:   <g><circle cx="12" cy="12" r="8.5" /><path d="M12 7.5V12l3 2" /></g>,
    note:   <g><path d="M4.5 4.5h15v15h-15z" /><path d="M8 9h8M8 13h5" /></g>,
  };
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color || "currentColor"}
         strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
      {paths[kind] || paths.note}
    </svg>
  );
}

function FocusCard({ group, today }) {
  if (!group) {
    return (
      <div className="rounded-xl border border-border bg-surface px-5 py-6 text-fg3 text-[13px]">
        目前沒有規劃中的版本。Linear 的 cycle 設好發版日之後這裡就會出現。
      </div>
    );
  }
  const shipDate = parseYMD(group.date);
  const freezeDate = addDays(shipDate, -1);
  const qa = qaTally(group.issues);
  const qaDate = addDays(shipDate, -qaWindowDays(qa.total));

  const dLeft = (d) => diffDays(d, today);
  const noteOf = (d) => {
    const n = dLeft(d);
    return n < 0 ? "已完成" : n === 0 ? "就是今天" : `剩 ${n} 天`;
  };
  const stateOf = (d, isNext) => (dLeft(d) < 0 ? "done" : isNext ? "next" : "now");

  const blockers = (group.issues || [])
    .filter((i) => ["fail", "wait", "test"].includes(QA_BUCKET_OF(i.status)))
    .slice(0, 5);
  const passed = qa.ready;

  return (
    <article className="relative overflow-hidden rounded-xl border border-border bg-surface px-5 py-4">
      <span className="absolute left-0 top-0 bottom-0 w-[3px] grad-fill" />
      <div className="flex flex-wrap gap-6 items-start">
        <div className="flex-1 min-w-[280px]">
          <div className="flex items-center gap-2.5 flex-wrap">
            <span className="text-[19px] font-semibold tracking-tight">
              {group.products.map((p) => p.version).filter(Boolean)[0] || "未定版號"}
            </span>
            {group.products.map((p) => (
              <span key={p.product} className={`chip rounded-md px-2 py-0.5 text-[11px] font-semibold mono
                ${p.product === "VF" ? "tint-vf" : p.product === "PG" ? "tint-pg" : "tint-none"}`}>
                {p.product} {p.count}
              </span>
            ))}
          </div>
          <div className="flex gap-2 mt-3.5 flex-wrap">
            <GateCard icon="qa" label="進測截止" date={mdLabel(qaDate)}
                      state={stateOf(qaDate)} note={noteOf(qaDate)} />
            <GateCard icon="freeze" label="凍結日" date={mdLabel(freezeDate)}
                      state={stateOf(freezeDate)} note={noteOf(freezeDate)} />
            <GateCard icon="ship" label="發版" date={mdLabel(shipDate)}
                      state={stateOf(shipDate, true)} note={noteOf(shipDate)} />
          </div>
        </div>

        <div className="min-w-[240px] flex-1">
          <div className="flex items-baseline justify-between mb-1.5">
            <span className="text-[12px] text-fg2">QA 進度</span>
            <span className="mono text-[13px] font-semibold">{passed} / {qa.total}</span>
          </div>
          <div className="h-[7px] rounded flex overflow-hidden bg-surface2">
            {QA_BUCKETS.map((b) => qa[b.key] > 0 && (
              <i key={b.key} className="block h-full"
                 style={{ width: `${(qa[b.key] / Math.max(1, qa.total)) * 100}%`, background: b.color }} />
            ))}
          </div>
          <div className="flex gap-x-3.5 gap-y-1 flex-wrap mt-2 text-[11.5px] text-fg2">
            {QA_BUCKETS.map((b) => qa[b.key] > 0 && (
              <span key={b.key}>
                <i className="inline-block w-2 h-2 rounded-sm mr-1.5 align-[-1px]" style={{ background: b.color }} />
                {b.label} {qa[b.key]}
              </span>
            ))}
          </div>
          {blockers.length > 0 && (
            <div className="mt-3 pt-2.5 hair">
              <div className="text-fg3 text-[11px] mb-1">擋住發版的 {qa.total - passed} 張</div>
              {blockers.map((i) => {
                const b = QA_BUCKETS.find((x) => x.key === QA_BUCKET_OF(i.status));
                return (
                  <a key={i.id} href={i.url} target="_blank" rel="noreferrer"
                     className="flex items-center gap-2 py-[3px] text-[12.5px] text-fg2 hover:text-fg">
                    <span className="mono text-[10px] px-1.5 py-px rounded font-semibold shrink-0"
                          style={{ background: `${b.color}22`, color: b.color }}>{b.label}</span>
                    <span className="truncate">{i.title}</span>
                  </a>
                );
              })}
            </div>
          )}
        </div>
      </div>
    </article>
  );
}

/* ───────────────────── 2 發版節奏 ───────────────────── */

function RhythmBar({ groups, commitments, customEvents, absence, today }) {
  const weeks = useMemo(() => {
    const first = startOfWeekMon(today);
    return Array.from({ length: WEEK_COUNT }, (_, i) => {
      const from = addDays(first, i * 7);
      const to = addDays(from, 6);
      return { i, from, to };
    });
  }, [today]);

  const inWeek = (ymd, w) => {
    const d = parseYMD(ymd);
    return d && d >= w.from && d <= addDays(w.to, 1);
  };

  return (
    <div className="flex overflow-x-auto no-scrollbar rounded-xl border border-border bg-surface">
      {weeks.map((w) => {
        const rels = groups.filter((g) => inWeek(g.date, w));
        const goals = commitments.filter((c) => c.target_date && inWeek(c.target_date, w));
        const notes = (customEvents || []).filter((e) => inWeek(e.date, w));
        const isCur = w.i === 0;
        return (
          <div key={w.i}
               className={`flex-1 min-w-[168px] border-r border-border last:border-r-0 px-3.5 py-3 ${isCur ? "grad-tint" : ""}`}>
            <div className="flex items-baseline justify-between mb-2.5">
              <b className="text-[12.5px] font-semibold">{isCur ? "本週" : w.i === 1 ? "下週" : `第 ${w.i + 1} 週`}</b>
              <span className="mono text-[10.5px] text-fg3">{mdLabel(w.from)}–{mdLabel(w.to)}</span>
            </div>

            {rels.map((g) => {
              const ship = parseYMD(g.date);
              const qa = qaTally(g.issues);
              return (
                <div key={g.date} className="mb-2">
                  <div className="flex items-start gap-2 text-[12px] py-[2px] text-fg2">
                    <GateIcon kind="freeze" color="var(--qa-wait)" size={12} />
                    <span>{mdLabel(addDays(ship, -1))} 凍結</span>
                  </div>
                  <div className="flex items-start gap-2 text-[12px] py-[2px] text-fg2">
                    <GateIcon kind="ship" color="var(--qa-risk)" size={12} />
                    <span className="text-fg font-medium">
                      {mdLabel(ship)} {g.products.map((p) => p.version).filter(Boolean)[0] || ""}
                    </span>
                  </div>
                  <div className="flex gap-1.5 mt-1.5 flex-wrap">
                    {g.products.map((p) => (
                      <span key={p.product} className={`chip rounded px-1.5 py-px text-[10.5px] font-semibold mono
                        ${p.product === "VF" ? "tint-vf" : p.product === "PG" ? "tint-pg" : "tint-none"}`}>
                        {p.product} {p.count}
                      </span>
                    ))}
                  </div>
                  <div className="text-[10.5px] text-fg3 mt-1.5">
                    {qa.total === 0 ? "尚無票" : `QA ${qa.ready}/${qa.total}`}
                    {qa.total > qa.ready && ` · ${qa.total - qa.ready} 張未過`}
                  </div>
                </div>
              );
            })}

            {goals.map((c) => (
              <div key={c.id} className="flex items-start gap-2 text-[12px] py-[2px]" style={{ color: "var(--rm-2)" }}>
                <GateIcon kind="goal" color="var(--rm-2)" size={12} />
                <span className="truncate">{c.name} 到期</span>
              </div>
            ))}

            {notes.map((e) => (
              <div key={e.id} className="flex items-start gap-2 text-[12px] py-[2px] text-fg2">
                <GateIcon kind="note" color="rgb(var(--fg3-rgb))" size={12} />
                <span className="truncate">{e.title}</span>
              </div>
            ))}

            {rels.length === 0 && goals.length === 0 && notes.length === 0 && (
              <div className="text-[11px] text-fg3">scope 未定</div>
            )}

            {absence?.active && absence.from && inWeek(absence.from, w) && (
              <div className="mt-2.5 rounded-md px-2 py-1.5 text-[11px]"
                   style={{ background: "rgb(var(--vf-rgb) / 0.13)", border: "1px solid rgb(var(--vf-rgb) / 0.3)" }}>
                休假 {absence.from}–{absence.to}
              </div>
            )}
          </div>
        );
      })}
    </div>
  );
}

/* ───────────────────── 3 承諾軸 ───────────────────── */

// Linear 的 targetDateResolution：null=精確到日，month/quarter/halfYear/year=模糊目標。
// 粒度本來就對應老闆講話的方式（「10 月底前」），直接照著顯示，不要自作主張換算成某一天。
function fmtTarget(date, res) {
  const d = parseYMD(date);
  if (!d) return null;
  const m = d.getMonth() + 1;
  if (res === "month") {
    const day = d.getDate();
    return `${m} 月${day <= 10 ? "初" : day <= 20 ? "中" : "底"}`;
  }
  if (res === "quarter") return `${d.getFullYear()} Q${Math.floor(d.getMonth() / 3) + 1}`;
  if (res === "halfYear") return `${d.getFullYear()} H${d.getMonth() < 6 ? 1 : 2}`;
  if (res === "year") return `${d.getFullYear()} 年`;
  return `${m}/${d.getDate()}`;
}

// 風險比的是「時間過了多少」對「做完多少」，不用人工標記。
function riskOf(p, today) {
  if (!p.target_date) return "none";
  const left = diffDays(parseYMD(p.target_date), today);
  if (left < 0) return "late";
  const total = p.issue_total || 0;
  const doneRatio = total ? (p.issue_done + p.issue_ready) / total : 0;
  const start = parseYMD(p.start_date);
  if (!start) return left <= 7 && doneRatio < 0.9 ? "warn" : "ok";
  const span = Math.max(1, diffDays(parseYMD(p.target_date), start));
  const elapsed = Math.min(1, Math.max(0, 1 - left / span));
  const gap = elapsed - doneRatio;
  if (gap > 0.25) return "late";
  if (gap > 0.1) return "warn";
  return "ok";
}
const RISK_COLOR = { late: "var(--qa-risk)", warn: "var(--qa-wait)", ok: "var(--qa-pass)", none: "rgb(var(--fg3-rgb))" };

function CommitmentRow({ p, today, big, canPin, onPin }) {
  const total = Math.max(1, p.issue_total || 0);
  const risk = riskOf(p, today);
  const left = p.target_date ? diffDays(parseYMD(p.target_date), today) : null;
  const label = p.target_date ? fmtTarget(p.target_date, p.target_res) : "未設定目標";

  if (!big) {
    return (
      <a href={p.url || "#"} target="_blank" rel="noreferrer"
         className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface2 px-2.5 py-1.5 text-[11.5px] text-fg2 hover:text-fg">
        <span className="truncate max-w-[150px]">{p.name}</span>
        <span className="mono text-[10.5px] text-fg3">{p.issue_done + p.issue_ready}/{p.issue_total}</span>
      </a>
    );
  }
  return (
    <div className="grid grid-cols-[minmax(0,1fr)_96px] lg:grid-cols-[200px_minmax(0,1fr)_120px_92px] gap-x-3.5 gap-y-2 items-center px-4 lg:px-5 py-2.5 border-b border-border last:border-b-0">
      <div className="min-w-0">
        <a href={p.url || "#"} target="_blank" rel="noreferrer"
           className="text-[13.5px] font-medium truncate block hover:underline">{p.name}</a>
        <small className="block text-fg3 text-[10.5px] mt-px">
          {(p.team_codes || []).join(" · ") || "—"}{p.status_name ? ` · ${p.status_name}` : ""}
        </small>
      </div>
      <div className="col-span-2 lg:col-span-1 h-[7px] rounded flex overflow-hidden bg-surface2">
        <i className="block h-full" style={{ width: `${(p.issue_done / total) * 100}%`, background: RISK_COLOR[risk] }} />
        <i className="block h-full" style={{ width: `${(p.issue_ready / total) * 100}%`, background: RISK_COLOR[risk], opacity: 0.42 }} />
      </div>
      <div className="text-[12px] lg:text-right" style={{ color: RISK_COLOR[risk] }}>
        {label}
        {left != null && (
          <span className="block text-[10.5px] opacity-80">
            {left < 0 ? `逾期 ${-left} 天` : `剩 ${left} 天`}
          </span>
        )}
      </div>
      <div className="mono text-[12px] text-fg2 text-right flex items-center justify-end gap-2">
        <span>{p.issue_done + p.issue_ready} / {p.issue_total}</span>
        {canPin && (
          <button onClick={() => onPin(p)} title={p.pinned ? "取消釘選" : "釘選到重點追蹤"}
                  className={`shrink-0 ${p.pinned ? "text-fg" : "text-fg3 hover:text-fg2"}`}>
            <svg width="13" height="13" viewBox="0 0 24 24" fill={p.pinned ? "currentColor" : "none"}
                 stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round">
              <path d="M12 17v5M9 3h6l-1 6 3 3v2H7v-2l3-3z" />
            </svg>
          </button>
        )}
      </div>
    </div>
  );
}

/* ───────────────────── 組裝 ───────────────────── */

function ScheduleBoard({ userId, canEdit = false, isAdmin = false }) {
  const today = useMemo(() => { const d = new Date(); d.setHours(0, 0, 0, 0); return d; }, []);
  const [customEvents, setCustomEvents] = useState([]);
  const [commitments, setCommitments] = useState([]);
  const [absence, setAbsence] = useState(null);
  const [loadErr, setLoadErr] = useState(null);
  const [showAll, setShowAll] = useState(false);
  const [modalOpen, setModalOpen] = useState(false);
  const [editingEvent, setEditingEvent] = useState(null);

  useEffect(() => { fetchCustomEvents().then(setCustomEvents).catch(() => {}); }, []);
  useEffect(() => {
    let cancelled = false;
    pmApi("/commitments")
      .then((d) => { if (!cancelled) setCommitments(d.projects || []); })
      .catch((e) => { if (!cancelled) setLoadErr(String(e?.message || e)); });
    // 休假區間顯示在節奏條上；拿不到就當沒有，不要擋住整頁
    pmApi("/absence").then((d) => { if (!cancelled) setAbsence(d.absence || null); }).catch(() => {});
    return () => { cancelled = true; };
  }, []);

  const groups = groupReleasesByDate(typeof window !== "undefined" ? window.NEXT_RELEASES : []);
  const todayStr = fmtDate(today);
  const nextGroup = groups.find((g) => g.date >= todayStr) || null;

  // 版號專案（v1.8.0 這種）是發版流程的產物，不是對外承諾，排掉
  const live = commitments.filter(
    (c) => c.state === "started" && !/^v\d+\.\d+\.\d+$/.test(c.name || "")
  );
  const sorted = [...live].sort((a, b) => {
    if (!!a.pinned !== !!b.pinned) return a.pinned ? -1 : 1;
    return (a.target_date || "9999-12-31").localeCompare(b.target_date || "9999-12-31");
  });
  const big = sorted.filter((c) => c.pinned || c.target_date).slice(0, 4);
  const bigIds = new Set(big.map((c) => c.id));
  const small = sorted.filter((c) => !bigIds.has(c.id));
  const noTarget = live.filter((c) => !c.target_date).length;

  const handlePin = async (p) => {
    const next = !p.pinned;
    setCommitments((prev) => prev.map((x) => (x.id === p.id ? { ...x, pinned: next } : x)));
    try {
      await pmApi("/commitments/pin", { method: "POST", body: JSON.stringify({ id: p.id, pinned: next }) });
    } catch (e) {
      setCommitments((prev) => prev.map((x) => (x.id === p.id ? { ...x, pinned: !next } : x)));
      alert("釘選失敗：" + (e?.message || e));
    }
  };

  const saveEvent = async (data) => {
    const { data: row, error } = await upsertCustomEvent(data, userId);
    if (error) { alert("儲存失敗：" + error.message); return; }
    if (row) {
      setCustomEvents((prev) =>
        prev.some((e) => e.id === row.id) ? prev.map((e) => (e.id === row.id ? row : e)) : [...prev, row]);
    }
    setModalOpen(false);
  };
  const removeEvent = async (id) => {
    const { error } = await deleteCustomEvent(id);
    if (error) { alert("刪除失敗：" + error.message); return; }
    setCustomEvents((prev) => prev.filter((e) => e.id !== id));
    setModalOpen(false);
  };

  const H2 = ({ children, sub }) => (
    <div className="flex items-baseline gap-2 flex-wrap mt-7 first:mt-1 mb-2.5">
      <h2 className="text-[12px] font-semibold uppercase tracking-[0.06em] text-fg3">{children}</h2>
      {sub && <span className="text-[11.5px] text-fg3">{sub}</span>}
    </div>
  );

  return (
    <section className="mt-4 mb-6">
      <H2 sub="現在最該關心什麼">本週焦點</H2>
      <FocusCard group={nextGroup} today={today} />

      <H2 sub={`未來 ${WEEK_COUNT} 週 — 由 Linear cycle 推導，不需手動建事件`}>發版節奏</H2>
      <RhythmBar groups={groups} commitments={live} customEvents={customEvents}
                 absence={absence} today={today} />

      <div className="flex items-baseline gap-2 flex-wrap mt-7 mb-2.5">
        <h2 className="text-[12px] font-semibold uppercase tracking-[0.06em] text-fg3">承諾軸</h2>
        <span className="text-[11.5px] text-fg3">來自 Linear Project 的目標日期</span>
        {canEdit && (
          <button onClick={() => { setEditingEvent({ date: todayStr }); setModalOpen(true); }}
                  className="ml-auto rounded-md grad-btn px-2.5 h-7 text-[11.5px] font-semibold">
            新增事件
          </button>
        )}
      </div>

      {loadErr ? (
        <div className="rounded-xl border border-border bg-surface px-5 py-4 text-[13px] text-fg2">
          承諾軸讀取失敗：{loadErr}
        </div>
      ) : big.length === 0 ? (
        <div className="rounded-xl border border-border bg-surface px-5 py-5 text-[13px] text-fg2">
          目前沒有設定目標日期的進行中 Project。
          <span className="text-fg3">
            {" "}到 Linear 的 Project 設 target date（可選「月」粒度，對應「10 月底前」這種講法），這裡就會出現。
          </span>
        </div>
      ) : (
        <div className="rounded-xl border border-border bg-surface">
          {big.map((p) => (
            <CommitmentRow key={p.id} p={p} today={today} big canPin={isAdmin} onPin={handlePin} />
          ))}
        </div>
      )}

      {small.length > 0 && (
        <div className="mt-3">
          <button onClick={() => setShowAll((v) => !v)}
                  className="text-[11.5px] text-fg3 hover:text-fg2 mb-2">
            其他進行中（{small.length}）{showAll ? " ▴" : " ▾"}
          </button>
          {showAll && (
            <div className="flex gap-2 flex-wrap">
              {small.map((p) => <CommitmentRow key={p.id} p={p} today={today} big={false} />)}
            </div>
          )}
        </div>
      )}

      {big.length > 0 && noTarget > 0 && (
        <div className="text-[11.5px] text-fg3 mt-2.5">
          另有 {noTarget} 個進行中的 Project 沒設目標日期，不會出現在重點追蹤。
        </div>
      )}

      {modalOpen && (
        <CustomEventModal
          initialData={editingEvent}
          onSave={saveEvent}
          onClose={() => setModalOpen(false)}
          onDelete={editingEvent?.id ? () => removeEvent(editingEvent.id) : undefined}
        />
      )}
    </section>
  );
}

// ---------- 舊的月曆（已停用，暫時保留以便回退）----------
function ScheduleCalendar({ pmMode, userId, canEdit = false }) {
  const today = useMemo(() => new Date(), []);
  const [viewDate, setViewDate] = useState(() => new Date(today.getFullYear(), today.getMonth(), 1));
  const [selectedDate, setSelectedDate] = useState(() => fmtDate(today));
  const [expandedReleases, setExpandedReleases] = useState(() => new Set());

  const toggleReleaseExpand = (key) => {
    setExpandedReleases(prev => {
      const next = new Set(prev);
      if (next.has(key)) next.delete(key);
      else next.add(key);
      return next;
    });
  };

  const [customEvents, setCustomEvents] = useState([]);
  const [eventsLoaded, setEventsLoaded] = useState(false);
  const [modalOpen, setModalOpen] = useState(false);
  const [editingEvent, setEditingEvent] = useState(null);

  // Fetch from Supabase on mount
  useEffect(() => {
    fetchCustomEvents().then(rows => {
      setCustomEvents(rows);
      setEventsLoaded(true);
    });
  }, []);

  const events = useMemo(() => buildScheduleEvents(customEvents), [customEvents]);

  const [detailTab, setDetailTab] = useState("all");
  // Reset detail tab when selected date changes
  useEffect(() => { setDetailTab("all"); }, [selectedDate]);

  const openAdd = (presetDate) => {
    setEditingEvent({ date: presetDate || selectedDate || fmtDate(today) });
    setModalOpen(true);
  };
  const openEdit = (ev) => {
    setEditingEvent(ev);
    setModalOpen(true);
  };
  const handleSave = async (eventData) => {
    const { data, error } = await upsertCustomEvent(eventData, userId);
    if (error) {
      alert("儲存失敗：" + error.message);
      return;
    }
    if (data) {
      setCustomEvents(prev => {
        const exists = prev.some(e => e.id === data.id);
        return exists ? prev.map(e => e.id === data.id ? data : e) : [...prev, data];
      });
      logAudit(eventData.id ? "update_event" : "create_event", "custom_event", data.id, { title: data.title, date: data.date });
    }
    setModalOpen(false);
    setEditingEvent(null);
    setSelectedDate(eventData.date);
  };

  const handleDelete = async (id) => {
    const { error } = await deleteCustomEvent(id);
    if (error) {
      alert("刪除失敗：" + error.message);
      return;
    }
    setCustomEvents(prev => prev.filter(e => e.id !== id));
    logAudit("delete_event", "custom_event", id);
    setModalOpen(false);
    setEditingEvent(null);
  };

  const importMilestones = async () => {
    const suggestions = generateMilestonesFromCycles();
    const existingKeys = new Set(customEvents.map(e => `${e.date}|${e.title}`));
    const toAdd = suggestions.filter(s => !existingKeys.has(`${s.date}|${s.title}`));
    if (toAdd.length === 0) {
      alert("這些里程碑已經匯入過了。");
      return;
    }
    const { data, error } = await batchInsertCustomEvents(toAdd, userId);
    if (error) {
      alert("匯入失敗：" + error.message);
      return;
    }
    if (data) {
      setCustomEvents(prev => [...prev, ...data]);
      logAudit("import_milestones", "custom_event", null, { count: data.length });
    }
  };

  const year = viewDate.getFullYear();
  const month = viewDate.getMonth();
  const firstDay = new Date(year, month, 1);
  const lastDay = new Date(year, month + 1, 0);
  const startWeekday = firstDay.getDay();
  const daysInMonth = lastDay.getDate();

  // Build 6-row grid (42 cells) including leading days from prev month and trailing from next
  const cells = [];
  for (let i = startWeekday - 1; i >= 0; i--) {
    const d = new Date(year, month, -i);
    cells.push({ date: d, inMonth: false });
  }
  for (let d = 1; d <= daysInMonth; d++) {
    cells.push({ date: new Date(year, month, d), inMonth: true });
  }
  while (cells.length < 42) {
    const last = cells[cells.length - 1].date;
    cells.push({ date: new Date(last.getFullYear(), last.getMonth(), last.getDate() + 1), inMonth: false });
  }

  const todayStr = fmtDate(today);
  const monthName = `${year} 年 ${month + 1} 月`;
  const selEvents = (selectedDate && events[selectedDate]) || [];

  const goPrev = () => setViewDate(new Date(year, month - 1, 1));
  const goNext = () => setViewDate(new Date(year, month + 1, 1));
  const goToday = () => {
    setViewDate(new Date(today.getFullYear(), today.getMonth(), 1));
    setSelectedDate(todayStr);
  };

  // Stats for header
  const monthEvents = Object.entries(events).filter(([k]) => {
    const d = new Date(k + "T00:00:00");
    return d.getFullYear() === year && d.getMonth() === month;
  });
  const monthEventCount = monthEvents.reduce((s, [, arr]) => s + arr.length, 0);

  return (
    <section className="mt-5 mb-4">
      {/* Calendar header */}
      <div className="flex items-center justify-between mb-4 flex-wrap gap-2">
        <div className="flex items-center gap-2">
          <button onClick={goPrev}
            className="w-8 h-8 rounded-lg border border-border bg-surface text-fg2 hover:text-fg hover:border-borderStrong transition-colors flex items-center justify-center"
            aria-label="上個月">‹</button>
          <h3 className="text-fg text-[16px] font-semibold tracking-tight min-w-[110px] text-center">{monthName}</h3>
          <button onClick={goNext}
            className="w-8 h-8 rounded-lg border border-border bg-surface text-fg2 hover:text-fg hover:border-borderStrong transition-colors flex items-center justify-center"
            aria-label="下個月">›</button>
        </div>
        <div className="flex items-center gap-2 text-[12px]">
          <span className="text-fg3 mono">本月 {monthEventCount} 個事件</span>
          <button onClick={goToday}
            className="rounded-md border border-border bg-surface px-2.5 h-7 text-fg2 hover:text-fg hover:border-borderStrong transition-colors text-[11.5px]">
            今天
          </button>
          {canEdit && (
            <>
              <button onClick={importMilestones}
                className="rounded-md border border-border bg-surface px-2.5 h-7 text-fg2 hover:text-fg hover:border-borderStrong transition-colors text-[11.5px] inline-flex items-center gap-1"
                title="從 NEXT_RELEASES 自動產生 freeze 日 + 發版日里程碑">
                <EventIcon cat="milestone" size={13} tinted={false} /> 匯入發版里程碑
              </button>
              <button onClick={() => openAdd()}
                className="rounded-md grad-btn px-2.5 h-7 hover:opacity-90 transition-opacity text-[11.5px] font-semibold inline-flex items-center gap-1.5">
                <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"><path d="M12 5v14M5 12h14" /></svg>
                新增事件
              </button>
            </>
          )}
        </div>
      </div>

      {/* Legend */}
      <div className="flex items-center gap-x-4 gap-y-1 mb-3 text-[11.5px] text-fg3 flex-wrap">
        <span className="flex items-center gap-1.5"><span className="inline-block w-2 h-2 rounded-full" style={{background:"rgb(var(--vf-rgb))"}} />PredictGo</span>
        <span className="flex items-center gap-1.5"><span className="inline-block w-2 h-2 rounded-full" style={{background:"rgb(var(--pg-rgb))"}} />Predict Gaming</span>
        <span className="flex items-center gap-1.5"><span className="inline-block w-2 h-2 rounded-full" style={{background:"rgb(var(--hotfix-rgb))"}} />Hotfix</span>
        <span className="flex items-center gap-1.5"><span className="inline-block w-2 h-2 rounded-sm border" style={{borderColor:"rgb(var(--fg3-rgb))",background:"transparent"}} />未來規劃</span>
        <span className="text-fg3">·</span>
        <span className="flex items-center gap-1.5"><span className="inline-block w-2 h-2 rotate-45" style={{background:"rgb(var(--fg2-rgb))"}} />Linear due</span>
        <span className="text-fg3">·</span>
        {Object.entries(EVENT_CATEGORIES).map(([k, meta]) => (
          <span key={k} className="flex items-center gap-1">
            <EventIcon cat={k} size={13} />
            {meta.label}
          </span>
        ))}
      </div>

      {/* Weekday headers */}
      <div className="grid grid-cols-7 gap-1 mb-1">
        {["日","一","二","三","四","五","六"].map((d, i) => (
          <div key={d} className={`text-center text-[11px] mono py-1.5 ${i === 0 || i === 6 ? "text-fg3" : "text-fg2"}`}>{d}</div>
        ))}
      </div>

      {/* Day grid */}
      <div className="grid grid-cols-7 gap-1">
        {cells.map((cell, i) => {
          const dStr = fmtDate(cell.date);
          const isToday = dStr === todayStr;
          const isSelected = dStr === selectedDate;
          const dayEvents = events[dStr] || [];
          const dimClass = !cell.inMonth ? "opacity-30" : "";

          // Split: milestone events show as full-width title bars; everything else as dots
          const milestoneEvents = dayEvents.filter(e => e.kind === "custom" && e.category === "milestone");
          const otherEvents = dayEvents.filter(e => !(e.kind === "custom" && e.category === "milestone"));

          return (
            <button
              key={i}
              onClick={() => setSelectedDate(dStr)}
              className={`relative min-h-[92px] p-1.5 rounded-lg border text-left transition-colors flex flex-col gap-1
                ${dimClass}
                ${isSelected ? "bg-surface2 border-borderStrong" : "bg-surface border-border hover:bg-surface2"}
                ${isToday ? "ring-2 ring-vf/40" : ""}`}
            >
              <div className={`text-[12.5px] mono ${isToday ? "text-vf font-semibold" : cell.inMonth ? "text-fg" : "text-fg3"}`}>
                {cell.date.getDate()}
              </div>

              {/* Milestone title bars — visible directly on the cell */}
              {milestoneEvents.length > 0 && (
                <div className="flex flex-col gap-0.5">
                  {milestoneEvents.slice(0, 2).map((e, mi) => (
                    <div key={mi}
                      className="rounded px-1 py-0.5 text-[9.5px] font-medium leading-tight truncate"
                      style={{
                        background: "rgba(234,179,8,0.15)",
                        color: "rgb(234,179,8)",
                        border: "1px solid rgba(234,179,8,0.35)",
                      }}
                      title={e.title}
                    >
                      <EventIcon cat="milestone" size={11} tinted={false} /> {e.title}
                    </div>
                  ))}
                  {milestoneEvents.length > 2 && (
                    <div className="text-[9px] text-fg3 mono">+{milestoneEvents.length - 2} 個里程碑</div>
                  )}
                </div>
              )}

              {/* Non-milestone events as compact dots */}
              {otherEvents.length > 0 && (
                <div className="flex flex-wrap gap-1 mt-auto">
                  {otherEvents.slice(0, 6).map((e, ei) => {
                    if (e.kind === "custom") {
                      const meta = EVENT_CATEGORIES[e.category] || EVENT_CATEGORIES.other;
                      return (
                        <span key={ei} className="leading-none inline-flex" title={e.title}>
                          <EventIcon cat={e.category} size={12} />
                        </span>
                      );
                    }
                    if (e.kind === "linear") {
                      const c = e.product === "VF" ? "--vf-rgb" : "--pg-rgb";
                      return (
                        <span key={ei}
                          className="w-1.5 h-1.5 rotate-45"
                          style={{background: `rgb(var(${c}))`}}
                          title={`${e.id} ${e.title}`} />
                      );
                    }
                    const isHot = e.hotfix;
                    const colorVar = isHot ? "--hotfix-rgb" : (e.product === "VF" ? "--vf-rgb" : "--pg-rgb");
                    return (
                      <span key={ei}
                        className="w-1.5 h-1.5 rounded-full"
                        style={{
                          background: e.isFuture ? "transparent" : `rgb(var(${colorVar}))`,
                          boxShadow: e.isFuture ? `inset 0 0 0 1px rgb(var(${colorVar}))` : undefined,
                        }}
                        title={`${e.product} ${e.version}`} />
                    );
                  })}
                  {otherEvents.length > 6 && (
                    <span className="text-[9px] mono text-fg3">+{otherEvents.length - 6}</span>
                  )}
                </div>
              )}
            </button>
          );
        })}
      </div>

      {/* Selected date detail panel */}
      <div className="mt-5 rounded-xl border border-border bg-surface p-4">
        <div className="flex items-center justify-between mb-3 gap-2 flex-wrap">
          <div className="flex items-center gap-2">
            <span className="text-fg text-[14px] font-semibold mono">
              {selectedDate || "—"}
            </span>
            {selectedDate === todayStr && (
              <span className="tint-fix mono text-[10px] px-1.5 py-0.5 rounded font-semibold uppercase">今天</span>
            )}
          </div>
          <div className="flex items-center gap-2">
            <span className="text-fg3 text-[11.5px] mono">{selEvents.length} 個事件</span>
            {canEdit && (
              <button onClick={() => openAdd(selectedDate)}
                className="rounded-md border border-border bg-surface px-2 h-6 text-fg2 hover:text-fg hover:border-borderStrong transition-colors text-[11px]">
                ＋ 加事件
              </button>
            )}
          </div>
        </div>

        {/* Detail tabs (filter events by source) */}
        {selEvents.length > 0 && (() => {
          const counts = {
            all: selEvents.length,
            release: selEvents.filter(e => e.kind === "release" || e.kind === "upcoming").length,
            linear: selEvents.filter(e => e.kind === "linear").length,
            milestone: selEvents.filter(e => e.kind === "custom" && e.category === "milestone").length,
            custom: selEvents.filter(e => e.kind === "custom" && e.category !== "milestone").length,
          };
          const tabs = [
            { key: "all",       label: "全部" },
            { key: "milestone", label: "重要時程",  hidden: counts.milestone === 0 },
            { key: "release",   label: "發版",       hidden: counts.release === 0 },
            { key: "linear",    label: "Linear due", hidden: counts.linear === 0 },
            { key: "custom",    label: "其他",       hidden: counts.custom === 0 },
          ].filter(t => !t.hidden);
          return (
            <div className="flex items-center gap-1 mb-3 border-b border-border overflow-x-auto">
              {tabs.map(t => {
                const isActive = detailTab === t.key;
                return (
                  <button key={t.key}
                    onClick={() => setDetailTab(t.key)}
                    className={`relative flex items-center gap-1.5 px-3 py-2 text-[12px] whitespace-nowrap transition-colors
                      ${isActive ? "text-fg" : "text-fg3 hover:text-fg2"}`}>
                    <span>{t.label}</span>
                    <span className="mono text-[10.5px] text-fg3">{counts[t.key]}</span>
                    {isActive && <span className="absolute left-0 right-0 -bottom-px h-0.5 grad-fill" />}
                  </button>
                );
              })}
            </div>
          );
        })()}

        {/* Events list — filter by detailTab */}
        {(() => {
          const filtered = selEvents.filter(e => {
            if (detailTab === "all") return true;
            if (detailTab === "release") return e.kind === "release" || e.kind === "upcoming";
            if (detailTab === "linear") return e.kind === "linear";
            if (detailTab === "milestone") return e.kind === "custom" && e.category === "milestone";
            if (detailTab === "custom") return e.kind === "custom" && e.category !== "milestone";
            return true;
          });
          if (filtered.length === 0) {
            return <div className="text-fg3 text-[12.5px] py-2">{selEvents.length === 0 ? "這天沒有排定事件" : "此分類無事件"}</div>;
          }
          return (
          <ul className="flex flex-col gap-2">
            {filtered.map((e, i) => {
              if (e.kind === "custom") {
                const meta = EVENT_CATEGORIES[e.category] || EVENT_CATEGORIES.other;
                return (
                  <li key={`c-${e.id}`} className="flex items-start gap-3 py-1.5 group">
                    <span className="pt-0.5 shrink-0 inline-flex"><EventIcon cat={e.category} size={15} /></span>
                    <div className="min-w-0 flex-1">
                      <div className="flex items-center gap-2 flex-wrap">
                        <span className="text-[13.5px] text-fg font-medium">{e.title}</span>
                        <span className="rounded px-1.5 py-0.5 text-[10px] font-semibold mono uppercase border border-border text-fg3">
                          {meta.label}
                        </span>
                      </div>
                      {e.description && (
                        <div className="text-fg2 text-[12px] mt-0.5 leading-snug">{e.description}</div>
                      )}
                    </div>
                    {canEdit && (
                      <button onClick={() => openEdit(e)}
                        className="opacity-0 group-hover:opacity-100 transition-opacity text-fg3 hover:text-fg text-[11px] px-2 h-6 rounded border border-border">
                        編輯
                      </button>
                    )}
                  </li>
                );
              }
              if (e.kind === "linear") {
                const tint = e.product === "VF" ? "tint-vf" : "tint-pg";
                const priorityLabel = ["", "Urgent", "High", "Medium", "Low"][e.priority] || "";
                const isOverdue = selectedDate < todayStr;
                return (
                  <li key={`l-${e.id}`} className="flex items-start gap-3 py-1.5">
                    <span className={`${tint} mono rounded px-1.5 py-0.5 text-[10.5px] font-semibold whitespace-nowrap shrink-0`}>
                      {e.id}
                    </span>
                    <div className="min-w-0 flex-1">
                      <div className="flex items-center gap-2 flex-wrap">
                        <a href={e.url} target="_blank" rel="noopener noreferrer"
                          className="text-[13px] text-fg font-medium hover:underline">
                          {e.title}
                        </a>
                        {isOverdue && <span className="rounded px-1.5 py-0.5 text-[10px] font-semibold mono uppercase" style={{background:"rgba(239,68,68,0.12)", color:"rgb(239,68,68)", border:"1px solid rgba(239,68,68,0.3)"}}>逾期</span>}
                        {e.status && <span className="tint-fix rounded px-1.5 py-0.5 text-[10px] font-semibold mono uppercase">{e.status}</span>}
                        {priorityLabel && <span className="text-fg3 mono text-[10px] uppercase">{priorityLabel}</span>}
                      </div>
                      <div className="text-fg3 text-[11.5px] mt-0.5">
                        Linear due · {e.assignee || "未指派"}
                      </div>
                    </div>
                  </li>
                );
              }
              // release / upcoming — expandable to show issues
              const tint = e.product === "VF" ? "tint-vf" : "tint-pg";
              const expandKey = `${e.product}-${e.version}`;
              const isExpanded = expandedReleases.has(expandKey);

              // For upcoming: pull issues from NEXT_RELEASES
              // For past: collect tickets from RELEASE_DATA whatsNew + bugFixes
              let issues = [];
              if (e.kind === "upcoming") {
                const cycle = (typeof window !== "undefined" && window.NEXT_RELEASES || [])
                  .find(c => c.product === e.product && c.plannedVersion === e.version);
                issues = (cycle?.issues || []).map(it => ({ ...it, kind: "live" }));
              } else if (e.kind === "release") {
                const r = (typeof window !== "undefined" && window.RELEASE_DATA || [])
                  .find(x => x.product === e.product && x.version === e.version);
                if (r) {
                  const items = [
                    ...(r.whatsNew || []).map(it => ({ ...it, section: "What's New" })),
                    ...(r.bugFixes || []).map(it => ({ ...it, section: "Bug Fixes" })),
                  ];
                  // flatten to ticket-level entries
                  items.forEach(it => {
                    (it.tickets || []).forEach(tid => {
                      issues.push({
                        id: tid,
                        title: it.name,
                        section: it.section,
                        url: `https://linear.app/${typeof window !== "undefined" ? (window.LINEAR_WORKSPACE || "singdm") : "singdm"}/issue/${tid}`,
                        kind: "historical",
                      });
                    });
                  });
                }
              }
              const hasIssues = issues.length > 0;

              return (
                <li key={`r-${i}`} className="flex flex-col py-1.5">
                  <button
                    onClick={() => hasIssues && toggleReleaseExpand(expandKey)}
                    disabled={!hasIssues}
                    className={`flex items-start gap-3 ${hasIssues ? "cursor-pointer hover:opacity-80" : "cursor-default"}`}
                  >
                    <span className={`${tint} mono rounded px-1.5 py-0.5 text-[10.5px] font-semibold whitespace-nowrap shrink-0`}>
                      {PRODUCT_SHORT[e.product]}
                    </span>
                    <div className="min-w-0 flex-1 text-left">
                      <div className="flex items-center gap-2 flex-wrap">
                        <span className="mono text-[13px] text-fg font-semibold">{e.version}</span>
                        {e.hotfix && <span className="tint-hot rounded px-1.5 py-0.5 text-[10px] font-semibold mono uppercase">Hotfix</span>}
                        {e.isFuture && <span className="rounded px-1.5 py-0.5 text-[10px] font-semibold mono uppercase border border-border text-fg3">未來規劃</span>}
                        {e.isCurrent && <span className="tint-fix rounded px-1.5 py-0.5 text-[10px] font-semibold mono uppercase">進行中</span>}
                        {hasIssues && (
                          <span className="text-fg3 mono text-[10px]">
                            {issues.length} 張票
                          </span>
                        )}
                      </div>
                      <div className="text-fg2 text-[12.5px] mt-0.5 flex items-center gap-1.5">
                        {e.title}
                        {hasIssues && (
                          <span className={`chev text-fg3 ${isExpanded ? "open" : ""}`}>
                            <Icon.Chev className="w-3 h-3" />
                          </span>
                        )}
                      </div>
                    </div>
                  </button>

                  {isExpanded && hasIssues && (
                    <ul className="mt-2 ml-0 pl-9 flex flex-col gap-1 border-t border-border pt-2 max-h-[360px] overflow-y-auto">
                      {issues.map(it => {
                        const idEl = pmMode
                          ? <a href={it.url} target="_blank" rel="noopener noreferrer"
                              className={`ticket-chip ${e.product.toLowerCase()}`}
                              onClick={ev => ev.stopPropagation()}>{it.id}</a>
                          : <span className="mono text-[10.5px] text-fg3">{it.id}</span>;

                        if (it.kind === "live") {
                          // upcoming — full Linear info
                          const statusTintCls = it.statusType === "completed" ? "tint-new"
                            : it.statusType === "started" ? "tint-fix"
                            : "tint-none";
                          return (
                            <li key={it.id} className="flex items-start gap-2 py-0.5">
                              <span className={`${statusTintCls} mono rounded px-1.5 py-0.5 text-[10px] font-semibold whitespace-nowrap shrink-0`}>{it.status}</span>
                              <div className="min-w-0 flex-1">
                                <div className="text-[12px] text-fg leading-snug">{it.title}</div>
                                <div className="flex items-center gap-2 mt-0.5 text-[10.5px] text-fg3">
                                  {idEl}
                                  {it.assignee && <span>· {it.assignee}</span>}
                                </div>
                              </div>
                            </li>
                          );
                        }
                        // historical — section + title
                        return (
                          <li key={`${it.id}-${it.section}`} className="flex items-start gap-2 py-0.5">
                            <span className="mono rounded px-1.5 py-0.5 text-[10px] font-semibold whitespace-nowrap shrink-0 border border-border text-fg3">{it.section}</span>
                            <div className="min-w-0 flex-1">
                              <div className="text-[12px] text-fg leading-snug">{it.title}</div>
                              <div className="mt-0.5 text-[10.5px]">
                                {idEl}
                              </div>
                            </div>
                          </li>
                        );
                      })}
                    </ul>
                  )}
                </li>
              );
            })}
          </ul>
          );
        })()}
      </div>

      {/* Modal for add/edit custom event */}
      {modalOpen && (
        <CustomEventModal
          initialData={editingEvent}
          onSave={handleSave}
          onClose={() => { setModalOpen(false); setEditingEvent(null); }}
          onDelete={handleDelete}
        />
      )}
    </section>
  );
}

// ---------- Upcoming Releases (Linear cycles) ----------
// Read window.NEXT_RELEASES freshly each render so it reflects post-fetch updates.
const getNextData = () => (typeof window !== "undefined" && window.NEXT_RELEASES) || [];

// Group statusType into 3 buckets for compact stats
function bucketStatus(statusType) {
  if (statusType === "completed") return "done";
  if (statusType === "started") return "wip";
  return "todo"; // unstarted, triage, backlog
}

const STATUS_BUCKET_META = {
  done: { label: "已完成", color: "rgb(var(--success-rgb))" },
  wip:  { label: "進行中", short: "⏵", color: "rgb(var(--vf-rgb))" },
  todo: { label: "待處理", short: "○", color: "rgb(var(--fg3-rgb))" },
};

// Detailed status name → tint class
// Parse a Linear-style title: strip the version prefix ([v1.9.0] or the older [Release v1.9.0]),
// then peel off remaining [chip] prefixes
function parseTitleWithChips(rawTitle) {
  if (!rawTitle) return { chips: [], clean: "" };
  let str = String(rawTitle).replace(/\[(?:Release\s*)?v\d+\.\d+\.\d+\]\s*/gi, "");
  const chips = [];
  const m = /^\s*(\[[^\]]+\]\s*)+/.exec(str);
  if (m) {
    const re = /\[([^\]]+)\]/g;
    let cm;
    while ((cm = re.exec(m[0])) !== null) chips.push(cm[1].trim());
    str = str.slice(m[0].length);
  }
  return { chips, clean: str.trim() };
}

// ---------- Roadmap：從標題的 [主題] 標籤推導泳道 ----------
// 略過類型／版號類標籤，取第一個真正的主題標籤；沒有就歸「未分類」。
const ROADMAP_SKIP_TAG = /^(BUG|Bug|Feature|Improvement|UI|Perf|需釐清|待評估|高緊急|中緊急|低緊急|VF|PG|Epic|Release\s+v[\d.]+|v[\d.]+)$/;
const UNTAGGED_LANE = "未分類";
function themeFromTitle(rawTitle) {
  const { chips } = parseTitleWithChips(rawTitle);
  for (const c of chips) if (!ROADMAP_SKIP_TAG.test(c)) return c;
  return null;
}

// Roadmap 進度：依 Linear 狀態名稱換算填滿比例
const RM_PROGRESS = {
  "backlog": 0, "ai collected from tg": 0, "qa issue": 0,
  "todo": 25, "in review": 25,
  "in progress": 50, "qa failed": 50,
  "ready for qa": 75,
  "ready for release": 100, "done": 100,
};
function rmProgress(status, statusType) {
  const k = String(status || "").trim().toLowerCase();
  if (k in RM_PROGRESS) return RM_PROGRESS[k];
  // 未列舉的狀態退回用 statusType 判斷
  if (statusType === "completed") return 100;
  if (statusType === "started") return 50;
  if (statusType === "unstarted") return 25;
  return 0;
}

// Roadmap 負責人：縮寫 + 配色（--as-1..8，色盤在 index.html）
const RM_PIN_KEY = "release-notes-roadmap-pins";   // localStorage — 釘選的主題（存這台瀏覽器）
const RM_ORDER_KEY = "release-notes-roadmap-order"; // localStorage — 手動拖曳出來的主題順序
// 同一個時間區間內，同一個人的票達到這個數量就警示。
// 計數跨主題、跨產品，且「已完成的票也算」。改門檻只要改這個數字。
const RM_OVERLOAD = 4;
const RM_ASSIGNEE_COLORS = 8;
const RM_UNASSIGNED = "未指派";
function rmInitials(name) {
  const s = String(name || "").trim();
  if (!s) return "?";
  if (/[\u3400-\u9fff]/.test(s)) return s.slice(-2);
  const parts = s.split(/\s+/).filter(Boolean);
  if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
  return (parts[0].length <= 2 ? parts[0] : parts[0][0] + parts[1][0]).toUpperCase();
}

function statusTint(statusType) {
  if (statusType === "completed") return "tint-new";
  if (statusType === "started") return "tint-fix";
  return "tint-none";
}

function daysUntil(dateStr) {
  if (!dateStr) return null;
  const target = new Date(dateStr + "T00:00:00");
  const now = new Date();
  now.setHours(0, 0, 0, 0);
  const diff = Math.round((target - now) / (1000 * 60 * 60 * 24));
  return diff;
}

// Strip inline chips (e.g. "[v1.17.0] [BUG] title" → "title")
// and drop any Linear-style ticket IDs like "VF-123" / "PG-456" (per project rule:
// public-facing copy must not include ticket IDs).
function stripTicketTitle(raw) {
  const { clean } = parseTitleWithChips(raw || "");
  return String(clean || "").replace(/\b(?:VF|PG)-\d+\b/gi, "").replace(/\s{2,}/g, " ").trim();
}

// Public URL for release notes site (shown at the bottom of the TG announcement).
const RELEASE_NOTES_URL = "https://board.sg-ai-tech.com";

function buildCycleCopyText(cycle) {
  // Public product names (external-facing).
  const productName = cycle.product === "VF" ? "PredictGo"
                    : cycle.product === "PG" ? "Predict Gaming"
                    : (PRODUCT_LABEL[cycle.product] || cycle.product);
  const productEmoji = cycle.product === "VF" ? "🟦" : cycle.product === "PG" ? "🟧" : "▪️";

  // Date: prefer release date; fall back to today (shown in slash format).
  const dateSrc = cycle.releaseDate || new Date().toISOString().slice(0, 10);
  const dateFmt = dateSrc.replace(/-/g, "/");

  const issues = cycle.issues || [];
  const issueMap = new Map(issues.map(i => [i.id, i]));
  const topLevel = issues.filter(it => !(it.parentTicket && issueMap.has(it.parentTicket)));

  const lines = [
    `📢 版本發布通知 — ${dateFmt}`,
    "",
    "━━━━━━━━━━━━━━━━━━",
    "",
    `${productEmoji} ${productName} ${cycle.plannedVersion || ""}`.trim(),
  ];

  if (topLevel.length === 0) {
    lines.push("（尚未排票）");
  } else {
    topLevel.forEach((it, idx) => {
      const title = stripTicketTitle(it.title);
      if (title) lines.push(`${idx + 1}. ${title}`);
    });
  }

  lines.push("", `📋 Release Note 👉 ${RELEASE_NOTES_URL}`);
  return lines.join("\n");
}

function CycleCard({ cycle, pmMode, defaultOpen, canCopy }) {
  const [open, setOpen] = useState(!!defaultOpen);
  const [copied, setCopied] = useState(false);
  const issues = cycle.issues || [];
  const buckets = { done: 0, wip: 0, todo: 0 };
  issues.forEach(i => { buckets[bucketStatus(i.statusType)]++; });

  const days = daysUntil(cycle.releaseDate);
  const dayHint = days == null
    ? ""
    : days < 0
      ? `已逾期 ${-days} 天`
      : days === 0
        ? "今天發版"
        : days === 1
          ? "明天發版"
          : `${days} 天後發版`;

  const tint = cycle.product === "VF" ? "tint-vf" : "tint-pg";
  const empty = issues.length === 0;

  const handleCopy = async (e) => {
    e.stopPropagation();
    try {
      await navigator.clipboard.writeText(buildCycleCopyText(cycle));
      setCopied(true);
      setTimeout(() => setCopied(false), 1800);
    } catch {
      setCopied(false);
    }
  };

  return (
    <article className={`rounded-xl border bg-surface overflow-hidden transition-colors
      ${cycle.isCurrent ? "border-borderStrong" : "border-border"}`}>
      <button
        onClick={() => !empty && setOpen(o => !o)}
        disabled={empty}
        className={`w-full text-left px-4 py-3 flex items-center gap-3 ${empty ? "cursor-default" : "trace-hover cursor-pointer"} transition-colors`}
      >
        <div className="flex items-center gap-2.5 min-w-0 flex-1 flex-wrap">
          <span className={`${tint} rounded-md px-1.5 py-0.5 text-[10.5px] font-semibold mono tracking-wide`}>
            {PRODUCT_SHORT[cycle.product]}
          </span>
          <span className="mono text-[13px] text-fg font-semibold">{cycle.plannedVersion}</span>
          {cycle.isCurrent && (
            <span className="tint-fix rounded px-1.5 py-0.5 text-[10px] font-semibold mono uppercase tracking-wider">進行中</span>
          )}
        </div>

        <div className="shrink-0 flex items-center gap-2 text-[11.5px]">
          {empty ? (
            <span className="text-fg3 mono">尚未排票</span>
          ) : (
            <>
              <span className="text-fg3 mono">{issues.length} 票</span>
              {pmMode && (
                <>
                  <span className="text-fg3">·</span>
                  <span className="flex items-center gap-1.5 mono">
                    <span className="inline-flex items-center gap-0.5" style={{color: STATUS_BUCKET_META.done.color}}>
                      <Icon.Check className="w-3 h-3" />{buckets.done}
                    </span>
                    <span style={{color: STATUS_BUCKET_META.wip.color}}>⏵{buckets.wip}</span>
                    <span style={{color: STATUS_BUCKET_META.todo.color}}>○{buckets.todo}</span>
                  </span>
                </>
              )}
            </>
          )}
        </div>

        {!empty && (
          <span className={`chev shrink-0 ${open ? "open" : ""} text-fg3`}>
            <Icon.Chev className="w-3.5 h-3.5" />
          </span>
        )}
      </button>

      {/* Date + days */}
      <div className="px-4 pb-2.5 flex items-center justify-between gap-2 text-[11.5px]">
        <span className="text-fg3 mono shrink-0 inline-flex items-center gap-1">
          <Icon.Calendar className="w-3.5 h-3.5" />預計 {cycle.releaseDate}
        </span>
        <div className="flex items-center gap-2">
          {dayHint && (
            <span className={`mono ${days != null && days <= 1 ? "text-hotfix" : "text-fg2"}`}>
              {dayHint}
            </span>
          )}
          {pmMode && cycle.notionUrl && (
            <a
              href={cycle.notionUrl}
              target="_blank"
              rel="noopener noreferrer"
              onClick={(e) => e.stopPropagation()}
              className="chip rounded-md px-2 h-6 text-[10.5px] border bg-surface text-fg3 border-border hover:text-fg hover:border-borderStrong inline-flex items-center gap-1"
              title="在 Notion 編輯預告內容"
            >
              <Icon.Doc className="w-3 h-3" />Notion
            </a>
          )}
          {canCopy && !empty && (
            <button
              onClick={handleCopy}
              onMouseDown={(e) => e.stopPropagation()}
              className={`chip rounded-md px-2 h-6 text-[10.5px] border inline-flex items-center gap-1 transition-colors
                ${copied
                  ? "bg-success/10 text-success border-success/30"
                  : "bg-surface text-fg3 border-border hover:text-fg hover:border-borderStrong"}`}
              title="複製此版本內容（純文字、無票號）"
            >
              {copied ? <><Icon.Check className="w-3 h-3" />已複製</> : <><Icon.Copy className="w-3 h-3" />複製</>}
            </button>
          )}
        </div>
      </div>

      {/* Expand: flat issue list (status shown as inline pill, sorted by status type).
          Grouping-by-status removed — Roadmap tab already provides that view. */}
      {open && !empty && (() => {
        const STATUS_TYPE_ORDER = { completed: 0, started: 1, unstarted: 2 };
        const sortedIssues = [...issues].sort((a, b) => {
          const so = (STATUS_TYPE_ORDER[a.statusType] ?? 99) - (STATUS_TYPE_ORDER[b.statusType] ?? 99);
          if (so !== 0) return so;
          return (a.status || "").localeCompare(b.status || "");
        });

        const ChipRow = ({ chips }) => chips.length === 0 ? null : (
          <>
            {chips.map((chip, i) => {
              const isBug = /^bug$/i.test(chip);
              const cls = isBug
                ? "bg-red-500/15 text-red-300 border border-red-500/30"
                : "bg-fg3/10 text-fg2 border border-fg3/20";
              return (
                <span key={i} className={`${cls} mono rounded px-1 py-px text-[9.5px] font-semibold whitespace-nowrap inline-block align-middle mr-1`}>
                  {chip}
                </span>
              );
            })}
          </>
        );

        const IssueRow = ({ it, isChild }) => {
          const { chips, clean } = parseTitleWithChips(it.title);
          const bullet = isChild ? "◦" : "•";
          if (!pmMode) {
            return (
              <div className="py-0.5 flex items-start gap-2">
                <span className="text-fg3 shrink-0 leading-snug select-none w-3 text-center">{bullet}</span>
                <div className="text-[12.5px] text-fg leading-snug min-w-0 flex-1">
                  <ChipRow chips={chips} />
                  {clean}
                </div>
              </div>
            );
          }
          return (
            <div className="flex items-start gap-2 py-0.5">
              <span className="text-fg3 shrink-0 leading-snug select-none w-3 text-center mt-0.5">{bullet}</span>
              <div className="min-w-0 flex-1">
                <div className="text-[12.5px] text-fg leading-snug">
                  <ChipRow chips={chips} />
                  {clean}
                </div>
                <div className="flex items-center gap-2 mt-0.5 text-[11px] text-fg3">
                  <a href={it.url} target="_blank" rel="noopener noreferrer"
                     className={`ticket-chip ${cycle.product.toLowerCase()}`}
                     onClick={e=>e.stopPropagation()}>{it.id}</a>
                  {it.assignee && <span>· {it.assignee}</span>}
                </div>
              </div>
            </div>
          );
        };

        // Build parent → children map across full ticket list (no more status boundaries)
        const idsInList = new Set(sortedIssues.map(i => i.id));
        const childrenOf = new Map();
        const topLevel = [];
        sortedIssues.forEach(it => {
          if (it.parentTicket && idsInList.has(it.parentTicket)) {
            if (!childrenOf.has(it.parentTicket)) childrenOf.set(it.parentTicket, []);
            childrenOf.get(it.parentTicket).push(it);
          } else {
            topLevel.push(it);
          }
        });

        return (
          <div className="px-4 pb-4 border-t border-border pt-3 max-h-[420px] overflow-y-auto">
            <ul className="flex flex-col gap-0.5 pl-1">
              {topLevel.map(parent => {
                const kids = childrenOf.get(parent.id) || [];
                return (
                  <li key={parent.id} className="list-none">
                    <IssueRow it={parent} isChild={false} />
                    {kids.length > 0 && (
                      <ul className="pl-5 mt-0.5 flex flex-col gap-0.5">
                        {kids.map(k => (
                          <li key={k.id} className="list-none">
                            <IssueRow it={k} isChild={true} />
                          </li>
                        ))}
                      </ul>
                    )}
                  </li>
                );
              })}
            </ul>
          </div>
        );
      })()}
    </article>
  );
}

function filterCycleIssues(cycle, q) {
  if (!q) return cycle;
  const lower = q.toLowerCase();
  const filtered = (cycle.issues || []).filter(it => {
    const hay = [it.id, it.title, it.status, it.assignee, ...(it.labels || [])]
      .filter(Boolean).join(" ").toLowerCase();
    return hay.includes(lower);
  });
  return { ...cycle, issues: filtered };
}

// Compact "重新整理" button — reusable, self-contains state + auth.
// Only renders for pm/editor/admin (worker requires admin bearer token).
// Visual matches QA board's refresh button (outlined, svg icon).
function SyncLinearButton({ canSync, onSynced, showTimestamp = false, compact = false }) {
  const [syncing, setSyncing] = useState(false);
  const [msg, setMsg] = useState(null);
  const [lastSyncAt, setLastSyncAt] = useState(null);

  const fetchLastSync = useCallback(async () => {
    if (!canSync) return;
    try {
      const { data } = await supabase.from("settings").select("value").eq("key", "linear_last_sync").single();
      if (data?.value?.timestamp) setLastSyncAt(data.value.timestamp);
    } catch { /* ignore */ }
  }, [canSync]);

  useEffect(() => { if (showTimestamp) fetchLastSync(); }, [showTimestamp, fetchLastSync]);

  const handleClick = async () => {
    if (syncing || !canSync) return;
    setSyncing(true);
    setMsg(null);
    try {
      const { data: { session } } = await supabase.auth.getSession();
      const token = session?.access_token;
      if (!token) throw new Error("尚未登入或 session 失效");
      const res = await fetch(WORKER_URL, {
        method: "POST",
        headers: { Authorization: `Bearer ${token}` },
      });
      const ct = res.headers.get("content-type") || "";
      const data = ct.includes("application/json") ? await res.json() : { ok: false, error: `HTTP ${res.status}` };
      if (data.ok) {
        setMsg({ type: "ok", text: `同步完成：${data.cycles} cycles · ${data.dueDates} due dates` });
        if (data.timestamp) setLastSyncAt(data.timestamp);
        else fetchLastSync();
        onSynced?.();
      } else {
        setMsg({ type: "err", text: data.error || "同步失敗" });
      }
    } catch (e) {
      setMsg({ type: "err", text: `${e.message || e}` });
    } finally {
      setSyncing(false);
      setTimeout(() => setMsg(null), 6000);
    }
  };
  if (!canSync) return null;
  const stampFull = lastSyncAt ? new Date(lastSyncAt).toLocaleString("zh-TW", { hour12: false }) : "—";
  const stampShort = lastSyncAt
    ? new Date(lastSyncAt).toLocaleString("zh-TW", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false })
    : "—";
  return (
    <div className={compact ? "relative inline-flex items-center gap-2" : "inline-flex items-center gap-3 flex-wrap"}>
      {showTimestamp && (
        <span className="text-[11px] text-fg3 whitespace-nowrap" title={compact ? `最後同步：${stampFull}` : undefined}>
          {compact ? `更新 ${stampShort}` : `· 更新 ${stampFull}`}
        </span>
      )}
      <button
        onClick={handleClick}
        disabled={syncing}
        title="馬上抓 Linear 資料寫進 Supabase"
        className={`inline-flex items-center gap-1.5 text-[11.5px] font-semibold text-fg2 hover:text-fg border border-border rounded-lg ${compact ? "px-2 py-[3px]" : "px-2.5 py-1"} whitespace-nowrap transition-colors disabled:opacity-50`}
      >
        <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
             strokeLinecap="round" strokeLinejoin="round" className={syncing ? "animate-spin" : ""}>
          <path d="M20.5 12a8.5 8.5 0 1 1-2.5-6" />
          <path d="M20.5 4v5h-5" />
        </svg>
        {syncing ? "同步中…" : "重新整理"}
      </button>
      {msg && (compact ? (
        // 浮在按鈕下方，不把工具列撐高
        <div className="absolute right-0 top-full mt-1 z-30 whitespace-nowrap rounded-md border border-border bg-surface px-2 py-1 shadow-sm">
          <FlashMsg msg={msg} size="text-[11.5px]" />
        </div>
      ) : (
        <FlashMsg msg={msg} size="text-[11.5px]" />
      ))}
    </div>
  );
}

function UpcomingReleases({ pmMode, query, canCopy, pmUnlocked, onReloadData }) {
  const [mobileTab, setMobileTab] = useState("VF"); // mobile-only product tab
  const NEXT_DATA = getNextData();
  const cycles = query
    ? NEXT_DATA.map(c => filterCycleIssues(c, query))
    : NEXT_DATA;

  const vfCycles = cycles.filter(c => c.product === "VF");
  const pgCycles = cycles.filter(c => c.product === "PG");

  const totalIssues = cycles.reduce((s, c) => s + (c.issues?.length || 0), 0);
  const vfIssues = vfCycles.reduce((s, c) => s + (c.issues?.length || 0), 0);
  const pgIssues = pgCycles.reduce((s, c) => s + (c.issues?.length || 0), 0);

  const Column = ({ cycles, label, accent, hideHeaderOnMobile }) => (
    <div className="flex-1 min-w-0">
      <div className={`${hideHeaderOnMobile ? "hidden lg:flex" : "flex"} items-center gap-2 mb-3`}>
        <span className="inline-block w-1.5 h-1.5 rounded-full" style={{background: accent}} />
        <h3 className="text-fg text-[13px] font-semibold tracking-wide">{label}</h3>
      </div>
      <div className="flex flex-col gap-2.5">
        {cycles.map(c => (
          <CycleCard key={c.cycleId} cycle={c} pmMode={pmMode} defaultOpen={c.isCurrent || !!query} canCopy={canCopy} />
        ))}
      </div>
    </div>
  );

  return (
    <section className="mt-5 mb-4">
      {/* Sync button — admin/pm/editor only; sits at top so users can refresh even when list is empty */}
      {pmUnlocked && (
        <div className="mb-4 flex items-center gap-2 justify-end">
          <SyncLinearButton canSync={pmUnlocked} onSynced={onReloadData} showTimestamp />
        </div>
      )}

      {query && NEXT_DATA.length > 0 && (
        <div className="text-fg3 text-[12.5px] mb-4">
          搜尋「<span className="text-fg">{query}</span>」共找到 <span className="mono text-fg">{totalIssues}</span> 張票
        </div>
      )}

      {NEXT_DATA.length === 0 && (
        <div className="text-fg3 text-[13px] py-12 text-center border border-border rounded-xl bg-surface">
          目前沒有預計發版項目
          {pmUnlocked && <div className="text-[11.5px] text-fg3 mt-1">按右上「立即同步」可即時抓 Linear 最新狀態</div>}
        </div>
      )}

      {NEXT_DATA.length > 0 && (
        <>
      {/* Mobile-only product tabs */}
      <div className="lg:hidden flex items-center gap-1 mb-4 border-b border-border">
        {[
          { key: "VF", label: "PredictGo",      accent: "rgb(var(--vf-rgb))", count: vfIssues },
          { key: "PG", label: "Predict Gaming", accent: "rgb(var(--pg-rgb))", count: pgIssues },
        ].map(t => {
          const isActive = mobileTab === t.key;
          return (
            <button key={t.key}
              onClick={() => setMobileTab(t.key)}
              className={`relative flex items-center gap-2 px-3 py-2 text-[13px] font-medium transition-colors
                ${isActive ? "text-fg" : "text-fg3 hover:text-fg2"}`}>
              <span className="inline-block w-1.5 h-1.5 rounded-full" style={{background: t.accent}} />
              <span>{t.label}</span>
              <span className={`mono text-[10.5px] ${isActive ? "text-fg2" : "text-fg3"}`}>{t.count}</span>
              {isActive && <span className="absolute left-0 right-0 -bottom-px h-0.5 grad-fill" />}
            </button>
          );
        })}
      </div>

      {/* Layout: side-by-side on desktop, mobile shows only active tab */}
      <div className="flex flex-col lg:flex-row gap-5">
        <div className={`${mobileTab === "VF" ? "block" : "hidden"} lg:block flex-1 min-w-0`}>
          <Column cycles={vfCycles} label="PredictGo (VF)" accent="rgb(var(--vf-rgb))" hideHeaderOnMobile />
        </div>
        <div className={`${mobileTab === "PG" ? "block" : "hidden"} lg:block flex-1 min-w-0`}>
          <Column cycles={pgCycles} label="Predict Gaming (PG)" accent="rgb(var(--pg-rgb))" hideHeaderOnMobile />
        </div>
      </div>
        </>
      )}
    </section>
  );
}


// Roadmap 的列與圓標刻意放在模組層。
// 放進 RoadmapChart 裡的話，每次 setState 都會產生新的 function identity，
// React 會 unmount／remount 整棵子樹 → 正在被拖的 <a> 節點被換掉 → 瀏覽器中斷拖曳。
const rmDot = p => (p === "VF" ? "rgb(var(--vf-rgb))" : "rgb(var(--pg-rgb))");

function RmAvatar({ name, color, size = 17, warn, title }) {
  return (
    <span className="relative shrink-0 rounded-full inline-flex items-center justify-center mono font-bold leading-none"
          style={{ width: size, height: size, fontSize: size <= 16 ? 8 : 8.5,
                   background: color, color: "rgb(var(--bg-rgb))",
                   // 中間留一圈底色，判斷依據是「有沒有圈」而非圈的顏色，
                   // 所以負責人本身是紅色系也不會混在一起。
                   ...(warn ? { boxShadow: "0 0 0 1.5px rgb(var(--surface-rgb)), 0 0 0 3px rgb(var(--hotfix-rgb))" } : null) }}
          title={title || name}>
      {name === RM_UNASSIGNED ? "?" : rmInitials(name)}
    </span>
  );
}

function RmRow({ r, laneC, lane, pmMode, canDrag, dimmed, moving, ownerColor, ownerLoad, onDragStartRow, onDragEndRow, brands, brandRegistry }) {
  const { clean } = parseTitleWithChips(r.title);
  const pct = rmProgress(r.status, r.statusType);
  const owner = r.assignee || RM_UNASSIGNED;
  const linkable = pmMode && !!r.url;
  const cls = "relative flex items-center gap-2 px-2 py-[5px] rounded-md min-w-0 overflow-hidden"
    + (dimmed ? " opacity-25" : "")
    + (moving ? " opacity-40" : "")
    + (linkable ? " rm-row-link" : "");
  const style = {
    background: `color-mix(in srgb, ${laneC} 8%, transparent)`,
    ...(canDrag ? { cursor: "grab" } : null),
  };
  // label 在 Linear 上的順序每張票都不同，統一改依品牌設定的排序顯示
  const brandChips = (brands && brands.length > 0 && brandRegistry)
    ? brands.slice()
        .sort((a, b) => ((brandRegistry.get(a)?.sort_order ?? 999) - (brandRegistry.get(b)?.sort_order ?? 999))
          || a.localeCompare(b))
        .slice(0, 3)
        .map(code => {
          const b = brandRegistry.get(code);
          return { code, color: b?.color || "#6f9bc4", name: b?.name || code };
        })
    : [];
  const extraBrands = (brands?.length || 0) - brandChips.length;
  const tip = pmMode
    ? `${r.id} · ${r.status || "—"}（${pct}%）· ${owner}${linkable ? " · 點開 Linear，左右拖曳可換 cycle" : ""}`
    : undefined;
  const dragProps = canDrag ? {
    draggable: true,
    onDragStart: e => {
      e.dataTransfer.effectAllowed = "move";
      e.dataTransfer.setData("text/plain", r.id);
      onDragStartRow({ id: r.id, product: r.product, cycleId: r.cycleId, lane });
    },
    onDragEnd: onDragEndRow,
  } : null;
  const body = (
    <>
      {/* 進度填色：寬度＝狀態換算的百分比，色相跟隨主題 */}
      <span className="absolute inset-y-0 left-0 pointer-events-none"
            style={{ width: `${pct}%`, background: `color-mix(in srgb, ${laneC} 26%, transparent)` }} />
      <span className="relative w-[7px] h-[7px] rounded-full shrink-0" style={{ background: rmDot(r.product) }}
            title={PRODUCT_LABEL[r.product] || r.product} />
      {pmMode && (
        <span className="relative mono text-[10.5px] text-fg3 shrink-0">{r.id}</span>
      )}
      {brandChips.length > 0 && (
        <span className="relative flex items-center gap-0.5 shrink-0" title={brandChips.map(b => b.name).join(" · ")}>
          {brandChips.map(b => (
            <span key={b.code} className="mono text-[9px] font-semibold rounded px-1 leading-tight"
                  style={{ background: b.color, color: brandTextColor(b.color) }}>{b.code}</span>
          ))}
          {extraBrands > 0 && (
            <span className="mono text-[9px] font-semibold text-fg3">+{extraBrands}</span>
          )}
        </span>
      )}
      <span className="relative text-[12.5px] text-fg leading-snug truncate flex-1 min-w-0">{clean}</span>
      {pmMode && r.status && (
        <span className="relative text-[10.5px] text-fg3 shrink-0 whitespace-nowrap">{r.status}</span>
      )}
      {pmMode && (
        <RmAvatar name={owner} color={ownerColor} size={16}
          warn={ownerLoad >= RM_OVERLOAD}
          title={ownerLoad >= RM_OVERLOAD
            ? `${owner}｜這個時間區間有 ${ownerLoad} 張，已達 ${RM_OVERLOAD} 張警示門檻`
            : owner} />
      )}
    </>
  );
  return linkable
    ? <a href={r.url} target="_blank" rel="noopener noreferrer" className={cls} style={style} title={tip} {...dragProps}>{body}</a>
    : <div className={cls} style={style} title={tip} {...dragProps}>{body}</div>;
}

// ---------- Roadmap（主題 × 時間區間） ----------
// 資料來源與 Upcoming Releases 相同（window.NEXT_RELEASES，由 Linear 同步）。
// 泳道 = 標題的 [主題] 標籤；欄位 = 發版日期（同日 cycle 合併）；色點 = 產品。
// 泳道色（--rm-1..6，配色 B）僅作為色標使用，不用於文字：部分色階在淺色底下未達 3:1 對比。
const RM_LANE_COLORS = 6;

function RoadmapChart({ pmMode, query, pmUnlocked, onReloadData }) {
  const q = (query || "").trim().toLowerCase();
  // 負責人聚焦：null = 全部；選了某人後，其他人的票淡出（不改變版面高度）
  const [who, setWho] = useState(null);
  const [activeLane, setActiveLane] = useState(null); // click lane name → focus that lane, dim others
  const [colFocus, setColFocus] = useState(null);     // 點日期欄頭：該期有票的主題排最前，再點一次復原

  // 品牌對應（多品牌 roadmap 篩選）
  const [brandRegistry, setBrandRegistry] = useState([]);  // [{code, name, color, active, ...}]
  const [brandMap, setBrandMap] = useState({});            // ticket_id → brands[]
  const [brandFilter, setBrandFilter] = useState(() => {
    try { return localStorage.getItem("rm-brand-filter") || "all"; } catch { return "all"; }
  });
  const setBrandFilterPersist = v => {
    setBrandFilter(v);
    try { localStorage.setItem("rm-brand-filter", v); } catch {}
  };
  // 選了特定品牌時，預設連通用票一起顯示；開這個就只看該品牌專屬的票
  const [brandOnly, setBrandOnly] = useState(() => {
    try { return localStorage.getItem("rm-brand-only") === "1"; } catch { return false; }
  });
  const setBrandOnlyPersist = v => {
    setBrandOnly(v);
    try { localStorage.setItem("rm-brand-only", v ? "1" : "0"); } catch {}
  };
  useEffect(() => {
    const guest = typeof window !== "undefined" ? window.GUEST_BRANDS : null;
    if (guest) {
      setBrandRegistry((guest.registry || []).filter(b => b.active));
      const m = {};
      for (const row of guest.mappings || []) m[row.ticket_id] = row.brands || [];
      setBrandMap(m);
      return;
    }
    let cancelled = false;
    Promise.all([
      supabase.from("brand_registry").select("*").order("sort_order").order("code"),
      supabase.from("brand_ticket_mapping").select("ticket_id, brands"),
    ]).then(([reg, map]) => {
      if (cancelled) return;
      if (reg.data) setBrandRegistry(reg.data.filter(b => b.active));
      if (map.data) {
        const m = {};
        for (const row of map.data) m[row.ticket_id] = row.brands || [];
        setBrandMap(m);
      }
    });
    return () => { cancelled = true; };
  }, []);
  const brandRegMap = new Map(brandRegistry.map(b => [b.code, b]));

  // 釘選的主題：排到最上面，寫進 localStorage，下次打開自動套用
  const [pins, setPins] = useState(() => {
    try { return new Set(JSON.parse(localStorage.getItem(RM_PIN_KEY) || "[]")); }
    catch { return new Set(); }
  });
  const togglePin = lane => setPins(prev => {
    const next = new Set(prev);
    if (next.has(lane)) next.delete(lane); else next.add(lane);
    try { localStorage.setItem(RM_PIN_KEY, JSON.stringify([...next])); } catch {}
    return next;
  });

  // 手動排序：存整份順序（含沒被拖過的主題），這樣之後每次拖都是可預期的。
  // 沒出現在這份清單裡的主題（例如新主題）會退回依票數排。
  const [order, setOrder] = useState(() => {
    try { const v = JSON.parse(localStorage.getItem(RM_ORDER_KEY) || "[]"); return Array.isArray(v) ? v : []; }
    catch { return []; }
  });
  const [laneDrag, setLaneDrag] = useState(null);
  const [laneOver, setLaneOver] = useState(null);   // { lane, pos: "before" | "after" }
  const saveOrder = next => {
    setOrder(next);
    try { localStorage.setItem(RM_ORDER_KEY, JSON.stringify(next)); } catch {}
  };
  // 拖曳換 cycle：moves 是「已送出但鏡像還沒更新」的暫存位置，失敗會還原
  const [moves, setMoves] = useState({});
  const [drag, setDrag] = useState(null);
  const [overCell, setOverCell] = useState(null);
  const [flash, setFlash] = useState(null);
  const say = (text, bad) => setFlash({ text, bad: !!bad });


  const cycles = getNextData()
    .slice()
    .sort((a, b) => String(a.releaseDate || "").localeCompare(String(b.releaseDate || "")));

  // 同一個發版日期的 cycle 合併為同一欄，不分產品
  const cols = [];
  const colByDate = new Map();
  cycles.forEach(c => {
    const key = c.releaseDate || "—";
    if (!colByDate.has(key)) {
      const g = { key, releaseDate: c.releaseDate, cycles: [], isCurrent: false };
      colByDate.set(key, g);
      cols.push(g);
    }
    const g = colByDate.get(key);
    g.cycles.push(c);
    if (c.isCurrent) g.isCurrent = true;
  });

  const rows = [];
  cycles.forEach(c => (c.issues || []).forEach(it => {
    if (q && !(`${it.id || ""} ${it.title || ""}`.toLowerCase().includes(q))) return;
    // 品牌篩選：unmapped = mapping 表沒 row；generic = 空陣列（通用）；code = 該品牌
    if (brandFilter !== "all") {
      const brands = effectiveBrands(it.id, it.labels, brandMap).brands;  // undefined = 未分類；[] = 通用
      if (brandFilter === "unmapped") {
        if (brands !== undefined) return;
      } else if (brandFilter === "generic") {
        if (!brands || brands.length !== 0) return;
      } else {
        // 指定品牌：預設含通用票（空陣列＝所有品牌都有份）；開「只看專屬」才排除
        if (!brands) return;                                            // 未分類不算
        if (brandOnly && !brands.includes(brandFilter)) return;
        if (!brandOnly && brands.length > 0 && !brands.includes(brandFilter)) return;
      }
    }
    rows.push({
      ...it,
      colKey: moves[it.id]?.colKey || c.releaseDate || "—",
      cycleId: moves[it.id]?.cycleId || c.cycleId,
      product: c.product,
      theme: themeFromTitle(it.title) || UNTAGGED_LANE,
    });
  }));

  // 泳道排序：釘選的排最前，其餘依票數多寡，「未分類」永遠最後
  const laneCount = new Map();
  rows.forEach(r => laneCount.set(r.theme, (laneCount.get(r.theme) || 0) + 1));
  const laneRank = l => (pins.has(l) ? 0 : l === UNTAGGED_LANE ? 2 : 1);
  const orderIdx = new Map(order.map((l, i) => [l, i]));
  const manualIdx = l => (orderIdx.has(l) ? orderIdx.get(l) : Number.MAX_SAFE_INTEGER);
  const baseLanes = [...laneCount.keys()].sort((a, b) =>
    laneRank(a) - laneRank(b)
    || manualIdx(a) - manualIdx(b)
    || laneCount.get(b) - laneCount.get(a)
    || a.localeCompare(b, "zh-Hant"));

  // 聚焦某一期：該期有票的主題往前，兩組各自保留原本順序（釘選、手動排序都不變）。
  // 欄位不存在了（例如同步後 cycle 結束）就當作沒聚焦。
  const focusKey = colFocus && cols.some(c => c.key === colFocus) ? colFocus : null;
  const focusHas = focusKey ? new Set(rows.filter(r => r.colKey === focusKey).map(r => r.theme)) : null;
  const lanes = focusHas
    ? [...baseLanes.filter(l => focusHas.has(l)), ...baseLanes.filter(l => !focusHas.has(l))]
    : baseLanes;

  // 配色索引刻意用「沒有釘選時的順序」算，否則釘一個主題會害所有主題換色。
  const colorOrder = [...laneCount.keys()]
    .filter(l => l !== UNTAGGED_LANE)
    .sort((a, b) => laneCount.get(b) - laneCount.get(a) || a.localeCompare(b, "zh-Hant"));
  const laneColorIdx = new Map(colorOrder.map((l, i) => [l, i]));

  // 負責人清單（未指派永遠排最後）與每人票數
  const personCount = new Map();
  rows.forEach(r => {
    const k = r.assignee || RM_UNASSIGNED;
    personCount.set(k, (personCount.get(k) || 0) + 1);
  });
  const people = [...personCount.keys()].sort((a, b) =>
    (a === RM_UNASSIGNED) - (b === RM_UNASSIGNED) || a.localeCompare(b, "zh-Hant"));
  const personIdx = new Map(people.filter(p => p !== RM_UNASSIGNED).map((p, i) => [p, i]));

  // 負荷：每個時間區間 × 每個人各幾張（跨主題、跨產品，已完成的票也算）
  const loadKey = (colKey, who) => `${colKey}\u0000${who}`;
  const loadMap = new Map();
  rows.forEach(r => {
    const k = loadKey(r.colKey, r.assignee || RM_UNASSIGNED);
    loadMap.set(k, (loadMap.get(k) || 0) + 1);
  });
  const loadOf = (colKey, who) => loadMap.get(loadKey(colKey, who)) || 0;
  // 每個時間區間有哪些人超載（「未指派」不算，那是資料缺漏不是負荷問題）
  const overloadCache = new Map();
  const overloadedIn = colKey => {
    if (!overloadCache.has(colKey)) {
      overloadCache.set(colKey, people
        .filter(p => p !== RM_UNASSIGNED && loadOf(colKey, p) >= RM_OVERLOAD)
        .sort((a, b) => loadOf(colKey, b) - loadOf(colKey, a) || a.localeCompare(b, "zh-Hant")));
    }
    return overloadCache.get(colKey);
  };
  const personColor = p => (p === RM_UNASSIGNED
    ? "rgb(var(--fg3-rgb))"
    : `var(--as-${(personIdx.get(p) % RM_ASSIGNEE_COLORS) + 1})`);

  // 面板高度用量的，不用寫死的數字：頁首一改高度就會跟著對。
  const panelRef = useRef(null);
  const [panelMax, setPanelMax] = useState(null);
  useEffect(() => {
    const fit = () => {
      const el = panelRef.current;
      if (!el) return;
      const top = el.getBoundingClientRect().top + window.scrollY;
      setPanelMax(Math.max(320, Math.round(window.innerHeight - top - 28)));
    };
    fit();
    window.addEventListener("resize", fit);
    return () => window.removeEventListener("resize", fit);
    // 依賴的是「會改變面板起始位置」的東西；不放空陣列是因為頁首上方的
    // 提示列／篩選列會換行，但也不能每次 render 都跑，否則拖曳時會狂掛 listener。
  }, [flash, people.length, cols.length, brandRegistry.length]);

  if (cols.length === 0) {
    return (
      <section className="mt-5 mb-4">
        <div className="text-fg3 text-[13px] py-12 text-center border border-border rounded-xl bg-surface">
          目前沒有預計發版項目
        </div>
      </section>
    );
  }

  // 拖曳只允許水平移動（換 cycle）：同一條泳道內、且目標欄要有同產品的 cycle。
  // 泳道是從標題的 [主題] 推導的，換泳道等於改標題，所以不開放上下拖。
  const canDrag = pmMode;
  const endDrag = () => { setDrag(null); setOverCell(null); };
  // null＝現在沒在拖；{ok:true,cycle}＝可放；{ok:false,reason}＝不可放
  // reason 為 null 代表「這就是它原本的位置」，不需要提示。
  const dropCheck = (col, lane) => {
    if (!drag) return null;
    if (drag.lane !== lane) return { ok: false, reason: "不能換主題，主題是從標題的 [標籤] 來的" };
    const c = col.cycles.find(x => x.product === drag.product);
    if (!c) return { ok: false, reason: `這一欄沒有 ${PRODUCT_SHORT[drag.product] || drag.product} 的 cycle` };
    if (c.cycleId === drag.cycleId) return { ok: false, reason: null };
    return { ok: true, cycle: c };
  };
  const doDrop = async (target, col) => {
    const d = drag;
    setDrag(null); setOverCell(null);
    if (!d || !target) return;
    setMoves(m => ({ ...m, [d.id]: { colKey: col.key, cycleId: target.cycleId } }));
    say(`${d.id} 移動中…`);
    try {
      const { data: { session } } = await supabase.auth.getSession();
      const res = await fetch(`${WORKER_URL}/roadmap/move-issue`, {
        method: "POST",
        headers: { Authorization: `Bearer ${session?.access_token}`, "Content-Type": "application/json" },
        body: JSON.stringify({ issueId: d.id, toCycleId: target.cycleId, fromCycleId: d.cycleId }),
      });
      const j = await res.json();
      if (!j.ok) throw new Error(j.error || "移動失敗");
      say(`${d.id} 已移到 ${target.plannedVersion || col.releaseDate}`);
    } catch (e) {
      setMoves(m => { const n = { ...m }; delete n[d.id]; return n; });
      say(`${d.id} 移動失敗：${String(e.message || e)}`, true);
    }
  };

  // 泳道拖曳只在同一個群組內（釘選的跟釘選的、未釘的跟未釘的）。
  // 要跨群組請用圖釘，這樣「釘選 = 在哪一區」「拖曳 = 區內順序」不會互相打架。
  // 聚焦期間不開放拖曳調順序，否則會把暫時的排序存成手動順序
  const canReorder = (a, b) => !focusKey && !!a && !!b && a !== b && laneRank(a) === laneRank(b);
  const doLaneDrop = (targetLane, pos) => {
    const src = laneDrag;
    setLaneDrag(null); setLaneOver(null);
    if (!canReorder(src, targetLane)) return;
    const next = baseLanes.filter(l => l !== src);
    const at = next.indexOf(targetLane) + (pos === "after" ? 1 : 0);
    next.splice(at, 0, src);
    saveOrder(next);   // 存整份，不是只存被拖的那一個
  };

  const grid = { gridTemplateColumns: `156px repeat(${cols.length}, minmax(300px, 1fr))` };
  const laneColor = lane => (lane === UNTAGGED_LANE
    ? "rgb(var(--fg3-rgb))"
    : `var(--rm-${((laneColorIdx.get(lane) ?? 0) % RM_LANE_COLORS) + 1})`);


  return (
    <section className="mt-5 mb-4">
      {/* 工具列：品牌、負責人篩選與同步合成一列。
          篩選區超出寬度時橫向捲動（不換行），同步鈕固定在右側不跟著捲走 */}
      {(pmUnlocked || brandRegistry.length > 0 || (pmMode && people.length > 1)) && (
        <div className="flex items-center gap-2 mb-2">
          <div className="flex-1 min-w-0 overflow-x-auto no-scrollbar">
            <div className="flex items-center gap-1.5 whitespace-nowrap">
              {brandRegistry.length > 0 && (
                <>
                  <span className="text-[11.5px] text-fg3 mr-0.5 shrink-0">品牌</span>
                  {[
                    { key: "all", label: "全部" },
                    ...brandRegistry.map(b => ({ key: b.code, label: b.code, color: b.color, name: b.name })),
                    { key: "generic", label: "通用" },
                  ].map(chip => {
                    const on = brandFilter === chip.key;
                    return (
                      <button key={chip.key} type="button" onClick={() => setBrandFilterPersist(chip.key)}
                        title={chip.name || chip.label}
                        className={`chip shrink-0 flex items-center gap-1.5 rounded-full border px-2.5 py-[3px] text-[11.5px] transition-colors ${
                          on ? "border-borderStrong text-fg" : "border-border text-fg2 hover:text-fg"}`}
                        style={on && chip.color
                          ? { background: chip.color, borderColor: chip.color, color: brandTextColor(chip.color) }
                          : on ? { background: "rgb(var(--surface2-rgb))" } : undefined}>
                        {chip.color && <span className="inline-block w-1.5 h-1.5 rounded-full" style={{ background: on ? brandTextColor(chip.color) : chip.color }} />}
                        <span className="mono whitespace-nowrap">{chip.label}</span>
                      </button>
                    );
                  })}
                  {brandRegistry.some(b => b.code === brandFilter) && (
                    <button type="button" onClick={() => setBrandOnlyPersist(!brandOnly)}
                      aria-pressed={brandOnly}
                      title={brandOnly
                        ? "目前只看這個品牌專屬的票，點一下改為連通用票一起顯示"
                        : "目前含通用票，點一下只看這個品牌專屬的票"}
                      className={`chip shrink-0 flex items-center gap-1 rounded-full border px-2.5 py-[3px] text-[11.5px] transition-colors ${
                        brandOnly ? "border-borderStrong bg-surface2 text-fg" : "border-border text-fg2 hover:text-fg"}`}>
                      只看專屬
                    </button>
                  )}
                </>
              )}

              {brandRegistry.length > 0 && pmMode && people.length > 1 && (
                <span className="w-px h-4 bg-border mx-1.5 shrink-0" aria-hidden="true" />
              )}

              {pmMode && people.length > 1 && (
                <>
                  <span className="text-[11.5px] text-fg3 mr-0.5 shrink-0">負責人</span>
                  {people.map(p => {
                    const on = who === p;
                    return (
                      <button key={p} type="button" onClick={() => setWho(on ? null : p)}
                        className={`chip shrink-0 flex items-center gap-1.5 rounded-full border pl-1 pr-2.5 py-[3px] text-[11.5px] ${
                          on ? "border-transparent grad-tint text-fg" : "border-border text-fg2 hover:text-fg"}`}
                        title={on ? `取消聚焦 ${p}` : `只看 ${p} 的票`}>
                        <RmAvatar name={p} color={personColor(p)} size={15} />
                        <span className="whitespace-nowrap">{p}</span>
                        <span className="mono text-[10px] text-fg3">{personCount.get(p)}</span>
                      </button>
                    );
                  })}
                  {who && (
                    <button type="button" onClick={() => setWho(null)}
                      className="shrink-0 text-[11.5px] text-fg3 hover:text-fg2 ml-1 underline underline-offset-2">
                      清除
                    </button>
                  )}
                </>
              )}
            </div>
          </div>

          {pmUnlocked && (
            <div className="shrink-0">
              <SyncLinearButton canSync={pmUnlocked} onSynced={onReloadData} showTimestamp compact />
            </div>
          )}
        </div>
      )}

      {/* 只有真的在搜尋時才佔一行回報筆數 */}
      {q && (
        <div className="text-fg3 text-[12px] mb-2">
          篩選「<span className="text-fg">{query}</span>」共 <span className="mono text-fg">{rows.length}</span> 張
        </div>
      )}

      {flash && (
        <div className={`text-[11.5px] mb-2 ${flash.bad ? "text-hotfix" : "text-fg3"}`}>
          {flash.text}
          <button type="button" onClick={() => setFlash(null)}
            className="ml-2 text-fg3 hover:text-fg2 underline underline-offset-2">關閉</button>
        </div>
      )}

      <div ref={panelRef} className="border border-border rounded-xl bg-bg overflow-auto"
           style={{ maxHeight: panelMax ? `${panelMax}px` : "calc(100vh - 180px)" }}>
        <div className="min-w-max">
          {/* 欄頭：凍結在面板頂端 */}
          <div className="grid border-b border-borderStrong sticky top-0 z-20 bg-surface" style={grid}>
            <div className="sticky left-0 z-10 bg-surface" />
            {cols.map(col => (
              <div key={col.key}
                   role="button" tabIndex={0} aria-pressed={focusKey === col.key}
                   onClick={() => setColFocus(f => (f === col.key ? null : col.key))}
                   onKeyDown={e => {
                     if (e.key === "Enter" || e.key === " ") {
                       e.preventDefault();
                       setColFocus(f => (f === col.key ? null : col.key));
                     }
                   }}
                   title={focusKey === col.key ? "再點一次恢復原本的主題順序" : "把這一期有票的主題排到最前面"}
                   className={`border-l border-border px-3 py-2 flex items-center gap-2 flex-wrap cursor-pointer select-none transition-colors ${
                     focusKey === col.key ? "bg-surface2" : "bg-surface hover:bg-surface2"}`}
                   style={focusKey === col.key ? { boxShadow: "inset 0 -2px 0 0 rgb(var(--fg-rgb))" } : undefined}>
                <span className="mono text-[12.5px] text-fg font-semibold whitespace-nowrap">{col.releaseDate || "—"}</span>
                {col.cycles.map(c => (
                  <span key={c.cycleId}
                    className={`${c.product === "VF" ? "tint-vf" : "tint-pg"} rounded px-1.5 py-0.5 text-[10px] font-semibold mono whitespace-nowrap`}>
                    {PRODUCT_SHORT[c.product]} {c.plannedVersion}
                  </span>
                ))}
                {col.isCurrent && (
                  <span className="tint-fix rounded px-1.5 py-0.5 text-[9.5px] font-semibold mono uppercase">進行中</span>
                )}
                {pmMode && overloadedIn(col.key).length > 0 && (
                  <span className="ml-auto shrink-0 mono text-[9.5px] font-semibold rounded px-1.5 py-0.5 whitespace-nowrap"
                        style={{ background: "rgb(var(--hotfix-rgb) / 0.16)",
                                 color: "rgb(var(--hotfix-rgb))",
                                 border: "1px solid rgb(var(--hotfix-rgb) / 0.45)" }}
                        title={`排太滿（門檻 ${RM_OVERLOAD} 張）：${
                          overloadedIn(col.key).map(w => `${w} ${loadOf(col.key, w)} 張`).join("、")}`}>
                    <Icon.Alert className="w-3 h-3" />{overloadedIn(col.key).length}
                  </span>
                )}
              </div>
            ))}
          </div>

          {/* 泳道 */}
          {lanes.map((lane, li) => {
            const reorderable = canReorder(laneDrag, lane);
            const mark = laneOver && laneOver.lane === lane ? laneOver.pos : null;
            return (
            <React.Fragment key={lane}>
            <div className={`grid bg-surface border-t border-b border-border${laneDrag === lane ? " opacity-40" : ""}`}
              style={{ ...grid,
                boxShadow: mark === "before" ? "inset 0 3px 0 0 rgb(var(--fg-rgb) / 0.5)"
                         : mark === "after" ? "inset 0 -3px 0 0 rgb(var(--fg-rgb) / 0.5)" : undefined }}
              onDragOver={reorderable ? (e => {
                e.preventDefault();
                const r = e.currentTarget.getBoundingClientRect();
                const pos = (e.clientY - r.top) < r.height / 2 ? "before" : "after";
                setLaneOver(o => (o && o.lane === lane && o.pos === pos ? o : { lane, pos }));
              }) : undefined}
              onDrop={reorderable ? (e => { e.preventDefault(); doLaneDrop(lane, laneOver?.pos || "before"); }) : undefined}>
              <div className={`group flex items-center gap-1.5 px-3 py-2.5 sticky left-0 z-10 bg-surface transition-opacity${activeLane && activeLane !== lane ? " opacity-25" : ""}`}
                   style={{ borderLeft: `3px solid ${laneColor(lane)}`, cursor: focusKey ? "default" : "grab" }}
                   draggable={!focusKey}
                   onDragStart={e => {
                     e.dataTransfer.effectAllowed = "move";
                     e.dataTransfer.setData("text/plain", lane);
                     setLaneDrag(lane);
                   }}
                   onDragEnd={() => { setLaneDrag(null); setLaneOver(null); }}>
                <span className={`shrink-0 text-fg3 opacity-0 group-hover:opacity-100 transition-opacity${focusKey ? " invisible" : ""}`}
                      title="拖曳可調整主題順序">
                  <svg viewBox="0 0 24 24" className="w-3 h-3" fill="currentColor">
                    <circle cx="9" cy="6" r="1.5" /><circle cx="15" cy="6" r="1.5" />
                    <circle cx="9" cy="12" r="1.5" /><circle cx="15" cy="12" r="1.5" />
                    <circle cx="9" cy="18" r="1.5" /><circle cx="15" cy="18" r="1.5" />
                  </svg>
                </span>
                <button type="button"
                  onClick={(e) => { e.stopPropagation(); setActiveLane(a => a === lane ? null : lane); }}
                  title={activeLane === lane ? `取消聚焦「${lane}」` : `聚焦「${lane}」，其他淡化`}
                  className={`text-left text-[12.5px] font-semibold min-w-0 break-words hover:underline decoration-dotted underline-offset-2 ${lane === UNTAGGED_LANE ? "text-fg3" : "text-fg"}`}>
                  {lane}
                </button>
                <button type="button" onClick={() => togglePin(lane)}
                  title={pins.has(lane) ? `取消釘選「${lane}」` : `釘選「${lane}」，之後打開都排在最上面`}
                  aria-pressed={pins.has(lane)}
                  className={`ml-auto shrink-0 transition-opacity ${
                    pins.has(lane) ? "text-fg2 hover:text-fg" : "text-fg3 hover:text-fg2 opacity-0 group-hover:opacity-100 focus:opacity-100"}`}>
                  <svg viewBox="0 0 24 24" className="w-3.5 h-3.5"
                       fill={pins.has(lane) ? "currentColor" : "none"}
                       stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
                    <path d="M9.5 3.5h5l-.8 5.2 2.8 2.8v1.8H7.5v-1.8l2.8-2.8-.8-5.2Z" />
                    <line x1="12" y1="13.3" x2="12" y2="20.5" />
                  </svg>
                </button>
                <span className="mono text-[10.5px] text-fg3 shrink-0">{laneCount.get(lane)}</span>
              </div>
              {cols.map(col => {
                const cell = rows.filter(r => r.theme === lane && r.colKey === col.key);
                const cellKey = `${lane}|${col.key}`;
                const chk = dropCheck(col, lane);
                const hovering = overCell === cellKey;
                // 不可放的格子也要接 dragover，否則游標和畫面都不會有任何反應，
                // 使用者只會覺得「拖曳壞了」。只是不 preventDefault，所以放不下去。
                const outline = !chk ? "none"
                  : chk.ok ? (hovering ? "2px dashed rgb(var(--fg3-rgb))" : "1px dashed rgb(var(--fg3-rgb) / 0.35)")
                  : (hovering && chk.reason ? "2px dashed rgb(var(--hotfix-rgb))" : "none");
                return (
                  <div key={col.key}
                    className="relative border-l border-border px-1.5 py-1.5 flex flex-col gap-1 min-h-[40px]"
                    style={{
                      outline,
                      outlineOffset: "-3px",
                      background: hovering && chk?.ok ? "rgb(var(--fg-rgb) / 0.04)" : undefined,
                    }}
                    onDragOver={chk ? (e => {
                      if (chk.ok) { e.preventDefault(); e.dataTransfer.dropEffect = "move"; }
                      setOverCell(cellKey);
                    }) : undefined}
                    onDragLeave={chk ? (() => setOverCell(k => (k === cellKey ? null : k))) : undefined}
                    onDrop={chk?.ok ? (e => { e.preventDefault(); doDrop(chk.cycle, col); }) : undefined}>
                    {hovering && chk && !chk.ok && chk.reason && (
                      <div className="absolute inset-0 flex items-center justify-center pointer-events-none px-1">
                        <span className="text-[10px] leading-tight text-center rounded px-1.5 py-0.5"
                              style={{ background: "rgb(var(--surface-rgb))",
                                       color: "rgb(var(--hotfix-rgb))",
                                       border: "1px solid rgb(var(--hotfix-rgb) / 0.4)" }}>
                          {chk.reason}
                        </span>
                      </div>
                    )}
                    {cell.map(r => (
                      <RmRow key={r.id} r={r} lane={lane} laneC={laneColor(lane)}
                        pmMode={pmMode} canDrag={canDrag}
                        dimmed={(!!who && (r.assignee || RM_UNASSIGNED) !== who) || (!!activeLane && lane !== activeLane)}
                        moving={drag?.id === r.id}
                        ownerColor={personColor(r.assignee || RM_UNASSIGNED)}
                        ownerLoad={(r.assignee || RM_UNASSIGNED) === RM_UNASSIGNED ? 0 : loadOf(r.colKey, r.assignee)}
                        brands={effectiveBrands(r.id, r.labels, brandMap).brands}
                        brandRegistry={brandRegMap}
                        onDragStartRow={setDrag}
                        onDragEndRow={endDrag} />
                    ))}
                  </div>
                );
              })}
            </div>

            {/* 溝槽：泳道之間的留白，但欄線要接得回去。
                刻意用跟泳道同一組 grid，欄界才是結構上對齊，而不是靠手算。 */}
            {li < lanes.length - 1 && (
              <div className="grid" style={{ ...grid, height: (focusHas
                     ? focusHas.has(lane) !== focusHas.has(lanes[li + 1])
                     : pins.has(lane) !== pins.has(lanes[li + 1])) ? 14 : 7 }}
                   aria-hidden="true">
                <div className="sticky left-0 z-10 bg-bg" />
                {cols.map(col => <div key={col.key} className="border-l border-border" />)}
              </div>
            )}
            </React.Fragment>
            );
          })}
        </div>
      </div>

      {/* 圖例 */}
      <div className="flex items-center gap-4 mt-3 text-[11.5px] text-fg3 flex-wrap">
        <span className="flex items-center gap-1.5">
          <span className="inline-block w-[7px] h-[7px] rounded-full" style={{ background: "rgb(var(--vf-rgb))" }} />PredictGo (VF)
        </span>
        <span className="flex items-center gap-1.5">
          <span className="inline-block w-[7px] h-[7px] rounded-full" style={{ background: "rgb(var(--pg-rgb))" }} />Predict Gaming (PG)
        </span>
        <span>{cols.length} 個時間區間 · {lanes.length} 個主題 · {rows.length} 張票</span>
        {pmMode && <span className="text-fg3">點票開 Linear · 左右拖曳換 cycle · 拖主題名稱調順序 · 點日期欄頭把該期有票的主題排前面</span>}
        {order.length > 0 && (
          <button type="button" onClick={() => saveOrder([])}
            className="text-fg3 hover:text-fg2 underline underline-offset-2">
            主題順序已自訂（重設）
          </button>
        )}
      </div>
    </section>
  );
}


function Section({ color, title, count, items, query, compact, product, pmMode, subVersionDates }) {
  // Group consecutive items by subVersion (for merged releases like v1.10.0 + v1.10.1)
  const groups = [];
  let curr = null;
  items.forEach((it) => {
    const sv = it.subVersion || null;
    if (!curr || curr.subVersion !== sv) {
      curr = { subVersion: sv, items: [] };
      groups.push(curr);
    }
    curr.items.push(it);
  });
  const hasSubVersions = groups.some((g) => g.subVersion);

  // Parse desc for sub-items in【X.Y 標題】內容 or 【標題】內容 form and split into
  // { intro, subs: [{label, body}] } — desc becomes intro, subs render as visual list.
  const parseSubItems = (desc) => {
    const raw = String(desc || "");
    // Detect the FULL-WIDTH bracket forms 【…】; skip if none present.
    if (!/【[^】]+】/.test(raw)) return { intro: raw, subs: [] };
    const re = /【([^】]+)】([\s\S]*?)(?=【[^】]+】|$)/g;
    let m, first = null;
    const subs = [];
    while ((m = re.exec(raw)) !== null) {
      if (first === null) first = m.index;
      subs.push({ label: m[1].trim(), body: m[2].trim() });
    }
    const intro = (first == null ? raw : raw.slice(0, first)).trim();
    return { intro, subs };
  };

  const renderItem = (it, idx) => {
    const parsed = parseSubItems(it.desc);
    return (
    <li key={idx} className="flex gap-3">
      <span className="mono text-[11.5px] text-fg3 pt-0.5 w-5 shrink-0 text-right">
        {String(idx + 1).padStart(2, "0")}
      </span>
      <div className="min-w-0 flex-1">
        <div className="flex items-center gap-1.5 flex-wrap">
          <div className="text-[13.5px] text-fg font-medium leading-snug">
            <Highlight text={it.name} query={query} />
          </div>
          {pmMode && (it.tickets || []).map(t => (
            <TicketChip key={t} id={t} product={product} />
          ))}
          {it.guide_url && (
            <a
              href={it.guide_url}
              target="_blank"
              rel="noopener noreferrer"
              className="inline-flex items-center gap-1 rounded-md px-2 h-6 text-[11px] font-semibold border border-border bg-surface2 text-fg2 hover:text-fg hover:border-borderStrong transition-colors"
              title={it.guide_label || "查看操作手冊"}
            >
              <Icon.Doc className="w-3 h-3" />
              {it.guide_label || "操作手冊"}
              <svg viewBox="0 0 24 24" className="w-2.5 h-2.5" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
                <path d="M7 17 17 7M8 7h9v9"/>
              </svg>
            </a>
          )}
        </div>
        {parsed.intro && (
          <div className="text-[12.5px] text-fg2 leading-relaxed mt-0.5 whitespace-pre-line">
            <Highlight text={parsed.intro} query={query} />
          </div>
        )}
        {parsed.subs.length > 0 && (
          <ul className="mt-2 space-y-1.5 border-l border-border pl-3">
            {parsed.subs.map((s, si) => {
              const key = (s.label.match(/^(\d+(?:\.\d+)+)/) || [])[1];
              const g = (it.sub_guides && key) ? it.sub_guides[key] : null;
              return (
                <li key={si} className="text-[12.5px] leading-relaxed">
                  <span className="text-fg font-medium mr-1.5"><Highlight text={s.label} query={query} /></span>
                  <span className="text-fg2"><Highlight text={s.body} query={query} /></span>
                  {g && g.url && (
                    <a
                      href={g.url}
                      target="_blank"
                      rel="noopener noreferrer"
                      className="ml-1.5 inline-flex items-center gap-1 rounded-md px-1.5 h-5 align-[1px] text-[10.5px] font-semibold border border-border bg-surface2 text-fg2 hover:text-fg hover:border-borderStrong transition-colors"
                      title={g.label || "查看操作手冊"}
                    >
                      <Icon.Doc className="w-2.5 h-2.5" />
                      {g.label || "操作手冊"}
                      <svg viewBox="0 0 24 24" className="w-2 h-2" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
                        <path d="M7 17 17 7M8 7h9v9"/>
                      </svg>
                    </a>
                  )}
                </li>
              );
            })}
          </ul>
        )}
        {(it.bullets || []).length > 0 && (
          <ul className="mt-1.5 space-y-1">
            {it.bullets.map((b, bi) => (
              <li key={bi} className="flex gap-2 text-[12.5px] text-fg2 leading-relaxed">
                <span className="text-fg3 select-none shrink-0">•</span>
                <span className="min-w-0 flex-1"><Highlight text={b} query={query} /></span>
              </li>
            ))}
          </ul>
        )}
      </div>
    </li>
    );
  };

  return (
    <div className={compact ? "mt-5" : ""}>
      <div className="flex items-center gap-2 mb-3">
        <Icon.Dot className="w-2 h-2" style={{ color }} />
        <h4 className="text-[13px] font-semibold text-fg tracking-wide">{title}</h4>
        <span className="mono text-[11px] text-fg3">{String(count).padStart(2, "0")}</span>
      </div>
      {groups.map((grp, gi) => (
        <div key={gi} className={gi > 0 ? "mt-4" : ""}>
          {hasSubVersions && grp.subVersion && (
            <div className="flex items-center gap-2.5 mb-2.5">
              <span className="mono text-[11.5px] text-fg2 font-semibold tracking-wide">
                {grp.subVersion}
                {subVersionDates?.[grp.subVersion] && (
                  <span className="text-fg3 font-normal ml-1.5">· {subVersionDates[grp.subVersion]}</span>
                )}
              </span>
              <span className="flex-1 hair" />
              <span className="mono text-[10.5px] text-fg3">
                {String(grp.items.length).padStart(2, "0")}
              </span>
            </div>
          )}
          <ol className="space-y-2.5 pl-0">
            {grp.items.map(renderItem)}
          </ol>
        </div>
      ))}
    </div>
  );
}

// ─── Login Gate ───
// Two ways in:
//   1. Guest password (default visible) — type today's date (YYYY-MM-DD) → viewer-only access via Worker
//   2. Magic Link (collapsed) — Supabase email login, full role-based access

function LoginGate({ theme, onToggleTheme, lang, onToggleLang, t, onGuestSuccess }) {
  // guest password state
  const [guestPwd, setGuestPwd] = useState("");
  const [guestSubmitting, setGuestSubmitting] = useState(false);
  const [guestErr, setGuestErr] = useState(null);
  const guestInputRef = useRef(null);

  // magic link state (collapsed by default)
  const [showMagicLink, setShowMagicLink] = useState(false);
  const [email, setEmail] = useState("");
  const [sending, setSending] = useState(false);
  const [sent, setSent] = useState(false);
  const [error, setError] = useState(null);
  const emailInputRef = useRef(null);

  useEffect(() => { guestInputRef.current?.focus(); }, []);
  useEffect(() => { if (showMagicLink) emailInputRef.current?.focus(); }, [showMagicLink]);

  const submitGuest = async () => {
    const pwd = guestPwd.trim();
    if (!pwd || guestSubmitting) return;
    setGuestSubmitting(true);
    setGuestErr(null);
    try {
      const res = await fetch(`${WORKER_URL}/releases?password=${encodeURIComponent(pwd)}`);
      const data = await res.json();
      if (!data.ok) {
        setGuestErr(data.error === "Invalid password" ? t("pwdError") : (data.error || (lang === "en" ? "Login failed" : "登入失敗")));
        return;
      }
      const payload = { releases: data.releases || [], cycles: data.cycles || [], brands: data.brands || null };
      sessionStorage.setItem(GUEST_PWD_KEY, pwd);
      sessionStorage.setItem(GUEST_DATA_KEY, JSON.stringify(payload));
      onGuestSuccess(payload);
    } catch (e) {
      setGuestErr(`${lang === "en" ? "Network error" : "網路錯誤"}：${e.message || e}`);
    } finally {
      setGuestSubmitting(false);
    }
  };

  const sendLink = async () => {
    if (!email.trim() || sending) return;
    setSending(true);
    setError(null);
    const { error } = await supabase.auth.signInWithOtp({
      email: email.trim().toLowerCase(),
      options: {
        emailRedirectTo: window.location.origin,
        shouldCreateUser: true,
      },
    });
    setSending(false);
    if (error) {
      setError(error.message || "送出失敗，請稍後再試");
    } else {
      setSent(true);
    }
  };

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-bg px-5">
      <div className="absolute top-5 right-5 flex items-center gap-2">
        <LangToggle lang={lang} onToggle={onToggleLang} />
        <ThemeToggle theme={theme} onToggle={onToggleTheme} />
      </div>

      <div className="w-full max-w-[400px]">
        {/* brand mark */}
        <div className="flex flex-col items-center mb-8">
          <BrandMark className="w-[64px] h-[64px] mb-5" rx={7} />
          <div className="text-[11px] mono uppercase tracking-[0.18em] text-fg3 mb-1.5">{t("loginInternalTool")}</div>
          <h1 className="text-fg text-[20px] font-semibold">{t("loginBrandTitle")}</h1>
          <p className="text-fg2 text-[13px] mt-1">PredictGo <span className="text-fg3">×</span> Predict Gaming</p>
        </div>

        {/* guest password — default visible */}
        <div className="bg-surface border border-border rounded-2xl p-6">
          <label className="block text-fg text-[13.5px] font-medium mb-2.5">{t("inputPwdLabel")}</label>
          <input
            ref={guestInputRef}
            type="password"
            value={guestPwd}
            onChange={(e) => { setGuestPwd(e.target.value); setGuestErr(null); }}
            onKeyDown={(e) => e.key === "Enter" && submitGuest()}
            placeholder="••••"
            inputMode="numeric"
            autoComplete="off"
            disabled={guestSubmitting}
            className={`w-full h-11 px-3.5 bg-surface2 border rounded-lg text-fg text-[14px] mono outline-none transition-colors
              ${guestErr ? "border-red-500/60" : "border-border focus:border-borderStrong"}`}
          />
          <div className="mt-2 h-5 text-[12px]">
            {guestErr
              ? <span className="text-red-400">{guestErr}</span>
              : <span className="text-fg3">&nbsp;</span>}
          </div>
          <button
            onClick={submitGuest}
            disabled={!guestPwd.trim() || guestSubmitting}
            className="mt-4 w-full h-11 rounded-lg grad-btn text-[14px] font-semibold hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed transition-opacity"
          >
            {guestSubmitting ? t("verifyingBtn") : t("enterBtn")}
          </button>
        </div>

        {/* magic link — collapsed by default */}
        <div className="mt-4 bg-surface border border-border rounded-2xl overflow-hidden">
          <button
            onClick={() => setShowMagicLink(v => !v)}
            className="w-full px-6 py-3.5 flex items-center justify-between text-fg2 hover:text-fg text-[13px] transition-colors"
          >
            <span>{t("magicLinkToggle")}</span>
            <span className={`chev text-fg3 ${showMagicLink ? "open" : ""}`}>▾</span>
          </button>

          {showMagicLink && (
            <div className="px-6 pb-6 pt-1 border-t border-border">
              {!sent ? (
                <>
                  <input
                    ref={emailInputRef}
                    type="email"
                    value={email}
                    onChange={(e) => { setEmail(e.target.value); setError(null); }}
                    onKeyDown={(e) => e.key === "Enter" && sendLink()}
                    placeholder={t("magicLinkPlaceholder")}
                    autoComplete="email"
                    disabled={sending}
                    className={`mt-4 w-full h-11 px-3.5 bg-surface2 border rounded-lg text-fg text-[14px] outline-none transition-colors
                      ${error ? "border-red-500/60" : "border-border focus:border-borderStrong"}`}
                  />
                  <div className="mt-2 h-5 text-[12px]">
                    {error
                      ? <span className="text-red-400">{error}</span>
                      : <span className="text-fg3">{t("magicLinkHint")}</span>}
                  </div>
                  <button
                    onClick={sendLink}
                    disabled={!email.trim() || sending}
                    className="mt-4 w-full h-11 rounded-lg border border-borderStrong bg-surface2 text-fg text-[14px] font-medium hover:border-fg3 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
                  >
                    {sending ? t("magicLinkSending") : t("magicLinkSendBtn")}
                  </button>
                </>
              ) : (
                <div className="text-center py-4">
                  <div className="flex justify-center mb-3 text-fg3"><Icon.Inbox className="w-9 h-9" /></div>
                  <div className="text-fg text-[14px] font-semibold mb-2">{t("magicLinkSentTitle")}</div>
                  <div className="text-fg2 text-[12.5px] mb-3">
                    <span className="text-fg mono">{email}</span> {t("magicLinkSentHint")}
                    <br />{t("magicLinkSentValid")}
                  </div>
                  <button
                    onClick={() => { setSent(false); setEmail(""); }}
                    className="text-fg3 hover:text-fg text-[12px] underline">
                    {t("magicLinkChangeEmail")}
                  </button>
                </div>
              )}
            </div>
          )}
        </div>

        <div className="mt-5 text-center text-[11px] mono text-fg3">
          © Singdm Internal · {new Date().getFullYear()}
        </div>
      </div>
    </div>
  );
}

// ---------- empty state ----------
function EmptyState({ query, t }) {
  const tx = t || ((k) => ({ emptyTitle: "找不到符合條件的版本", emptyNoMatch: "no match for", emptyClearFilters: "try clearing filters" }[k] || k));
  return (
    <div className="rounded-xl border border-dashed border-border bg-surface/40 py-16 flex flex-col items-center justify-center">
      <div className="w-12 h-12 rounded-full border border-border flex items-center justify-center text-fg3 mb-4">
        <Icon.Search className="w-5 h-5" />
      </div>
      <div className="text-fg font-medium text-[14px] mb-1">{tx("emptyTitle")}</div>
      <div className="text-fg3 text-[12.5px] mono">
        {query ? <>{tx("emptyNoMatch")} "<span className="text-fg2">{query}</span>"</> : tx("emptyClearFilters")}
      </div>
    </div>
  );
}

// ---------- mobile sidebar (collapsed to filter row) ----------
function MobileFilters({ counts, filters, setFilters }) {
  const [expanded, setExpanded] = useState(false);

  const Row = ({ label, items, value, onChange }) => (
    <div>
      <div className="text-[10.5px] font-medium uppercase tracking-[0.12em] text-fg3 mb-1.5">{label}</div>
      <div className="flex gap-1.5 flex-wrap">
        {items.map(it => {
          const active = value === it.key;
          return (
            <button
              key={it.key}
              onClick={() => onChange(it.key)}
              className={`chip rounded-full px-2.5 h-7 text-[12px] border flex items-center gap-1.5
                ${active ? "grad-btn" : "bg-surface text-fg2 border-border"}`}
            >
              {it.dot && <span className="inline-block w-1.5 h-1.5 rounded-full" style={{background: it.dot}} />}
              {it.label}
              {it.count != null && <span className="mono opacity-70">{it.count}</span>}
            </button>
          );
        })}
      </div>
    </div>
  );

  // Show active filter summary so user knows what's applied without expanding
  const activeSummary = [];
  if (filters.product !== "all") activeSummary.push(filters.product === "VF" ? "PredictGo" : "Predict Gaming");
  if (filters.year !== "all") activeSummary.push(filters.year);
  if (filters.type !== "all") activeSummary.push({ new: "新功能", fix: "修復", hotfix: "Hotfix" }[filters.type]);
  const activeCount = activeSummary.length;

  return (
    <div className="lg:hidden mb-4">
      <button
        onClick={() => setExpanded(v => !v)}
        className="w-full flex items-center gap-2 px-3 h-9 rounded-lg border border-border bg-surface text-fg2 hover:text-fg hover:border-borderStrong transition-colors text-[12.5px]"
      >
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-3.5 h-3.5 shrink-0">
          <polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>
        </svg>
        <span>篩選</span>
        {activeCount > 0 ? (
          <span className="mono text-[10.5px] text-fg bg-fg/10 px-1.5 py-0.5 rounded">
            {activeSummary.join(" · ")}
          </span>
        ) : (
          <span className="mono text-[10.5px] text-fg3">無條件</span>
        )}
        <span className="flex-1" />
        <span className={`chev text-fg3 ${expanded ? "open" : ""}`}>
          <Icon.Chev className="w-3.5 h-3.5" />
        </span>
      </button>
      {expanded && (
        <div className="mt-2 space-y-3 px-1">
          <Row
            label="產品"
            value={filters.product}
            onChange={v => setFilters(f => ({ ...f, product: v }))}
            items={[
              { key: "all", label: "全部", count: counts.product.all },
              { key: "VF",  label: "PredictGo", dot: "rgb(var(--vf-rgb))", count: counts.product.VF },
              { key: "PG",  label: "Predict Gaming", dot: "rgb(var(--pg-rgb))", count: counts.product.PG },
            ]}
          />
          <Row
            label="年份"
            value={filters.year}
            onChange={v => setFilters(f => ({ ...f, year: v }))}
            items={[
              { key: "all", label: "全部", count: counts.year.all },
              { key: "2026", label: "2026", count: counts.year["2026"] || 0 },
              { key: "2025", label: "2025", count: counts.year["2025"] || 0 },
            ]}
          />
          <Row
            label="類型"
            value={filters.type}
            onChange={v => setFilters(f => ({ ...f, type: v }))}
            items={[
              { key: "all", label: "全部", count: counts.type.all },
              { key: "new", label: "新功能", dot: "var(--type-new)", count: counts.type.new },
              { key: "fix", label: "修復", dot: "var(--type-fix)", count: counts.type.fix },
              { key: "hotfix", label: "Hotfix", dot: "var(--type-hot)", count: counts.type.hotfix },
            ]}
          />
        </div>
      )}
    </div>
  );
}

// ---------- main app ----------
function App() {
  const data = (typeof window !== "undefined" && window.RELEASE_DATA) || [];

  const [query, setQuery] = useState("");
  const [filters, setFilters] = useState({ product: "all", year: "all", type: "all" });
  const [chip, setChip] = useState("all");
  const [sort, setSort] = useState("desc");
  const [openIds, setOpenIds] = useState(() => new Set());
  const [flashId, setFlashId] = useState(null);
  const [activeTab, setActiveTab] = useState(() => {
    return localStorage.getItem("rn-active-tab") || "history";
  });
  useEffect(() => { localStorage.setItem("rn-active-tab", activeTab); }, [activeTab]);
  // (fallback useEffect for tab restrictions is declared further below, after `isAdmin` is defined)

  // ---------- Theme ----------
  const [theme, setTheme] = useState(getInitialTheme);
  useEffect(() => {
    applyTheme(theme);
    localStorage.setItem(THEME_KEY, theme);
  }, [theme]);
  // live follow system changes only if user hasn't manually set
  useEffect(() => {
    const mq = window.matchMedia?.("(prefers-color-scheme: light)");
    if (!mq) return;
    const onChange = (e) => {
      if (!localStorage.getItem(THEME_KEY + "-manual")) {
        setTheme(e.matches ? "light" : "dark");
      }
    };
    mq.addEventListener?.("change", onChange);
    return () => mq.removeEventListener?.("change", onChange);
  }, []);
  const toggleTheme = useCallback(() => {
    setTheme(prev => {
      const next = prev === "dark" ? "light" : "dark";
      localStorage.setItem(THEME_KEY + "-manual", "1");
      return next;
    });
  }, []);

  // ---------- Language ----------
  const [lang, setLang] = useState(() => localStorage.getItem(LANG_KEY) || "zh");
  const toggleLang = useCallback(() => {
    setLang(prev => {
      const next = prev === "zh" ? "en" : "zh";
      localStorage.setItem(LANG_KEY, next);
      return next;
    });
  }, []);
  const t = useCallback((key, ...args) => {
    const dict = I18N[lang] || I18N.zh;
    const val = dict[key];
    if (typeof val === "function") return val(...args);
    return val ?? key;
  }, [lang]);

  // ---------- Auth & PM mode (Supabase) ----------
  // session: Supabase auth session (null = not logged in, or loading)
  // profile: row from public.profiles (has role, is_active, etc.)
  // authReady: have we attempted initial session check?
  // guestMode: logged in via daily date password, no Supabase session, viewer-only
  // guestData: release rows fetched from Worker on guest login
  const [session, setSession] = useState(null);
  const [profile, setProfile] = useState(null);
  const [authReady, setAuthReady] = useState(false);
  const [guestMode, setGuestMode] = useState(false);
  const [guestData, setGuestData] = useState(null);

  // Synthetic profile for guest — viewer role, no real id/email
  const effectiveProfile = guestMode
    ? { id: null, email: "guest", role: "viewer", is_active: true }
    : profile;

  const role = effectiveProfile?.role || null;   // 'viewer' | 'pm' | 'editor' | 'admin' | null
  const isAdmin = role === "admin";
  const canSeeQa = role === "admin" || role === "qa";  // 可看 QA 進測看板
  const pmUnlocked = role === "pm" || role === "editor" || role === "admin";  // can see Linear/Notion/Schedule
  const canEditEvents = role === "pm" || role === "editor" || role === "admin"; // can write to custom_events
  const isInactive = effectiveProfile && effectiveProfile.is_active === false;

  // PM display toggle — editor/admin can hide PM features (e.g. screen sharing)
  // The toggle is just a preference; effective pmMode requires role unlock.
  // 側欄收合：預設展開，選擇記在這台瀏覽器
  const [sidebarOpen, setSidebarOpen] = useState(() => localStorage.getItem(SIDEBAR_KEY) !== "0");
  const toggleSidebar = () => setSidebarOpen(v => {
    const next = !v;
    try { localStorage.setItem(SIDEBAR_KEY, next ? "1" : "0"); } catch {}
    return next;
  });

  const [pmModeToggle, setPmModeToggle] = useState(() => {
    return localStorage.getItem(PM_DISPLAY_KEY) !== "0"; // default ON
  });
  const pmMode = pmUnlocked && pmModeToggle;  // effective: viewer always false even if toggle is on
  const setPmMode = setPmModeToggle;          // backward-compat shim for any old setter usage

  // schedule tab requires PM/Editor/Admin role; admin tab requires admin role
  useEffect(() => {
    if (activeTab === "schedule" && !pmUnlocked) setActiveTab("history");
    if (activeTab === "admin" && !isAdmin) setActiveTab("history");
    if (activeTab === "qa" && !canSeeQa) setActiveTab("history");
    if (activeTab === "draft" && !pmUnlocked) setActiveTab("history");
    if (activeTab === "relay" && !pmUnlocked) setActiveTab("history");
  }, [pmUnlocked, isAdmin, canSeeQa, activeTab]);

  // 待審核草稿數量（顯示在分頁上的小紅字，讓人一眼看到有東西等著）
  const [draftCount, setDraftCount] = useState(0);
  useEffect(() => {
    if (!pmUnlocked) { setDraftCount(0); return; }
    let cancelled = false;
    supabase
      .from("releases")
      .select("id", { count: "exact", head: true })
      .eq("status", "draft")
      .then(({ count }) => { if (!cancelled) setDraftCount(count || 0); });
    return () => { cancelled = true; };
  }, [pmUnlocked, activeTab]);

  // TG 中繼未讀總數（分頁上的紅字）；每 60 秒更新一次
  const [relayUnread, setRelayUnread] = useState(0);
  useEffect(() => {
    if (!pmUnlocked) { setRelayUnread(0); return; }
    let cancelled = false;
    const poll = async () => {
      try {
        const d = await pmApi("/tg-relay/chats");
        if (!cancelled) {
          setRelayUnread((d.chats || []).reduce((n, c) => n + (c.unread || 0), 0));
        }
      } catch (_) { /* 沒部署或沒權限就安靜略過 */ }
    };
    poll();
    const t = setInterval(poll, 60000);
    return () => { cancelled = true; clearInterval(t); };
  }, [pmUnlocked, activeTab]);

  // ─── Subscribe to Supabase auth changes ───
  useEffect(() => {
    let mounted = true;
    supabase.auth.getSession().then(({ data }) => {
      if (!mounted) return;
      setSession(data.session);
      // Restore guest session if today's date stored AND no Supabase session
      if (!data.session) {
        const storedPwd = sessionStorage.getItem(GUEST_PWD_KEY);
        const storedData = sessionStorage.getItem(GUEST_DATA_KEY);
        if (storedPwd && storedData && storedPwd === todayPasswordTPE()) {
          try {
            const parsed = JSON.parse(storedData);
            // 舊格式只存 releases 陣列，沿用時包成現在的形狀
            setGuestData(Array.isArray(parsed) ? { releases: parsed, cycles: [], brands: null } : parsed);
            setGuestMode(true);
            // 先用快取畫出來，再重抓一次：舊分頁存的可能是還沒有 roadmap 資料的舊格式
            fetch(`${WORKER_URL}/releases?password=${encodeURIComponent(storedPwd)}`)
              .then(r => r.json())
              .then(fresh => {
                if (!mounted || !fresh?.ok) return;
                const payload = { releases: fresh.releases || [], cycles: fresh.cycles || [], brands: fresh.brands || null };
                sessionStorage.setItem(GUEST_DATA_KEY, JSON.stringify(payload));
                setGuestData(payload);
              })
              .catch(() => {});
          } catch {
            sessionStorage.removeItem(GUEST_PWD_KEY);
            sessionStorage.removeItem(GUEST_DATA_KEY);
          }
        } else if (storedPwd) {
          // stale (different day) — clear
          sessionStorage.removeItem(GUEST_PWD_KEY);
          sessionStorage.removeItem(GUEST_DATA_KEY);
        }
      }
      setAuthReady(true);
    });
    const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, sess) => {
      setSession(sess);
      // If a Supabase login happens, drop any guest state
      if (sess) {
        setGuestMode(false);
        setGuestData(null);
        sessionStorage.removeItem(GUEST_PWD_KEY);
        sessionStorage.removeItem(GUEST_DATA_KEY);
      }
    });
    return () => { mounted = false; subscription?.unsubscribe(); };
  }, []);

  const handleGuestSuccess = useCallback((payload) => {
    setGuestData(payload);
    setGuestMode(true);
  }, []);

  // ─── When session changes, fetch the profile row ───
  useEffect(() => {
    if (!session?.user) {
      setProfile(null);
      return;
    }
    let cancelled = false;
    supabase.from("profiles").select("*").eq("id", session.user.id).maybeSingle()
      .then(({ data, error }) => {
        if (cancelled) return;
        if (error) {
          console.error("fetch profile error:", error.message);
          return;
        }
        setProfile(data);
        if (data) {
          // Log login event (only once per session, on first profile load)
          logAudit("login", "user", data.id, { email: data.email });
        }
      });
    return () => { cancelled = true; };
  }, [session?.user?.id]);

  const handleLogout = useCallback(async () => {
    if (guestMode) {
      sessionStorage.removeItem(GUEST_PWD_KEY);
      sessionStorage.removeItem(GUEST_DATA_KEY);
      setGuestMode(false);
      setGuestData(null);
      return;
    }
    if (profile) logAudit("logout", "user", profile.id, { email: profile.email });
    await supabase.auth.signOut();
    setSession(null);
    setProfile(null);
  }, [profile, guestMode]);

  // ─── Fetch app data from Supabase (releases / cycles / linear due dates) ───
  // bumped after fetch to trigger components reading window.* via getNextData() etc.
  const [dataVersion, setDataVersion] = useState(0);
  const [reloadTick, setReloadTick] = useState(0); // bump to force refetch (e.g. after sync)
  const triggerReload = useCallback(() => setReloadTick(n => n + 1), []);
  useEffect(() => {
    // Guest mode: data already came from Worker — just populate window.RELEASE_DATA
    // Map a Supabase row → frontend release object, picking EN fields when lang='en'
    const mapRelease = (r) => {
      const useEn = lang === "en" && r.title_en;
      return {
        id: r.id,
        product: r.product,
        version: r.version,
        date: r.date,
        title: useEn ? r.title_en : r.title,
        hotfix: r.hotfix,
        parentTicket: r.parent_ticket,
        whatsNew: useEn && r.whats_new_en ? r.whats_new_en : (r.whats_new || []),
        bugFixes: useEn && r.bug_fixes_en ? r.bug_fixes_en : (r.bug_fixes || []),
        subVersionDates: r.sub_version_dates || null,
      };
    };

    const mapCycle = (c) => ({
      cycleId: c.id,
      product: c.product,
      cycleNumber: c.cycle_number,
      cycleTitle: c.cycle_title,
      plannedVersion: c.planned_version,
      notionUrl: c.notion_url,
      releaseDate: c.release_date,
      isCurrent: c.is_current,
      issues: c.issues || [],
    });

    if (guestMode) {
      // 訪客的 roadmap 資料由 Worker 給（cycles 表有 RLS，訪客沒有帳號讀不到）
      const g = guestData || {};
      window.RELEASE_DATA = (g.releases || []).map(mapRelease);
      window.NEXT_RELEASES = (g.cycles || []).map(mapCycle);
      window.LINEAR_DUE_DATES = [];
      window.GUEST_BRANDS = g.brands || null;
      setDataVersion(v => v + 1);
      return;
    }
    if (!profile || isInactive) return;
    window.GUEST_BRANDS = null;   // 訪客切成登入帳號後，別讓訪客那份品牌資料殘留
    let cancelled = false;
    Promise.all([
      supabase.from("releases").select("*").neq("status", "draft").order("date", { ascending: false }),
      supabase.from("cycles").select("*").order("release_date", { ascending: true }),
      supabase.from("linear_due_dates").select("*").order("due_date", { ascending: true }),
    ]).then(([rels, cycs, dds]) => {
      if (cancelled) return;
      if (rels.data) {
        window.RELEASE_DATA = rels.data.map(mapRelease);
      } else if (rels.error) {
        console.error("releases fetch:", rels.error.message);
      }
      if (cycs.data) {
        window.NEXT_RELEASES = cycs.data.map(mapCycle);
      } else if (cycs.error) {
        console.error("cycles fetch:", cycs.error.message);
      }
      if (dds.data) {
        window.LINEAR_DUE_DATES = dds.data.map(d => ({
          id: d.id,
          product: d.product,
          title: d.title,
          dueDate: d.due_date,
          status: d.status,
          statusType: d.status_type,
          priority: d.priority,
          assignee: d.assignee,
          url: d.url,
          labels: d.labels || [],
        }));
      } else if (dds.error) {
        console.error("linear_due_dates fetch:", dds.error.message);
      }
      setDataVersion(v => v + 1);
    });
    return () => { cancelled = true; };
  }, [profile?.id, isInactive, guestMode, guestData, lang, reloadTick]);

  const togglePm = useCallback(() => {
    if (!pmUnlocked) return;
    setPmMode(prev => {
      const next = !prev;
      localStorage.setItem(PM_DISPLAY_KEY, next ? "1" : "0");
      return next;
    });
  }, [pmUnlocked]);

  const inputRef = useRef(null);
  const cardRefs = useRef({});

  // keyboard shortcut ⌘K / Ctrl+K + ⌘⇧P for PM toggle
  useEffect(() => {
    const onKey = (e) => {
      if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "p") {
        e.preventDefault();
        if (pmUnlocked) togglePm(); // 只有 PM 可以切換顯示
        return;
      }
      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
        e.preventDefault();
        inputRef.current?.focus();
        inputRef.current?.select();
      } else if (e.key === "Escape" && document.activeElement === inputRef.current) {
        setQuery("");
        inputRef.current?.blur();
      }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [pmUnlocked, togglePm]);

  // ---------- counts (unfiltered reference) ----------
  const counts = useMemo(() => {
    const c = {
      product: { all: data.length, VF: 0, PG: 0 },
      year: { all: data.length },
      type: { all: data.length, new: 0, fix: 0, hotfix: 0 },
      lastUpdate: [...data].sort((a,b) => b.date.localeCompare(a.date))[0]?.date,
    };
    for (const r of data) {
      c.product[r.product] = (c.product[r.product] || 0) + 1;
      const y = yearOf(r.date); c.year[y] = (c.year[y] || 0) + 1;
      const tags = getTypeTags(r);
      if (tags.includes("new")) c.type.new++;
      if (tags.includes("fix")) c.type.fix++;
      if (tags.includes("hotfix")) c.type.hotfix++;
    }
    return c;
  }, [data]);

  // ---------- keyword hits (used for summary even before chip filters) ----------
  const keywordHits = useMemo(() => {
    if (!query) return [];
    return data
      .filter(r => matchesQuery(r, query))
      .sort((a,b) => b.date.localeCompare(a.date));
  }, [data, query]);

  // ---------- filtered + sorted list ----------
  const filtered = useMemo(() => {
    let list = data.filter(r => {
      if (filters.product !== "all" && r.product !== filters.product) return false;
      if (filters.year !== "all" && yearOf(r.date) !== filters.year) return false;
      const tags = getTypeTags(r);
      if (filters.type !== "all" && !tags.includes(filters.type)) return false;

      if (chip === "hotfix" && !r.hotfix) return false;
      if (chip === "new" && (r.whatsNew?.length ?? 0) === 0) return false;

      if (!matchesQuery(r, query)) return false;
      return true;
    });

    const parseVer = (s) => {
      const m = String(s || "").match(/v(\d+)\.(\d+)\.(\d+)/);
      return m ? [+m[1], +m[2], +m[3]] : [0, 0, 0];
    };
    const cmpVer = (a, b) => {
      const [a1, a2, a3] = parseVer(a);
      const [b1, b2, b3] = parseVer(b);
      return (a1 - b1) || (a2 - b2) || (a3 - b3);
    };
    const PRODUCT_ORDER = { PG: 0, VF: 1 };
    list.sort((a, b) => {
      const d = sort === "desc" ? b.date.localeCompare(a.date) : a.date.localeCompare(b.date);
      if (d !== 0) return d;
      const v = sort === "desc" ? cmpVer(b.version, a.version) : cmpVer(a.version, b.version);
      if (v !== 0) return v;
      return (PRODUCT_ORDER[a.product] ?? 99) - (PRODUCT_ORDER[b.product] ?? 99);
    });

    if (chip === "latest") {
      // keep only the most-recent per product
      const seen = new Set();
      list = list.filter(r => (seen.has(r.product) ? false : (seen.add(r.product), true)));
    }
    return list;
  }, [data, filters, chip, query, sort]);

  const toggle = useCallback((id) => {
    setOpenIds(prev => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id); else next.add(id);
      return next;
    });
  }, []);

  const jumpTo = useCallback((id) => {
    setOpenIds(prev => new Set(prev).add(id));
    setFlashId(id);
    setTimeout(() => {
      const el = cardRefs.current[id];
      if (el) {
        const y = el.getBoundingClientRect().top + window.scrollY - 80;
        window.scrollTo({ top: y, behavior: "smooth" });
      }
    }, 40);
    setTimeout(() => setFlashId(null), 1500);
  }, []);

  // auto-expand matched cards when searching
  useEffect(() => {
    if (query) {
      setOpenIds(new Set(keywordHits.map(r => r.id)));
    }
  }, [query, keywordHits]);

  // ─── Auth gate ───
  // 1. Auth not ready yet → show splash (avoids login flash)
  // 2. No session → show login form
  // 3. Logged in but profile not loaded yet → splash
  // 4. Profile is_active = false → show locked screen
  if (!authReady) {
    return <div className="fixed inset-0 flex items-center justify-center bg-bg text-fg3 text-[13px]">載入中...</div>;
  }
  if (!session && !guestMode) {
    return <LoginGate theme={theme} onToggleTheme={toggleTheme} lang={lang} onToggleLang={toggleLang} t={t} onGuestSuccess={handleGuestSuccess} />;
  }
  if (session && !profile) {
    return <div className="fixed inset-0 flex items-center justify-center bg-bg text-fg3 text-[13px]">取得使用者資料中...</div>;
  }
  if (isInactive) {
    return (
      <div className="fixed inset-0 flex items-center justify-center bg-bg px-5">
        <div className="max-w-[400px] text-center">
          <div className="flex justify-center mb-4 text-fg3"><Icon.Ban className="w-11 h-11" /></div>
          <h1 className="text-fg text-[18px] font-semibold mb-2">帳號已停用</h1>
          <p className="text-fg2 text-[13.5px] mb-6">你的帳號已被停用，請聯繫管理員 ({profile?.email})</p>
          <button onClick={handleLogout}
            className="rounded-lg px-4 h-9 text-[13px] border border-border bg-surface text-fg2 hover:text-fg hover:border-borderStrong transition-colors">
            登出
          </button>
        </div>
      </div>
    );
  }

  return (
    <div className="min-h-screen flex">
      <Sidebar counts={counts} filters={filters} setFilters={setFilters} open={sidebarOpen} />

      <main className="flex-1 min-w-0">
        <div className="mx-auto px-5 lg:px-10 py-5 lg:py-6 pb-24 lg:pb-6 max-w-none">
          {/* header — 精簡版：拿掉裝飾性的 kicker，標題縮小，副標降為輔助說明 */}
          <header className="mb-4 flex items-center justify-between gap-3 lg:gap-4">
            <div className="flex items-center gap-3 min-w-0">
              {/* 側欄收合鈕：固定放在這裡，展開收合都是同一顆，不用到處找 */}
              <button type="button" onClick={toggleSidebar}
                title={sidebarOpen ? "收合左側篩選欄" : "展開左側篩選欄"}
                aria-expanded={sidebarOpen}
                className="hidden lg:flex shrink-0 items-center justify-center w-8 h-8 rounded-lg border border-border bg-surface hover:border-borderStrong text-fg3 hover:text-fg2 transition-colors">
                <svg viewBox="0 0 24 24" className="w-4 h-4" fill="none" stroke="currentColor"
                     strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
                  <rect x="3" y="4" width="18" height="16" rx="2" />
                  <line x1="9" y1="4" x2="9" y2="20" />
                  {sidebarOpen
                    ? <polyline points="14.5 9.5 12 12 14.5 14.5" />
                    : <polyline points="12 9.5 14.5 12 12 14.5" />}
                </svg>
              </button>
              <div className="min-w-0">
                <h1 className="text-[17px] lg:text-[20px] font-semibold tracking-tight text-fg leading-tight truncate">
                  {t("pageTitle")}
                </h1>
                <p className="hidden lg:block text-fg3 text-[12px] mt-0.5 leading-snug truncate">
                  {t("pageSubtitle")}
                </p>
              </div>
            </div>
            <div className="shrink-0 flex items-center gap-2">
              {/* PM MODE — 原本是搜尋列下方那條橫幅，改成頁首的開關。
                  開關兩種狀態都看得到，所以關掉之後不用再靠 ⌘⇧P 才找得回來。 */}
              {pmUnlocked && (
                <button
                  onClick={togglePm}
                  title={pmMode
                    ? "PM MODE 開啟中：顯示 Linear 票號、負責人與拖曳，外部使用者看不到。點一下關閉（⌘⇧P）"
                    : "PM MODE 已關閉：目前看到的就是外部使用者的畫面。點一下開啟（⌘⇧P）"}
                  aria-pressed={pmMode}
                  className={`flex items-center gap-1.5 mono text-[10.5px] font-semibold uppercase tracking-wider px-2 py-1 rounded transition-colors ${
                    pmMode ? "tint-fix" : "bg-surface border border-border text-fg3 hover:text-fg2"}`}
                >
                  <span className="w-1.5 h-1.5 rounded-full"
                        style={pmMode
                          ? { background: "currentColor" }
                          : { boxShadow: "inset 0 0 0 1px currentColor" }} />
                  PM MODE
                </button>
              )}
              {/* role badge */}
              {(() => {
                const meta = {
                  admin:  { label: "ADMIN",  cls: "tint-hot" },
                  editor: { label: "EDITOR", cls: "tint-vf" },
                  pm:     { label: "PM",     cls: "tint-new" },
                  qa:     { label: "QA",     cls: "tint-fix" },
                  viewer: { label: "VIEWER", cls: "bg-surface border border-border text-fg3" },
                }[role] || { label: "—", cls: "bg-surface border border-border text-fg3" };
                return (
                  <span className={`${meta.cls} mono text-[10.5px] font-semibold uppercase tracking-wider px-2 py-1 rounded`}
                        title={profile?.email}>
                    {meta.label}
                  </span>
                );
              })()}
              <LangToggle lang={lang} onToggle={toggleLang} />
              <ThemeToggle theme={theme} onToggle={toggleTheme} />
              <button
                onClick={handleLogout}
                title={`${t("logoutPrefix")} ${profile?.email || ""}`.trim()}
                aria-label={t("logoutPrefix")}
                className="flex items-center justify-center w-9 h-9 rounded-lg border border-border bg-surface hover:border-borderStrong text-fg2 hover:text-fg transition-colors"
              >
                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-4 h-4">
                  <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
                  <polyline points="16 17 21 12 16 7"/>
                  <line x1="21" y1="12" x2="9" y2="12"/>
                </svg>
              </button>
            </div>
          </header>

          {/* tab bar（手機版是底部膠囊鈕，見 TabBar） */}
          <div className="lg:mb-5">
            <TabBar
              active={activeTab}
              onChange={setActiveTab}
              upcomingCount={getNextData().length}
              roadmapCount={getNextData().reduce((n, c) => n + ((c.issues || []).length), 0)}
              historyCount={counts.product.all}
              pmUnlocked={pmUnlocked}
              isAdmin={isAdmin}
              canSeeQa={canSeeQa}
              draftCount={draftCount}
              relayUnread={relayUnread}
            />
          </div>

          {/* search bar — shared by both tabs */}
          <SearchBar query={query} setQuery={setQuery} inputRef={inputRef} t={t} />
          {activeTab === "history" && (
            <KeywordSummary query={query} hits={keywordHits} onJump={jumpTo} />
          )}

          {activeTab === "upcoming" && (
            <UpcomingReleases pmMode={pmMode} query={query} canCopy={pmUnlocked} pmUnlocked={pmUnlocked} onReloadData={triggerReload} />
          )}

          {activeTab === "roadmap" && (
            <RoadmapChart pmMode={pmMode} query={query} pmUnlocked={pmUnlocked} onReloadData={triggerReload} />
          )}

          {activeTab === "schedule" && pmUnlocked && (
            <ScheduleBoard userId={profile?.id} canEdit={canEditEvents} isAdmin={isAdmin} />
          )}

          {activeTab === "admin" && isAdmin && (
            <AdminPanel currentUserId={profile?.id} />
          )}

          {activeTab === "qa" && canSeeQa && (
            <div className="mt-5"><QaBoard /></div>
          )}

          {activeTab === "draft" && pmUnlocked && (
            <ReleaseDraftPanel />
          )}

          {activeTab === "relay" && pmUnlocked && (
            <TgRelayPanel />
          )}

          {activeTab === "history" && (
            <>
              {/* mobile filters */}
              <div className="mt-5">
                <MobileFilters counts={counts} filters={filters} setFilters={setFilters} />
              </div>

              {/* chips */}
              <div className="mt-5">
                <QuickChips
                  value={chip}
                  onChange={setChip}
                  sort={sort}
                  setSort={setSort}
                  visibleCount={filtered.length}
                />
              </div>

              {/* cards */}
              <div className="mt-5 flex flex-col gap-3">
                {filtered.length === 0 ? (
                  <EmptyState query={query} t={t} />
                ) : filtered.map(r => (
                  <VersionCard
                    key={r.id}
                    r={r}
                    query={query}
                    open={openIds.has(r.id)}
                    onToggle={() => toggle(r.id)}
                    flash={flashId === r.id}
                    cardRef={(el) => { cardRefs.current[r.id] = el; }}
                    pmMode={pmMode}
                  />
                ))}
              </div>
            </>
          )}

          {/* footer */}
          <footer className="mt-16 pt-6 hair text-[11.5px] text-fg3 mono flex flex-wrap items-center gap-x-3 gap-y-1">
            <span>last sync <span className="text-fg2">{counts.lastUpdate}</span></span>
            <span>·</span>
            <span>source: internal release tracker</span>
            <span>·</span>
            <span>press <span className="kbd" style={{padding:"2px 5px"}}>⌘K</span> to search</span>
          </footer>
        </div>
      </main>

    </div>
  );
}

ReactDOM.createRoot(document.getElementById("root")).render(<App />);
