/**
* 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) => (
-
{t.topic}
{t.total_users} users
avg interest {fmt(t.avg_interest_score)} · {t.turns_in_window} turns · μ-reward {fmt(t.avg_reward)}
))}
{(data.by_topic || []).length === 0 && (
- No topic data in window.
)}
{/* 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) => (
-
{prettyName(s.strategy)}
{fmt(s.avg_reward)} μ
{s.total_pulls} pulls · {s.unique_users} users · {s.unique_cells} cells
))}
{(data.by_strategy || []).length === 0 && (
- No strategies pulled yet.
)}
{/* 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 (
);
}
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;
}