// financial/src/career.jsx — the Career section of the Financial Hub.
//
// Four views: Board (scanned postings — Phase 1), Pipeline (every
// application, what's due today, outcome capture), Insights (the funnel
// and what actually converts), Profile (search config + commute limits +
// target employers).
//
// All the maths lives in the mirrored libs — window.JobFit for the A–D
// suitability grade, window.CareerFunnel for stages, follow-up cadence,
// feedback solicitation and outcome calibration. This file renders; it
// decides nothing, so the board, the digest email and the agents can
// never disagree about a number.
//
// Slices run in their own scope before the inline block, so everything
// shared comes from globals and the view is published on
// window.FinanceViews.

const { useState, useEffect, useMemo, useCallback, useRef } = React;

// `db` and `fns` come from the plain <script> in index.html's <head>, which
// runs before Babel gets to this file. Nothing here may call firebase.* at
// module scope: a throw out here never publishes window.FinanceViews.Career
// and the shell has no component to render.
const JobFit = window.JobFit;
const CareerFunnel = window.CareerFunnel;

// Slices have their own scope, so this can't reuse the inline block's
// helper. The scan legitimately runs for minutes across every source.
const callFn = (name, data) =>
  fns.httpsCallable(name, { timeout: 540000 })(data).then(r => r.data);

// Seed employers for the two target sectors, so the target list doesn't
// start from a blank page. Workday entries carry host/tenant/site read
// straight off the employer's careers URL.
const STARTER_EMPLOYERS = [
  { name: "Wells Fargo", ats: "workday", host: "wd1.myworkdaysite.com", tenant: "wf", site: "WellsFargoJobs", tier: 1 },
  { name: "Capital One", ats: "workday", host: "capitalone.wd1.myworkdayjobs.com", tenant: "capitalone", site: "Capital_One", tier: 1 },
  { name: "Stripe", ats: "greenhouse", slug: "stripe", tier: 1 },
  { name: "Block", ats: "greenhouse", slug: "square", tier: 2 },
  { name: "Plaid", ats: "lever", slug: "plaid", tier: 2 },
  { name: "Ramp", ats: "ashby", slug: "ramp", tier: 2 },
  { name: "Datadog", ats: "greenhouse", slug: "datadog", tier: 2 },
  { name: "Netflix", ats: "lever", slug: "netflix", tier: 1 },
];

const money = n => n == null || n === ""
  ? "—"
  : new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(Number(n) || 0);

const day = ms => {
  if (!ms) return "—";
  try { return new Date(ms).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); }
  catch (e) { return "—"; }
};

const todayISO = () => new Date().toISOString().slice(0, 10);
const isoToMs = s => (s ? Date.parse(s + "T12:00:00") : null);
const msToISO = ms => (ms ? new Date(ms).toISOString().slice(0, 10) : "");

const GRADE_COLOR = { A: "#10b981", B: "#3b82f6", C: "#f59e0b", D: "#ef4444" };
const REASON_KIND_COLOR = { plus: "#10b981", flag: "#f59e0b", minus: "#ef4444" };

// ── Small shared pieces ──────────────────────────────────────────────────────

function GradeBadge({ grade, score, size = 38 }) {
  const color = GRADE_COLOR[grade] || "#64748b";
  return (
    <div style={{
      width: size, height: size, borderRadius: 10, flexShrink: 0,
      background: color + "22", border: `1px solid ${color}55`, color,
      display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
    }}>
      <div style={{ fontSize: size * 0.45, fontWeight: 700, lineHeight: 1 }}>{grade || "?"}</div>
      {score != null && <div style={{ fontSize: 8, opacity: 0.8, marginTop: 1 }}>{score}</div>}
    </div>
  );
}

function Field({ label, hint, children, width }) {
  return (
    <div style={{ flex: width ? `0 0 ${width}` : "1 1 180px", minWidth: 0 }}>
      <div className="label" style={{ marginBottom: 4 }}>{label}</div>
      {children}
      {hint && <div style={{ fontSize: 10, color: "#475569", marginTop: 4, lineHeight: 1.4 }}>{hint}</div>}
    </div>
  );
}

function Row({ children, gap = 12 }) {
  return <div style={{ display: "flex", flexWrap: "wrap", gap, marginBottom: 14 }}>{children}</div>;
}

// Empty states say what to do next — never "no data".
function Empty({ title, children }) {
  return (
    <div className="empty-state">
      <h3>{title}</h3>
      <div style={{ fontSize: 12, maxWidth: 460, margin: "0 auto", lineHeight: 1.6 }}>{children}</div>
    </div>
  );
}

// A count is never shown without what it is out of.
function Stat({ label, value, of, tone }) {
  return (
    <div className="card-sm">
      <div className="label">{label}</div>
      <div className="val-md" style={{ color: tone || "#e2e8f0" }}>
        {value}
        {of != null && <span style={{ fontSize: 12, color: "#64748b", fontWeight: 400 }}> {of}</span>}
      </div>
    </div>
  );
}

// ── Profile ──────────────────────────────────────────────────────────────────

const EMPTY_PROFILE = {
  targetTitles: [], seniorityLevel: "", currentTitle: "", yearsExperience: "",
  skills: [], industries: ["tech", "financial"],
  compFloor: "", compHardFloor: "", compTarget: "",
  compTargetTC: "", compTargetTCMax: "",
  remotePreference: "hybrid", relocation: false,
  home: { city: "", state: "", lat: "", lng: "" },
  commute: { maxMinutesOneWay: 45, maxDaysPerWeekOnsite: 3 },
  targetCompanies: [], dealbreakers: { excludeCompanies: [] },
};

const list = v => (Array.isArray(v) ? v.join(", ") : "");
const parseList = s => String(s || "").split(",").map(x => x.trim()).filter(Boolean);

// Editing the profile is the one place here where real work accumulates
// before anything is written — a dozen target employers typed by hand, each
// with a slug looked up from a careers URL. All of it lived in component
// state, so switching to the Board, changing tab, or reloading discarded the
// lot with no warning: the form looked authoritative right up until it was
// empty. The draft is mirrored to localStorage on every keystroke instead, so
// the work survives whatever the browser does next, and a restored draft says
// so rather than pretending it was saved.
const DRAFT_KEY = uid => `careerProfileDraft:${uid || "anon"}`;

const readDraft = uid => {
  try { return JSON.parse(localStorage.getItem(DRAFT_KEY(uid)) || "null"); }
  catch (e) { return null; }
};
const writeDraft = (uid, value) => {
  try { localStorage.setItem(DRAFT_KEY(uid), JSON.stringify(value)); } catch (e) {}
};
const clearDraft = uid => {
  try { localStorage.removeItem(DRAFT_KEY(uid)); } catch (e) {}
};

// Same shape in, same string out — both sides come from this component, so
// key order is stable enough to use as a dirty check.
const sameProfile = (a, b) => JSON.stringify(a || null) === JSON.stringify(b || null);

function ProfileView({ profile, onSave, saving, marketP50, uid, comp, onImported }) {
  const saved = profile || EMPTY_PROFILE;
  // A draft only counts if it actually differs from what's stored — otherwise
  // a stale key would show "unsaved changes" over an identical form.
  const initialDraft = readDraft(uid);
  const [p, setP] = useState(
    initialDraft && !sameProfile(initialDraft, saved) ? initialDraft : saved);
  const [restored, setRestored] = useState(
    !!(initialDraft && !sameProfile(initialDraft, saved)));

  const dirty = !sameProfile(p, saved);
  // The effect below fires on a prop change, before the render that would
  // recompute `dirty` — so it reads the value as of now, not last render.
  const dirtyRef = useRef(dirty);
  dirtyRef.current = dirty;

  // Adopt the stored profile when it changes underneath us — but never over
  // the top of unsaved edits, which is how they got lost in the first place.
  useEffect(() => {
    if (profile && !dirtyRef.current) setP(profile);
  }, [profile]);

  useEffect(() => {
    if (dirty) writeDraft(uid, p);
    else { clearDraft(uid); setRestored(false); }
  }, [p, dirty, uid]);

  // Belt to the localStorage braces: a reload is survivable now, but say so
  // before it happens rather than after.
  useEffect(() => {
    if (!dirty) return;
    const onBeforeUnload = e => { e.preventDefault(); e.returnValue = ""; };
    window.addEventListener("beforeunload", onBeforeUnload);
    return () => window.removeEventListener("beforeunload", onBeforeUnload);
  }, [dirty]);

  const save = useCallback(async () => {
    const stored = await onSave(p);
    // Only a confirmed write clears the draft — a failed save must keep the
    // work, which is the entire point of having one.
    if (stored) {
      setP(stored);
      clearDraft(uid);
      setRestored(false);
    }
  }, [onSave, p, uid]);

  const discardDraft = useCallback(() => {
    clearDraft(uid);
    setRestored(false);
    setP(saved);
  }, [uid, saved]);

  const set = (k, v) => setP(s => ({ ...s, [k]: v }));
  const setIn = (group, k, v) => setP(s => ({ ...s, [group]: { ...(s[group] || {}), [k]: v } }));
  const toggleIndustry = key => setP(s => {
    const cur = s.industries || [];
    return { ...s, industries: cur.includes(key) ? cur.filter(x => x !== key) : cur.concat(key) };
  });

  const companies = p.targetCompanies || [];
  const setCompany = (i, patch) => setP(s => {
    const next = (s.targetCompanies || []).slice();
    next[i] = { ...next[i], ...patch };
    return { ...s, targetCompanies: next };
  });

  // ── Suggestions ────────────────────────────────────────────────────────────
  // suggestEmployers proposes employers in the same vein as the ones already
  // targeted and verifies each against its real job board, so what lands here
  // is already known to resolve. It reads the SAVED profile, so anything typed
  // and not yet saved is invisible to it — the dedupe below covers that case,
  // dropping a suggestion that duplicates an unsaved row.
  const [suggesting, setSuggesting] = useState(false);
  const [suggestions, setSuggestions] = useState(null);
  const [picked, setPicked] = useState({});
  const [suggestError, setSuggestError] = useState(null);

  const slugKey = c => `${(c.ats || "").toLowerCase()}:${(c.slug || "").toLowerCase()}`;

  const suggest = useCallback(async () => {
    setSuggesting(true);
    setSuggestError(null);
    try {
      const res = await callFn("suggestEmployers", { count: 40 });
      setSuggestions(res);
      const all = {};
      for (const v of res.verified || []) all[slugKey(v)] = true;
      setPicked(all);
    } catch (e) {
      setSuggestError(e.message || "Suggestion failed");
      setSuggestions(null);
    } finally {
      setSuggesting(false);
    }
  }, []);

  const addPicked = useCallback(() => {
    const chosen = ((suggestions && suggestions.verified) || []).filter(v => picked[slugKey(v)]);
    setP(s => {
      const existing = s.targetCompanies || [];
      const haveSlug = new Set(existing.map(slugKey));
      const haveName = new Set(existing.map(c => (c.name || "").trim().toLowerCase()));
      const additions = chosen
        .filter(v => !haveSlug.has(slugKey(v)) && !haveName.has(v.name.trim().toLowerCase()))
        .map(v => ({ name: v.name, ats: v.ats, slug: v.slug, tier: v.tier }));
      return { ...s, targetCompanies: existing.concat(additions) };
    });
    setSuggestions(null);
    setPicked({});
  }, [suggestions, picked]);

  const pickedCount = Object.values(picked).filter(Boolean).length;

  // ── Checking what's already on the list ────────────────────────────────────
  // Suggestions are verified before they land, so those rows were good on the
  // day. Nothing else was: the starter list, rows typed off a careers URL, and
  // rows that were right until the employer moved ATS or renamed a Workday
  // site. That last case is the dangerous one — the scan skips what it can't
  // resolve, so a dead row reads as an employer with nothing open, and the
  // list keeps its length while quietly covering less of the market.
  const [checking, setChecking] = useState(false);
  const [checks, setChecks] = useState(null);
  const [checkError, setCheckError] = useState(null);

  const check = useCallback(async () => {
    setChecking(true);
    setCheckError(null);
    try {
      // The draft, not the stored profile — a row being typed right now is
      // exactly the row most likely to be wrong.
      const companiesAtRun = p.targetCompanies || [];
      const res = await callFn("checkEmployers", { companies: companiesAtRun });
      setChecks({ ...res, signature: JSON.stringify(companiesAtRun) });
    } catch (e) {
      setCheckError(e.message || "Check failed");
      setChecks(null);
    } finally {
      setChecking(false);
    }
  }, [p.targetCompanies]);

  // A result describes the list as it was when the button was pressed. Edit
  // the list and the row statuses are pinned to rows that may have moved, so
  // they drop — a status against the wrong employer is worse than none. The
  // summary stays but says it's stale, because deleting it on the first
  // keystroke of a fix would take away the count they're working through.
  const checkStale = !!checks && checks.signature !== JSON.stringify(p.targetCompanies || []);
  const checkByIndex = useMemo(() => {
    if (!checks || checkStale) return {};
    const out = {};
    for (const r of checks.results || []) out[r.index] = r;
    return out;
  }, [checks, checkStale]);

  const CHECK_TONE = { ok: "#10b981", broken: "#ef4444", incomplete: "#f59e0b", unknown: "#64748b" };

  // ── Compensation ───────────────────────────────────────────────────────────
  // Three bare number inputs sitting next to fifteen years of pay history is a
  // form asking a question it can already answer. This works the range out
  // instead and says it in sentences: what they earn, what to aim at, and how
  // far below today's base to search — which is a different number, because a
  // posted band is BASE salary and negotiates upward from what it says.
  const [widenPct, setWidenPct] = useState(10);
  const [manualComp, setManualComp] = useState(false);

  // One input shape for the card, the autofill panel and the dial, so the
  // three cannot end up quoting different numbers at each other.
  const compInput = useCallback(extra => Object.assign({
    compensation: (comp || {}).raw,
    comparable: (comp || {}).comparable,
    latestComplete: (comp || {}).latestComplete,
    currentYear: (comp || {}).currentYear,
    baseShares: (comp || {}).baseShares,
    cagrPct: (comp || {}).cagrPct,
    ladder: window.COMP_LADDER,
    // The one figure only the athlete knows: the package they're actually
    // after. Everything else on this card is arithmetic on their history.
    targetTC: { min: p.compTargetTC, max: p.compTargetTCMax },
    marketP50,
    widenPct,
    nowYear: new Date().getFullYear(),
  }, extra || {}), [comp, marketP50, widenPct, p.compTargetTC, p.compTargetTCMax]);

  const compR = useMemo(
    () => (comp && window.ProfileAutofill ? window.ProfileAutofill.compRange(compInput()) : null),
    [comp, compInput]);

  // Everything derivable from data already in the Hub — years of experience
  // from the compensation history, the comp figures from the last complete
  // year and the athlete's own ladder. Suggestions with provenance: the
  // reason sits next to the number, because a profile that fills itself
  // silently is one nobody trusts.
  const auto = useMemo(
    () => (comp && window.ProfileAutofill ? window.ProfileAutofill.autofill(compInput()) : null),
    [comp, compInput]);
  const autoPending = auto ? window.ProfileAutofill.pending(p, auto.fields) : [];

  // Seed the draft once, and only when all three are still blank — any value
  // already there is a decision, and decisions don't get overwritten by a
  // page load. Moving the dial below IS a decision, so that one does write.
  const seededComp = useRef(false);
  useEffect(() => {
    if (seededComp.current || !compR) return;
    seededComp.current = true;
    if (window.ProfileAutofill.pending(p, compR.fields).length === 3) {
      setP(st => window.ProfileAutofill.applyTo(st, compR.fields));
    }
  }, [compR]); // eslint-disable-line react-hooks/exhaustive-deps

  const widen = useCallback(pct => {
    setWidenPct(pct);
    const r = window.ProfileAutofill.compRange(compInput({ widenPct: pct }));
    if (r) setP(st => window.ProfileAutofill.applyTo(st, r.fields, { overwrite: true }));
  }, [compInput]);

  // Changing the ambition moves the target, so it has to write through — a
  // stated goal the search then ignores is worse than no goal at all.
  const setTargetTC = useCallback((key, value) => {
    setP(st => {
      const next = { ...st, [key]: value };
      const r = window.ProfileAutofill.compRange(Object.assign(compInput(), {
        targetTC: { min: next.compTargetTC, max: next.compTargetTCMax },
      }));
      return r ? { ...next, compTarget: r.fields.compTarget } : next;
    });
  }, [compInput]);

  // Titles, level, years and skills are all facts a résumé states. If none of
  // them are set, this page is asking for data entry it can do itself — so it
  // leads with the import rather than four empty inputs.
  const unfilled = !(p.targetTitles || []).length && !(p.skills || []).length
    && !String(p.seniorityLevel || "").trim();

  return (
    <div style={{ display: "grid", gap: 16 }}>
      {unfilled && onImported && (
        <React.Fragment>
          <div className="card-sm" style={{ borderColor: "#10b981" }}>
            <div style={{ fontSize: 12, color: "#e2e8f0", lineHeight: 1.7 }}>
              <strong>Don't fill this in by hand.</strong> Target titles, your level, years of
              experience and skills are all on your résumé — import it below and they're set
              for you. Compensation is worked out from your pay history further down. What
              stays yours: sectors, commute and relocation are decisions, not facts, so
              nothing guesses them.
            </div>
          </div>
          <ResumeImport onImported={onImported} />
        </React.Fragment>
      )}

      {autoPending.length > 0 && (
        <div className="card-sm" style={{ borderColor: "#10b981" }}>
          <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 16, flexWrap: "wrap" }}>
            <div style={{ fontSize: 11, color: "#94a3b8", lineHeight: 1.7, flex: "1 1 420px" }}>
              <strong style={{ color: "#e2e8f0" }}>
                {autoPending.length} field{autoPending.length === 1 ? "" : "s"} can come from data you've already entered.
              </strong>
              {auto.reasons.filter(r => autoPending.includes(r.field)).map(r => (
                <div key={r.field} style={{ marginTop: 6 }}>
                  <span style={{ color: "#10b981" }}>
                    {({ yearsExperience: "Years experience", compFloor: "Comp floor",
                        compHardFloor: "Hard floor", compTarget: "Target" })[r.field] || r.field}
                    {": "}
                    {r.field === "yearsExperience" ? r.value : money(r.value)}
                  </span>
                  {" — "}{r.why}
                </div>
              ))}
            </div>
            <button className="btn-primary" style={{ fontSize: 11, padding: "6px 14px", whiteSpace: "nowrap" }}
              onClick={() => setP(st => window.ProfileAutofill.applyTo(st, auto.fields))}>
              Use all {autoPending.length}
            </button>
          </div>
        </div>
      )}

      <div className="card">
        <div className="section-header">What you're looking for</div>
        <Row>
          <Field label="Target titles" hint="Comma separated. Grading matches on these, ignoring seniority words.">
            <input value={list(p.targetTitles)} onChange={e => set("targetTitles", parseList(e.target.value))}
              placeholder="Product Manager, Director of Product" />
          </Field>
          <Field label="Your level" width="180px" hint="Bank or tech title — both ladders are understood.">
            <input value={p.seniorityLevel || ""} onChange={e => set("seniorityLevel", e.target.value)}
              placeholder="Vice President" />
          </Field>
          <Field label="Years experience" width="140px">
            <input type="number" value={p.yearsExperience ?? ""} onChange={e => set("yearsExperience", e.target.value)} />
          </Field>
        </Row>
        <Row>
          <Field label="Your skills" hint="Until the résumé parser lands, list them here — grading scores JD requirements against this.">
            <input value={list(p.skills)} onChange={e => set("skills", parseList(e.target.value))}
              placeholder="product management, sql, payments, agile" />
          </Field>
        </Row>
        <Row>
          <Field label="Sectors" width="260px" hint="Anything outside these scores 30 instead of 100.">
            <div style={{ display: "flex", gap: 8, marginTop: 2 }}>
              {[["tech", "Tech"], ["financial", "Financial institutions"]].map(([key, lbl]) => {
                const on = (p.industries || []).includes(key);
                return (
                  <button key={key} onClick={() => toggleIndustry(key)}
                    className={on ? "btn-primary" : "btn-secondary"} style={{ fontSize: 11, padding: "6px 12px" }}>
                    {lbl}
                  </button>
                );
              })}
            </div>
          </Field>
          <Field label="Remote preference" width="150px">
            <select value={p.remotePreference || "hybrid"} onChange={e => set("remotePreference", e.target.value)}>
              <option value="remote">Remote</option>
              <option value="hybrid">Hybrid</option>
              <option value="onsite">On site</option>
            </select>
          </Field>
          <Field label="Would relocate?" width="150px">
            <select value={p.relocation ? "yes" : "no"} onChange={e => set("relocation", e.target.value === "yes")}>
              <option value="no">No</option>
              <option value="yes">Yes</option>
            </select>
          </Field>
        </Row>
      </div>

      <div className="card">
        <div className="section-header">Location &amp; commute</div>
        <div style={{ fontSize: 11, color: "#64748b", marginBottom: 12, lineHeight: 1.5 }}>
          Commute is graded on <strong style={{ color: "#94a3b8" }}>weekly hours in the car</strong>, not minutes —
          60 minutes twice a week beats 30 minutes five days a week. Coordinates are optional but make the
          estimate real rather than a same-metro guess.
        </div>
        <Row>
          <Field label="Home city" width="160px">
            <input value={p.home?.city || ""} onChange={e => setIn("home", "city", e.target.value)} placeholder="Wilmington" />
          </Field>
          <Field label="State" width="90px">
            <input value={p.home?.state || ""} onChange={e => setIn("home", "state", e.target.value)} placeholder="DE" />
          </Field>
          <Field label="Latitude" width="130px">
            <input value={p.home?.lat ?? ""} onChange={e => setIn("home", "lat", e.target.value)} placeholder="39.7459" />
          </Field>
          <Field label="Longitude" width="130px">
            <input value={p.home?.lng ?? ""} onChange={e => setIn("home", "lng", e.target.value)} placeholder="-75.5466" />
          </Field>
        </Row>
        <Row>
          <Field label="Max minutes each way" width="190px">
            <input type="number" value={p.commute?.maxMinutesOneWay ?? 45}
              onChange={e => setIn("commute", "maxMinutesOneWay", e.target.value)} />
          </Field>
          <Field label="Max days on site / week" width="190px"
            hint={`Tolerance = ${Math.round(((Number(p.commute?.maxMinutesOneWay) || 45) * 2 * (Number(p.commute?.maxDaysPerWeekOnsite) || 3)) / 60 * 10) / 10}h/week in the car.`}>
            <input type="number" min="0" max="5" value={p.commute?.maxDaysPerWeekOnsite ?? 3}
              onChange={e => setIn("commute", "maxDaysPerWeekOnsite", e.target.value)} />
          </Field>
        </Row>
      </div>

      <div className="card">
        <div className="section-header">Compensation</div>

        {compR ? (
          <React.Fragment>
            <div style={{ fontSize: 13, color: "#e2e8f0", lineHeight: 1.7, marginBottom: 14 }}>
              {compR.summary}
              {compR.inProgress && (
                <div style={{ fontSize: 12, color: "#94a3b8", marginTop: 4 }}>{compR.inProgress}</div>
              )}
              <div style={{ fontSize: 11, color: "#64748b", marginTop: 6, lineHeight: 1.6 }}>{compR.basis}</div>
            </div>

            <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(150px, 1fr))", gap: 10, marginBottom: 14 }}>
              {[
                ["Searching from", money(compR.fields.compHardFloor),
                  compR.widenPct > 0 ? `${compR.widenPct}% under your base` : "your base exactly", "#64748b"],
                ["Anchored at", money(compR.fields.compFloor), `your ${compR.anchorYear} base`, "#e2e8f0"],
                ["Target base", compR.targetBaseMax > compR.targetBaseMin
                  ? `${money(compR.targetBaseMin)}–${money(compR.targetBaseMax)}`
                  : money(compR.targetBaseMin),
                  `for ${money(compR.targetTC)}${compR.targetTCMax > compR.targetTC ? `–${money(compR.targetTCMax)}` : ""} all in`, "#10b981"],
              ].map(([label, value, sub, tone]) => (
                <div key={label} className="card-sm" style={{ padding: "10px 12px" }}>
                  <div className="label" style={{ marginBottom: 2 }}>{label}</div>
                  <div className="val-md" style={{ color: tone }}>{value}</div>
                  <div style={{ fontSize: 10, color: "#64748b", marginTop: 2 }}>{sub}</div>
                </div>
              ))}
            </div>

            <div style={{ fontSize: 11, color: "#94a3b8", lineHeight: 1.7 }}>
              {compR.aim}
              <div style={{ marginTop: 4, color: "#e2e8f0" }}>{compR.gap}</div>
              <div style={{ marginTop: 4 }}>{compR.widened}</div>
              {compR.note && <div style={{ marginTop: 4, color: "#f59e0b" }}>{compR.note}</div>}
            </div>

            <Row>
              <Field label="Aiming for (package)" width="150px"
                hint="Total comp, mix irrelevant — the target base is worked out from it.">
                <input type="number" value={p.compTargetTC ?? ""} placeholder="375000"
                  onChange={e => setTargetTC("compTargetTC", e.target.value)} />
              </Field>
              <Field label="up to" width="150px" hint={compR.targetSource === "stated"
                ? "Your own number, not the comp ladder's."
                : "Leave blank and this follows your comp ladder instead."}>
                <input type="number" value={p.compTargetTCMax ?? ""} placeholder="400000"
                  onChange={e => setTargetTC("compTargetTCMax", e.target.value)} />
              </Field>
            </Row>

            <div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 14, flexWrap: "wrap" }}>
              <span style={{ fontSize: 11, color: "#64748b" }}>Cast the net</span>
              {[[0, "Tight"], [10, "Normal"], [20, "Wide"], [30, "Very wide"]].map(([pct, lbl]) => (
                <button key={pct} onClick={() => widen(pct)}
                  className={widenPct === pct ? "btn-primary" : "btn-secondary"}
                  style={{ fontSize: 11, padding: "5px 12px" }}>
                  {lbl}
                </button>
              ))}
              <span style={{ fontSize: 10, color: "#64748b" }}>
                Nothing under {money(compR.fields.compHardFloor)} reaches the board.
              </span>
            </div>

            <button className="btn-secondary" style={{ fontSize: 10, padding: "4px 10px", marginTop: 14 }}
              onClick={() => setManualComp(v => !v)}>
              {manualComp ? "Hide" : "Override these numbers"}
            </button>
          </React.Fragment>
        ) : (
          <div style={{ fontSize: 11, color: "#94a3b8", lineHeight: 1.6, marginBottom: 12 }}>
            {comp && comp.comparable.basis === "year-in-progress"
              ? `The only year on record is ${comp.comparable.year}, and it's still in progress — bonus and stock aren't paid yet, so there's no full package to anchor on. Add last year's compensation, or set these by hand:`
              : comp && comp.comparable.basis === "complete-year"
                ? `${comp.comparable.year} is on record but without a base salary, so there's nothing to anchor the search on. Add it in the Compensation section, or set these by hand:`
                : "No compensation history reached this page, so there's nothing to work these out from. Set them by hand:"}
          </div>
        )}

        {(manualComp || !compR) && (
          <Row>
            <Field label="Anchor" width="170px" hint="The grader scores a posted band's midpoint against this.">
              <input type="number" value={p.compFloor ?? ""} onChange={e => set("compFloor", e.target.value)} placeholder="200000" />
            </Field>
            <Field label="Search floor" width="170px" hint="A posting whose band CEILING is under this is vetoed outright.">
              <input type="number" value={p.compHardFloor ?? ""} onChange={e => set("compHardFloor", e.target.value)} placeholder="185000" />
            </Field>
            <Field label="Target" width="170px" hint="Base salary, not package.">
              <input type="number" value={p.compTarget ?? ""} onChange={e => set("compTarget", e.target.value)} placeholder="260000" />
            </Field>
            {marketP50 != null && (
              <Field label="Market P50" width="170px" hint="From your weekly market-rate refresh — for reference.">
                <div className="val-md" style={{ paddingTop: 4 }}>{money(marketP50)}</div>
              </Field>
            )}
          </Row>
        )}
      </div>

      <div className="card">
        <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
          <div className="section-header" style={{ marginBottom: 0 }}>Target employers</div>
          <div style={{ display: "flex", gap: 6 }}>
            {companies.length > 0 && (
              <button className="btn-secondary" style={{ fontSize: 11, padding: "4px 10px" }}
                onClick={check} disabled={checking}>
                {checking ? <span className="spinner" style={{ width: 12, height: 12 }} /> : "Check all"}
              </button>
            )}
            <button className="btn-secondary" style={{ fontSize: 11, padding: "4px 10px" }}
              onClick={suggest} disabled={suggesting}>
              {suggesting ? <span className="spinner" style={{ width: 12, height: 12 }} /> : "Suggest more"}
            </button>
            <button className="btn-secondary" style={{ fontSize: 11, padding: "4px 10px" }}
              onClick={() => set("targetCompanies", companies.concat({ name: "", ats: "greenhouse", slug: "", tier: 1 }))}>
              + Add
            </button>
          </div>
        </div>

        {suggesting && (
          <div style={{ fontSize: 11, color: "#64748b", marginBottom: 12, lineHeight: 1.5 }}>
            Reading each suggested employer's own job board to confirm the slug resolves before
            offering it. Takes a minute — nothing unverified gets added.
          </div>
        )}

        {suggestError && (
          <div className="card-sm" style={{ borderColor: "#ef4444", marginBottom: 12, fontSize: 11, color: "#ef4444" }}>
            {suggestError}
          </div>
        )}

        {suggestions && (
          <div className="card-sm" style={{ marginBottom: 12 }}>
            {(suggestions.verified || []).length === 0 ? (
              <div style={{ fontSize: 12, color: "#94a3b8", lineHeight: 1.6 }}>
                {suggestions.message
                  || `Nothing survived verification — ${(suggestions.rejected || []).length} suggestion(s) had no reachable board.`}
                <div style={{ marginTop: 10 }}>
                  <button className="btn-secondary" style={{ fontSize: 11, padding: "4px 10px" }}
                    onClick={() => setSuggestions(null)}>Dismiss</button>
                </div>
              </div>
            ) : (
              <React.Fragment>
                <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12, flexWrap: "wrap", marginBottom: 10 }}>
                  <div style={{ fontSize: 11, color: "#64748b" }}>
                    <strong style={{ color: "#10b981" }}>{(suggestions.verified || []).length} verified</strong>
                    {" "}of {suggestions.suggested} suggested — each one's board answered.
                    {(suggestions.rejected || []).length > 0 &&
                      ` ${(suggestions.rejected || []).length} dropped as unreachable.`}
                  </div>
                  <div style={{ display: "flex", gap: 6 }}>
                    <button className="btn-secondary" style={{ fontSize: 11, padding: "4px 10px" }}
                      onClick={() => setSuggestions(null)}>Cancel</button>
                    <button className="btn-primary" style={{ fontSize: 11, padding: "4px 12px" }}
                      onClick={addPicked} disabled={pickedCount === 0}>
                      Add {pickedCount}
                    </button>
                  </div>
                </div>
                <div style={{ maxHeight: 360, overflowY: "auto", display: "grid", gap: 6 }}>
                  {(suggestions.verified || []).map(v => {
                    const key = slugKey(v);
                    return (
                      <label key={key} style={{ display: "flex", gap: 10, alignItems: "flex-start", cursor: "pointer", padding: "6px 8px", borderRadius: 6, background: picked[key] ? "#161b22" : "transparent" }}>
                        <input type="checkbox" checked={!!picked[key]} style={{ width: "auto", marginTop: 3 }}
                          onChange={e => setPicked(s => ({ ...s, [key]: e.target.checked }))} />
                        <div style={{ flex: 1, minWidth: 0 }}>
                          <div style={{ display: "flex", gap: 8, alignItems: "baseline", flexWrap: "wrap" }}>
                            <strong style={{ fontSize: 12 }}>{v.name}</strong>
                            <span className="tag tag-blue">tier {v.tier}</span>
                            <span style={{ fontSize: 11, color: "#64748b" }}>{v.ats}:{v.slug}</span>
                            <span style={{ fontSize: 11, color: v.openRoles > 0 ? "#10b981" : "#64748b" }}>
                              {v.openRoles > 0 ? `${v.openRoles} open` : "board live, nothing open"}
                            </span>
                          </div>
                          {v.why && <div style={{ fontSize: 11, color: "#94a3b8", marginTop: 2 }}>{v.why}</div>}
                        </div>
                      </label>
                    );
                  })}
                </div>
                <div style={{ fontSize: 11, color: "#475569", marginTop: 10 }}>
                  Adding puts them in the list below — they aren't stored until you hit Save profile.
                </div>
              </React.Fragment>
            )}
          </div>
        )}
        {(checkError || checks) && (
          <div className="card-sm" style={{ marginBottom: 12, borderColor: checkError || (checks && checks.summary.broken + checks.summary.incomplete > 0) ? "#f59e0b" : "#10b981" }}>
            <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
              <div style={{ fontSize: 11, color: checkError ? "#ef4444" : "#e2e8f0", lineHeight: 1.6 }}>
                {checkError || checks.summary.text}
                {!checkError && checkStale && (
                  <div style={{ color: "#f59e0b", marginTop: 4 }}>
                    You've edited the list since — run it again to see where it stands.
                  </div>
                )}
                {!checkError && !checkStale && checks.summary.broken + checks.summary.incomplete > 0 && (
                  <div style={{ color: "#94a3b8", marginTop: 4 }}>
                    Each one is flagged on its row below. A row the scan can't resolve is skipped
                    silently — it reads as an employer with nothing open.
                  </div>
                )}
              </div>
              <button className="btn-secondary" style={{ fontSize: 11, padding: "4px 10px" }}
                onClick={() => { setChecks(null); setCheckError(null); }}>Dismiss</button>
            </div>
          </div>
        )}
        <div style={{ fontSize: 11, color: "#64748b", marginBottom: 12, lineHeight: 1.5 }}>
          These drive the scan — each employer's own ATS is better data than any aggregator: full job text,
          exact titles, no reposted noise. Tier 1 scores 100 on the company dimension, tier 2 scores 88,
          everyone else 55. Workday needs the three values from the careers URL, e.g.
          {" "}<code style={{ color: "#94a3b8" }}>wd1.myworkdaysite.com/en-US/recruiting/<strong>wf</strong>/<strong>WellsFargoJobs</strong></code>.
        </div>
        {companies.length === 0 && (
          <div style={{ fontSize: 12, color: "#475569", lineHeight: 1.6, marginBottom: 12 }}>
            None yet. Start from the seed list, then prune it once the board shows you who's actually hiring.
            <div style={{ marginTop: 10 }}>
              <button className="btn-secondary" style={{ fontSize: 11, padding: "5px 12px" }}
                onClick={() => set("targetCompanies", STARTER_EMPLOYERS.slice())}>
                Add {STARTER_EMPLOYERS.length} starter employers
              </button>
            </div>
          </div>
        )}
        {companies.map((c, i) => (
          <React.Fragment key={i}>
          <Row gap={8}>
            <Field label={i === 0 ? "Company" : ""}>
              <input value={c.name || ""} onChange={e => setCompany(i, { name: e.target.value })} placeholder="Wells Fargo" />
            </Field>
            <Field label={i === 0 ? "ATS" : ""} width="130px">
              <select value={c.ats || "greenhouse"} onChange={e => setCompany(i, { ats: e.target.value })}>
                <option value="greenhouse">Greenhouse</option>
                <option value="lever">Lever</option>
                <option value="ashby">Ashby</option>
                <option value="workday">Workday</option>
              </select>
            </Field>
            {c.ats === "workday" ? (
              <React.Fragment>
                <Field label={i === 0 ? "Host" : ""} width="210px">
                  <input value={c.host || ""} onChange={e => setCompany(i, { host: e.target.value })}
                    placeholder="wd1.myworkdaysite.com" />
                </Field>
                <Field label={i === 0 ? "Tenant" : ""} width="110px">
                  <input value={c.tenant || ""} onChange={e => setCompany(i, { tenant: e.target.value })} placeholder="wf" />
                </Field>
                <Field label={i === 0 ? "Site" : ""} width="160px">
                  <input value={c.site || ""} onChange={e => setCompany(i, { site: e.target.value })} placeholder="WellsFargoJobs" />
                </Field>
              </React.Fragment>
            ) : (
              <Field label={i === 0 ? "Board slug" : ""} width="150px">
                <input value={c.slug || ""} onChange={e => setCompany(i, { slug: e.target.value })} placeholder="stripe" />
              </Field>
            )}
            <Field label={i === 0 ? "Tier" : ""} width="80px">
              <select value={c.tier || 1} onChange={e => setCompany(i, { tier: Number(e.target.value) })}>
                <option value={1}>1</option>
                <option value={2}>2</option>
                <option value={3}>3</option>
              </select>
            </Field>
            <div style={{ display: "flex", alignItems: "flex-end", paddingBottom: 2 }}>
              <button className="btn-danger" style={{ fontSize: 11, padding: "6px 10px" }}
                onClick={() => set("targetCompanies", companies.filter((_, j) => j !== i))}>Remove</button>
            </div>
          </Row>
          {checkByIndex[i] && (
            <div style={{ fontSize: 11, color: CHECK_TONE[checkByIndex[i].status] || "#64748b", margin: "-6px 0 10px 2px", lineHeight: 1.5 }}>
              {checkByIndex[i].status === "ok"
                ? `Board answers — ${checkByIndex[i].openRoles > 0
                    ? `${checkByIndex[i].openRoles} open now`
                    : "nothing open today, which is fine; the scan runs four times a day"}.`
                : checkByIndex[i].reason}
            </div>
          )}
          </React.Fragment>
        ))}
        {companies.length > 0 && (
          <button className="btn-secondary" style={{ fontSize: 11, padding: "5px 12px" }}
            onClick={() => set("targetCompanies", companies.concat({ name: "", ats: "greenhouse", slug: "", tier: 2 }))}>
            + Add another
          </button>
        )}
      </div>

      <div className="card">
        <div className="section-header">Dealbreakers</div>
        <Row>
          <Field label="Never these employers" hint="Comma separated. Any match is vetoed to grade D.">
            <input value={list(p.dealbreakers?.excludeCompanies)}
              onChange={e => setIn("dealbreakers", "excludeCompanies", parseList(e.target.value))} />
          </Field>
        </Row>
      </div>

      <div style={{ display: "flex", justifyContent: "flex-end", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
        {dirty && (
          <div style={{ fontSize: 11, color: "#f59e0b", display: "flex", alignItems: "center", gap: 10 }}>
            <span>
              {restored
                ? "Unsaved changes, kept from your last visit — nothing is stored until you save."
                : "Unsaved changes — nothing is stored until you save."}
            </span>
            <button className="btn-secondary" style={{ fontSize: 11, padding: "3px 10px" }}
              onClick={discardDraft}>Discard</button>
          </div>
        )}
        <button className="btn-primary" onClick={save} disabled={saving}>
          {saving ? <span className="spinner" /> : dirty ? "Save profile •" : "Save profile"}
        </button>
      </div>
    </div>
  );
}

// ── Pipeline ─────────────────────────────────────────────────────────────────

const BLANK_APP = () => ({
  company: "", title: "", url: "", sector: "financial", referral: false,
  salaryPosted: true, resumeVariant: "", grade: "", levelDelta: "0",
  companySize: "", appliedOn: todayISO(), postedOn: "",
});

function AddApplication({ onAdd, onCancel }) {
  const [f, setF] = useState(BLANK_APP());
  const set = (k, v) => setF(s => ({ ...s, [k]: v }));
  const valid = f.company.trim() && f.title.trim();

  return (
    <div className="card" style={{ borderColor: "#10b98155" }}>
      <div className="section-header">Log an application</div>
      <div style={{ fontSize: 11, color: "#64748b", marginBottom: 12, lineHeight: 1.5 }}>
        Every field below is something the calibration can learn from later — how you got in, which résumé
        went out, how fast you applied. Skip what you don't know; blanks are excluded from the analysis
        rather than guessed.
      </div>
      <Row>
        <Field label="Company"><input value={f.company} onChange={e => set("company", e.target.value)} autoFocus /></Field>
        <Field label="Title"><input value={f.title} onChange={e => set("title", e.target.value)} /></Field>
      </Row>
      <Row>
        <Field label="Posting URL"><input value={f.url} onChange={e => set("url", e.target.value)} placeholder="https://" /></Field>
      </Row>
      <Row>
        <Field label="Sector" width="150px">
          <select value={f.sector} onChange={e => set("sector", e.target.value)}>
            <option value="financial">Financial</option>
            <option value="tech">Tech</option>
            <option value="other">Other</option>
          </select>
        </Field>
        <Field label="Route in" width="150px">
          <select value={f.referral ? "referred" : "cold"} onChange={e => set("referral", e.target.value === "referred")}>
            <option value="cold">Cold apply</option>
            <option value="referred">Referred</option>
          </select>
        </Field>
        <Field label="Level move" width="160px">
          <select value={f.levelDelta} onChange={e => set("levelDelta", e.target.value)}>
            <option value="-1">Below my level</option>
            <option value="0">Lateral</option>
            <option value="1">One rung up</option>
            <option value="2">Two+ rungs up</option>
          </select>
        </Field>
        <Field label="Salary band" width="150px">
          <select value={f.salaryPosted ? "posted" : "undisclosed"} onChange={e => set("salaryPosted", e.target.value === "posted")}>
            <option value="posted">Posted</option>
            <option value="undisclosed">Undisclosed</option>
          </select>
        </Field>
      </Row>
      <Row>
        <Field label="Résumé variant" width="180px" hint="Name it, so you can find out which story converts.">
          <input value={f.resumeVariant} onChange={e => set("resumeVariant", e.target.value)} placeholder="banking-v2" />
        </Field>
        <Field label="Fit grade" width="120px" hint="From the board, if it came from there.">
          <select value={f.grade} onChange={e => set("grade", e.target.value)}>
            <option value="">—</option>
            {["A", "B", "C", "D"].map(g => <option key={g} value={g}>{g}</option>)}
          </select>
        </Field>
        <Field label="Applied on" width="160px">
          <input type="date" value={f.appliedOn} onChange={e => set("appliedOn", e.target.value)} />
        </Field>
        <Field label="Posting went up" width="160px" hint="Optional — measures whether speed matters.">
          <input type="date" value={f.postedOn} onChange={e => set("postedOn", e.target.value)} />
        </Field>
      </Row>
      <div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}>
        <button className="btn-secondary" onClick={onCancel}>Cancel</button>
        <button className="btn-primary" disabled={!valid} onClick={() => onAdd(f)}>Add to pipeline</button>
      </div>
    </div>
  );
}

function OutcomeForm({ app, onSave, onCancel }) {
  const [outcome, setOutcome] = useState("rejected");
  const [reasons, setReasons] = useState([]);
  const [text, setText] = useState("");
  const toggle = r => setReasons(s => (s.includes(r) ? s.filter(x => x !== r) : s.concat(r)));

  return (
    <div style={{ padding: "12px 0 4px", borderTop: "1px solid #1e2a3a", marginTop: 10 }}>
      <Row gap={8}>
        <Field label="Outcome" width="160px">
          <select value={outcome} onChange={e => setOutcome(e.target.value)}>
            {CareerFunnel.OUTCOMES.map(o => <option key={o} value={o}>{o}</option>)}
          </select>
        </Field>
      </Row>
      <div className="label" style={{ marginBottom: 6 }}>Why (tag it — free text doesn't aggregate)</div>
      <div style={{ display: "flex", flexWrap: "wrap", gap: 6, marginBottom: 12 }}>
        {CareerFunnel.FEEDBACK_REASONS.map(r => {
          const on = reasons.includes(r);
          return (
            <button key={r} onClick={() => toggle(r)}
              style={{
                fontSize: 11, padding: "4px 10px", borderRadius: 6,
                background: on ? "rgba(16,185,129,0.15)" : "transparent",
                border: `1px solid ${on ? "#10b981" : "#1e2a3a"}`,
                color: on ? "#10b981" : "#64748b",
              }}>{r}</button>
          );
        })}
      </div>
      <Row gap={8}>
        <Field label="Anything they actually said">
          <input value={text} onChange={e => setText(e.target.value)} placeholder="Optional — the tags are what get analysed" />
        </Field>
      </Row>
      <div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}>
        <button className="btn-secondary" onClick={onCancel}>Cancel</button>
        <button className="btn-primary" onClick={() => onSave({ outcome, reasons, text })}>Record outcome</button>
      </div>
    </div>
  );
}

function ApplicationRow({ app, onAdvance, onOutcome, onFollowUp, onFeedbackRequested }) {
  const [open, setOpen] = useState(false);
  const [recording, setRecording] = useState(false);
  const stage = CareerFunnel.peakStage(app);
  const closed = CareerFunnel.isClosed(app);
  const stageMap = CareerFunnel.stageMap(app);
  const idx = CareerFunnel.STAGES.findIndex(s => s.key === stage);
  const next = CareerFunnel.STAGES[idx + 1];
  const quiet = CareerFunnel.lastActivityAt(app);
  const quietDays = quiet ? Math.round((Date.now() - quiet) / 86400000) : null;

  return (
    <div className="card-sm" style={{ marginBottom: 8, opacity: closed ? 0.65 : 1 }}>
      <div style={{ display: "flex", alignItems: "center", gap: 12, cursor: "pointer" }} onClick={() => setOpen(o => !o)}>
        <GradeBadge grade={app.grade} size={32} />
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{ fontWeight: 600, fontSize: 13, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
            {app.title} <span style={{ color: "#64748b", fontWeight: 400 }}>· {app.company}</span>
          </div>
          <div style={{ fontSize: 11, color: "#64748b", marginTop: 2 }}>
            {CareerFunnel.STAGE_BY_KEY[stage]?.label || stage}
            {app.referral && <span style={{ color: "#10b981" }}> · referred</span>}
            {quietDays != null && !closed && <span> · quiet {quietDays}d</span>}
            {closed && <span> · {app.outcome}</span>}
          </div>
        </div>
        {!closed && next && (
          <button className="btn-secondary" style={{ fontSize: 11, padding: "5px 10px" }}
            onClick={e => { e.stopPropagation(); onAdvance(app, next.key); }}>
            → {next.label}
          </button>
        )}
      </div>

      {open && (
        <div style={{ marginTop: 12, paddingTop: 12, borderTop: "1px solid #1e2a3a" }}>
          <div style={{ display: "flex", flexWrap: "wrap", gap: 16, fontSize: 11, color: "#64748b", marginBottom: 12 }}>
            {CareerFunnel.STAGES.filter(s => stageMap[s.key]).map(s => (
              <span key={s.key}>{s.label}: <span style={{ color: "#94a3b8" }}>{day(stageMap[s.key])}</span></span>
            ))}
            {app.resumeVariant && <span>Résumé: <span style={{ color: "#94a3b8" }}>{app.resumeVariant}</span></span>}
            {(app.followUps || []).length > 0 && <span>Chased ×{app.followUps.length}</span>}
          </div>
          {app.feedback?.reasons?.length > 0 && (
            <div style={{ fontSize: 11, marginBottom: 12 }}>
              <span style={{ color: "#64748b" }}>Feedback: </span>
              {app.feedback.reasons.map(r => <span key={r} className="tag tag-blue" style={{ marginRight: 6 }}>{r}</span>)}
              {app.feedback.text && <div style={{ color: "#94a3b8", marginTop: 6 }}>“{app.feedback.text}”</div>}
            </div>
          )}
          {recording ? (
            <OutcomeForm app={app} onCancel={() => setRecording(false)}
              onSave={payload => { setRecording(false); onOutcome(app, payload); }} />
          ) : (
            <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
              {app.url && <a href={app.url} target="_blank" rel="noreferrer" className="btn-secondary"
                style={{ fontSize: 11, padding: "5px 10px", textDecoration: "none", display: "inline-block" }}>Posting ↗</a>}
              {!closed && <button className="btn-secondary" style={{ fontSize: 11, padding: "5px 10px" }}
                onClick={() => onFollowUp(app)}>Log a chase</button>}
              {!closed && <button className="btn-secondary" style={{ fontSize: 11, padding: "5px 10px" }}
                onClick={() => setRecording(true)}>Record outcome</button>}
              {closed && !app.feedback?.requestedAt && (
                <button className="btn-secondary" style={{ fontSize: 11, padding: "5px 10px" }}
                  onClick={() => onFeedbackRequested(app)}>Mark feedback asked</button>
              )}
            </div>
          )}
        </div>
      )}
    </div>
  );
}

function PipelineView({ apps, onAdd, onAdvance, onOutcome, onFollowUp, onFeedbackRequested }) {
  const [adding, setAdding] = useState(false);
  const [showClosed, setShowClosed] = useState(false);

  const plan = useMemo(() => CareerFunnel.followUpPlan(apps, Date.now()), [apps]);
  const asks = useMemo(() => CareerFunnel.feedbackRequests(apps, Date.now()), [apps]);

  const open = apps.filter(CareerFunnel.isOpen);
  const closed = apps.filter(CareerFunnel.isClosed);
  const byStage = CareerFunnel.STAGES.filter(s => s.key !== "draft").map(s => ({
    stage: s,
    apps: open.filter(a => CareerFunnel.peakStage(a) === s.key),
  })).filter(g => g.apps.length);

  const dueCount = plan.due.length + plan.stale.length + asks.length;

  return (
    <div style={{ display: "grid", gap: 16 }}>
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
        <div style={{ fontSize: 12, color: "#64748b" }}>
          {open.length} open · {closed.length} closed
        </div>
        {!adding && <button className="btn-primary" onClick={() => setAdding(true)}>+ Log an application</button>}
      </div>

      {adding && <AddApplication onCancel={() => setAdding(false)}
        onAdd={f => { setAdding(false); onAdd(f); }} />}

      {dueCount > 0 && (
        <div className="card" style={{ borderColor: "#f59e0b55" }}>
          <div className="section-header" style={{ color: "#f59e0b" }}>Due today ({dueCount})</div>
          {plan.due.map((d, i) => (
            <div key={"d" + i} style={{ fontSize: 12, padding: "6px 0", borderBottom: "1px solid #0d1117" }}>
              <strong>{d.company}</strong> — follow-up {d.followUpNumber} of {d.of}, quiet {d.quietDays}d.
              <span style={{ color: "#64748b" }}> {d.suggestion}</span>
            </div>
          ))}
          {plan.stale.map((d, i) => (
            <div key={"s" + i} style={{ fontSize: 12, padding: "6px 0", borderBottom: "1px solid #0d1117" }}>
              <strong>{d.company}</strong> — <span style={{ color: "#ef4444" }}>gone quiet {d.quietDays}d</span>.
              <span style={{ color: "#64748b" }}> {d.suggestion}</span>
            </div>
          ))}
          {asks.map((a, i) => (
            <div key={"f" + i} style={{ fontSize: 12, padding: "6px 0", borderBottom: "1px solid #0d1117" }}>
              <strong>{a.company}</strong> — ask for feedback ({a.value} value, reached {a.stage}, {a.ageDays}d ago).
              <span style={{ color: "#64748b" }}> {a.askVia}.</span>
            </div>
          ))}
        </div>
      )}

      {apps.length === 0 && !adding && (
        <Empty title="No applications logged yet">
          Log one and the pipeline starts working immediately — chases fall due on their own schedule,
          rejections queue a feedback ask inside the window where recruiters still answer, and once there
          are enough outcomes the Insights view will tell you what's actually converting.
        </Empty>
      )}

      {byStage.map(g => (
        <div key={g.stage.key}>
          <div className="section-header">{g.stage.label} ({g.apps.length})</div>
          {g.apps.map(a => (
            <ApplicationRow key={a.id} app={a} onAdvance={onAdvance} onOutcome={onOutcome}
              onFollowUp={onFollowUp} onFeedbackRequested={onFeedbackRequested} />
          ))}
        </div>
      ))}

      {closed.length > 0 && (
        <div>
          <div className="section-header" style={{ cursor: "pointer" }} onClick={() => setShowClosed(s => !s)}>
            {showClosed ? "▾" : "▸"} Closed ({closed.length})
          </div>
          {showClosed && closed.map(a => (
            <ApplicationRow key={a.id} app={a} onAdvance={onAdvance} onOutcome={onOutcome}
              onFollowUp={onFollowUp} onFeedbackRequested={onFeedbackRequested} />
          ))}
        </div>
      )}
    </div>
  );
}

// ── Insights ─────────────────────────────────────────────────────────────────

function SignalRow({ sig, muted }) {
  const up = sig.lift != null && sig.lift > 1;
  const color = muted ? "#475569" : up ? "#10b981" : "#ef4444";
  return (
    <tr style={{ opacity: muted ? 0.55 : 1 }}>
      <td style={{ color: "#94a3b8" }}>{sig.attributeLabel}</td>
      <td>{sig.value}</td>
      <td style={{ textAlign: "right", color }}>{sig.rate}%</td>
      <td style={{ textAlign: "right", color: "#64748b" }}>{sig.baselineRate}%</td>
      <td style={{ textAlign: "right", color }}>{sig.lift == null ? "—" : sig.lift + "×"}</td>
      <td style={{ textAlign: "right", color: "#64748b" }}>n={sig.n}</td>
      <td style={{ color: "#64748b", fontSize: 11 }}>{muted ? "too few to report" : sig.confidence}</td>
    </tr>
  );
}

function InsightsView({ apps }) {
  const [milestone, setMilestone] = useState("screening");
  const stats = useMemo(() => CareerFunnel.funnelStats(apps, Date.now()), [apps]);
  const cal = useMemo(() => CareerFunnel.calibrate(apps, { milestone }), [apps, milestone]);
  const themes = useMemo(() => CareerFunnel.feedbackThemes(apps), [apps]);

  if (!stats.applied) {
    return (
      <Empty title="Nothing to analyse yet">
        Once applications start closing out, this view measures what converts for you — referrals vs cold
        applies, lateral moves vs stretches, which résumé variant lands — each against your own baseline
        and always with the sample size attached.
      </Empty>
    );
  }

  return (
    <div style={{ display: "grid", gap: 16 }}>
      <div className="grid-4">
        <Stat label="Applied" value={stats.applied} />
        <Stat label="Response rate" value={stats.responseRate == null ? "—" : stats.responseRate + "%"}
          of={`of ${stats.applied}`} tone="#10b981" />
        <Stat label="Offer rate" value={stats.offerRate == null ? "—" : stats.offerRate + "%"} of={`of ${stats.applied}`} />
        <Stat label="Ghosted" value={stats.ghostRate == null ? "—" : stats.ghostRate + "%"}
          of={`of ${stats.applied}`} tone="#f59e0b" />
      </div>

      <div className="card">
        <div className="section-header">The funnel</div>
        <table>
          <thead>
            <tr>
              <th>Stage</th>
              <th style={{ textAlign: "right" }}>Reached</th>
              <th style={{ textAlign: "right" }}>From previous</th>
              <th style={{ textAlign: "right" }}>Median days from apply</th>
            </tr>
          </thead>
          <tbody>
            {stats.stages.map(r => (
              <tr key={r.stage}>
                <td style={{ color: "#94a3b8" }}>{r.label}</td>
                <td style={{ textAlign: "right" }}>{r.count}</td>
                <td style={{ textAlign: "right", color: "#64748b" }}>
                  {r.conversionFromPrev == null ? "—" : r.conversionFromPrev + "%"}
                </td>
                <td style={{ textAlign: "right", color: "#64748b" }}>
                  {r.medianDaysFromApply == null ? "—" : r.medianDaysFromApply + "d"}
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      <div className="card">
        <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12, flexWrap: "wrap", marginBottom: 12 }}>
          <div className="section-header" style={{ marginBottom: 0 }}>What converts</div>
          <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
            <span style={{ fontSize: 11, color: "#64748b" }}>Getting past</span>
            <select value={milestone} onChange={e => setMilestone(e.target.value)} style={{ width: "auto", fontSize: 11, padding: "4px 8px" }}>
              {CareerFunnel.STAGES.filter(s => s.order >= 2).map(s => (
                <option key={s.key} value={s.key}>{s.label}</option>
              ))}
            </select>
          </div>
        </div>

        <div style={{
          padding: "10px 14px", borderRadius: 8, marginBottom: 14,
          background: "rgba(59,130,246,0.08)", border: "1px solid rgba(59,130,246,0.25)",
          fontSize: 12, color: "#94a3b8", lineHeight: 1.5,
        }}>
          {cal.verdict}. Baseline: <strong style={{ color: "#e2e8f0" }}>{cal.baseline == null ? "—" : cal.baseline + "%"}</strong> of
          {" "}{cal.sample} settled application{cal.sample === 1 ? "" : "s"} got past this stage.
        </div>

        {cal.signals.length === 0 && cal.underpowered.length === 0 ? (
          <div style={{ fontSize: 12, color: "#475569" }}>
            No attribute has enough applications behind it to say anything yet.
          </div>
        ) : (
          <div style={{ overflowX: "auto" }}>
            <table>
              <thead>
                <tr>
                  <th>Attribute</th><th>Value</th>
                  <th style={{ textAlign: "right" }}>Rate</th>
                  <th style={{ textAlign: "right" }}>Baseline</th>
                  <th style={{ textAlign: "right" }}>Lift</th>
                  <th style={{ textAlign: "right" }}>Sample</th>
                  <th>Confidence</th>
                </tr>
              </thead>
              <tbody>
                {cal.signals.map((s, i) => <SignalRow key={"s" + i} sig={s} />)}
                {cal.underpowered.map((s, i) => <SignalRow key={"u" + i} sig={s} muted />)}
              </tbody>
            </table>
          </div>
        )}
      </div>

      {themes.themes.length > 0 && (
        <div className="card">
          <div className="section-header">Why applications died ({themes.withFeedback} with feedback)</div>
          {themes.themes.map(t => (
            <div key={t.reason} style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 8 }}>
              <div style={{ width: 170, fontSize: 12, color: "#94a3b8" }}>{t.reason}</div>
              <div className="progress-bar" style={{ flex: 1 }}>
                <div className="progress-fill" style={{
                  width: `${(t.count / themes.themes[0].count) * 100}%`, background: "#f59e0b",
                }} />
              </div>
              <div style={{ fontSize: 12, color: "#64748b", width: 30, textAlign: "right" }}>{t.count}</div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

// ── Board ────────────────────────────────────────────────────────────────────

function PostingCard({ posting, onStatus }) {
  const [open, setOpen] = useState(false);
  const fit = posting.fit || {};
  const c = fit.commute || {};
  const vetoed = (fit.vetoes || []).length > 0;

  const pay = posting.salaryMin
    ? `$${Math.round(posting.salaryMin / 1000)}k–$${Math.round((posting.salaryMax || posting.salaryMin) / 1000)}k`
    : "no band posted";

  // Estimates say they are estimates — the method decides how much to claim.
  const commuteLabel = c.remote ? "Remote"
    : c.minutesOneWay == null ? "Commute unknown"
      : `${c.minutesOneWay} min × ${c.daysOnSite}d/wk${c.method === "coordinates" || c.method === "given" ? "" : " (rough)"}`;

  return (
    <div className="card-sm" style={{ marginBottom: 8, opacity: posting.status === "dismissed" ? 0.5 : 1 }}>
      <div style={{ display: "flex", gap: 12, alignItems: "flex-start", cursor: "pointer" }} onClick={() => setOpen(o => !o)}>
        <GradeBadge grade={fit.grade} score={fit.score} />
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{ fontWeight: 600, fontSize: 13 }}>{posting.title}</div>
          <div style={{ fontSize: 11, color: "#64748b", marginTop: 2 }}>
            {posting.company} · {pay} · {commuteLabel}
            {posting.location?.city && ` · ${posting.location.city}${posting.location.state ? ", " + posting.location.state : ""}`}
          </div>
          {/* Worst reasons first — a bad commute or a low band should land before the flattery. */}
          <div style={{ marginTop: 6 }}>
            {(fit.reasons || []).slice(0, 3).map((r, i) => (
              <div key={i} style={{ fontSize: 11, color: REASON_KIND_COLOR[r.kind] || "#64748b", marginBottom: 2 }}>
                {r.kind === "plus" ? "+" : r.kind === "minus" ? "−" : "·"} {r.text}
              </div>
            ))}
          </div>
          {vetoed && (
            <div style={{ fontSize: 11, color: "#ef4444", marginTop: 6 }}>
              Vetoed: {fit.vetoes.join("; ")}
            </div>
          )}
        </div>
      </div>

      {open && (
        <div style={{ marginTop: 12, paddingTop: 12, borderTop: "1px solid #1e2a3a" }}>
          <div style={{ display: "flex", flexWrap: "wrap", gap: 10, marginBottom: 12 }}>
            {Object.entries(fit.subscores || {}).map(([k, v]) => (
              <div key={k} style={{ width: 110 }}>
                <div style={{ fontSize: 10, color: "#64748b", marginBottom: 3, textTransform: "capitalize" }}>{k}</div>
                <div className="progress-bar">
                  <div className="progress-fill" style={{
                    width: `${v}%`,
                    background: v >= 80 ? "#10b981" : v >= 55 ? "#3b82f6" : "#ef4444",
                  }} />
                </div>
                <div style={{ fontSize: 10, color: "#475569", marginTop: 2 }}>
                  {v}<span style={{ opacity: 0.6 }}> × {Math.round((fit.weights?.[k] || 0) * 100)}%</span>
                </div>
              </div>
            ))}
          </div>
          {(fit.unknowns || []).length > 0 && (
            <div style={{ fontSize: 11, color: "#f59e0b", marginBottom: 10 }}>
              Not enough data on: {fit.unknowns.join(", ")}
              {fit.capped === "unknowns" && " — capped at B until we know"}
            </div>
          )}
          {(fit.missingSkills || []).length > 0 && (
            <div style={{ fontSize: 11, color: "#64748b", marginBottom: 10 }}>
              Wants but you haven't evidenced: <span style={{ color: "#94a3b8" }}>{fit.missingSkills.slice(0, 8).join(", ")}</span>
            </div>
          )}
          {c.annualCost ? (
            <div style={{ fontSize: 11, color: "#64748b", marginBottom: 10 }}>
              Driving it costs about <span style={{ color: "#f59e0b" }}>${c.annualCost.toLocaleString("en-US")}/yr</span> —
              a real pay cut against the band.
            </div>
          ) : null}
          <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
            {posting.url && (
              <a href={posting.url} target="_blank" rel="noreferrer" className="btn-secondary"
                style={{ fontSize: 11, padding: "5px 10px", textDecoration: "none", display: "inline-block" }}>
                Open posting ↗
              </a>
            )}
            <button className="btn-secondary" style={{ fontSize: 11, padding: "5px 10px" }}
              onClick={() => onStatus(posting, posting.status === "saved" ? "new" : "saved")}>
              {posting.status === "saved" ? "Unsave" : "Save"}
            </button>
            <button className="btn-secondary" style={{ fontSize: 11, padding: "5px 10px" }}
              onClick={() => onStatus(posting, "dismissed")}>Dismiss</button>
          </div>
        </div>
      )}
    </div>
  );
}

function BoardView({ profile, postings, snapshot, onScan, scanning, onStatus }) {
  const [minGrade, setMinGrade] = useState("B");
  const [showDismissed, setShowDismissed] = useState(false);
  const rank = { A: 3, B: 2, C: 1, D: 0 };

  const visible = useMemo(() => (postings || [])
    .filter(p => showDismissed || p.status !== "dismissed")
    .filter(p => !p.closedAt)
    .filter(p => (rank[p.fit?.grade] ?? 0) >= (rank[minGrade] ?? 0))
    .sort((a, b) => (b.fit?.score || 0) - (a.fit?.score || 0)),
  [postings, minGrade, showDismissed]);

  const gaps = snapshot?.profile?.gaps || [];
  const sources = snapshot?.sources || [];
  const broken = sources.filter(s => s.stale || s.lastError);

  return (
    <div style={{ display: "grid", gap: 16 }}>
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
        <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
          <span style={{ fontSize: 11, color: "#64748b" }}>Show</span>
          <select value={minGrade} onChange={e => setMinGrade(e.target.value)}
            style={{ width: "auto", fontSize: 11, padding: "4px 8px" }}>
            <option value="A">A only</option>
            <option value="B">A and B</option>
            <option value="C">C and up</option>
            <option value="D">Everything</option>
          </select>
          <label style={{ fontSize: 11, color: "#64748b", display: "flex", alignItems: "center", gap: 5 }}>
            <input type="checkbox" checked={showDismissed} onChange={e => setShowDismissed(e.target.checked)}
              style={{ width: "auto" }} />
            dismissed
          </label>
        </div>
        <button className="btn-primary" onClick={onScan} disabled={scanning}>
          {scanning ? <span className="spinner" /> : "Scan now"}
        </button>
      </div>

      {snapshot?.lastScanAt && (
        <div style={{ fontSize: 11, color: "#64748b" }}>
          Last scan {day(snapshot.lastScanAt)}
          {snapshot.scanAgeHours != null && ` · ${Math.round(snapshot.scanAgeHours)}h ago`}
          {sources.length > 0 && ` · ${sources.length - broken.length} of ${sources.length} sources healthy`}
          {" "}· scans at 6am, 11am, 4pm and 9pm
        </div>
      )}

      {(snapshot?.warnings || []).map((w, i) => (
        <div key={i} style={{
          background: "rgba(245,158,11,0.08)", border: "1px solid rgba(245,158,11,0.3)",
          borderRadius: 8, padding: "10px 14px", fontSize: 12, color: "#f59e0b", lineHeight: 1.5,
        }}>{w}</div>
      ))}

      {broken.length > 0 && (
        <div className="card">
          <div className="section-header">Source health</div>
          {sources.map((s, i) => (
            <div key={i} style={{ display: "flex", gap: 10, fontSize: 11, padding: "4px 0", alignItems: "baseline" }}>
              <span style={{ color: s.stale ? "#ef4444" : "#10b981" }}>●</span>
              <span style={{ color: "#94a3b8", minWidth: 160 }}>{s.company || s.label}</span>
              <span style={{ color: "#64748b" }}>
                {s.lastError ? s.lastError : `${s.lastCount} postings · ${s.ageHours}h ago`}
              </span>
            </div>
          ))}
        </div>
      )}

      {gaps.length > 0 && (
        <Empty title="Fill in the profile first">
          Grading needs {gaps.join(", ")}. Until those are set the A–D grade isn't measuring anything you'd
          want to act on.
        </Empty>
      )}

      {!gaps.length && !postings.length && (
        <Empty title="Nothing scanned yet">
          Hit <strong>Scan now</strong>. It reads every target employer's own job board, grades what it finds
          against your profile, and keeps running four times a day after that.
        </Empty>
      )}

      {!!postings.length && !visible.length && (
        <Empty title={`Nothing at grade ${minGrade} or above`}>
          {postings.length} posting{postings.length === 1 ? "" : "s"} scanned, none clearing that bar. Widen the
          filter, or take it as the honest answer for today.
        </Empty>
      )}

      {visible.map(p => <PostingCard key={p.id} posting={p} onStatus={onStatus} />)}
    </div>
  );
}

// ── Résumé ───────────────────────────────────────────────────────────────────
// The base résumé: one core document, adapted per application rather than
// copied. lib/resumeModel owns the shape and the variant maths; this is the
// way onto the site — the model shipped without one, so nothing could reach
// it. Skills entered here are what the grader's skills dimension matches a
// posting's requirements against, which is what makes the board reflect the
// experience rather than just the job titles.

const RM = window.ResumeModel;

function BulletRows({ bullets, onChange }) {
  return (
    <div style={{ display: "grid", gap: 6 }}>
      {(bullets || []).map((b, i) => {
        const text = typeof b === "string" ? b : (b.text || "");
        return (
          <div key={b.id || i} style={{ display: "flex", gap: 6, alignItems: "flex-start" }}>
            <span style={{ color: "#475569", paddingTop: 8, fontSize: 12 }}>•</span>
            <textarea value={text} rows={2} style={{ resize: "vertical" }}
              placeholder="What you did, and what changed because you did it."
              onChange={e => onChange((bullets || []).map((x, j) =>
                j === i ? (typeof x === "string" ? e.target.value : { ...x, text: e.target.value }) : x))} />
            <button className="btn-danger" style={{ fontSize: 11, padding: "6px 9px" }}
              onClick={() => onChange((bullets || []).filter((_, j) => j !== i))}>✕</button>
          </div>
        );
      })}
      <div>
        <button className="btn-secondary" style={{ fontSize: 11, padding: "4px 10px" }}
          onClick={() => onChange((bullets || []).concat({ text: "" }))}>+ Bullet</button>
      </div>
    </div>
  );
}

// Import: the profile page was asking for target titles, seniority, years
// and skills — every one of them a fact this document already states. Upload
// once; the résumé and the search criteria both come out of it.
// Download. The rendered text is stripped of the characters that mark a
// document as machine-written — em dashes, curly quotes, ellipses, bullet
// glyphs, invisible spaces — which are also the characters ATS parsers
// mangle most reliably. Wording is a different matter: "spearheaded" is a
// judgement about a document you sign your name to, so it's reported and
// left alone.
function ResumeDownload({ resume }) {
  // Named to avoid clobbering window.ResumeExport: slices run at global
  // scope, so a `function ResumeExport()` declaration would replace the lib.
  const RE = window.ResumeExport;
  const audit = useMemo(() => RE.auditTells(resume || {}), [resume, RE]);
  const text = useMemo(() => RE.renderPlainText(resume || {}), [resume, RE]);
  const [showTells, setShowTells] = useState(false);
  const empty = !text.trim();

  const download = useCallback(() => {
    const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = RE.filename(resume || {}, "txt");
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    // Revoked on the next tick so the click has definitely been serviced.
    setTimeout(() => URL.revokeObjectURL(url), 0);
  }, [text, resume, RE]);

  // Printed from a hidden node in this document rather than a popped window:
  // no blocker to trip over, and the page's CSP doesn't apply to a document
  // we never create.
  const print = useCallback(() => {
    const holder = document.getElementById("resume-print");
    if (!holder) return;
    holder.innerHTML = RE.renderHtml(resume || {});
    window.print();
  }, [resume, RE]);

  return (
    <div className="card">
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12, flexWrap: "wrap", marginBottom: 10 }}>
        <div className="section-header" style={{ marginBottom: 0 }}>Download</div>
        <div style={{ display: "flex", gap: 6 }}>
          <button className="btn-secondary" style={{ fontSize: 11, padding: "5px 12px" }}
            onClick={download} disabled={empty}>Download .txt</button>
          <button className="btn-primary" style={{ fontSize: 11, padding: "5px 12px" }}
            onClick={print} disabled={empty}>Print / Save as PDF</button>
        </div>
      </div>

      {empty ? (
        <div style={{ fontSize: 11, color: "#475569" }}>
          Nothing to download yet — import a résumé or fill one in above.
        </div>
      ) : (
        <React.Fragment>
          <div style={{ fontSize: 11, color: "#64748b", lineHeight: 1.6, marginBottom: 10 }}>
            One bullet marker, one date format, one heading style, straight quotes and plain
            hyphens throughout.
            {audit.charTells.length > 0
              ? ` Stripped on the way out: ${audit.charTells.join(", ")}.`
              : " Nothing machine-written to strip."}
          </div>

          {audit.phraseTells.length > 0 && (
            <div className="card-sm" style={{ borderColor: "#f59e0b", marginBottom: 10 }}>
              <div style={{ fontSize: 11, color: "#f59e0b", display: "flex", justifyContent: "space-between", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
                <span>
                  {audit.phraseTells.length} line{audit.phraseTells.length === 1 ? "" : "s"} use wording that reads
                  as machine-written. Not changed — it's your document, and the fix is a word you'd choose.
                </span>
                <button className="btn-secondary" style={{ fontSize: 11, padding: "3px 10px" }}
                  onClick={() => setShowTells(v => !v)}>{showTells ? "Hide" : "Show"}</button>
              </div>
              {showTells && (
                <div style={{ marginTop: 10, display: "grid", gap: 8 }}>
                  {audit.phraseTells.map((t, i) => (
                    <div key={i} style={{ fontSize: 11, lineHeight: 1.5 }}>
                      <div style={{ color: "#94a3b8" }}>
                        {t.where} — <span style={{ color: "#f59e0b" }}>{t.phrases.join(", ")}</span>
                      </div>
                      <div style={{ color: "#64748b" }}>{t.text}</div>
                    </div>
                  ))}
                </div>
              )}
            </div>
          )}

          <details>
            <summary style={{ fontSize: 11, color: "#64748b", cursor: "pointer" }}>Preview</summary>
            <pre style={{ fontSize: 11, color: "#cbd5e1", background: "#0d1117", padding: 12, borderRadius: 8,
              marginTop: 8, whiteSpace: "pre-wrap", maxHeight: 320, overflowY: "auto" }}>{text}</pre>
          </details>
        </React.Fragment>
      )}
    </div>
  );
}

function ResumeImport({ onImported }) {
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState(null);
  const [text, setText] = useState("");
  const [showPaste, setShowPaste] = useState(false);
  const fileRef = useRef(null);

  const run = useCallback(async payload => {
    setBusy(true); setErr(null);
    try {
      const res = await callFn("parseResume", payload);
      onImported(res);
      setText(""); setShowPaste(false);
    } catch (e) {
      setErr(e.message || "Import failed");
    }
    setBusy(false);
  }, [onImported]);

  const onFile = useCallback(async e => {
    const file = (e.target.files || [])[0];
    if (!file) return;
    // 15MB is the Storage object cap this app uses elsewhere; the same limit
    // keeps a scanned résumé from silently failing mid-upload.
    if (file.size > 15 * 1024 * 1024) { setErr("That file is over 15MB — export a smaller PDF."); return; }
    const b64 = await new Promise((resolve, reject) => {
      const r = new FileReader();
      r.onload = () => resolve(String(r.result).split(",")[1]);
      r.onerror = () => reject(new Error("Could not read that file"));
      r.readAsDataURL(file);
    }).catch(ex => { setErr(ex.message); return null; });
    if (b64) await run({ fileBase64: b64, mimeType: file.type || "application/pdf", filename: file.name });
    if (fileRef.current) fileRef.current.value = "";
  }, [run]);

  return (
    <div className="card" style={{ borderColor: "#1e3a2f" }}>
      <div className="section-header">Import your résumé</div>
      <div style={{ fontSize: 11, color: "#64748b", marginBottom: 12, lineHeight: 1.6 }}>
        Upload the PDF or Word file and this fills itself in — positions, bullets and skills — and sets the
        search criteria the board grades on: target titles, level, years of experience and
        skills, all read off the document. Pay comes from your compensation history, not from
        here. Sectors and relocation stay yours; those are decisions, not facts to be extracted.
      </div>
      {err && <div style={{ fontSize: 11, color: "#ef4444", marginBottom: 10 }}>{err}</div>}
      <div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
        <input ref={fileRef} type="file" accept=".pdf,.docx,.png,.jpg,.jpeg,image/*,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
          onChange={onFile} disabled={busy} style={{ width: "auto", flex: "1 1 260px" }} />
        <button className="btn-secondary" style={{ fontSize: 11, padding: "5px 12px" }}
          onClick={() => setShowPaste(v => !v)} disabled={busy}>
          {showPaste ? "Hide" : "Paste text instead"}
        </button>
        {busy && <span style={{ fontSize: 11, color: "#94a3b8", display: "flex", alignItems: "center", gap: 8 }}>
          <span className="spinner" style={{ width: 14, height: 14 }} /> Reading it…
        </span>}
      </div>
      {showPaste && (
        <div style={{ marginTop: 12 }}>
          <textarea rows={8} value={text} onChange={e => setText(e.target.value)} style={{ resize: "vertical" }}
            placeholder="Paste the whole résumé — headings, roles, bullets, skills." />
          <div style={{ marginTop: 8 }}>
            <button className="btn-primary" style={{ fontSize: 11, padding: "5px 14px" }}
              onClick={() => run({ text })} disabled={busy || text.trim().length < 40}>
              Import
            </button>
          </div>
        </div>
      )}
    </div>
  );
}

function ResumeView({ resume, onSave, saving, profileSkills, onUseSkills, onImported }) {
  const [r, setR] = useState(() => RM.ensureIds(resume || RM.emptyBase()));
  const savedRef = useRef(JSON.stringify(RM.ensureIds(resume || RM.emptyBase())));
  useEffect(() => {
    if (!resume) return;
    const next = RM.ensureIds(resume);
    setR(next);
    savedRef.current = JSON.stringify(next);
  }, [resume]);

  const dirty = JSON.stringify(r) !== savedRef.current;
  const set = (k, v) => setR(s => ({ ...s, [k]: v }));
  const setContact = (k, v) => setR(s => ({ ...s, contact: { ...(s.contact || {}), [k]: v } }));
  const setPos = (i, patch) => setR(s => ({
    ...s, positions: (s.positions || []).map((p, j) => (j === i ? { ...p, ...patch } : p)),
  }));

  const bulletCount = (r.positions || []).reduce((n, p) => n + (p.bullets || []).length, 0);
  // The grader matches a posting's listed requirements against these, so a
  // résumé whose skills aren't mirrored onto the profile grades as if the
  // experience isn't there.
  const skillsSynced = JSON.stringify([...(r.skills || [])].sort())
    === JSON.stringify([...(profileSkills || [])].sort());

  return (
    <div style={{ display: "grid", gap: 16 }}>
      <ResumeImport onImported={onImported} />
      <ResumeDownload resume={r} />

      <div className="card">
        <div className="section-header">The base résumé</div>
        <div style={{ fontSize: 11, color: "#64748b", marginBottom: 12, lineHeight: 1.6 }}>
          One core document. Applications reference it and record only what they changed —
          which bullets were used and how they were worded — so a fix here reaches every
          application that didn't override that line, and "what actually differed about the
          ones that converted?" stays answerable.
          {bulletCount > 0 && <span> Currently {bulletCount} bullet{bulletCount === 1 ? "" : "s"} across {(r.positions || []).length} position{(r.positions || []).length === 1 ? "" : "s"}.</span>}
        </div>
        <Row>
          <Field label="Name"><input value={r.contact?.name || ""} onChange={e => setContact("name", e.target.value)} /></Field>
          <Field label="Email"><input value={r.contact?.email || ""} onChange={e => setContact("email", e.target.value)} /></Field>
          <Field label="Phone" width="160px"><input value={r.contact?.phone || ""} onChange={e => setContact("phone", e.target.value)} /></Field>
        </Row>
        <Row>
          <Field label="Location"><input value={r.contact?.location || ""} onChange={e => setContact("location", e.target.value)} placeholder="Wilmington, DE" /></Field>
          <Field label="LinkedIn"><input value={r.contact?.linkedin || ""} onChange={e => setContact("linkedin", e.target.value)} /></Field>
        </Row>
      </div>

      <div className="card">
        <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
          <div className="section-header" style={{ marginBottom: 0 }}>Summaries</div>
          <button className="btn-secondary" style={{ fontSize: 11, padding: "4px 10px" }}
            onClick={() => set("summaries", (r.summaries || []).concat({ label: "", text: "" }))}>+ Add</button>
        </div>
        <div style={{ fontSize: 11, color: "#64748b", marginBottom: 12, lineHeight: 1.5 }}>
          Several angles on the same career — an application picks one rather than rewriting it.
        </div>
        {(r.summaries || []).length === 0 && (
          <div style={{ fontSize: 12, color: "#475569" }}>None yet. Add one per story you tell — "platform leader", "payments specialist".</div>
        )}
        {(r.summaries || []).map((sm, i) => (
          <Row key={sm.id || i}>
            <Field label="Label" width="180px">
              <input value={sm.label || ""} placeholder="platform-leader"
                onChange={e => set("summaries", r.summaries.map((x, j) => j === i ? { ...x, label: e.target.value } : x))} />
            </Field>
            <Field label="Text">
              <textarea rows={3} value={sm.text || ""} style={{ resize: "vertical" }}
                onChange={e => set("summaries", r.summaries.map((x, j) => j === i ? { ...x, text: e.target.value } : x))} />
            </Field>
            <div style={{ display: "flex", alignItems: "flex-end", paddingBottom: 2 }}>
              <button className="btn-danger" style={{ fontSize: 11, padding: "6px 10px" }}
                onClick={() => set("summaries", r.summaries.filter((_, j) => j !== i))}>Remove</button>
            </div>
          </Row>
        ))}
      </div>

      <div className="card">
        <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
          <div className="section-header" style={{ marginBottom: 0 }}>Experience</div>
          <button className="btn-secondary" style={{ fontSize: 11, padding: "4px 10px" }}
            onClick={() => set("positions", (r.positions || []).concat({ company: "", title: "", start: "", end: "", location: "", bullets: [] }))}>
            + Position
          </button>
        </div>
        {(r.positions || []).length === 0 && (
          <div style={{ fontSize: 12, color: "#475569" }}>No positions yet. Add the roles you'd put on a CV, newest first.</div>
        )}
        {(r.positions || []).map((pos, i) => (
          <div key={pos.id || i} className="card-sm" style={{ marginBottom: 12 }}>
            <Row gap={8}>
              <Field label="Company"><input value={pos.company || ""} onChange={e => setPos(i, { company: e.target.value })} /></Field>
              <Field label="Title"><input value={pos.title || ""} onChange={e => setPos(i, { title: e.target.value })} /></Field>
              <Field label="Start" width="110px"><input value={pos.start || ""} placeholder="2021" onChange={e => setPos(i, { start: e.target.value })} /></Field>
              <Field label="End" width="110px"><input value={pos.end || ""} placeholder="Present" onChange={e => setPos(i, { end: e.target.value })} /></Field>
              <div style={{ display: "flex", alignItems: "flex-end", paddingBottom: 2 }}>
                <button className="btn-danger" style={{ fontSize: 11, padding: "6px 10px" }}
                  onClick={() => set("positions", r.positions.filter((_, j) => j !== i))}>Remove</button>
              </div>
            </Row>
            <BulletRows bullets={pos.bullets} onChange={b => setPos(i, { bullets: b })} />
          </div>
        ))}
      </div>

      <div className="card">
        <div className="section-header">Skills, education, certifications</div>
        <Row>
          <Field label="Skills" hint="Comma separated. These are what a posting's listed requirements are matched against — 20% of the fit grade.">
            <textarea rows={3} style={{ resize: "vertical" }} value={list(r.skills)}
              onChange={e => set("skills", parseList(e.target.value))} />
          </Field>
        </Row>
        {(r.skills || []).length > 0 && !skillsSynced && (
          <div style={{ fontSize: 11, color: "#f59e0b", marginBottom: 12, display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
            <span>
              The job grader reads skills from your Career profile, not from here — right now they differ,
              so postings are being graded against {(profileSkills || []).length} skill{(profileSkills || []).length === 1 ? "" : "s"} rather than these {(r.skills || []).length}.
            </span>
            <button className="btn-secondary" style={{ fontSize: 11, padding: "3px 10px" }}
              onClick={() => onUseSkills(r.skills)}>Use these for matching</button>
          </div>
        )}
        <Row>
          <Field label="Education" hint="Comma separated.">
            <input value={list((r.education || []).map(e => (typeof e === "string" ? e : [e.degree, e.institution, e.year].filter(Boolean).join(" "))))}
              onChange={e => set("education", parseList(e.target.value))} />
          </Field>
          <Field label="Certifications" hint="Comma separated.">
            <input value={list((r.certifications || []).map(c => (typeof c === "string" ? c : c.name)))}
              onChange={e => set("certifications", parseList(e.target.value))} />
          </Field>
        </Row>
      </div>

      <div style={{ display: "flex", justifyContent: "flex-end", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
        {dirty && <span style={{ fontSize: 11, color: "#f59e0b" }}>Unsaved changes — nothing is stored until you save.</span>}
        <button className="btn-primary" onClick={() => onSave(r)} disabled={saving}>
          {saving ? <span className="spinner" /> : dirty ? "Save résumé •" : "Save résumé"}
        </button>
      </div>
    </div>
  );
}

// A slice can arrive newer than the shell that loads it. index.html carries
// the <script> tags for the UMD libs, and it was cacheable for an hour while
// src/ was not — so a deploy that adds a lib gives the browser new code and
// an old shell, and the new code reaches for a global that was never loaded.
// That killed the Résumé tab silently: TypeError inside a view, caught by the
// boundary, nothing useful on screen.
//
// The libs this slice needs, checked before anything tries to use them, so a
// version skew reads as "reload" rather than as a broken feature.
const REQUIRED_LIBS = ["JobFit", "CareerFunnel", "CompHistory", "ResumeModel", "ResumeExport", "ProfileAutofill"];

function StaleShell({ missing }) {
  return (
    <div style={{ maxWidth: 1280, margin: "0 auto", padding: "24px 20px" }}>
      <div className="card" style={{ borderColor: "#f59e0b" }}>
        <div style={{ color: "#f59e0b", fontWeight: 600, marginBottom: 8 }}>This page is out of date</div>
        <div style={{ fontSize: 12, color: "#94a3b8", lineHeight: 1.7 }}>
          Your browser is running new code against an older copy of the page, so part of it
          didn't load ({missing.join(", ")}). A normal reload won't fix it — the old copy is
          cached.
          <div style={{ marginTop: 10 }}>
            Press <strong style={{ color: "#e2e8f0" }}>Ctrl+Shift+R</strong> (Windows) or{" "}
            <strong style={{ color: "#e2e8f0" }}>Cmd+Shift+R</strong> (Mac), or use the button below.
          </div>
        </div>
        <div style={{ marginTop: 14 }}>
          <button className="btn-primary" style={{ fontSize: 12 }}
            onClick={() => window.location.reload(true)}>Reload the page</button>
        </div>
      </div>
    </div>
  );
}

// ── Shell ────────────────────────────────────────────────────────────────────

const VIEWS = [
  { key: "board", label: "Board" },
  { key: "pipeline", label: "Pipeline" },
  { key: "insights", label: "Insights" },
  { key: "resume", label: "Résumé" },
  { key: "profile", label: "Profile" },
];

// The five views are pages in their own right — a pipeline you want to link
// someone to, a board you want bookmarked — so they get the second URL
// segment: /financial/career/pipeline. Bare /financial/career → board. The
// shell owns the first segment and never looks past it.
const viewFromPath = () => {
  const seg = ((window.location.pathname.split("/financial/career")[1] || "").split("/")[1] || "").toLowerCase();
  return (VIEWS.find(v => v.key === seg) || VIEWS[0]).key;
};
const viewPath = key => (key === "board" ? "/financial/career" : `/financial/career/${key}`);

function Career({ uid }) {
  const [view, setViewRaw] = useState(viewFromPath);

  const setView = useCallback(key => {
    setViewRaw(key);
    try { window.history.pushState(null, "", viewPath(key)); } catch (e) {}
  }, []);

  // Back/forward inside Career. A pop that leaves the section entirely is the
  // shell's listener to handle — this one just re-reads its own segment, and
  // unmounts with the section.
  useEffect(() => {
    const onPop = () => setViewRaw(viewFromPath());
    window.addEventListener("popstate", onPop);
    return () => window.removeEventListener("popstate", onPop);
  }, []);

  const [profile, setProfile] = useState(null);
  const [apps, setApps] = useState([]);
  const [postings, setPostings] = useState([]);
  const [snapshot, setSnapshot] = useState(null);
  const [marketP50, setMarketP50] = useState(null);
  const [comp, setComp] = useState(null);
  const [resume, setResume] = useState(null);
  const [loading, setLoading] = useState(true);
  const [saving, setSaving] = useState(false);
  const [scanning, setScanning] = useState(false);
  const [toast, setToast] = useState(null);

  const flash = useCallback(msg => {
    setToast(msg);
    setTimeout(() => setToast(null), 2600);
  }, []);

  const userRef = useCallback(() => db.collection("users").doc(uid), [uid]);

  const loadApps = useCallback(async () => {
    const snap = await userRef().collection("applications").get().catch(() => null);
    setApps(snap ? snap.docs.map(d => ({ id: d.id, ...d.data() })) : []);
  }, [userRef]);

  const loadBoard = useCallback(async () => {
    const [postSnap, snapDoc] = await Promise.all([
      userRef().collection("jobPostings").get().catch(() => null),
      userRef().collection("derived").doc("careerSnapshot").get().catch(() => null),
    ]);
    setPostings(postSnap ? postSnap.docs.map(d => ({ id: d.id, ...d.data() })) : []);
    setSnapshot(snapDoc && snapDoc.exists ? snapDoc.data() : null);
  }, [userRef]);

  useEffect(() => {
    if (!uid) return;
    let alive = true;
    (async () => {
      const [profSnap, rateSnap, compSnap, resumeSnap] = await Promise.all([
        userRef().collection("career").doc("profile").get().catch(() => null),
        userRef().collection("marketRates").doc(String(new Date().getFullYear())).get().catch(() => null),
        // The Compensation section already holds every year's figures, so the
        // profile reads them rather than asking for a number twice.
        userRef().collection("compensation").get().catch(() => null),
        userRef().collection("career").doc("resume").get().catch(() => null),
      ]);
      if (!alive) return;
      setProfile(profSnap && profSnap.exists ? profSnap.data() : null);
      const rates = rateSnap && rateSnap.exists ? rateSnap.data() : null;
      setMarketP50(rates ? (rates.tcP50 ?? rates.salaryP50 ?? null) : null);
      // Same merge the Compensation tab does: the spreadsheet history is a
      // constant in the shell, Firestore holds whatever has been edited since,
      // and Firestore wins per year. Reading only the collection is why this
      // page asked for a complete year while showing fifteen of them one tab
      // over.
      const byYear = {};
      for (const h of window.HISTORICAL_COMP || []) byYear[String(h.year)] = { ...h };
      if (compSnap) {
        for (const d of compSnap.docs) {
          if (/^\d{4}$/.test(d.id)) byYear[d.id] = { ...(byYear[d.id] || {}), ...d.data() };
        }
      }
      setComp(Object.assign(window.CompHistory.summary(byYear), { raw: byYear }));
      setResume(resumeSnap && resumeSnap.exists ? resumeSnap.data() : null);
      await Promise.all([loadApps(), loadBoard()]);
      if (alive) setLoading(false);
    })();
    return () => { alive = false; };
  }, [uid, userRef, loadApps, loadBoard]);

  const scan = useCallback(async () => {
    setScanning(true);
    try {
      const res = await callFn("scanJobMarket", {});
      if (!res.ok) {
        flash(res.message || "Scan could not run");
      } else {
        await loadBoard();
        // Failures are surfaced, not buried in a log — a source that broke
        // and a market with no jobs look identical on a board.
        flash(res.failedSources && res.failedSources.length
          ? `${res.found} postings · ${res.failedSources.length} source(s) failed — see Source health`
          : `${res.found} postings graded · ${res.newHighFit} new grade A`);
      }
    } catch (e) {
      flash("Scan failed: " + e.message);
    }
    setScanning(false);
  }, [loadBoard, flash]);

  const setPostingStatus = useCallback(async (posting, status) => {
    setPostings(list => list.map(p => (p.id === posting.id ? { ...p, status } : p)));
    try { await userRef().collection("jobPostings").doc(posting.id).set({ status }, { merge: true }); }
    catch (e) { flash("Could not update: " + e.message); }
  }, [userRef, flash]);

  const saveProfile = useCallback(async p => {
    setSaving(true);
    // Numbers are typed as strings in the form; the libs expect numbers.
    const clean = {
      ...p,
      yearsExperience: p.yearsExperience === "" ? null : Number(p.yearsExperience),
      compFloor: p.compFloor === "" ? null : Number(p.compFloor),
      compHardFloor: p.compHardFloor === "" ? null : Number(p.compHardFloor),
      compTarget: p.compTarget === "" ? null : Number(p.compTarget),
      compTargetTC: p.compTargetTC === "" ? null : Number(p.compTargetTC),
      compTargetTCMax: p.compTargetTCMax === "" ? null : Number(p.compTargetTCMax),
      home: {
        ...(p.home || {}),
        lat: p.home?.lat === "" || p.home?.lat == null ? null : Number(p.home.lat),
        lng: p.home?.lng === "" || p.home?.lng == null ? null : Number(p.home.lng),
      },
      commute: {
        maxMinutesOneWay: Number(p.commute?.maxMinutesOneWay) || 45,
        maxDaysPerWeekOnsite: Number(p.commute?.maxDaysPerWeekOnsite) || 3,
      },
      updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
    };
    try {
      await userRef().collection("career").doc("profile").set(clean, { merge: true });
      setProfile(clean);
      flash("Profile saved");
      setSaving(false);
      // Handed back so the editor can adopt the normalized values. Without
      // it the form still holds the strings it was typed with, reads as
      // permanently unsaved against the numbers that were stored, and never
      // clears its draft.
      return clean;
    } catch (e) {
      flash("Save failed: " + e.message);
    }
    setSaving(false);
    return null;
  }, [userRef, flash]);

  const saveResume = useCallback(async next => {
    setSaving(true);
    try {
      const doc = { ...window.ResumeModel.ensureIds(next), updatedAt: Date.now() };
      await userRef().collection("career").doc("resume").set(doc, { merge: false });
      setResume(doc);
      flash("Résumé saved");
      setSaving(false);
      return doc;
    } catch (e) {
      flash("Save failed: " + e.message);
    }
    setSaving(false);
    return null;
  }, [userRef, flash]);

  // The grader reads skills from the profile, so mirroring them across is what
  // makes the board reflect the experience rather than just the job titles.
  const useResumeSkills = useCallback(async skills => {
    try {
      await userRef().collection("career").doc("profile")
        .set({ skills }, { merge: true });
      setProfile(p => ({ ...(p || {}), skills }));
      flash(`Matching on ${skills.length} skill${skills.length === 1 ? "" : "s"}`);
    } catch (e) { flash("Could not update: " + e.message); }
  }, [userRef, flash]);

  // parseResume writes both documents server-side, so the client adopts what
  // came back rather than re-reading — and says what it filled in, since a
  // profile that changes silently is one nobody trusts.
  const onResumeImported = useCallback(res => {
    if (!res || !res.ok) return;
    setResume(res.resume);
    if (res.profile) setProfile(res.profile);
    const c = res.counts || {};
    const filled = [];
    const d = res.derived || {};
    if (d.targetTitles) filled.push(`${d.targetTitles.length} target titles`);
    if (d.seniorityLevel) filled.push("level");
    if (d.yearsExperience != null) filled.push("years of experience");
    if ((d.skills || []).length) filled.push(`${d.skills.length} skills`);
    flash(`Read ${c.positions} positions, ${c.bullets} bullets — filled ${filled.join(", ") || "nothing new"}`);
  }, [flash]);

  const addApp = useCallback(async f => {
    const doc = {
      company: f.company.trim(),
      title: f.title.trim(),
      url: f.url.trim() || null,
      sector: f.sector,
      referral: !!f.referral,
      salaryPosted: !!f.salaryPosted,
      resumeVariant: f.resumeVariant.trim() || null,
      grade: f.grade || null,
      levelDelta: f.levelDelta === "" ? null : Number(f.levelDelta),
      companySize: f.companySize || null,
      postedAt: isoToMs(f.postedOn),
      stage: "applied",
      stages: { applied: isoToMs(f.appliedOn) || Date.now() },
      followUps: [],
      createdAt: Date.now(),
      updatedAt: Date.now(),
    };
    try {
      const ref = await userRef().collection("applications").add(doc);
      setApps(a => a.concat({ id: ref.id, ...doc }));
      flash("Logged " + doc.company);
    } catch (e) { flash("Could not save: " + e.message); }
  }, [userRef, flash]);

  const patchApp = useCallback(async (app, patch) => {
    const next = { ...patch, updatedAt: Date.now() };
    setApps(list => list.map(a => (a.id === app.id ? { ...a, ...next } : a)));
    try { await userRef().collection("applications").doc(app.id).set(next, { merge: true }); }
    catch (e) { flash("Update failed: " + e.message); }
  }, [userRef, flash]);

  const advance = useCallback((app, stageKey) => {
    patchApp(app, { stage: stageKey, stages: { ...(app.stages || {}), [stageKey]: Date.now() } });
    flash("Moved to " + (CareerFunnel.STAGE_BY_KEY[stageKey]?.label || stageKey));
  }, [patchApp, flash]);

  const recordOutcome = useCallback((app, { outcome, reasons, text }) => {
    patchApp(app, {
      outcome,
      closedAt: Date.now(),
      feedback: {
        ...(app.feedback || {}),
        reasons: reasons || [],
        text: text || null,
        receivedAt: (reasons && reasons.length) || text ? Date.now() : null,
      },
    });
    flash("Outcome recorded — it now counts towards your calibration");
  }, [patchApp, flash]);

  const logFollowUp = useCallback(app => {
    patchApp(app, { followUps: (app.followUps || []).concat({ at: Date.now() }) });
    flash("Chase logged");
  }, [patchApp, flash]);

  const markFeedbackAsked = useCallback(app => {
    patchApp(app, { feedback: { ...(app.feedback || {}), requestedAt: Date.now() } });
  }, [patchApp]);

  const missingLibs = REQUIRED_LIBS.filter(n => !window[n]);
  if (missingLibs.length) return <StaleShell missing={missingLibs} />;

  if (loading) {
    return <div style={{ padding: 40, textAlign: "center" }}><span className="spinner" style={{ width: 28, height: 28 }} /></div>;
  }

  return (
    <div style={{ maxWidth: 1280, margin: "0 auto", padding: "24px 20px 60px" }}>
      {toast && <div className="toast success">{toast}</div>}

      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 16, flexWrap: "wrap", marginBottom: 20 }}>
        <div className="page-title" style={{ marginBottom: 0 }}>Career</div>
        <div style={{ display: "flex", gap: 6 }}>
          {VIEWS.map(v => (
            <button key={v.key} onClick={() => setView(v.key)}
              className={view === v.key ? "btn-primary" : "btn-secondary"}
              style={{ fontSize: 12, padding: "6px 14px" }}>{v.label}</button>
          ))}
        </div>
      </div>

      {view === "board" && (
        <BoardView profile={profile} postings={postings} snapshot={snapshot}
          onScan={scan} scanning={scanning} onStatus={setPostingStatus} />
      )}
      {view === "pipeline" && (
        <PipelineView apps={apps} onAdd={addApp} onAdvance={advance} onOutcome={recordOutcome}
          onFollowUp={logFollowUp} onFeedbackRequested={markFeedbackAsked} />
      )}
      {view === "insights" && <InsightsView apps={apps} />}
      {view === "resume" && (
        <ResumeView resume={resume} onSave={saveResume} saving={saving}
          profileSkills={(profile || {}).skills} onUseSkills={useResumeSkills}
          onImported={onResumeImported} />
      )}
      {view === "profile" && (
        <ProfileView profile={profile} onSave={saveProfile} saving={saving} marketP50={marketP50}
          uid={uid} comp={comp} onImported={onResumeImported} />
      )}
    </div>
  );
}

window.FinanceViews = Object.assign(window.FinanceViews || {}, { Career });
