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. */}
) } // ── Steady signals — established patterns ────────────────────────── function SteadySignalCard({ item, i }) { const cfg = FRAMING_CONFIG[item.framing] || FRAMING_CONFIG.observation const dotColor = SIGNAL_COLORS[item.signal_key] || "#8b89aa" const evidence = formatMean(item.signal_key, item.mean, item.mode_label) return (
{item.label} {cfg.label}
{item.note && (

{item.note}

)} {evidence && (

Evidence: {evidence} · based on {item.sample_count} sessions

)}
) } // ── Still forming — not enough evidence yet ──────────────────────── function StillFormingRow({ item }) { const pct = Math.min(100, Math.round((item.sample_count / item.min_needed) * 100)) return (
{item.label}
{item.sample_count} of {item.min_needed} sessions
) } // ── How you shift by context ─────────────────────────────────────── function ContextShiftCard({ item, i }) { const dotColor = SIGNAL_COLORS[item.signal_key] || "#8b89aa" const isCategorical = CATEGORICAL_SIGNAL_KEYS.has(item.signal_key) return (
{item.label}
{item.note && (

{item.note}

)}
{/* by_context values are a raw mean (continuous signals) or the mode label itself (categorical signals) — matches main.py's value_field selection in get_profile(). */} {Object.entries(item.by_context).map(([ctx, value]) => ( {CONTEXT_LABELS[ctx] || ctx}: {isCategorical ? formatMean(item.signal_key, null, value) : formatMean(item.signal_key, value)} ))}
) } // ── Signal Trends (collapsible, raw history — not a pattern claim) ─ function SignalTrends({ chartData }) { const [open, setOpen] = useState(false) const [activeSignal, setActiveSignal] = useState("talk_ratio") if (chartData.length < 2) return null const signalConfig = SIGNAL_OPTIONS.find(s => s.key === activeSignal) return (
setOpen(v => !v)} style={{ padding: "14px 18px", display: "flex", justifyContent: "space-between", alignItems: "center", cursor: "pointer", borderBottom: open ? "1px solid #1e2438" : "none" }}> Signal History {open ? "▲ close" : "▼ expand"}
{open && (
{SIGNAL_OPTIONS.map(s => { const isActive = activeSignal === s.key return ( ) })}
`${v}${signalConfig?.unit || ""}`} /> [`${v}${signalConfig?.unit || ""}`, signalConfig?.label]} labelFormatter={(_, p) => p?.[0]?.payload?.date || ""} />
)}
) } // ── 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 (
{value}
{label}
) } function IndicatorsStrip({ indicators }) { if (!indicators) return null const tiles = [ { value: indicators.session_count, label: "Sessions", accent: "#5b9cf6" }, { value: formatDuration(indicators.time_recorded_s), label: "Recorded", accent: "#34d399" }, indicators.avg_talk_share != null && { value: `${Math.round(indicators.avg_talk_share * 100)}%`, label: "Avg talk-share", accent: "#818cf8", }, indicators.avg_pace != null && { value: Math.round(indicators.avg_pace), label: "Avg WPM", accent: "#f59e0b", }, { value: `${indicators.contexts_covered} / ${indicators.contexts_total}`, label: "Contexts", accent: "#22d3ee" }, ].filter(Boolean) return (
{tiles.map((t, i) => )}
) } // ── 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

) } // ── Main ProfileView ("You" page) ────────────────────────────────── export default function ProfileView({ active }) { const [profile, setProfile] = useState(null) const [trends, setTrends] = useState([]) const [loading, setLoading] = useState(true) useEffect(() => { if (!active) return setLoading(true) Promise.all([api.get("/api/profile"), api.get("/api/trends")]) .then(([profileRes, trendsRes]) => { setProfile(profileRes.data) setTrends(trendsRes.data.data || []) }) .catch(console.error) .finally(() => setLoading(false)) }, [active]) if (loading) return (
Loading your profile…
) if (!profile || profile.insufficient_data) { return (

Your mirror is waiting

Record your first Google Meet call to start building your profile.

Open Google Meet →

What you'll see here

{[ "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 => (
·

{line}

))}
) } const { profile_strength, portrait, how_you_shift_by_context, indicators, portrait_paragraph, portrait_tags, coaching } = profile const steady = portrait?.steady || [] const stillForming = portrait?.still_forming || [] const chartData = trends.map((point, i) => ({ ...point, label: `S${i + 1}`, date: new Date(point.date).toLocaleDateString("en-IN", { day: "numeric", month: "short" }), })) return (
{/* 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. */}
{profile_strength?.label && ( <> {profile_strength.label}
)}
{/* 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 && ( )}
) }