import { useState, useEffect } from "react"
import {
RadarChart, Radar, PolarGrid, PolarAngleAxis, PolarRadiusAxis,
LineChart, Line, XAxis, YAxis, Tooltip,
ResponsiveContainer, CartesianGrid,
} from "recharts"
import api from "../lib/api"
import Reveal, { RevealItem } from "./Reveal"
import { SIGNAL_COLORS } from "../lib/dimensionColors"
const G = "linear-gradient(135deg, #1d4ed8 0%, #0891b2 100%)"
const CONTEXT_LABELS = {
social: "Casual & Low-Stakes", collaborative: "Collaborative",
evaluative: "Interview & Review · High Stakes", influential: "Persuading & Pitching",
negotiation: "Negotiation", adversarial: "Conflict & Friction",
developmental: "Coaching & Feedback", support: "Supportive Listening",
intimate: "Deep Personal", casual: "Casual & Low-Stakes", meeting: "Meeting",
job_interview: "Interview & Review · High Stakes", disagreement: "Conflict & Friction",
presentation: "Interview & Review · High Stakes", sales_call: "Persuading & Pitching",
feedback_conversation: "Coaching & Feedback", coaching_call: "Coaching & Feedback",
first_date: "Deep Personal",
}
// Mirrors backend/pipeline/portrait_synthesizer.py's _SIGNAL_FORMAT exactly —
// kept as a literal mirror (same scale/fmt/unit per key) rather than a fresh
// guess, since two independently hand-maintained copies drifting apart is
// exactly what caused this map to go stale after the 9→15 dimension rebuild.
// pacing_arc/energy_arc intentionally absent — categorical, see formatMean.
const SIGNAL_FORMAT = {
talk_ratio: { scale: 100, digits: 0, unit: "% of speaking time" },
curiosity: { scale: 1, digits: 2, unit: " question-turns per 100 words" },
turn_taking_assertiveness: { scale: 1, digits: 1, unit: " interruptions per 10 speaker changes" },
conversational_drive: { scale: 100, digits: 0, unit: "% drive score" },
hedging: { scale: 1, digits: 1, unit: " hedging phrases per 100 words" },
directness: { scale: 1, digits: 1, unit: " direct/assertive phrases per 100 words" },
building_on_others: { scale: 100, digits: 0, unit: "% of your turns build on someone else's point" },
pace: { scale: 1, digits: 0, unit: " words per minute" },
vocal_expressiveness: { scale: 1, digits: 1, unit: " Hz of pitch variation" },
turn_length: { scale: 1, digits: 1, unit: "s per turn on average" },
vocabulary_richness: { scale: 100, digits: 0, unit: "% unique words in a typical stretch of speech" },
fillers: { scale: 1, digits: 2, unit: " filler words per 100 words" },
response_latency: { scale: 1, digits: 1, unit: "s before responding" },
}
const CATEGORICAL_SIGNAL_KEYS = new Set(["pacing_arc", "energy_arc"])
function formatMean(signalKey, mean, modeLabel) {
if (CATEGORICAL_SIGNAL_KEYS.has(signalKey)) {
return modeLabel ? `consistently ${modeLabel}` : ""
}
const f = SIGNAL_FORMAT[signalKey]
if (!f || mean == null) return ""
return `${(mean * f.scale).toFixed(f.digits)}${f.unit}`
}
// Practical chart-scaling ranges only — NOT a comparative score. Each signal's
// natural range is mapped to 0-100 purely so the spectrum chart has something
// sensible to plot; nothing here is shown to the user as a number. Categorical
// signals (pacing_arc, energy_arc) use CATEGORICAL_SPECTRUM_POSITIONS instead.
const SPECTRUM_RANGES = {
talk_ratio: [0, 1],
curiosity: [0, 10],
turn_taking_assertiveness: [0, 5],
conversational_drive: [0, 1],
hedging: [0, 10],
directness: [0, 10],
building_on_others: [0, 1],
pace: [80, 220],
vocal_expressiveness: [10, 80],
turn_length: [3, 45],
vocabulary_richness: [0.3, 0.8],
fillers: [0, 8],
response_latency: [0, 5],
}
// Fixed positions for categorical dimensions — same "chart-scaling only, not a
// comparative score" spirit as SPECTRUM_RANGES, just for a mode label instead
// of a numeric mean. "stable" sits at the visual center, the two directional
// extremes split toward either side.
const CATEGORICAL_SPECTRUM_POSITIONS = {
pacing_arc: { decelerating: 25, stable: 50, accelerating: 75 },
energy_arc: { decreasing: 25, stable: 50, increasing: 75 },
}
function spectrumPosition(signalKey, mean, modeLabel) {
if (CATEGORICAL_SIGNAL_KEYS.has(signalKey)) {
const positions = CATEGORICAL_SPECTRUM_POSITIONS[signalKey]
return positions?.[modeLabel] ?? 0
}
const [lo, hi] = SPECTRUM_RANGES[signalKey] || [0, 1]
if (mean == null) return 0
const pct = ((mean - lo) / (hi - lo)) * 100
return Math.max(4, Math.min(100, pct))
}
// Curated to 7 — deliberately not all 15 tracked dimensions ("we can just
// best 5-7 trends which are really worthy to earn that place"). Selection
// follows CLAUDE.md's own priority order (relational lead, one delivery
// slot) — pacing_arc/energy_arc excluded structurally (categorical, can't
// plot on a line chart); building_on_others/vocabulary_richness/
// vocal_expressiveness/turn_length/response_latency/fillers excluded per
// weaknesses already on record elsewhere in this project (weakest proxy,
// framing risk, audio noise, noisier delivery signals, "never the headline").
const SIGNAL_OPTIONS = [
{ key: "talk_ratio", label: "Talk-share", unit: "%" },
{ key: "curiosity_rate", label: "Curiosity", unit: "/100w" },
{ key: "hedging_rate", label: "Hedging", unit: "/100w" },
{ key: "directness_rate", label: "Directness", unit: "/100w" },
{ key: "drive_score", label: "Conversational drive", unit: "%" },
{ key: "wpm", label: "Pace", unit: "wpm" },
{ key: "turn_taking_rate", label: "Turn-taking assertiveness", unit: "/10" },
]
const FRAMING_CONFIG = {
strength: { color: "#34d399", label: "Strength" },
growth_area: { color: "#fb923c", label: "Growth area" },
observation: { color: "#818cf8", label: "Observation" },
}
// ── Section label ─────────────────────────────────────────────────
function SectionLabel({ children }) {
return (
{children}
)
}
// ── Spectrum chart ─────────────────────────────────────────────────
// Real measured signals as axes (never invented trait names), self-relative
// chart-scaling (never a population comparison), no number printed anywhere —
// CLAUDE.md's "fingerprint spectrums", not a graded score. Only steady signals
// get a plotted point; not-yet-steady axes are omitted rather than faked.
// Custom PolarAngleAxis tick — tints each axis label with that signal's
// identity color from SIGNAL_COLORS, ties Shape into the same color language
// as Reflected Back / Context-Shift cards without a meaningless multi-color
// fill region (a radar's fill only makes sense as one series).
function ColoredAxisTick({ x, y, payload, colorBySubject }) {
const color = colorBySubject[payload.value] || "#8b89aa"
return (
{payload.value}
)
}
function SpectrumChart({ steady }) {
if (!steady?.length) return null
const radarData = steady.map(s => ({
subject: s.label,
value: spectrumPosition(s.signal_key, s.mean, s.mode_label),
recent: s.recent_mean != null
? spectrumPosition(s.signal_key, s.recent_mean, s.mode_label)
: null,
}))
const colorBySubject = Object.fromEntries(
steady.map(s => [s.label, SIGNAL_COLORS[s.signal_key] || "#8b89aa"])
)
return (
} />
{/* niceTicks="none" is load-bearing, not decorative: Recharts 3.x's
default niceTicks="auto" can silently EXTEND even a fixed [0,100]
domain (combineAxisDomainWithNiceTicks takes Math.max(domainMax,
lastNiceTickValue)) — without this, a value of exactly 100 was
rendering at ~55% of the outer radius instead of 100%, discovered
via a Playwright pixel-position check, not visible from the code
alone. Confirmed via recharts/es6/state/selectors/axisSelectors.js. */}
{/* type="number" / niceTicks="none" / allowDataOverflow / explicit
radiusAxisId matching are all real, best-practice hardening for
a fixed 0-100 domain (verified: Recharts' default niceTicks
can silently expand a fixed domain — see combineAxisDomainWithNiceTicks
in recharts/es6/state/selectors/axisSelectors.js). Flagging
honestly: even with all of these, a Playwright pixel-measurement
check found the *absolute* size of the plotted polygon is still
compressed relative to the grid's outer ring in this Recharts
version (3.8.1) — confirmed via tick-label ground truth, not
speculation. The distortion is uniform across all axes in a
single render (verified: every axis in a 4-signal test showed
the identical ratio), so relative shape/proportions — the
actual information this chart carries, per its own "no numbers,
just shape" design — are NOT corrupted, only the polygon's
absolute size within the frame looks smaller than intended.
Root cause not fully isolated after substantial investigation;
worth a follow-up pass, not blocking this build. */}
)
}
// ── Portrait paragraph + tags ────────────────────────────────────────
// Narrative connective tissue between several established signals, plus a
// handful of short behavior-phrase chips — deliberately kept even though it
// overlaps with the itemized cards below, for the visual/skimmable value.
// Absent entirely (not a placeholder) until >=1 signal is steady.
function PortraitParagraph({ text, tags }) {
if (!text) return null
return (
"{text}"
{tags?.length > 0 && (
{tags.map((tag, i) => (
{tag}
))}
)}
)
}
// ── Global indicators strip ──────────────────────────────────────────
// Plain observational facts about the user's own history (not evidence-gated
// patterns) — visible from session 1. Option B styling: stat tiles with a
// colored left-border accent, reusing HomeView's existing card language.
function formatDuration(totalSeconds) {
if (!totalSeconds) return "0m"
const h = Math.floor(totalSeconds / 3600)
const m = Math.round((totalSeconds % 3600) / 60)
return h > 0 ? `${h}h ${m}m` : `${m}m`
}
function StatTile({ value, label, accent }) {
return (
)
}
// ── Where you might grow — coaching synthesis ────────────────────────
// Distilled recurring themes from coaching_suggestions, grouped by
// dimension_key server-side — a concrete-instance complement to Reflected
// Back's aggregate framing, not a restatement of it.
function CoachingCard({ item, i }) {
const dotColor = SIGNAL_COLORS[item.dimension_key] || "#8b89aa"
return (
{item.pattern && (
{item.pattern}
)}
{item.suggestion && (
Try:
{item.suggestion}
)}
Came up in {item.recurrence_count} of your recent sessions
{[
"Patterns steady enough to call your own — hedging, directness, how you build on others",
"How you shift across different kinds of calls",
"Growth areas, framed as possibility, not a grade",
].map(line => (
{/* Header — confidence-only now: how much the app knows about you.
Facts about you (session count, etc.) live in the indicators
strip below instead. No page title here — the "You" nav tab
already says which page this is. */}
{/* Portrait paragraph + tags — absent until >=1 signal is steady */}
{portrait_paragraph && (
Standing Portrait
Your Portrait
)}
{/* Global indicators strip — plain facts, visible from session 1 */}
{/* Spectrum — fingerprint shape across your established signals */}
{steady.length > 0 && (
Your Spectrum
Your Shape
Each axis is a real, measured signal — positioned relative to your own history, never compared to anyone else
)}
{/* Established patterns */}
{steady.length > 0 && (
Established
Reflected Back
{steady.map((item, i) => (
))}
)}
{/* How you shift by context */}
{how_you_shift_by_context?.length > 0 && (
Context Breakdown
How You Shift By Context
{how_you_shift_by_context.map((item, i) => (
))}
)}
{/* Where you might grow — coaching synthesis, deliberately after the
descriptive sections above rather than near the top: observe first,
reflect on growth after. Absent until >=3 sessions and >=1 recurring
theme — not a repeat of a single session's own coaching card. */}
{coaching?.length > 0 && (
Growth
Where You Might Grow
{coaching.map((item, i) => (
))}
)}
{/* Still forming */}
{stillForming.length > 0 && (
Building Evidence
Still Forming
Not enough evidence yet to describe these as established — that's expected early on
{stillForming.map(item => (
))}
)}
{/* Signal History — collapsible, raw data */}
{chartData.length >= 2 && (
)}