// Dashboard — analytics + operations home.
// Real data: KPI tiles, a workflow pipeline funnel, tier/signal/category/outreach
// charts, an outreach mini-board, and the day's triage lists. All charts are
// inline SVG (no external library) and themed from the app's CSS tokens.

const TIER_COLORS = {
  'tier-1': 'var(--accent)',
  'tier-2': 'var(--sig-blue-fg)',
  'tier-3': 'var(--sig-amber-fg)',
  'kiv': 'var(--muted)',
  'no-action': 'var(--ink-4)',
};

function Dashboard({ brands, onNavigate, onOpenBrand, onUpdateBrand, onToast, currentUser, approvals, onReloadProperties }) {
  const { PIPELINE_STAGES, PRIORITY_META, SIGNALS } = window.IPSEM_DATA;
  const [digestOpen, setDigestOpen] = React.useState(true);
  // Today vs Analytics. Seven charts on the same page as the day's work meant
  // scrolling past all of them to reach the three numbers that change daily.
  // Today is the default; the charts are one click away and nothing was removed.
  const [view, setView] = usePersistentState('dashboard.view', 'today');

  // Follow-ups due today or overdue (drives the tasks widget).
  //
  // pbIsHeldOrClosed is the app's ONE definition of "no longer in the flow" —
  // rejected, duplicate, no-action, KIV, no-match, do-not-contact or closed.
  // This list used to hand-roll a narrower version (do-not-contact or closed
  // only), so a brand rejected at intake kept asking to be chased: Coinbase sat
  // here for a week reading "overdue" after it had been stopped three different
  // ways. Any test for "is this brand still live" belongs in that one function.
  const followUps = React.useMemo(() => (
    brands
      .filter((b) => b.nextFollowUp && !pbIsHeldOrClosed(b))
      .map((b) => ({ b, d: daysUntil(b.nextFollowUp) }))
      .filter((x) => x.d !== null && x.d <= 0)
      .sort((a, z) => a.d - z.d)
  ), [brands]);

  // Outreach performance (M1): reply rate, meetings, and by-assignee split.
  const perf = React.useMemo(() => {
    const isConfirmed = (b) => (b.confirmedProperties || []).length > 0 && !(b.confirmedProperties || []).includes('none');
    const pipeline = brands.filter(isConfirmed);
    const isContacted = (b) => b.outreachStatus && b.outreachStatus !== 'not_contacted';
    const isReplied = (b) => b.outreachStatus === 'replied' || b.outreachStatus === 'call_scheduled';
    const contacted = pipeline.filter(isContacted).length;
    const repliedN = pipeline.filter(isReplied).length;
    const meetings = pipeline.filter((b) => b.outreachStatus === 'call_scheduled').length;
    const byAssignee = {};
    ASSIGNEE_NAMES.forEach((a) => { byAssignee[a] = { contacted: 0, replied: 0 }; });
    pipeline.forEach((b) => {
      const who = displayAssignee(b.assignee);
      if (!who || !byAssignee[who]) return;
      if (isContacted(b)) byAssignee[who].contacted++;
      if (isReplied(b)) byAssignee[who].replied++;
    });
    return { contacted, repliedN, meetings, replyRate: contacted ? Math.round((repliedN / contacted) * 100) : 0, byAssignee };
  }, [brands]);

  const logFollowUp = (b) => {
    const today = ipsemToday();
    onUpdateBrand && onUpdateBrand(b.id, {
      nextFollowUp: addBusinessDays(today, 5),
      outreachHistory: [...(b.outreachHistory || []), { action: 'follow_up', date: today, note: 'Logged a follow-up' }],
    });
    onToast && onToast(`Follow-up logged for ${b.brand} — next in 5 working days`);
  };
  const markReplied = (b) => {
    onUpdateBrand && onUpdateBrand(b.id, { outreachStatus: 'replied', nextFollowUp: addBusinessDays(ipsemToday(), 3) });
    onToast && onToast(`${b.brand} marked as replied`);
  };
  const snooze = (b) => {
    onUpdateBrand && onUpdateBrand(b.id, { nextFollowUp: addBusinessDays(ipsemToday(), 3) });
    onToast && onToast(`${b.brand} snoozed 3 working days`);
  };
  // Stop chasing this one, without killing the brand. Snooze only ever defers —
  // there was no way to say "done chasing" short of rejecting the brand or
  // marking it do-not-contact, which say something much stronger and take it out
  // of the pipeline. Clearing the date is the whole mechanism: the reminder
  // exists because a date is set.
  const stopFollowUps = (b) => {
    onUpdateBrand && onUpdateBrand(b.id, { nextFollowUp: null });
    onToast && onToast({
      text: `No more follow-up reminders for ${b.brand}`,
      action: {
        label: 'Undo',
        onClick: () => onUpdateBrand(b.id, { nextFollowUp: b.nextFollowUp }),
      },
    });
  };

  // Property portfolio — read from the list app.jsx already loaded, rather than
  // fetching the whole table again on every visit to the Dashboard.
  const properties = window.IPSEM_DATA.PROPERTIES;

  // Latest scan run — fetched on mount, refreshed every 30s.
  const [latestScan, setLatestScan] = React.useState(null);
  React.useEffect(() => {
    let cancelled = false;
    const fetchScan = () => {
      window.IPSEM_API.getScanRuns()
        .then((runs) => { if (!cancelled) setLatestScan(Array.isArray(runs) && runs[0] ? runs[0] : null); })
        .catch(() => {});
    };
    fetchScan();
    const interval = setInterval(fetchScan, 30000);
    return () => { cancelled = true; clearInterval(interval); };
  }, []);

  // IPSEM's operating day (GMT+8) — matches the scanner's review_date.
  const todayStr = ipsemToday();
  const dateLabel = new Intl.DateTimeFormat('en-GB', {
    timeZone: viewerTimeZone(),
    weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
  }).format(new Date());
  // Warn when the reader's calendar day is not IPSEM's — otherwise "due today"
  // silently means a different day for Stuart and Rene than it does for Aaron.
  const dayDrift = viewerOffsetFromIpsemDay();

  // ─── Derived metrics ──────────────────────────────────────────────────
  const m = React.useMemo(() => {
    const isConfirmed = (b) =>
      (b.confirmedProperties || []).length > 0 && !(b.confirmedProperties || []).includes('none');
    const inPipeline = brands.filter(isConfirmed);

    const newToday      = brands.filter((b) => b.reviewDate === todayStr).length;
    const tier1Today    = brands.filter((b) => b.reviewDate === todayStr && b.priority === 'tier-1').length;
    // The two gates the weekly meeting walks, kept separate because they are
    // different questions asked on different pages. This replaced a single
    // "awaiting triage" number that mixed brands already parked as KIV with
    // brands actually waiting on a property, and matched no filter anywhere.
    const needsDecision = brands.filter(needsRelevanceCall).length;    // News Intake
    const needsProperty = brands.filter(needsPropertyDecision).length; // Brand Research
    const aiFlagged     = brands.filter((b) => needsPropertyDecision(b) && b.needsManualReview).length;
    const researching   = brands.filter((b) => b.researchStatus === 'researching' || b.researchStatus === 'needs-review').length;
    const complete      = brands.filter((b) => b.researchStatus === 'complete').length;
    // readyMatch used to live here with the same hand-rolled shape as the old
    // draftsReady (it counted held and already-reviewed brands as awaiting a
    // property). Nothing rendered it, so rather than fix a number no one reads,
    // it is gone — needsProperty above is the one that is displayed, and it
    // already uses the shared needsPropertyDecision gate.
    // draftReadyNotSent is the app's one definition of "written and still to go
    // out" — it excludes held/KIV/closed brands and ones already contacted. This
    // used to be hand-rolled as "has a draft and a confirmed property", which
    // counted 256 against a Draft page offering 227: 25 brands parked as KIV and
    // 4 already sent. The tile and the digest both read it, and both link
    // straight to the list that disagreed with them.
    const draftsReady   = brands.filter(draftReadyNotSent).length;
    const contacted     = inPipeline.filter((b) => b.outreachStatus && b.outreachStatus !== 'not_contacted').length;

    const scores = brands.map((b) => b.relevanceScore || 0).filter((s) => s > 0);
    const avgScore = scores.length ? Math.round(scores.reduce((a, s) => a + s, 0) / scores.length) : 0;

    // Tier distribution
    const tierCounts = {};
    brands.forEach((b) => { tierCounts[b.priority] = (tierCounts[b.priority] || 0) + 1; });

    // Signal frequency
    const sigCounts = {};
    brands.forEach((b) => (b.signals || []).forEach((s) => { sigCounts[s] = (sigCounts[s] || 0) + 1; }));

    // Category frequency
    const catCounts = {};
    brands.forEach((b) => { if (b.category) catCounts[b.category] = (catCounts[b.category] || 0) + 1; });

    // Outreach stage distribution (within the confirmed pipeline)
    const stageCounts = {};
    inPipeline.forEach((b) => {
      const s = b.outreachStatus || 'not_contacted';
      stageCounts[s] = (stageCounts[s] || 0) + 1;
    });

    return {
      total: brands.length, newToday, tier1Today, needsDecision, researching, complete,
      draftsReady, contacted, pipelineTotal: inPipeline.length,
      avgScore, tierCounts, sigCounts, catCounts, stageCounts, inPipeline,
      aiFlagged, needsProperty,
    };
  }, [brands, todayStr]);

  // Deep-link straight to the matching tab on News Intake, so a number on this
  // page always has a list behind it. Previously "Triage" dropped you on the
  // whole 593-row book with no filter that matched the count.
  const openIntakeTab = (tabId) => {
    try { sessionStorage.setItem('ipsem-intake-tab', tabId); } catch (_) {}
    onNavigate('intake');
  };

  // Action-oriented daily digest items (only surfaced when there's something to do).
  // The weekly walk, in the order it is actually done: relevance calls on News
  // Intake, then property decisions on Brand Research, then drafts to send.
  const digestItems = [
    m.needsDecision > 0 && {
      icon: 'eye',
      text: `${m.needsDecision} brand${m.needsDecision === 1 ? '' : 's'} to research, keep in view, or reject`,
      cta: 'Review', tab: 'needs-review', route: 'intake', tone: 'var(--pending)',
    },
    m.needsProperty > 0 && {
      icon: 'link',
      text: m.aiFlagged > 0
        ? `${m.needsProperty} brand${m.needsProperty === 1 ? '' : 's'} need a property decision — ${m.aiFlagged} the AI was unsure about`
        : `${m.needsProperty} brand${m.needsProperty === 1 ? '' : 's'} need a property decision`,
      cta: 'Decide', route: 'research', tone: 'var(--sig-purple-fg)',
    },
    m.draftsReady > 0 && {
      icon: 'mail',
      text: `${m.draftsReady} draft${m.draftsReady === 1 ? '' : 's'} ready to send`,
      cta: 'Open', route: 'draft', tone: 'var(--positive)',
    },
    m.tier1Today > 0 && {
      icon: 'bolt',
      text: `${m.tier1Today} new Tier 1 brand${m.tier1Today === 1 ? '' : 's'} today`,
      cta: 'Review', tab: 'needs-review', route: 'intake', tone: 'var(--accent)',
    },
  ].filter(Boolean);

  // Pipeline breakdown — every brand placed at its ONE current stage, using the
  // exact same pbStage() model as the Pipeline board, so the two views always
  // agree. Held/closed brands get their own row. Rows therefore SUM to the total
  // (m.total) — no cumulative subsets, no numbers that fail to add up.
  const funnel = React.useMemo(() => {
    const counts = {};
    PB_STAGES.forEach((s) => { counts[s.key] = 0; });
    let held = 0;
    brands.forEach((b) => {
      const st = pbStage(b);
      if (!st) held += 1; else counts[st] += 1;
    });
    const rows = PB_STAGES.map((s) => ({
      key: s.key, label: s.label, value: counts[s.key], route: s.route, color: s.color,
    }));
    rows.push({ key: 'held', label: 'Held / closed', value: held, route: 'intake', tab: 'held', color: 'var(--ink-4)' });
    return rows;
  }, [brands]);
  const funnelTotal = funnel.reduce((a, f) => a + f.value, 0);

  // Tier donut data
  const tierData = ['tier-1', 'tier-2', 'tier-3', 'kiv', 'no-action']
    .map((t) => ({ key: t, label: (PRIORITY_META[t] && PRIORITY_META[t].label) || t, value: m.tierCounts[t] || 0, color: TIER_COLORS[t] }))
    .filter((d) => d.value > 0);

  // Top signals
  const signalData = Object.entries(m.sigCounts)
    .map(([k, v]) => ({ key: k, label: (SIGNALS[k] && SIGNALS[k].label) || k, value: v, color: `var(--sig-${(SIGNALS[k] && SIGNALS[k].tone) || 'neutral'}-fg)` }))
    .sort((a, b) => b.value - a.value)
    .slice(0, 7);

  // Top categories
  const catData = Object.entries(m.catCounts)
    .map(([k, v]) => ({ key: k, label: k, value: v, color: 'var(--accent)' }))
    .sort((a, b) => b.value - a.value)
    .slice(0, 6);

  // Outreach stage donut
  const stageData = OUTREACH_STAGES
    .map((s) => ({ key: s.value, label: s.label, value: m.stageCounts[s.value] || 0, color: s.color }))
    .filter((d) => d.value > 0);

  // Brands by IPSEM property — counts each brand against every property it is
  // linked to (confirmed first, else potential). Mirrors the weekly workbook's
  // "Breakdown of brands by IPSEM property" donut.
  const PROP_PALETTE = [
    'var(--accent)', 'var(--sig-blue-fg)', 'var(--sig-purple-fg)', 'var(--positive)',
    'var(--pending)', 'var(--sig-amber-fg)', 'var(--attention)', 'var(--muted)',
    'var(--ink-3)', 'var(--ink-4)',
  ];
  const propData = React.useMemo(() => {
    const nameOf = {};
    properties.forEach((p) => { nameOf[p.id] = p.short || p.name; });
    nameOf['generic'] = 'Generic';
    const counts = {};
    brands.forEach((b) => {
      const ids = (b.confirmedProperties && b.confirmedProperties.length ? b.confirmedProperties : (b.potentialProperties || []))
        .filter((id) => id && id !== 'none');
      new Set(ids).forEach((id) => { counts[id] = (counts[id] || 0) + 1; });
    });
    let items = Object.entries(counts)
      .map(([id, v]) => ({ key: id, label: nameOf[id] || id, value: v }))
      .sort((a, b) => b.value - a.value);
    if (items.length > 10) {
      const rest = items.slice(9);
      items = items.slice(0, 9).concat([{ key: '__other', label: `Other (${rest.length})`, value: rest.reduce((a, i) => a + i.value, 0) }]);
    }
    return items.map((it, i) => ({ ...it, color: PROP_PALETTE[i % PROP_PALETTE.length] }));
  }, [brands, properties]);
  const propTotal = propData.reduce((a, d) => a + d.value, 0);

  // Brands reviewed over time — weekly counts from review_date (workbook line chart).
  const reviewTrend = React.useMemo(() => {
    const weekOf = (iso) => {
      const d = new Date(iso + 'T00:00:00Z');
      if (isNaN(d)) return null;
      const day = (d.getUTCDay() + 6) % 7;            // Monday = 0
      d.setUTCDate(d.getUTCDate() - day);
      return d.toISOString().slice(0, 10);
    };
    const counts = {};
    brands.forEach((b) => {
      if (!b.reviewDate) return;
      const w = weekOf(b.reviewDate);
      if (w) counts[w] = (counts[w] || 0) + 1;
    });
    const weeks = Object.keys(counts).sort();
    if (weeks.length === 0) return [];
    // Fill quiet weeks with 0 so the line shows real cadence (no compressed gaps).
    const out = [];
    const cur = new Date(weeks[0] + 'T00:00:00Z');
    const end = new Date(weeks[weeks.length - 1] + 'T00:00:00Z');
    while (cur <= end) {
      const k = cur.toISOString().slice(0, 10);
      out.push({ week: k, value: counts[k] || 0 });
      cur.setUTCDate(cur.getUTCDate() + 7);
    }
    return out;
  }, [brands]);

  // What today's scan brought in. Strictly today's — the old fallback showed
  // unreviewed brands instead, which made this a third copy of the queue.
  const previewBrands = brands.filter((b) => b.reviewDate === todayStr).slice(0, 5);

  // Hero copy
  let heroTitle, heroSub;
  if (m.newToday > 0) {
    heroTitle = `${m.newToday} new brand${m.newToday === 1 ? '' : 's'} from today's scan.`;
    heroSub = m.needsDecision > 0
      ? `${m.needsDecision} brand${m.needsDecision === 1 ? '' : 's'} still need reviewing. Start with today's intake.`
      : `Open today's intake to route them through the pipeline.`;
  } else if (m.needsDecision > 0) {
    heroTitle = `${m.needsDecision} brand${m.needsDecision === 1 ? '' : 's'} not yet reviewed.`;
    heroSub = `No new brands today. Work the review list below and tick each one off — reviewed brands stop reappearing.`;
  } else {
    heroTitle = `Pipeline overview.`;
    // Scanning is manual — don't promise a daily scan that may not fire.
    heroSub = `All caught up on intake. Use Re-run scan in the top bar to pull in new brands.`;
  }

  return (
    <div className="page page-cap">
      {/* Header */}
      <div className="row" style={{ justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 24, gap: 24 }}>
        <div>
          <div className="row gap-2 text-3 text-sm" style={{ marginBottom: 10 }}>
            <window.I.cal size={14} />
            <span>{dateLabel}</span>
          </div>
          <h1 className="h1" style={{ margin: 0, fontSize: 34 }}>{heroTitle}</h1>
          <p className="text-2" style={{ marginTop: 10, fontSize: 14.5, maxWidth: 560, lineHeight: 1.6 }}>{heroSub}</p>
        </div>
        <div className="row gap-2">
          <button className="btn" onClick={() => onNavigate('properties')}>
            <window.I.trophy size={14} /> Properties
          </button>
          <button className="btn btn-primary" onClick={() => onNavigate('intake')}>
            Review intake <window.I.arrowRight size={15} />
          </button>
        </div>
      </div>

      {/* Today's work, or the charts — never both at once. */}
      <div className="row gap-3" style={{ marginBottom: 20, flexWrap: 'wrap', alignItems: 'center' }}>
        <div className="toggle-group">
          <button className={view === 'today' ? 'is-active' : ''} onClick={() => setView('today')}>Today</button>
          <button className={view === 'analytics' ? 'is-active' : ''} onClick={() => setView('analytics')}>Analytics</button>
        </div>
        <span className="text-xs text-3">
          {view === 'today'
            ? 'What needs you today. Charts moved to Analytics.'
            : 'The full picture across the book — nothing here needs action.'}
        </span>
      </div>

      {view === 'today' ? (
      <>
      {/* Last scan error — surfaced so failures aren't invisible */}
      {latestScan && latestScan.status === 'error' && latestScan.errorMessage ? (
        <div className="row gap-2" style={{ marginBottom: 22, padding: '12px 16px', background: 'var(--negative-soft)', border: '1px solid var(--negative)', borderRadius: 10, alignItems: 'center' }}>
          <window.I.warn size={16} stroke="var(--negative)" />
          <div className="grow">
            <div style={{ fontWeight: 600 }}>Last scan failed</div>
            <div className="text-2 text-sm">{latestScan.errorMessage}</div>
          </div>
        </div>
      ) : null}

      {/* Industry news — rightsholder + trade-press intelligence */}
      <IndustryNewsCard onNavigate={onNavigate} />

      {/* Follow-ups due — the outreach chase list */}
      {followUps.length > 0 ? (
        <Card lg style={{ marginBottom: 22, borderColor: 'var(--attention)' }}>
          <div className="row" style={{ justifyContent: 'space-between', marginBottom: 12 }}>
            <div className="row gap-2">
              <window.I.cal size={14} stroke="var(--attention)" />
              <span className="eyebrow">Follow-ups due · {followUps.length}</span>
            </div>
          </div>
          <div className="col gap-2">
            {followUps.slice(0, 8).map(({ b, d }) => (
              <div key={b.id} className="row gap-3" style={{ padding: '10px 12px', background: 'var(--card-alt)', borderRadius: 8, alignItems: 'center' }}>
                <BrandLogo brand={b} size={26} />
                <div className="grow" style={{ minWidth: 0, cursor: 'pointer' }} onClick={() => onOpenBrand(b.id)}>
                  <div className="row gap-2" style={{ alignItems: 'center' }}>
                    <span style={{ fontWeight: 600, fontSize: 13.5 }} className="truncate">{b.brand}</span>
                    <Pill kind={d < 0 ? 'attention' : 'pending'} style={{ fontSize: 10 }}>
                      {d < 0 ? `${Math.abs(d)}d overdue` : 'due today'}
                    </Pill>
                    <span className="text-xs text-3">{outreachMeta(b.outreachStatus).label}</span>
                  </div>
                  <div className="text-xs text-3 truncate" style={{ marginTop: 1 }}>
                    {b.outreachContact ? `Contact: ${b.outreachContact}` : (b.category || '')}
                  </div>
                </div>
                <div className="row gap-1" onClick={(e) => e.stopPropagation()}>
                  <button className="btn btn-sm" onClick={() => logFollowUp(b)} title="Logged a follow-up, next in 5 working days">
                    <window.I.check size={12} /> Followed up
                  </button>
                  <button className="btn btn-sm btn-ghost" onClick={() => markReplied(b)} title="Mark replied">Replied</button>
                  <button className="btn btn-sm btn-ghost" onClick={() => snooze(b)} title="Snooze 3 working days">Snooze</button>
                  <button
                    className="btn btn-sm btn-ghost"
                    onClick={() => stopFollowUps(b)}
                    title="Stop reminding me about this brand. It stays in the pipeline — this only clears the follow-up date."
                  >
                    <window.I.ban size={12} /> Stop
                  </button>
                </div>
              </div>
            ))}
          </div>
        </Card>
      ) : null}

      {/* Awaiting rightsholder — the approval chase list, one row per property
          because that is the unit you actually chase: one email covers all of
          a rightsholder's outstanding brands, not one email per brand. */}
      <AwaitingRightsholder
        brands={brands}
        approvals={approvals}
        onNavigate={onNavigate}
        onToast={onToast}
        onReloadProperties={onReloadProperties}
      />

      {/* Daily digest — your prioritised to-do for today */}
      {digestOpen && digestItems.length > 0 ? (
        <Card lg style={{ marginBottom: 22, background: 'var(--card-alt)', borderColor: 'var(--hairline-strong)' }}>
          <div className="row" style={{ justifyContent: 'space-between', marginBottom: 12 }}>
            <div className="row gap-2">
              <window.I.sparkle size={14} stroke="var(--accent)" />
              <span className="eyebrow">Your day · {digestItems.length} thing{digestItems.length === 1 ? '' : 's'} to action</span>
            </div>
            <button className="btn btn-icon btn-ghost" onClick={() => setDigestOpen(false)} title="Dismiss">
              <window.I.x size={14} />
            </button>
          </div>
          <div className="col gap-2">
            {digestItems.map((it) => {
              const Ico = window.I[it.icon];
              return (
                <div key={it.route + it.text} className="row gap-3" style={{ alignItems: 'center', padding: '8px 12px', background: 'var(--card)', borderRadius: 8, border: '1px solid var(--hairline)' }}>
                  <span style={{ width: 8, height: 8, borderRadius: 999, background: it.tone, flexShrink: 0 }} />
                  {Ico ? <Ico size={15} stroke={it.tone} /> : null}
                  <span className="text-sm grow" style={{ fontWeight: 500 }}>{it.text}</span>
                  <button className="btn btn-sm" onClick={() => (it.tab ? openIntakeTab(it.tab) : onNavigate(it.route))}>
                    {it.cta} <window.I.arrowRight size={12} />
                  </button>
                </div>
              );
            })}
          </div>
        </Card>
      ) : null}

      {/* KPI tiles */}
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 14, marginBottom: 28 }}>
        <StatTile value={m.total}        label="Brands tracked"      tone="ink"      icon="briefcase" onClick={() => onNavigate('intake')} />
        <StatTile value={m.newToday}     label="New today"           tone="accent"   icon="bolt"      onClick={() => onNavigate('intake')} />
        <StatTile value={m.needsDecision} label="Relevance call"     tone="pending"  icon="eye"       onClick={() => openIntakeTab('needs-review')} />
        <StatTile value={m.needsProperty} label="Needs a property"   tone="blue"     icon="link"      onClick={() => onNavigate('research')} />
        <StatTile value={m.draftsReady}  label="Drafts ready"        tone="positive" icon="mail"      onClick={() => onNavigate('draft')} />
      </div>

      {/* The work queue — one ordered list of everything waiting on a person,
          so choosing where to start is no longer a decision in itself. */}
      <QueuePanel
        brands={brands}
        onNavigate={onNavigate}
        onOpenBrand={onOpenBrand}
        currentUser={currentUser}
        onUpdateBrand={onUpdateBrand}
        onToast={onToast}
      />
      </>
      ) : null}

      {view === 'analytics' ? (
      <>
      {/* Pipeline funnel + tier donut */}
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(340px, 1fr))', gap: 18, marginBottom: 18 }}>
        <Card lg>
          <ChartHead title="Pipeline breakdown" sub="Every brand shown once, at its current stage. The rows add up to the total. Click a stage to open it." />
          <div className="col gap-2" style={{ marginTop: 4 }}>
            {funnel.map((f, i) => {
              const pct = funnelTotal > 0 ? Math.round((f.value / funnelTotal) * 100) : 0;
              const isHeld = f.key === 'held';
              return (
                <div
                  key={f.key}
                  className="row gap-3"
                  style={{ cursor: 'pointer', alignItems: 'center' }}
                  onClick={() => f.tab ? openIntakeTab(f.tab) : onNavigate(f.route)}
                  title={`${f.value} brand${f.value === 1 ? '' : 's'} · ${pct}% of ${funnelTotal}`}
                >
                  <span className="row gap-2" style={{ width: 148, flexShrink: 0, alignItems: 'center' }}>
                    <span style={{ width: 18, height: 18, borderRadius: 5, background: f.color, color: '#fff', fontSize: 10, fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>{isHeld ? '·' : i + 1}</span>
                    <span className="text-sm text-2 truncate">{f.label}</span>
                  </span>
                  <span style={{ flex: 1, height: 26, background: 'var(--card-alt)', borderRadius: 6, overflow: 'hidden', position: 'relative' }}>
                    <span style={{
                      display: 'block', height: '100%',
                      width: `${Math.max(pct, f.value > 0 ? 4 : 0)}%`,
                      background: f.color, borderRadius: 6,
                      transition: 'width 400ms ease',
                    }} />
                  </span>
                  <span style={{ width: 78, textAlign: 'right', flexShrink: 0 }}>
                    <span className="mono" style={{ fontWeight: 600, fontSize: 14 }}>{f.value}</span>
                    <span className="text-3" style={{ display: 'block', fontSize: 10.5, marginTop: 1, fontWeight: 400 }}>
                      {pct}% of total
                    </span>
                  </span>
                </div>
              );
            })}
          </div>
          <div className="row gap-4 text-xs text-3" style={{ marginTop: 16, paddingTop: 14, borderTop: '1px solid var(--hairline)' }}>
            <span>Total <strong className="mono" style={{ color: 'var(--ink)' }}>{funnelTotal}</strong> brands</span>
            <span>·</span>
            <span>Avg score <strong className="mono" style={{ color: 'var(--ink)' }}>{m.avgScore}</strong></span>
            <span>·</span>
            <span>{formatScanRun(latestScan)}</span>
          </div>
        </Card>

        <Card lg>
          <ChartHead title="Brands by tier" sub="Priority distribution across all tracked brands." />
          {tierData.length === 0 ? <Empty /> : (
            <Donut data={tierData} total={m.total} centerLabel="brands" />
          )}
        </Card>
      </div>

      {/* Signals + outreach */}
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(340px, 1fr))', gap: 18, marginBottom: 18 }}>
        <Card lg>
          <ChartHead title="Top buying signals" sub="Most frequent signals detected across the book." />
          {signalData.length === 0 ? <Empty /> : <HBars items={signalData} />}
        </Card>
        <Card lg>
          <ChartHead title="Outreach status" sub={`${m.contacted} of ${m.pipelineTotal} confirmed brands contacted.`} />
          {stageData.length === 0 ? <Empty msg="No confirmed brands in the outreach pipeline yet." /> : (
            <Donut data={stageData} total={m.pipelineTotal} centerLabel="in pipeline" onSegmentClick={() => onNavigate('draft')} />
          )}
        </Card>
      </div>

      {/* By property + review trend (from the weekly workbook dashboard) */}
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(340px, 1fr))', gap: 18, marginBottom: 18 }}>
        <Card lg>
          <ChartHead title="Brands by IPSEM property" sub="How the prospect book spreads across the portfolio (confirmed first, else potential)." />
          {propData.length === 0 ? <Empty msg="No brands linked to properties yet." /> : (
            <Donut data={propData} total={propTotal} centerLabel="links" onSegmentClick={() => onNavigate('match')} />
          )}
        </Card>
        <Card lg>
          <ChartHead title="Brands reviewed over time" sub="New brands added per review week." />
          {reviewTrend.length === 0 ? <Empty /> : <TrendLine points={reviewTrend} />}
        </Card>
      </div>

      {/* Categories */}
      {catData.length > 0 ? (
        <Card lg style={{ marginBottom: 18 }}>
          <ChartHead title="Top categories" sub="Where the prospect book is concentrated." />
          <HBars items={catData} />
        </Card>
      ) : null}

      {/* Outreach performance (M1) */}
      {perf.contacted > 0 ? (
        <Card lg style={{ marginBottom: 18 }}>
          <ChartHead title="Outreach performance" sub="How contacted brands are converting." />
          <div className="row gap-3" style={{ flexWrap: 'wrap', marginBottom: 16 }}>
            {[
              { label: 'Contacted', value: perf.contacted, color: 'var(--pending)' },
              { label: 'Replied', value: perf.repliedN, color: 'var(--accent)' },
              { label: 'Meetings booked', value: perf.meetings, color: 'var(--positive)' },
              { label: 'Reply rate', value: `${perf.replyRate}%`, color: 'var(--ink)' },
            ].map((k) => (
              <div key={k.label} style={{ flex: '1 1 130px', padding: '12px 14px', background: 'var(--card-alt)', borderRadius: 10 }}>
                <div className="mono" style={{ fontSize: 24, fontWeight: 500, color: k.color }}>{k.value}</div>
                <div className="eyebrow text-xs" style={{ marginTop: 2 }}>{k.label}</div>
              </div>
            ))}
          </div>
          <div className="eyebrow text-xs" style={{ marginBottom: 8 }}>By owner</div>
          <table className="tbl">
            <thead><tr><th>Owner</th><th style={{ width: 120, textAlign: 'right' }}>Contacted</th><th style={{ width: 120, textAlign: 'right' }}>Replied</th><th style={{ width: 110, textAlign: 'right' }}>Reply rate</th></tr></thead>
            <tbody>
              {ASSIGNEE_NAMES.map((a) => {
                const r = perf.byAssignee[a] || { contacted: 0, replied: 0 };
                const rate = r.contacted ? Math.round((r.replied / r.contacted) * 100) : 0;
                return (
                  <tr key={a}>
                    <td style={{ fontWeight: 500 }}>{a}</td>
                    <td className="mono text-sm" style={{ textAlign: 'right' }}>{r.contacted}</td>
                    <td className="mono text-sm" style={{ textAlign: 'right' }}>{r.replied}</td>
                    <td className="mono text-sm" style={{ textAlign: 'right', color: rate >= 20 ? 'var(--positive)' : 'var(--ink-2)' }}>{rate}%</td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        </Card>
      ) : null}

      </>
      ) : null}

      {/* What the scan found today. Only rendered when there IS something new:
          it answers "what arrived", which is the one question Your queue does
          not. Its old fallback listed unreviewed brands instead, which made it
          a second copy of the queue on every day without a scan. */}
      {view === 'today' && previewBrands.length > 0 ? (
        <Card pad={false} style={{ marginBottom: 22 }}>
          <div className="row" style={{ padding: '14px 18px', borderBottom: '1px solid var(--hairline)', justifyContent: 'space-between' }}>
            <div className="col" style={{ gap: 2 }}>
              <div className="h3">Today's brand picks</div>
              <div className="text-3 text-xs">
                Showing {previewBrands.length} of {m.newToday} new.
              </div>
            </div>
            <button className="btn btn-sm btn-ghost" onClick={() => onNavigate('intake')}>
              See all <window.I.chevronRight size={14} />
            </button>
          </div>
          <div>
            {previewBrands.map((b) => (
              <div
                key={b.id}
                className="row gap-3"
                style={{ padding: '12px 18px', borderBottom: '1px solid var(--hairline)', cursor: 'pointer' }}
                onClick={() => onOpenBrand(b.id)}
                onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(29,78,216,0.045)')}
                onMouseLeave={(e) => (e.currentTarget.style.background = '')}
              >
                <BrandLogo brand={b} size={34} />
                <div className="col grow" style={{ gap: 4, minWidth: 0 }}>
                  <div className="row gap-2">
                    <span style={{ fontWeight: 600 }}>{b.brand}</span>
                    <span className="text-3 text-xs">· {b.category}</span>
                  </div>
                  <div className="text-2 text-sm truncate">{b.news?.title}</div>
                </div>
                <div className="col" style={{ gap: 6, alignItems: 'flex-end' }}>
                  <PriorityPill priority={b.priority} />
                  <span className="text-xs text-3 mono">{b.relevanceScore}</span>
                </div>
              </div>
            ))}
          </div>
        </Card>
      ) : null}
    </div>
  );
}

// ─── Building blocks ───────────────────────────────────────────────────────

// ─── The work queue ───────────────────────────────────────────────────────
// buildQueue() (ui.jsx) orders every brand whose blocker needs a person —
// earliest stage first, then tier, then newest. This panel is the readable
// version of the topbar's "Work the queue" button: same order, same source.
// ─── Awaiting rightsholder ────────────────────────────────────────────────
// Exporting an approval sheet used to be the end of the story: if a
// rightsholder never replied, nothing in the app ever mentioned it again. This
// is the outreach follow-up panel's counterpart for the approval round.
//
// Grouped by property, because that is the unit of the chase — one email to
// Coventry covers all 38 of their outstanding brands. Chase and snooze are
// therefore stored on the property too (MIGRATION_approval_chasing.sql), which
// also means this panel needs no request of its own: properties are already
// loaded app-wide and approvals arrive with the rest of the boot data.
function AwaitingRightsholder({ brands, approvals, onNavigate, onToast, onReloadProperties }) {
  const [busy, setBusy] = React.useState('');
  const todayISO = ipsemToday();

  const rows = React.useMemo(() => {
    const byProperty = {};
    (brands || []).forEach((b) => {
      if (pbIsHeldOrClosed(b)) return;
      const a = (approvals || {})[b.id];
      if (!a) return;
      const specific = pbConfirmed(b).filter((p) => p !== 'generic');
      // Already approved somewhere — the brand is through, nothing to chase.
      if (specific.some((p) => (a.approved || []).includes(p))) return;
      specific.forEach((pid) => {
        const waiting = (a.pending || []).includes(pid) || (a.discuss || []).includes(pid);
        if (!waiting) return;
        const row = byProperty[pid] || (byProperty[pid] = { propertyId: pid, brands: [], oldest: null });
        row.brands.push(b);
        if (a.askedAt && (!row.oldest || a.askedAt < row.oldest)) row.oldest = a.askedAt;
      });
    });

    return Object.values(byProperty)
      .map((r) => {
        const prop = PropertyById(r.propertyId) || {};
        const days = r.oldest ? Math.floor((Date.now() - new Date(r.oldest).getTime()) / 86400000) : null;
        return { ...r, prop, days, snoozedUntil: prop.approvalSnoozedUntil || null, chasedAt: prop.approvalChasedAt || null };
      })
      // A snooze hides the row until its date; an un-snoozed row with no ask on
      // record cannot be aged, so it sorts last rather than pretending to be new.
      .filter((r) => !(r.snoozedUntil && r.snoozedUntil > todayISO))
      .sort((a, b) => (b.days ?? -1) - (a.days ?? -1));
  }, [brands, approvals, todayISO]);

  if (!rows.length) return null;
  const totalBrands = rows.reduce((n, r) => n + r.brands.length, 0);
  const anyLate = rows.some((r) => r.days !== null && r.days >= APPROVAL_CHASE_DAYS);

  const patchProperty = async (propertyId, patch, message) => {
    setBusy(propertyId);
    try {
      await window.IPSEM_API.updateProperty(propertyId, patch);
      if (onReloadProperties) await onReloadProperties();
      onToast && onToast(message);
    } catch (e) {
      console.error('approval chase update failed:', e);
      onToast && onToast(e.message || 'Could not save that — the chase columns may need MIGRATION_approval_chasing.sql');
    } finally {
      setBusy('');
    }
  };

  return (
    <Card lg style={{ marginBottom: 22, borderColor: anyLate ? 'var(--attention)' : 'var(--hairline)' }}>
      <div className="row" style={{ justifyContent: 'space-between', marginBottom: 12, gap: 10, flexWrap: 'wrap' }}>
        <div className="row gap-2">
          <window.I.trophy size={14} stroke={anyLate ? 'var(--attention)' : 'var(--ink-3)'} />
          <span className="eyebrow">
            Awaiting rightsholder · {totalBrands} brand{totalBrands === 1 ? '' : 's'} across {rows.length} propert{rows.length === 1 ? 'y' : 'ies'}
          </span>
        </div>
        <span className="text-xs text-3 hide-mobile">Chased after {APPROVAL_CHASE_DAYS} days with no answer.</span>
      </div>

      <div className="col gap-2">
        {rows.map((r) => {
          const late = r.days !== null && r.days >= APPROVAL_CHASE_DAYS;
          return (
            <div key={r.propertyId} className="row gap-3" style={{ padding: '10px 12px', background: 'var(--card-alt)', borderRadius: 8, alignItems: 'center', flexWrap: 'wrap' }}>
              <div className="grow" style={{ minWidth: 180 }}>
                <div className="row gap-2" style={{ alignItems: 'center', flexWrap: 'wrap' }}>
                  <span style={{ fontWeight: 600, fontSize: 13.5 }}>{r.prop.name || r.propertyId}</span>
                  <Pill kind={late ? 'attention' : 'pending'} style={{ fontSize: 10 }}>
                    {r.days === null ? 'no ask on record' : r.days === 0 ? 'asked today' : `${r.days}d waiting`}
                  </Pill>
                  <span className="text-xs text-3">
                    {r.brands.length} brand{r.brands.length === 1 ? '' : 's'}
                  </span>
                </div>
                <div className="text-xs text-3 truncate" style={{ marginTop: 2 }}>
                  {r.brands.slice(0, 4).map((b) => b.brand).join(', ')}
                  {r.brands.length > 4 ? ` +${r.brands.length - 4} more` : ''}
                  {r.chasedAt ? ` · last chased ${formatDate(r.chasedAt)}` : ''}
                </div>
              </div>
              <div className="row gap-1">
                <button className="btn btn-sm" onClick={() => onNavigate('properties')} title="Open this property's Brands assigned list">
                  Open
                  <window.I.arrowRight size={12} />
                </button>
                <button
                  className="btn btn-sm btn-ghost"
                  disabled={busy === r.propertyId}
                  onClick={() => patchProperty(r.propertyId, { approvalChasedAt: new Date().toISOString() },
                    `Chase logged for ${r.prop.name || r.propertyId}`)}
                  title="Record that you have chased them. Does not reset the waiting time — that still counts from the original ask."
                >
                  <window.I.check size={12} /> Chased
                </button>
                <button
                  className="btn btn-sm btn-ghost"
                  disabled={busy === r.propertyId}
                  onClick={() => patchProperty(r.propertyId, { approvalSnoozedUntil: addBusinessDays(todayISO, 5) },
                    `Snoozed ${r.prop.name || r.propertyId} for a week`)}
                  title="Hide for 5 working days"
                >
                  Snooze
                </button>
              </div>
            </div>
          );
        })}
      </div>
    </Card>
  );
}

function QueuePanel({ brands, onNavigate, onOpenBrand, currentUser, onUpdateBrand, onToast }) {
  const [showAll, setShowAll] = React.useState(false);
  const queue = React.useMemo(() => buildQueue(brands), [brands]);
  const shown = showAll ? queue.slice(0, 40) : queue.slice(0, 8);

  if (queue.length === 0) {
    return (
      <Card lg style={{ marginBottom: 22, borderColor: 'var(--positive)' }}>
        <div className="row gap-2" style={{ alignItems: 'center' }}>
          <window.I.check size={16} stroke="var(--positive)" />
          <span style={{ fontWeight: 600 }}>Queue clear.</span>
          <span className="text-2 text-sm">
            Nothing in the flow is waiting on a person. Run a scan to pull in new brands.
          </span>
        </div>
      </Card>
    );
  }

  // How the queue splits by stage — tells you where the work actually is.
  const byStage = {};
  queue.forEach((q) => { byStage[q.stage] = (byStage[q.stage] || 0) + 1; });
  const stageLabel = (key) => {
    const s = PB_STAGES.find((x) => x.key === key);
    return s ? s.label : key;
  };

  return (
    <Card lg style={{ marginBottom: 22, borderColor: 'var(--accent)' }}>
      <div className="row" style={{ justifyContent: 'space-between', marginBottom: 12, flexWrap: 'wrap', gap: 10 }}>
        <div className="row gap-2">
          <window.I.bolt size={15} stroke="var(--accent)" />
          <span className="eyebrow">Your queue · {queue.length} waiting on you</span>
        </div>
        <button className="btn btn-sm btn-primary" onClick={() => onNavigate(queue[0].blocker.route, queue[0].brand.id)}>
          Start with {queue[0].brand.brand}
          <window.I.arrowRight size={12} />
        </button>
      </div>

      <div className="row gap-1" style={{ flexWrap: 'wrap', marginBottom: 14 }}>
        {Object.keys(byStage).map((k) => (
          <Pill key={k} kind="muted" style={{ fontSize: 10.5 }}>
            {stageLabel(k)} · {byStage[k]}
          </Pill>
        ))}
      </div>

      <div className="col gap-2">
        {shown.map(({ brand, blocker, review }) => (
          <div
            key={brand.id}
            className="row gap-3"
            style={{ padding: '10px 12px', background: 'var(--card-alt)', borderRadius: 8, alignItems: 'center', flexWrap: 'wrap' }}
          >
            <BrandLogo brand={brand} size={26} />
            <div className="grow" style={{ minWidth: 0 }}>
              <div className="row gap-2" style={{ alignItems: 'center', flexWrap: 'wrap', cursor: 'pointer' }} onClick={() => onOpenBrand(brand.id)}>
                <span className="truncate" style={{ fontWeight: 600, fontSize: 13.5 }}>{brand.brand}</span>
                <PriorityPill priority={brand.priority} />
                <BrandStatusChip brand={brand} />
              </div>
              <div className="text-xs text-3 truncate" style={{ marginTop: 1 }}>
                {displayAssignee(brand.assignee) ? `${displayAssignee(brand.assignee)} · ` : ''}{brand.category || ''}
              </div>
              {/* Why it is on the list, for the ones nobody has signed off. The
                  removed "Brands to review" card was the only place this said
                  anything, and it is the sentence the weekly meeting runs on. */}
              {review ? (
                <div className="text-2 text-xs" style={{ marginTop: 4, lineHeight: 1.5 }}>{reviewReason(brand)}</div>
              ) : null}
              {review && brand.manualReviewNote ? (
                <div className="text-3 text-xs" style={{ fontStyle: 'italic', marginTop: 2 }}>{brand.manualReviewNote}</div>
              ) : null}
            </div>
            <div className="row gap-1" style={{ flexWrap: 'wrap' }}>
              <button className="btn btn-sm" onClick={() => onNavigate(blocker.route, brand.id)}>
                Deal with it
                <window.I.arrowRight size={12} />
              </button>
              {/* Ticking a brand off without leaving the page was the other thing
                  only the review card could do. */}
              {review ? (
                <MarkReviewedControl
                  brand={brand}
                  currentUser={currentUser}
                  onUpdateBrand={onUpdateBrand}
                  onToast={onToast}
                />
              ) : null}
            </div>
          </div>
        ))}
      </div>

      {queue.length > shown.length ? (
        <button className="btn btn-sm btn-ghost" style={{ marginTop: 12 }} onClick={() => setShowAll(true)}>
          Show {Math.min(32, queue.length - shown.length)} more
        </button>
      ) : null}
    </Card>
  );
}

function StatTile({ value, label, tone, icon, onClick }) {
  const colors = {
    ink: 'var(--ink)', accent: 'var(--accent)', positive: 'var(--positive)',
    pending: 'var(--pending)', blue: 'var(--sig-blue-fg)',
  };
  const Ico = icon && window.I[icon];
  return (
    <div
      className="card card-pad"
      style={{ cursor: onClick ? 'pointer' : 'default', transition: 'border-color 120ms ease' }}
      onClick={onClick}
      onMouseEnter={(e) => (e.currentTarget.style.borderColor = 'var(--hairline-strong)')}
      onMouseLeave={(e) => (e.currentTarget.style.borderColor = '')}
    >
      <div className="row" style={{ justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 6 }}>
        <span className="eyebrow" style={{ fontSize: 10.5 }}>{label}</span>
        {Ico ? <Ico size={15} stroke={colors[tone] || 'var(--ink-3)'} /> : null}
      </div>
      <div className="mono" style={{ fontSize: 30, fontWeight: 500, color: colors[tone] || 'var(--ink)', lineHeight: 1, fontFamily: 'var(--f-mono)' }}>
        {value}
      </div>
    </div>
  );
}

function ChartHead({ title, sub }) {
  return (
    <div style={{ marginBottom: 16 }}>
      <div className="h3" style={{ fontSize: 16 }}>{title}</div>
      {sub ? <div className="text-3 text-xs" style={{ marginTop: 3 }}>{sub}</div> : null}
    </div>
  );
}

function Empty({ msg }) {
  return <div className="text-3 text-sm" style={{ padding: '24px 4px' }}>{msg || 'No data yet.'}</div>;
}

// Horizontal bar list
function HBars({ items }) {
  const max = Math.max(...items.map((i) => i.value), 1);
  return (
    <div className="col gap-2">
      {items.map((it) => (
        <div key={it.key} className="row gap-3" style={{ alignItems: 'center' }} title={`${it.label}: ${it.value}`}>
          <span className="text-sm text-2 truncate" style={{ width: 150, flexShrink: 0 }}>{it.label}</span>
          <span style={{ flex: 1, height: 18, background: 'var(--card-alt)', borderRadius: 5, overflow: 'hidden' }}>
            <span style={{ display: 'block', height: '100%', width: `${(it.value / max) * 100}%`, background: it.color, borderRadius: 5, opacity: 0.85, transition: 'width 400ms ease' }} />
          </span>
          <span className="mono text-sm" style={{ width: 28, textAlign: 'right', fontWeight: 600 }}>{it.value}</span>
        </div>
      ))}
    </div>
  );
}

// SVG donut chart with legend
function Donut({ data, total, centerLabel, onSegmentClick }) {
  const size = 150, stroke = 22;
  const r = (size - stroke) / 2;
  const C = 2 * Math.PI * r;
  const sum = data.reduce((a, d) => a + d.value, 0) || 1;
  let offset = 0;

  return (
    <div className="row gap-4" style={{ alignItems: 'center' }}>
      <span style={{ position: 'relative', width: size, height: size, flexShrink: 0 }}>
        <svg width={size} height={size} role="img"
             aria-label={`Donut chart. ${data.map((d) => `${d.label}: ${d.value} (${Math.round((d.value / sum) * 100)}%)`).join('; ')}.`}>
          <g transform={`rotate(-90 ${size / 2} ${size / 2})`}>
            <circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--card-alt)" strokeWidth={stroke} />
            {data.map((d) => {
              const len = (d.value / sum) * C;
              const pct = Math.round((d.value / sum) * 100);
              const seg = (
                <circle
                  key={d.key}
                  cx={size / 2} cy={size / 2} r={r}
                  fill="none" stroke={d.color} strokeWidth={stroke}
                  strokeDasharray={`${len} ${C - len}`}
                  strokeDashoffset={-offset}
                  style={{ cursor: onSegmentClick ? 'pointer' : 'default', transition: 'stroke-dasharray 400ms ease' }}
                  onClick={onSegmentClick}
                >
                  <title>{`${d.label}: ${d.value} (${pct}%)`}</title>
                </circle>
              );
              offset += len;
              return seg;
            })}
          </g>
        </svg>
        <span style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
          <span className="mono" style={{ fontSize: 26, fontWeight: 500, lineHeight: 1 }}>{total}</span>
          {centerLabel ? <span className="text-3" style={{ fontSize: 10.5, marginTop: 2 }}>{centerLabel}</span> : null}
        </span>
      </span>
      <div className="col gap-2 grow" style={{ minWidth: 0 }}>
        {data.map((d) => (
          <div key={d.key} className="row gap-2" style={{ alignItems: 'center' }}>
            <span style={{ width: 10, height: 10, borderRadius: 3, background: d.color, flexShrink: 0 }} />
            <span className="text-sm text-2 grow truncate" title={d.label}>{d.label}</span>
            <span className="mono text-sm" style={{ fontWeight: 600 }}>{d.value}</span>
            <span className="text-3 text-xs mono" style={{ width: 34, textAlign: 'right' }}>{Math.round((d.value / sum) * 100)}%</span>
          </div>
        ))}
      </div>
    </div>
  );
}

// SVG line chart — weekly brand-review counts.
function TrendLine({ points }) {
  const W = 520, H = 180, PAD_L = 34, PAD_R = 12, PAD_T = 14, PAD_B = 30;
  const iw = W - PAD_L - PAD_R, ih = H - PAD_T - PAD_B;
  const max = Math.max(...points.map((p) => p.value), 1);
  const x = (i) => PAD_L + (points.length === 1 ? iw / 2 : (i / (points.length - 1)) * iw);
  const y = (v) => PAD_T + ih - (v / max) * ih;
  const path = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(p.value).toFixed(1)}`).join(' ');
  const area = `${path} L${x(points.length - 1).toFixed(1)},${(PAD_T + ih).toFixed(1)} L${x(0).toFixed(1)},${(PAD_T + ih).toFixed(1)} Z`;
  // Sparse x labels: ~6 across the range.
  const step = Math.max(1, Math.ceil(points.length / 6));
  // Calendar dates — pinned to UTC so the label never slips a day by timezone.
  const fmt = (iso) => new Intl.DateTimeFormat('en-GB', { timeZone: 'UTC', day: 'numeric', month: 'short' })
    .format(new Date(iso + 'T00:00:00Z'));
  const gridY = [0.25, 0.5, 0.75, 1].map((f) => Math.round(max * f));

  return (
    <div style={{ overflowX: 'auto' }}>
      <svg viewBox={`0 0 ${W} ${H}`} style={{ width: '100%', height: 'auto', display: 'block' }} role="img"
           aria-label={`Line chart of brands reviewed per week, peaking at ${max}.`}>
        {gridY.map((v) => (
          <g key={v}>
            <line x1={PAD_L} x2={W - PAD_R} y1={y(v)} y2={y(v)} stroke="var(--hairline)" strokeWidth="1" />
            <text x={PAD_L - 6} y={y(v) + 3.5} fontSize="9.5" fill="var(--ink-3)" textAnchor="end">{v}</text>
          </g>
        ))}
        <path d={area} fill="var(--accent)" opacity="0.08" />
        <path d={path} fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
        {points.map((p, i) => (
          <circle key={p.week} cx={x(i)} cy={y(p.value)} r="3" fill="var(--accent)">
            <title>{`Week of ${fmt(p.week)}: ${p.value} brand${p.value === 1 ? '' : 's'}`}</title>
          </circle>
        ))}
        {points.map((p, i) => (i % step === 0 || i === points.length - 1 ? (
          <text key={`l${p.week}`} x={x(i)} y={H - 10} fontSize="9.5" fill="var(--ink-3)" textAnchor="middle">{fmt(p.week)}</text>
        ) : null))}
      </svg>
    </div>
  );
}

function formatScanRun(run) {
  if (!run) return 'No scans recorded yet.';
  const status = run.status || 'unknown';
  if (status === 'running') return 'Scan in progress…';
  const when = run.runDate ? timeAgo(new Date(run.runDate)) : 'unknown';
  if (status === 'error') return `Last scan: ${when} · failed`;
  const articles = run.brandsFound ?? 0;
  const added = run.brandsClassified ?? 0;
  return `Last scan: ${when} · ${articles} article${articles === 1 ? '' : 's'} · ${added} added`;
}

function timeAgo(d) {
  const diff = Math.max(0, Date.now() - d.getTime());
  const mins = Math.round(diff / 60000);
  if (mins < 1) return 'just now';
  if (mins < 60) return `${mins} min${mins === 1 ? '' : 's'} ago`;
  const hrs = Math.round(mins / 60);
  if (hrs < 24) return `${hrs} hour${hrs === 1 ? '' : 's'} ago`;
  const days = Math.round(hrs / 24);
  return `${days} day${days === 1 ? '' : 's'} ago`;
}

window.Dashboard = Dashboard;
