/* global React */
// Shared Worship Planner component — themed via CSS variables on the wrapper.
// Theme contract (CSS vars set by parent):
//   --wp-bg, --wp-surface, --wp-surface-2, --wp-ink, --wp-ink-soft, --wp-ink-faint,
//   --wp-rule, --wp-accent, --wp-accent-soft, --wp-season (set per Sunday),
//   --wp-radius, --wp-shadow,
//   --wp-font-display, --wp-font-body, --wp-font-mono, --wp-font-tabular
// Theme variants:
//   data-wp-theme="reverent" | "editorial" | "utility"

const { useState, useMemo, useRef, useEffect } = React;

const PASSAGES = ["ot", "psalm", "epistle", "gospel"];

// ---------- contemporary theme sidecar merge ----------
// Runs ONCE at load, after window.SUNDAYS (data-contemporary.js) and
// window.SONG_THEMES (data-song-themes.js) both exist. Stamps song.themes
// (and song.comm) onto every contemporary song object in place, joining by
// ccli (title|artist for the ccli:null church original). The sidecar is the
// single source of truth for tags; songs not in it are left untouched.
(function mergeSongThemes() {
  if (!window.SUNDAYS || !window.SONG_THEMES) return;
  const byCcli = new Map();
  const byTitleArtist = new Map();
  for (const s of window.SONG_THEMES.songs) {
    if (s.ccli) byCcli.set(String(s.ccli), s);
    else if (s.title && s.artist) byTitleArtist.set(s.title + "|" + s.artist, s);
  }
  const stamp = (song) => {
    if (!song) return;
    let meta = song.ccli ? byCcli.get(String(song.ccli)) : null;
    if (!meta && song.title && song.artist) meta = byTitleArtist.get(song.title + "|" + song.artist);
    if (!meta) return;
    song.themes = meta.themes || [];
    if (meta.comm) song.comm = true;
    if (meta.youtube) song.youtube = meta.youtube;            // durable YouTube search deep-link
    if (meta.praisecharts) song.praisecharts = meta.praisecharts; // PraiseCharts search deep-link
  };
  for (const sun of window.SUNDAYS) {
    if (!sun.music) continue;
    for (const passage of ["ot", "psalm", "epistle", "gospel"]) {
      const c = sun.music[passage] && sun.music[passage].contemporary;
      if (!c) continue;
      for (const bucket of ["strong", "thematic", "loose"]) {
        (c[bucket] || []).forEach(stamp);
      }
    }
    (sun.crossCuttingContemporary || []).forEach(stamp);
  }
})();

// ---------- usage tracking (last-selected, per text / per tune) ----------
// We derive "last selected" from the existing per-day selection state, so the
// service plan IS the source of truth — checking a hymn marks the day's date
// as the last time that text (and that tune) was used. No new persistence.

function textKey(title) {
  // Normalize for matching: strip parentheticals (e.g. "(Diadem)" tune tag),
  // drop punctuation, collapse whitespace. So "All Hail the Power of Jesus'
  // Name" and "All Hail the Power of Jesus' Name (Diadem)" share a key.
  return (title || "").
  toLowerCase().
  replace(/\([^)]*\)/g, " ").
  replace(/[^\w\s]/g, " ").
  replace(/\s+/g, " ").
  trim();
}
function tuneKeyOf(t) {return (t || "").toLowerCase().trim();}

// Are two hymn titles the SAME hymn (harmless first-line / bilingual variant)
// or genuinely DIFFERENT hymns? Used to decide collisions on a "<HYMNAL> <NUM>"
// key: a harmless variant ("Amazing Grace" vs "Amazing Grace! How Sweet the
// Sound") is the same hymn; a genuinely different title at the same number means
// one of them has the wrong number (a transcription slip). textKey already drops
// punctuation/parentheticals, so equality or one-contains-the-other = same hymn.
function sameHymnText(a, b) {
  const ka = textKey(a), kb = textKey(b);
  if (!ka || !kb) return true; // nothing to compare — don't treat as a conflict
  if (ka === kb) return true;
  // First-line / extended-title / bilingual variant: one title contains the other
  // (e.g. "When We Are Living" ⊂ "Pues si vivimos / When We Are Living").
  const shorter = ka.length <= kb.length ? ka : kb, longer = ka.length <= kb.length ? kb : ka;
  if (shorter.length >= 6 && longer.indexOf(shorter) >= 0) return true;
  // Otherwise compare word sets — tolerates minor spelling diffs ("Hand"/"Hands")
  // but separates genuinely different hymns (a wrong-number transcription slip).
  const wa = ka.split(" "), wb = kb.split(" "), sb = new Set(wb);
  let inter = 0; const counted = new Set();
  wa.forEach((w) => { if (sb.has(w) && !counted.has(w)) { inter++; counted.add(w); } });
  const uni = new Set([...wa, ...wb]).size;
  return uni > 0 && inter / uni >= 0.5;
}

function tuneFor(h) {
  if (!h) return null;
  if (h.tune) return h.tune;
  if (h.hymnal && h.num != null) {
    return window.HYMN_TUNES && window.HYMN_TUNES[`${h.hymnal} ${h.num}`] || null;
  }
  return null;
}

// ---------- orchestration badge ----------
// Joins a hymn to window.ORCHESTRATION on "<HYMNAL> <NUM>" (same key as tunes).
function orchFor(h) {
  if (!h || h.ccli || !h.hymnal || h.num == null) return null;
  return window.ORCHESTRATION && window.ORCHESTRATION[`${h.hymnal} ${h.num}`] || null;
}

// Completeness ranking (higher = more complete). Used to decide whether the
// tune's best orchestration (from another text) actually beats this hymn's own.
const ORCH_RANK = { full: 6, near: 5, almost: 4, less: 3, satb_only: 2, minimal: 1, taize_iv: 5, taize_i: 4 };
// Tone bucket for color coding the badge.
function orchTone(code) {
  if (code === "full") return "good";
  if (code === "near" || code === "almost") return "mid";
  if (code === "taize_i" || code === "taize_iv") return "taize";
  return "low"; // less / satb_only / minimal
}
// Short label for the pill face (tooltip carries the full wording).
const ORCH_SHORT = {
  full: "Fully orch.", near: "Near orch.", almost: "Almost orch.", less: "Partly orch.",
  satb_only: "SATB only", minimal: "Minimal orch.", taize_i: "Taizé", taize_iv: "Taizé",
};
// Sentence for the hymn's own orchestration (badge tooltip).
function orchTip(rec) {
  switch (rec.code) {
    case "full": return "Fully orchestrated — all parts in hand.";
    case "near":
    case "almost": {
      const miss = rec.missing && rec.missing.length ?
      ` Still needs ${rec.missing.join(", ")}.` : "";
      return `${rec.label} orchestration.${miss}`;
    }
    case "satb_only": return "SATB choral score only — no instrumental parts orchestrated.";
    case "minimal": return "Minimal / source material only.";
    case "less": return "Less complete — several parts still missing.";
    case "taize_i": return "Taizé — instrumental parts.";
    case "taize_iv": return "Taizé — instruments plus vocal solos.";
    default: return rec.label;
  }
}

function OrchBadge({ hymn }) {
  const rec = orchFor(hymn);
  if (!rec) return null;
  const tone = orchTone(rec.code);
  // Re-text opportunity: the tune is already orchestrated MORE completely for a
  // DIFFERENT text, so this hymn could be quickly re-texted onto it.
  const ownRank = ORCH_RANK[rec.code] || 0;
  const tuneRank = ORCH_RANK[rec.tuneBest] || 0;
  const retext =
  rec.tuneOrch && rec.tuneBestFrom &&
  rec.tuneBestFrom !== rec.title &&
  tuneRank > ownRank && tuneRank >= ORCH_RANK.almost;
  return (
    <span className="wp-orch-group">
      <span className={`wp-orch wp-orch-${tone}`} title={orchTip(rec)}>
        <svg className="wp-orch-note" width="9" height="9" viewBox="0 0 9 9" aria-hidden="true">
          <path d="M6.4 1 L6.4 6.1" stroke="currentColor" strokeWidth="1" fill="none" strokeLinecap="round" />
          <ellipse cx="5" cy="6.4" rx="1.5" ry="1.2" fill="currentColor" />
        </svg>
        {ORCH_SHORT[rec.code] || rec.label}
      </span>
      {retext &&
      <span className="wp-orch wp-orch-retext"
      title={`Tune already orchestrated (${rec.tuneBestLabel}) — from “${rec.tuneBestFrom}.” Could be re-texted onto this hymn.`}>
          <svg className="wp-orch-cycle" width="9" height="9" viewBox="0 0 9 9" aria-hidden="true">
            <path d="M7.2 3.2 A3 3 0 1 0 7.5 5.4" stroke="currentColor" strokeWidth="1" fill="none" strokeLinecap="round" />
            <path d="M5.2 2.6 L7.4 3 L7 0.9" stroke="currentColor" strokeWidth="1" fill="none" strokeLinecap="round" strokeLinejoin="round" />
          </svg>
          tune
        </span>}
    </span>);

}

// Returns { byText, byTune, byCcli } — each maps key → array of usage
// records { dayId, dateStr, label, ts } sorted newest-first.
function buildUsageIndex(selectedByDay, hymnLookup, sundays) {
  const byText = {};
  const byTune = {};
  const byCcli = {};
  for (const sun of sundays) {
    const sel = selectedByDay[sun.id];
    if (!sel || sel.size === 0) continue;
    const look = hymnLookup[sun.id] || {};
    const ts = Date.parse(sun.date) || 0;
    for (const k of sel) {
      const ent = look[k];if (!ent) continue;
      const h = ent.hymn;
      const rec = { dayId: sun.id, dateStr: sun.date, label: sun.day, ts };
      if (h.title) {
        const tk = textKey(h.title);
        if (tk) (byText[tk] = byText[tk] || []).push(rec);
      }
      const tune = tuneFor(h);
      if (tune) {
        const un = tuneKeyOf(tune);
        if (un) (byTune[un] = byTune[un] || []).push(rec);
      }
      if (h.ccli) (byCcli[String(h.ccli)] = byCcli[String(h.ccli)] || []).push(rec);
    }
  }
  // Sort newest-first; "newest" by service date — future-scheduled Sundays
  // come before older past ones (which is what a planner cares about: "when
  // is this on the schedule, past or future?").
  const sort = (arr) => arr.sort((a, b) => b.ts - a.ts);
  Object.values(byText).forEach(sort);
  Object.values(byTune).forEach(sort);
  Object.values(byCcli).forEach(sort);
  return { byText, byTune, byCcli };
}

// Pull the most-recent OTHER use for a hymn (excluding the current Sunday,
// since "selected here" is already conveyed by the checkmark).
function hymnUsage(usage, hymn, currentDayId) {
  if (!usage || !hymn) return { text: null, tune: null, ccli: null };
  const tk = textKey(hymn.title);
  const tune = tuneFor(hymn);
  const un = tune ? tuneKeyOf(tune) : null;
  const ccli = hymn.ccli ? String(hymn.ccli) : null;
  const pick = (arr) => (arr || []).find((r) => r.dayId !== currentDayId) || null;
  return {
    text: pick(usage.byText[tk]),
    tune: un ? pick(usage.byTune[un]) : null,
    ccli: ccli ? pick(usage.byCcli[ccli]) : null
  };
}

// "Nov 30, 2025" -> "Nov 30"
function shortDate(s) {return (s || "").replace(/,\s*\d+\s*$/, "");}

// ---------- hymn corpus (for autocomplete) ----------
// Collects every hymn the planner knows about — from the per-Sunday data,
// from the supplemental library, and enriched by HYMN_TUNES — into a single
// deduped list. Built once, on first access.
let _corpusCache = null;
function getHymnCorpus() {
  if (_corpusCache) return _corpusCache;
  const seen = new Map();
  // `authoritative` = this hymn came from the curated HYMN_LIBRARY, which owns
  // the "<HYMNAL> <NUM>" → title identity. On a same-key collision where the
  // titles are genuinely DIFFERENT hymns, the library title wins — so a plan
  // entry carrying a wrong number (e.g. a phantom "Lift Up Your Eyes to the
  // Mountain" squatting on UMH:519) can't shadow the real hymn in autocomplete.
  // Harmless first-line variants keep the (already-present) plan title.
  const add = (h, authoritative) => {
    if (!h || !h.title) return;
    // Don't autocomplete contemporary songs in the hymn flow.
    if (h.ccli && !h.hymnal) return;
    const key = h.hymnal && h.num != null ?
    `${h.hymnal}:${h.num}` :
    `t:${textKey(h.title)}`;
    const tune = h.tune || (
    h.hymnal && h.num != null ?
    window.HYMN_TUNES && window.HYMN_TUNES[`${h.hymnal} ${h.num}`] :
    null) ||
    null;
    const entry = {
      title: h.title,
      hymnal: h.hymnal || null,
      num: h.num != null ? String(h.num) : null,
      tune,
      alt: Array.isArray(h.alt) ? h.alt : [],
      authoritative: !!authoritative
    };
    const existing = seen.get(key);
    if (!existing) { seen.set(key, entry); return; }
    if (authoritative && !existing.authoritative) {
      // Library is authoritative on identity. Genuinely different hymn at this
      // number → library title wins (fixes a wrong plan num). Same hymn (first-
      // line variant) → keep the plan title, just mark it authoritative and fill
      // a missing tune.
      if (!sameHymnText(existing.title, entry.title)) { seen.set(key, entry); return; }
      existing.authoritative = true;
      if (entry.tune && !existing.tune) existing.tune = entry.tune;
      return;
    }
    // Non-authoritative collision (or two library rows): prefer the one with a tune.
    if (entry.tune && !existing.tune) seen.set(key, entry);
  };
  for (const sun of window.SUNDAYS || []) {
    (sun.crossCutting || []).forEach((h) => add(h, false));
    for (const p of PASSAGES) {
      const m = sun.music && sun.music[p];
      if (!m) continue;
      ["strong", "thematic", "loose"].forEach((b) => (m[b] || []).forEach((h) => add(h, false)));
    }
  }
  (window.HYMN_LIBRARY || []).forEach((h) => add(h, true));
  _corpusCache = Array.from(seen.values()).
  sort((a, b) => a.title.localeCompare(b.title));
  return _corpusCache;
}

function searchHymnCorpus(query, max = 8) {
  const q = (query || "").trim().toLowerCase();
  if (q.length < 2) return [];
  const corpus = getHymnCorpus();
  const starts = [];
  const contains = [];
  for (const h of corpus) {
    const t = h.title.toLowerCase();
    const matchTitle = t.startsWith(q) ? 0 : t.includes(q) ? 1 : -1;
    let matchAlt = -1;
    if (matchTitle < 0 && h.alt && h.alt.length) {
      for (const a of h.alt) {
        const at = a.toLowerCase();
        if (at.startsWith(q)) {matchAlt = 0;break;}
        if (at.includes(q)) {matchAlt = 1;}
      }
    }
    const m = matchTitle >= 0 ? matchTitle : matchAlt;
    if (m === 0) starts.push(h);else
    if (m === 1) contains.push(h);
    if (starts.length >= max) break;
  }
  return [...starts, ...contains].slice(0, max);
}

let _contempCorpus = null;
function getContempCorpus() {
  if (_contempCorpus) return _contempCorpus;
  const seen = new Set();
  const corpus = [];
  for (const s of (window.SUNDAYS || [])) {
    const m = s.music || {};
    for (const pk of Object.keys(m)) {
      const c = m[pk].contemporary || {};
      for (const bk of ["strong", "thematic", "loose"]) {
        for (const song of (c[bk] || [])) {
          const key = song.ccli ? String(song.ccli) : song.title.toLowerCase();
          if (seen.has(key)) continue;
          seen.add(key);
          corpus.push({ title: song.title, ccli: song.ccli || "", artist: song.artist || "" });
        }
      }
    }
    for (const song of (s.crossCuttingContemporary || [])) {
      const key = song.ccli ? String(song.ccli) : song.title.toLowerCase();
      if (seen.has(key)) continue;
      seen.add(key);
      corpus.push({ title: song.title, ccli: song.ccli || "", artist: song.artist || "" });
    }
  }
  corpus.sort((a, b) => a.title.localeCompare(b.title));
  _contempCorpus = corpus;
  return corpus;
}
function searchContempCorpus(query, max) {
  max = max || 8;
  const q = (query || "").trim().toLowerCase();
  if (q.length < 2) return [];
  const corpus = getContempCorpus();
  const starts = [], contains = [];
  for (const s of corpus) {
    const t = s.title.toLowerCase();
    if (t.startsWith(q)) starts.push(s);
    else if (t.includes(q)) contains.push(s);
    if (starts.length >= max) break;
  }
  return [...starts, ...contains].slice(0, max);
}

// Relative position to the currently-viewed Sunday. We don't show "X weeks
// ago" — too noisy — but we DO color past vs upcoming differently so the
// planner can tell at a glance whether the repeat would be a future
// double-book or a recent reuse.
function relTo(rec, currentTs) {
  if (!rec || !currentTs) return "other";
  if (rec.ts > currentTs) return "upcoming";
  if (rec.ts < currentTs) return "past";
  return "other";
}

// ---------- export ----------

function buildHymnRows(sunday, kind) {
  const rows = [];
  const passageMusic = (p) => {
    const src = sunday.music?.[p];
    if (!src) return null;
    return kind === "contemporary" ? src.contemporary : src;
  };
  for (const p of PASSAGES) {
    const m = passageMusic(p);
    if (!m) continue;
    for (const bucket of ["strong", "thematic", "loose"]) {
      for (const h of m[bucket] || []) {
        rows.push({
          date: sunday.date,
          day: sunday.day,
          season: sunday.season,
          passage: window.PASSAGE_LABELS?.[p] || p,
          reading: sunday.readings?.[p]?.ref || "",
          bucket,
          id: kind === "contemporary" ? `CCLI ${h.ccli || ""}` : `${h.hymnal || ""} ${h.num || ""}`.trim(),
          title: h.title || "",
          artist: h.artist || "",
          tier: h.tier || "",
          note: h.note || ""
        });
      }
    }
  }
  const cross = kind === "contemporary" ? sunday.crossCuttingContemporary : sunday.crossCutting;
  for (const h of cross || []) {
    rows.push({
      date: sunday.date,
      day: sunday.day,
      season: sunday.season,
      passage: "Cross-cutting",
      reading: (h.passages || []).map((p) => window.PASSAGE_SHORT?.[p] || p).join(" + "),
      bucket: h.feature ? "feature" : "cross",
      id: kind === "contemporary" ? `CCLI ${h.ccli || ""}` : `${h.hymnal || ""} ${h.num || ""}`.trim(),
      title: h.title || "",
      artist: h.artist || "",
      tier: h.tier || "",
      note: h.note || ""
    });
  }
  return rows;
}

function csvEscape(v) {
  const s = String(v ?? "");
  if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
  return s;
}

// Anthem library rows — the choral-anthem track flattened for export. Every
// track column is included (semi/comp/cct labeled in the passage) so the dump
// is complete; picks keep their ranked order. Carries an `owned` tag.
function buildAnthemRows(sunday) {
  const entry = (window.ANTHEMS || {})[sunday.id];
  if (!entry) return [];
  const TRACK_SUFFIX = { semi: " (semi-cont.)", comp: " (complementary)", cct: " (CCT)" };
  const rows = [];
  for (const col of entry.columns || []) {
    const suffix = col.track && col.track !== "standard" ? TRACK_SUFFIX[col.track] || "" : "";
    const plabel = (window.PASSAGE_LABELS?.[col.passage] || col.passage) + suffix;
    const reading = sunday.readings?.[col.passage]?.ref || "";
    for (const p of col.picks || []) {
      rows.push({
        date: sunday.date,
        day: sunday.day,
        season: sunday.season,
        passage: plabel,
        reading: p.ref || reading,
        bucket: p.bucket,
        id: p.voicing || "",
        title: p.title || "",
        artist: p.composer || "",
        tier: "",
        owned: p.owned ? "owned" : "",
        note: p.note || ""
      });
    }
  }
  return rows;
}

function runExport({ tabs, sundays, format, scope, plan }) {
  if (scope === "plan" && plan) return runPlanExport({ sundays, format, plan });
  if (scope === "worksheet" && plan) return runWorksheetExport({ sundays, plan });
  const sections = [];
  for (const tabId of tabs) {
    const tab = window.PLANNER_TABS.find((t) => t.id === tabId);
    if (!tab) continue;
    const ready = tabId === "hymns" || tabId === "contemporary" || tabId === "anthems";
    if (!ready) {sections.push({ tab, rows: [], placeholder: true });continue;}
    const kind = tabId === "contemporary" ? "contemporary" : "hymn";
    const rows = tabId === "anthems" ?
      sundays.flatMap((s) => buildAnthemRows(s).map((r) => ({ section: tab.label, ...r }))) :
      sundays.flatMap((s) => buildHymnRows(s, kind).map((r) => ({ section: tab.label, ...r })));
    sections.push({ tab, rows, placeholder: false });
  }

  const dateRange = sundays.length ?
  `${sundays[0].date.replace(/[, ]+/g, "-")}_to_${sundays[sundays.length - 1].date.replace(/[, ]+/g, "-")}` :
  "range";
  let blob, filename;

  if (format === "csv") {
    const cols = ["section", "date", "day", "season", "passage", "reading", "bucket", "id", "title", "artist", "tier", "owned", "note"];
    const lines = [cols.join(",")];
    for (const sec of sections) {
      if (sec.placeholder) {
        lines.push(`# ${sec.tab.label} — not included in the library export yet (headers only)`);
        continue;
      }
      for (const r of sec.rows) lines.push(cols.map((c) => csvEscape(r[c])).join(","));
    }
    blob = new Blob([lines.join("\n")], { type: "text/csv" });
    filename = `worship-planner_${dateRange}.csv`;
  } else if (format === "json") {
    const out = sections.map((sec) => ({ section: sec.tab.label, placeholder: sec.placeholder, rows: sec.rows }));
    blob = new Blob([JSON.stringify(out, null, 2)], { type: "application/json" });
    filename = `worship-planner_${dateRange}.json`;
  } else {
    const lines = [];
    lines.push(`Worship Planner — Export`);
    lines.push(`${sundays[0]?.date} through ${sundays[sundays.length - 1]?.date}`);
    lines.push("");
    for (const sec of sections) {
      lines.push(`══ ${sec.tab.label} ══`);
      if (sec.placeholder) {lines.push("  (not included in the library export yet)");lines.push("");continue;}
      let lastDay = "",lastPassage = "";
      for (const r of sec.rows) {
        if (r.day !== lastDay) {lines.push("");lines.push(`${r.date} · ${r.day}`);lastDay = r.day;lastPassage = "";}
        if (r.passage !== lastPassage) {lines.push(`  ${r.passage}${r.reading ? ` — ${r.reading}` : ""}`);lastPassage = r.passage;}
        const tail = [r.id, r.title, r.artist].filter(Boolean).join(" · ");
        lines.push(`    [${r.bucket}] ${tail}${r.owned ? " \u2605" : ""}`);
      }
      lines.push("");
    }
    blob = new Blob([lines.join("\n")], { type: "text/plain" });
    filename = `worship-planner_${dateRange}.txt`;
  }

  downloadBlob(blob, filename);
}

function downloadBlob(blob, filename) {
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;a.download = filename;
  document.body.appendChild(a);a.click();a.remove();
  setTimeout(() => URL.revokeObjectURL(url), 1000);
}

// ---------- "my plan" export (the user's actual selections) ----------

function buildPlanRows(sunday, sel, lookup, notes, customOrder) {
  const order = { ot: 0, psalm: 1, epistle: 2, gospel: 3, general: 4 };
  const custom = customOrder || [];
  const orderIdx = (k) => { const i = custom.indexOf(k); return i < 0 ? Infinity : i; };
  const rows = [];
  for (const k of sel || []) {
    const ent = (lookup || {})[k];
    if (!ent || !ent.hymn) continue;
    const h = ent.hymn;
    const serviceId = serviceIdFor(k, h, ent.serviceId);
    const passages = ent.passages && ent.passages.length ? ent.passages : [ent.passage || "general"];
    rows.push({
      date: sunday.date, day: sunday.day, season: sunday.season,
      service: SERVICE_LABEL(serviceId),
      passage: passages.map((p) => window.PASSAGE_SHORT?.[p] || p).join(" + "),
      id: h.ccli ? `CCLI ${h.ccli}` : `${h.hymnal || ""}${h.num ? ` ${h.num}` : ""}`.trim(),
      title: h.title || "",
      tune: h.ccli ? "" : tuneFor(h) || "",
      note: notes && notes[k] || h.note || "",
      _userNote: (notes && notes[k]) || "",
      _composer: h._composer || "",
      _artist: h._artist || "",
      _year: h._year || "",
      _svc: serviceId,
      _custom: orderIdx(k),
      _ord: order[passages[0]] ?? 99
    });
  }
  // Keep services grouped (in canonical SERVICES order); within each service,
  // honor the planner's manual order, then default reading order.
  const svcRank = {}; SERVICES.forEach((s, i) => { svcRank[s.id] = i; });
  rows.sort((a, b) => {
    const sd = (svcRank[a._svc] ?? 99) - (svcRank[b._svc] ?? 99);
    if (sd) return sd;
    if (a._custom !== b._custom) return a._custom - b._custom;
    return a._ord - b._ord;
  });
  rows.forEach((r) => { delete r._ord; delete r._custom; delete r._svc; });
  return rows;
}

function runPlanExport({ sundays, format, plan }) {
  const prOrder = (window.PRAYER_ELEMENTS || []).map((e) => e.id);
  const days = sundays.map((s) => {
    const pp = (plan.prayerPlanByDay || {})[s.id] || {};
    const prayers = Object.values(pp)
      .filter((d) => window.resolvePrayerPick && window.resolvePrayerPick(s, d))
      .sort((a, b) => prOrder.indexOf(a.element) - prOrder.indexOf(b.element));
    return {
      sunday: s,
      rows: buildPlanRows(
        s,
        plan.selectedByDay[s.id],
        plan.hymnLookup[s.id],
        (plan.notesByDay || {})[s.id],
        (plan.planOrderByDay || {})[s.id]
      ),
      prayers,
    };
  });
  const dateRange = sundays.length ?
  `${sundays[0].date.replace(/[, ]+/g, "-")}_to_${sundays[sundays.length - 1].date.replace(/[, ]+/g, "-")}` :
  "range";
  let blob, filename;

  if (format === "csv") {
    const cols = ["date", "day", "season", "service", "passage", "id", "title", "tune", "note"];
    const lines = [cols.join(",")];
    for (const d of days) {
      for (const r of d.rows) lines.push(cols.map((c) => csvEscape(r[c])).join(","));
      for (const desc of d.prayers) {
        const r = window.resolvePrayerPick(d.sunday, desc);
        const text = r.cells.map((c) => c.lines.map((l) => l.replace(/\*\*/g, "")).join(" / ")).join("  ||  ");
        const row = { date: d.sunday.date, day: d.sunday.day, season: d.sunday.season,
          service: "Prayers", passage: r.meta.label, id: r.meta.single ? "" : desc.register,
          title: text, tune: "", note: "" };
        lines.push(cols.map((c) => csvEscape(row[c])).join(","));
      }
    }
    blob = new Blob([lines.join("\n")], { type: "text/csv" });
    filename = `worship-plan_${dateRange}.csv`;
  } else if (format === "json") {
    const out = days.map((d) => ({
      id: d.sunday.id, date: d.sunday.date, day: d.sunday.day, season: d.sunday.season,
      items: d.rows,
      prayers: d.prayers.map((desc) => {
        const r = window.resolvePrayerPick(d.sunday, desc);
        return {
          element: desc.element, label: r.meta.label,
          register: r.meta.single ? null : desc.register,
          perms: desc.perms,
          text: r.cells.map((c) => c.lines.map((l) => l.replace(/\*\*/g, ""))),
        };
      }),
    }));
    blob = new Blob([JSON.stringify(out, null, 2)], { type: "application/json" });
    filename = `worship-plan_${dateRange}.json`;
  } else {
    const lines = [];
    lines.push("Worship Planner — My Plan");
    lines.push(`${sundays[0]?.date} through ${sundays[sundays.length - 1]?.date}`);
    for (const d of days) {
      lines.push("");
      lines.push(`${d.sunday.date} · ${d.sunday.day}`);
      if (d.rows.length === 0 && d.prayers.length === 0) {lines.push("  (nothing selected)");continue;}
      let lastSvc = "";
      for (const r of d.rows) {
        if (r.service !== lastSvc) {lines.push(`  ${r.service}`);lastSvc = r.service;}
        const tail = [r.id, r.title, r.tune].filter(Boolean).join(" · ");
        lines.push(`    [${r.passage}] ${tail}`);
        if (r.note) lines.push(`        — ${r.note}`);
      }
      if (d.prayers.length) {
        lines.push("  Prayers");
        for (const desc of d.prayers) {
          window.prayerPickToLines(d.sunday, desc, "    ").forEach((l) => lines.push(l));
        }
      }
    }
    blob = new Blob([lines.join("\n")], { type: "text/plain" });
    filename = `worship-plan_${dateRange}.txt`;
  }
  downloadBlob(blob, filename);
}

// ---------- "weekly grid" export (.xlsx worksheet, mirrors the hand-made sheet) ----------
// One row per Sunday: Date | Scripture | Traditional Hymns | Other Music/Art | Contemporary Songs.
// Hymns + Contemporary fill from the plan; the Other Music/Art column is a labeled
// scaffold the planner fills by hand (Anthem / Solo / Ensemble / Handbells / Artwork).
// The Scripture column shows OT / Canticle (Psalm) / Epistle / Gospel; the canticle
// line is enriched from the UMH Psalter (e.g. "Psalm 121 - UMH 844 - 2 responses").
function runWorksheetExport({ sundays, plan }) {
  const PASS = ["ot", "psalm", "epistle", "gospel"];
  const isContemp = (r) => /contemp/i.test(r.service || "") || /^CCLI/i.test(r.id || "");
  // BibleGateway combined-passage link (CEB): one click opens all readings together.
  // Combined-passage link, identical to the "Look up Scripture" bar (NRSVue): one
  // click on the Scripture cell opens all of that Sunday's readings together.
  const cebLink = (refs) => {
    if (!refs.length) return null;
    // BibleGateway wants plain hyphens, not en/em dashes, in verse ranges.
    const q = refs.join("; ").replace(/[\u2012-\u2015]/g, "-");
    return "https://www.biblegateway.com/passage/?search=" + encodeURIComponent(q) + "&version=NRSVUE";
  };
  // Canticle line: psalm reference + its UMH Psalter setting + response count,
  // e.g. "Psalm 121 - UMH 844 - 2 responses". Falls back to the bare ref when the
  // Sunday has no Psalter entry (or it's a canticle with no numbered setting).
  const psalmLine = (s) => {
    const ref = s.readings && s.readings.psalm && s.readings.psalm.ref;
    const ent = ((window.PSALTER || {})[s.id] || {}).base;
    const e = ent && ent[0];
    if (!e) return ref || "";
    const parts = [e.ref || ref];
    if (e.umh && e.umh.length) parts.push("UMH " + e.umh.join("/"));
    if (e.responses) parts.push(e.responses + (e.responses === 1 ? " response" : " responses"));
    return parts.filter(Boolean).join(" - ");
  };
  const worksheetDay = (s) => {
    const m = s.id.match(/^(?:[bc]-)?proper-(\d+)$/);
    if (!m) return s.day;
    const prefix = s.id.startsWith("b-") ? "b-" : s.id.startsWith("c-") ? "c-" : "";
    const pent = (window.SUNDAYS || []).find(x => x.id === (prefix ? prefix + "pentecost" : "pentecost"));
    if (!pent) return s.day;
    const weeks = Math.round((Date.parse(s.date) - Date.parse(pent.date)) / 604800000);
    const sfx = ["th","st","nd","rd"], v = weeks % 100;
    return (weeks + (sfx[(v - 20) % 10] || sfx[v] || sfx[0])) + " Sunday after Pentecost\n(Proper " + m[1] + ")";
  };
  const OTHER_SVC = new Set(["Choral Anthems", "Solo & Ensemble", "Handbells", "Organ/Piano", "Artwork"]);
  const MUSIC_CATS = [
    { svc: "Choral Anthems", label: "Anthem" },
    { svc: "Solo & Ensemble", label: "Solo" },
    { svc: "Handbells", label: "Handbells" },
    { svc: "Organ/Piano", label: "Organ" },
    { svc: "Artwork", label: "Artwork" }
  ];
  const rows = sundays.map((s) => {
    const pr = buildPlanRows(
      s, plan.selectedByDay[s.id], plan.hymnLookup[s.id],
      (plan.notesByDay || {})[s.id], (plan.planOrderByDay || {})[s.id]
    );
    const hymns = pr.filter((r) => !isContemp(r) && !OTHER_SVC.has(r.service))
      .map((r) => [r.id, r.title].filter(Boolean).join(" - ")).join("\n");
    const contemporary = pr.filter(isContemp).map((r) => r.title).join("\n");
    const orchestra = pr.filter((r) => !isContemp(r) && !OTHER_SVC.has(r.service))
      .map((r) => r.title + (r.tune ? " (" + r.tune + ")" : "")).join("\n");
    const dayHidden = (plan.hiddenByDay || {})[s.id];
    const hidSet = dayHidden instanceof Set ? dayHidden : new Set(dayHidden || []);
    const usedPass = PASS.filter(p => !hidSet.has(p));
    const refs = usedPass
      .map((p) => s.readings && s.readings[p] && s.readings[p].ref)
      .filter(Boolean);
    const displayLines = usedPass
      .map((p) => {
        const ref = s.readings && s.readings[p] && s.readings[p].ref;
        if (!ref) return null;
        return p === "psalm" ? psalmLine(s) : ref;
      })
      .filter(Boolean);
    const link = cebLink(refs);
    const scripture = link ?
    { t: displayLines.join("\n"), link } :
    displayLines.join("\n");
    const date = [s.date, worksheetDay(s)].filter(Boolean).join("\n");
    // Inject organ/piano from ORGAN_PLAN when no manual plan items exist
    const organPlan = (window.ORGAN_PLAN || {})[s.id];
    const music = MUSIC_CATS.map(({ svc, label }) => {
      if (svc === "Organ/Piano" && organPlan) {
        const manualItems = pr.filter((r) => r.service === svc);
        if (!manualItems.length) {
          const parts = [];
          if (organPlan.prelude) parts.push("Prelude: " + organPlan.prelude);
          if (organPlan.postlude) parts.push("Postlude: " + organPlan.postlude);
          return parts.length ? parts.join("\n") : null;
        }
      }
      const items = pr.filter((r) => r.service === svc);
      if (!items.length) return null;
      return items.map((r) => {
        if (r._composer) {
          const c = r._composer, p = c.split(/\s+/), last = /bach/i.test(c) ? c.trim() : (c.match(/^(arr\.\s*)/i) ? c.match(/^(arr\.\s*)/i)[1] + p[p.length-1] : p[p.length-1]);
          return label + ": " + r.title + " (" + last + ")" + (r._userNote ? " - " + r._userNote : "");
        }
        if (r._artist) return label + ": " + r.title + " (" + r._artist + ")" + (r._year ? " - " + r._year : "");
        if (/artwork/i.test(r.service) && r.note) {
          const np = r.note.split(" \u00b7 ");
          const artist = np[0] || "";
          const yr = np.find(p => /^\d{4}$/.test(p.trim())) || "";
          if (artist) return label + ": " + r.title + " (" + artist.trim() + ")" + (yr ? " - " + yr.trim() : "");
        }
        return label + ": " + r.title + (r._userNote || r.note ? " - " + (r._userNote || r.note) : "");
      }).join("\n");
    }).filter(Boolean).join("\n");
    return [date, scripture, hymns, music, contemporary, orchestra];
  });
  const headers = ["Date", "Scripture", "Traditional Hymns", "Other Music/Art", "Contemporary Songs", "Orchestra Pieces"];
  const legend = [
    "Date\nLectionary Day",
    "OT\nCanticle - UMH # - responses\nEpistle\nGospel",
    "UMH XXX - Hymn 1\nUMH XXX - Hymn 2\nUMH XXX - Hymn 3",
    "Anthem: Title (Composer) - with \u201coptional instrumentation\u201d\nSolo: Title (Composer)\nHandbells: Title (Composer)\nArtwork: Title (Artist), Year",
    "Song 1\nSong 2\nSong 3"
  ];
  const blob = window.makeWorksheetXlsx
    ? window.makeWorksheetXlsx({ headers, rows, sheetName: "Worship Plan" })
    : null;
  if (!blob) return;
  const dateRange = sundays.length ?
    `${sundays[0].date.replace(/[, ]+/g, "-")}_to_${sundays[sundays.length - 1].date.replace(/[, ]+/g, "-")}` :
    "range";
  downloadBlob(blob, `worship-worksheet_${dateRange}.xlsx`);
}

// ---------- backup & restore (all wp:* localStorage) ----------
function backupPlannerState() {
  const keys = {};
  for (let i = 0; i < localStorage.length; i++) {
    const k = localStorage.key(i);
    if (k && k.indexOf("wp:") === 0) keys[k] = localStorage.getItem(k);
  }
  const payload = {
    app: "worship-planner",
    version: 1,
    exported: new Date().toISOString(),
    keys
  };
  const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
  downloadBlob(blob, `worship-planner-backup_${new Date().toISOString().slice(0, 10)}.json`);
}

function restorePlannerState(file, onDone) {
  const reader = new FileReader();
  reader.onerror = () => onDone(false, "Couldn't read that file.");
  reader.onload = () => {
    try {
      const data = JSON.parse(reader.result);
      if (!data || data.app !== "worship-planner" || !data.keys || typeof data.keys !== "object") {
        onDone(false, "That doesn't look like a Worship Planner backup file.");
        return;
      }
      let n = 0;
      for (const k of Object.keys(data.keys)) {
        if (k.indexOf("wp:") !== 0 || typeof data.keys[k] !== "string") continue;
        try {localStorage.setItem(k, data.keys[k]);n++;} catch (_) {}
      }
      onDone(true, `Restored ${n} saved ${n === 1 ? "setting" : "settings"}. Reloading\u2026`);
      setTimeout(() => location.reload(), 700);
    } catch (_) {
      onDone(false, "Couldn't parse that file as JSON.");
    }
  };
  reader.readAsText(file);
}

// ---------- lectionary tracks (shared by Hymns / Contemporary / Solo) ----------
// Which track options a Sunday offers, derived from window.TRACK_READINGS
// (itself generated from the Year-A hymn plan's track labels). Semantics
// mirror hymn-plan.jsx's switcher exactly.

function trackOptsFor(sundayId) {
  const tr = (window.TRACK_READINGS || {})[sundayId];
  if (!tr) return [];
  const opts = [];
  const hasSemi = !!tr.semi, hasComp = !!tr.comp;
  if (hasSemi) opts.push(["semi", "Semi-continuous"]);
  if (hasComp) opts.push(["comp", "Complementary"]);
  return opts.length >= 2 ? opts : [];
}

function hasCctFor(sundayId) {
  const tr = (window.TRACK_READINGS || {})[sundayId];
  return !!(tr && tr.cct);
}

// Clamp a remembered lectionary track (semi/comp) to one this Sunday offers.
function clampTrack(track, opts) {
  const has = (k) => opts.some(([o]) => o === k);
  if (has(track)) return track;
  return opts[0] ? opts[0][0] : null;
}

// Parse a compound effective-track string into its dimensions.
//   "n/a" | "semi" | "comp" | "cct" | "cct-semi" | "cct-comp"  (+ optional "+alt")
// The trailing "+alt" flags the UMC alternate-psalm toggle: it is psalm-scoped
// (see readingForTrack / the psalm column) and rides on top of whatever
// lectionary track is selected.
function parseEffTrack(effTrack) {
  let e = effTrack || "n/a";
  let alt = false;
  if (e.length > 4 && e.slice(-4) === "+alt") { alt = true; e = e.slice(0, -4); }
  const isCct = e === "cct" || e.indexOf("cct-") === 0;
  const lect = (e === "semi" || e === "cct-semi") ? "semi"
    : (e === "comp" || e === "cct-comp") ? "comp" : null;
  return { isCct, lect, alt };
}

// Compose the compound effective track from the UI dimensions for a Sunday.
// altActive appends the psalm-scoped "+alt" flag (caller decides whether an
// alternate is actually available for the resolved track).
function effTrackFor(sundayId, track, rclVersion, altActive) {
  const opts = trackOptsFor(sundayId);
  const lect = opts.length ? clampTrack(track, opts) : null;
  const cct = hasCctFor(sundayId) && rclVersion === "cct";
  const base = cct ? (lect ? "cct-" + lect : "cct") : (lect || "n/a");
  return altActive ? base + "+alt" : base;
}

// Does a song/piece show under the selected track? Untagged entries always
// show; cct-tagged only under the CCT version; semi/comp under their own track.
// "alt" (UMC alternate-psalm) entries are hidden here BY DESIGN — they surface
// only through the psalm column's dedicated alt selection when the toggle is on,
// so alt content can ship inert in every payload with no per-payload gating.
function trackAllows(entryTrack, effTrack) {
  if (!entryTrack) return true;
  if (entryTrack === "alt") return false;
  const { isCct, lect } = parseEffTrack(effTrack);
  if (entryTrack === "cct") return isCct;
  if (entryTrack === "standard") return !isCct;
  if (entryTrack === "semi" || entryTrack === "comp") return lect ? entryTrack === lect : true;
  return true;
}

// ---------- UMH Psalter annotations + the UMC alternate-psalm toggle ----------
// window.PSALTER[sundayId] = { base:[entry..], semi:[..], comp:[..], cct:[..] }
//   entry = { ref, num, umh, responses, kind, inPsalter, alt:{ref,umh,responses}|null, display }
// The alternate is keyed to the SAME track the RCL psalm is on for that Sunday
// (base/semi vs comp), so simply reading the track-appropriate list gives the
// right parent-track gating for free.
function psalterPsalmEntry(sunday, effTrack) {
  const P = (window.PSALTER || {})[sunday.id];
  if (!P) return null;
  const { isCct, lect } = parseEffTrack(effTrack);
  const list = (isCct && P.cct) ? P.cct
    : (lect === "semi" && P.semi) ? P.semi
    : (lect === "comp" && P.comp) ? P.comp
    : (P.base || P.semi || P.comp || null);
  return (list && list[0]) ? list[0] : null;
}

// The alternate offered for this Sunday+track, or null.
function altPsalmFor(sunday, effTrack) {
  const e = psalterPsalmEntry(sunday, effTrack);
  return e && e.alt ? e.alt : null;
}

// Track-correct reading for a passage card: swaps the ref (and gist, via
// PASSAGE_THEMES) to match the selected track. The CCT revision overrides
// whatever passages it provides; semi/comp override OT+Psalm. They compose.
// When the alt-psalm toggle is on, the PSALM ref swaps to the UMC alternate.
function readingForTrack(sunday, pkey, effTrack) {
  const tr = (window.TRACK_READINGS || {})[sunday.id];
  const base = sunday.readings[pkey];
  const pick = (ref) => ref ? { ref, theme: (window.PASSAGE_THEMES || {})[ref] || "" } : null;
  const { isCct, lect, alt } = parseEffTrack(effTrack);
  if (alt && pkey === "psalm") {
    const a = altPsalmFor(sunday, effTrack);
    if (a) return pick(a.ref) || base;
  }
  if (!tr) return base;
  if (isCct && tr.cct && tr.cct[pkey]) return pick(tr.cct[pkey]) || base;
  if (lect && tr[lect] && tr[lect][pkey]) return pick(tr[lect][pkey]) || base;
  return base;
}

// The lectionary-track switcher (semi / complementary).
function TrackSeg({ opts, effTrack, onChange }) {
  if (!opts || opts.length === 0) return null;
  const lect = parseEffTrack(effTrack).lect || effTrack;
  return (
    <div className="wp-seg wp-hplan-trackseg" role="group" aria-label="Lectionary track">
      {opts.map(([k, l]) =>
      <button key={k} type="button"
      className={`wp-seg-btn ${lect === k ? "on" : ""}`}
      onClick={() => onChange(k)} aria-pressed={lect === k}>{l}</button>
      )}
    </div>);
}

// Separate toggle for Traditional RCL vs CCT proposed revision, with a link out
// to the CCT's announcement of the provisional revision.
function RclVersionSeg({ hasCct, effRcl, onChange }) {
  if (!hasCct) return null;
  return (
    <div className="wp-rcl-wrap">
      <div className="wp-seg wp-hplan-trackseg" role="group" aria-label="RCL version">
        <button type="button"
        className={`wp-seg-btn ${effRcl !== "cct" ? "on" : ""}`}
        onClick={() => onChange("traditional")} aria-pressed={effRcl !== "cct"}>
          Traditional RCL
        </button>
        <button type="button"
        className={`wp-seg-btn is-cct ${effRcl === "cct" ? "on" : ""}`}
        title="Consultation on Common Texts — proposed revision to the RCL"
        onClick={() => onChange("cct")} aria-pressed={effRcl === "cct"}>
          CCT revision
        </button>
      </div>
      <a className="wp-rcl-link"
      href="https://www.commontexts.org/2025/04/11/the-consultation-on-common-texts-cct-announces-a-provisional-update-to-the-revised-common-lectionary/"
      target="_blank" rel="noopener noreferrer">
        Read more about the CCT Revision
      </a>
    </div>);
}

// Both toggles together (version + lectionary track), independent of each other.
// Stacked vertically: lectionary track on top, RCL version below. Shared by the
// Hymns / Contemporary / Solo / Prayers tabs.
function TrackControls({ sundayId, track, rclVersion, changeTrack, changeRclVersion }) {
  const opts = trackOptsFor(sundayId);
  const hasCct = hasCctFor(sundayId);
  if (!opts.length && !hasCct) return null;
  const lect = opts.length ? clampTrack(track, opts) : null;
  return (
    <div className="wp-trackctl">
      {opts.length > 0 &&
      <TrackSeg opts={opts} effTrack={lect} onChange={changeTrack} />}
      <RclVersionSeg hasCct={hasCct} effRcl={rclVersion} onChange={changeRclVersion} />
    </div>);
}

// Prominent per-day lectionary-track bar, shown at the top of the track-aware
// tabs (Hymns / Contemporary / Solo) right under the "Scripture used" bar — the
// same semi/complementary + RCL/CCT controls the Prayers tab carries, surfaced
// where they are easy to find rather than buried in a "by reading" sub-header.
// Renders nothing on Sundays that carry no track variants.
function TrackBar({ sundayId, track, rclVersion, changeTrack, changeRclVersion }) {
  const opts = trackOptsFor(sundayId);
  const hasCct = hasCctFor(sundayId);
  if (!opts.length && !hasCct) return null;
  const lect = opts.length ? clampTrack(track, opts) : null;
  return (
    <div className="wp-trackbar" role="group" aria-label="Lectionary track">
      <div className="wp-trackbar-lead">
        <span className="wp-trackbar-label">Lectionary track</span>
        <span className="wp-trackbar-hint">
          {hasCct && opts.length
            ? "Choose the Propers track and RCL version for this Sunday"
            : hasCct
            ? "Traditional RCL or the CCT proposed revision"
            : "Semi-continuous or complementary Propers for this Sunday"}
        </span>
      </div>
      <div className="wp-trackbar-ctl">
        {opts.length > 0 &&
        <TrackSeg opts={opts} effTrack={lect} onChange={changeTrack} />}
        <RclVersionSeg hasCct={hasCct} effRcl={rclVersion} onChange={changeRclVersion} />
      </div>
    </div>);
}

// The UMH Psalter annotation + UMC alternate-psalm toggle. Renders only on
// Sundays whose current track offers an alternate (UM Book of Worship
// ¶227–237). The affordance now lives under the Psalm chip in the Scripture
// used bar (see ScriptureUsedBar); this helper just computes availability.

// ---------- tiny atoms ----------

function TierDot({ tier }) {
  if (!tier) return null;
  const fills = { 1: 0.35, 2: 0.6, 3: 1 };
  const f = fills[tier] ?? 0;
  return (
    <span className="wp-tier" title={`Tier ${tier}`} aria-label={`Tier ${tier}`}>
      <svg width="14" height="14" viewBox="0 0 14 14">
        <circle cx="7" cy="7" r="5.5" fill="none" stroke="currentColor" strokeWidth="1" opacity="0.55" />
        <circle cx="7" cy="7" r="5.5" fill="currentColor" opacity={f}
        clipPath={tier === 3 ? undefined : `inset(${(1 - f) * 100}% 0 0 0)`} />
      </svg>
    </span>);

}

function SourceMeter({ sources }) {
  if (!sources) return null;
  const [n, d] = sources.split("/").map(Number);
  return (
    <span className="wp-meter" title={`${n} of ${d} planners suggest`}>
      {Array.from({ length: d }).map((_, i) =>
      <span key={i} className={"wp-meter-pip " + (i < n ? "on" : "")} />
      )}
    </span>);

}

function Chip({ children, tone, title, onClick, active }) {
  return (
    <span className={`wp-chip wp-chip-${tone || "neutral"} ${active ? "is-active" : ""} ${onClick ? "is-clickable" : ""}`}
    onClick={onClick} title={title}>{children}</span>);

}

function PassageDot({ p, on, label }) {
  return (
    <span className={`wp-pdot wp-pdot-${p} ${on ? "on" : ""}`} title={label || p} />);

}

// ---------- header ----------

function ExportDialog({ sundays, onClose, onExport, initialIdx }) {
  const tabOptions = window.PLANNER_TABS;
  const [selectedTabs, setSelectedTabs] = useState(() => new Set(["hymns", "contemporary"]));
  const [fromIdx, setFromIdx] = useState(initialIdx || 0);
  const [toIdx, setToIdx] = useState(Math.min((initialIdx || 0) + 4, sundays.length - 1));
  const [format, setFormat] = useState("csv");
  const [scope, setScope] = useState("worksheet");
  const [restoreMsg, setRestoreMsg] = useState(null);
  const fileRef = useRef(null);
  const isoDate = (i) => { try { return new Date(sundays[i].date).toISOString().slice(0, 10); } catch(_) { return ""; } };
  const findSundayIdx = (ds) => { const p = new Date(ds + "T12:00:00"); if (isNaN(p)) return toIdx; let b = 0; for (let i = 0; i < sundays.length; i++) { if (new Date(sundays[i].date) <= p) b = i; } return b; };

  const toggleTab = (id) => setSelectedTabs((prev) => {
    const next = new Set(prev);
    if (next.has(id)) next.delete(id);else next.add(id);
    return next;
  });

  const lo = Math.min(fromIdx, toIdx);
  const hi = Math.max(fromIdx, toIdx);
  const sundaysInRange = sundays.slice(lo, hi + 1);
  const tabsInRange = Array.from(selectedTabs);
  const canExport = sundaysInRange.length > 0 && (scope === "worksheet" || tabsInRange.length > 0);

  return (
    <div className="wp-modal-scrim" onClick={onClose}>
      <div className="wp-modal" role="dialog" aria-modal="true" aria-label="Export"
      onClick={(e) => e.stopPropagation()}>
        <header className="wp-modal-head">
          <div>
            <div className="wp-modal-eyebrow">Export</div>
            <h2 className="wp-modal-title">Choose data and dates</h2>
          </div>
          <button className="wp-modal-x" onClick={onClose} aria-label="Close">
            <svg width="14" height="14" viewBox="0 0 14 14"><path d="M3 3 L11 11 M11 3 L3 11" stroke="currentColor" strokeWidth="1.25" /></svg>
          </button>
        </header>

        <div className="wp-modal-body">
          <section className="wp-modal-section">
            <h3 className="wp-modal-h">What to export</h3>
            <div className="wp-seg" role="group" aria-label="Export scope">
              {[["worksheet", "Weekly grid"], ["library", "Suggestion library"]].map(([k, l]) =>
              <button key={k} className={`wp-seg-btn ${scope === k ? "on" : ""}`} onClick={() => setScope(k)}>{l}</button>
              )}
            </div>
            <p className="wp-modal-hint">{scope === "worksheet" ?
            "A printable Excel grid — one row per Sunday: date, readings, your hymns, anthems/artwork, and your contemporary songs." :
            "The full suggestion lists for each Sunday, regardless of what you've selected."}</p>
          </section>

          {scope === "library" &&
          <section className="wp-modal-section">
            <h3 className="wp-modal-h">What to include</h3>
            <p className="wp-modal-hint">Pick one or more planning aspects. Sections marked “soon” aren’t in the library export yet and export as headers only.</p>
            <div className="wp-modal-grid">
              {tabOptions.map((t) => {
                const on = selectedTabs.has(t.id);
                const ready = t.id === "hymns" || t.id === "contemporary" || t.id === "anthems";
                return (
                  <label key={t.id} className={`wp-modal-tile ${on ? "on" : ""}`}>
                    <input type="checkbox" checked={on} onChange={() => toggleTab(t.id)} />
                    <span className="wp-modal-tile-check" aria-hidden="true">
                      <svg width="11" height="11" viewBox="0 0 14 14">
                        {on ? <path d="M3 7 L6 10 L11 4" stroke="currentColor" strokeWidth="1.75" fill="none" /> :
                        <rect x="2" y="2" width="10" height="10" stroke="currentColor" fill="none" strokeWidth="1" opacity="0.4" />}
                      </svg>
                    </span>
                    <span className="wp-modal-tile-label">{t.label}</span>
                    {!ready && <span className="wp-modal-tile-soon">soon</span>}
                  </label>);

              })}
            </div>
          </section>
          }

          <section className="wp-modal-section">
            <h3 className="wp-modal-h">Date range</h3>
            <div className="wp-modal-range">
              <label className="wp-modal-field">
                <span className="wp-modal-field-l">From</span>
                <select className="wp-modal-select" value={fromIdx} onChange={(e) => setFromIdx(+e.target.value)}>
                  {sundays.map((s, i) => <option key={s.id} value={i}>{s.date} — {s.day}</option>)}
                </select>
              </label>
              <span className="wp-modal-range-arrow" aria-hidden="true">
                <svg width="14" height="10" viewBox="0 0 14 10"><path d="M1 5 L13 5 M9 1 L13 5 L9 9" stroke="currentColor" fill="none" strokeWidth="1.25" /></svg>
              </span>
              <label className="wp-modal-field">
                <span className="wp-modal-field-l">Through</span>
                <input type="date" className="wp-modal-select" value={isoDate(toIdx)}
                  onChange={(e) => setToIdx(findSundayIdx(e.target.value))} />
              </label>
            </div>
            <div className="wp-modal-range-summary">
              {sundaysInRange.length === 0 ?
              <span className="wp-modal-warn">No Sundays in this range.</span> :

              <>
                  <span className="wp-modal-range-pill">{sundaysInRange.length} {sundaysInRange.length === 1 ? "Sunday" : "Sundays"}</span>
                  <span className="wp-modal-range-list">
                    {sundaysInRange.map((s) => <span key={s.id} className="wp-modal-range-chip"><span className="wp-modal-range-dot" style={{ background: s.color }} />{s.date}</span>)}
                  </span>
                </>
              }
            </div>
          </section>

          <section className="wp-modal-section" style={{ display: scope === "worksheet" ? "none" : undefined }}>
            <h3 className="wp-modal-h">Format</h3>
            <div className="wp-seg" role="group" aria-label="Format">
              {[["csv", "CSV"], ["json", "JSON"], ["txt", "Plain text"]].map(([k, l]) =>
              <button key={k} className={`wp-seg-btn ${format === k ? "on" : ""}`} onClick={() => setFormat(k)}>{l}</button>
              )}
            </div>
          </section>

          <section className="wp-modal-section">
            <h3 className="wp-modal-h">Backup &amp; restore</h3>
            <p className="wp-modal-hint">
              Selections, notes, and settings live only in this browser. Download a backup
              to keep a copy or move your plan to another computer.
            </p>
            <div className="wp-modal-foot-actions" style={{ justifyContent: "flex-start" }}>
              <button className="wp-btn wp-btn-ghost" onClick={backupPlannerState}>Download backup</button>
              <button className="wp-btn wp-btn-ghost" onClick={() => fileRef.current && fileRef.current.click()}>Restore from file…</button>
              <input ref={fileRef} type="file" accept=".json,application/json" style={{ display: "none" }}
              onChange={(e) => {
                const f = e.target.files && e.target.files[0];
                if (f) restorePlannerState(f, (ok, msg) => setRestoreMsg({ ok, msg }));
                e.target.value = "";
              }} />
            </div>
            {restoreMsg &&
            <p className={`wp-modal-hint ${restoreMsg.ok ? "" : "wp-modal-warn"}`}>{restoreMsg.msg}</p>
            }
          </section>
        </div>

        <footer className="wp-modal-foot">
          <div className="wp-modal-foot-summary">
            {canExport ?
            `${scope === "worksheet" ? "Weekly grid" : `${tabsInRange.length} ${tabsInRange.length === 1 ? "section" : "sections"}`} · ${sundaysInRange.length} ${sundaysInRange.length === 1 ? "Sunday" : "Sundays"} · ${scope === "worksheet" ? ".xlsx" : "." + format}` :
            scope === "library" ? "Pick at least one section and a valid range." : "Pick a valid date range."}
          </div>
          <div className="wp-modal-foot-actions">
            <button className="wp-btn wp-btn-ghost" onClick={onClose}>Cancel</button>
            <button className="wp-btn wp-btn-primary" disabled={!canExport}
            onClick={() => onExport({ tabs: tabsInRange, sundays: sundaysInRange, format, scope })}>
              Export
            </button>
          </div>
        </footer>
      </div>
    </div>);

}

// Season buckets used by the Sunday navigator's "More" drill-down. Takes the
// whole Sunday object: the long green season is split by month — Summer
// (Propers thru Aug), Autumn (Sep–Oct), and the November stretch (All Saints →
// Christ the King + Thanksgiving).
function seasonBucketKey(s) {
  const base = ((s && s.season) || "").split("·")[0].trim();
  if (base === "Advent") return "advent";
  if (base === "Christmas" || base === "Epiphany") return "christmas";
  if (base === "Lent" || base === "Holy Week") return "lent";
  if (base === "Easter") return "easter";
  if (base === "Pentecost") return "pentecost"; // Day of Pentecost + Trinity
  const mon = (((s && s.date) || "").match(/^[A-Z][a-z]{2}/) || [""])[0];
  if (mon === "Sep" || mon === "Oct") return "autumn";
  if (mon === "Nov" || mon === "Dec") return "kingdom";
  return "summer";
}
const SEASON_BUCKETS = [
  { key: "advent", label: "Advent" },
  { key: "christmas", label: "Christmas / Epiphany" },
  { key: "lent", label: "Lent / Holy Week" },
  { key: "easter", label: "Easter" },
  { key: "pentecost", label: "Pentecost / Trinity" },
  { key: "summer", label: "After Pentecost · Summer" },
  { key: "autumn", label: "After Pentecost · Autumn" },
  { key: "kingdom", label: "All Saints / Christ the King" }];

// Lectionary years surfaced in the season ribbon. All three are live spines:
// A in data.js, B in data-yearB.js, C in data-yearC.js.
const LECTIONARY_YEARS = [
  { key: "A", label: "Year A", range: "2025–2026" },
  { key: "B", label: "Year B", range: "2026–2027" },
  { key: "C", label: "Year C", range: "2027–2028" }];

// "Nov 30, 2025" + "Dec 21, 2025" → "Nov 30 – Dec 21, 2025";
// cross-year → "Dec 24 ’25 – Feb 15 ’26". Strips "(alt)"-style suffixes.
function seasonDateRange(matches) {
  if (!matches.length) return "";
  const clean = (d) => (d || "").replace(/\s*\(.*\)\s*$/, "");
  const a = clean(matches[0].date);
  const z = clean(matches[matches.length - 1].date);
  if (matches.length === 1) return a;
  const ya = (a.match(/\d{4}/) || [""])[0];
  const yz = (z.match(/\d{4}/) || [""])[0];
  if (ya === yz) return `${a.replace(/,\s*\d{4}\s*$/, "")} – ${z}`;
  const shortYr = (d) => d.replace(/,\s*\d{2}(\d{2})\s*$/, " ’$1");
  return `${shortYr(a)} – ${shortYr(z)}`;
}


function PlannerHeader({ sunday, sundays, idx, onPrev, onNext, onGoto, hymnals, toggleHymnal, density, setDensity, tab, setTab, onOpenExport }) {
  const showHymnals = true;
  // navMode: "window" (collapsed 5-window) | "seasons:<year>" (season chooser
  // for that lectionary year) | "<year>:<bucket>" (that season's Sundays)
  const [navMode, setNavMode] = useState("window");
  const total = sundays.length;
  const canCollapse = total > 5;
  const yearOf = (s) => (s && s.year) || "A";
  const isSeasons = navMode.indexOf("seasons") === 0;
  const isBucket = navMode !== "window" && !isSeasons;
  const seasonsYear = isSeasons ? navMode.split(":")[1] || "A" :
  isBucket ? navMode.split(":")[0] : yearOf(sunday);
  let visible;
  if (isBucket) {
    const bucketYear = navMode.split(":")[0];
    const bucketKey = navMode.split(":")[1];
    visible = sundays.
    map((s, i) => ({ s, origIdx: i })).
    filter(({ s }) => yearOf(s) === bucketYear && seasonBucketKey(s) === bucketKey);
  } else if (navMode === "window" && canCollapse) {
    // Window: previous + selected + next 3 = 5 services. Clamp to ends.
    let start = Math.max(0, idx - 1);
    let end = start + 5;
    if (end > total) {end = total;start = Math.max(0, end - 5);}
    visible = sundays.slice(start, end).map((s, i) => ({ s, origIdx: start + i }));
  } else {
    visible = sundays.map((s, i) => ({ s, origIdx: i }));
  }
  const seasonCounts = SEASON_BUCKETS.map((b) => {
    const matches = sundays.filter((s) => yearOf(s) === seasonsYear && seasonBucketKey(s) === b.key);
    return { ...b, count: matches.length, range: seasonDateRange(matches), color: matches[0] ? matches[0].color : "var(--wp-season-ink)" };
  });
  const isCollapsedView = navMode === "window" && canCollapse;
  return (
    <header className="wp-header">
      <div className="wp-header-row">
        <div className="wp-brand">
          <span className="wp-brand-mark" aria-hidden="true">
            <svg width="22" height="22" viewBox="0 0 22 22">
              <path d="M11 2 L11 20 M5 11 L17 11 M11 2 C 13 5 13 8 11 11 C 9 8 9 5 11 2 Z" stroke="currentColor" fill="none" strokeWidth="1.25" />
            </svg>
          </span>
          <span className="wp-brand-text">Worship Planner</span>
          <span className="wp-brand-sub">RCL · Years A–C</span>
        </div>
        <div className="wp-header-tools">
          {showHymnals &&
          <div className="wp-seg wp-seg-multi" role="group" aria-label="My hymnals">
              <span className="wp-seg-label">My hymnals</span>
              {["UMH", "TFWS", "W&S", "GtG", "PH", "ELW"].map((h) =>
            <button key={h}
            className={`wp-seg-btn ${hymnals.has(h) ? "on" : ""}`}
            onClick={() => toggleHymnal(h)}
            aria-pressed={hymnals.has(h)}>
                  <span className="wp-seg-check" aria-hidden="true">
                    {hymnals.has(h) ? "✓" : ""}
                  </span>
                  {h}
                </button>
            )}
            </div>
          }
          <div className="wp-header-tools-stack">
            <button className="wp-export-btn" onClick={onOpenExport} aria-label="Export">
              <span className="wp-export-btn-icon" aria-hidden="true">
                <svg width="12" height="12" viewBox="0 0 12 12">
                  <path d="M6 1 L6 8 M3 5 L6 8 L9 5 M2 10 L10 10" stroke="currentColor" strokeWidth="1.25" fill="none" strokeLinecap="round" strokeLinejoin="round" />
                </svg>
              </span>
              <span>Export</span>
            </button>
          </div>
        </div>
      </div>

      <div className="wp-suntitle">
        <button className="wp-nav" onClick={onPrev} aria-label="Previous Sunday">
          <svg width="14" height="14" viewBox="0 0 14 14"><path d="M9 2 L4 7 L9 12" stroke="currentColor" fill="none" strokeWidth="1.5" /></svg>
        </button>
        <div className="wp-suntitle-main">
          <div className="wp-eyebrow">
            <span className="wp-season-swatch" style={{ background: "var(--wp-season)" }} />
            <span>{sunday.season}</span>
            <span className="wp-eyebrow-sep">·</span>
            <span>{sunday.date}</span>
            <span className="wp-eyebrow-sep">·</span>
            <span>Year {yearOf(sunday)}</span>
            {sunday.candle && <>
              <span className="wp-eyebrow-sep">·</span>
              <span className="wp-candle">{sunday.candle} candle</span>
            </>}
          </div>
          <h1 className="wp-suntitle-h1">{sunday.day}</h1>
        </div>
        <button className="wp-nav" onClick={onNext} aria-label="Next Sunday">
          <svg width="14" height="14" viewBox="0 0 14 14"><path d="M5 2 L10 7 L5 12" stroke="currentColor" fill="none" strokeWidth="1.5" /></svg>
        </button>
      </div>

      {isSeasons ?
      <nav className="wp-sunpips wp-sunpips-seasons is-expanded" aria-label={`Seasons — Year ${seasonsYear}`}>
        {seasonCounts.map((b) =>
        <button key={b.key} type="button" className="wp-sunpip wp-sunpip-season"
        onClick={() => setNavMode(seasonsYear + ":" + b.key)}
        title={`${b.label} — ${b.count} service${b.count === 1 ? "" : "s"}`}>
            <span className="wp-sunpip-color" style={{ background: b.color }} />
            <span className="wp-sunpip-stack">
              <span className="wp-sunpip-label">{b.label}</span>
              <span className="wp-sunpip-date">{b.range}</span>
            </span>
          </button>
        )}
        {LECTIONARY_YEARS.filter((y) => y.key !== seasonsYear).map((y) =>
        <button key={y.key} type="button" className="wp-sunpip wp-sunpip-season wp-sunpip-year"
        onClick={() => setNavMode("seasons:" + y.key)}
        title={`${y.label} (${y.range}) — browse its seasons`}>
            <span className="wp-sunpip-color" />
            <span className="wp-sunpip-stack">
              <span className="wp-sunpip-label">{y.label}</span>
              <span className="wp-sunpip-date">{y.range}</span>
            </span>
          </button>
        )}
        <button type="button" className="wp-sunpip-toggle" onClick={() => setNavMode("window")} title="Back to nearby Sundays">
            <span className="wp-sunpip-toggle-glyph" aria-hidden="true">
              <svg width="10" height="10" viewBox="0 0 10 10"><path d="M2 6 L5 3 L8 6" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>
            </span>
            <span className="wp-sunpip-toggle-label">Less</span>
          </button>
      </nav> :

      <nav className={`wp-sunpips ${isCollapsedView ? "is-collapsed" : "is-expanded"}`} aria-label="Sundays">
        {isBucket &&
        <button type="button" className="wp-sunpip-toggle wp-sunpip-back" onClick={() => setNavMode("seasons:" + navMode.split(":")[0])} title="Back to seasons">
            <span className="wp-sunpip-toggle-glyph" aria-hidden="true">
              <svg width="10" height="10" viewBox="0 0 10 10"><path d="M6 2 L3 5 L6 8" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>
            </span>
            <span className="wp-sunpip-toggle-label">Seasons</span>
          </button>
        }
        {visible.map(({ s, origIdx }) =>
        <button key={s.id} className={`wp-sunpip ${origIdx === idx ? "on" : ""}`}
        onClick={() => { setNavMode("window"); onGoto && onGoto(origIdx); }}
        title={`${s.day} · ${s.date}`}>
            <span className="wp-sunpip-color" style={{ background: s.color }} />
            <span className="wp-sunpip-label">{s.day.replace(/^.*?(\w+ Sunday.*|Christmas.*|Epiphany.*|Easter.*)/, "$1")}</span>
            <span className="wp-sunpip-date">{s.date}</span>
          </button>
        )}
        {isCollapsedView &&
        <button
          type="button"
          className="wp-sunpip-toggle"
          onClick={() => setNavMode("seasons:" + yearOf(sunday))}
          title="Browse by season">
            <span className="wp-sunpip-toggle-glyph" aria-hidden="true">
              <svg width="10" height="10" viewBox="0 0 10 10"><path d="M2 4 L5 7 L8 4" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>
            </span>
            <span className="wp-sunpip-toggle-label">More</span>
          </button>
        }
      </nav>
      }

      <nav className="wp-tabs" aria-label="Planning aspect">
        {window.PLANNER_TABS.map((t) =>
        <button key={t.id}
        className={`wp-tab ${tab === t.id ? "on" : ""}`}
        onClick={() => setTab(t.id)}
        aria-pressed={tab === t.id}>
            <span className="wp-tab-label">{t.label}</span>
            {!["hymns", "contemporary", "artwork", "solo", "prayers", "anthems", "handbells"].includes(t.id) && <span className="wp-tab-soon">soon</span>}
          </button>
        )}
      </nav>
    </header>);

}

// ---------- passage card ----------

function PassageCard({ pkey, reading, label }) {
  return (
    <section className="wp-passage" data-passage={pkey}>
      <div className="wp-passage-marker">
        <span className="wp-passage-tag">{label}</span>
      </div>
      <div className="wp-passage-body">
        <div className="wp-passage-ref">{reading.ref}</div>
        <p className="wp-passage-theme">{reading.theme}</p>
      </div>
    </section>);

}

// ---------- shared "Scripture used" bar ----------
// One service-wide multi-select of the day's four readings, shared across the
// Hymns / Contemporary / Artwork / Solo / Prayers tabs. Unchecking a reading
// drops its column from every tab for THIS Sunday (per-day, persisted in
// wp:hiddenByDay) — it replaces the old per-column "Hide" toggles. The Prayers
// tab also resolves its liturgy permutation from this same set.
function ScriptureUsedBar({ sunday, hidden, toggleHidden, effTrack, psalterAlt, altActive, onToggleAlt }) {
  const readingOf = (k) => (window.readingForTrack ? window.readingForTrack(sunday, k, effTrack) : (sunday.readings || {})[k]);
  const usedCount = PASSAGES.filter((p) => !hidden.has(p)).length;
  const usedRefs = PASSAGES.filter((p) => !hidden.has(p)).map((p) => {
    if (p === "psalm" && altActive && psalterAlt) return psalterAlt.ref;
    const rEff = p === "psalm" ? (effTrack || "n/a").replace(/\+alt$/, "") : effTrack;
    const r = window.readingForTrack ? window.readingForTrack(sunday, p, rEff) : readingOf(p);
    return r && r.ref;
  }).filter(Boolean).map((ref) => ref.replace(/[\u2012-\u2015]/g, "-"));
  const lookupUrl = usedRefs.length
    ? `https://www.biblegateway.com/passage/?search=${encodeURIComponent(usedRefs.join("; "))}&version=NRSVUE`
    : null;
  return (
    <div className="wp-scripture" role="group" aria-label="Scripture used this Sunday">
      <div className="wp-scripture-lead">
        <span className="wp-scripture-label">Scripture used</span>
        <span className="wp-scripture-hint">
          {usedCount === PASSAGES.length
            ? "All four readings · uncheck any not read this Sunday"
            : `${usedCount} of ${PASSAGES.length} readings · applies to every tab`}
        </span>
      </div>
      <div className="wp-scripture-chips">
        {PASSAGES.map((p) => {
          const on = !hidden.has(p);
          // The RCL psalm chip always shows the RCL psalm — the "+alt" swap is
          // reflected only by the separate UMH-alt chip below, not here.
          const rEff = p === "psalm" ? (effTrack || "n/a").replace(/\+alt$/, "") : effTrack;
          const r = window.readingForTrack ? window.readingForTrack(sunday, p, rEff) : readingOf(p);
          const pe = p === "psalm" ? psalterPsalmEntry(sunday, effTrack) : null;
          const chip = (
            <button key="chip" type="button"
              className={`wp-scripture-chip ${p === "psalm" && pe && pe.umh ? "wp-scripture-chip-stack" : ""} ${on && !(p === "psalm" && altActive) ? "on" : ""}`}
              data-passage={p} aria-pressed={on && !(p === "psalm" && altActive)}
              onClick={() => (p === "psalm" && altActive) ? onToggleAlt() : toggleHidden(p)}
              title={on
                ? `${window.PASSAGE_LABELS[p]} — in use this Sunday; click to set aside`
                : `${window.PASSAGE_LABELS[p]} — set aside; click to use`}>
              <span className="wp-scripture-chip-box" aria-hidden="true"></span>
              <span className="wp-scripture-chip-short">{window.PASSAGE_SHORT[p]}</span>
              {r && r.ref && <span className="wp-scripture-chip-ref">{r.ref}{p === "psalm" && pe && pe.umh ? <span className="wp-scripture-alt-umh">UMH {[].concat(pe.umh).join(", ")}{pe.responses ? " · " + pe.responses + " response" + (pe.responses === 1 ? "" : "s") : ""}</span> : null}</span>}
            </button>);
          if (p !== "psalm" || !psalterAlt) return <div key={p} className="wp-scripture-slot">{chip}</div>;
          const resp = psalterAlt.responses ? `${psalterAlt.responses} response${psalterAlt.responses === 1 ? "" : "s"}` : null;
          return (
            <div key={p} className="wp-scripture-slot">
              {chip}
              <button type="button"
                className={`wp-scripture-chip wp-scripture-alt ${altActive ? "on" : ""}`}
                data-passage="psalm" aria-pressed={altActive} onClick={onToggleAlt}
                title={altActive
                  ? "Using the UMH alternate psalm — click to restore the RCL psalm"
                  : "Use the UMH alternate psalm (Book of Worship ¶227–237)"}>
                <span className="wp-scripture-chip-box" aria-hidden="true"></span>
                <span className="wp-scripture-chip-short">UMH alt</span>
                <span className="wp-scripture-chip-ref">{psalterAlt.ref}<span className="wp-scripture-alt-umh">UMH {[].concat(psalterAlt.umh).join(", ")}{resp ? " · " + resp : ""}</span></span>
              </button>
            </div>);
        })}
      </div>
      {lookupUrl && (
        <a className="wp-scripture-lookup" href={lookupUrl} target="_blank" rel="noopener noreferrer"
          title={`Open on BibleGateway (NRSVue): ${usedRefs.join("; ")}`}>
          Look up Scripture ↗
        </a>
      )}
    </div>);

}

// ---------- last-used pill ----------

function LastUsedPill({ kind, rec, currentTs, tune }) {
  if (!rec) return null;
  const rel = relTo(rec, currentTs);
  // kind: "hymn" (same text+tune — i.e. the same hymn) | "tune" (shared tune,
  // different text) | "text" | "ccli"
  const label = kind === "tune" ?
  <>Tune <span className="wp-lu-tune">{tune}</span></> :
  kind === "ccli" ? "Song" : kind === "hymn" ? "Hymn" : "Text";
  const verb = rel === "upcoming" ? "scheduled" : "last used";
  const what = kind === "tune" ? `Tune ${tune}` : kind === "ccli" ? "This song" : kind === "hymn" ? "This hymn" : "This text";
  return (
    <span className={`wp-lu wp-lu-${kind} wp-lu-${rel}`}
    title={`${what} ${rel === "upcoming" ? "is scheduled" : "was last selected"} for ${rec.label} (${rec.dateStr})`}>
      <span className="wp-lu-dot" aria-hidden="true" />
      <span className="wp-lu-l">{label}</span>
      <span className="wp-lu-v">{verb}</span>
      <span className="wp-lu-d">{shortDate(rec.dateStr)}</span>
    </span>);

}

// Historical "← Sung N weeks ago" advisory for a contemporary song, keyed by CCLI #
// (Task 1 — the SUNG_LOG parity that hymns already have via the look-ahead pills).
// Display-only: it never reorders or blocks the song. Reuses the look-ahead
// engine's last-sung math + label so it reads identically to the hymn badge.
function SungLastPill({ ls }) {
  if (!ls) return null;
  const text = window.sungPillLabel ? window.sungPillLabel(ls) : `Sung ${ls.weeks} wk ago`;
  return (
    <span className="wp-pills">
      <span className="wp-pill wp-pill-last"
      title="Actually sung recently (from the performance log). Advisory only — the song stays fully available.">
        <span className="wp-pill-glyph" aria-hidden="true">←</span>
        <span className="wp-pill-text">{text}</span>
      </span>
    </span>);

}

// ---------- hymn row ----------

function HymnRow({ hymn, pkey, isSelected, onToggle, note, onNote, showLoose, kind, usage, currentDayId, currentTs }) {
  const [openNote, setOpenNote] = useState(Boolean(note));
  const tone = hymn.style === "modern-sacred" ? "sacred" : null;
  const isContemp = kind === "contemporary";
  const tune = !isContemp ? tuneFor(hymn) : null;
  const use = usage ? hymnUsage(usage, hymn, currentDayId) : { text: null, tune: null, ccli: null };
  const sungLast = isContemp && hymn.ccli && window.lastSungInfo ?
  window.lastSungInfo(String(hymn.ccli), currentTs) : null;
  return (
    <div className={`wp-hymn ${isSelected ? "is-selected" : ""} ${showLoose ? "is-loose" : ""} ${isContemp ? "is-contemp" : ""}`}>
      <button className="wp-hymn-check" onClick={onToggle} aria-pressed={isSelected}>
        <svg width="14" height="14" viewBox="0 0 14 14">
          {isSelected ?
          <path d="M3 7 L6 10 L11 4" stroke="currentColor" strokeWidth="1.75" fill="none" /> :
          <circle cx="7" cy="7" r="5" stroke="currentColor" strokeWidth="1" fill="none" opacity="0.4" />}
        </svg>
      </button>
      <div className="wp-hymn-body">
        <div className="wp-hymn-line">
          {isContemp ?
          <span className="wp-hymn-num wp-hymn-ccli" title={`CCLI #${hymn.ccli}`}>CCLI {hymn.ccli}</span> :

          <span className="wp-hymn-num">
              {hymn.hymnal} {hymn.num}
            </span>
          }
          <span className="wp-hymn-title">
            {hymn.title}
            {tune && <span className="wp-hymn-tune-incaps" title={`Tune: ${tune}`}> ({tune})</span>}
            {isContemp && hymn.artist && <span className="wp-hymn-artist"> · {hymn.artist}</span>}
            {hymn.also && hymn.also.length > 0 &&
            <span className="wp-hymn-also"> ({hymn.also.map((p) => window.PASSAGE_SHORT[p]).join(" + ")})</span>
            }
          </span>
          {hymn.direct && <span className="wp-tag wp-tag-direct">direct</span>}
          {tone && <span className="wp-tag wp-tag-sacred">modern sacred</span>}
          {isContemp && hymn.repertoire === false && <span className="wp-tag wp-tag-new">[NEW]</span>}
          {isContemp && hymn.seedbed && <span className="wp-tag wp-tag-seedbed">Seedbed</span>}
        </div>
        <div className="wp-hymn-line wp-hymn-meta">
          <TierDot tier={hymn.tier} />
          {!isContemp && <SourceMeter sources={hymn.sources} />}
          {!isContemp && <OrchBadge hymn={hymn} />}
          <span className="wp-hymn-note">{hymn.note}</span>
          {sungLast && <SungLastPill ls={sungLast} />}
          {(use.text || use.tune || use.ccli) &&
          <span className="wp-hymn-uses">
              {isContemp ?
            <LastUsedPill kind="ccli" rec={use.ccli} currentTs={currentTs} /> :
            <>
                    {use.text && <LastUsedPill kind="hymn" rec={use.text} currentTs={currentTs} />}
                    {use.tune && (!use.text || use.tune.dayId !== use.text.dayId) &&
              <LastUsedPill kind="tune" rec={use.tune} currentTs={currentTs} tune={tune} />}
                  </>}
            </span>
          }
          {isContemp && (hymn.youtube || hymn.praisecharts) &&
          <span className="wp-hymn-links">
              {hymn.youtube &&
            <a className="wp-hymn-link" href={hymn.youtube} target="_blank" rel="noopener noreferrer"
            title={`Find "${hymn.title}" on YouTube`}>
                  <svg width="11" height="11" viewBox="0 0 14 14" aria-hidden="true">
                    <rect x="0.6" y="2.6" width="12.8" height="8.8" rx="2.4" fill="none" stroke="currentColor" strokeWidth="1.1" />
                    <path d="M5.6 5 L9.2 7 L5.6 9 Z" fill="currentColor" />
                  </svg>
                  Listen
                </a>}
              {hymn.praisecharts &&
            <a className="wp-hymn-link" href={hymn.praisecharts} target="_blank" rel="noopener noreferrer"
            title={`Find charts for "${hymn.title}" on PraiseCharts`}>
                  <svg width="11" height="11" viewBox="0 0 14 14" aria-hidden="true">
                    <path d="M2 2.7 H12 M2 7 H12 M2 11.3 H8" stroke="currentColor" strokeWidth="1.1" fill="none" strokeLinecap="round" />
                  </svg>
                  Charts
                </a>}
            </span>
          }
        </div>
        {isSelected &&
        <div className="wp-hymn-pnote">
            {openNote ?
          <textarea
            className="wp-hymn-pnote-input"
            placeholder="Why this hymn here? (planner notes)"
            value={note || ""}
            onChange={(e) => onNote(e.target.value)}
            rows={2} /> :


          <button className="wp-link" onClick={() => setOpenNote(true)}>
                <svg width="11" height="11" viewBox="0 0 11 11"><path d="M2 2 L9 2 L9 9 L2 9 Z M4 5 L7 5 M4 7 L6 7" stroke="currentColor" fill="none" strokeWidth="1" /></svg>
                Add a note
              </button>
          }
          </div>
        }
      </div>
    </div>);

}

// ---------- music column ----------

function MusicColumn({ pkey, label, reading, music, selected, toggle, notes, setNote, kind, usage, currentDayId, currentTs }) {
  const looseCount = music.loose?.length || 0;
  // Auto-reveal the "further afield" bucket for contemporary columns that are
  // thin up top: when fewer than 4 songs show across Strong + Thematic but
  // looser picks exist, open them by default rather than burying the only other
  // options behind a disclosure. Re-applies whenever the column's contents shift
  // (Sunday / track / source-filter change); a manual toggle wins until then.
  const shownAbove = (music.strong?.length || 0) + (music.thematic?.length || 0);
  const autoLoose = kind === "contemporary" && looseCount > 0 && shownAbove < 4;
  const [showLoose, setShowLoose] = useState(autoLoose);
  const autoRef = useRef(autoLoose);
  useEffect(() => {
    if (autoRef.current !== autoLoose) {autoRef.current = autoLoose;setShowLoose(autoLoose);}
  }, [autoLoose]);
  const idOf = (h) => kind === "contemporary" ? `ccli:${h.ccli}` : `${h.hymnal}:${h.num}`;
  const empty = !(music.strong?.length || music.thematic?.length || looseCount);
  return (
    <article className="wp-col" data-passage={pkey}>
      <PassageCard pkey={pkey} reading={reading} label={label} />

      <>

      {empty &&
        <div className="wp-bucket wp-bucket-empty">
          <p className="wp-bucket-empty-text">
            No strong {kind === "contemporary" ? "song" : "hymn"} match for this reading. Most picks bleed from neighboring passages.
          </p>
        </div>
        }

      {music.strong?.length > 0 &&
        <div className="wp-bucket">
          <h4 className="wp-bucket-h">Strong match</h4>
          <div className="wp-bucket-list">
            {music.strong.map((h, i) => {
              const k = `${pkey}:${idOf(h)}:strong:${i}`;
              return <HymnRow key={k} hymn={h} pkey={pkey} kind={kind}
              isSelected={selected.has(k)} onToggle={() => toggle(k, h, pkey)}
              note={notes[k]} onNote={(v) => setNote(k, v)}
              usage={usage} currentDayId={currentDayId} currentTs={currentTs} />;
            })}
          </div>
        </div>
        }

      {music.thematic?.length > 0 &&
        <div className="wp-bucket">
          <h4 className="wp-bucket-h">Thematic connection</h4>
          <div className="wp-bucket-list">
            {music.thematic.map((h, i) => {
              const k = `${pkey}:${idOf(h)}:thematic:${i}`;
              return <HymnRow key={k} hymn={h} pkey={pkey} kind={kind}
              isSelected={selected.has(k)} onToggle={() => toggle(k, h, pkey)}
              note={notes[k]} onNote={(v) => setNote(k, v)}
              usage={usage} currentDayId={currentDayId} currentTs={currentTs} />;
            })}
          </div>
        </div>
        }

      {looseCount > 0 &&
        <div className="wp-bucket wp-bucket-loose">
          <button className="wp-disclosure" onClick={() => setShowLoose((s) => !s)}>
            <span className={`wp-disclosure-caret ${showLoose ? "on" : ""}`}>
              <svg width="9" height="9" viewBox="0 0 9 9"><path d="M2 2 L7 4.5 L2 7 Z" fill="currentColor" /></svg>
            </span>
            Further afield
            <span className="wp-disclosure-count">{looseCount}</span>
            <span className="wp-disclosure-hint">looser textual ties · the long tail</span>
          </button>
          {showLoose &&
          <div className="wp-bucket-list">
              {music.loose.map((h, i) => {
              const k = `${pkey}:${idOf(h)}:loose:${i}`;
              return <HymnRow key={k} hymn={h} pkey={pkey} showLoose kind={kind}
              isSelected={selected.has(k)} onToggle={() => toggle(k, h, pkey)}
              note={notes[k]} onNote={(v) => setNote(k, v)}
              usage={usage} currentDayId={currentDayId} currentTs={currentTs} />;
            })}
            </div>
          }
        </div>
        }
      </>
    </article>);

}

// ---------- service plan panel ----------

const SERVICES = [
{ id: "traditional", label: "Traditional Hymns", short: "Hymn", noun: "hymn" },
{ id: "contemporary", label: "Contemporary Songs", short: "Song", noun: "song" },
{ id: "anthem", label: "Choral Anthems", short: "Anthem", noun: "anthem" },
{ id: "solo", label: "Solo & Ensemble", short: "Solo", noun: "solo" },
{ id: "handbell", label: "Handbells", short: "Bells", noun: "handbell" },
{ id: "organ", label: "Organ/Piano", short: "Organ", noun: "piece" },
{ id: "artwork", label: "Artwork", short: "Art", noun: "artwork" }];

const ADD_SERVICES = SERVICES;
const TAB_TO_SERVICE = { hymns: "traditional", contemporary: "contemporary", anthems: "anthem", solo: "solo", handbells: "handbell", artwork: "artwork" };

// Which service a plan item belongs to. The plan KEY prefix is authoritative
// for the typed tabs (anthem / handbell / solo / artwork); otherwise a stored
// serviceId or the CCLI/hymn shape decides between contemporary and hymns.
function serviceIdFor(k, hymn, storedId) {
  if (k) {
    if (k.indexOf("anthem:") === 0) return "anthem";
    if (k.indexOf("handbell:") === 0) return "handbell";
    if (k.indexOf("solo:") === 0) return "solo";
    if (k.indexOf("organ:") === 0) return "organ";
    if (k.indexOf("art:") === 0) return "artwork";
  }
  return storedId || (hymn && hymn.ccli ? "contemporary" : "traditional");
}

const SERVICE_LABEL = (id) => (SERVICES.find((s) => s.id === id) || { label: id }).label;
const SERVICE_SHORT = (id) => (SERVICES.find((s) => s.id === id) || { short: id }).short;
// Vocabulary helpers. The manual-add button's noun is keyed to whichever
// PLANNING TAB is active — "Add a hymn manually" while on Traditional
// Hymns, "Add a song manually" on Contemporary Songs, "Add an anthem
// manually" on Choral Anthems, etc. Falls back to a generic phrase for the
// service-plan filter cases.
const NOUN_FOR_TAB = (tabId) => {
  const t = (window.PLANNER_TABS || []).find((t) => t.id === tabId);
  return t && t.noun || "item";
};
// "a" vs "an" — handles the common cases (anthem → an anthem, artwork → an
// artwork, ensemble → an ensemble) without trying to be a full English
// grammar engine.
const ART_FOR = (noun) => /^[aeiou]/i.test((noun || "").trim()) ? "an" : "a";
// Simple pluralizer for the tab nouns we actually use. Falls back to +"s".
const PLURALIZE = (noun) => {
  const n = (noun || "").trim();
  if (!n) return "items";
  if (/y$/.test(n) && !/[aeiou]y$/.test(n)) return n.replace(/y$/, "ies");
  if (/(s|x|z|ch|sh)$/i.test(n)) return n + "es";
  if (n === "artwork") return "artwork"; // mass noun
  return n + "s";
};

function AddCustomHymnForm({ initial, onSubmit, onCancel, mode = "add", defaultService, noun = "hymn", article = "a", nounPlural = "hymns" }) {
  const [title, setTitle] = useState(initial?.title || "");
  const [id, setId] = useState(
    initial && initial.hymnal ?
    `${initial.hymnal}${initial.num ? " " + initial.num : ""}` :
    initial && initial.ccli ? `CCLI ${initial.ccli}` : ""
  );
  const [tune, setTune] = useState(initial?.tune || "");
  const [composer, setComposer] = useState(initial?.composer || "");
  const [year, setYear] = useState(initial?.year || "");
  // Readings a manual entry is tied to — multi-select. "general" (service-wide,
  // not tied to a reading) is mutually exclusive with the specific readings.
  const [passages, setPassages] = useState(() => {
    if (initial?.passages && initial.passages.length) return initial.passages;
    if (initial?.passage) return [initial.passage];
    return ["general"];
  });
  const togglePassage = (k) => {
    setPassages((prev) => {
      if (k === "general") return ["general"];
      const base = prev.filter((p) => p !== "general");
      if (base.includes(k)) {
        const next = base.filter((p) => p !== k);
        return next.length ? next : ["general"];
      }
      return [...base, k];
    });
  };
  const [serviceId, setServiceId] = useState(initial?.serviceId || defaultService || "traditional");
  const [note, setNote] = useState(initial?.note || "");
  const canSubmit = title.trim().length > 0;
  const showHymnFields = serviceId === "traditional";
  const showAutocomplete = serviceId === "traditional" || serviceId === "contemporary";
  const showComposer = serviceId === "anthem" || serviceId === "solo" || serviceId === "handbell";
  const showArtwork = serviceId === "artwork";

  // Autocomplete state ----------------------------------------------------
  // Fields the user has TYPED INTO (vs. ones the autocomplete filled). Once
  // a user has edited a field manually, picking a suggestion never overwrites
  // it — they've signaled intent.
  const [editedId, setEditedId] = useState(false);
  const [editedTune, setEditedTune] = useState(false);
  const [suggestionsOpen, setSuggestionsOpen] = useState(false);
  const [highlightIdx, setHighlightIdx] = useState(0);
  const [pickedKey, setPickedKey] = useState(null);
  const suggestions = useMemo(() => serviceId === "contemporary" ? searchContempCorpus(title) : searchHymnCorpus(title), [title, serviceId]);

  // If the typed title exactly matches a suggestion's title, keep it as the
  // "picked" entry without forcing the dropdown — but don't auto-clear the
  // suggestions on every keystroke either.
  useEffect(() => {setHighlightIdx(0);}, [title]);

  const pickSuggestion = (s) => {
    setTitle(s.title);
    setPickedKey(s.hymnal && s.num ? `${s.hymnal}:${s.num}` : `t:${textKey(s.title)}`);
    if (!editedId) {
      setId(s.hymnal && s.num ? `${s.hymnal} ${s.num}` : s.ccli ? `CCLI ${s.ccli}` : "");
    }
    if (!editedTune) {
      setTune(s.tune || "");
    }
    setSuggestionsOpen(false);
  };

  const onTitleChange = (v) => {
    setTitle(v);
    setSuggestionsOpen(true);
    setPickedKey(null);
  };
  const onTitleKey = (e) => {
    if (!suggestionsOpen || suggestions.length === 0) {
      if (e.key === "ArrowDown" && suggestions.length > 0) {
        setSuggestionsOpen(true);e.preventDefault();
      }
      return;
    }
    if (e.key === "ArrowDown") {
      setHighlightIdx((i) => Math.min(suggestions.length - 1, i + 1));
      e.preventDefault();
    } else if (e.key === "ArrowUp") {
      setHighlightIdx((i) => Math.max(0, i - 1));
      e.preventDefault();
    } else if (e.key === "Enter") {
      pickSuggestion(suggestions[highlightIdx]);
      e.preventDefault();
    } else if (e.key === "Escape") {
      setSuggestionsOpen(false);
      e.preventDefault();
    } else if (e.key === "Tab") {
      // Tab into next field with the highlight applied — useful UX.
      if (suggestions.length > 0) pickSuggestion(suggestions[highlightIdx]);
    }
  };

  const submit = () => {
    if (!canSubmit) return;
    const raw = id.trim();
    const m = raw.match(/^([A-Za-z&\/]{1,8})\s*([0-9]+[A-Za-z]?)$/);
    const hymn = {
      title: title.trim(),
      hymnal: m ? m[1] : raw || null,
      num: m ? m[2] : null,
      tune: tune.trim() || null,
      composer: composer.trim() || null,
      year: year.trim() || null,
      note: note.trim()
    };
    onSubmit(hymn, passages, serviceId);
  };
  const onKey = (e) => {
    if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) submit();
    if (e.key === "Escape" && !suggestionsOpen) onCancel();
  };

  // Render helper: wrap matched substring in <mark>
  const renderMatched = (text, q) => {
    const lc = text.toLowerCase();
    const i = lc.indexOf(q.toLowerCase());
    if (i < 0 || !q) return text;
    return <>{text.slice(0, i)}<mark className="wp-plan-add-match">{text.slice(i, i + q.length)}</mark>{text.slice(i + q.length)}</>;
  };

  return (
    <div className="wp-plan-add-form" onKeyDown={onKey}>
      <div className="wp-plan-add-h">{mode === "edit" ? `Edit ${noun}` : `Add ${article} ${noun} used this Sunday`}</div>
      <p className="wp-plan-add-hint">{mode === "edit" ?
        (showHymnFields ? "Change the title, hymnal number, tune, reading, or category." :
         showComposer ? "Change the title, composer/arranger, reading, or category." :
         showArtwork ? "Change the title, artist, year, reading, or category." :
         "Change the title, reading, or category.") :
        (showAutocomplete ? `For ${nounPlural} not in the suggestion lists. Type the title — we'll autofill the number and tune if we know it.` :
         `For ${nounPlural} not in the suggestion lists.`)}</p>
      <label className="wp-plan-add-field wp-plan-add-field-title">
        <span className="wp-plan-add-label">Title</span>
        <div className="wp-plan-add-autocomplete">
          <input className="wp-plan-add-input" autoFocus value={title}
          onChange={(e) => showAutocomplete ? onTitleChange(e.target.value) : setTitle(e.target.value)}
          onKeyDown={showAutocomplete ? onTitleKey : undefined}
          onFocus={showAutocomplete ? () => setSuggestionsOpen(true) : undefined}
          onBlur={showAutocomplete ? () => setTimeout(() => setSuggestionsOpen(false), 120) : undefined}
          placeholder={showComposer ? (serviceId === "anthem" ? "Cantate Domino" : serviceId === "handbell" ? "Greensleeves" : "The Gift of Love") : showArtwork ? "The Supper at Emmaus" : "Holy Spirit, Truth Divine"}
          autoComplete="off"
          spellCheck="false"
          role="combobox"
          aria-expanded={suggestionsOpen && suggestions.length > 0}
          aria-autocomplete="list" />
          {pickedKey && !suggestionsOpen &&
          <span className="wp-plan-add-picked" title="Filled from library" aria-hidden="true">
              <svg width="11" height="11" viewBox="0 0 14 14"><path d="M3 7 L6 10 L11 4" stroke="currentColor" strokeWidth="1.75" fill="none" /></svg>
            </span>
          }
          {showAutocomplete && suggestionsOpen && suggestions.length > 0 &&
          <ul className="wp-plan-add-suggestions" role="listbox">
              {suggestions.map((s, i) =>
            <li key={`${s.hymnal || ""}:${s.num || ""}:${s.title}`}
            className={`wp-plan-add-suggestion ${i === highlightIdx ? "on" : ""}`}
            role="option"
            aria-selected={i === highlightIdx}
            onMouseDown={(e) => {e.preventDefault();pickSuggestion(s);}}
            onMouseEnter={() => setHighlightIdx(i)}>
                  <span className="wp-plan-add-suggestion-title">{renderMatched(s.title, title)}</span>
                  <span className="wp-plan-add-suggestion-meta">
                    {s.hymnal && s.num && <span className="wp-plan-add-suggestion-num">{s.hymnal} {s.num}</span>}
                    {s.tune && <span className="wp-plan-add-suggestion-tune">{s.tune}</span>}
                  </span>
                </li>
            )}
            </ul>
          }
        </div>
      </label>
      {showHymnFields &&
      <div className="wp-plan-add-row">
        <label className="wp-plan-add-field">
          <span className="wp-plan-add-label">Hymnal &amp; number</span>
          <input className="wp-plan-add-input wp-plan-add-input-mono" value={id}
          onChange={(e) => {setId(e.target.value);setEditedId(true);}}
          placeholder="UMH 384" />
        </label>
        <label className="wp-plan-add-field">
          <span className="wp-plan-add-label">Tune</span>
          <input className="wp-plan-add-input wp-plan-add-input-mono" value={tune}
          onChange={(e) => {setTune(e.target.value.toUpperCase());setEditedTune(true);}}
          placeholder="HYFRYDOL" />
        </label>
      </div>
      }
      {showComposer &&
      <label className="wp-plan-add-field">
        <span className="wp-plan-add-label">Composer / Arranger</span>
        <input className="wp-plan-add-input" value={composer}
        onChange={(e) => setComposer(e.target.value)}
        placeholder="arr. John Rutter" />
      </label>
      }
      {showArtwork &&
      <div className="wp-plan-add-row">
        <label className="wp-plan-add-field" style={{flex:2}}>
          <span className="wp-plan-add-label">Artist</span>
          <input className="wp-plan-add-input" value={composer}
          onChange={(e) => setComposer(e.target.value)}
          placeholder="Caravaggio" />
        </label>
        <label className="wp-plan-add-field" style={{flex:1}}>
          <span className="wp-plan-add-label">Year <span className="wp-plan-add-opt">(optional)</span></span>
          <input className="wp-plan-add-input" value={year}
          onChange={(e) => setYear(e.target.value)}
          placeholder="1601" />
        </label>
      </div>
      }
      <div className="wp-plan-add-field">
        <span className="wp-plan-add-label">Category</span>
        <div className="wp-plan-add-services" role="group" aria-label="Category">
          {ADD_SERVICES.map((s) =>
          <button key={s.id} type="button"
          className={`wp-plan-add-svc ${serviceId === s.id ? "on" : ""}`}
          data-service={s.id}
          onClick={() => setServiceId(s.id)}>
              <span className="wp-plan-add-svc-dot" aria-hidden="true" />
              {s.label}
            </button>
          )}
        </div>
      </div>
      <div className="wp-plan-add-field">
        <span className="wp-plan-add-label">Tied to which reading <span className="wp-plan-add-opt">(select any)</span></span>
        <div className="wp-plan-add-passages" role="group" aria-label="Readings">
          {[["ot", "OT"], ["psalm", "Psalm"], ["epistle", "Epistle"], ["gospel", "Gospel"], ["general", "General"]].map(([k, l]) =>
          <button key={k} type="button"
          className={`wp-plan-add-pass ${passages.includes(k) ? "on" : ""}`}
          data-passage={k}
          aria-pressed={passages.includes(k)}
          onClick={() => togglePassage(k)}>{l}</button>
          )}
        </div>
      </div>
      <label className="wp-plan-add-field">
        <span className="wp-plan-add-label">Note <span className="wp-plan-add-opt">(optional)</span></span>
        <input className="wp-plan-add-input" value={note}
        onChange={(e) => setNote(e.target.value)}
        placeholder="Why this hymn here?" />
      </label>
      <div className="wp-plan-add-actions">
        <button className="wp-btn wp-btn-ghost" type="button" onClick={onCancel}>Cancel</button>
        <button className="wp-btn wp-btn-primary" type="button" disabled={!canSubmit}
        onClick={submit}>{mode === "edit" ? "Save changes" : "Add to plan"}</button>
      </div>
    </div>);

}

// ---------- "Mark as sung" review dialog (Task 5) ----------
// Records what was ACTUALLY sung on a Sunday into the performance log so the
// last-sung advisories + soft demotion stay current. Planned != sung 100% of the
// time, so the user reviews/unchecks before committing. Advisory-only: feeding
// the log never hides or locks a pick.
function MarkSungDialog({ items, sunday, onClose, onConfirm }) {
  const eligible = (items || []).filter(
    (it) => it.hymn && (it.hymn.ccli || it.hymn.hymnal && it.hymn.num != null));
  const [sel, setSel] = useState(() => new Set(eligible.map((it) => it.key)));
  const toggle = (k) => setSel((prev) => {
    const n = new Set(prev);
    if (n.has(k)) n.delete(k);else n.add(k);
    return n;
  });
  const chosen = eligible.filter((it) => sel.has(it.key));
  return (
    <div className="wp-modal-scrim" onClick={onClose}>
      <div className="wp-modal wp-modal-sung" role="dialog" aria-modal="true" aria-label="Mark as sung"
      onClick={(e) => e.stopPropagation()}>
        <header className="wp-modal-head">
          <div>
            <div className="wp-modal-eyebrow">Mark as sung</div>
            <h2 className="wp-modal-title">{sunday.day}</h2>
          </div>
          <button className="wp-modal-x" onClick={onClose} aria-label="Close">
            <svg width="14" height="14" viewBox="0 0 14 14"><path d="M3 3 L11 11 M11 3 L3 11" stroke="currentColor" strokeWidth="1.25" /></svg>
          </button>
        </header>
        <div className="wp-modal-body">
          <section className="wp-modal-section">
            <p className="wp-modal-hint">
              Records what you actually sang on <strong>{sunday.date}</strong> into the performance
              log, so the “← Sung … ago” advisory and the soft within-tier demotion stay current.
              Uncheck anything you didn’t end up using. This only feeds the advisories — it never
              hides or locks a pick.
            </p>
            {eligible.length === 0 ?
            <p className="wp-modal-warn">No hymns or songs in this service plan yet.</p> :
            <ul className="wp-sung-list">
                {eligible.map((it) => {
                  const h = it.hymn;
                  const id = h.ccli ? `CCLI ${h.ccli}` : `${h.hymnal}${h.num != null ? ` ${h.num}` : ""}`;
                  const on = sel.has(it.key);
                  return (
                    <li key={it.key} className={`wp-sung-row ${on ? "on" : ""}`}>
                      <label className="wp-sung-check">
                        <input type="checkbox" checked={on} onChange={() => toggle(it.key)} />
                        <span className="wp-sung-box" aria-hidden="true">
                          <svg width="11" height="11" viewBox="0 0 14 14">
                            {on ?
                          <path d="M3 7 L6 10 L11 4" stroke="currentColor" strokeWidth="1.75" fill="none" /> :
                          <rect x="2" y="2" width="10" height="10" stroke="currentColor" fill="none" strokeWidth="1" opacity="0.4" />}
                          </svg>
                        </span>
                        <span className="wp-sung-title">{h.title}</span>
                        <span className="wp-sung-id">{id}</span>
                      </label>
                    </li>);

                })}
              </ul>
            }
          </section>
        </div>
        <footer className="wp-modal-foot">
          <div className="wp-modal-foot-summary">
            {chosen.length} {chosen.length === 1 ? "item" : "items"} → performance log
          </div>
          <div className="wp-modal-foot-actions">
            <button className="wp-btn wp-btn-ghost" onClick={onClose}>Cancel</button>
            <button className="wp-btn wp-btn-primary" disabled={!chosen.length}
            onClick={() => onConfirm(chosen.map((it) => it.hymn))}>
              Mark as sung
            </button>
          </div>
        </footer>
      </div>
    </div>);

}

function ServicePlan({ items, total, removeItem, notes, sunday, onAddCustom, onUpdate, onReorder, prayerItems, onRemovePrayer, planView, setPlanView, activeTab, onOpenExport, onMarkSung }) {
  const [adding, setAdding] = useState(false);
  const [editingKey, setEditingKey] = useState(null);


  // Drag-to-reorder within the order of service. Dragging is constrained to
  // a single service group (you order the hymns within a service; you don't
  // move a hymn out of its service by dragging). onReorder receives the full
  // new key sequence across all items and the parent persists it per Sunday.
  const [dragKey, setDragKey] = useState(null);
  const [overKey, setOverKey] = useState(null);
  const reorder = (dragK, targetK) => {
    if (!dragK || dragK === targetK) return;
    const di = items.findIndex((i) => i.key === dragK);
    const ti = items.findIndex((i) => i.key === targetK);
    if (di < 0 || ti < 0) return;
    if (items[di].serviceId !== items[ti].serviceId) return; // same group only
    const arr = items.slice();
    const [moved] = arr.splice(di, 1);
    const tindex = arr.findIndex((i) => i.key === targetK);
    arr.splice(di < ti ? tindex + 1 : tindex, 0, moved);
    onReorder && onReorder(arr.map((i) => i.key));
  };
  // Nudge an item one slot up/down within its service group (keyboard / touch
  // fallback for drag-and-drop).
  const moveBy = (key, dir) => {
    const idx = items.findIndex((i) => i.key === key);
    if (idx < 0) return;
    const svc = items[idx].serviceId;
    let j = idx + dir;
    while (j >= 0 && j < items.length && items[j].serviceId !== svc) j += dir;
    if (j < 0 || j >= items.length || items[j].serviceId !== svc) return;
    reorder(key, items[j].key);
  };

  // Noun for the manual-add button, form header, and hint — keyed to the
  // active planning tab. "Add a hymn manually" on Traditional Hymns,
  // "Add a song manually" on Contemporary Songs, "Add an anthem manually"
  // on Choral Anthems, etc.
  const noun = NOUN_FOR_TAB(activeTab);
  const article = ART_FOR(noun);
  const nounPlural = PLURALIZE(noun);

  // Count per service for the tab pills.
  const counts = useMemo(() => {
    const c = { all: items.length, traditional: 0, contemporary: 0 };
    for (const it of items) c[it.serviceId] = (c[it.serviceId] || 0) + 1;
    return c;
  }, [items]);

  // Visible items for current view.
  const visible = useMemo(() => {
    if (planView === "all") return items;
    return items.filter((it) => it.serviceId === planView);
  }, [items, planView]);

  // For "All" view, group items by service.
  const grouped = useMemo(() =>
    SERVICES.map((s) => ({ svc: s, items: items.filter((it) => it.serviceId === s.id) }))
      .filter((g) => g.items.length > 0),
  [items]);

  // Where do manual adds go by default?
  //  - If viewing one service: into that one.
  //  - If "All" view: follow the tab the user is currently in
  //    (contemporary tab → contemporary; otherwise traditional).
  const defaultAddService = TAB_TO_SERVICE[activeTab] || "traditional";

  const handleAdd = (hymn, passage, serviceId) => {
    onAddCustom(hymn, passage, serviceId);
    setAdding(false);
  };
  const handleUpdate = (key, hymn, passage, serviceId) => {
    onUpdate(key, hymn, passage, serviceId);
    setEditingKey(null);
  };

  const editingItem = editingKey ? items.find((i) => i.key === editingKey) : null;

  // One plan-item rendered in either display or edit-form mode.
  const renderItem = ({ key, hymn, passage, passages, serviceId, auto }) => {
    const passList = passages && passages.length ? passages : [passage || "general"];
    const groupItems = items.filter((i) => i.serviceId === serviceId);
    const gpos = groupItems.findIndex((i) => i.key === key);
    const canUp = gpos > 0;
    const canDown = gpos >= 0 && gpos < groupItems.length - 1;
    if (editingKey === key) {
      return (
        <li key={key} className="wp-plan-item is-editing">
          <AddCustomHymnForm
            mode="edit"
            initial={{ ...hymn, passage, passages: passList, serviceId }}
            noun={noun} article={article} nounPlural={nounPlural}
            onSubmit={(h, p, s) => handleUpdate(key, h, p, s)}
            onCancel={() => setEditingKey(null)} />
        </li>);

    }
    const isDragging = dragKey === key;
    const isOver = overKey === key && dragKey && dragKey !== key &&
      (items.find((i) => i.key === dragKey) || {}).serviceId === serviceId;
    return (
      <li key={key}
        className={`wp-plan-item ${hymn.custom ? "is-custom" : ""} ${isDragging ? "is-dragging" : ""} ${isOver ? "is-drag-over" : ""}`}
        data-service={serviceId}
        {...(auto ? {"data-auto": ""} : {})}
        draggable={!auto && editingKey !== key}
        onDragStart={(e) => { setDragKey(key); e.dataTransfer.effectAllowed = "move"; try { e.dataTransfer.setData("text/plain", key); } catch (err) {} }}
        onDragEnd={() => { setDragKey(null); setOverKey(null); }}
        onDragOver={(e) => { if (dragKey && dragKey !== key) { e.preventDefault(); e.dataTransfer.dropEffect = "move"; if (overKey !== key) setOverKey(key); } }}
        onDragLeave={() => { if (overKey === key) setOverKey(null); }}
        onDrop={(e) => { e.preventDefault(); reorder(dragKey, key); setDragKey(null); setOverKey(null); }}>
        {!auto ? <span className="wp-plan-grip" aria-hidden="true" title="Drag to reorder">
          <svg width="8" height="13" viewBox="0 0 8 13"><circle cx="2" cy="2" r="1"/><circle cx="6" cy="2" r="1"/><circle cx="2" cy="6.5" r="1"/><circle cx="6" cy="6.5" r="1"/><circle cx="2" cy="11" r="1"/><circle cx="6" cy="11" r="1"/></svg>
        </span> : <span></span>}
        <span className="wp-plan-passages-col">
          {hymn._role ?
          <span className="wp-plan-passage wp-plan-passage-role">{hymn._role}</span> :
          passList.map((p) =>
          <span key={p} className="wp-plan-passage" data-passage={p}>
            {window.PASSAGE_SHORT[p] || p}
          </span>
          )}
        </span>
        <div className="wp-plan-item-body">
          <div className="wp-plan-item-title">
            {hymn.title}
            {hymn.custom && <span className="wp-plan-item-custom" title="Added manually">added</span>}
          </div>
          <div className="wp-plan-item-meta">
            {(hymn.ccli || hymn.hymnal) &&
            <span>{hymn.ccli ? `CCLI ${hymn.ccli}` : `${hymn.hymnal}${hymn.num ? ` ${hymn.num}` : ""}`}</span>
            }
            {!hymn.ccli && tuneFor(hymn) && <span className="wp-plan-item-tune">{tuneFor(hymn)}</span>}
            {hymn.composer && <span>{hymn.composer}{hymn.year ? `, ${hymn.year}` : ""}</span>}
            {!hymn.composer && hymn.year && <span>{hymn.year}</span>}
            {hymn.direct && <span className="wp-tag wp-tag-direct sm">direct</span>}
          </div>
          {(notes[key] || hymn.note) &&
          <div className="wp-plan-item-note">
              “{notes[key] || hymn.note}”
            </div>
          }
        </div>
        {auto ? <div className="wp-plan-item-actions"></div> :
        <div className="wp-plan-item-actions">
          <div className="wp-plan-move">
            <button className="wp-plan-move-btn" disabled={!canUp}
              onClick={() => moveBy(key, -1)} aria-label="Move up" title="Move up">
              <svg width="9" height="9" viewBox="0 0 10 10"><path d="M5 2 L8.5 6.5 L1.5 6.5 Z" fill="currentColor" /></svg>
            </button>
            <button className="wp-plan-move-btn" disabled={!canDown}
              onClick={() => moveBy(key, 1)} aria-label="Move down" title="Move down">
              <svg width="9" height="9" viewBox="0 0 10 10"><path d="M5 8 L1.5 3.5 L8.5 3.5 Z" fill="currentColor" /></svg>
            </button>
          </div>
          {hymn.custom &&
          <button className="wp-plan-edit" onClick={() => setEditingKey(key)} aria-label="Edit" title="Edit">
              <svg width="11" height="11" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round">
                <path d="M2 10 L4 9.5 L9.5 4 L8 2.5 L2.5 8 Z" />
              </svg>
            </button>
          }
          <button className="wp-plan-x" onClick={() => removeItem(key)} aria-label="Remove" title="Remove">
            <svg width="11" height="11" viewBox="0 0 11 11"><path d="M2 2 L9 9 M9 2 L2 9" stroke="currentColor" strokeWidth="1.25" /></svg>
          </button>
        </div>}
      </li>);

  };

  const hasPrayers = !!(prayerItems && prayerItems.length);
  const empty = total === 0;
  const filteredEmpty = !empty && visible.length === 0;

  return (
    <aside className="wp-plan">
      <div className="wp-plan-head">
        <div>
          <div className="wp-plan-eyebrow">Service plan</div>
          <div className="wp-plan-title">{sunday.day}</div>
        </div>
        <div className="wp-plan-count">
          <span className="wp-plan-count-n">{counts.all}</span>
          <span className="wp-plan-count-l">total</span>
        </div>
      </div>

      {empty ?
      (hasPrayers ? null :
      <div className="wp-plan-empty">
          <div className="wp-plan-empty-art" aria-hidden="true">
            <svg width="56" height="56" viewBox="0 0 56 56">
              <rect x="10" y="10" width="36" height="36" fill="none" stroke="currentColor" strokeWidth="1" opacity="0.35" />
              <path d="M16 22 L40 22 M16 28 L34 28 M16 34 L38 34" stroke="currentColor" strokeWidth="1" opacity="0.25" />
            </svg>
          </div>
          <p>Tick a hymn, song, anthem, or artwork to add it to the order of service.</p>
        </div>) :
      <div className="wp-plan-groups">
          {grouped.map((g) =>
        <div key={g.svc.id} className="wp-plan-group">
              <div className="wp-plan-group-h" data-service={g.svc.id}>
                <span className="wp-plan-group-dot" aria-hidden="true" />
                <span className="wp-plan-group-l">{g.svc.label}</span>
                <span className="wp-plan-group-c">{g.items.length}</span>
              </div>
              <ol className="wp-plan-list">
                {g.items.map(renderItem)}
              </ol>
            </div>
        )}
        </div>
      }

      {hasPrayers &&
      <section className="wp-plan-prayers">
        <div className="wp-plan-group-h" data-service="prayers">
          <span className="wp-plan-group-dot" aria-hidden="true" />
          <span className="wp-plan-group-l">Prayers</span>
          <span className="wp-plan-group-c">{prayerItems.length}</span>
        </div>
        <ol className="wp-plan-list wp-plan-prayer-list">
          {prayerItems.map(({ key, desc, resolved }) =>
          <li key={key} className="wp-plan-prayer">
            <div className="wp-plan-prayer-main">
              <div className="wp-plan-prayer-l">{resolved.meta.label}</div>
              <div className="wp-plan-prayer-meta">
                {!resolved.meta.single &&
                  <span className="wp-plan-prayer-reg">{resolved.register}</span>}
                {resolved.cells.map((c, i) =>
                  <span key={i} className="wp-plan-prayer-perm">
                    {c.perm.split("+").length === 4 ? "all four" : c.perm}
                  </span>)}
              </div>
            </div>
            <button className="wp-plan-x" onClick={() => onRemovePrayer(key)}
              aria-label="Remove prayer" title="Remove">
              <svg width="11" height="11" viewBox="0 0 11 11"><path d="M2 2 L9 9 M9 2 L2 9" stroke="currentColor" strokeWidth="1.25" /></svg>
            </button>
          </li>)}
        </ol>
      </section>
      }

      {activeTab === "prayers" ? null :
      adding ?
      <AddCustomHymnForm
        mode="add"
        defaultService={defaultAddService}
        noun={noun} article={article} nounPlural={nounPlural}
        onSubmit={handleAdd}
        onCancel={() => setAdding(false)} /> :
      !editingItem &&
      <button type="button" className="wp-plan-add-btn" onClick={() => setAdding(true)}>
          <span className="wp-plan-add-btn-plus" aria-hidden="true">+</span>
          <span className="wp-plan-add-btn-l">{`Add ${article} ${noun} manually`}</span>
          <span className="wp-plan-add-btn-h">
            {`for ${nounPlural} not in the suggestion lists`}
          </span>
        </button>
      }

      <div className="wp-plan-foot">
        <button className="wp-btn wp-btn-ghost" onClick={backupPlannerState}
        title="Download a backup file of every selection, note, and setting">Back up</button>

        <button className="wp-btn wp-btn-primary" onClick={onOpenExport}>Export…</button>
      </div>


    </aside>);

}

// ---------- cross-cutting strip ----------

function CrossCutting({ items, selected, toggle, kind, usage, currentDayId, currentTs, hidden }) {
  if (!items?.length) return null;
  const used = hidden || new Set();
  // A cross-cutting pick stays while at least one reading it bridges is still in
  // use (per the shared Scripture-used bar). Picks with no passage keying always
  // remain. We map over the full list and skip-render dropped picks so each
  // surviving pick keeps its original index — and therefore its selection key.
  const isVisible = (h) => {
    const ps = h.passages || [];
    return !ps.length || ps.some((p) => !used.has(p));
  };
  if (!items.some(isVisible)) return null;
  return (
    <section className="wp-cross">
      <div className="wp-cross-h">
        <span className="wp-cross-h-l">Cross-cutting picks</span>
        <span className="wp-cross-h-s">{(kind === "contemporary" ? "SONGS" : "HYMNS") + " THAT BRIDGE MULTIPLE READINGS"}</span>
      </div>
      <div className="wp-cross-list">
        {items.map((h, i) => {
          if (!isVisible(h)) return null;
          const k = kind === "contemporary" ? `cross:ccli:${h.ccli}:${i}` : `cross:${h.hymnal}:${h.num}:${i}`;
          const isSelected = selected.has(k);
          // bridge passage = first listed (for sort/order)
          const primaryPassage = h.passages?.[0] || "ot";
          const tune = kind !== "contemporary" ? tuneFor(h) : null;
          const use = usage ? hymnUsage(usage, h, currentDayId) : { text: null, tune: null, ccli: null };
          return (
            <button key={i} type="button"
            className={`wp-cross-card ${h.feature ? "is-feature" : ""} ${isSelected ? "is-selected" : ""}`}
            onClick={() => toggle(k, h, primaryPassage)}
            aria-pressed={isSelected}>
              <span className="wp-cross-card-check" aria-hidden="true">
                <svg width="14" height="14" viewBox="0 0 14 14">
                  {isSelected ?
                  <path d="M3 7 L6 10 L11 4" stroke="currentColor" strokeWidth="1.75" fill="none" /> :
                  <circle cx="7" cy="7" r="5" stroke="currentColor" strokeWidth="1" fill="none" opacity="0.5" />}
                </svg>
              </span>
              <div className="wp-cross-card-passages">
                {PASSAGES.map((p) =>
                <PassageDot key={p} p={p} on={h.passages?.includes(p)}
                label={window.PASSAGE_LABELS[p]} />
                )}
              </div>
              <div className="wp-cross-card-id">
                <span className="wp-cross-num">
                  {kind === "contemporary" ? `CCLI ${h.ccli}` : `${h.hymnal} ${h.num}`}
                  {tune && <span className="wp-hymn-tune"> · {tune}</span>}
                </span>
                {h.feature && <span className="wp-feature-mark">★ feature</span>}
              </div>
              <div className="wp-cross-card-title">
                {h.title}
                {kind === "contemporary" && h.artist && <span className="wp-cross-card-artist"> · {h.artist}</span>}
              </div>
              <div className="wp-cross-card-meta">
                <TierDot tier={h.tier} />
                {kind !== "contemporary" && <SourceMeter sources={h.sources} />}
                {kind !== "contemporary" && <OrchBadge hymn={h} />}
                {kind === "contemporary" && h.repertoire === false && <span className="wp-tag wp-tag-new">[NEW]</span>}
                {kind === "contemporary" && h.seedbed && <span className="wp-tag wp-tag-seedbed">Seedbed</span>}
              </div>
              <div className="wp-cross-card-note">{h.note}</div>
              {kind === "contemporary" && h.ccli && window.lastSungInfo &&
              (() => { const ls = window.lastSungInfo(String(h.ccli), currentTs); return ls ? <SungLastPill ls={ls} /> : null; })()
              }
              {kind === "contemporary" && (h.youtube || h.praisecharts) &&
              <div className="wp-cross-card-links">
                  {h.youtube &&
                <span className="wp-hymn-link" role="link" tabIndex={0}
                title={`Find "${h.title}" on YouTube`}
                onClick={(e) => {e.stopPropagation();window.open(h.youtube, "_blank", "noopener");}}>
                      <svg width="11" height="11" viewBox="0 0 14 14" aria-hidden="true">
                        <rect x="0.6" y="2.6" width="12.8" height="8.8" rx="2.4" fill="none" stroke="currentColor" strokeWidth="1.1" />
                        <path d="M5.6 5 L9.2 7 L5.6 9 Z" fill="currentColor" />
                      </svg>
                      Listen
                    </span>}
                  {h.praisecharts &&
                <span className="wp-hymn-link" role="link" tabIndex={0}
                title={`Find charts for "${h.title}" on PraiseCharts`}
                onClick={(e) => {e.stopPropagation();window.open(h.praisecharts, "_blank", "noopener");}}>
                      <svg width="11" height="11" viewBox="0 0 14 14" aria-hidden="true">
                        <path d="M2 2.7 H12 M2 7 H12 M2 11.3 H8" stroke="currentColor" strokeWidth="1.1" fill="none" strokeLinecap="round" />
                      </svg>
                      Charts
                    </span>}
                </div>
              }
              {(use.text || use.tune || use.ccli) &&
              <div className="wp-cross-card-uses">
                  {kind === "contemporary" ?
                <LastUsedPill kind="ccli" rec={use.ccli} currentTs={currentTs} /> :
                <>
                        {use.text && <LastUsedPill kind="hymn" rec={use.text} currentTs={currentTs} />}
                        {use.tune && (!use.text || use.tune.dayId !== use.text.dayId) &&
                <LastUsedPill kind="tune" rec={use.tune} currentTs={currentTs} tune={tune} />}
                      </>}
                </div>
              }
            </button>);

        })}
      </div>
    </section>);

}

// ---------- gaps + tone notes ----------

function Notices({ sunday }) {
  const out = [];
  if (sunday.toneNote) out.push(
    <div key="tone" className="wp-notice wp-notice-tone">
      <div className="wp-notice-icon">
        <svg width="16" height="16" viewBox="0 0 16 16"><path d="M8 1 L15 14 L1 14 Z M8 6 L8 10 M8 12 L8 12.5" stroke="currentColor" fill="none" strokeWidth="1.25" /></svg>
      </div>
      <div>
        <div className="wp-notice-title">{sunday.toneNote.title}</div>
        <p className="wp-notice-body">{sunday.toneNote.body}</p>
      </div>
    </div>
  );
  if (sunday.gaps?.length) sunday.gaps.forEach((g, i) => out.push(
    <div key={"g" + i} className="wp-notice wp-notice-gap">
      <div className="wp-notice-icon">
        <svg width="16" height="16" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6.5" stroke="currentColor" fill="none" strokeWidth="1" /><path d="M8 4 L8 9 M8 11 L8 11.5" stroke="currentColor" strokeWidth="1.25" /></svg>
      </div>
      <div>
        <div className="wp-notice-title">Coverage gap</div>
        <p className="wp-notice-body">{g}</p>
      </div>
    </div>
  ));
  if (out.length === 0) return null;
  return <div className="wp-notices">{out}</div>;
}

// ---------- main planner ----------

function TabPlaceholder({ tab, sunday }) {
  if (!tab) return null;
  return (
    <section className="wp-tab-empty">
      <div className="wp-tab-empty-icon" aria-hidden="true">
        <svg width="40" height="40" viewBox="0 0 40 40">
          <path d={tab.glyph} stroke="currentColor" fill="none" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" />
        </svg>
      </div>
      <h2 className="wp-tab-empty-h">{tab.label}</h2>
      <p className="wp-tab-empty-lede">{tab.lede}</p>
      <p className="wp-tab-empty-sub">
        Once a content library is wired up, picks for <em>{sunday.day}</em> will appear here —
        organized by the same four readings, with the same selection workflow as Traditional Hymns.
      </p>
      <ul className="wp-tab-empty-fields">
        {tab.fields.map((f) => <li key={f}>{f}</li>)}
      </ul>
    </section>);

}

function WorshipPlanner({ theme, initialIdx = 0 }) {
  // Sundays flagged `hidden` (e.g. Easter Vigil) are kept in the data for joins
  // but dropped from the planner's navigation, pips, and export range.
  const sundays = (window.SUNDAYS || []).filter((s) => !s.hidden);
  // ---- localStorage persistence helpers ----
  // Values are stored under "wp:<name>". Sets and Maps-of-Sets are encoded as
  // arrays so JSON round-trips cleanly.
  const lsLoad = (key, fallback) => {
    try {
      const raw = localStorage.getItem("wp:" + key);
      if (raw == null) return fallback;
      return JSON.parse(raw);
    } catch (_) {return fallback;}
  };
  const lsSave = (key, value) => {
    try {localStorage.setItem("wp:" + key, JSON.stringify(value));} catch (_) {}
  };
  // Encode/decode a Set for storage.
  const encSet = (s) => Array.from(s || []);
  const decSet = (a) => new Set(Array.isArray(a) ? a : []);
  // Encode/decode a {dayKey: Set} map.
  const encDaySetMap = (m) => {
    const out = {};
    for (const k of Object.keys(m || {})) out[k] = Array.from(m[k] || []);
    return out;
  };
  const decDaySetMap = (o) => {
    const out = {};
    for (const k of Object.keys(o || {})) out[k] = new Set(o[k] || []);
    return out;
  };

  // Remember the last-viewed Sunday across reloads. Keyed by sunday.id so the
  // saved value survives even if the lectionary array gets reordered.
  const [idx, setIdx] = useState(() => {
    const savedId = lsLoad("lastSundayId", null);
    if (savedId) {
      const i = sundays.findIndex((s) => s.id === savedId);
      if (i >= 0) return i;
    }
    return initialIdx;
  });
  React.useEffect(() => {lsSave("lastSundayId", sundays[idx].id);}, [idx, sundays]);

  const [hymnals, setHymnals] = useState(() => decSet(lsLoad("hymnals", ["UMH", "TFWS"])));
  React.useEffect(() => {lsSave("hymnals", encSet(hymnals));}, [hymnals]);
  const toggleHymnal = (h) => setHymnals((prev) => {
    const next = new Set(prev);
    if (next.has(h)) {
      if (next.size > 1) next.delete(h); // never let it go empty
    } else next.add(h);
    return next;
  });
  const hymnalAllowed = (h, maps) => {
    if (!h) return true;
    // Allow ecumenical/cross-hymnal entries (e.g. "GtG/H1982") if any token matches
    const tokens = h.split(/[\/\s,]+/);
    if (tokens.some((t) => hymnals.has(t))) return true;
    // Master-ranking hymnal-profile view: a hymn also passes when its cross-hymnal
    // `hymnals` map (from the hymnary/CTW/Pavlechko crosswalk) has a number in any
    // selected hymnal — so a GtG-only profile still sees UMH hymns that ARE in GtG.
    return !!maps && Object.keys(maps).some((c) => hymnals.has(c));
  };
  const filterBucket = (arr) => (arr || []).filter((it) => hymnalAllowed(it.hymnal));
  const [density, setDensity] = useState(() => lsLoad("density", "comfortable"));
  React.useEffect(() => {lsSave("density", density);}, [density]);
  const [tab, setTab] = useState(() => lsLoad("tab", "hymns"));
  React.useEffect(() => {lsSave("tab", tab);}, [tab]);
  // Service plan view: "all" shows everything grouped by service; or filter
  // to a single service. Persisted globally — most planners stick to one
  // mode across the year.
  const [planView, setPlanView] = useState(() => lsLoad("planView", "all"));
  React.useEffect(() => {lsSave("planView", planView);}, [planView]);
  // Lectionary track (semi-continuous / complementary / CCT revision) —
  // shared by the Hymns, Contemporary, and Solo tabs. Remembered PER SUNDAY
  // in wp:hymnTrackByDay; wp:hymnTrackLast is the season default. Both are
  // stored in the same raw-string format hymn-plan.jsx used, so existing
  // saved choices carry over.
  const [trackMap, setTrackMap] = useState(() => {
    try {return JSON.parse(localStorage.getItem("wp:hymnTrackByDay") || "{}") || {};} catch (_) {return {};}
  });
  const [trackFallback, setTrackFallback] = useState(() => {
    try {return localStorage.getItem("wp:hymnTrackLast") || localStorage.getItem("wp:hymnTrack") || "both";} catch (_) {return "both";}
  });
  // RCL version (traditional / cct) — separate from the semi/comp track.
  // Remembered per Sunday (wp:rclVersionByDay) with a season fallback (wp:rclVersionLast).
  const [rclVersionMap, setRclVersionMap] = useState(() => {
    try {return JSON.parse(localStorage.getItem("wp:rclVersionByDay") || "{}") || {};} catch (_) {return {};}
  });
  const [rclVersionFallback, setRclVersionFallback] = useState(() => {
    try {return localStorage.getItem("wp:rclVersionLast") || "traditional";} catch (_) {return "traditional";}
  });
  // UMC alternate-psalm toggle (Book of Worship ¶227–237) — per-day boolean,
  // persisted under wp:altPsalmByDay. Only "effective" on Sundays whose current
  // track actually offers an alternate (see altAvail below).
  const [altByDay, setAltByDay] = useState(() => {
    try {return JSON.parse(localStorage.getItem("wp:altPsalmByDay") || "{}") || {};} catch (_) {return {};}
  });
  const [exportOpen, setExportOpen] = useState(false);
  // One-time seed of imported history (window.HISTORY_PLANS) into the service
  // plan. Fills only Sundays the planner hasn't already worked — the user's own
  // selections always win. Computed once so selected + lookup stay consistent.
  const _seed = React.useRef(null);
  if (!_seed.current) {
    const sel = decDaySetMap(lsLoad("selectedByDay", {}));
    const look = lsLoad("hymnLookup", {});
    const plans = window.HISTORY_PLANS || {};
    for (const dayId in plans) {
      if (sel[dayId] && sel[dayId].size > 0) continue; // already worked — keep theirs
      const set = new Set();
      const dayLook = { ...(look[dayId] || {}) };
      (plans[dayId] || []).forEach((h, i) => {
        const key = h.num != null ? `${h.hymnal}:${h.num}` : `hist:${dayId}:${i}`;
        set.add(key);
        dayLook[key] = {
          hymn: { hymnal: h.hymnal || null, num: h.num != null ? String(h.num) : null, title: h.title, note: h.note || null },
          passage: "general", passages: ["general"], serviceId: "traditional"
        };
      });
      sel[dayId] = set;
      look[dayId] = dayLook;
    }
    _seed.current = { sel, look };
  }
  const [selectedByDay, setSelectedByDay] = useState(_seed.current.sel);
  React.useEffect(() => {lsSave("selectedByDay", encDaySetMap(selectedByDay));}, [selectedByDay]);
  const [notesByDay, setNotesByDay] = useState(() => lsLoad("notesByDay", {}));
  React.useEffect(() => {lsSave("notesByDay", notesByDay);}, [notesByDay]);
  const [hymnLookup, setHymnLookup] = useState(_seed.current.look);
  React.useEffect(() => {lsSave("hymnLookup", hymnLookup);}, [hymnLookup]);
  // Passages the planner has marked as "not used this Sunday" (e.g. epistle skipped).
  // Per-day Set so toggling one Sunday doesn't affect others.
  const [hiddenByDay, setHiddenByDay] = useState(() => decDaySetMap(lsLoad("hiddenByDay", {})));
  React.useEffect(() => {lsSave("hiddenByDay", encDaySetMap(hiddenByDay));}, [hiddenByDay]);
  // User-defined order of service-plan items, per Sunday. Array of item keys;
  // items not listed fall back to default reading order. Persisted under
  // wp:planOrderByDay (round-tripped by the backup/restore flow with all wp:*).
  const [planOrderByDay, setPlanOrderByDay] = useState(() => lsLoad("planOrderByDay", {}));
  React.useEffect(() => {lsSave("planOrderByDay", planOrderByDay);}, [planOrderByDay]);

  const sunday = sundays[idx];
  const dayKey = sunday.id;
  const selected = selectedByDay[dayKey] || new Set();
  const notes = notesByDay[dayKey] || {};
  const lookup = hymnLookup[dayKey] || {};

  // ---- prayer picks (Prayers tab → service plan / export) ----
  // Per-day map keyed by element id: { [elementId]: { element, register, perms } }.
  // Isolated from the hymn/song selection model; persisted under wp:prayerPlanByDay.
  const [prayerPlanByDay, setPrayerPlanByDay] = useState(() => lsLoad("prayerPlanByDay", {}));
  const prayerPicks = prayerPlanByDay[dayKey] || {};
  const addPrayer = (desc) => setPrayerPlanByDay((prev) => {
    const key = window.prayerPickKey(desc);
    const next = { ...prev, [dayKey]: { ...(prev[dayKey] || {}), [key]: desc } };
    lsSave("prayerPlanByDay", next); return next;
  });
  const removePrayer = (key) => setPrayerPlanByDay((prev) => {
    const day = { ...(prev[dayKey] || {}) }; delete day[key];
    const next = { ...prev };
    if (Object.keys(day).length) next[dayKey] = day; else delete next[dayKey];
    lsSave("prayerPlanByDay", next); return next;
  });
  const prayerPlanItems = React.useMemo(() => {
    const order = (window.PRAYER_ELEMENTS || []).map((e) => e.id);
    return Object.entries(prayerPicks)
      .map(([key, desc]) => ({ key, desc, resolved: window.resolvePrayerPick && window.resolvePrayerPick(sunday, desc) }))
      .filter((x) => x.resolved)
      .sort((a, b) => {
        const d = order.indexOf(a.desc.element) - order.indexOf(b.desc.element);
        if (d) return d;
        return ((a.desc.perms || [])[0] || "").localeCompare(((b.desc.perms || [])[0] || ""));
      });
  }, [prayerPicks, sunday]);

  const hidden = hiddenByDay[dayKey] || new Set();
  const toggleHidden = (p) => setHiddenByDay((prev) => {
    const next = new Set(prev[dayKey] || []);
    if (next.has(p)) next.delete(p);else next.add(p);
    return { ...prev, [dayKey]: next };
  });

  const track = trackMap[dayKey] || trackFallback;
  const changeTrack = (t) => {
    setTrackMap((prev) => {
      const next = { ...prev, [dayKey]: t };
      try {localStorage.setItem("wp:hymnTrackByDay", JSON.stringify(next));} catch (_) {}
      return next;
    });
    setTrackFallback(t);
    try {localStorage.setItem("wp:hymnTrackLast", t);} catch (_) {}
  };
  const trackOpts = trackOptsFor(dayKey);
  const hasCct = hasCctFor(dayKey);
  const rclVersion = hasCct ? (rclVersionMap[dayKey] || rclVersionFallback) : "traditional";
  const changeRclVersion = (v) => {
    setRclVersionMap((prev) => {
      const next = { ...prev, [dayKey]: v };
      try {localStorage.setItem("wp:rclVersionByDay", JSON.stringify(next));} catch (_) {}
      return next;
    });
    setRclVersionFallback(v);
    try {localStorage.setItem("wp:rclVersionLast", v);} catch (_) {}
  };
  const effTrackBase = effTrackFor(dayKey, track, rclVersion);
  // Is a UMC alternate psalm available for the currently-selected track? Gated on
  // the UMH hymnal being selected at the top — the alternate lives in the UMH
  // Psalter, so it is only offered to planners who carry UMH.
  const psalterAlt = (hymnals.has("UMH") ? altPsalmFor(sunday, effTrackBase) : null);
  const altAvail = !!psalterAlt;
  const altActive = altAvail && !!altByDay[dayKey];
  const effTrack = effTrackFor(dayKey, track, rclVersion, altActive);
  const toggleAltPsalm = () => {
    setAltByDay((prev) => {
      const next = { ...prev, [dayKey]: !prev[dayKey] };
      try {localStorage.setItem("wp:altPsalmByDay", JSON.stringify(next));} catch (_) {}
      return next;
    });
    // Turning the alt on means the psalm IS in use (as the alternate) — clear any
    // "set aside" on the psalm so the two read as one-or-the-other, never neither.
    if (!altByDay[dayKey]) setHiddenByDay((prev) => {
      const s = new Set(prev[dayKey] || []);
      if (!s.has("psalm")) return prev;
      s.delete("psalm");
      return { ...prev, [dayKey]: s };
    });
  };

  const toggle = (k, hymn, passage, serviceId) => {
    // Default service: contemporary songs land in the "contemporary" service,
    // hymns/cross-cutting land in "traditional". Caller can override.
    const svc = serviceId || (hymn && hymn.ccli ? "contemporary" : "traditional");
    setSelectedByDay((prev) => {
      const next = new Set(prev[dayKey] || []);
      if (next.has(k)) next.delete(k);else next.add(k);
      return { ...prev, [dayKey]: next };
    });
    setHymnLookup((prev) => ({ ...prev, [dayKey]: { ...(prev[dayKey] || {}), [k]: { hymn, passage, serviceId: svc } } }));
  };
  const setNote = (k, v) => setNotesByDay((prev) => ({ ...prev, [dayKey]: { ...(prev[dayKey] || {}), [k]: v } }));
  const removeItem = (k) => {
    setSelectedByDay((prev) => {
      const next = new Set(prev[dayKey] || []);next.delete(k);
      return { ...prev, [dayKey]: next };
    });
    // Drop the lookup entry too, so removed items don't linger in storage.
    // Re-ticking a suggestion row recreates it via toggle().
    setHymnLookup((prev) => {
      const dayMap = prev[dayKey];
      if (!dayMap || !(k in dayMap)) return prev;
      const next = { ...dayMap };delete next[k];
      return { ...prev, [dayKey]: next };
    });
  };

  // Add a hymn that's not in any of the suggested lists. Treated like any
  // other selected hymn — appears in the service plan AND populates the
  // last-used index, so the next time the planner is considering this title
  // or tune they'll see "Text last used: ..." on the suggestion.
  const addCustomHymn = (hymn, passages, serviceId) => {
    const k = `custom:${Date.now()}:${Math.random().toString(36).slice(2, 7)}`;
    const svc = serviceId || "traditional";
    const passList = Array.isArray(passages) ? passages : [passages || "general"];
    setSelectedByDay((prev) => {
      const next = new Set(prev[dayKey] || []);
      next.add(k);
      return { ...prev, [dayKey]: next };
    });
    setHymnLookup((prev) => ({
      ...prev,
      [dayKey]: { ...(prev[dayKey] || {}), [k]: { hymn: { ...hymn, custom: true }, passage: passList[0], passages: passList, serviceId: svc } }
    }));
  };

  // Update an existing item (only manual custom adds are user-editable).
  const updateItem = (k, hymn, passages, serviceId) => {
    const passList = Array.isArray(passages) ? passages : [passages || "general"];
    setHymnLookup((prev) => {
      const dayMap = prev[dayKey] || {};
      const existing = dayMap[k];if (!existing) return prev;
      const merged = { ...existing.hymn, ...hymn };
      return { ...prev, [dayKey]: { ...dayMap, [k]: { hymn: merged, passage: passList[0], passages: passList, serviceId: serviceId || existing.serviceId || "traditional" } } };
    });
  };

  const planItems = useMemo(() => {
    const order = { ot: 0, psalm: 1, epistle: 2, gospel: 3, general: 4 };
    const custom = planOrderByDay[dayKey] || [];
    const orderIdx = (k) => { const i = custom.indexOf(k); return i < 0 ? Infinity : i; };
    const arr = Array.from(selected).map((k) => {
      const ent = lookup[k];if (!ent) return null;
      // Lazy migration: pre-multi-service entries have no serviceId. Default
      // contemporary songs to "contemporary", everything else to "traditional".
      const serviceId = serviceIdFor(k, ent.hymn, ent.serviceId);
      const passages = ent.passages && ent.passages.length ? ent.passages : (ent.passage ? [ent.passage] : ["general"]);
      return { key: k, hymn: ent.hymn, passage: passages[0], passages, serviceId };
    }).filter(Boolean).sort((a, b) => {
      // User-ordered items first (in their chosen order); the rest follow in
      // default reading order.
      const ao = orderIdx(a.key), bo = orderIdx(b.key);
      if (ao !== bo) return ao - bo;
      return (order[a.passage] ?? 99) - (order[b.passage] ?? 99);
    });
    // Auto-populate organ prelude/postlude from ORGAN_PLAN when no manual
    // organ items have been added (mirrors the weekly-grid export logic).
    const hasManualOrgan = arr.some((it) => it.serviceId === "organ");
    if (!hasManualOrgan) {
      const op = (window.ORGAN_PLAN || {})[dayKey];
      if (op) {
        if (op.prelude) arr.push({ key: "organ:auto:prelude", hymn: { title: op.prelude, _role: "Prelude" }, passage: "general", passages: ["general"], serviceId: "organ", auto: true });
        if (op.postlude) arr.push({ key: "organ:auto:postlude", hymn: { title: op.postlude, _role: "Postlude" }, passage: "general", passages: ["general"], serviceId: "organ", auto: true });
      }
    }
    return arr;
  }, [selected, lookup, planOrderByDay, dayKey]);

  // Persist a new full ordering of the day's plan items.
  const reorderPlan = (orderedKeys) =>
    setPlanOrderByDay((prev) => ({ ...prev, [dayKey]: orderedKeys }));

  // Usage index — derived from selections across ALL Sundays. This is what
  // powers the "last used" pills on hymn rows. Excludes the current Sunday
  // at the row level (since the checkmark already conveys "selected here").
  const usage = useMemo(
    () => buildUsageIndex(selectedByDay, hymnLookup, sundays),
    [selectedByDay, hymnLookup, sundays]
  );
  const currentTs = Date.parse(sunday.date) || 0;

  // ---- "Mark as sung" → append to the performance log (Task 5) ----
  // Writes user-recorded sung dates into BOTH window.SUNG_LOG (live, so the
  // last-sung advisories update immediately) and localStorage wp:sungLogAdds
  // (so they persist + round-trip through Backup & restore). De-dupes per date.
  // Advisory-only: this feeds the guards, it never locks or hides a pick.
  const [, setSungBump] = useState(0);
  const isoFromDate = (dateStr) => {
    const t = Date.parse(dateStr);
    if (!t) return null;
    const d = new Date(t);
    const mm = String(d.getMonth() + 1).padStart(2, "0");
    const dd = String(d.getDate()).padStart(2, "0");
    return `${d.getFullYear()}-${mm}-${dd}`;
  };
  const markPlanSung = (hymns) => {
    const iso = isoFromDate(sunday.date);
    if (!iso || !window.SUNG_LOG) return 0;
    const adds = lsLoad("sungLogAdds", {}) || {};
    let n = 0;
    (hymns || []).forEach((h) => {
      if (!h) return;
      const key = h.ccli ? String(h.ccli) :
      h.hymnal && h.num != null ? `${h.hymnal} ${h.num}` : null;
      if (!key) return;
      const cur = window.SUNG_LOG[key] || (window.SUNG_LOG[key] = []);
      if (cur.indexOf(iso) < 0) {cur.push(iso);cur.sort();}
      const a = adds[key] || (adds[key] = []);
      if (a.indexOf(iso) < 0) {a.push(iso);a.sort();}
      n++;
    });
    lsSave("sungLogAdds", adds);
    setSungBump((x) => x + 1); // re-render so advisories pick up the new dates
    return n;
  };

  const goto = (i) => setIdx((i % sundays.length + sundays.length) % sundays.length);
  const onPrev = () => goto(idx - 1);
  const onNext = () => goto(idx + 1);

  return (
    <div className="wp-root" data-wp-theme={theme} data-wp-density={density}
    style={{
      "--wp-season": sunday.color
    }}>
      <PlannerHeader sunday={sunday} sundays={sundays} idx={idx}
      onPrev={onPrev} onNext={onNext} onGoto={goto}
      hymnals={hymnals} toggleHymnal={toggleHymnal}
      density={density} setDensity={setDensity}
      tab={tab} setTab={setTab}
      onOpenExport={() => setExportOpen(true)} />

      <div className="wp-body">
        <main className="wp-main">
          {["hymns", "contemporary", "artwork", "solo", "prayers", "anthems", "handbells"].includes(tab) &&
          <ScriptureUsedBar sunday={sunday} hidden={hidden} toggleHidden={toggleHidden} effTrack={effTrack}
          psalterAlt={psalterAlt} altActive={altActive} onToggleAlt={toggleAltPsalm} />}
          {["hymns", "contemporary", "solo", "artwork", "anthems", "handbells"].includes(tab) &&
          <TrackBar sundayId={dayKey} track={track} rclVersion={rclVersion}
          changeTrack={changeTrack} changeRclVersion={changeRclVersion} />}
          {tab === "hymns" && window.HYMN_PLAN && window.HYMN_PLAN[sunday.id] && window.HymnPlanView ?
          <>
              <Notices sunday={sunday} />
              <window.HymnPlanView key={sunday.id}
              plan={window.HYMN_PLAN[sunday.id]} sunday={sunday}
              selected={selected} toggle={toggle}
              notes={notes} setNote={setNote}
              hymnalAllowed={hymnalAllowed} hymnalProfile={hymnals}
              hidden={hidden} toggleHidden={toggleHidden}
              track={track} changeTrack={changeTrack}
              rclVersion={rclVersion} changeRclVersion={changeRclVersion}
              usage={usage} currentDayId={dayKey} currentTs={currentTs} />
            </> :
          tab === "hymns" || tab === "contemporary" ?
          <>
              <Notices sunday={sunday} />
              <CrossCutting
              items={tab === "contemporary" ?
              sunday.crossCuttingContemporary || [] :
              (sunday.crossCutting || []).filter((it) => hymnalAllowed(it.hymnal))}
              kind={tab === "contemporary" ? "contemporary" : "hymn"}
              selected={selected} toggle={toggle} hidden={hidden}
              usage={usage} currentDayId={dayKey} currentTs={currentTs} />

              <div className="wp-cols-h">
                <span>{tab === "contemporary" ? "Contemporary by reading" : "Music by reading"}</span>
                <span className="wp-cols-h-hint">
                  {tab === "contemporary" ?
                "From the church's repertoire · Seedbed picks · CCLI top-100 candidates" :
                "Three tiers per reading: strong · thematic · further afield"}
                </span>
              </div>
              <div className="wp-cols">
                {PASSAGES.filter((p) => !hidden.has(p)).map((p) => {
                const src = tab === "contemporary" ?
                sunday.music[p]?.contemporary || { strong: [], thematic: [], loose: [] } :
                sunday.music[p];
                const psalmAlt = p === "psalm" && parseEffTrack(effTrack).alt;
                const tAllow = (arr) => (arr || []).filter((it) => psalmAlt ? it.track === "alt" : trackAllows(it.track, effTrack));
                const filteredMusic = tab === "contemporary" ?
                {
                  strong: tAllow(src.strong),
                  thematic: tAllow(src.thematic),
                  loose: tAllow(src.loose)
                } :
                {
                  strong: tAllow(filterBucket(src?.strong)),
                  thematic: tAllow(filterBucket(src?.thematic)),
                  loose: tAllow(filterBucket(src?.loose))
                };
                return (
                  <MusicColumn key={p} pkey={p}
                  label={window.PASSAGE_LABELS[p]}
                  reading={readingForTrack(sunday, p, effTrack)}
                  music={filteredMusic}
                  kind={tab === "contemporary" ? "contemporary" : "hymn"}
                  selected={selected} toggle={toggle}
                  notes={notes} setNote={setNote}
                  usage={usage} currentDayId={dayKey} currentTs={currentTs} />);

              })}
              </div>
            </> :
          tab === "artwork" && window.ArtworkView ?
          <window.ArtworkView sunday={sunday} hidden={hidden} toggleHidden={toggleHidden} effTrack={effTrack}
          selected={selected} toggle={toggle} /> :

          tab === "solo" && window.SoloView ?
          <window.SoloView sunday={sunday} track={track} changeTrack={changeTrack}
          rclVersion={rclVersion} changeRclVersion={changeRclVersion} hidden={hidden}
          selected={selected} toggle={toggle} /> :

          tab === "prayers" && window.PrayersView ?
          <window.PrayersView sunday={sunday}
          track={track} changeTrack={changeTrack}
          rclVersion={rclVersion} changeRclVersion={changeRclVersion} effTrack={effTrack}
          usedKeys={PASSAGES.filter((p) => !hidden.has(p))}
          planPicks={prayerPicks} onAddPrayer={addPrayer} onRemovePrayer={removePrayer} /> :

          tab === "anthems" && window.AnthemsView ?
          <window.AnthemsView sunday={sunday} track={track} rclVersion={rclVersion}
          effTrack={effTrack} hidden={hidden} selected={selected} toggle={toggle}
          changeTrack={changeTrack} changeRclVersion={changeRclVersion} /> :

          tab === "handbells" && window.HandbellsView ?
          <window.HandbellsView sunday={sunday} hidden={hidden} selected={selected} toggle={toggle} effTrack={effTrack} /> :

          <TabPlaceholder tab={window.PLANNER_TABS.find((t) => t.id === tab)} sunday={sunday} />
          }
        </main>

        <ServicePlan items={planItems} total={planItems.length}
        removeItem={removeItem} notes={notes} sunday={sunday}
        onAddCustom={addCustomHymn}
        onUpdate={updateItem}
        onReorder={reorderPlan}
        prayerItems={prayerPlanItems} onRemovePrayer={removePrayer}
        planView={planView} setPlanView={setPlanView}
        activeTab={tab}
        onOpenExport={() => setExportOpen(true)}
        onMarkSung={markPlanSung} />
      </div>

      {exportOpen &&
      <ExportDialog
        sundays={sundays}
        initialIdx={idx}
        onClose={() => setExportOpen(false)}
        onExport={(opts) => {runExport({ ...opts, plan: { selectedByDay, hymnLookup, notesByDay, prayerPlanByDay, planOrderByDay, hiddenByDay } });setExportOpen(false);}} />

      }
    </div>);

}

window.WorshipPlanner = WorshipPlanner;
// Shared with hymn-plan.jsx (separate Babel script, separate scope).
Object.assign(window, {
  tuneFor, hymnUsage, LastUsedPill, orchFor, OrchBadge,
  trackOptsFor, hasCctFor, clampTrack, parseEffTrack, effTrackFor, trackAllows, readingForTrack,
  psalterPsalmEntry, altPsalmFor,
  TrackSeg, RclVersionSeg, TrackControls
});