/* ============================================================
   PAGE 4 — FBO DETAIL  (single-owner coaching view)
   A search-driven, single-owner page that pulls every metric from
   the other pages and adds operational coaching context: pricing,
   recurring penetration, customer-frequency mix, route utilization.
   Everything compares the owner to (a) their FBC peers and (b) their
   own prior year. Wins surface for recognition; gaps surface as
   quantified opportunities. Many figures are PLACEHOLDER (see data.js).
   ============================================================ */

const CWX = MM.config.currentWeek;

/* ---- null-safe display helpers ---- */
// renders "-" for any value that isn't a real number
const isNum = (v) => typeof v === "number" && !isNaN(v);
const D = (v, fmt) => isNum(v) ? fmt ? fmt(v) : v : "-";
// keep only real numbers from a list (peer/system benchmark arrays)
const clean = (arr) => (arr || []).filter(isNum);

/* ---- tiny stats helpers (null-safe) ---- */
function dMedian(vals) {
  const a = clean(vals).sort((x, y) => x - y),n = a.length;
  if (!n) return null;
  return n % 2 ? a[(n - 1) / 2] : (a[n / 2 - 1] + a[n / 2]) / 2;
}
function pctlOf(vals, v) {const a = clean(vals);const n = a.length || 1;return a.filter((x) => x < v).length / n;}
function toneFromPctl(p, higherBetter) {
  const q = higherBetter ? p : 1 - p;
  return q >= 0.66 ? "great" : q >= 0.34 ? "average" : "opportunity";
}
const fmtPctD = (v) => MM.fmt.pct(v);

/* revenue window for the selected range (mirrors the rest of the app) */
function revWin(o, range) {return MM.ownerWindow(o, range);}
function planWin(o, range) {
  const ci = CWX - 1;
  if (range === "week") return o.weekly.plan[ci] || 0;
  if (range === "rolling") {let s = 0;for (let i = Math.max(0, ci - 3); i <= ci; i++) s += o.weekly.plan[i] || 0;return s;}
  return o.planYtd2026;
}

/* ---- search box ---- */
function OwnerSearch({ onPick, current }) {
  const [q, setQ] = useState("");
  const [open, setOpen] = useState(false);
  const ref = useRef(null);
  useEffect(() => {
    function onDoc(e) {if (ref.current && !ref.current.contains(e.target)) setOpen(false);}
    document.addEventListener("mousedown", onDoc);
    return () => document.removeEventListener("mousedown", onDoc);
  }, []);
  const needle = q.trim().toLowerCase();
  const results = !needle ? [] : MM.owners.filter((o) =>
  [o.contact, o.name, o.legalName, o.city, o.state, o.license, o.city + ", " + o.state].
  some((s) => s && String(s).toLowerCase().includes(needle))).slice(0, 8);
  function pick(o) {onPick(o.id);setQ("");setOpen(false);}
  return (
    <div className="fbo-search" ref={ref}>
      <span className="fbo-search-ic" aria-hidden="true">
        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" /></svg>
      </span>
      <input className="fbo-search-input" value={q}
      placeholder="Search a franchise owner by name, DBA, license, or location…"
      onChange={(e) => {setQ(e.target.value);setOpen(true);}} onFocus={() => setOpen(true)} />
      {current && <span className="fbo-search-now">Viewing: <b>{current.contact}</b></span>}
      {open && results.length > 0 &&
      <div className="fbo-search-menu">
          {results.map((o) =>
        <button key={o.id} className="fbo-search-opt" onClick={() => pick(o)}>
              <span className="fso-name">{o.contact}</span>
              <span className="fso-sub">{o.name.replace("Molly Maid of ", "")} · {o.city}, {o.state} · {o.license}</span>
            </button>
        )}
        </div>
      }
      {open && needle && results.length === 0 &&
      <div className="fbo-search-menu"><p className="fso-empty">No owners match “{q}”.</p></div>
      }
    </div>);

}

/* ---- comparison bar: owner value vs peer + system medians ---- */
function BenchBar({ label, value, valueText, peer, peerText, sys, sysText, domain, tone }) {
  const [lo, hi] = domain || [0, 1],span = hi - lo || 1;
  const x = (v) => Math.max(1, Math.min(99, (v - lo) / span * 100));
  const hasVal = isNum(value);
  return (
    <div className="bb">
      <div className="bb-top">
        <span className="bb-label">{label}</span>
        <span className={"bb-val tone-" + tone}>{hasVal ? valueText : "-"}</span>
      </div>
      <div className="bb-track">
        {hasVal && <span className={"bb-fill grad-" + tone} style={{ width: x(value) + "%" }}></span>}
        {isNum(sys) && <span className="bb-mark bb-sys" style={{ left: x(sys) + "%" }} title={"System median " + (sysText || "")}></span>}
        {isNum(peer) && <span className="bb-mark bb-peer" style={{ left: x(peer) + "%" }} title={"Peer median " + (peerText || "")}></span>}
      </div>
      <div className="bb-foot">
        <span className="bb-cap"><span className="bb-key peer"></span>Peer median {isNum(peer) ? peerText : "-"}</span>
        {isNum(sys) && <span className="bb-cap"><span className="bb-key sys"></span>System median {sysText || "-"}</span>}
      </div>
    </div>);

}

/* ---- donut gauge with peer + (optional) system median ticks ---- */
function Donut({ value, peer, sys, label, sub, tone }) {
  const hasVal = isNum(value);
  const R = 52,C = 2 * Math.PI * R,frac = hasVal ? Math.max(0, Math.min(1, value / 100)) : 0;
  const col = "var(--mly-" + tone + ")";
  const tick = (p, cls) => {
    if (!isNum(p)) return null;
    const a = (-90 + p / 100 * 360) * Math.PI / 180;
    const x1 = 70 + (R - 11) * Math.cos(a),y1 = 70 + (R - 11) * Math.sin(a);
    const x2 = 70 + (R + 11) * Math.cos(a),y2 = 70 + (R + 11) * Math.sin(a);
    return <line x1={x1} y1={y1} x2={x2} y2={y2} className={cls} />;
  };
  return (
    <div className="donut">
      <svg viewBox="0 0 140 140" className="donut-svg" role="img" aria-label={label}>
        <circle cx="70" cy="70" r={R} className="donut-track" />
        <circle cx="70" cy="70" r={R} stroke={col} className="donut-arc"
        strokeDasharray={(C * frac).toFixed(1) + " " + C.toFixed(1)} transform="rotate(-90 70 70)" />
        {tick(sys, "donut-sys")}
        {tick(peer, "donut-peer")}
        <text x="70" y="66" className="donut-val">{hasVal ? Math.round(value) + "%" : "-"}</text>
        <text x="70" y="90" className="donut-sub">{sub}</text>
      </svg>
      <div className="donut-cap">
        <span className="donut-label">{label}</span>
        <span className="donut-peer-cap"><span className="donut-key peer"></span>Peer median {Math.round(peer)}%
          {isNum(sys) && <React.Fragment> · <span className="donut-key sys"></span>System median {Math.round(sys)}%</React.Fragment>}
        </span>
      </div>
    </div>);

}

/* ---- customer frequency mix (this year vs last) ----
   Shows recurring cadences incl. tri-weekly, with each segment's customer
   COUNT (first) and its share of recurring customers, plus a running total. */
const FREQ_SEGS = [
{ k: "weekly", l: "Weekly", c: "var(--mly-viz-1)" },
{ k: "biweekly", l: "Bi-weekly", c: "var(--mly-viz-2)" },
{ k: "triweekly", l: "Tri-weekly", c: "var(--mly-viz-3)" },
{ k: "monthly", l: "Every 4 wks", c: "var(--mly-viz-5)" }];

function freqTotal(counts) {
  if (!counts) return 0;
  return FREQ_SEGS.reduce((s, seg) => s + (isNum(counts[seg.k]) ? counts[seg.k] : 0), 0);
}
// convert recurring customer COUNTS to % of recurring customers
function toPct(counts) {
  const tot = freqTotal(counts);
  if (!tot) return null;
  const out = {};
  FREQ_SEGS.forEach((seg) => {out[seg.k] = Math.round((counts[seg.k] || 0) / tot * 100);});
  return out;
}
function FreqStack({ dist, prior }) {
  const dp = toPct(dist),pp = toPct(prior);
  const totNow = Math.round(freqTotal(dist)),totPrior = Math.round(freqTotal(prior));
  const cnt = (counts, k) => isNum(counts && counts[k]) ? Math.round(counts[k]) : 0;
  // counts live INSIDE each bar segment (with the %), not in the legend below.
  const bar = (d, counts) =>
  d ?
  <div className="fq-bar">
      {FREQ_SEGS.map((s) => d[s.k] > 0 &&
    <span key={s.k} className="fq-seg" style={{ width: d[s.k] + "%", background: s.c }} title={s.l + " · " + MM.fmt.num(cnt(counts, s.k)) + " customers · " + d[s.k] + "%"}>
          {d[s.k] >= 13 ? MM.fmt.num(cnt(counts, s.k)) + " · " + d[s.k] + "%" : d[s.k] >= 7 ? MM.fmt.num(cnt(counts, s.k)) : ""}
        </span>
    )}
    </div> :
  <div className="fq-bar"><span className="fq-empty">-</span></div>;

  return (
    <div className="fq" data-comment-anchor="ea9ae04f85-div-160-5">
      <div className="fq-row"><span className="fq-yr">This year<b className="fq-tot">{totNow ? MM.fmt.num(totNow) : "—"} recurring</b></span>{bar(dp, dist)}</div>
      <div className="fq-row"><span className="fq-yr muted">Last year<b className="fq-tot">{totPrior ? MM.fmt.num(totPrior) : "—"} recurring</b></span>{bar(pp, prior)}</div>
      <div className="fq-legend">
        {FREQ_SEGS.map((s) =>
        <span key={s.k} className="fq-leg">
            <span className="fq-sw" style={{ background: s.c }}></span>
            <span className="fq-leg-l">{s.l}</span>
          </span>
        )}
        <span className="fq-leg-note">Each segment shows customer count · % of recurring</span>
      </div>
    </div>);

}

/* ---- YoY trend chip ---- */
function Yoy({ delta, higherBetter = true, fmt }) {
  if (delta == null || !isNum(delta)) return null;
  const good = higherBetter ? delta >= 0 : delta <= 0;
  const arrow = delta > 0 ? "▲" : delta < 0 ? "▼" : "▬";
  return <span className={"yoy " + (good ? "good" : "bad")}>{arrow} {fmt(Math.abs(delta))} YoY</span>;
}

/* ---- price tile ---- */
function PriceTile({ label, value, prior, peerMed, tone }) {
  return (
    <div className="ptile">
      <span className="ptile-lab">{label}</span>
      <span className="ptile-val">{D(value, (v) => "$" + Math.round(v))}</span>
      <div className="ptile-meta">
        <Yoy delta={isNum(value) && isNum(prior) ? value - prior : null} higherBetter fmt={(v) => "$" + Math.round(v)} />
        <span className={"ptile-peer tone-" + tone}>Peer {D(peerMed, (v) => "$" + Math.round(v))}</span>
      </div>
    </div>);

}

/* ---- recognition / opportunity engine ---- */
// cache windowed drivers per (owner,range) so peer maps are cheap
function drv(p, range) {return MM.ownerDrivers(p, range);}
function buildMetrics(o, range) {
  const peers = MM.ownersFor([o.fbcId]).filter((p) => p.id !== o.id);
  const sys = MM.owners;
  const w = revWin(o, range);
  const growth = w.prior ? (w.cur - w.prior) / w.prior * 100 : 0;
  const gOf = (p) => {const x = revWin(p, range);return x.prior ? (x.cur - x.prior) / x.prior * 100 : 0;};
  const rlab = { ytd: "YTD", rolling: "last 4 wks", week: "this wk" }[range] || "YTD";

  // windowed drivers for this owner + peer/system arrays (all honor `range`)
  const D = drv(o, range);
  const Dpeers = peers.map((p) => drv(p, range));
  const Dsys = sys.map((p) => drv(p, range));

  const defs = [
  { key: "growth", label: "Revenue growth (" + rlab + " YoY)", group: "driver", higherBetter: true, value: growth, prior: 0,
    fmt: (v) => MM.fmt.pct(v), pv: peers.map(gOf), sv: sys.map(gOf) },
  { key: "penetration", label: "Recurring penetration (" + rlab + ")", group: "health", higherBetter: true, value: D.penetration, prior: D.penetrationPrior,
    fmt: (v) => Math.round(v * 100) / 100 + "%", pv: Dpeers.map((d) => d.penetration), sv: Dsys.map((d) => d.penetration) },
  { key: "recPrice", label: "Recurring clean price (" + rlab + ")", group: "price", higherBetter: true, value: D.recPrice, prior: D.recPricePrior,
    fmt: (v) => "$" + Math.round(v), pv: Dpeers.map((d) => d.recPrice), sv: Dsys.map((d) => d.recPrice) },
  { key: "occPrice", label: "Occasional clean price (" + rlab + ")", group: "price", higherBetter: true, value: D.occPrice, prior: D.occPricePrior,
    fmt: (v) => "$" + Math.round(v), pv: Dpeers.map((d) => d.occPrice), sv: Dsys.map((d) => d.occPrice) },
  { key: "util", label: "Route utilization (" + rlab + ")", group: "ops", higherBetter: true, value: D.util, prior: D.utilPrior,
    fmt: (v) => Math.round(v) + "%", pv: Dpeers.map((d) => d.util), sv: Dsys.map((d) => d.util) },
  { key: "conv", label: "Estimate close rate (" + rlab + ")", group: "driver", higherBetter: true, value: D.conv, prior: D.convPrior,
    fmt: (v) => Math.round(v) + "%", pv: Dpeers.map((d) => d.conv), sv: Dsys.map((d) => d.conv) },
  { key: "freq", label: "Cleans / customer (" + rlab + ")", group: "driver", higherBetter: true, value: D.freq, prior: D.freqPrior,
    fmt: (v) => v.toFixed(2), pv: Dpeers.map((d) => d.freq), sv: Dsys.map((d) => d.freq) },
  { key: "churn", label: "Annualized 13-wk churn (" + rlab + ")", group: "driver", higherBetter: false, value: D.churn, prior: D.churnPrior,
    fmt: (v) => v.toFixed(1) + "%", pv: Dpeers.map((d) => d.churn), sv: Dsys.map((d) => d.churn) },
  { key: "nps", label: "Franchisee NPS rating", group: "driver", higherBetter: true, value: o.npsRating, prior: null,
    fmt: (v) => v + "/10", pv: peers.map((p) => p.npsRating), sv: sys.map((p) => p.npsRating) },
  { key: "contacts", label: "FranConnect contacts YTD", group: "driver", higherBetter: true, value: o.contactsYtd, prior: null,
    fmt: (v) => v + " / 10", pv: peers.map((p) => p.contactsYtd), sv: sys.map((p) => p.contactsYtd) }];


  defs.forEach((m) => {
    m.peerMed = dMedian(m.pv);
    m.sysMed = dMedian(m.sv);
    const hasVal = isNum(m.value);
    if (hasVal) {
      m.pctl = pctlOf(m.pv, m.value);
      m.tone = toneFromPctl(m.pctl, m.higherBetter);
      m.aheadPct = Math.round((m.higherBetter ? m.pctl : 1 - m.pctl) * 100);
    } else {
      m.pctl = null;m.tone = "average";m.aheadPct = null;
    }
    const all = clean(m.pv.concat([m.value, m.sysMed, m.peerMed]));
    if (all.length) {
      const lo = Math.min.apply(null, all),hi = Math.max.apply(null, all),pad = (hi - lo) * 0.08 || 1;
      m.domain = [lo - pad, hi + pad];
    } else {
      m.domain = [0, 1];
    }
    m.yoy = isNum(m.prior) && hasVal ? m.value - m.prior : null;
  });
  return { metrics: defs, growth };
}

function upsideText(m, o) {
  if (!isNum(m.value) || !isNum(m.peerMed)) return "Awaiting data to benchmark this metric.";
  const gap = m.higherBetter ? m.peerMed - m.value : m.value - m.peerMed;
  if (m.key === "recPrice" && gap > 0 && isNum(o.customers) && isNum(o.freq2026)) {
    const cleans = o.customers * (o.freq2026 * 12);
    return "Priced $" + Math.round(gap) + " under peer median — ≈ " + MM.fmt.money(gap * cleans) + "/yr at current volume";
  }
  if (m.key === "occPrice" && gap > 0) return "$" + Math.round(gap) + " under peer median on one-time cleans";
  if (m.key === "penetration" && gap > 0 && isNum(o.customers)) return "≈ " + Math.round(gap / 100 * o.customers) + " customers to convert to recurring to reach peer median";
  if (m.key === "util" && gap > 0) return Math.round(gap) + " pts of idle capacity on mature routes vs peers";
  if (m.key === "churn" && gap > 0) return gap.toFixed(1) + " pts higher churn than peer median";
  if (m.key === "growth" && gap > 0) return "Trailing peer median growth by " + MM.fmt.pct(gap);
  return isNum(m.peerMed) ? "Below peer median (" + m.fmt(m.peerMed) + ")" : "Below peer median";
}

function Signals({ metrics, o, fbcName, rangeLabel }) {
  // FranConnect contacts are coaching/engagement signals, not recognition wins.
  const wins = metrics.filter((m) => m.tone === "great" && m.key !== "contacts").
  sort((a, b) => (b.higherBetter ? b.pctl : 1 - b.pctl) - (a.higherBetter ? a.pctl : 1 - a.pctl)).slice(0, 4);
  const opps = metrics.filter((m) => m.tone === "opportunity").
  sort((a, b) => (a.higherBetter ? a.pctl : 1 - a.pctl) - (b.higherBetter ? b.pctl : 1 - b.pctl)).slice(0, 4);
  return (
    <div className="sig-cols">
      <div className="sig-card win">
        <div className="sig-head">
          <span className="sig-ic" aria-hidden="true">
            <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M8 21h8M12 17v4M7 4h10v5a5 5 0 0 1-10 0Z" /><path d="M17 5h3v2a3 3 0 0 1-3 3M7 5H4v2a3 3 0 0 0 3 3" /></svg>
          </span>
          <span className="sig-title">Recognize the wins</span>
          {rangeLabel && <span className="sig-window">{rangeLabel}</span>}
        </div>
        {wins.length === 0 && <p className="sig-empty">No standout peer-leading metrics this period — focus on the opportunities.</p>}
        {wins.map((m) =>
        <div className="sig-item" key={m.key}>
            <span className="sig-item-top"><span className="sig-item-lab">{m.label}</span><span className="sig-item-val tone-great">{m.fmt(m.value)}</span></span>
            <span className="sig-item-det">Ahead of {m.aheadPct}% of {fbcName} peers{m.yoy != null && (m.higherBetter ? m.yoy > 0 : m.yoy < 0) ? " · improving YoY" : ""}</span>
          </div>
        )}
      </div>
      <div className="sig-card opp">
        <div className="sig-head">
          <span className="sig-ic" aria-hidden="true">
            <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><circle cx="12" cy="12" r="4" /><circle cx="12" cy="12" r="0.6" fill="currentColor" /></svg>
          </span>
          <span className="sig-title">Biggest opportunities</span>
          {rangeLabel && <span className="sig-window">{rangeLabel}</span>}
        </div>
        {opps.length === 0 && <p className="sig-empty">No metric is in the bottom third of peers — solid all around.</p>}
        {opps.map((m) =>
        <div className="sig-item" key={m.key}>
            <span className="sig-item-top"><span className="sig-item-lab">{m.label}</span><span className="sig-item-val tone-opportunity">{m.fmt(m.value)}</span></span>
            <span className="sig-item-det">{upsideText(m, o)}</span>
          </div>
        )}
      </div>
    </div>);

}

/* ---- KPI stat with YoY ---- */
function KpiCell({ label, value, yoy, tone, info }) {
  return (
    <div className="fbo-kpi">
      <span className="fbo-kpi-lab">{label}{info && <InfoDot text={info} />}</span>
      <span className="fbo-kpi-val">{value}</span>
      <span className="fbo-kpi-meta">{yoy}</span>
      {tone && <span className={"fbo-kpi-bar grad-" + tone}></span>}
    </div>);

}

/* ---- recurring clean price distribution (10 bins across all owners) ----
   Bins recompute against the selected time window. The owner's own bin is
   highlighted; hovering any bar lists the owners captured in it. */
function PriceDistribution({ owner, range, rangeLabel, peerMed }) {
  const [hover, setHover] = useState(null);
  const fbc = MM.fbcs.find((f) => f.id === owner.fbcId) || {};
  const BINS = 5;
  const fmt$ = (v) => "$" + Math.round(v);
  // peers only — the owner's own FBC book (which includes this owner)
  const rows = MM.ownersFor([owner.fbcId]).map((o) => ({ o, v: MM.ownerDrivers(o, range).recPrice })).filter((r) => isNum(r.v));
  if (rows.length < 3) return <p className="empty">Not enough priced peers in {fbc.name || "this FBC"}'s book to chart a distribution for this window.</p>;
  let lo = Math.min.apply(null, rows.map((r) => r.v)), hi = Math.max.apply(null, rows.map((r) => r.v));
  if (lo === hi) {lo -= 1;hi += 1;}
  const width = (hi - lo) / BINS;
  const bins = Array.from({ length: BINS }, (_, i) => ({ i, lo: lo + i * width, hi: lo + (i + 1) * width, owners: [] }));
  const binOf = (v) => Math.min(BINS - 1, Math.max(0, Math.floor((v - lo) / width)));
  rows.forEach((r) => bins[binOf(r.v)].owners.push(r));
  bins.forEach((b) => b.owners.sort((a, c) => c.v - a.v));
  const maxCount = Math.max.apply(null, bins.map((b) => b.owners.length).concat([1]));
  const ownerRow = rows.find((r) => r.o.id === owner.id);
  const ownerVal = ownerRow ? ownerRow.v : null;
  const ownerBin = isNum(ownerVal) ? binOf(ownerVal) : -1;
  return (
    <div className="dist">
      <div className="dist-sub">Recurring clean price across {fbc.name ? fbc.name + "'s" : "the FBC's"} {rows.length} priced peers · {rangeLabel} window · 5 price bands</div>
      <div className="dist-chart">
        {bins.map((b) => {
          const isOwner = b.i === ownerBin;
          const h = b.owners.length / maxCount * 100;
          return (
            <div key={b.i} className={"dist-col" + (isOwner ? " owner" : "")}
            onMouseEnter={() => setHover(b.i)} onMouseLeave={() => setHover(null)}>
              <div className="dist-bar-wrap">
                {hover === b.i && b.owners.length > 0 &&
                <div className="dist-pop">
                    <div className="dist-pop-h">{fmt$(b.lo)}–{fmt$(b.hi)} · {b.owners.length} peer{b.owners.length !== 1 ? "s" : ""}</div>
                    <div className="dist-pop-list">
                      {b.owners.map((r) =>
                    <div key={r.o.id} className={"dist-pop-row" + (r.o.id === owner.id ? " me" : "")}>
                          <span className="dist-pop-nm">{r.o.contact}{r.o.id === owner.id ? " (this owner)" : ""}</span><span className="dist-pop-v">{fmt$(r.v)}</span>
                        </div>
                    )}
                    </div>
                  </div>
                }
                <div className={"dist-bar" + (isOwner ? " owner" : "")} style={{ height: Math.max(2, h) + "%" }}>
                  <span className="dist-count">{b.owners.length || ""}</span>
                </div>
                {isOwner && <span className="dist-you" aria-hidden="true">▲</span>}
              </div>
              <span className="dist-x">{fmt$(b.lo)}–{fmt$(b.hi)}</span>
            </div>);

        })}
      </div>
      <div className="dist-foot">
        {isNum(ownerVal) ?
        <span className="dist-you-note"><span className="dist-dot"></span><b>{owner.contact}</b> · <b>{fmt$(ownerVal)}</b> — band {ownerBin + 1} of {BINS}</span> :
        <span className="dist-you-note">No recurring price for this owner in the {rangeLabel} window.</span>}
        {isNum(peerMed) && <span className="dist-ref">Peer median {fmt$(peerMed)}</span>}
        <span className="dist-ref">Hover a band to see the peers in it</span>
      </div>
    </div>);

}

/* ---- dual-axis YTD trend: total routes (left) + customers/route (right) ---- */
function RouteTrend({ owner }) {
  const [hover, setHover] = useState(null);
  const wrapRef = useRef(null);
  const [w, setW] = useState(640);
  useLayoutEffect(() => {
    function m() {if (wrapRef.current) setW(wrapRef.current.clientWidth);}
    m();const ro = new ResizeObserver(m);if (wrapRef.current) ro.observe(wrapRef.current);
    return () => ro.disconnect();
  }, []);
  const cw = CWX, dvw = owner.dvw2026;
  const routes = [], cpr = [];
  for (let i = 0; i < cw; i++) {
    const r = dvw && isNum(dvw.routes[i]) ? dvw.routes[i] : null;
    const c = dvw && isNum(dvw.totalCust[i]) ? dvw.totalCust[i] : null;
    routes.push(r);
    cpr.push(isNum(r) && r > 0 && isNum(c) ? c / r : null);
  }
  const haveRoutes = routes.some(isNum), haveCpr = cpr.some(isNum);
  if (!haveRoutes && !haveCpr) return <p className="empty">No weekly route data for this owner yet.</p>;
  const H = 230, mL = 44, mR = 52, mT = 16, mB = 26, plotW = Math.max(120, w - mL - mR), plotH = H - mT - mB;
  function dom(arr) {let lo = Infinity, hi = -Infinity;arr.forEach((v) => {if (isNum(v)) {if (v < lo) lo = v;if (v > hi) hi = v;}});if (lo === Infinity) {lo = 0;hi = 1;}if (lo === hi) {lo -= 1;hi += 1;}const pad = (hi - lo) * 0.15;return [Math.max(0, lo - pad), hi + pad];}
  const [rLo, rHi] = dom(routes), [cLo, cHi] = dom(cpr);
  const X = (i) => mL + (cw <= 1 ? 0 : i / (cw - 1) * plotW);
  const YR = (v) => mT + plotH - (v - rLo) / (rHi - rLo) * plotH;
  const YC = (v) => mT + plotH - (v - cLo) / (cHi - cLo) * plotH;
  function path(arr, Y) {let d = "", started = false;arr.forEach((v, i) => {if (!isNum(v)) {started = false;return;}d += (started ? "L" : "M") + X(i).toFixed(1) + " " + Y(v).toFixed(1) + " ";started = true;});return d.trim();}
  function onMove(e) {
    const rect = e.currentTarget.getBoundingClientRect();
    const px = (e.clientX - rect.left) * (w / rect.width);
    let i = Math.round((px - mL) / (plotW || 1) * (cw - 1));
    i = Math.max(0, Math.min(cw - 1, i));
    setHover(i);
  }
  const navy = "var(--mly-viz-primary)", rasp = "var(--mly-viz-accent)";
  return (
    <div className="rt" ref={wrapRef}>
      <div className="rt-legend">
        <span className="rt-leg"><span className="rt-sw" style={{ background: navy }}></span>Total routes</span>
        <span className="rt-leg"><span className="rt-sw" style={{ background: rasp }}></span>Customers / route</span>
        <span className="rt-leg-note">Separate scales · YTD through W{String(cw).padStart(2, "0")}</span>
      </div>
      <svg viewBox={"0 0 " + w + " " + H} className="rt-svg" onMouseMove={onMove} onMouseLeave={() => setHover(null)} preserveAspectRatio="none">
        {[0, 0.5, 1].map((t, k) => {const y = mT + plotH * t;return <line key={k} x1={mL} y1={y} x2={mL + plotW} y2={y} className="rt-grid" />;})}
        <text x={mL - 8} y={YR(rHi)} className="rt-ax rt-ax-l">{Math.round(rHi)}</text>
        <text x={mL - 8} y={YR(rLo) - 2} className="rt-ax rt-ax-l">{Math.round(rLo)}</text>
        <text x={mL + plotW + 8} y={YC(cHi)} className="rt-ax rt-ax-r">{Math.round(cHi)}</text>
        <text x={mL + plotW + 8} y={YC(cLo) - 2} className="rt-ax rt-ax-r">{Math.round(cLo)}</text>
        <path d={path(routes, YR)} fill="none" stroke={navy} strokeWidth="2.2" strokeLinejoin="round" strokeLinecap="round" />
        <path d={path(cpr, YC)} fill="none" stroke={rasp} strokeWidth="2.2" strokeLinejoin="round" strokeLinecap="round" />
        {hover != null &&
        <g>
            <line x1={X(hover)} y1={mT} x2={X(hover)} y2={mT + plotH} className="rt-cursor" />
            {isNum(routes[hover]) && <circle cx={X(hover)} cy={YR(routes[hover])} r="3.4" fill={navy} />}
            {isNum(cpr[hover]) && <circle cx={X(hover)} cy={YC(cpr[hover])} r="3.4" fill={rasp} />}
          </g>
        }
      </svg>
      {hover != null &&
      <div className="rt-tip">
          <b>Week {hover + 1}</b> · {MM.weekDates(hover + 1)}
          <span className="rt-tip-r"><span className="rt-sw" style={{ background: navy }}></span>{isNum(routes[hover]) ? Math.round(routes[hover]) + " routes" : "—"}</span>
          <span className="rt-tip-r"><span className="rt-sw" style={{ background: rasp }}></span>{isNum(cpr[hover]) ? Math.round(cpr[hover]) + " cust/route" : "—"}</span>
        </div>
      }
    </div>);

}

/* ---- dual-line mini chart (org vs system) for the hover popover ---- */
function lineD(arr, sx, sy) {
  let d = "", started = false;
  arr.forEach((v, i) => {if (!isNum(v)) {started = false;return;}d += (started ? "L" : "M") + sx(i).toFixed(1) + " " + sy(v).toFixed(1) + " ";started = true;});
  return d.trim();
}
function DualSpark({ org, sys, tone }) {
  const all = (org || []).concat(sys || []).filter(isNum);
  if (all.length < 2) return <span className="mp-spark-empty">Weekly trend not available</span>;
  const W = 236, H = 46, pad = 5, n = Math.max(org.length, sys.length);
  let minY = Math.min.apply(null, all), maxY = Math.max.apply(null, all);
  if (minY === maxY) {minY -= 1;maxY += 1;}
  const sx = (i) => pad + (n <= 1 ? 0 : i / (n - 1) * (W - 2 * pad));
  const sy = (v) => H - pad - (v - minY) / (maxY - minY) * (H - 2 * pad);
  // last real owner point — anchors a dot in the owner's tone so the line's
  // end state reads at a glance (same color as the raw value + rank chip).
  let li = -1;
  for (let i = (org || []).length - 1; i >= 0; i--) {if (isNum(org[i])) {li = i;break;}}
  const t = " tone-" + (tone || "info");
  return (
    <svg className="mp-spark" viewBox={"0 0 " + W + " " + H} preserveAspectRatio="none" aria-hidden="true">
      <path d={lineD(sys, sx, sy)} className="ds-sys" fill="none" />
      <path d={lineD(org, sx, sy)} className={"ds-org" + t} fill="none" />
      {li >= 0 && <circle className={"ds-dot" + t} cx={sx(li)} cy={sy(org[li])} r="2.8" />}
    </svg>);

}
function TrendPopover({ pos, title, org, sys, ownerName, hasSpark, rawText, rankText, tone }) {
  if (!pos) return null;
  return (
    <span className={"metric-pop fixed" + (pos.below ? " below" : "")} role="tooltip"
    style={{ left: pos.left + "px", top: pos.top + "px" }}>
      <span className="mp-head"><span className="mp-name">{title}</span>{rankText && <span className="mp-rank">{rankText}</span>}</span>
      <span className="mp-figs"><span className={"mp-raw tone-" + tone}>{rawText}</span></span>
      {hasSpark ? <DualSpark org={org} sys={sys} tone={tone} /> : <span className="mp-spark-empty">Weekly trend not tracked for this metric</span>}
      <span className="mp-legend2"><span className={"mp-l2 org tone-" + (tone || "info")}></span>{ownerName}<span className="mp-l2 sys"></span>System avg</span>
    </span>);

}

/* ---- a Drivers-vs-Peers bar: bench bar + rank chip + org-vs-system hover ---- */
function BenchBarRank({ m, driver, owner }) {
  const { open, pos, bind } = useHoverPop();
  const rk = window.rankOf ? window.rankOf(owner, driver, "owner") : null;
  const meta = window.DRIVER_META ? window.DRIVER_META[driver] : null;
  const tone = rk ? (() => {const q = rk.rank / rk.total;return q <= 0.34 ? "great" : q <= 0.66 ? "average" : "opportunity";})() : "info";
  const safe = (v) => isNum(v) ? m.fmt(v) : "-";
  const org = meta && meta.weekly && window.ownerWeeklySeries ? window.ownerWeeklySeries(owner, driver) : [];
  const sys = meta && meta.weekly && window.systemWeeklySeries ? window.systemWeeklySeries(driver) : [];
  return (
    <div className="bb-rank-wrap" {...bind}>
      <BenchBar label={m.label} value={m.value} valueText={safe(m.value)} peer={m.peerMed} peerText={safe(m.peerMed)} sys={m.sysMed} sysText={safe(m.sysMed)} domain={m.domain} tone={m.tone} />
      <div className="bb-rank-strip">
        <span className={"bb-rank-chip tone-" + tone}>{rk ? "Rank #" + rk.rank + " of " + rk.total + " owners" : "Not enough data to rank"}</span>
        <span className="bb-rank-hint">{meta && meta.weekly ? "Hover for weekly trend vs system" : "Point-in-time metric"}</span>
      </div>
      {open && <TrendPopover pos={pos} title={m.label} org={org} sys={sys} ownerName={owner.contact}
      hasSpark={!!(meta && meta.weekly)} rawText={safe(m.value)} rankText={rk ? "#" + rk.rank + " of " + rk.total : null} tone={tone} />}
    </div>);

}

/* ---- FranConnect contact deep dive (REAL notes, deterministic) ----
   Reads the owner's processed call notes straight from the data feed. The
   themes and the contact log are derived ONLY from the literal text logged in
   contactHistoryLogCall — nothing is inferred or generated. "Conversations"
   are real two-way contacts; left-messages and no-answers are excluded. When
   an owner has no substantive notes we say so plainly rather than guess. */
function ContactDeepDive({ owner, fbcName }) {
  const [open, setOpen] = useState(null);     // expanded log row index
  const [topicFilter, setTopicFilter] = useState(null);

  const themes = (owner.contactThemes || []);
  const log = (owner.contactLog || []);
  const conv2026 = isNum(owner.contactConversations2026) ? owner.contactConversations2026 : 0;
  const convAll = isNum(owner.contactConversationsAll) ? owner.contactConversationsAll : 0;
  const attempts = isNum(owner.contactAttempts2026) ? owner.contactAttempts2026 : 0;
  const notesCount = isNum(owner.contactNotesCount) ? owner.contactNotesCount : 0;
  const hasNotes = notesCount > 0 && log.length > 0;

  // keyword highlight for the active topic filter (literal match, no inference)
  const filtered = topicFilter
    ? log.filter((e) => {
        const t = themes.find((x) => x.key === topicFilter);
        if (!t) return true;
        const hay = ((e.subject || "") + " " + (e.comment || "")).toLowerCase();
        // a row matches the topic if any of that theme's example subjects/words appear
        return (t.examples || []).some((ex) =>
          ex.text && hay.includes(ex.text.replace(/…$/, "").toLowerCase().slice(0, 24)));
      })
    : log;

  return (
    <Card eyebrow="Engagement · FranConnect deep dive" title="FranConnect Contact Deep Dive"
    info="Built directly from the notes logged against this owner's FranConnect coaching contacts. Contacts are matched to this owner strictly by franchise license number (the source of truth), so a territory's history follows the license even after a resale or name change. Topics and themes are derived only from words that actually appear in the notes; left-messages and no-answers are excluded so the counts reflect real conversations. Nothing here is inferred or AI-generated.">
      <div className="cdd-meta">
        <span className="cdd-stat"><b>{conv2026}</b> conversations YTD 2026</span>
        <span className="cdd-stat">Goal <b>10</b>/yr</span>
        {attempts > 0 && <span className="cdd-stat"><b>{attempts}</b> logged attempt{attempts !== 1 ? "s" : ""} (no answer / message)</span>}
        <span className="cdd-stat">Last conversation <b>{owner.lastContact || "—"}</b></span>
        <span className={"cdd-pace " + (owner.contactsOnPace ? "on" : "off")}>{owner.contactsOnPace ? "On pace" : "Behind pace"}</span>
      </div>
      <p className="cdd-note">Topics and contact log are extracted verbatim from the notes in contactHistoryLogCall — not AI-generated. Attempts with no conversation are not shown below.</p>

      {!hasNotes &&
        <p className="cdd-err">
          {convAll > 0
            ? convAll + " contact" + (convAll !== 1 ? "s" : "") + " " + (convAll !== 1 ? "are" : "is") + " logged for " + owner.contact + ", but the notes captured are insufficient to provide deep-dive details. Ask " + (fbcName || "the coach") + " to log conversation notes in FranConnect to populate topics and themes here."
            : "No conversation notes are logged for " + owner.contact + " yet — current notes are insufficient to provide deep-dive details."}
        </p>}

      {hasNotes &&
      <div className="cdd-body">
        {themes.length > 0 &&
          <div className="cdd-themes">
            <div className="cdd-sub">Key topics across {notesCount} noted conversation{notesCount !== 1 ? "s" : ""}
              {topicFilter && <button className="cdd-clear" onClick={() => setTopicFilter(null)}>Clear filter</button>}
            </div>
            <div className="cdd-theme-grid">
              {themes.slice(0, 8).map((t) =>
                <button key={t.key}
                  className={"cdd-theme cdd-theme-btn" + (topicFilter === t.key ? " active" : "")}
                  onClick={() => setTopicFilter(topicFilter === t.key ? null : t.key)}
                  title="Filter the contact log to conversations that mention this topic">
                  <span className="cdd-theme-h">
                    <span className="cdd-theme-t">{t.title}</span>
                    <span className="cdd-theme-n">{t.mentions}×</span>
                  </span>
                  {t.examples && t.examples.length > 0 &&
                    <span className="cdd-theme-d">
                      e.g. {t.examples.slice(0, 2).map((ex) => ex.text).join(" · ")}
                    </span>}
                  {t.lastDate && <span className="cdd-theme-last">last raised {t.lastDate}</span>}
                </button>
              )}
            </div>
          </div>}

        <div className="cdd-log">
          <div className="cdd-sub">Contact log
            <span className="cdd-log-c">showing {filtered.length} of {log.length} noted conversation{log.length !== 1 ? "s" : ""}{log.length >= 60 ? " (most recent 60)" : ""}</span>
          </div>
          <div className="cdd-table-wrap">
            <table className="cdd-table">
              <thead>
                <tr><th>Date</th><th>Topic / subject</th><th>Channel</th><th>Coach</th></tr>
              </thead>
              <tbody>
                {filtered.map((e, i) => {
                  const isOpen = open === i;
                  return (
                    <React.Fragment key={i}>
                      <tr className={"cdd-row" + (isOpen ? " open" : "")} onClick={() => setOpen(isOpen ? null : i)}>
                        <td className="cdd-td-date">{e.date || "—"}</td>
                        <td className="cdd-td-subj">
                          <span className="cdd-caret" aria-hidden="true">{isOpen ? "▾" : "▸"}</span>
                          {e.subject || "(no subject)"}
                        </td>
                        <td className="cdd-td-chan">{e.channel || "—"}</td>
                        <td className="cdd-td-coach">{e.loggedBy || "—"}</td>
                      </tr>
                      {isOpen &&
                        <tr className="cdd-note-row">
                          <td colSpan={4}><p className="cdd-mtg-sum">{e.comment || "(no note text)"}</p></td>
                        </tr>}
                    </React.Fragment>);
                })}
                {filtered.length === 0 &&
                  <tr><td colSpan={4} className="cdd-empty-row">No noted conversations mention this topic.</td></tr>}
              </tbody>
            </table>
          </div>
          <p className="cdd-tablenote">Click any row to read the exact note logged for that conversation.</p>
        </div>
      </div>}
    </Card>);

}

/* ============================================================
   MAIN PAGE
   ============================================================ */
function FBODetailPage({ ownerId, setOwnerId }) {
  const [range, setRange] = useState("ytd");
  const owner = ownerId ? MM.owners.find((x) => x.id === ownerId) : null;
  const RLAB = { ytd: "YTD", rolling: "Rolling 4 wks", week: "W" + String(CWX).padStart(2, "0") };

  /* ---------- empty state ---------- */
  if (!owner) {
    const suggest = MM.owners.map((o) => ({ o, g: revWin(o, "ytd").prior ? (revWin(o, "ytd").cur - revWin(o, "ytd").prior) / revWin(o, "ytd").prior * 100 : 0 })).
    sort((a, b) => a.g - b.g).slice(0, 6);
    return (
      <div className="page fbo">
        <OwnerSearch onPick={setOwnerId} />
        <div className="fbo-empty">
          <div className="fbo-empty-ic" aria-hidden="true">
            <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
          </div>
          <h2 className="fbo-empty-h">Coach a single franchise owner</h2>
          <p className="fbo-empty-p">Search above by name, DBA, license, or location — or open an owner from any map dot, table row, or contributor list. You'll get their full performance picture plus pricing, recurring penetration, customer-frequency mix and route utilization, all benchmarked against peers and their own prior year.</p>
          <div className="fbo-empty-sug">
            <span className="fbo-empty-sug-lab">Owners trailing on YoY growth — a good place to start</span>
            <div className="fbo-empty-chips">
              {suggest.map(({ o, g }) =>
              <button key={o.id} className="fbo-chip" onClick={() => setOwnerId(o.id)}>
                  <span className="fbo-chip-n">{o.contact}</span>
                  <span className="fbo-chip-s">{o.city}, {o.state}<span className="fbo-chip-g">{MM.fmt.pct(g)}</span></span>
                </button>
              )}
            </div>
          </div>
        </div>
      </div>);

  }

  /* ---------- with an owner ---------- */
  const fbc = MM.fbcs.find((f) => f.id === owner.fbcId) || {};
  const { metrics, growth } = buildMetrics(owner, range);
  const mBy = {};metrics.forEach((m) => mBy[m.key] = m);
  const DRV = MM.ownerDrivers(owner, range); // windowed drivers for this owner
  const w = revWin(owner, range);
  const plan = planWin(owner, range);
  const vsPlan = plan ? (w.cur - plan) / plan * 100 : null;
  const royalty = w.cur * owner.royaltyRate;
  const custYoy = isNum(DRV.customers) && isNum(DRV.customersPrior) ? DRV.customers - DRV.customersPrior : null;

  const wkSeries = [
  { key: "y2024", name: "2024", color: "#9aa2bd", type: "line", data: owner.weekly.rev2024 },
  { key: "y2025", name: "2025", color: "#071d49", type: "line", data: owner.weekly.rev2025 },
  { key: "y2026", name: "2026", color: "#e22f7c", type: "line", data: owner.weekly.rev2026.map((v, i) => i < CWX ? v : null) },
  { key: "plan", name: "2026 plan", color: "#1f8a5b", type: "plan", data: owner.weekly.plan }];


  const benchBar = (key, valueOverrideText) => {
    const m = mBy[key];
    const safe = (v) => isNum(v) ? m.fmt(v) : "-";
    return <BenchBar label={m.label} value={m.value} valueText={valueOverrideText || safe(m.value)}
    peer={m.peerMed} peerText={safe(m.peerMed)} sys={m.sysMed} sysText={safe(m.sysMed)} domain={m.domain} tone={m.tone} />;
  };

  return (
    <div className="page fbo" data-screen-label="FBO Detail">
      <OwnerSearch onPick={setOwnerId} current={owner} />

      {/* identity header */}
      <div className="fbo-id card">
        <div className="fbo-id-main">
          <div className="eyebrow">Franchise Owner</div>
          <h2 className="fbo-id-name">{owner.contact}</h2>
          <p className="fbo-id-sub">
            <span className="fbo-id-dba">{owner.name}</span>
            {owner.legalName && <React.Fragment><span className="fbo-id-dot">·</span>{owner.legalName}</React.Fragment>}
            <span className="fbo-id-dot">·</span>{owner.city}, {owner.state}
          </p>
          <div className="fbo-id-tags">
            <span className="fbo-tag">{"License " + owner.license}</span>
            <span className="fbo-tag">{"Est. " + D(owner.yearStarted)}</span>
            <span className="fbo-tag">{(isNum(DRV.customers) ? Math.round(DRV.customers) : "-") + " customers"}</span>
            <span className="fbo-tag">{(isNum(DRV.routes) ? Math.round(DRV.routes) : "-") + " routes"}</span>
            <span className="fbo-tag"><FbcTag id={owner.fbcId} /></span>
          </div>
        </div>
        <div className="fbo-id-right">
          <span className="fbo-range-lab">Time window
            <InfoDot text="Sets the revenue window for this page — year-to-date, the rolling last 4 weeks, or the latest week. Structural metrics (pricing, penetration, routes) are point-in-time with a year-over-year comparison." /></span>
          <Segmented size="sm" value={range} onChange={setRange}
          options={[{ value: "ytd", label: "YTD" }, { value: "rolling", label: "Rolling 4 wks" }, { value: "week", label: RLAB.week }]} />
          <span className="fbo-scope">Comparing to {fbc.name}'s book &amp; the whole system</span>
        </div>
      </div>

      {/* recognition + opportunities */}
      <Signals metrics={metrics} o={owner} fbcName={fbc.name} rangeLabel={RLAB[range]} />

      {/* coaching — next best actions (moved up, right below recognition/opportunities) */}
      <Card eyebrow="Coaching" title="Next Best Actions"
      info="A prioritized, quantified action list derived from this owner's gaps versus peers, their own prior year, plan, and FranConnect engagement. Recalculates every week as the data refreshes.">
        <div className="nba">
          {(window.MM_INSIGHTS ? MM_INSIGHTS.ownerActions(owner, { limit: 3, range: range }) : []).map((a, i) =>
          <div className={"nba-row tone-" + a.tone} key={i}>
            <span className="nba-rank">{i + 1}</span>
            <div className="nba-body">
              <div className="nba-top">
                <span className="nba-title">{a.title}</span>
                <span className={"nba-tag tone-" + a.tone}>{a.tag}</span>
              </div>
              <p className="nba-detail">{a.detail}</p>
            </div>
          </div>
          )}
        </div>
      </Card>

      {/* KPI overview */}
      <div className="fbo-kpis">
        <KpiCell label={"Revenue · " + RLAB[range]} value={MM.fmt.money(w.cur, { dp: 1 })}
        yoy={<Yoy delta={w.cur - w.prior} higherBetter fmt={(v) => MM.fmt.money(v)} />}
        tone={growth >= 0 ? "great" : "opportunity"} />
        <KpiCell label="vs Plan" value={D(vsPlan, (v) => MM.fmt.pct(v))} tone={!isNum(vsPlan) ? "average" : vsPlan >= -0.5 ? "great" : vsPlan > -3 ? "average" : "opportunity"}
        info="2026 revenue against the owner's plan for this window." />
        <KpiCell label="Royalty" value={MM.fmt.money(royalty, { dp: 1 })} info="Royalty earned on revenue this window." />
        <KpiCell label={"Active customers · " + RLAB[range]} value={D(DRV.customers, (v) => MM.fmt.num(Math.round(v)))}
        yoy={<Yoy delta={custYoy} higherBetter fmt={(v) => MM.fmt.num(Math.round(v))} />} />
        <KpiCell label={"Recurring penetration · " + RLAB[range]} value={D(DRV.penetration, (v) => v.toFixed(2) + "%")}
        yoy={<Yoy delta={isNum(DRV.penetration) && isNum(DRV.penetrationPrior) ? DRV.penetration - DRV.penetrationPrior : null} higherBetter fmt={(v) => v.toFixed(2) + " pts"} />}
        tone={mBy.penetration.tone}
        info="Share of territory households on a recurring plan, averaged across the window." />
        <KpiCell label="Franchisee NPS" value={D(owner.npsRating, (v) => v + "/10")} tone={mBy.nps.tone}
        info="This owner's Fall 2025 NPS rating of their consultant (0–10). Updates Fall 2026." />
      </div>

      {/* revenue trend */}
      <Card eyebrow={"Revenue trend · " + fbc.name + "'s book"} title="Weekly Revenue — 53-Week View"
      info="This owner's weekly revenue across full fiscal years. Dashed green is their 2026 plan; solid pink is 2026 actual through the current week. Hover any week for year-over-year and vs-plan differences.">
        <LineChart series={wkSeries} currentWeek={CWX} formatY={(v) => MM.fmt.money(v)} height={340} />
      </Card>

      {/* drivers vs peers */}
      <Card eyebrow="Goal drivers" title="Drivers vs Peers"
      info="The five goal drivers for this owner, each shown against their FBC peers, plus the owner's rank among all owners in the system and a year-to-date weekly trend. The filled bar is this owner; the solid tick is the peer median, the faint tick the whole-system median.">
        <p className="range-note">Benchmarked against {fbc.name}'s other owners and ranked across all {MM.owners.length} system owners · revenue growth uses the {RLAB[range]} window.</p>
        <div className="bb-grid">
          {benchBar("growth")}
          <BenchBarRank m={mBy.conv} driver="conversion" owner={owner} />
          <BenchBarRank m={mBy.freq} driver="frequency" owner={owner} />
          <BenchBarRank m={mBy.churn} driver="retention" owner={owner} />
          <BenchBarRank m={mBy.nps} driver="nps" owner={owner} />
          <BenchBarRank m={mBy.contacts} driver="engagement" owner={owner} />
        </div>
      </Card>

      {/* pricing & recurring */}
      <Card eyebrow="Pricing & recurring revenue" title="Pricing Power & Recurring Mix"
      info="Average price per clean, and where this owner's recurring clean price falls among all owners. Recurring penetration now lives in the Customer Base section below.">
        <div className="price-tiles two">
          <PriceTile label="Recurring clean price" value={DRV.recPrice} prior={DRV.recPricePrior} peerMed={mBy.recPrice.peerMed} tone={mBy.recPrice.tone} />
          <PriceTile label="Occasional clean price" value={DRV.occPrice} prior={DRV.occPricePrior} peerMed={mBy.occPrice.peerMed} tone={mBy.occPrice.tone} />
        </div>
        <div data-comment-anchor="f93e457627-div-485-9">
          <PriceDistribution owner={owner} range={range} rangeLabel={RLAB[range]} peerMed={mBy.recPrice.peerMed} sysMed={mBy.recPrice.sysMed} />
        </div>
      </Card>

      {/* recurring customer frequency mix + occasional trend */}
      <Card eyebrow="Customer base" title="Recurring Frequency Mix & Occasional Trend"
      info="Top: how recurring customers split across cadence (weekly / bi-weekly / tri-weekly / every-4-weeks), this year vs last. Bottom: occasional / one-time work, tracked as a share of cleans and cleans per week.">
        <FreqStack dist={DRV.mix} prior={DRV.mixPrior} />
        <div className="occ-row">
          <div className="ops-tile">
            <span className="ops-val">{D(DRV.occPct, (v) => v.toFixed(1) + "%")}</span>
            <span className="ops-lab">Occasional cleans % ({RLAB[range]}) <Yoy delta={isNum(DRV.occPct) && isNum(DRV.occPctPrior) ? DRV.occPct - DRV.occPctPrior : null} higherBetter={false} fmt={(v) => v.toFixed(1) + " pts"} /></span>
          </div>
          <div className="ops-tile">
            <span className="ops-val">{D(DRV.occPerWk, (v) => Math.round(v))}</span>
            <span className="ops-lab">Occasional cleans / wk ({RLAB[range]}) <Yoy delta={isNum(DRV.occPerWk) && isNum(DRV.occPerWkPrior) ? DRV.occPerWk - DRV.occPerWkPrior : null} higherBetter fmt={(v) => Math.round(v) + ""} /></span>
          </div>
        </div>
        <p className="fq-readout">
          Territory penetration (recurring customers ÷ households) is <b>{D(DRV.penetration, (v) => v.toFixed(2) + "%")}</b>
          {isNum(DRV.penetration) && isNum(DRV.penetrationPrior) && (DRV.penetration >= DRV.penetrationPrior ?
          <span className="tone-great">, up {(DRV.penetration - DRV.penetrationPrior).toFixed(2)} pts YoY</span> :
          <span className="tone-opportunity">, down {(DRV.penetrationPrior - DRV.penetration).toFixed(2)} pts YoY</span>)}
          {isNum(mBy.penetration.peerMed) && <React.Fragment>{" "}vs a peer median of <b>{mBy.penetration.peerMed.toFixed(2)}%</b>.</React.Fragment>}
          {(() => {
            const rec = freqTotal(DRV.mix);
            const hh = isNum(DRV.penetration) && DRV.penetration > 0 && rec ? rec / (DRV.penetration / 100) : null;
            return hh ? <React.Fragment>{" "}That works out to roughly <b>{MM.fmt.num(Math.round(rec))} recurring customers</b> across an estimated <b>{MM.fmt.num(Math.round(hh))} territory households</b>.</React.Fragment> : null;
          })()}
        </p>
      </Card>

      {/* route operations */}
      <Card eyebrow="Operations" title="Route Capacity & Utilization"
      info="How total routes and customers-per-route have trended week by week this year (separate scales), alongside how fully the mature routes are utilized — the operational ceiling on growth. Low utilization means room to add jobs without adding routes; high utilization with strong demand signals it's time to add a route.">
        <div className="ops-grid">
          <div className="ops-trend">
            <div className="ops-trend-h">Routes &amp; customers per route — weekly, year-to-date</div>
            <RouteTrend owner={owner} />
          </div>
          <Donut value={DRV.util} peer={mBy.util.peerMed} sys={mBy.util.sysMed} label="Route utilization" sub="of capacity" tone={mBy.util.tone} />
        </div>
        {isNum(DRV.util) &&
        <p className="ops-note">
          Utilization {RLAB[range]} is {isNum(DRV.utilPrior) && (DRV.util >= DRV.utilPrior ?
          <span className="tone-great">up {(DRV.util - DRV.utilPrior).toFixed(0)} pts YoY</span> :
          <span className="tone-opportunity">down {(DRV.utilPrior - DRV.util).toFixed(0)} pts YoY</span>)}.
          {isNum(mBy.util.peerMed) && (DRV.util < mBy.util.peerMed ?
          " Idle route capacity is the fastest path to more revenue with no new fixed cost." :
          " Routes are running hot — watch for a route-add trigger if demand holds.")}
        </p>}
      </Card>

      {/* FranConnect contact deep dive — real notes */}
      <ContactDeepDive owner={owner} fbcName={fbc.name} />
    </div>);

}

window.FBODetailPage = FBODetailPage;