/* Daily Translation Challenge — standalone React root.
   Global-script convention (React UMD + in-browser Babel), same as the main site. */

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

/* ─────────── constants ─────────── */

// lang = language the learner writes (target); srcLang/tgtLang are BCP-47ish
// codes for the lang= attributes; flag = the language being learned.
const PAIRS = [
  { code: "es", label: "EN → Spanish", flag: "🇪🇸", lang: "Spanish", srcLang: "en", tgtLang: "es" },
  { code: "de", label: "EN → German", flag: "🇩🇪", lang: "German", srcLang: "en", tgtLang: "de" },
  { code: "fr", label: "EN → French", flag: "🇫🇷", lang: "French", srcLang: "en", tgtLang: "fr" },
  { code: "fi", label: "EN → Finnish", flag: "🇫🇮", lang: "Finnish", srcLang: "en", tgtLang: "fi" },
  { code: "sv", label: "EN → Swedish", flag: "🇸🇪", lang: "Swedish", srcLang: "en", tgtLang: "sv" },
  { code: "it", label: "EN → Italian", flag: "🇮🇹", lang: "Italian", srcLang: "en", tgtLang: "it" },
  { code: "ja", label: "EN → Japanese", flag: "🇯🇵", lang: "Japanese", srcLang: "en", tgtLang: "ja" },
  { code: "no", label: "EN → Norwegian", flag: "🇳🇴", lang: "Norwegian", srcLang: "en", tgtLang: "no" },
  { code: "enbr", label: "PT → English", flag: "🇬🇧", lang: "English", srcLang: "pt-BR", tgtLang: "en" },
  { code: "enes", label: "ES → English", flag: "🇬🇧", lang: "English", srcLang: "es", tgtLang: "en" },
];
const LEVELS = ["B1", "B2", "C1"];
const LEVEL_HINTS = {
  B1: "Intermediate — feedback focuses on meaning and core grammar, and ignores style and rare idioms",
  B2: "Upper-intermediate (default) — feedback covers grammar plus how natural your phrasing sounds",
  C1: "Advanced — held to a near-native bar: register, idiom and word-choice nuance are all graded",
};
const VERDICT_EMOJI = { excellent: "✨", good: "✅", fair: "🟡", "needs-work": "🔴" };
const VERDICT_RANK = ["needs-work", "fair", "good", "excellent"];
const SITE_URL = "https://parallel.pub/daily/";

// XP: first grade of a day/pair earns by verdict × level; revisions a flat +5
// (anti-grinding — re-grading the same sentence is practice, not new work).
const XP_BY_VERDICT = { excellent: 40, good: 25, fair: 15, "needs-work": 10 };
const LEVEL_MULT = { B1: 1, B2: 1.25, C1: 1.5 };
const REVISION_XP = 5;
const STREAK_MILESTONES = [7, 30, 90];

// Soft gate: anonymous play is free for this many distinct days, then grading
// requires an account. Must match FREE_ANON_DAYS in functions/grade-core.js —
// the server enforces it; this constant only decides when to show the panel.
const FREE_DAYS = 5;

// Feature flag: the optional "email me updates" consent checkbox in the
// sign-in modal. Disabled 2026-07-31 together with the main site's newsletter
// invite (NL_INVITE_ENABLED in ../components.jsx) — there is no outbound
// mailbox for info@parallel.pub yet. Flip to true to re-enable; the whole
// consent→Kit pipeline (subscribeConsented) stays intact behind it.
const CONSENT_CHECKBOX_ENABLED = false;

/* ─────────── defensive localStorage (pattern from ../components.jsx) ─────────── */

function lsGet(key, fallback) {
  try {
    const raw = window.localStorage.getItem(key);
    return raw === null ? fallback : JSON.parse(raw);
  } catch (e) {
    return fallback;
  }
}
function lsSet(key, value) {
  try {
    window.localStorage.setItem(key, JSON.stringify(value));
  } catch (e) {
    /* private mode etc. — features degrade gracefully */
  }
}

function anonId() {
  let id = lsGet("pe-daily-anon-id", null);
  if (!id) {
    id =
      window.crypto && crypto.randomUUID
        ? crypto.randomUUID()
        : "xxxxxxxx-xxxx-4xxx-8xxx-xxxxxxxxxxxx".replace(/x/g, () =>
            ((Math.random() * 16) | 0).toString(16)
          );
    lsSet("pe-daily-anon-id", id);
  }
  return id;
}

/* ─────────── analytics ─────────── */

function trackEvent(path, title) {
  try {
    if (window.goatcounter && window.goatcounter.count) {
      window.goatcounter.count({ path, title: title || path, event: true });
    }
  } catch (e) {}
}

/* ─────────── accounts (Firebase Auth + Firestore sync) ───────────
   Optional layer: with firebase-config.js still on its placeholder apiKey (or
   the SDK scripts blocked), every function here degrades to a no-op and the
   page behaves exactly as the account-less original. localStorage remains the
   working store either way — Firestore is the cross-device backup. */

function fbReady() {
  return !!(
    window.firebase &&
    typeof FIREBASE_CONFIG === "object" &&
    FIREBASE_CONFIG.apiKey &&
    FIREBASE_CONFIG.apiKey.indexOf("PASTE_") !== 0
  );
}

let _fbInited = false;
function fb() {
  if (!fbReady()) return null;
  if (!_fbInited) {
    firebase.initializeApp(FIREBASE_CONFIG);
    _fbInited = true;
  }
  return firebase;
}

function onAuth(cb) {
  const f = fb();
  if (!f) return () => {};
  return f.auth().onAuthStateChanged(cb);
}

function signInGoogle() {
  const f = fb();
  if (!f) return Promise.reject(new Error("accounts not configured"));
  return f.auth().signInWithPopup(new f.auth.GoogleAuthProvider());
}

// Email-link sign-in was removed 2026-07-31: the Firebase console rejected a
// custom email action URL for parallel.pub (non-specific error), and the
// default firebaseapp.com action domain is the compact redirect shell, which
// breaks the emailed links. Google sign-in is the sole method.
function signOutUser() {
  const f = fb();
  return f ? f.auth().signOut() : Promise.resolve();
}

async function authToken() {
  const f = fb();
  const u = f && f.auth().currentUser;
  if (!u) return null;
  try {
    return await u.getIdToken();
  } catch (e) {
    return null;
  }
}

/* Firestore mirror: users/{uid} profile + users/{uid}/days/{date} day docs
   (same shape as the localStorage archive slot, so both directions are plain
   copies). The server also appends a minimal attempt record on every authed
   grade; the client's richer write (with the schedule entry snapshot) lands
   right after and supersedes it. */

function cloudDayRef(uid, date) {
  return fb().firestore().collection("users").doc(uid).collection("days").doc(date);
}

async function cloudSaveDay(uid, date) {
  const day = loadArchive()[date];
  if (!day) return;
  await cloudDayRef(uid, date).set(
    { pairs: day, ts: firebase.firestore.FieldValue.serverTimestamp() },
    { merge: false }
  );
}

async function cloudSaveProfile(uid, extra) {
  const f = fb();
  const u = f.auth().currentUser;
  await f
    .firestore()
    .collection("users")
    .doc(uid)
    .set(
      Object.assign(
        {
          email: (u && u.email) || null,
          xp: totalXp(),
          streak: lsGet("pe-daily-streak", { count: 0, lastDate: null }),
          pair: lsGet("pe-daily-pair", "es"),
          level: lsGet("pe-daily-level", "B2"),
          ts: firebase.firestore.FieldValue.serverTimestamp(),
        },
        extra || {}
      ),
      { merge: true }
    );
}

/* First sign-in on this browser (per uid): push every local day up, pull every
   remote day down, keep the richer side per (date, pair), and take the larger
   XP. Marked done in localStorage so it runs once per uid per browser. */
async function syncAccount(uid) {
  const f = fb();
  const syncedKey = "pe-daily-synced-" + uid;
  const snap = await f.firestore().collection("users").doc(uid).collection("days").get();

  const archive = loadArchive();
  let localDirty = false;
  const toPush = new Set(lsGet(syncedKey, false) ? [] : Object.keys(archive));

  snap.forEach((doc) => {
    const remote = (doc.data() && doc.data().pairs) || {};
    const date = doc.id;
    const local = archive[date];
    for (const pair of Object.keys(remote)) {
      const r = remote[pair];
      if (!r || !Array.isArray(r.attempts) || !r.attempts.length) continue;
      // Server-only day docs lack the schedule entry snapshot — synthesize one
      // so Review renders without crashing.
      if (!r.entry) {
        r.entry = { src: "", book: "", author: "", chapter: "", chapterTitle: "", ctaUrl: "../", dayN: 0 };
      }
      if (!local || !local[pair]) {
        archive[date] = archive[date] || {};
        archive[date][pair] = r;
        localDirty = true;
      } else if (local[pair].attempts.length < r.attempts.length && r.entry.src) {
        archive[date][pair] = r;
        localDirty = true;
      } else if (local[pair].attempts.length > r.attempts.length) {
        toPush.add(date);
      }
    }
  });
  if (localDirty) saveArchive(archive);

  for (const date of toPush) {
    try {
      await cloudSaveDay(uid, date);
    } catch (e) {
      /* partial push is fine — next grade re-pushes its day */
    }
  }

  // History/streak/XP: recompute from the merged archive so both devices agree.
  const h = loadHistory();
  for (const date of Object.keys(archive)) {
    for (const pair of Object.keys(archive[date])) {
      if (!h[date] || !h[date][pair]) {
        h[date] = Object.assign({}, h[date], {
          [pair]: archive[date][pair].attempts[0].feedback.verdictLevel,
        });
      }
    }
  }
  lsSet("pe-daily-history", h);

  // Streak: trailing consecutive run ending today or yesterday.
  const dates = playedDates();
  const today = localDate();
  let run = 0;
  for (let i = dates.length - 1; i >= 0; i--) {
    if (i === dates.length - 1) {
      const gap = daysBetween(dates[i], today);
      if (gap > 1) break;
      run = 1;
    } else if (daysBetween(dates[i], dates[i + 1]) === 1) {
      run++;
    } else break;
  }
  if (run > 0) lsSet("pe-daily-streak", { count: run, lastDate: dates[dates.length - 1] });

  try {
    const profile = await f.firestore().collection("users").doc(uid).get();
    const remoteXp = (profile.exists && profile.data().xp) || 0;
    if (remoteXp > totalXp()) lsSet("pe-daily-xp", remoteXp);
  } catch (e) {}

  await cloudSaveProfile(uid, lsGet(syncedKey, false) ? null : { createdAt: firebase.firestore.FieldValue.serverTimestamp(), migratedAt: firebase.firestore.FieldValue.serverTimestamp() });
  lsSet(syncedKey, true);
}

/* Consent → Kit: same two-step flow as the main site's subscribe form — the
   function validates + records consent and returns the Kit form POST, which
   must be sent from the visitor's own browser (datacenter IPs get
   quarantined; see functions/subscribe-core.js). Best-effort: a failure here
   never blocks sign-in. */
async function subscribeConsented(email) {
  try {
    const res = await fetch("/api/subscribe", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ email, source: "daily-account" }),
    });
    const data = await res.json().catch(() => null);
    if (res.ok && data && data.post) {
      await fetch(data.post.url, {
        method: "POST",
        headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
        body: new URLSearchParams(data.post.params).toString(),
      });
    }
    // Suppress the main site's newsletter invite — this visitor just joined the
    // list here. Raw "1", matching NL_SUBSCRIBED_KEY handling in components.jsx.
    try {
      window.localStorage.setItem("pe-newsletter-subscribed", "1");
    } catch (e2) {}
    trackEvent("daily/consent-subscribe");
  } catch (e) {}
}

/* ─────────── dates / streak / history ─────────── */

function localDate() {
  const d = new Date();
  const p = (n) => String(n).padStart(2, "0");
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
}

function daysBetween(a, b) {
  return Math.round((Date.parse(b + "T00:00:00Z") - Date.parse(a + "T00:00:00Z")) / 86400000);
}

function fmtDate(iso) {
  // "Mon, Jul 20" (+ year when it isn't the current one)
  const d = new Date(iso + "T00:00:00");
  const opts = { weekday: "short", month: "short", day: "numeric" };
  if (iso.slice(0, 4) !== localDate().slice(0, 4)) opts.year = "numeric";
  try {
    return d.toLocaleDateString(undefined, opts);
  } catch (e) {
    return iso;
  }
}

function loadHistory() {
  const h = lsGet("pe-daily-history", {});
  // prune to last 60 days
  const today = localDate();
  for (const d of Object.keys(h)) {
    if (daysBetween(d, today) > 60) delete h[d];
  }
  return h;
}

function recordResult(pair, verdictLevel) {
  const today = localDate();
  const h = loadHistory();
  h[today] = Object.assign({}, h[today], { [pair]: verdictLevel });
  lsSet("pe-daily-history", h);

  const s = lsGet("pe-daily-streak", { count: 0, lastDate: null });
  let incremented = false;
  if (s.lastDate !== today) {
    s.count = s.lastDate && daysBetween(s.lastDate, today) === 1 ? s.count + 1 : 1;
    s.lastDate = today;
    lsSet("pe-daily-streak", s);
    incremented = true;
  }
  return { count: s.count, incremented };
}

function currentStreak() {
  const s = lsGet("pe-daily-streak", { count: 0, lastDate: null });
  if (!s.lastDate) return 0;
  const gap = daysBetween(s.lastDate, localDate());
  return gap <= 1 ? s.count : 0; // yesterday keeps it alive; older = broken
}

/* ─────────── archive (full-fidelity save of every graded day) ───────────
   pe-daily-archive:
   { "<date>": { "<pair>": { level, entry: {src, book, …, dayN},
                             attempts: [{userText, rendering, feedback, ts}, …] } } }
   attempts[0] is the day as first done and is never overwritten; revisions
   append. The schedule entry is snapshotted so Review still renders faithfully
   after the day-90 schedule rebuild rotates schedule.json. */

const ARCHIVE_KEY = "pe-daily-archive";

function loadArchive() {
  return lsGet(ARCHIVE_KEY, {});
}

function saveArchive(archive) {
  try {
    window.localStorage.setItem(ARCHIVE_KEY, JSON.stringify(archive));
  } catch (e) {
    // Quota exceeded (years of data, or a tiny embedded-webview quota):
    // drop the oldest day and retry once; give up silently after that.
    const dates = Object.keys(archive).sort();
    if (dates.length > 1) {
      delete archive[dates[0]];
      try {
        window.localStorage.setItem(ARCHIVE_KEY, JSON.stringify(archive));
      } catch (e2) {}
    }
  }
}

function recordAttempt({ date, pair, level, entry, dayN, userText, result }) {
  const archive = loadArchive();
  const day = (archive[date] = archive[date] || {});
  const attempt = {
    userText,
    rendering: result.rendering,
    feedback: result.feedback,
    ts: Date.now(),
  };
  const isFirst = !day[pair];
  if (isFirst) {
    day[pair] = {
      level,
      entry: {
        src: entry.src,
        book: entry.book,
        author: entry.author,
        chapter: entry.chapter,
        chapterTitle: entry.chapterTitle || "",
        ctaUrl: entry.ctaUrl,
        dayN,
      },
      attempts: [attempt],
    };
  } else {
    day[pair].attempts.push(attempt);
  }
  saveArchive(archive);
  return isFirst;
}

/* ─────────── XP ─────────── */

function totalXp() {
  return lsGet("pe-daily-xp", 0);
}
function addXp(n) {
  const t = totalXp() + n;
  lsSet("pe-daily-xp", t);
  return t;
}
function xpForGrade(verdictLevel, level, isFirst) {
  if (!isFirst) return REVISION_XP;
  return Math.round((XP_BY_VERDICT[verdictLevel] || 10) * (LEVEL_MULT[level] || 1));
}

/* ─────────── derived stats (recomputed from storage — no state to drift) ─────────── */

function playedDates() {
  // History is pruned at 60 days; the archive keeps everything — union them.
  const set = new Set(Object.keys(loadHistory()));
  for (const d of Object.keys(loadArchive())) set.add(d);
  return [...set].sort();
}

function bestStreak() {
  const dates = playedDates();
  let best = 0;
  let run = 0;
  for (let i = 0; i < dates.length; i++) {
    run = i > 0 && daysBetween(dates[i - 1], dates[i]) === 1 ? run + 1 : 1;
    if (run > best) best = run;
  }
  return best;
}

function dayBestVerdicts() {
  // date -> best verdict that day, across pairs, from history ∪ archive firsts.
  const map = {};
  const bump = (date, v) => {
    if (!v) return;
    if (!map[date] || VERDICT_RANK.indexOf(v) > VERDICT_RANK.indexOf(map[date])) map[date] = v;
  };
  const h = loadHistory();
  for (const date of Object.keys(h)) for (const p of Object.keys(h[date])) bump(date, h[date][p]);
  const a = loadArchive();
  for (const date of Object.keys(a))
    for (const p of Object.keys(a[date])) bump(date, a[date][p].attempts[0].feedback.verdictLevel);
  return map;
}

function archiveStats() {
  const archive = loadArchive();
  const verdictCounts = { excellent: 0, good: 0, fair: 0, "needs-work": 0 };
  const pairCounts = {};
  let graded = 0;
  let c1Excellents = 0;
  for (const date of Object.keys(archive)) {
    for (const pair of Object.keys(archive[date])) {
      const slot = archive[date][pair];
      const v = slot.attempts[0].feedback.verdictLevel;
      verdictCounts[v] = (verdictCounts[v] || 0) + 1;
      pairCounts[pair] = (pairCounts[pair] || 0) + 1;
      graded++;
      if (v === "excellent" && slot.level === "C1") c1Excellents++;
    }
  }
  return {
    verdictCounts,
    pairCounts,
    graded,
    excellents: verdictCounts.excellent,
    c1Excellents,
    pairsPlayed: Object.keys(pairCounts).length,
    daysPlayed: playedDates().length,
    bestStreak: bestStreak(),
  };
}

/* ─────────── badges (derived, never stored) ─────────── */

const BADGES = [
  { id: "first", icon: "✒️", name: "First lines", hint: "Grade your first translation", need: 1, value: (s) => s.graded },
  { id: "days10", icon: "📖", name: "Ten days read", hint: "Play on 10 different days", need: 10, value: (s) => s.daysPlayed },
  { id: "streak7", icon: "🔥", name: "One week running", hint: "Reach a 7-day streak", need: 7, value: (s) => s.bestStreak },
  { id: "streak30", icon: "🌙", name: "A month of pages", hint: "Reach a 30-day streak", need: 30, value: (s) => s.bestStreak },
  { id: "streak90", icon: "🏛️", name: "The full run", hint: "Reach a 90-day streak", need: 90, value: (s) => s.bestStreak },
  { id: "pairs3", icon: "🧭", name: "Polyglot", hint: "Play 3 language pairs", need: 3, value: (s) => s.pairsPlayed },
  { id: "exc5", icon: "✨", name: "Five excellents", hint: "Earn 5 excellent verdicts", need: 5, value: (s) => s.excellents },
  { id: "c1exc", icon: "🎓", name: "Near-native", hint: "Score excellent at C1 level", need: 1, value: (s) => s.c1Excellents },
];

function earnedBadgeIds(stats) {
  return new Set(BADGES.filter((b) => b.value(stats) >= b.need).map((b) => b.id));
}

/* ─────────── grading (real endpoint or mock) ─────────── */

// Mock mode: canned, pair-aware placeholder feedback for UI testing only.
// The rendering is a fixed sample sentence per language (NOT today's sentence) —
// the real rendering only exists server-side and ships in the grading response.
const MOCK_RENDERINGS = {
  es: "Era una mujer sumamente generosa y poseía una considerable fortuna personal.",
  de: "Sie war eine überaus großzügige Frau und besaß ein beträchtliches eigenes Vermögen.",
  fi: "Hän oli mitä anteliain nainen, ja hänellä oli huomattava oma omaisuus.",
};

function mockFeedback(pair, userText) {
  const lang = PAIRS.find((p) => p.code === pair).lang;
  const quote = userText.split(/\s+/).slice(0, 3).join(" ");
  return {
    rendering: MOCK_RENDERINGS[pair] || "(sample rendering — mock mode)",
    feedback: {
      verdictLevel: "good",
      verdict: `[MOCK] Placeholder ${lang} feedback — the real grader is not connected yet.`,
      strengths: `[MOCK] “${quote || "…"}” — a sample note on what you did well.`,
      findings: [
        {
          category: "grammar",
          quote: quote || "…",
          explanation:
            "This is canned mock feedback for UI testing. The real grader will analyze your actual sentence here.",
          suggestion: "(example suggestion)",
        },
      ],
      takeaway:
        "Mock mode is on (daily/config.js → mock: true). Flip it to false once the grading endpoint is deployed.",
    },
  };
}

async function grade({ pair, date, level, userText }) {
  if (DAILY_CONFIG.mock) {
    await new Promise((r) => setTimeout(r, 600));
    return mockFeedback(pair, userText);
  }
  const headers = { "content-type": "application/json" };
  const token = await authToken();
  if (token) headers.authorization = "Bearer " + token;
  const res = await fetch(DAILY_CONFIG.endpoint, {
    method: "POST",
    headers,
    body: JSON.stringify({ pair, date, level, userText, anonId: anonId() }),
  });
  const data = await res.json().catch(() => ({}));
  if (!res.ok) {
    const err = new Error(data.error || "Something went wrong — please try again.");
    err.code = data.error === "signin-required" ? "signin-required" : res.status;
    throw err;
  }
  return data;
}

/* ─────────── components ─────────── */

function PairSelect({ pair, onChange, doneToday }) {
  // Native <select> styled as a pill: one line on any screen, and mobile gets
  // the OS picker sheet for free. A ✓ marks pairs already graded today.
  return (
    <select
      className="pairsel"
      value={pair}
      aria-label="Language pair"
      title="Each language pair has its own daily challenge"
      onChange={(e) => onChange(e.target.value)}
    >
      {PAIRS.map((p) => (
        <option key={p.code} value={p.code}>
          {p.flag} {p.label}
          {doneToday[p.code] ? " ✓" : ""}
        </option>
      ))}
    </select>
  );
}

function LevelPicker({ level, onChange }) {
  return (
    <div className="levels" role="radiogroup" aria-label="Your level">
      {LEVELS.map((l) => (
        <button
          key={l}
          role="radio"
          aria-checked={level === l}
          className={level === l ? "on" : ""}
          title={LEVEL_HINTS[l]}
          onClick={() => onChange(l)}
        >
          {l}
        </button>
      ))}
    </div>
  );
}

function StreakBadge({ streak }) {
  if (!streak) return null;
  return (
    <span className="streak" title={`${streak}-day streak`}>
      🔥 {streak}-day streak
    </span>
  );
}

function XpChip({ xp }) {
  if (!xp) return null;
  return (
    <span className="xpchip" title="Experience points — earned for every graded translation">
      ✦ {xp.toLocaleString()} xp
    </span>
  );
}

function HistoryStrip({ history }) {
  // Last 7 local days, oldest → newest; each dot takes the day's best verdict
  // across pairs. Empty days render as hollow rings. Hidden until there's at
  // least one graded day, so first-time visitors see no mystery dots.
  const today = localDate();
  const days = [];
  for (let off = 6; off >= 0; off--) {
    const d = new Date();
    d.setDate(d.getDate() - off);
    const p = (n) => String(n).padStart(2, "0");
    const date = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
    const verdicts = Object.values(history[date] || {});
    const best = verdicts.length
      ? verdicts.reduce((a, b) => (VERDICT_RANK.indexOf(b) > VERDICT_RANK.indexOf(a) ? b : a))
      : null;
    days.push({ date, best, isToday: date === today });
  }
  if (!days.some((d) => d.best)) return null;
  return (
    <span className="hist" role="img" aria-label="Last 7 days of results">
      {days.map((d) => (
        <span
          key={d.date}
          className={"dot" + (d.best ? " " + d.best : "") + (d.isToday ? " today" : "")}
          title={`${d.date}${d.best ? " — " + d.best.replace("-", " ") : " — not played"}`}
        />
      ))}
    </span>
  );
}

function FeedbackView({ userText, result, pairMeta, xpGain, animate }) {
  // animate: staged reveal (verdict → comparison → findings → takeaway) via
  // CSS animation-delay steps; disabled under prefers-reduced-motion in CSS.
  const f = result.feedback;
  let step = 0;
  // Merges the base class with the reveal class — never overwrite className.
  const stage = (cls) =>
    animate ? { className: cls + " rv", style: { "--i": step++ } } : { className: cls };
  return (
    <div className="fb">
      <p {...stage("verdict")}>
        <span className={"pill " + f.verdictLevel}>
          {VERDICT_EMOJI[f.verdictLevel] || ""} {f.verdictLevel.replace("-", " ")}
        </span>
        {xpGain ? <span className="xp-gain">+{xpGain} xp</span> : null}
        <span className="text">{f.verdict}</span>
      </p>

      {/* Older archived attempts predate the strengths field — guard so
          Review still renders them. */}
      {f.strengths && (
        <div {...stage("strengths")}>
          <div className="label">What worked</div>
          {f.strengths}
        </div>
      )}

      <div {...stage("delta")}>
        <div className="box">
          <div className="label">Your translation</div>
          <div lang={pairMeta.tgtLang}>{userText}</div>
        </div>
        <div className="box">
          <div className="label">The edition's rendering</div>
          <div lang={pairMeta.tgtLang}>{result.rendering}</div>
        </div>
      </div>
      <p {...stage("ref-note")}>
        The edition's rendering is one good answer — not the only one. The grade is about your
        sentence on its own terms.
      </p>

      {f.findings.map((fi, i) => (
        <div key={i} {...stage("finding")}>
          <span className="cat">{fi.category}</span>{" "}
          <span className="quote">“{fi.quote}”</span> — {fi.explanation}{" "}
          <span className="sugg">→ {fi.suggestion}</span>
        </div>
      ))}
      {f.findings.length === 0 && (
        <div {...stage("finding")}>
          <span className="cat">flawless</span> Nothing to correct today.
        </div>
      )}

      <div {...stage("takeaway")}>
        <div className="label">Takeaway</div>
        {f.takeaway}
      </div>
    </div>
  );
}

function ShareButton({ pair, verdictLevel, streak }) {
  const [copied, setCopied] = useState(false);
  const pairMeta = PAIRS.find((p) => p.code === pair);
  const canNativeShare = typeof navigator !== "undefined" && !!navigator.share;
  const resultLine =
    `Parallel Editions daily · ${pairMeta.label} ${pairMeta.flag} · ` +
    `${VERDICT_EMOJI[verdictLevel] || ""} ${verdictLevel.replace("-", " ")}` +
    `${streak > 1 ? ` · 🔥 ${streak}-day streak` : ""}`;
  const share = () => {
    if (canNativeShare) {
      // Mobile: real share sheet (text + url). AbortError = user closed it.
      navigator
        .share({ text: resultLine, url: SITE_URL })
        .then(() => trackEvent("daily/share"))
        .catch(() => {});
      return;
    }
    const text = `${resultLine}\n${SITE_URL}`;
    const done = () => {
      setCopied(true);
      trackEvent("daily/share");
      setTimeout(() => setCopied(false), 2000);
    };
    const legacyCopy = () => {
      try {
        const ta = document.createElement("textarea");
        ta.value = text;
        document.body.appendChild(ta);
        ta.select();
        document.execCommand("copy");
        document.body.removeChild(ta);
        done();
      } catch (e) {}
    };
    if (navigator.clipboard && navigator.clipboard.writeText) {
      navigator.clipboard.writeText(text).then(done, legacyCopy);
    } else {
      legacyCopy();
    }
  };
  return (
    <button
      className="btn2"
      title={
        canNativeShare
          ? "Share a spoiler-free summary — your verdict and streak, never the sentence"
          : "Copy a spoiler-free summary to the clipboard — your verdict and streak, never the sentence"
      }
      onClick={share}
    >
      {canNativeShare ? "Share result" : copied ? "Copied ✓" : "Copy result"}
    </button>
  );
}

function BookCTA({ entry, pair }) {
  if (!entry) return null;
  // Editions not yet on Amazon link to our catalog instead of a buy page.
  const isBuyLink = entry.ctaUrl.includes("amazon");
  return (
    <div className="cta-book">
      Today's sentence is from <strong>{entry.book}</strong> by {entry.author} —{" "}
      {entry.chapterTitle || `chapter ${entry.chapter}`}.{" "}
      <a
        href={entry.ctaUrl}
        target="_blank"
        rel="noopener"
        onClick={() => trackEvent(`daily/cta-${pair}`)}
      >
        {isBuyLink ? "Read the whole book bilingually →" : "Browse our bilingual editions →"}
      </a>
    </div>
  );
}

function BookCalendar({ lineup }) {
  // Book-club calendar: one featured book per calendar month, emitted by the
  // build from its FEATURED registry (schedule.json "_lineup"). Absent on
  // stale cached schedules — render nothing rather than guess.
  if (!lineup || !lineup.length) return null;
  const today = localDate();
  return (
    <div className="bookcal">
      <div className="label">Book club — one book a month</div>
      <ol>
        {lineup.map((b) => {
          const state = today > b.end ? "past" : today >= b.start ? "current" : "upcoming";
          const month = new Date(b.start + "T00:00:00").toLocaleString("en", { month: "long" });
          return (
            <li key={b.start} className={"cal-" + state}>
              <span className="cal-month">{month}</span>
              <span className="cal-book">
                {b.title} <span className="cal-author">· {b.author}</span>
              </span>
              {state === "current" && <span className="cal-now">reading now</span>}
            </li>
          );
        })}
      </ol>
    </div>
  );
}

function MilestoneBanner({ streak }) {
  const words = { 7: "A full week of daily translation.", 30: "A month of pages, day after day.", 90: "The full run — ninety days straight." };
  return (
    <div className="milestone">
      🔥 <strong>{streak}-day streak.</strong> {words[streak] || "Keep going."}
    </div>
  );
}

/* ─────────── account UI ─────────── */

function AccountModal({ onClose, onSignedIn }) {
  const [consent, setConsent] = useState(false);
  const [error, setError] = useState("");

  const google = async () => {
    setError("");
    try {
      const cred = await signInGoogle();
      if (consent && cred.user && cred.user.email) subscribeConsented(cred.user.email);
      trackEvent("daily/signin-google");
      onSignedIn(cred.user);
    } catch (e) {
      if (e && e.code === "auth/popup-closed-by-user") return;
      setError("Google sign-in didn't complete — please try again.");
    }
  };

  return (
    <div className="acct-overlay" onClick={(e) => e.target === e.currentTarget && onClose()}>
      <div className="acct-modal" role="dialog" aria-modal="true" aria-label="Save your progress">
        <button className="acct-close" aria-label="Close" onClick={onClose}>×</button>
        <h2>Save your progress</h2>
        <p className="acct-sub">
          Keep your streak, XP and review history across devices — free, one tap, no password.
        </p>
        <button className="btn-google" onClick={google}>Continue with Google</button>
        {error && <p className="err">{error}</p>}
        {CONSENT_CHECKBOX_ENABLED && (
          <label className="acct-consent">
            <input type="checkbox" checked={consent} onChange={(e) => setConsent(e.target.checked)} />
            <span>
              Also email me occasional updates — new bilingual books and challenge features.
              Optional; unsubscribe any time.
            </span>
          </label>
        )}
        <p className="acct-note">
          Your graded translations are stored in your account so you can review them on any
          device. See our <a href="../privacy.html" target="_blank" rel="noopener">privacy policy</a>.
        </p>
      </div>
    </div>
  );
}

function SignInGate({ onOpen }) {
  return (
    <div className="gate">
      <h3>You've used your {FREE_DAYS} free days — nice run! 🎉</h3>
      <p>
        Create a free account to keep playing — and your streak, XP and review history follow
        you to any device.
      </p>
      <button className="btn-link" onClick={() => { trackEvent("daily/gate-shown-click"); onOpen(); }}>
        Sign in free
      </button>
    </div>
  );
}

function Challenge({ pair, level, streak, doneToday, onGraded, user, gated, onRequestSignIn }) {
  const [schedule, setSchedule] = useState(null);
  const [loadErr, setLoadErr] = useState(null);
  const [text, setText] = useState("");
  const [state, setState] = useState("idle"); // idle | grading | graded | error
  const [result, setResult] = useState(null);
  const [error, setError] = useState(null);
  const [xpGain, setXpGain] = useState(0);
  const [milestone, setMilestone] = useState(0);

  const today = localDate();
  const pairMeta = PAIRS.find((p) => p.code === pair);

  useEffect(() => {
    // no-store: never serve the schedule from the browser HTTP cache — a stale
    // copy from an earlier deploy would blank the day's sentence.
    fetch("schedule.json", { cache: "no-store" })
      .then((r) => (r.ok ? r.json() : Promise.reject(new Error("HTTP " + r.status))))
      .then(setSchedule)
      .catch((e) => setLoadErr(e));
  }, []);

  // reset per pair switch
  useEffect(() => {
    setText("");
    setState("idle");
    setResult(null);
    setError(null);
    setXpGain(0);
    setMilestone(0);
  }, [pair]);

  if (loadErr) return <div className="card">Couldn't load today's challenge — please refresh.</div>;
  if (!schedule) {
    // Skeleton mirrors the real card's shape — no layout shift when it loads.
    return (
      <div className="card" aria-busy="true" aria-label="Loading today's sentence">
        <div className="skel" style={{ width: "45%", height: 12 }} />
        <div className="skel" style={{ width: "100%", height: 22, marginTop: 18 }} />
        <div className="skel" style={{ width: "70%", height: 22, marginTop: 8 }} />
        <div className="skel" style={{ width: "100%", height: 96, marginTop: 22 }} />
      </div>
    );
  }

  // Guard on entry.src too: a stale cached schedule (old schema) must not
  // render an empty sentence.
  const entry = schedule[pair] && schedule[pair][today];
  if (!entry || !entry.src) {
    return (
      <div className="card">
        <p className="sentence" style={{ fontStyle: "normal" }}>
          No challenge scheduled for today — check back soon, or{" "}
          <a href="../">browse the bilingual catalog</a> in the meantime.
        </p>
        <BookCalendar lineup={schedule._lineup} />
      </div>
    );
  }

  // Book-club context: which day of the featured book's month today is.
  // Stamped per entry by the build (restarts at 1 for each month's book);
  // the index fallback covers entries generated before the dayN field.
  const dayN = entry.dayN || Object.keys(schedule[pair]).sort().indexOf(today) + 1;

  const submit = async () => {
    if (!text.trim() || state === "grading") return;
    setState("grading");
    setError(null);
    trackEvent(`daily/submit-${pair}`);
    try {
      const r = await grade({ pair, date: today, level, userText: text.trim() });
      const isFirst = recordAttempt({
        date: today,
        pair,
        level,
        entry,
        dayN,
        userText: text.trim(),
        result: r,
      });
      const gained = xpForGrade(r.feedback.verdictLevel, level, isFirst);
      const total = addXp(gained);
      setXpGain(gained);
      setResult(r);
      setState("graded");
      const s = recordResult(pair, r.feedback.verdictLevel);
      setMilestone(s.incremented && STREAK_MILESTONES.includes(s.count) ? s.count : 0);
      trackEvent(`daily/graded-${r.feedback.verdictLevel}`);
      onGraded({ streak: s.count, xp: total });
      if (user) {
        // Fire-and-forget cloud mirror; localStorage already has the truth.
        cloudSaveDay(user.uid, today).catch(() => {});
        cloudSaveProfile(user.uid).catch(() => {});
      }
    } catch (e) {
      if (e.code === "signin-required") {
        setState("idle");
        onRequestSignIn();
        return;
      }
      setError(e.message || "Something went wrong — please try again.");
      setState("error");
    }
  };

  return (
    <div className="card">
      {doneToday[pair] && state !== "graded" && (
        <div className="done-note">
          You already did today's {pairMeta.lang} sentence ({VERDICT_EMOJI[doneToday[pair]] || "✓"}
          ). Another attempt is saved as a revision and won't change your streak.
        </div>
      )}
      <p className="attrib">
        {dayN > 0 ? `Day ${dayN} · ` : ""}
        {entry.book} · Chapter {entry.chapter}
      </p>
      <p className="sentence" lang={pairMeta.srcLang}>“{entry.src}”</p>

      {state !== "graded" && gated && <SignInGate onOpen={onRequestSignIn} />}

      {state !== "graded" && !gated && (
        <div>
          <textarea
            lang={pairMeta.tgtLang}
            maxLength={500}
            enterKeyHint="send"
            placeholder={`Your ${pairMeta.lang} translation…`}
            value={text}
            onChange={(e) => setText(e.target.value)}
            onKeyDown={(e) => {
              // A translated sentence never needs a newline: plain Enter
              // submits (Shift+Enter still inserts one, Ctrl/Cmd+Enter kept
              // for muscle memory).
              if (e.key === "Enter" && !e.shiftKey) {
                e.preventDefault();
                submit();
              }
            }}
            disabled={state === "grading"}
            // Desktop only — on touch devices autofocus pops the keyboard and
            // scroll-jumps past the sentence.
            autoFocus={window.matchMedia("(pointer: fine)").matches}
          />
          <div className="actions">
            <button
              className="submit"
              title="Grade my translation (or press Enter)"
              onClick={submit}
              disabled={!text.trim() || state === "grading"}
            >
              {state === "grading" ? (
                <span className="grading-dots" aria-label="Grading">
                  Grading<i>.</i><i>.</i><i>.</i>
                </span>
              ) : (
                "Check my translation"
              )}
            </button>
            <span className="count">{text.length}/500</span>
          </div>
          {error && <p className="err">{error}</p>}
        </div>
      )}

      {state === "graded" && result && (
        <div>
          <FeedbackView
            userText={text.trim()}
            result={result}
            pairMeta={pairMeta}
            xpGain={xpGain}
            animate
          />
          {milestone > 0 && <MilestoneBanner streak={milestone} />}
          <div className="after">
            <ShareButton pair={pair} verdictLevel={result.feedback.verdictLevel} streak={streak} />
            <button
              className="btn2"
              title="Redo today's sentence with the feedback in mind — saved as a revision"
              onClick={() => {
                setState("idle");
                setResult(null);
                setText("");
                setXpGain(0);
                setMilestone(0);
              }}
            >
              Revise my translation
            </button>
          </div>
        </div>
      )}

      <BookCTA entry={entry} pair={pair} />
      <BookCalendar lineup={schedule._lineup} />
    </div>
  );
}

/* ─────────── Review tab ─────────── */

function AttemptView({ attempt, pairMeta }) {
  return (
    <FeedbackView
      userText={attempt.userText}
      result={{ rendering: attempt.rendering, feedback: attempt.feedback }}
      pairMeta={pairMeta}
    />
  );
}

function ReviewEntry({ date, pairMeta, slot }) {
  const first = slot.attempts[0];
  const revisions = slot.attempts.slice(1);
  const e = slot.entry;
  return (
    <div className="rev-detail">
      <p className="attrib">
        {e.dayN > 0 ? `Day ${e.dayN} · ` : ""}
        {e.book} · {e.chapterTitle || `Chapter ${e.chapter}`} · graded at {slot.level}
      </p>
      <p className="sentence" lang={pairMeta.srcLang}>“{e.src}”</p>
      <AttemptView attempt={first} pairMeta={pairMeta} />
      {revisions.map((a, i) => (
        <details className="revision" key={i}>
          <summary>
            Revision {i + 2} ·{" "}
            <span className={"pill " + a.feedback.verdictLevel}>
              {a.feedback.verdictLevel.replace("-", " ")}
            </span>
          </summary>
          <AttemptView attempt={a} pairMeta={pairMeta} />
        </details>
      ))}
    </div>
  );
}

function ReviewTab({ user }) {
  const [filter, setFilter] = useState("all");
  const [open, setOpen] = useState(null); // "<date>|<pair>"
  const archive = useMemo(loadArchive, []);

  const rows = [];
  for (const date of Object.keys(archive).sort().reverse()) {
    for (const p of PAIRS) {
      const slot = archive[date][p.code];
      if (slot && (filter === "all" || filter === p.code)) rows.push({ date, pairMeta: p, slot });
    }
  }
  const pairsInArchive = PAIRS.filter((p) =>
    Object.keys(archive).some((d) => archive[d][p.code])
  );

  if (!rows.length && filter === "all") {
    return (
      <div className="card empty-state">
        <p className="empty-icon" aria-hidden="true">📖</p>
        <p>
          <strong>Nothing to review yet.</strong>
        </p>
        <p className="empty-sub">
          Every translation you grade is saved here — the sentence, your answer, and the full
          feedback, exactly as they appeared.
        </p>
      </div>
    );
  }

  return (
    <div>
      {pairsInArchive.length > 1 && (
        <div className="rev-filter">
          <select
            className="pairsel"
            value={filter}
            aria-label="Filter by language pair"
            onChange={(e) => setFilter(e.target.value)}
          >
            <option value="all">All pairs</option>
            {pairsInArchive.map((p) => (
              <option key={p.code} value={p.code}>
                {p.flag} {p.label}
              </option>
            ))}
          </select>
        </div>
      )}
      <div className="rev-list">
        {rows.map(({ date, pairMeta, slot }) => {
          const key = date + "|" + pairMeta.code;
          const isOpen = open === key;
          const v = slot.attempts[0].feedback.verdictLevel;
          return (
            <div className={"rev-item" + (isOpen ? " open" : "")} key={key}>
              <button
                className="rev-row"
                aria-expanded={isOpen}
                onClick={() => setOpen(isOpen ? null : key)}
              >
                <span className="rev-date">{fmtDate(date)}</span>
                <span className="rev-pair" title={pairMeta.label}>
                  {pairMeta.flag} {pairMeta.lang}
                </span>
                <span className={"pill " + v}>{v.replace("-", " ")}</span>
                <span className="rev-snippet" lang={pairMeta.srcLang}>
                  {slot.entry.src}
                </span>
                <span className="rev-chev" aria-hidden="true">
                  {isOpen ? "▾" : "▸"}
                </span>
              </button>
              {isOpen && <ReviewEntry date={date} pairMeta={pairMeta} slot={slot} />}
            </div>
          );
        })}
        {!rows.length && (
          <div className="card empty-state">
            <p className="empty-sub">No saved translations for this pair yet.</p>
          </div>
        )}
      </div>
      <p className="rev-note">
        {user
          ? "Saved to your account — your review history follows you to any device."
          : "Saved in this browser only — clearing site data clears your review history. Sign in under Today to keep it."}
      </p>
    </div>
  );
}

/* ─────────── Stats tab ─────────── */

// Ordinal verdict ramp for color-alone marks (heatmap cells, history dots):
// one hue, light→dark = needs-work→excellent. Validated CVD-safe (ΔE ≥ 17 on
// every adjacent pair); verdict names ride on tooltips and the legend.
const VERDICT_RAMP = { "needs-work": "#c6d3e0", fair: "#7f9dba", good: "#44698f", excellent: "#122c47" };

function Heatmap({ verdicts }) {
  // Last 13 ISO weeks (Mon-start), GitHub-style: columns = weeks, rows = days.
  const today = new Date();
  const p = (n) => String(n).padStart(2, "0");
  const iso = (d) => `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
  const monday = new Date(today);
  monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7)); // this week's Monday
  const weeks = [];
  const monthLabels = [];
  let lastMonth = -1;
  let lastLabelWeek = -3;
  for (let w = 12; w >= 0; w--) {
    const col = [];
    const start = new Date(monday);
    start.setDate(start.getDate() - w * 7);
    if (start.getMonth() !== lastMonth) {
      lastMonth = start.getMonth();
      const week = 12 - w;
      if (week - lastLabelWeek >= 3) {
        // ≥3 columns apart so short month names never collide
        monthLabels.push({ week, label: start.toLocaleDateString(undefined, { month: "short" }) });
        lastLabelWeek = week;
      }
    }
    for (let d = 0; d < 7; d++) {
      const day = new Date(start);
      day.setDate(day.getDate() + d);
      const date = iso(day);
      col.push({ date, future: day > today, v: verdicts[date] || null });
    }
    weeks.push(col);
  }
  return (
    <div className="hm-wrap">
      <div className="hm-months" aria-hidden="true">
        {monthLabels.map((m) => (
          <span key={m.label + m.week} style={{ "--w": m.week }}>
            {m.label}
          </span>
        ))}
      </div>
      <div className="hm" role="img" aria-label="Daily activity over the last 13 weeks">
        {weeks.map((col, i) => (
          <div className="hm-col" key={i}>
            {col.map((c) => (
              <span
                key={c.date}
                className={"hm-cell" + (c.future ? " future" : "") + (c.v ? " played" : "")}
                style={c.v ? { background: VERDICT_RAMP[c.v] } : null}
                title={c.future ? undefined : `${fmtDate(c.date)}${c.v ? " — " + c.v.replace("-", " ") : " — not played"}`}
              />
            ))}
          </div>
        ))}
      </div>
      <div className="hm-legend" aria-hidden="true">
        <span>not played</span>
        <span className="hm-cell" />
        {VERDICT_RANK.map((v) => (
          <span key={v} className="hm-cell played" style={{ background: VERDICT_RAMP[v] }} title={v.replace("-", " ")} />
        ))}
        <span>excellent</span>
      </div>
    </div>
  );
}

function VerdictBar({ counts, total }) {
  if (!total) return null;
  // Single stacked bar, 2px surface gaps between segments; the legend below
  // carries names + counts in text so color never stands alone.
  return (
    <div className="vbar-wrap">
      <div className="vbar">
        {VERDICT_RANK.map((v) =>
          counts[v] ? (
            <span
              key={v}
              className="seg"
              style={{ flexGrow: counts[v], background: VERDICT_RAMP[v] }}
              title={`${v.replace("-", " ")}: ${counts[v]}`}
            />
          ) : null
        )}
      </div>
      <div className="vbar-legend">
        {VERDICT_RANK.map((v) =>
          counts[v] ? (
            <span key={v} className="vl-item">
              <span className="vl-dot" style={{ background: VERDICT_RAMP[v] }} />
              {v.replace("-", " ")} · {counts[v]}
            </span>
          ) : null
        )}
      </div>
    </div>
  );
}

function StatsTab({ streak, xp }) {
  const stats = useMemo(archiveStats, []);
  const verdicts = useMemo(dayBestVerdicts, []);
  const earned = earnedBadgeIds(stats);

  return (
    <div>
      <div className="tiles">
        <div className="tile">
          <div className="tile-n">{stats.daysPlayed}</div>
          <div className="tile-l">days played</div>
        </div>
        <div className="tile">
          <div className="tile-n">{streak}</div>
          <div className="tile-l">current streak</div>
        </div>
        <div className="tile">
          <div className="tile-n">{stats.bestStreak}</div>
          <div className="tile-l">best streak</div>
        </div>
        <div className="tile">
          <div className="tile-n">{xp.toLocaleString()}</div>
          <div className="tile-l">total xp</div>
        </div>
      </div>

      <div className="card stat-card">
        <h2 className="stat-h">Activity</h2>
        <Heatmap verdicts={verdicts} />
      </div>

      {stats.graded > 0 && (
        <div className="card stat-card">
          <h2 className="stat-h">Verdicts</h2>
          <VerdictBar counts={stats.verdictCounts} total={stats.graded} />
          {stats.pairsPlayed > 0 && (
            <div className="pair-chips">
              {PAIRS.filter((p) => stats.pairCounts[p.code]).map((p) => (
                <span className="pair-chip" key={p.code} title={p.label}>
                  {p.flag} {p.lang} · {stats.pairCounts[p.code]}
                </span>
              ))}
            </div>
          )}
        </div>
      )}

      <div className="card stat-card">
        <h2 className="stat-h">Badges</h2>
        <div className="badges">
          {BADGES.map((b) => {
            const got = earned.has(b.id);
            const cur = Math.min(b.value(stats), b.need);
            return (
              <div className={"badge" + (got ? " earned" : "")} key={b.id} title={b.hint}>
                <span className="badge-icon" aria-hidden="true">{b.icon}</span>
                <span className="badge-name">{b.name}</span>
                <span className="badge-hint">{got ? "Earned" : `${cur}/${b.need} — ${b.hint.toLowerCase()}`}</span>
              </div>
            );
          })}
        </div>
      </div>

      {stats.graded === 0 && (
        <p className="rev-note">Stats build up as you play — everything is stored on this device.</p>
      )}
    </div>
  );
}

/* ─────────── shell ─────────── */

function Tabs({ tab, onChange }) {
  const TABS = [
    { id: "today", label: "Today" },
    { id: "review", label: "Review" },
    { id: "stats", label: "Stats" },
  ];
  return (
    <nav className="tabs" role="tablist" aria-label="Daily challenge sections">
      {TABS.map((t) => (
        <button
          key={t.id}
          role="tab"
          aria-selected={tab === t.id}
          className={tab === t.id ? "on" : ""}
          onClick={() => {
            onChange(t.id);
            if (t.id !== "today") trackEvent(`daily/tab-${t.id}`);
          }}
        >
          {t.label}
        </button>
      ))}
    </nav>
  );
}

function InstallChip() {
  // Chrome/Android/Edge fire beforeinstallprompt when the page is installable;
  // stash it and offer a one-tap install. Safari/iOS never fires it (users go
  // via Share -> Add to Home Screen), so the chip simply never shows there.
  const [promptEvent, setPromptEvent] = useState(null);
  useEffect(() => {
    const onPrompt = (e) => {
      e.preventDefault();
      setPromptEvent(e);
    };
    window.addEventListener("beforeinstallprompt", onPrompt);
    return () => window.removeEventListener("beforeinstallprompt", onPrompt);
  }, []);
  if (!promptEvent) return null;
  const install = () => {
    promptEvent.prompt();
    promptEvent.userChoice.then((choice) => {
      trackEvent(`daily/pwa-install-${choice.outcome}`); // accepted | dismissed
    });
    setPromptEvent(null);
  };
  return (
    <button className="btn2" title="Install the Daily Challenge as an app on this device" onClick={install}>
      📲 Install
    </button>
  );
}

function DailyApp() {
  const [tab, setTab] = useState("today");
  const [pair, setPair] = useState(() => {
    const saved = lsGet("pe-daily-pair", "es");
    return PAIRS.some((p) => p.code === saved) ? saved : "es";
  });
  const [level, setLevel] = useState(() => {
    const saved = lsGet("pe-daily-level", "B2");
    return LEVELS.includes(saved) ? saved : "B2";
  });
  const [streak, setStreak] = useState(currentStreak());
  const [xp, setXp] = useState(totalXp());
  const [badgeToast, setBadgeToast] = useState(null);
  const [user, setUser] = useState(null);
  const [showAcct, setShowAcct] = useState(false);
  const [syncTick, setSyncTick] = useState(0); // bumps after cloud merge so Review/Stats re-read
  // Badge diffing: seed with what's already earned so nothing toasts on load;
  // only badges crossed during this session announce themselves.
  const earnedRef = useRef(null);
  if (earnedRef.current === null) earnedRef.current = earnedBadgeIds(archiveStats());

  // Re-read after each grade (setStreak/setXp re-render) — cheap localStorage read.
  const history = loadHistory();
  const doneToday = history[localDate()] || {};

  // Soft gate: only when accounts are actually configured — otherwise the
  // sign-in button would dead-end and the page must stay account-less.
  const gated =
    fbReady() && !user && playedDates().filter((d) => d !== localDate()).length >= FREE_DAYS;

  useEffect(() => {
    trackEvent("daily/view");
    // Campaign attribution: links like /daily/?source=reddit-learnfinnish fire
    // daily/src-reddit-learnfinnish. The PWA manifest's ?source=pwa is excluded —
    // installed opens are already counted via display-mode below.
    const src = new URLSearchParams(window.location.search).get("source");
    if (src && src !== "pwa") {
      const clean = src.toLowerCase().replace(/[^a-z0-9_-]/g, "").slice(0, 40);
      if (clean) trackEvent(`daily/src-${clean}`);
    }
    // Opened as an installed app? (display-mode from manifest, or iOS standalone)
    if (
      (window.matchMedia && window.matchMedia("(display-mode: standalone)").matches) ||
      window.navigator.standalone === true
    ) {
      trackEvent("daily/pwa-open");
    }
    anonId(); // mint on first visit

    // Accounts: follow auth state. On sign-in, merge local and cloud history
    // (once per uid per browser it's a full two-way sync; afterwards a cheap
    // no-op merge).
    const unsub = onAuth((u) => {
      setUser(u);
      if (u) {
        setShowAcct(false);
        syncAccount(u.uid)
          .then(() => {
            setStreak(currentStreak());
            setXp(totalXp());
            setSyncTick((t) => t + 1);
          })
          .catch(() => {});
      }
    });
    return unsub;
  }, []);

  const changePair = (p) => {
    setPair(p);
    lsSet("pe-daily-pair", p);
  };
  const changeLevel = (l) => {
    setLevel(l);
    lsSet("pe-daily-level", l);
    trackEvent(`daily/level-${l}`);
  };

  const handleGraded = ({ streak: s, xp: total }) => {
    setStreak(s);
    setXp(total);
    const earned = earnedBadgeIds(archiveStats());
    for (const id of earned) {
      if (!earnedRef.current.has(id)) {
        const badge = BADGES.find((b) => b.id === id);
        setBadgeToast(badge);
        trackEvent(`daily/badge-${id}`);
        setTimeout(() => setBadgeToast(null), 4500);
        break; // one toast at a time is plenty
      }
    }
    earnedRef.current = earned;
  };

  return (
    <div className="wrap">
      <header className="mast">
        <a className="brand" href="../">Parallel Editions</a>
        <a className="back" href="../">← Book catalog</a>
      </header>

      <h1 className="page-title">Daily Translation Challenge</h1>
      <p className="page-sub">
        One sentence a day from a classic book. Translate it, get graded, keep the streak.
      </p>

      {DAILY_CONFIG.mock && (
        <div className="done-note" style={{ marginBottom: 14 }}>
          🧪 Mock mode — submissions return canned placeholder feedback, not real grading
          (daily/config.js → <code>mock: true</code>).
        </div>
      )}

      <Tabs tab={tab} onChange={setTab} />

      {/* Today stays mounted (hidden) so a graded result survives tab switches. */}
      <div style={{ display: tab === "today" ? "" : "none" }}>
        <div className="controls">
          <PairSelect pair={pair} onChange={changePair} doneToday={doneToday} />
          <LevelPicker level={level} onChange={changeLevel} />
          <InstallChip />
          {fbReady() &&
            (user ? (
              <span className="acct-chip" title={user.email || ""}>
                <span className="acct-email">{user.email}</span>
                <button onClick={() => signOutUser()}>Sign out</button>
              </span>
            ) : (
              <button
                className="btn2 acct-save"
                title="Sign in to keep your streak, XP and history on any device"
                onClick={() => { trackEvent("daily/acct-open"); setShowAcct(true); }}
              >
                Save progress
              </button>
            ))}
          <span className="progress">
            <HistoryStrip history={history} />
            <StreakBadge streak={streak} />
            <XpChip xp={xp} />
          </span>
        </div>

        <Challenge
          pair={pair}
          level={level}
          streak={streak}
          doneToday={doneToday}
          onGraded={handleGraded}
          user={user}
          gated={gated}
          onRequestSignIn={() => setShowAcct(true)}
        />
      </div>

      {tab === "review" && <ReviewTab key={"r" + syncTick} user={user} />}
      {tab === "stats" && <StatsTab key={"s" + syncTick} streak={streak} xp={xp} />}

      {showAcct && (
        <AccountModal onClose={() => setShowAcct(false)} onSignedIn={() => setShowAcct(false)} />
      )}

      {badgeToast && (
        <div className="badge-toast" role="status">
          <span aria-hidden="true">{badgeToast.icon}</span> Badge earned —{" "}
          <strong>{badgeToast.name}</strong>
        </div>
      )}

      <footer className="foot">
        Feedback is generated by AI and graded against our bilingual edition of the featured book.
        One sentence per language pair per day ·{" "}
        {user
          ? "your streak and saved translations are saved to your account — revisit them under Review on any device."
          : "your streak and saved translations live in this browser — revisit them under Review."}
      </footer>
    </div>
  );
}

ReactDOM.createRoot(document.getElementById("root")).render(<DailyApp />);
