/** * PlatformOverviewCard — macro view across ALL users in the window. * * Layout: * ┌─ PLATFORM OVERVIEW · last 30 days ──────────────────────────┐ * │ [7 users] [261 turns] [21 topics] [16 strategies] │ * │ │ * │ ┌──── Top topics ────┐ ┌──── Top strategies ──────┐ │ * │ │ roth_ira ████ 3 │ │ decision_card 0.83 ▓▓▓ │ │ * │ │ compound ███ 2 │ │ comparison_t. 0.73 ▓▓ │ │ * │ └────────────────────┘ └─────────────────────────┘ │ * │ │ * │ ┌─ Decision-stage funnel ─┐ ┌─ Readiness ─┐ │ * │ │ Action-ready ▓▓ 2 │ │ Ready 2 │ │ * │ │ Evaluation ▓ 1 │ │ Likely 1 │ │ * │ │ Exploration ▓▓ 2 │ │ Nurture 2 │ │ * │ │ Awareness ▓▓ 2 │ │ Too early 2 │ │ * │ └─────────────────────────┘ └──────────────┘ │ * │ │ * │ Intent mix: Comparison 27% · Decision 21% · … │ * └─────────────────────────────────────────────────────────────┘ * * No per-user identifiers — only aggregates. Pairs with the single-user * Inspect view below. */ import InfoHint from "./InfoHint.jsx"; import MiniLineChart from "./MiniLineChart.jsx"; export default function PlatformOverviewCard({ data, windowLabel = "", platformDailySeries = null, // [{date, total_turns, unique_users}] topicsDailySeries = null, // [{topic, series: [{date, count}]}] }) { if (!data) return null; const maxTopicUsers = Math.max(...(data.by_topic || []).map((t) => t.total_users), 1); const maxStratPulls = Math.max(...(data.by_strategy || []).map((s) => s.total_pulls), 1); const maxStageCount = Math.max(...(data.stage_funnel || []).map((s) => s.count), 1); const maxReadyCount = Math.max(...(data.readiness_funnel || []).map((r) => r.count), 1); return (
Platform overview

All users {windowLabel}

{/* Activity trend chart — daily turns across the window */} {platformDailySeries && platformDailySeries.length > 0 && (
Activity trend Total turns per day across all users in this window. Hover any point to see the exact count. Rising = engagement growing; falling = users dropping off. This is the simplest gut-check for product health. {sum(platformDailySeries.map((d) => d.total_turns))} turns total
({ date: d.date, value: d.total_turns }))} width={640} height={88} color="#d76a35" yLabel="turns/day" formatValue={(v) => `${v} turn${v === 1 ? "" : "s"}`} />
)} {/* Two-column: top topics + top strategies */}
Top topics by user reach Ranked by unique users then sum of interest_score. The bar shows user reach; the sub-text shows avg interest and turn volume. "1 user" cells are weaker evidence than "3 users" cells with the same score.
    {(data.by_topic || []).map((t) => (
  1. {t.topic} {t.total_users} users
    avg interest {fmt(t.avg_interest_score)} · {t.turns_in_window} turns · μ-reward {fmt(t.avg_reward)}
  2. ))} {(data.by_topic || []).length === 0 && (
  3. No topic data in window.
  4. )}
{/* Per-topic trend chart — one line per top-N topic */} {topicsDailySeries && topicsDailySeries.length > 0 && (
Topic trends Daily turns for the top {topicsDailySeries.length} topics over this window. Hover to compare same-day counts. Use this to spot topics heating up (rising line) vs cooling off (falling line) — instruction or outreach focus often follows.
({ name: t.topic, points: (t.series || []).map((p) => ({ date: p.date, value: p.count })), }))} width={640} height={120} showLegend formatValue={(v) => `${v} turn${v === 1 ? "" : "s"}`} />
)}
Top strategies (popularity × reward) Strategies ranked by avg_reward × log(pulls + 1) — rewards that have been validated by volume. The big number is μ-reward (average normalized_reward in [-1, +1]); a strategy with μ=0.83 over 40 pulls is more trustworthy than one with μ=0.95 over 2.
    {(data.by_strategy || []).map((s) => (
  1. {prettyName(s.strategy)} {fmt(s.avg_reward)} μ
    {s.total_pulls} pulls · {s.unique_users} users · {s.unique_cells} cells
  2. ))} {(data.by_strategy || []).length === 0 && (
  3. No strategies pulled yet.
  4. )}
{/* Stage + readiness funnels */}
Decision-stage funnel Where users are in their buying journey, inferred from each user's recent intents. Awareness (Definitional-heavy) → Exploration (mixed) → Evaluation (Comparison-heavy) → Action-ready (Decision-heavy). Support-needed users are stuck and may need human help.
    {STAGE_ORDER.map((stage) => { const row = (data.stage_funnel || []).find((r) => r.stage === stage); const count = row?.count || 0; return (
  • {stage} {count}
  • ); })}
Outreach-readiness distribution How many users land in each readiness tier. Ready ≥ 0.70 — surface for outreach. Likely ≥ 0.50 — strong candidates, nurture lightly. Nurture ≥ 0.30 — keep engaged but don't push. Too early < 0.30 — leave them alone. The stage gate means an "Awareness" user can't be Ready regardless of engagement.
    {READINESS_ORDER.map((tier) => { const row = (data.readiness_funnel || []).find((r) => r.tier === tier); const count = row?.count || 0; return (
  • {tier} {count}
  • ); })}
{/* Intent + signal mix */}
Intent mix Percentage of all turns in this window by intent. A balanced spread is healthy; a heavy Definitional skew means the user base is still learning. A heavy Comparison/Decision skew means they're at the action end.
{(data.intent_mix || []).map((i) => ( {i.intent} {i.pct}% ))} {(data.intent_mix || []).length === 0 && No intents recorded.}
Signal mix (across all firings) How often each signal fired across all rewarded turns. The catalog is the 9-signal set from the reward doc: thumbs (UI) plus six LLM-detected text signals.

Green = positive (thumbs_up, format_praise_explicit, it_worked_statement, deeper_question). Red = negative (thumbs_down, format_change_request, content_correction, reask_same_question). A healthy mix is mostly green with rare explicit complaints.
{(data.signal_mix || []).map((s) => ( {s.signal.replace(/_/g, " ")} {s.pct}% ))} {(data.signal_mix || []).length === 0 && No signals recorded.}
); } // ---------- Helpers ---------- const STAGE_ORDER = ["Awareness", "Exploration", "Evaluation", "Action-ready", "Support-needed", "Unknown"]; const READINESS_ORDER = ["Ready", "Likely", "Nurture", "Too early"]; function signalToneClass(name) { if (!name) return "neutral"; // Legacy composite patterns may exist in old data — keep them distinct if (name.startsWith("pattern_")) return "composite"; // Positive — current catalog first, legacy names kept for old records if (/^(thumbs_up|format_praise_explicit|it_worked_statement|deeper_question|copy_save|format_keep_request|format_compliance_pass|session_continue)$/.test(name)) { return "pos"; } // Negative — current catalog first, legacy names kept for old records if (/^(thumbs_down|format_change_request|content_correction|reask_same_question|regenerate_click|session_abandon|format_compliance_fail)$/.test(name)) { return "neg"; } return "neutral"; } function PlatformNumber({ value, label }) { return (
{value ?? "—"}
{label}
); } function fmt(v) { if (v == null) return "—"; const x = Number(v); if (!Number.isFinite(x)) return "—"; return x.toFixed(2); } function prettyName(s) { if (!s) return "—"; return String(s).replace(/_/g, " "); } function sum(arr) { let s = 0; for (const x of arr) s += Number(x) || 0; return s; }