// Vantage — Build 2: Decision Journal.
// The product here is calibration: write the reasoning down BEFORE the
// outcome exists, review on a fixed 30/60/90 schedule, and let the
// analytics say whether your conviction predicts anything. Price drift
// since the decision is shown on every card so hindsight can't rewrite it.

const VG_ACTIONS = { buy: VG.up, sell: VG.down, hold: VG.ink3, watch: VG.accent2 };
const VG_HORIZONS = ['1mo', '3mo', '6mo', '1y', '3y+'];
const VG_STAGES = [30, 60, 90];

// Next unreviewed stage that is already due; or the next upcoming one.
function vgReviewState(d) {
  const done = new Set((d.reviews || []).map(r => r.stage));
  const age = vgAgoDays(d.created_at);
  for (const s of VG_STAGES) {
    if (!done.has(s)) {
      return age >= s ? { due: s } : { upcoming: s, inDays: s - age };
    }
  }
  return { complete: true };
}

function VgListInput({ label, items, setItems, placeholder }) {
  const [draft, setDraft] = React.useState('');
  const add = () => {
    const t = draft.trim();
    if (!t) return;
    setItems([...items, t]); setDraft('');
  };
  return (
    <VgField label={label}>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
        {items.map((it, i) => (
          <div key={i} style={{ display: 'flex', gap: 8, alignItems: 'center', background: VG.chip, borderRadius: 10, padding: '6px 10px' }}>
            <span style={{ flex: 1, fontSize: 12.5, color: VG.ink2 }}>{it}</span>
            <button type="button" onClick={() => setItems(items.filter((_, j) => j !== i))} style={{
              appearance: 'none', border: 0, background: 'transparent', color: VG.ink4, cursor: 'pointer', fontSize: 12,
            }}>✕</button>
          </div>
        ))}
        <div style={{ display: 'flex', gap: 8 }}>
          <VgInput value={draft} onChange={e => setDraft(e.target.value)} placeholder={placeholder}
            onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); add(); } }} />
          <VgBtn small onClick={add} disabled={!draft.trim()}>add</VgBtn>
        </div>
      </div>
    </VgField>
  );
}

// The signal card — the funnel's output for one symbol, plated. Leads with the
// synthesis (the "so what"), then the facets that actually move a decision:
// price character, fit to the book, the next catalyst, the options move, the
// tape, and this month historically. Degrades honestly — null facets vanish,
// an all-null card says the data is unreachable rather than showing nothing.
// One component renders it live in the form AND stamped on a past decision.
const VG_FACET_ORDER = [
  ['character', 'price character'],
  ['corrToBook', 'fit to your book'],
  ['catalyst', 'next catalyst'],
  ['expectedMove', 'options move'],
  ['coverage', 'in the tape'],
  ['seasonal', 'this month'],
];

function vgFacetChip(key, flags) {
  if (key === 'character' && flags.outOfChar) return ['out of character', VG.accent2];
  if (key === 'corrToBook' && flags.corrToBook === 'high') return ['concentrates', VG.accent2];
  if (key === 'corrToBook' && flags.corrToBook === 'low') return ['diversifies', VG.up];
  if (key === 'catalyst' && flags.intoCatalyst) return ['vol event', '#fb923c'];
  if (key === 'expectedMove' && flags.richMove) return ['rich', VG.accent2];
  if (key === 'coverage' && flags.activeThread) return ['thread', VG.accent2];
  return null;
}

function VgSignalCard({ card, loading, symbol, stamped }) {
  if (!loading && !card) return null;
  const facets = card?.facets || {};
  const present = VG_FACET_ORDER.filter(([k]) => facets[k]);
  const flags = card?.synthesis?.flags || {};
  const dead = !loading && card && present.length === 0;
  return (
    <div style={{ background: stamped ? 'transparent' : VG.chip, borderRadius: 12, padding: stamped ? 0 : '12px 14px' }}>
      <div style={{ ...vgS.caps, marginBottom: 8, display: 'flex', gap: 8, alignItems: 'baseline' }}>
        <span>{stamped ? 'signals when you decided' : 'signal read'}</span>
        {!stamped && symbol && <span style={{ color: VG.ink4 }}>{symbol}</span>}
      </div>
      {loading && <div style={{ fontSize: 12, color: VG.ink3 }}>reading the signals…</div>}
      {!loading && card && (
        <React.Fragment>
          {card.synthesis?.line && <VgBlurb text={card.synthesis.line} />}
          {dead ? (
            <div style={{ fontSize: 12, color: VG.ink3, marginTop: card.synthesis?.line ? 8 : 0 }}>
              market data is unreachable — the read fills in as quotes return.
            </div>
          ) : (
            <div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 10 }}>
              {present.map(([k, lbl]) => {
                const chip = vgFacetChip(k, flags);
                return (
                  <div key={k} style={{ display: 'grid', gridTemplateColumns: '108px 1fr', gap: 10, alignItems: 'baseline' }}>
                    <span style={vgS.caps}>{lbl}</span>
                    <div style={{ fontSize: 12, color: VG.ink2, lineHeight: 1.5 }}>
                      {facets[k].takeaway}
                      {chip && <span style={{ ...vgS.caps, color: chip[1], marginLeft: 8, whiteSpace: 'nowrap' }}>◆ {chip[0]}</span>}
                    </div>
                  </div>
                );
              })}
            </div>
          )}
        </React.Fragment>
      )}
    </div>
  );
}

function VgDecisionForm({ quotes, onDone, onClose, initial }) {
  const [f, setF] = React.useState({ symbol: initial?.symbol || '', action: 'buy', reasoning: '', confidence: 5, priceAt: '', targetPrice: '', horizon: '3mo' });
  const [assumptions, setAssumptions] = React.useState(initial?.assumptions || []);
  const [risks, setRisks] = React.useState(initial?.risks || []);
  const [err, setErr] = React.useState(null);
  const [busy, setBusy] = React.useState(false);
  const [card, setCard] = React.useState(null);
  const [cardLoading, setCardLoading] = React.useState(false);
  const set = k => e => setF({ ...f, [k]: e.target.value });

  // Prefill price from a live quote once the symbol matches one we hold.
  const sym = f.symbol.toUpperCase().trim();
  React.useEffect(() => {
    if (!sym || f.priceAt) return;
    const q = quotes[sym];
    if (q?.price != null) setF(prev => prev.priceAt ? prev : { ...prev, priceAt: String(q.price) });
    else if (/^[A-Z0-9.^-]{1,12}$/.test(sym)) {
      vgGet(`/vantage/api/quotes?symbols=${encodeURIComponent(sym)}`)
        .then(d => { const p = d.quotes?.[0]?.price; if (p != null) setF(prev => prev.priceAt ? prev : { ...prev, priceAt: String(p) }); })
        .catch(() => {});
    }
  }, [sym]);

  // The signal card: as soon as a valid symbol is entered (and when the horizon
  // changes, since "into a catalyst" depends on it), pull the plated read so
  // the decision is made WITH the funnel's output in view, not from memory.
  React.useEffect(() => {
    if (!/^[A-Z0-9.^-]{1,12}$/.test(sym)) { setCard(null); setCardLoading(false); return; }
    let alive = true;
    setCardLoading(true);
    const t = setTimeout(() => {
      vgGet(`/vantage/api/signal?symbol=${encodeURIComponent(sym)}&horizon=${encodeURIComponent(f.horizon)}`)
        .then(d => { if (alive) { setCard(d.card || null); setCardLoading(false); } })
        .catch(() => { if (alive) { setCard(null); setCardLoading(false); } });
    }, 450);
    return () => { alive = false; clearTimeout(t); };
  }, [sym, f.horizon]);

  const submit = async e => {
    e.preventDefault(); setBusy(true); setErr(null);
    try {
      await vgSend('/vantage/api/decision', 'POST', {
        symbol: sym, action: f.action,
        reasoning: f.reasoning, assumptions, risks,
        confidence: Number(f.confidence),
        priceAt: f.priceAt === '' ? null : Number(f.priceAt),
        targetPrice: f.targetPrice === '' ? null : Number(f.targetPrice),
        horizon: f.horizon,
      });
      onDone();
    } catch (ex) { setErr(ex.message); setBusy(false); }
  };

  return (
    <form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
        <VgField label="symbol">
          <VgInput value={f.symbol} onChange={set('symbol')} placeholder="NVDA" autoFocus style={{ textTransform: 'uppercase' }} />
        </VgField>
        <VgField label="action">
          <div style={{ display: 'flex', gap: 4, background: VG.chip, borderRadius: 10, padding: 3 }}>
            {Object.keys(VG_ACTIONS).map(a => (
              <button key={a} type="button" onClick={() => setF({ ...f, action: a })} style={{
                appearance: 'none', border: 0, cursor: 'pointer', flex: 1,
                padding: '7px 0', borderRadius: 8, fontSize: 12, fontWeight: 600,
                background: f.action === a ? VG.tile2 : 'transparent',
                color: f.action === a ? VG_ACTIONS[a] : VG.ink3,
              }}>{a}</button>
            ))}
          </div>
        </VgField>
      </div>
      <VgSignalCard card={card} loading={cardLoading} symbol={sym} />
      <VgField label="reasoning — why, written for your future self">
        <textarea value={f.reasoning} onChange={set('reasoning')} rows={3}
          placeholder="What do you believe that the market doesn't? What would change your mind?"
          style={{ ...vgInputStyle, resize: 'vertical', lineHeight: 1.5 }} />
      </VgField>
      <VgListInput label="key assumptions" items={assumptions} setItems={setAssumptions} placeholder="data-center capex keeps growing 20%+" />
      <VgListInput label="risks identified" items={risks} setItems={setRisks} placeholder="customer concentration; export controls" />
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
        <VgField label="price now">
          <VgInput value={f.priceAt} onChange={set('priceAt')} placeholder="auto from quote" inputMode="decimal" />
        </VgField>
        <VgField label="target (optional)">
          <VgInput value={f.targetPrice} onChange={set('targetPrice')} placeholder="—" inputMode="decimal" />
        </VgField>
        <VgField label="horizon">
          <select value={f.horizon} onChange={set('horizon')} style={{ ...vgInputStyle, cursor: 'pointer' }}>
            {VG_HORIZONS.map(h => <option key={h} value={h}>{h}</option>)}
          </select>
        </VgField>
      </div>
      <VgField label={`confidence · ${f.confidence} / 10`}>
        <input type="range" min="1" max="10" value={f.confidence} onChange={set('confidence')}
          style={{ width: '100%', accentColor: VG.accent }} />
        <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 10.5, color: VG.ink4 }}>
          <span>coin flip with feelings</span><span>bet-the-thesis sure</span>
        </div>
      </VgField>
      {err && <div style={{ color: VG.down, fontSize: 12.5 }}>{err}</div>}
      <div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
        <VgBtn onClick={onClose}>cancel</VgBtn>
        <VgBtn kind="primary" type="submit" disabled={busy || !sym}>record decision</VgBtn>
      </div>
    </form>
  );
}

function VgReviewForm({ decision, stage, quotes, onDone, onClose }) {
  const [outcome, setOutcome] = React.useState(null);
  const [notes, setNotes] = React.useState('');
  const [broken, setBroken] = React.useState([]);   // which stamped assumptions failed
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState(null);
  const q = quotes[decision.symbol];
  const drift = q?.price != null && decision.price_at ? q.price / decision.price_at - 1 : null;
  const assumptions = decision.assumptions || [];
  const showBroken = (outcome === 'incorrect' || outcome === 'unclear') && assumptions.length > 0;
  const submit = async () => {
    setBusy(true); setErr(null);
    try {
      await vgSend(`/vantage/api/decision/${decision.id}/review`, 'POST', {
        stage, outcome, notes, priceAt: q?.price ?? null,
        broken: showBroken ? broken : [],
      });
      onDone();
    } catch (ex) { setErr(ex.message); setBusy(false); }
  };
  return (
    <VgModal title={`${stage}-day review · ${decision.symbol}`} onClose={onClose} width={480}>
      <div style={{ fontSize: 13, color: VG.ink2, lineHeight: 1.55, marginBottom: 12 }}>
        You said <b style={{ color: VG_ACTIONS[decision.action] }}>{decision.action}</b> at {vgMoney(decision.price_at)}
        {decision.target_price ? <> targeting {vgMoney(decision.target_price)}</> : null}.
        {drift != null && <> It's now {vgMoney(q.price)} (<span style={{ color: vgDelta(drift) }}>{vgPct(drift)}</span>).</>}
      </div>
      {decision.reasoning && (
        <div style={{ ...vgS.serif, fontSize: 14.5, color: VG.ink2, background: VG.chip, borderRadius: 12, padding: '10px 14px', marginBottom: 14 }}>
          “{decision.reasoning}”
        </div>
      )}
      <div style={{ ...vgS.caps, marginBottom: 8 }}>was the call right — judged on your reasoning, not luck?</div>
      <div style={{ display: 'flex', gap: 8, marginBottom: 14 }}>
        {[['correct', VG.up], ['incorrect', VG.down], ['unclear', VG.ink3]].map(([o, c]) => (
          <button key={o} type="button" onClick={() => setOutcome(o)} style={{
            appearance: 'none', cursor: 'pointer', flex: 1, padding: '9px 0',
            borderRadius: 10, fontSize: 12.5, fontWeight: 600,
            border: 0, boxShadow: outcome === o ? 'none' : `inset 0 0 0 1px ${VG.rule}`,
            background: outcome === o ? c : 'transparent',
            color: outcome === o ? '#0a0208' : c,
          }}>{o}</button>
        ))}
      </div>
      {showBroken && (
        <div style={{ marginBottom: 14 }}>
          <div style={{ ...vgS.caps, marginBottom: 8 }}>which belief broke? — where calibration becomes learning</div>
          <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
            {assumptions.map((a, i) => {
              const on = broken.includes(a);
              return (
                <button key={i} type="button" onClick={() => setBroken(on ? broken.filter(x => x !== a) : [...broken, a])}
                  style={{
                    appearance: 'none', cursor: 'pointer', textAlign: 'left', borderRadius: 10, padding: '8px 12px',
                    border: 0, fontSize: 12.5, lineHeight: 1.4,
                    boxShadow: on ? 'none' : `inset 0 0 0 1px ${VG.rule}`,
                    background: on ? VG.down : 'transparent', color: on ? '#0a0208' : VG.ink2,
                  }}>{on ? '✓ ' : ''}{a}</button>
              );
            })}
          </div>
        </div>
      )}
      <textarea value={notes} onChange={e => setNotes(e.target.value)} rows={2}
        placeholder="what actually happened vs the thesis…"
        style={{ ...vgInputStyle, resize: 'vertical', lineHeight: 1.5, marginBottom: 14 }} />
      {err && <div style={{ color: VG.down, fontSize: 12.5, marginBottom: 8 }}>{err}</div>}
      <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
        <VgBtn kind="primary" onClick={submit} disabled={!outcome || busy}>save review</VgBtn>
      </div>
    </VgModal>
  );
}

function VgAnalytics({ analytics }) {
  // analytics is a shaped object even with zero decisions; null here means the
  // fetch failed (the journal only renders after the initial load settles), so
  // say so rather than silently dropping the whole win-rate/calibration block.
  if (!analytics) return (
    <section style={{ background: VG.tile, borderRadius: 18, padding: '14px 18px', marginBottom: 14 }}>
      <div style={{ color: VG.ink3, fontSize: 12.5 }}>calibration & win-rate analytics are unreachable right now — they return automatically.</div>
    </section>
  );
  const a = analytics;
  const cell = { background: VG.tile, borderRadius: 18, padding: '14px 18px' };
  const calibRows = [['low', '1–3'], ['mid', '4–7'], ['high', '8–10']]
    .map(([k, lbl]) => ({ k, lbl, ...a.calibration[k] }))
    .filter(r => r.n > 0);
  // The one-line calibration verdict a PM actually wants.
  let verdict = null;
  const hi = a.calibration.high, lo = a.calibration.low;
  if (hi.n >= 3 && lo.n + a.calibration.mid.n >= 3) {
    const hiRate = hi.wins / hi.n;
    const restN = lo.n + a.calibration.mid.n;
    const restRate = (lo.wins + a.calibration.mid.wins) / restN;
    verdict = hiRate > restRate + 0.1 ? 'your conviction is earning its keep — high-confidence calls win more.'
      : hiRate < restRate - 0.1 ? 'warning: your high-confidence calls do worse than the rest. Confidence ≠ edge yet.'
      : 'confidence and outcomes are uncorrelated so far — size positions accordingly.';
  }
  return (
    <React.Fragment>
      <section style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 10, marginBottom: 14 }}>
      <div style={cell}>
        <div style={vgS.caps}>win rate · reviewed calls</div>
        <div style={{ ...vgS.serif, ...vgS.num, fontSize: 30, color: a.winRate == null ? VG.ink3 : vgDelta(a.winRate - 0.5), marginTop: 4 }}>
          {a.winRate == null ? '—' : Math.round(a.winRate * 100) + '%'}
        </div>
        <div style={{ fontSize: 11.5, color: VG.ink3, marginTop: 3 }}>
          {a.reviewed} reviewed · {a.pending} awaiting review · {a.total} total
        </div>
      </div>
      <div style={cell}>
        <div style={vgS.caps}>calibration · does conviction predict wins?</div>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 7, marginTop: 8 }}>
          {calibRows.length === 0 && <span style={{ fontSize: 12, color: VG.ink3 }}>review a few decisions first.</span>}
          {calibRows.map(r => (
            <div key={r.k} style={{ display: 'grid', gridTemplateColumns: '64px 1fr 44px', gap: 10, alignItems: 'center', fontSize: 11.5 }}>
              <span style={{ color: VG.ink3 }}>conf {r.lbl}</span>
              <VgBar pct={r.n ? r.wins / r.n : 0} color={VG.accent} h={4} />
              <span style={{ ...vgS.num, color: VG.ink, textAlign: 'right' }}>{r.n ? Math.round(r.wins / r.n * 100) + '%' : '—'}</span>
            </div>
          ))}
        </div>
        {verdict && <div style={{ ...vgS.serif, fontSize: 13, color: VG.accent2, marginTop: 9, lineHeight: 1.4 }}>{verdict}</div>}
      </div>
      <div style={cell}>
        <div style={vgS.caps}>by action</div>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 7, marginTop: 8 }}>
          {Object.keys(a.byAction).length === 0 && <span style={{ fontSize: 12, color: VG.ink3 }}>no reviewed decisions yet.</span>}
          {Object.entries(a.byAction).map(([act, s]) => (
            <div key={act} style={{ display: 'grid', gridTemplateColumns: '52px 1fr 70px', gap: 10, alignItems: 'center', fontSize: 11.5 }}>
              <span style={{ ...vgS.caps, color: VG_ACTIONS[act] || VG.ink3 }}>{act}</span>
              <VgBar pct={s.n ? s.wins / s.n : 0} color={VG_ACTIONS[act] || VG.accent} h={4} />
              <span style={{ ...vgS.num, color: VG.ink, textAlign: 'right' }}>{s.wins}/{s.n} won</span>
            </div>
          ))}
        </div>
      </div>
      <VgMoodTile byMood={a.byMood} />
      <VgKellyTile k={a.kelly} />
      <VgContextTile byContext={a.byContext} baseline={a.winRate} />
      </section>
      <VgPostMortem pm={a.postMortem} />
    </React.Fragment>
  );
}

// The assumption-level post-mortem: recurring broken beliefs first (a blind
// spot repeats), then the recent misses with the specific beliefs that failed.
function VgPostMortem({ pm }) {
  if (!pm || !pm.entries?.length) return null;
  return (
    <section style={{ background: VG.tile, borderRadius: 18, padding: '15px 18px', marginBottom: 14 }}>
      <div style={{ ...vgS.caps, marginBottom: 8 }}>post-mortem · which beliefs broke</div>
      {pm.recurring?.length > 0 && (
        <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 11 }}>
          {pm.recurring.map((r, i) => (
            <span key={i} style={{ background: VG.down + '22', color: VG.down, borderRadius: 999, padding: '3px 11px', fontSize: 11.5, fontWeight: 600 }}>
              {r.text} · broke ×{r.count}
            </span>
          ))}
        </div>
      )}
      <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
        {pm.entries.slice(0, 8).map((e, i) => (
          <div key={i} style={{ display: 'flex', gap: 10, alignItems: 'baseline', fontSize: 12, flexWrap: 'wrap' }}>
            <span style={{ ...vgS.num, color: VG.ink, minWidth: 44, fontWeight: 600 }}>{e.symbol}</span>
            <span style={{ ...vgS.caps, color: e.outcome === 'incorrect' ? VG.down : VG.ink3 }}>{e.stage}d {e.outcome}</span>
            <span style={{ color: VG.ink2, flex: 1, minWidth: 0 }}>{e.broken.join(' · ')}</span>
          </div>
        ))}
      </div>
      <div style={{ fontSize: 10.5, color: VG.ink4, marginTop: 10 }}>{pm.note}</div>
    </section>
  );
}

// Kelly sizing from the journal's own measured record — the journal stops
// being a diary and starts sizing trades. Rides the analytics payload so
// the journal panel loads (and fails) as one unit. Gated below minN
// reviewed buys: Kelly is violently sensitive to win rate at small n, so
// until the record is real the tile says what's missing instead of printing
// a number — and a measured NEGATIVE edge says "size zero", never a
// negative "position size".
function VgKellyTile({ k }) {
  if (!k) return null;
  return (
    <div style={{ background: VG.tile, borderRadius: 18, padding: '14px 18px' }}>
      <div style={vgS.caps}>kelly sizing · from your reviewed buys</div>
      {k.gated && (
        <div style={{ fontSize: 12, color: VG.ink3, marginTop: 8, lineHeight: 1.5 }}>
          unlocks at {k.minN} reviewed buys — {k.n} so far. A win rate measured on
          single digits swings the formula wildly; Vantage won't size trades on noise.
        </div>
      )}
      {!k.gated && k.b == null && (
        <div style={{ fontSize: 12, color: VG.ink3, marginTop: 8, lineHeight: 1.5 }}>
          {k.n} reviewed buys, but no measurable payoff ratio yet — it needs winning
          calls that gained and losing calls that lost, by price drift at review.
        </div>
      )}
      {!k.gated && k.b != null && k.fullKelly <= 0 && (
        <React.Fragment>
          <div style={{ ...vgS.serif, ...vgS.num, fontSize: 30, color: VG.down, marginTop: 4 }}>size: zero</div>
          <div style={{ fontSize: 11.5, color: VG.ink3, marginTop: 3 }}>
            win rate {Math.round(k.p * 100)}% of {k.n} at payoff {k.b}:1 — Kelly is negative ({vgPct(k.fullKelly)})
          </div>
          <div style={{ ...vgS.serif, fontSize: 13, color: VG.accent2, marginTop: 9, lineHeight: 1.4 }}>
            the measured record says these bets lose money as sized — the formula's advice is not "smaller", it's "don't".
          </div>
        </React.Fragment>
      )}
      {!k.gated && k.b != null && k.fullKelly > 0 && (
        <React.Fragment>
          <div style={{ ...vgS.serif, ...vgS.num, fontSize: 30, color: VG.ink, marginTop: 4 }}>
            {vgPct(k.quarterKelly, false)}–{vgPct(k.halfKelly, false)}
          </div>
          <div style={{ fontSize: 11.5, color: VG.ink3, marginTop: 3 }}>
            ¼–½ Kelly max position · full Kelly {vgPct(k.fullKelly, false)} · win rate {Math.round(k.p * 100)}% of {k.n} · payoff {k.b}:1
          </div>
          <div style={{ ...vgS.serif, fontSize: 13, color: VG.accent2, marginTop: 9, lineHeight: 1.4 }}>
            {k.conservative != null && k.conservative <= 0
              ? `at the 95% floor of your measured win rate (${Math.round(k.pLo * 100)}%), Kelly says don't bet — the edge isn't proven yet, so treat these fractions as a ceiling.`
              : `even at the 95% floor of your win rate (${Math.round(k.pLo * 100)}%), Kelly stays positive at ${vgPct(k.conservative, false)} — the measured edge survives its own error bars.`}
          </div>
        </React.Fragment>
      )}
    </div>
  );
}

// Does the market's mood at decision time predict your outcomes? Buckets
// use the Fear & Greed zone edges, so this panel and the pulse dial agree.
function VgMoodTile({ byMood }) {
  if (!byMood) return null;
  const MOODS = [['fear', '#fb923c'], ['neutral', VG.ink3], ['greed', '#86efac']];
  const rows = MOODS.map(([k, c]) => ({ k, c, ...byMood[k] })).filter(r => r.n > 0);
  let verdict = null;
  const f = byMood.fear, g = byMood.greed;
  if (f.n >= 3 && g.n >= 3) {
    const fr = f.wins / f.n, gr = g.wins / g.n;
    verdict = gr < fr - 0.1 ? 'you decide worse when the market is greedy — extra scrutiny on euphoric days.'
      : fr < gr - 0.1 ? 'fearful tape hurts your judgement — slow down when the market is scared.'
      : 'market mood isn’t moving your hit rate — your process travels well.';
  }
  return (
    <div style={{ background: VG.tile, borderRadius: 18, padding: '14px 18px' }}>
      <div style={vgS.caps}>by market mood at decision</div>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 7, marginTop: 8 }}>
        {rows.length === 0 && (
          <span style={{ fontSize: 12, color: VG.ink3 }}>
            new decisions are stamped with the Fear &amp; Greed reading — this fills in as they get reviewed.
          </span>
        )}
        {rows.map(r => (
          <div key={r.k} style={{ display: 'grid', gridTemplateColumns: '58px 1fr 70px', gap: 10, alignItems: 'center', fontSize: 11.5 }}>
            <span style={{ ...vgS.caps, color: r.c }}>{r.k}</span>
            <VgBar pct={r.n ? r.wins / r.n : 0} color={r.c} h={4} />
            <span style={{ ...vgS.num, color: VG.ink, textAlign: 'right' }}>{r.wins}/{r.n} won</span>
          </div>
        ))}
      </div>
      {verdict && <div style={{ ...vgS.serif, fontSize: 13, color: VG.accent2, marginTop: 9, lineHeight: 1.4 }}>{verdict}</div>}
    </div>
  );
}

// Does the SIGNAL context at decision time predict outcomes? Each dimension is
// a condition the app stamped on the decision (out of character / correlated to
// the book / into a catalyst / an active thread running); its reviewed hit rate
// is compared to the overall rate. This is the funnel closing on itself — the
// journal learns which conditions help or hurt judgement, not just which moods.
function VgContextTile({ byContext, baseline }) {
  if (!byContext) return null;
  const DIMS = [
    ['outOfCharacter', 'out of character'],
    ['correlatedToBook', 'correlated to book'],
    ['intoCatalyst', 'into a catalyst'],
    ['activeThread', 'active thread'],
  ];
  const rows = DIMS.map(([k, lbl]) => ({ k, lbl, ...byContext[k] })).filter(r => r.n > 0);
  let verdict = null;
  if (baseline != null) {
    const top = rows.filter(r => r.n >= 3)
      .map(r => ({ ...r, rate: r.wins / r.n, gap: r.wins / r.n - baseline }))
      .sort((a, b) => Math.abs(b.gap) - Math.abs(a.gap))[0];
    if (top && Math.abs(top.gap) >= 0.15) {
      verdict = top.gap < 0
        ? `your calls ${top.lbl} win ${Math.round(top.rate * 100)}% vs ${Math.round(baseline * 100)}% overall — a blind spot worth extra scrutiny.`
        : `your calls ${top.lbl} win ${Math.round(top.rate * 100)}% vs ${Math.round(baseline * 100)}% overall — a genuine strength.`;
    }
  }
  return (
    <div style={{ background: VG.tile, borderRadius: 18, padding: '14px 18px' }}>
      <div style={vgS.caps}>by signal context at decision</div>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 7, marginTop: 8 }}>
        {rows.length === 0 && (
          <span style={{ fontSize: 12, color: VG.ink3 }}>
            new decisions are stamped with the signal card — this learns which conditions help or hurt your hit rate as they get reviewed.
          </span>
        )}
        {rows.map(r => (
          <div key={r.k} style={{ display: 'grid', gridTemplateColumns: '116px 1fr 58px', gap: 10, alignItems: 'center', fontSize: 11.5 }}>
            <span style={{ color: VG.ink3 }}>{r.lbl}</span>
            <VgBar pct={r.n ? r.wins / r.n : 0} color={VG.accent} h={4} />
            <span style={{ ...vgS.num, color: VG.ink, textAlign: 'right' }}>{r.wins}/{r.n} won</span>
          </div>
        ))}
      </div>
      {verdict && <div style={{ ...vgS.serif, fontSize: 13, color: VG.accent2, marginTop: 9, lineHeight: 1.4 }}>{verdict}</div>}
    </div>
  );
}

function VgDecisionCard({ d, quotes, onReview, watch }) {
  const [open, setOpen] = React.useState(false);
  const st = vgReviewState(d);
  const q = quotes[d.symbol];
  const drift = q?.price != null && d.price_at ? q.price / d.price_at - 1 : null;
  const latest = (d.reviews || [])[d.reviews.length - 1];
  return (
    <article style={{ background: VG.tile, borderRadius: 16, padding: '13px 16px' }}>
      <div onClick={() => setOpen(!open)} style={{ display: 'flex', gap: 12, alignItems: 'baseline', cursor: 'pointer', flexWrap: 'wrap' }}>
        <span style={{ fontWeight: 700, fontSize: 14, color: VG.ink }}>{d.symbol}</span>
        <VgTag color={VG_ACTIONS[d.action]}>{d.action}</VgTag>
        {d.confidence != null && <span style={{ ...vgS.num, fontSize: 11.5, color: VG.ink3 }}>conf {d.confidence}/10</span>}
        <span style={{ ...vgS.num, fontSize: 11.5, color: VG.ink3 }}>
          {vgMoney(d.price_at)}{d.target_price ? ` → ${vgMoney(d.target_price)}` : ''} · {d.horizon || '—'}
        </span>
        {drift != null && (
          <span style={{ ...vgS.num, fontSize: 11.5, color: vgDelta(drift) }}>since: {vgPct(drift)}</span>
        )}
        {d.fear_greed != null && (
          <span title="market mood when this was recorded" style={{
            ...vgS.num, fontSize: 10.5, color: d.fear_greed <= 45 ? '#fb923c' : d.fear_greed <= 55 ? VG.ink4 : '#86efac',
          }}>
            @ F&G {Math.round(d.fear_greed)}{d.vix != null ? ` · vix ${Number(d.vix).toFixed(0)}` : ''}
          </span>
        )}
        <span style={{ flex: 1 }} />
        {latest && (
          <VgTag color={latest.outcome === 'correct' ? VG.up : latest.outcome === 'incorrect' ? VG.down : VG.ink3}>
            {latest.stage}d · {latest.outcome}
          </VgTag>
        )}
        {st.due && (
          <button onClick={e => { e.stopPropagation(); onReview(d, st.due); }} className="vg-chip" style={{
            appearance: 'none', border: 0, cursor: 'pointer', borderRadius: 999,
            padding: '4px 11px', fontSize: 11, fontWeight: 700,
            background: VG.accent, color: '#0a0208',
          }}>{st.due}d review due →</button>
        )}
        {st.upcoming && <span style={{ ...vgS.caps, color: VG.ink4 }}>{st.upcoming}d in {st.inDays}d</span>}
        <span style={{ ...vgS.num, fontSize: 11, color: VG.ink4 }}>{vgDate(d.created_at)}</span>
      </div>
      {open && (
        <div style={{ marginTop: 10, paddingTop: 10, borderTop: `1px solid ${VG.rule}` }}>
          {d.reasoning && <p style={{ ...vgS.serif, fontSize: 14.5, color: VG.ink2, margin: '0 0 10px', lineHeight: 1.5 }}>“{d.reasoning}”</p>}
          <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
            {[['assumptions', d.assumptions], ['risks', d.risks]].map(([lbl, list]) => (
              <div key={lbl}>
                <div style={{ ...vgS.caps, marginBottom: 5 }}>{lbl}</div>
                {(list || []).length === 0
                  ? <span style={{ fontSize: 12, color: VG.ink4 }}>none noted</span>
                  : (list || []).map((x, i) => <div key={i} style={{ fontSize: 12.5, color: VG.ink2, padding: '2px 0' }}>· {x}</div>)}
              </div>
            ))}
          </div>
          {/* The skeptic: this thesis's assumptions as live watch rules, and
              any wire evidence leaning against them. Leads, not verdicts. */}
          {watch && (watch.rules > 0 || watch.hits.length > 0) && (
            <div style={{ marginTop: 12, paddingTop: 10, borderTop: `1px solid ${VG.rule}` }}>
              <div style={{ ...vgS.caps, marginBottom: 5 }}>
                the skeptic · watching {watch.rules} assumption{watch.rules === 1 ? '' : 's'}
                {watch.hits.length === 0 ? ' — nothing leans against you (14d)' : ''}
              </div>
              {watch.hits.map((h, i) => (
                <div key={i} style={{ fontSize: 12, lineHeight: 1.5, marginBottom: 4 }}>
                  <span style={{ color: '#fb923c' }}>⚠ </span>
                  <span style={{ color: VG.ink3 }}>“{h.assumption.slice(0, 70)}” — </span>
                  <a href={h.link} target="_blank" rel="noreferrer" style={{ color: VG.ink2 }}>{h.title}</a>
                  <span style={{ ...vgS.caps, fontSize: 8.5, color: VG.ink4, marginLeft: 6 }}>{h.source || ''}</span>
                  <span style={{ fontSize: 10.5, color: VG.ink4 }}> · {h.reason} · leans against it — judge it yourself</span>
                </div>
              ))}
            </div>
          )}
          {d.context && (
            <div style={{ marginTop: 12, paddingTop: 10, borderTop: `1px solid ${VG.rule}` }}>
              <VgSignalCard card={d.context} stamped />
            </div>
          )}
          {(d.reviews || []).length > 0 && (
            <div style={{ marginTop: 10 }}>
              <div style={{ ...vgS.caps, marginBottom: 5 }}>reviews</div>
              {d.reviews.map(r => (
                <div key={r.stage} style={{ fontSize: 12.5, color: VG.ink2, padding: '3px 0' }}>
                  <b style={{ color: r.outcome === 'correct' ? VG.up : r.outcome === 'incorrect' ? VG.down : VG.ink3 }}>{r.stage}d · {r.outcome}</b>
                  {r.priceAt != null && <span style={{ ...vgS.num, color: VG.ink3 }}> at {vgMoney(r.priceAt)}</span>}
                  {r.notes && <span style={{ color: VG.ink3 }}> — {r.notes}</span>}
                </div>
              ))}
            </div>
          )}
        </div>
      )}
    </article>
  );
}

// Obsidian vault status — the one place the dashboard admits whether your
// journal is actually mirroring out and your notes ingesting back in. Honest
// when unset (tells you the env var to set); a manual "sync now" for when you
// don't want to wait for the interval.
function VgVaultStatus() {
  const [v, setV] = React.useState(undefined);   // undefined loading · null status-endpoint down · {enabled,lastSync}
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState(null);
  const load = React.useCallback(() => {
    vgGet('/vantage/api/vault').then(setV).catch(() => setV(null));
  }, []);
  React.useEffect(load, [load]);

  const sync = async () => {
    setBusy(true); setErr(null);
    try { await vgSend('/vantage/api/vault/sync', 'POST'); load(); }
    catch (ex) { setErr(ex.message); }
    setBusy(false);
  };

  if (v === undefined || v === null) return null;   // quiet while loading / if status is unreachable — not core

  const strip = children => (
    <section style={{ background: VG.tile, borderRadius: 18, padding: '11px 16px', marginTop: 14,
      display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
      <span style={vgS.caps}>obsidian vault</span>
      {children}
    </section>
  );

  if (!v.enabled) return strip(
    <span style={{ fontSize: 12, color: VG.ink3 }}>
      not connected — set <code style={{ color: VG.ink2, fontSize: 11 }}>VAULT_GIT_URL</code> to mirror your journal out and surface your own notes in research. See docs/vault-setup.md.
    </span>
  );

  const ls = v.lastSync;
  let ago = 'first sync pending';
  if (ls?.at) {
    const m = Math.floor((Date.now() - ls.at) / 60000);
    ago = m < 1 ? 'just now' : m < 60 ? `${m}m ago` : m < 1440 ? `${Math.floor(m / 60)}h ago` : `${Math.floor(m / 1440)}d ago`;
  }
  return strip(
    <React.Fragment>
      <span style={{ fontSize: 12, color: VG.ink2 }}>
        connected · {ls?.at ? `last synced ${ago}` : ago}
        {ls && (ls.exported != null || ls.ingested != null) ? ` · ${ls.exported || 0} exported, ${ls.ingested || 0} ingested` : ''}
        {ls?.pushed ? ' · pushed' : ''}
      </span>
      {(ls?.error || err) && <span style={{ fontSize: 11, color: VG.down }}>{err || ls.error}</span>}
      <span style={{ flex: 1 }} />
      <VgBtn small onClick={sync} disabled={busy}>{busy ? 'syncing…' : 'sync now'}</VgBtn>
    </React.Fragment>
  );
}

function VantageJournal({ decisions, analytics, quotes, onRefresh }) {
  const [recording, setRecording] = React.useState(false);
  const [reviewing, setReviewing] = React.useState(null); // { decision, stage }
  // The skeptic's watch state: rules + recent hits, joined per decision.
  const [falsifiers, setFalsifiers] = React.useState(null);
  React.useEffect(() => {
    vgGet('/vantage/api/falsifiers').then(setFalsifiers).catch(() => setFalsifiers(null));
  }, [decisions.length]);
  const falsifierWatch = (decisionId) => {
    if (!falsifiers) return null;
    return {
      rules: (falsifiers.rules || []).filter(r => r.decisionId === decisionId).length,
      hits: (falsifiers.hits || []).filter(h => h.decisionId === decisionId),
    };
  };
  const [fSym, setFSym] = React.useState('');
  const [fOutcome, setFOutcome] = React.useState('all');

  const dueCount = decisions.filter(d => vgReviewState(d).due).length;
  const filtered = decisions.filter(d => {
    if (fSym && !d.symbol.includes(fSym.toUpperCase())) return false;
    if (fOutcome === 'due') return !!vgReviewState(d).due;
    if (fOutcome !== 'all') {
      const latest = (d.reviews || [])[d.reviews.length - 1];
      return latest?.outcome === fOutcome;
    }
    return true;
  });

  return (
    <React.Fragment>
      <VgAnalytics analytics={analytics} />
      {/* PM-Track: the morning calls and the discipline record live with the
          journal — calibration is this tab's product. */}
      <VgThesisLog refreshKey={decisions.length} />
      <VgDiscipline />
      <section style={{ display: 'flex', gap: 10, alignItems: 'center', marginBottom: 12, flexWrap: 'wrap' }}>
        <VgBtn kind="primary" onClick={() => setRecording(true)}>+ record decision</VgBtn>
        {dueCount > 0 && <VgTag color={VG.accent2}>{dueCount} review{dueCount > 1 ? 's' : ''} due</VgTag>}
        <span style={{ flex: 1 }} />
        <VgInput value={fSym} onChange={e => setFSym(e.target.value)} placeholder="filter symbol…"
          style={{ width: 130, textTransform: 'uppercase' }} />
        <select value={fOutcome} onChange={e => setFOutcome(e.target.value)} style={{ ...vgInputStyle, width: 130, cursor: 'pointer' }}>
          {['all', 'due', 'correct', 'incorrect', 'unclear'].map(o => <option key={o} value={o}>{o}</option>)}
        </select>
      </section>
      <section style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
        {filtered.length === 0 && (
          <div style={{ color: VG.ink3, fontSize: 12.5, padding: '18px 4px' }}>
            {decisions.length === 0
              ? 'no decisions yet. Record the next one before you act on it — that\'s the whole trick.'
              : 'nothing matches the filter.'}
          </div>
        )}
        {filtered.map(d => (
          <VgDecisionCard key={d.id} d={d} quotes={quotes} watch={falsifierWatch(d.id)}
            onReview={(decision, stage) => setReviewing({ decision, stage })} />
        ))}
      </section>

      <VgVaultStatus />

      {recording && (
        <VgModal title="Record a decision" onClose={() => setRecording(false)} width={560}>
          <VgDecisionForm quotes={quotes} onClose={() => setRecording(false)}
            onDone={() => { setRecording(false); onRefresh(); }} />
        </VgModal>
      )}
      {reviewing && (
        <VgReviewForm decision={reviewing.decision} stage={reviewing.stage} quotes={quotes}
          onClose={() => setReviewing(null)}
          onDone={() => { setReviewing(null); onRefresh(); }} />
      )}
    </React.Fragment>
  );
}

window.VantageJournal = VantageJournal;
window.vgReviewState = vgReviewState;
// Exported so the research tab can open the same decision form prefilled from
// a dossier — one form, one stamping path, no drift.
window.VgDecisionForm = VgDecisionForm;
