/* Viewership — HOV (hours of viewing) by brand, platform and show (Assigns window.LedgerViewership)

   The second tab of the Performance app. It replaces reading the CTV team's
   hand-kept "Portfolio HOV Tracker.xlsx" and shows the same cuts the workbook does:
   brand totals by period, the platform-by-period grid, the FAST / vMVPD / O&O mix,
   show-level viewing and Court TV's live-trial days.

   DATA. One RPC, analytics_hov_snapshot() (migration 0121), dictionary-encoded:
     facts = [kind, day offset from d0, brand, dim, class, hov, partialDays|null]
     kind  = day | month | show_week | show_month | trial
   The snapshot is refreshed nightly from BigQuery (dwh.ctv_hov*), which is loaded
   from the workbook by loaders/ctv/run_hov.sh. Its freshness is the workbook's, not
   the night's: the header says when the tracker's newest day is.

   TWO SERIES, ON PURPOSE. Daily and weekly views read the `day` facts. Monthly,
   quarterly and yearly views read the `month` facts, which are NOT the sum of the
   days: the tracker's monthly tabs carry platforms that never report daily (Sling,
   DirecTV, LocalNow, ...) and years of history before the daily tabs begin. The
   warehouse builds the month series as "monthly tab wins, daily fills the gaps",
   so a month here equals the tracker's own monthly total. The page says which
   series a view is on, so nobody reconciles a week against a month and finds a gap.

   Holding for later (operator, 2026-09-21): HOV against impressions and revenue on
   the main desk. The month facts here and the revenue desk's platform names share
   staging.dim_platform in the warehouse, so that join is a view, not a re-model. */
(function () {
  const MS = 86400000;
  const MON = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
  const K = { day: 0, month: 1, show_week: 2, show_month: 3, trial: 4 };

  const GRAINS = [
    { k: "day", label: "Daily", series: "day" },
    { k: "week", label: "Weekly", series: "day" },
    { k: "month", label: "Monthly", series: "month" },
    { k: "quarter", label: "Quarterly", series: "month" },
    { k: "year", label: "Yearly", series: "month" },
  ];
  const RANGES = [
    { k: "all", label: "All data" },
    { k: "90d", label: "Last 90 days", days: 90 },
    { k: "12m", label: "Last 12 months", months: 12 },
    { k: "24m", label: "Last 24 months", months: 24 },
    { k: "ytd", label: "Year to date" },
    { k: "custom", label: "Custom…" },
  ];
  const COMPARES = [
    { k: "prev", label: "Prev period" },
    { k: "yoy", label: "Year ago" },
  ];
  // Class order the tracker uses. "" is shows / trials (no class).
  const CLASS_ORDER = ["FAST", "vMVPD", "O&O"];
  const BRAND_SLOT = ["--s1", "--s2", "--s3", "--s4", "--s5", "--s6", "--s7", "--s8"];
  const THEMES = [
    { k: "auto", label: "Auto", title: "Follow the system" },
    { k: "light", label: "Light", title: "Cream paper" },
    { k: "dark", label: "Dark", title: "Navy ink" },
  ];
  const THEME_KEY = "lc-ppd-theme";   // shared with the revenue desk: one choice, both tabs
  const VIEW_KEY = "lc-hov-view";

  function readJSON(key, dflt) {
    try { const v = JSON.parse(window.localStorage.getItem(key) || "null"); return v ?? dflt; }
    catch { return dflt; }
  }
  function writeJSON(key, v) {
    try { window.localStorage.setItem(key, JSON.stringify(v)); } catch { /* private mode */ }
  }

  /* ---- formatting ------------------------------------------------------- */
  function fmtH(v) {
    const a = Math.abs(v);
    if (a >= 1e9) return (v / 1e9).toFixed(2) + "B";
    if (a >= 1e6) return (v / 1e6).toFixed(a >= 1e8 ? 0 : a >= 1e7 ? 1 : 2) + "M";
    if (a >= 1e3) return (v / 1e3).toFixed(a >= 1e5 ? 0 : 1) + "K";
    return Math.round(v).toLocaleString();
  }
  const fmtFull = (v) => Math.round(v).toLocaleString();
  const fmtPct = (v) => (v == null || !isFinite(v)) ? "—" : `${v >= 0 ? "+" : ""}${(v * 100).toFixed(1)}%`;
  const fmtShare = (v) => (v == null || !isFinite(v)) ? "—" : `${(v * 100).toFixed(1)}%`;
  function fmtDay(iso) {
    if (!iso) return "—";
    const d = new Date(iso + "T00:00:00Z");
    return `${MON[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`;
  }
  function fmtWhen(ts) {
    if (!ts) return "never";
    const d = new Date(ts);
    return d.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
  }
  const hoursSince = (ts) => ts ? (Date.now() - Date.parse(ts)) / 3600000 : Infinity;

  /* ---- periods ---------------------------------------------------------- */
  const daysInMonth = (y, m) => new Date(Date.UTC(y, m + 1, 0)).getUTCDate();

  /** Calendar bucket a UTC midnight falls in, at a grain. */
  function bucket(ms, grain) {
    const d = new Date(ms);
    const y = d.getUTCFullYear(), m = d.getUTCMonth(), dd = d.getUTCDate();
    switch (grain) {
      case "day": return { key: ms, span: 1, label: `${MON[m]} ${dd}, ${y}`, short: `${MON[m]} ${dd}` };
      case "week": {
        const s = ms - ((d.getUTCDay() + 6) % 7) * MS, sd = new Date(s);
        return { key: s, span: 7, label: `Week of ${MON[sd.getUTCMonth()]} ${sd.getUTCDate()}, ${sd.getUTCFullYear()}`,
                 short: `${MON[sd.getUTCMonth()]} ${sd.getUTCDate()}` };
      }
      case "month": return { key: Date.UTC(y, m, 1), span: daysInMonth(y, m), label: `${MON[m]} ${y}`,
                             short: `${MON[m]} '${String(y).slice(2)}` };
      case "quarter": {
        const q = Math.floor(m / 3), s = Date.UTC(y, q * 3, 1);
        return { key: s, span: Math.round((Date.UTC(y, q * 3 + 3, 1) - s) / MS), label: `Q${q + 1} ${y}`, short: `Q${q + 1} '${String(y).slice(2)}` };
      }
      default: return { key: Date.UTC(y, 0, 1), span: (y % 4 === 0 && (y % 100 || y % 400 === 0)) ? 366 : 365,
                        label: String(y), short: String(y) };
    }
  }
  /** The same bucket one step back, and one year back. */
  function shiftKey(key, grain, back) {
    const d = new Date(key);
    const y = d.getUTCFullYear(), m = d.getUTCMonth();
    if (back === "yoy") {
      if (grain === "day" || grain === "week") return bucket(key - 364 * MS, grain).key;  // same weekday
      return Date.UTC(y - 1, m, 1);
    }
    switch (grain) {
      case "day": return key - MS;
      case "week": return key - 7 * MS;
      case "month": return Date.UTC(y, m - 1, 1);
      case "quarter": return Date.UTC(y, m - 3, 1);
      default: return Date.UTC(y - 1, 0, 1);
    }
  }

  /* ---- decode ----------------------------------------------------------- */
  function decode(D) {
    const d0 = Date.parse(D.d0 + "T00:00:00Z");
    const day = [], month = [], showW = [], showM = [], trial = [];
    for (const f of D.facts) {
      const r = { ms: d0 + f[1] * MS, b: f[2], p: f[3], c: f[4], v: Number(f[5]), part: f[6] };
      switch (f[0]) {
        case K.day: day.push(r); break;
        case K.month: month.push(r); break;
        case K.show_week: showW.push(r); break;
        case K.show_month: showM.push(r); break;
        default: trial.push(r);
      }
    }
    const maxMs = (a) => a.reduce((m, r) => Math.max(m, r.ms), -Infinity);
    const minMs = (a) => a.reduce((m, r) => Math.min(m, r.ms), Infinity);
    // A month is partial when the tracker's monthly tab hasn't recorded it yet, i.e.
    // every row for it was built from days (part != null). NOT "some platform's days
    // don't fill the month": a platform that launched mid-month would then mark a
    // closed month partial. partDays = the most days any brand has in that month.
    const closed = new Set(), partDays = new Map();
    for (const r of month) {
      if (r.part == null) closed.add(r.ms);
      else partDays.set(r.ms, Math.max(partDays.get(r.ms) || 0, r.part));
    }
    const partialMonth = new Map([...partDays].filter(([ms]) => !closed.has(ms)));
    // Brands that ever report a platform daily, per brand — the daily view's coverage.
    const dailyPlat = D.brands.map(() => new Set());
    const monthPlat = D.brands.map(() => new Set());
    for (const r of day) dailyPlat[r.b].add(r.p);
    for (const r of month) monthPlat[r.b].add(r.p);
    const monthlyOnly = D.brands.map((_, b) =>
      [...monthPlat[b]].filter((p) => !dailyPlat[b].has(p)).map((p) => D.dims[p]).sort());
    const lastDayByBrand = D.brands.map((_, b) => day.reduce((m, r) => r.b === b ? Math.max(m, r.ms) : m, -Infinity));
    // Platforms are entered on different days: on 2026-09-21 Court TV's YouTube TV
    // ran to Sep 6 and Frndly to Aug 29 while the rest ran to Sep 13. Summing the
    // latest week would read as a collapse. So each brand is "complete through" the
    // earliest last-entry day among its platforms still reporting (entered within 21
    // days of the brand's newest day; older ones are treated as stopped, or they would
    // hold the date back forever). Day-grain periods past it are partial.
    const lastByPlat = new Map();
    for (const r of day) { const k = r.b * 100000 + r.p; if (!(lastByPlat.get(k) >= r.ms)) lastByPlat.set(k, r.ms); }
    const completeThrough = D.brands.map(() => Infinity), lagging = D.brands.map(() => []);
    for (const [k, ms] of lastByPlat) {
      const b = Math.floor(k / 100000), mx = lastDayByBrand[b];
      if (ms < mx - 21 * MS) continue;
      if (ms < completeThrough[b]) completeThrough[b] = ms;
      if (ms < mx) lagging[b].push([D.dims[k % 100000], ms]);
    }
    return {
      day, month, showW, showM, trial, partialMonth, monthlyOnly, lastDayByBrand, completeThrough, lagging,
      dayMin: minMs(day), dayMax: maxMs(day), monthMin: minMs(month), monthMax: maxMs(month),
    };
  }

  /* ---- small shared pieces --------------------------------------------- */
  function Seg({ items, value, onChange, label }) {
    return (
      <div className="seg" role="group" aria-label={label}>
        {items.map((it) => (
          <button key={it.k} type="button" aria-pressed={value === it.k} disabled={it.disabled}
            title={it.title} onClick={() => onChange(it.k)}>{it.label}</button>
        ))}
      </div>
    );
  }
  function Chips({ items, on, onToggle, colors }) {
    return (
      <div className="ctl-set">
        {items.map((it, i) => (
          <button key={it} type="button" className="chip" aria-pressed={on.has(i)} onClick={() => onToggle(i)}
            style={colors ? { color: on.has(i) ? undefined : `var(${colors[i]})` } : undefined}>
            <span className="dot" style={colors ? { background: `var(${colors[i]})` } : undefined} />{it}
          </button>
        ))}
      </div>
    );
  }
  function Delta({ v, star }) {
    if (v == null || !isFinite(v)) return <span className="num dim">—</span>;
    const up = v >= 0;
    return <span className="num" style={{ color: `var(${up ? "--up" : "--down"})` }}>
      {up ? "▲" : "▼"} {fmtPct(v)}{star ? "*" : ""}</span>;
  }
  function Spark({ vals, w = 96, h = 22 }) {
    if (!vals.length) return null;
    const mx = Math.max(...vals, 1);
    const pts = vals.map((v, i) => `${(i / Math.max(1, vals.length - 1)) * w},${h - (v / mx) * (h - 2) - 1}`).join(" ");
    return <svg viewBox={`0 0 ${w} ${h}`} width={w} height={h} aria-hidden="true">
      <polyline className="sm-line" points={pts} /></svg>;
  }

  /* ---- the tab ---------------------------------------------------------- */
  function Viewership() {
    const { Button } = window.LawCrimeDesignSystem_e18d68;
    const [data, setData] = React.useState(undefined);
    const [err, setErr] = React.useState(null);
    const [busy, setBusy] = React.useState(false);
    const [msg, setMsg] = React.useState(null);
    const [theme, setTheme] = React.useState(() => {
      try { const v = window.localStorage.getItem(THEME_KEY); return ["light", "dark", "auto"].includes(v) ? v : "auto"; }
      catch { return "auto"; }
    });
    const saved = React.useMemo(() => readJSON(VIEW_KEY, {}), []);
    const [grain, setGrain] = React.useState(saved.grain || "month");
    const [range, setRange] = React.useState(saved.range || "24m");
    const [from, setFrom] = React.useState(saved.from || "");
    const [to, setTo] = React.useState(saved.to || "");
    const [cmp, setCmp] = React.useState(saved.cmp || "prev");
    const [brandOff, setBrandOff] = React.useState(new Set(saved.brandOff || []));
    const [classOff, setClassOff] = React.useState(new Set(saved.classOff || []));
    const [gridBrand, setGridBrand] = React.useState(saved.gridBrand ?? -1);
    const [showGrain, setShowGrain] = React.useState(saved.showGrain || "show_week");
    const [trialView, setTrialView] = React.useState(saved.trialView || "trial");
    const [tip, setTip] = React.useState(null);

    React.useEffect(() => {
      writeJSON(VIEW_KEY, { grain, range, from, to, cmp, brandOff: [...brandOff], classOff: [...classOff],
                            gridBrand, showGrain, trialView });
    }, [grain, range, from, to, cmp, brandOff, classOff, gridBrand, showGrain, trialView]);

    const load = React.useCallback(async () => {
      setErr(null);
      try { setData(await window.LedgerAPI.hovSnapshot()); }
      catch (e) { setErr(e.message || String(e)); setData(null); }
    }, []);
    React.useEffect(() => { load(); }, [load]);

    async function refresh() {
      setBusy(true); setMsg(null); setErr(null);
      try {
        const r = await window.LedgerAPI.refreshAnalytics("hov");
        if (r && r.skipped === "cooldown") setMsg(`Refreshed ${Math.round(r.seconds_since_last / 60)} min ago — try again shortly.`);
        else { setMsg(`Refreshed. ${r && r.pulled_rows ? r.pulled_rows.toLocaleString() + " rows." : ""}`); await load(); }
      } catch (e) { setErr(e.message || String(e)); }
      finally { setBusy(false); }
    }
    function pickTheme(k) { setTheme(k); try { window.localStorage.setItem(THEME_KEY, k); } catch { /* */ } }

    const X = React.useMemo(() => (data && !data.empty ? decode(data) : null), [data]);
    const failed = data && data.status === "error";
    const stale = data && !data.empty && hoursSince(data.refreshed_at) > 36;

    return (
      <div className="ppd hov" data-ppd-theme={theme === "auto" ? undefined : theme}>
        <div className="page-head">
          <div>
            <div className="eyebrow">Performance · Viewership</div>
            <h1 className="page-title">Hours of Viewing</h1>
          </div>
          <div className="ppd-head-actions">
            <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
              <div className="seg ppd-theme" role="group" aria-label="Theme">
                {THEMES.map((t) => (
                  <button key={t.k} type="button" title={t.title} aria-pressed={theme === t.k}
                    onClick={() => pickTheme(t.k)}>{t.label}</button>
                ))}
              </div>
              <Button variant="secondary" icon="refresh-cw" disabled={busy} onClick={refresh}>
                {busy ? "Refreshing…" : "Refresh"}
              </Button>
            </div>
            {X && (
              <div className="ppd-meta">
                <b>Daily {fmtDay(data.day_min)} – {fmtDay(data.day_max)}</b> · monthly from {fmtDay(new Date(X.monthMin).toISOString().slice(0, 10)).replace(/ \d+,/, "")}<br />
                {data.brands.length} brands · Portfolio HOV Tracker<br />
                <span className={stale || failed ? "stale" : undefined}>
                  Snapshot as of {fmtWhen(data.refreshed_at)}{failed ? " · last refresh FAILED" : ""}
                </span>
              </div>
            )}
          </div>
        </div>

        {failed && (
          <div className="note warn" style={{ marginBottom: 18 }}>
            <span><b>The last refresh failed</b>. You are looking at the previous snapshot. {data.message}</span>
          </div>
        )}
        {err && <div className="login__err" style={{ margin: "12px 0" }}>{err}</div>}
        {msg && <div className="ai-saved" style={{ margin: "12px 0" }}>{msg}</div>}
        {data === undefined && <div className="ai-empty">Loading the snapshot…</div>}
        {data && data.empty && (
          <div className="empty" style={{ marginTop: 18 }}>
            No snapshot loaded yet. Press <b>Refresh</b> to pull one from the warehouse.
          </div>
        )}

        {X && <Board D={data} X={X} tip={tip} setTip={setTip}
          s={{ grain, setGrain, range, setRange, from, setFrom, to, setTo, cmp, setCmp,
               brandOff, setBrandOff, classOff, setClassOff, gridBrand, setGridBrand,
               showGrain, setShowGrain, trialView, setTrialView }} />}

        {tip && (
          <div className="ppd-tip" style={{ left: Math.min(tip.x + 14, window.innerWidth - 240), top: tip.y + 14 }}>
            <div className="tt">{tip.title}</div>
            {tip.rows.map(([k, v], i) => <div className="tr" key={i}><span>{k}</span><b>{v}</b></div>)}
          </div>
        )}
      </div>
    );
  }

  /* ---- board: everything below the header ------------------------------ */
  function Board({ D, X, s, setTip }) {
    const G = GRAINS.find((g) => g.k === s.grain);
    const onDaily = G.series === "day";
    const brandOn = new Set(D.brands.map((_, i) => i).filter((i) => !s.brandOff.has(i)));
    const classNames = CLASS_ORDER.filter((c) => D.classes.includes(c));
    const classIdx = classNames.map((c) => D.classes.indexOf(c));
    const classOn = new Set(classNames.map((_, i) => i).filter((i) => !s.classOff.has(i)));
    const classOk = new Set(classIdx.filter((_, i) => classOn.has(i)));
    const toggle = (set, setter) => (i) => { const n = new Set(set); n.has(i) ? n.delete(i) : n.add(i); setter(n); };

    // Range, anchored on the last day/month in the data, not on today.
    const lastMs = onDaily ? X.dayMax : X.monthMax;
    const [lo, hi] = React.useMemo(() => {
      const L = new Date(lastMs);
      const r = RANGES.find((x) => x.k === s.range) || RANGES[0];
      let a = -Infinity, b = Infinity;
      if (r.days) a = X.dayMax - (r.days - 1) * MS;
      else if (r.months) a = Date.UTC(L.getUTCFullYear(), L.getUTCMonth() - r.months + 1, 1);
      else if (r.k === "ytd") a = Date.UTC(L.getUTCFullYear(), 0, 1);
      else if (r.k === "custom") {
        if (s.from) a = Date.parse(s.from + "-01T00:00:00Z");
        if (s.to) { const t = new Date(s.to + "-01T00:00:00Z"); b = Date.UTC(t.getUTCFullYear(), t.getUTCMonth() + 1, 1) - MS; }
      }
      return [a, b];
    }, [s.range, s.from, s.to, lastMs, X]);

    const rows = onDaily ? X.day : X.month;
    const completeTo = Math.min(...[...brandOn].map((b) => X.completeThrough[b]));
    const sel = React.useMemo(
      () => rows.filter((r) => brandOn.has(r.b) && classOk.has(r.c)),
      // eslint-disable-next-line react-hooks/exhaustive-deps
      [rows, s.brandOff, s.classOff]);

    // Every period in the data (for compare lookups), and those in range.
    const agg = React.useMemo(() => {
      const byKey = new Map();        // key -> { total, brand[], cls{}, plat Map, days Set }
      for (const r of sel) {
        const bk = bucket(r.ms, s.grain);
        let p = byKey.get(bk.key);
        if (!p) { p = { ...bk, total: 0, brand: D.brands.map(() => 0), cls: {}, plat: new Map(), days: new Set(), part: false }; byKey.set(bk.key, p); }
        p.total += r.v; p.brand[r.b] += r.v;
        p.cls[r.c] = (p.cls[r.c] || 0) + r.v;
        const pk = r.b * 100000 + r.p;
        p.plat.set(pk, (p.plat.get(pk) || 0) + r.v);
        if (onDaily) p.days.add(r.ms);
        else if (X.partialMonth.has(r.ms)) p.part = true;
      }
      // Partial = the period isn't fully covered by the data.
      for (const p of byKey.values()) {
        if (onDaily) p.part = p.days.size < p.span || p.key + (p.span - 1) * MS > completeTo;
        else if (s.grain !== "month") {
          const d = new Date(p.key), months = s.grain === "quarter" ? 3 : 12;
          const endKey = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + months - 1, 1);
          if (endKey > X.monthMax) p.part = true;
        }
        p.cover = onDaily ? p.days.size : null;
      }
      return byKey;
    }, [sel, s.grain, onDaily, X, D]);

    const periods = [...agg.values()].filter((p) => p.key >= bucket(lo === -Infinity ? 0 : lo, s.grain).key && p.key <= hi)
      .sort((a, b) => a.key - b.key);
    const inRange = sel.filter((r) => r.ms >= lo && r.ms <= hi);
    const total = inRange.reduce((t, r) => t + r.v, 0);

    // Change for a period: raw totals when both are complete, daily rate otherwise (marked *).
    function change(p, how = s.cmp) {
      const q = agg.get(shiftKey(p.key, s.grain, how === "yoy" ? "yoy" : "prev"));
      if (!q || !q.total) return { v: null };
      if (!p.part && !q.part) return { v: p.total / q.total - 1 };
      // Day grain past the complete-through date: some platforms not entered yet, so
      // neither a total nor a daily rate is comparable.
      if (onDaily && p.key + (p.span - 1) * MS > completeTo) return { v: null };
      const rate = (x) => x.total / (onDaily ? Math.max(1, x.cover) : x.span);
      if (!onDaily && p.part) {
        // Month grain: pace = HOV so far / days reported. Quarter/year: no trustworthy
        // day count for the unfinished tail, so no change is claimed.
        const days = s.grain === "month" ? X.partialMonth.get(p.key) : null;
        if (!days) return { v: null };
        return { v: (p.total / days) / (q.total / q.span) - 1, star: true };
      }
      return { v: rate(p) / rate(q) - 1, star: true };
    }

    const complete = periods.filter((p) => !p.part);
    const latest = complete[complete.length - 1];
    const current = periods.length && periods[periods.length - 1].part ? periods[periods.length - 1] : null;
    const days = onDaily ? new Set(inRange.map((r) => r.ms)).size
      : periods.reduce((n, p) => n + (p.part ? 0 : p.span), 0);

    const brandColors = D.brands.map((_, i) => BRAND_SLOT[i % BRAND_SLOT.length]);

    return (
      <>
        <div className="controls">
          <div className="ctl-row">
            <div className="ctl"><div className="ctl-label">Period</div>
              <Seg items={GRAINS} value={s.grain} onChange={s.setGrain} label="Time grain" /></div>
            <div className="ctl"><div className="ctl-label">Date range</div>
              <div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
                <select className="picker-btn" value={s.range} onChange={(e) => s.setRange(e.target.value)} aria-label="Date range">
                  {RANGES.map((r) => <option key={r.k} value={r.k}>{r.label}</option>)}
                </select>
                {s.range === "custom" && (<>
                  <input type="month" className="picker-btn" value={s.from} onChange={(e) => s.setFrom(e.target.value)} aria-label="From month" />
                  <span className="range-dash">–</span>
                  <input type="month" className="picker-btn" value={s.to} onChange={(e) => s.setTo(e.target.value)} aria-label="To month" />
                </>)}
              </div></div>
            <div className="ctl"><div className="ctl-label">Compare to</div>
              <Seg items={COMPARES} value={s.cmp} onChange={s.setCmp} label="Comparison" /></div>
            <div className="ctl"><div className="ctl-label">Brand</div>
              <Chips items={D.brands} on={brandOn} onToggle={toggle(s.brandOff, s.setBrandOff)} colors={brandColors} /></div>
            <div className="ctl"><div className="ctl-label">Platform class</div>
              <Chips items={classNames} on={classOn} onToggle={toggle(s.classOff, s.setClassOff)} /></div>
          </div>
        </div>

        {onDaily
          ? <div className="note" style={{ marginBottom: 18 }}><span>
              <b>Daily series.</b> Daily and weekly views show only platforms the tracker reports by day. Platforms reported
              only monthly (e.g. {[...new Set(X.monthlyOnly.flat())].slice(0, 5).join(", ")}) and months before the daily
              tabs begin appear in the Monthly, Quarterly and Yearly views, which match the tracker's own monthly totals.
              {isFinite(completeTo) && <> Complete through <b>{fmtDay(new Date(completeTo).toISOString().slice(0, 10))}</b>;
                later days are still being entered{(() => { const l = [...brandOn].flatMap((b) => X.lagging[b].map(([n, ms]) =>
                  `${D.brands[b]} ${n} (to ${fmtDay(new Date(ms).toISOString().slice(0, 10)).replace(/, \d{4}$/, "")})`));
                  return l.length ? `: ${l.join(", ")}` : ""; })()}.</>}</span></div>
          : <div className="note" style={{ marginBottom: 18 }}><span>
              <b>Monthly series.</b> Each month is the tracker's monthly figure where it has one, and the sum of its days
              where it doesn't (the current month, and a few platform-months the monthly tab left blank). Months still
              being filled are marked partial and compared on a daily rate (*).</span></div>}

        <div className="kpis">
          <div className="kpi accent">
            <div className="kpi-l">HOV in range</div>
            <div className="kpi-v">{fmtH(total)}<span className="u"> hrs</span></div>
            <div className="kpi-s">{periods.length} {G.label.toLowerCase()} periods · {fmtFull(total)} hrs</div>
          </div>
          <div className="kpi">
            <div className="kpi-l">Avg per day</div>
            <div className="kpi-v">{fmtH(days ? (onDaily ? total : complete.reduce((t, p) => t + p.total, 0)) / days : 0)}</div>
            <div className="kpi-s">over {days.toLocaleString()} {onDaily ? "reported" : "complete-period"} days</div>
          </div>
          {latest && (() => { const c = change(latest); const y = change(latest, "yoy"); return (
            <div className="kpi">
              <div className="kpi-l">Latest complete · {latest.short}</div>
              <div className="kpi-v">{fmtH(latest.total)}</div>
              <div className="kpi-s">vs prev <Delta v={c.v} star={c.star} /> · YoY <Delta v={y.v} star={y.star} /></div>
            </div>); })()}
          {current && (() => { const c = change(current); return (
            <div className="kpi">
              <div className="kpi-l">In progress · {current.short}</div>
              <div className="kpi-v">{fmtH(current.total)}</div>
              <div className="kpi-s">pace vs {COMPARES.find((x) => x.k === s.cmp).label.toLowerCase()} <Delta v={c.v} star={c.star} /></div>
            </div>); })()}
          <MixKpi D={D} inRange={inRange} classNames={classNames} />
        </div>

        <section>
          <div className="sec-head">
            <div><h2>Hours of viewing over time</h2>
              <div className="sec-sub">{G.label} · stacked by brand · hatched = period not yet complete</div></div>
            <div className="legend">
              {D.brands.map((b, i) => brandOn.has(i) &&
                <span key={b}><i style={{ background: `var(${brandColors[i]})` }} />{b}</span>)}
            </div>
          </div>
          <TrendChart periods={periods} D={D} brandOn={brandOn} colors={brandColors} change={change}
            cmp={s.cmp} setTip={setTip} />
        </section>

        <section>
          <div className="sec-head">
            <div><h2>By brand</h2>
              <div className="sec-sub">Most recent first. Change is vs {COMPARES.find((x) => x.k === s.cmp).label.toLowerCase()}.</div></div>
          </div>
          <BrandTable periods={periods} D={D} brandOn={brandOn} agg={agg} grain={s.grain} cmp={s.cmp} change={change} />
        </section>

        <section>
          <div className="sec-head">
            <div><h2>By platform</h2>
              <div className="sec-sub">The tracker's platform grid: the last twelve periods in range, platforms ranked by HOV in range.</div></div>
            <select className="picker-btn" value={s.gridBrand} onChange={(e) => s.setGridBrand(Number(e.target.value))} aria-label="Brand for the platform grid">
              <option value={-1}>All selected brands</option>
              {D.brands.map((b, i) => brandOn.has(i) && <option key={b} value={i}>{b}</option>)}
            </select>
          </div>
          <PlatformGrid D={D} periods={periods} inRange={inRange} gridBrand={s.gridBrand} agg={agg} />
        </section>

        <section>
          <div className="sec-head">
            <div><h2>FAST · vMVPD · O&amp;O mix</h2>
              <div className="sec-sub">Share of HOV by platform class, per period (the tracker's Total FAST / % FAST columns, recomputed).</div></div>
          </div>
          <MixTable periods={periods} D={D} classNames={classNames} />
        </section>

        <section>
          <div className="sec-head">
            <div><h2>Shows</h2>
              <div className="sec-sub">Show-level HOV as the tracker records it (Law &amp; Crime and Mysteria). Its platform coverage is the tracker's, so show totals don't add up to the brand totals above.</div></div>
            <Seg items={[{ k: "show_week", label: "Weekly" }, { k: "show_month", label: "Monthly", title: "Law & Crime only" }]}
              value={s.showGrain} onChange={s.setShowGrain} label="Show grain" />
          </div>
          <Shows D={D} X={X} grain={s.showGrain} brandOn={brandOn} lo={lo} hi={hi} />
        </section>

        {X.trial.length > 0 && D.brands.some((b, i) => brandOn.has(i) && /^Court TV$/.test(b)) && (
          <section>
            <div className="sec-head">
              <div><h2>Court TV live trials</h2>
                <div className="sec-sub">Days the tracker names a live trial, with Court TV's HOV that day (daily-reported platforms) against the brand's median day in range.</div></div>
              <Seg items={[{ k: "trial", label: "By trial" }, { k: "day", label: "By day" }]}
                value={s.trialView} onChange={s.setTrialView} label="Trial view" />
            </div>
            <Trials D={D} X={X} lo={lo} hi={hi} view={s.trialView} />
          </section>
        )}

        <div className="foot">
          Source: <code>Portfolio HOV Tracker.xlsx</code> (CTV team, updated by hand) → BigQuery <code>dwh.ctv_hov</code>,
          <code> dwh.ctv_hov_month</code>, <code>dwh.ctv_hov_show</code>, <code>dwh.ctv_live_trial</code> → this snapshot, nightly.
          Every loaded row matched the tracker's own TOTAL column. The tracker's newest day per brand:{" "}
          {D.brands.map((b, i) => `${b} ${fmtDay(isFinite(X.lastDayByBrand[i]) ? new Date(X.lastDayByBrand[i]).toISOString().slice(0, 10) : null)}`).join(" · ")}.
          Platforms reported only monthly: {D.brands.map((b, i) => X.monthlyOnly[i].length ? `${b}: ${X.monthlyOnly[i].join(", ")}` : null).filter(Boolean).join(" · ")}.
        </div>
      </>
    );
  }

  function MixKpi({ D, inRange, classNames }) {
    const by = {};
    let t = 0;
    for (const r of inRange) { const c = D.classes[r.c]; by[c] = (by[c] || 0) + r.v; t += r.v; }
    return (
      <div className="kpi">
        <div className="kpi-l">Mix in range</div>
        <div className="kpi-v">{fmtShare(t ? (by.FAST || 0) / t : null)}<span className="u"> FAST</span></div>
        <div className="kpi-s">{classNames.filter((c) => c !== "FAST").map((c) => `${c} ${fmtShare(t ? (by[c] || 0) / t : null)}`).join(" · ")}</div>
      </div>
    );
  }

  /* ---- trend: stacked bars by brand ------------------------------------- */
  function TrendChart({ periods, D, brandOn, colors, change, cmp, setTip }) {
    if (!periods.length) return <div className="empty">No data in this range.</div>;
    const W = Math.max(680, periods.length * 14), H = 300, L = 58, R = 12, T = 14, B = 34;
    const mx = Math.max(...periods.map((p) => p.total), 1);
    const nice = Math.pow(10, Math.floor(Math.log10(mx)));
    const top = Math.ceil(mx / nice) * nice;
    const bw = (W - L - R) / periods.length;
    const y = (v) => T + (H - T - B) * (1 - v / top);
    const ticks = [0, .25, .5, .75, 1].map((f) => f * top);
    const every = Math.ceil(periods.length / Math.floor((W - L - R) / 64));
    const order = D.brands.map((_, i) => i).filter((i) => brandOn.has(i));
    return (
      <div className="chart-scroll">
        <svg viewBox={`0 0 ${W} ${H}`} role="img" aria-label="Hours of viewing by period, stacked by brand"
          onMouseLeave={() => setTip(null)}>
          <defs>
            <pattern id="hovhatch" width="5" height="5" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
              <rect width="5" height="5" fill="var(--card)" /><line x1="0" y1="0" x2="0" y2="5" stroke="var(--muted)" strokeWidth="2" />
            </pattern>
          </defs>
          {ticks.map((t) => <g key={t}>
            <line className="gridline" x1={L} x2={W - R} y1={y(t)} y2={y(t)} />
            <text className="axis-t" x={L - 8} y={y(t) + 4} textAnchor="end">{fmtH(t)}</text></g>)}
          {periods.map((p, i) => {
            let acc = 0;
            const x = L + i * bw + Math.max(1, bw * 0.12), w = Math.max(1, bw * 0.76);
            const c = change(p);
            return (
              <g key={p.key}>
                {order.map((b) => {
                  const v = p.brand[b]; if (!v) return null;
                  const y1 = y(acc + v), y0 = y(acc); acc += v;
                  return <rect key={b} x={x} y={y1} width={w} height={Math.max(0, y0 - y1)}
                    fill={p.part ? "url(#hovhatch)" : `var(${colors[b]})`}
                    stroke={p.part ? `var(${colors[b]})` : "none"} strokeWidth={p.part ? 1.5 : 0} />;
                })}
                {i % every === 0 && <text className="axis-t" x={x + w / 2} y={H - 12} textAnchor="middle">{p.short}</text>}
                <rect className="hit" x={L + i * bw} y={T} width={bw} height={H - T - B}
                  onMouseMove={(e) => setTip({ x: e.clientX, y: e.clientY, title: p.label + (p.part ? " (partial)" : ""),
                    rows: [["Total", fmtFull(p.total) + " hrs"],
                           ...order.filter((b) => p.brand[b]).map((b) => [D.brands[b], fmtFull(p.brand[b])]),
                           [`vs ${cmp === "yoy" ? "year ago" : "prev"}`, c.v == null ? "—" : fmtPct(c.v) + (c.star ? "*" : "")]] })} />
              </g>
            );
          })}
        </svg>
      </div>
    );
  }

  /* ---- brand table ------------------------------------------------------ */
  function BrandTable({ periods, D, brandOn, cmp, change }) {
    const order = D.brands.map((_, i) => i).filter((i) => brandOn.has(i));
    const list = [...periods].reverse();
    return (
      <div className="tbl-scroll">
        <table>
          <thead><tr>
            <th className="l">Period</th>
            {order.map((b) => <th key={b}>{D.brands[b]}</th>)}
            <th>Total</th><th>{cmp === "yoy" ? "YoY" : "Change"}</th>
          </tr></thead>
          <tbody>
            {list.map((p) => { const c = change(p); return (
              <tr key={p.key}>
                <td className="l k">{p.label} {p.part && <span className="pill part">partial</span>}</td>
                {order.map((b) => <td key={b}>{p.brand[b] ? fmtFull(p.brand[b]) : "—"}</td>)}
                <td className="k">{fmtFull(p.total)}</td>
                <td><Delta v={c.v} star={c.star} /></td>
              </tr>); })}
          </tbody>
        </table>
      </div>
    );
  }

  /* ---- platform grid ---------------------------------------------------- */
  function PlatformGrid({ D, periods, inRange, gridBrand }) {
    const cols = periods.slice(-12);
    const tot = new Map(), cls = new Map();
    for (const r of inRange) {
      if (gridBrand >= 0 && r.b !== gridBrand) continue;
      const k = gridBrand >= 0 ? r.p : r.p;       // platforms merge across brands in the all-brands view
      tot.set(k, (tot.get(k) || 0) + r.v);
      cls.set(k, D.classes[r.c]);
    }
    const all = [...tot.values()].reduce((a, b) => a + b, 0);
    const plats = [...tot.keys()].sort((a, b) => tot.get(b) - tot.get(a));
    const cell = (p, pk) => {
      let v = 0;
      for (const [k, x] of p.plat) {
        const b = Math.floor(k / 100000), dim = k % 100000;
        if (dim === pk && (gridBrand < 0 || b === gridBrand)) v += x;
      }
      return v;
    };
    if (!plats.length) return <div className="empty">No platforms in this selection.</div>;
    return (
      <div className="tbl-scroll">
        <table>
          <thead><tr>
            <th className="l">Platform</th><th className="l">Class</th>
            {cols.map((p) => <th key={p.key}>{p.short}{p.part ? "*" : ""}</th>)}
            <th>In range</th><th>Share</th><th className="l">Trend</th>
          </tr></thead>
          <tbody>
            {plats.map((pk) => { const series = cols.map((p) => cell(p, pk)); return (
              <tr key={pk}>
                <td className="l k">{D.dims[pk]}</td>
                <td className="l"><span className="pill">{cls.get(pk)}</span></td>
                {series.map((v, i) => <td key={i}>{v ? fmtH(v) : "—"}</td>)}
                <td className="k">{fmtFull(tot.get(pk))}</td>
                <td>{fmtShare(tot.get(pk) / all)}</td>
                <td className="l"><Spark vals={series} /></td>
              </tr>); })}
          </tbody>
        </table>
      </div>
    );
  }

  /* ---- class mix -------------------------------------------------------- */
  function MixTable({ periods, D, classNames }) {
    const list = [...periods].reverse();
    return (
      <div className="tbl-scroll" style={{ maxHeight: 360 }}>
        <table>
          <thead><tr><th className="l">Period</th>
            {classNames.map((c) => <th key={c}>{c}</th>)}
            {classNames.map((c) => <th key={c + "%"}>% {c}</th>)}
            <th className="l">Mix</th></tr></thead>
          <tbody>
            {list.map((p) => {
              const v = classNames.map((c) => p.cls[D.classes.indexOf(c)] || 0);
              const t = v.reduce((a, b) => a + b, 0) || 1;
              return (
                <tr key={p.key}>
                  <td className="l k">{p.label}{p.part ? " *" : ""}</td>
                  {v.map((x, i) => <td key={i}>{x ? fmtFull(x) : "—"}</td>)}
                  {v.map((x, i) => <td key={"p" + i}>{fmtShare(x / t)}</td>)}
                  <td className="l" style={{ minWidth: 140 }}>
                    <div className="hov-mix">{v.map((x, i) => <span key={i} title={`${classNames[i]} ${fmtShare(x / t)}`}
                      style={{ width: `${(x / t) * 100}%`, background: `var(--s${i + 1})` }} />)}</div>
                  </td>
                </tr>);
            })}
          </tbody>
        </table>
      </div>
    );
  }

  /* ---- shows ------------------------------------------------------------ */
  function Shows({ D, X, grain, brandOn, lo, hi }) {
    const rows = (grain === "show_month" ? X.showM : X.showW).filter((r) => brandOn.has(r.b));
    const inR = rows.filter((r) => r.ms >= (grain === "show_month" ? bucket(isFinite(lo) ? lo : 0, "month").key : lo - 6 * MS) && r.ms <= hi);
    if (!inR.length) return <div className="empty">No show-level data in this range{grain === "show_month" ? " (monthly show data is Law & Crime only, from Jan 2025)" : ""}.</div>;
    // Brands' show weeks end on different days, so "latest" and "previous" are per brand.
    const brandKeys = new Map();
    for (const r of inR) { if (!brandKeys.has(r.b)) brandKeys.set(r.b, new Set()); brandKeys.get(r.b).add(r.ms); }
    const lastOf = (b) => { const k = [...brandKeys.get(b)].sort((x, y) => x - y); return [k[k.length - 1], k[k.length - 2], k.slice(-12)]; };
    const by = new Map();
    for (const r of inR) {
      const k = r.b * 100000 + r.p;
      let o = by.get(k);
      if (!o) { o = { b: r.b, p: r.p, total: 0, per: new Map() }; by.set(k, o); }
      o.total += r.v; o.per.set(r.ms, (o.per.get(r.ms) || 0) + r.v);
    }
    const all = [...by.values()].reduce((t, o) => t + o.total, 0);
    const list = [...by.values()].sort((a, b) => b.total - a.total);
    const lab = (ms) => grain === "show_month" ? bucket(ms, "month").short : `wk ${bucket(ms, "day").short}`;
    return (
      <div className="tbl-scroll">
        <table>
          <thead><tr>
            <th className="l">Show</th><th className="l">Brand</th><th>In range</th><th>Share</th>
            <th>Latest period</th><th>vs previous</th><th className="l">Last 12</th>
          </tr></thead>
          <tbody>
            {list.map((o) => {
              const [last, prev, spark] = lastOf(o.b);
              const a = o.per.get(last) || 0, b = prev ? o.per.get(prev) || 0 : 0;
              return (
                <tr key={o.b + ":" + o.p}>
                  <td className="l k">{D.dims[o.p]}</td>
                  <td className="l">{D.brands[o.b]}</td>
                  <td className="k">{fmtFull(o.total)}</td>
                  <td>{fmtShare(o.total / all)}</td>
                  <td title={lab(last)}>{a ? fmtFull(a) : "—"}</td>
                  <td><Delta v={a && b ? a / b - 1 : null} /></td>
                  <td className="l"><Spark vals={spark.map((k) => o.per.get(k) || 0)} /></td>
                </tr>);
            })}
          </tbody>
        </table>
      </div>
    );
  }

  /* ---- Court TV live trials -------------------------------------------- */
  function Trials({ D, X, lo, hi, view }) {
    const ctv = D.brands.indexOf("Court TV");
    const dayTot = new Map();
    for (const r of X.day) if (r.b === ctv && r.ms >= lo && r.ms <= hi) dayTot.set(r.ms, (dayTot.get(r.ms) || 0) + r.v);
    const vals = [...dayTot.values()].sort((a, b) => a - b);
    const median = vals.length ? vals[Math.floor(vals.length / 2)] : 0;
    // "N/A", "#N/A", "N/A Memorial Day": no live trial. Dropped at load too.
    const days = X.trial.filter((r) => r.b === ctv && r.ms >= lo && r.ms <= hi && !/^#?N\/A\b/i.test(D.dims[r.p].trim()))
      .map((r) => ({ ms: r.ms, title: D.dims[r.p], hov: dayTot.get(r.ms) || 0 }));
    if (!days.length) return <div className="empty">No live-trial days in this range.</div>;
    // "Karen Read Retrial Hearing LIVE (250011/2584@Court TV LIVE S25-'25')" -> "Karen Read Retrial Hearing"
    const short = (t) => t.replace(/\s*\(.*$/, "").replace(/\s+LIVE$/i, "").trim();
    if (view === "day") {
      const list = [...days].sort((a, b) => b.ms - a.ms);
      return (
        <div className="tbl-scroll">
          <table>
            <thead><tr><th className="l">Day</th><th className="l">Live trial</th><th>Court TV HOV</th><th>vs median day</th></tr></thead>
            <tbody>{list.map((d) => (
              <tr key={d.ms}><td className="l">{fmtDay(new Date(d.ms).toISOString().slice(0, 10))}</td>
                <td className="l k" title={d.title}>{short(d.title)}</td>
                <td>{d.hov ? fmtFull(d.hov) : "—"}</td>
                <td><Delta v={d.hov && median ? d.hov / median - 1 : null} /></td></tr>))}</tbody>
          </table>
        </div>
      );
    }
    const by = new Map();
    for (const d of days) {
      const k = short(d.title);
      const o = by.get(k) || { title: k, n: 0, hov: 0, first: d.ms, last: d.ms };
      o.n += 1; o.hov += d.hov; o.first = Math.min(o.first, d.ms); o.last = Math.max(o.last, d.ms);
      by.set(k, o);
    }
    const list = [...by.values()].sort((a, b) => b.hov - a.hov);
    return (
      <div className="tbl-scroll">
        <table>
          <thead><tr><th className="l">Live trial</th><th>Days</th><th className="l">Aired</th>
            <th>Court TV HOV on those days</th><th>Avg per day</th><th>vs median day</th></tr></thead>
          <tbody>{list.map((o) => { const avg = o.hov / o.n; return (
            <tr key={o.title}><td className="l k">{o.title}</td><td>{o.n}</td>
              <td className="l">{fmtDay(new Date(o.first).toISOString().slice(0, 10))}{o.n > 1 ? ` – ${fmtDay(new Date(o.last).toISOString().slice(0, 10))}` : ""}</td>
              <td>{fmtFull(o.hov)}</td><td>{fmtFull(avg)}</td>
              <td><Delta v={median ? avg / median - 1 : null} /></td></tr>); })}</tbody>
        </table>
      </div>
    );
  }

  window.LedgerViewership = Viewership;
})();
