// record-extras.jsx — In-progress course tracking, pending verification,
// and the record-detail edit sheet. Loaded after screens.jsx so it can
// hang off existing components (Sheet, Icon, CatDot, etc.).
//
// All state that needs to outlive a navigation (which sessions of a
// course you've attended, marked-completed flags) is persisted via
// localStorage — preview has no backend, but a refresh shouldn't wipe
// the user's attendance ticks.

const { useState: rxS, useEffect: rxE, useMemo: rxM, useRef: rxR } = React;

// ─────────────────────────────────────────────────────────────────
// Local-date helpers — DO NOT use `new Date().toISOString().slice(0,10)`
// to get "today" or to serialise a Date back to a date string. That
// converts to UTC first, which in non-UTC zones (e.g. HK +0800) silently
// shifts the date by a day around midnight, and also silently shifts
// the day-of-week. Use these helpers instead.
// ─────────────────────────────────────────────────────────────────
function ymdLocal(d) {
  const y = d.getFullYear();
  const m = String(d.getMonth() + 1).padStart(2, '0');
  const day = String(d.getDate()).padStart(2, '0');
  return `${y}-${m}-${day}`;
}
function todayLocalIso() {return ymdLocal(new Date());}
function localDateFromIso(iso) {
  const [y, m, d] = (iso || '').split('-').map((n) => parseInt(n, 10));
  if (!y || !m || !d) return new Date();
  return new Date(y, m - 1, d);
}

// ─────────────────────────────────────────────────────────────────
// Session-date parsing — courses come in three shapes:
//   1. Multi-date in _raw_start_date  e.g. "23/2, 2/3, 9/3, 16/3 (Mon)"
//   2. Single ISO date in `date`      e.g. "2026-05-12"
//   3. `sessions: N` with one start   → synthesise N weekly sessions
// We return an array of { idx, iso, label, isPast } so the UI just
// renders pills.
// ─────────────────────────────────────────────────────────────────
function parseSessions(course) {
  if (!course) return [];
  const startIso = course.date;
  if (!startIso) return [];
  const startYear = Number(startIso.slice(0, 4));
  const todayIso = todayLocalIso();

  const raw = course.raw?._raw_start_date || '';
  // "23/2, 2/3, 9/3, 16/3, 23/3, 30/3, 13/4 (Mon)"  ← multi-date pattern
  const multi = raw.match(/\b\d{1,2}\/\d{1,2}\b/g);
  let isoList = [];

  if (multi && multi.length > 1) {
    let prevMonth = -1;
    let year = startYear;
    isoList = multi.map((tok) => {
      const [d, m] = tok.split('/').map((n) => parseInt(n, 10));
      // If month wraps backwards (e.g. 30/12 then 5/1), bump year.
      if (prevMonth > 0 && m < prevMonth) year += 1;
      prevMonth = m;
      return `${year}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
    });
  } else {
    // Synthesise weekly sessions from the count.
    const n = Math.max(1, Math.min(20, Number(course.sessions) || 1));
    // Build from local Y/M/D so the date never gets shifted by the UTC
    // offset (`toISOString().slice(0,10)` would do that in non-UTC zones).
    const [sy, sm, sd] = startIso.split('-').map((p) => parseInt(p, 10));
    const base = new Date(sy, sm - 1, sd);
    for (let i = 0; i < n; i++) {
      const d = new Date(base);
      d.setDate(base.getDate() + i * 7);
      const yy = d.getFullYear();
      const mm = String(d.getMonth() + 1).padStart(2, '0');
      const dd = String(d.getDate()).padStart(2, '0');
      isoList.push(`${yy}-${mm}-${dd}`);
    }
  }

  return isoList.map((iso, idx) => {
    const [y, m, d] = iso.split('-').map((n) => parseInt(n, 10));
    return {
      idx,
      iso,
      label: `${d}/${m}`,
      isPast: iso < todayIso,
      isToday: iso === todayIso
    };
  });
}

// ─────────────────────────────────────────────────────────────────
// localStorage helpers — namespaced per course.
//   attendance[courseId] = { [idx]: 'attended' | 'missed' }
// ─────────────────────────────────────────────────────────────────
const ATT_KEY = 'cne_attendance_v1';
function readAttendance() {
  try {return JSON.parse(localStorage.getItem(ATT_KEY) || '{}');}
  catch (e) {return {};}
}
function writeAttendance(all) {
  try {localStorage.setItem(ATT_KEY, JSON.stringify(all));}
  catch (e) {/* quota */}
}

function useAttendance() {
  const [all, setAll] = rxS(readAttendance);
  const set = (courseId, idx, status) => {
    setAll((prev) => {
      const cur = { ...(prev[courseId] || {}) };
      if (!status) delete cur[idx];else cur[idx] = status;
      const next = { ...prev, [courseId]: cur };
      writeAttendance(next);
      return next;
    });
  };
  return [all, set];
}

// ─────────────────────────────────────────────────────────────────
// Custom session dates — let the user override / add / remove the
// session schedule for a course. Used when the catalog data is
// missing or wrong, or when the user is tracking a course they
// added themselves. Keyed by courseId; an empty array still counts
// as "user has taken control" so we don't fall back to the synthetic
// schedule.
// ─────────────────────────────────────────────────────────────────
const SESSIONS_KEY = 'cne_session_dates_v1';
function readCustomSessions() {
  try {return JSON.parse(localStorage.getItem(SESSIONS_KEY) || '{}');}
  catch (e) {return {};}
}
function writeCustomSessions(all) {
  try {localStorage.setItem(SESSIONS_KEY, JSON.stringify(all));}
  catch (e) {/* quota */}
}
function useCustomSessions() {
  const [all, setAll] = rxS(readCustomSessions);
  // dates may be null (= "delete the override, fall back to default") or
  // an array of ISO date strings (auto-sorted).
  const set = (courseId, dates) => {
    setAll((prev) => {
      const next = { ...prev };
      if (dates === null || dates === undefined) {
        delete next[courseId];
      } else {
        next[courseId] = [...dates].filter(Boolean).sort();
      }
      writeCustomSessions(next);
      return next;
    });
  };
  return [all, set];
}

// Build the session list a course should render, layering custom dates
// on top of parseSessions' default. Also normalises attendance keys so
// flipping between default and custom doesn't lose ticks.
//
// Storage entries can be either "YYYY-MM-DD" (date only) or
// "YYYY-MM-DDTHH:MM" (date + time). We split them into iso/time on
// the way out so the UI can render both bits.
function normaliseSessionEntry(entry) {
  if (entry == null) return null;
  if (typeof entry !== 'string') return null;
  if (entry.includes('T')) {
    const [d, t] = entry.split('T');
    return { iso: d, time: (t || '').slice(0, 5) || null };
  }
  return { iso: entry, time: null };
}
function serialiseSessionEntry({ iso, time }) {
  if (!iso) return null;
  return time ? `${iso}T${time}` : iso;
}
function resolveSessions(course, customSessions) {
  const todayIso = todayLocalIso();
  const override = customSessions && customSessions[course.id];
  let list;
  if (Array.isArray(override)) {
    list = override.map(normaliseSessionEntry).filter(Boolean);
  } else {
    list = parseSessions(course).map((s) => ({ iso: s.iso, time: null }));
  }
  // GUARANTEE at least one session — every in-progress course must
  // surface something pickable, even if the catalog has no schedule.
  // Falls back to the course's start date (or today as last resort).
  if (list.length === 0) {
    list = [{ iso: course.date || todayIso, time: null }];
  }
  // Stable sort by iso+time so an editor that adds out-of-order rows
  // still presents them chronologically.
  list = [...list].sort((a, b) => {
    const ka = `${a.iso || ''}T${a.time || '00:00'}`;
    const kb = `${b.iso || ''}T${b.time || '00:00'}`;
    return ka < kb ? -1 : ka > kb ? 1 : 0;
  });
  return list.map((entry, idx) => {
    const [y, m, d] = (entry.iso || '').split('-').map((n) => parseInt(n, 10));
    return {
      idx,
      iso: entry.iso,
      time: entry.time || null,
      label: d && m ? `${d}/${m}` : entry.iso,
      timeLabel: entry.time || null,
      isPast: entry.iso && entry.iso < todayIso,
      isToday: entry.iso === todayIso,
      isCustom: Array.isArray(override)
    };
  });
}

// Cycle: blank → attended → missed → blank
const NEXT_STATUS = { undefined: 'attended', attended: 'missed', missed: undefined };

// ─────────────────────────────────────────────────────────────────
// <SessionPill> — one round-rect button per session date
// ─────────────────────────────────────────────────────────────────
function SessionPill({ session, status, onCycle }) {
  // Tone resolution
  let bg = 'var(--surface)';
  let color = 'var(--fg-2)';
  let border = '0.5px solid var(--border)';
  let icon = null;
  if (status === 'attended') {
    bg = 'color-mix(in oklab, var(--success) 18%, var(--surface))';
    color = 'var(--success)';
    border = '0.5px solid color-mix(in oklab, var(--success) 40%, transparent)';
    icon = <Icon name="check" size={11} stroke={2.4} />;
  } else if (status === 'missed') {
    bg = 'color-mix(in oklab, var(--danger) 12%, var(--surface))';
    color = 'var(--danger)';
    border = '0.5px solid color-mix(in oklab, var(--danger) 40%, transparent)';
    icon = <Icon name="close" size={11} stroke={2.4} />;
  } else if (session.isPast) {
    // Past unmarked — nudge with dashed accent
    bg = 'color-mix(in oklab, var(--accent) 8%, var(--surface))';
    color = 'var(--accent)';
    border = '0.5px dashed color-mix(in oklab, var(--accent) 45%, transparent)';
  } else if (!session.isPast) {
    // Future — muted
    bg = 'var(--bg-2)';
    color = 'var(--fg-3)';
    border = '0.5px solid var(--border)';
  }

  const title = status === 'attended' ? '已上' : status === 'missed' ? '冇上' :
  session.isPast ? '點擊標記' : '未開始';

  return (
    <button
      type="button"
      onClick={onCycle}
      title={title}
      aria-label={`${session.label} — ${title}`}
      className="session-pill"
      style={{
        background: bg, color, border,
        appearance: 'none', cursor: 'pointer',
        padding: '7px 11px', borderRadius: 999,
        display: 'inline-flex', alignItems: 'center', gap: 5,
        fontSize: 12.5, fontWeight: 500,
        fontFamily: 'var(--font-mono)', letterSpacing: '-0.01em',
        whiteSpace: 'nowrap',
        transition: 'transform .12s ease, background .15s ease, color .15s ease, border-color .15s ease'
      }}>
      {icon}
      <span>{session.label}{session.timeLabel ? ` · ${session.timeLabel}` : ''}</span>
      {session.isToday &&
      <span style={{
        width: 5, height: 5, borderRadius: '50%',
        background: 'currentColor', marginLeft: 1
      }} />
      }
    </button>);

}

// ─────────────────────────────────────────────────────────────────
// <InProgressCourseCard> — one enrolled course with session tracker
// ─────────────────────────────────────────────────────────────────
function InProgressCourseCard({ course, lang, attendance, onCycle, onSelect, onViewDetail, onEditRecord, onMarkComplete }) {
  const [customAll, setCustom] = useCustomSessions();
  const [editorOpen, setEditorOpen] = rxS(false);
  const [pickerOpen, setPickerOpen] = rxS(false);
  const [completing, setCompleting] = rxS(false);

  const sessions = rxM(
    () => resolveSessions(course, customAll),
    [course?.id, customAll]
  );
  const my = attendance[course.id] || {};
  const attended = sessions.filter((s) => my[s.idx] === 'attended').length;
  const missed = sessions.filter((s) => my[s.idx] === 'missed').length;
  const total = sessions.length || 1;
  const pct = Math.round(attended / total * 100);
  const nextSession = sessions.find((s) => !s.isPast && !my[s.idx]);
  const today = todayLocalIso();
  const dToNext = nextSession ?
  Math.max(0, Math.round((localDateFromIso(nextSession.iso) - localDateFromIso(today)) / 86400000)) :
  null;
  const hasOverride = Array.isArray(customAll[course.id]);

  // All session markers have a non-blank status (attended or missed) →
  // ready to be marked complete. Empty session lists don't count.
  const allMarked = sessions.length > 0 && sessions.every((s) => !!my[s.idx]);

  const saveSessions = (entries) => {
    // entries: [{ iso, time }]  — null entries skipped
    const serialised = entries.
    map(serialiseSessionEntry).
    filter(Boolean);
    setCustom(course.id, serialised);
  };
  const resetToDefault = () => setCustom(course.id, null);

  return (
    <div className="card" style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 12 }}>
      <button
        type="button"
        onClick={() => {
          // Manual records: parent already wired onSelect → record editor.
          // Catalog courses: open an action picker so the user can choose
          //   • 編輯紀錄  — edit the matching record (or log it if missing)
          //   • 查看詳情  — go to the catalog detail page
          if (course.isManual) {
            onSelect && onSelect();
          } else {
            setPickerOpen(true);
          }
        }}
        className="rx-course-head"
        style={{
          display: 'flex', gap: 12, alignItems: 'flex-start',
          appearance: 'none', background: 'transparent', border: 0,
          padding: 0, font: 'inherit', color: 'inherit', textAlign: 'left',
          cursor: 'pointer', width: '100%'
        }}>
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 5, flexWrap: 'wrap' }}>
            <TopicPill topic={course.topic} label={L(course.topicLabel, lang)} />
            {nextSession &&
            <span className="chip" style={{
              background: dToNext === 0 ? 'var(--accent-soft)' : 'transparent',
              color: dToNext === 0 ? 'var(--accent)' : 'var(--fg-2)',
              border: dToNext === 0 ? '0' : '0.5px solid var(--border)'
            }}>
                <Icon name="clock" size={10} stroke={1.8} />
                {dToNext === 0 ?
              lang === 'zh' ? '今日上堂' : 'Class today' :
              lang === 'zh' ? `${dToNext} 日後` : `in ${dToNext}d`}
              </span>
            }
          </div>
          <div className="h-row" style={{ fontSize: 14.5, lineHeight: 1.3 }}>
            {L(course.title, lang)}
          </div>
          <div className="mono muted-2" style={{ fontSize: 10.5, letterSpacing: '0.08em', marginTop: 4, textTransform: 'uppercase' }}>
            {L(course.providerShort, lang)} · {sessions.length} {lang === 'zh' ? '節' : 'sessions'}
            {hasOverride &&
            <span style={{ marginLeft: 6, color: 'var(--accent)' }}>
                · {lang === 'zh' ? '自訂' : 'custom'}
              </span>
            }
          </div>
        </div>
        <div style={{ textAlign: 'right', flexShrink: 0 }}>
          <div className="serif" style={{ fontSize: 22, lineHeight: 1, letterSpacing: '-0.02em' }}>
            {attended}<span className="muted-2" style={{ fontSize: 13 }}>/{total}</span>
          </div>
          <div className="mono muted-2" style={{ fontSize: 9, letterSpacing: '0.10em', textTransform: 'uppercase', marginTop: 3 }}>
            {lang === 'zh' ? '已上' : 'attended'}
          </div>
        </div>
      </button>

      {/* Progress bar */}
      <div className="progress-bar" style={{ height: 4 }}>
        <div style={{ width: pct + '%', background: 'var(--success)' }} />
      </div>

      {/* Session pills + a single edit icon button at the row's end. The
           edit button opens a full dialog (固定日期 / 不固定日期). */}
      <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
        {sessions.map((s) =>
        <SessionPill
          key={s.idx}
          session={s}
          status={my[s.idx]}
          onCycle={() => onCycle(course.id, s.idx, NEXT_STATUS[my[s.idx]])} />

        )}
        <button
          type="button"
          onClick={() => setEditorOpen(true)}
          aria-label={lang === 'zh' ? '編輯日期' : 'Edit session dates'}
          title={lang === 'zh' ? '編輯日期' : 'Edit session dates'}
          style={{
            appearance: 'none', cursor: 'pointer',
            width: 30, height: 30, borderRadius: 999,
            background: 'var(--surface)',
            border: '0.5px solid var(--border)',
            color: 'var(--fg-2)',
            display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
            padding: 0
          }}>
          <Icon name="edit" size={13} stroke={1.7} />
        </button>
      </div>

      {/* Mark-as-complete CTA — appears once every session has been
           ticked off (attended or missed). Pushes the record into the
           已完成紀錄 timeline; for catalog courses this also auto-
           unenrolls them so they stop appearing in 進行中. */}
      {allMarked && onMarkComplete &&
      <button
        type="button"
        disabled={completing}
        onClick={async () => {
          if (completing) return;
          setCompleting(true);
          try {await onMarkComplete(sessions);} finally
          {setCompleting(false);}
        }}
        style={{
          appearance: 'none',
          cursor: completing ? 'wait' : 'pointer',
          padding: '11px 14px', borderRadius: 10,
          background: 'var(--success)',
          border: 0, color: '#fff',
          font: 'inherit', fontSize: 13, fontWeight: 600,
          display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
          opacity: completing ? 0.7 : 1,
          transition: 'opacity .15s, transform .12s'
        }}>
          <Icon name="check" size={15} stroke={2.4} />
          {completing ?
        lang === 'zh' ? '處理中…' : 'Marking…' :
        lang === 'zh' ? '標記為已完成' : 'Mark as completed'}
        </button>
      }

      {/* Footer summary */}
      <div style={{
        display: 'flex', justifyContent: 'space-between', alignItems: 'center',
        fontSize: 11.5, color: 'var(--fg-2)',
        paddingTop: 8, borderTop: '0.5px solid var(--border)'
      }}>
        <div style={{ display: 'flex', gap: 12 }}>
          <span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
            <span style={{ width: 7, height: 7, borderRadius: '50%', background: 'var(--success)' }} />
            {attended} {lang === 'zh' ? '已上' : 'attended'}
          </span>
          {missed > 0 &&
          <span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color: 'var(--danger)' }}>
              <span style={{ width: 7, height: 7, borderRadius: '50%', background: 'var(--danger)' }} />
              {missed} {lang === 'zh' ? '冇上' : 'missed'}
            </span>
          }
        </div>
        <span className="muted-2" style={{ fontSize: 10.5 }}>
          {lang === 'zh' ? '點擊日期標記' : 'Tap a date to mark'}
        </span>
      </div>

      {editorOpen &&
      <SessionScheduleDialog
        course={course}
        lang={lang}
        sessions={sessions}
        hasOverride={hasOverride}
        onClose={() => setEditorOpen(false)}
        onSave={(entries) => {saveSessions(entries);setEditorOpen(false);}}
        onReset={() => {resetToDefault();setEditorOpen(false);}} />

      }

      {pickerOpen &&
      <CourseActionPickerDialog
        course={course}
        lang={lang}
        onClose={() => setPickerOpen(false)}
        onEditRecord={() => {setPickerOpen(false);onEditRecord && onEditRecord();}}
        onViewDetail={() => {setPickerOpen(false);onViewDetail && onViewDetail();}} />

      }
    </div>);

}

// ─────────────────────────────────────────────────────────────────
// <CourseActionPickerDialog> — small action sheet shown when a user
// taps a CATALOG course card in 正在參與的課程. Lets them pick
// between editing the linked record and viewing the catalog detail
// page. Manual records skip this picker entirely (no catalog page
// exists for them, so we route straight to the record editor).
// ─────────────────────────────────────────────────────────────────
function CourseActionPickerDialog({ course, lang, onClose, onEditRecord, onViewDetail }) {
  return (
    <Sheet open={true} onClose={onClose} title={L(course.title, lang)}>
      <div style={{ padding: '0 20px 24px', display: 'flex', flexDirection: 'column', gap: 10 }}>
        <button type="button"
        onClick={onEditRecord}
        style={{
          appearance: 'none', cursor: 'pointer', textAlign: 'left',
          display: 'flex', gap: 14, alignItems: 'center', padding: 14,
          borderRadius: 12, background: 'var(--surface)',
          border: '0.5px solid var(--border)',
          font: 'inherit', color: 'inherit'
        }}>
          <div style={{
            width: 38, height: 38, borderRadius: 12,
            background: 'var(--accent-soft)', color: 'var(--accent)',
            display: 'flex', alignItems: 'center', justifyContent: 'center'
          }}>
            <Icon name="edit" size={18} stroke={1.8} />
          </div>
          <div style={{ flex: 1, minWidth: 0 }}>
            <div className="h-row" style={{ fontSize: 14 }}>
              {lang === 'zh' ? '編輯紀錄' : 'Edit record'}
            </div>
            <div className="muted" style={{ fontSize: 12, marginTop: 2, lineHeight: 1.4 }}>
              {lang === 'zh' ? '記錄出席、上傳證書、加入備註及圖片' : 'Track attendance, upload certificates, add notes & images'}
            </div>
          </div>
          <Icon name="chev-r" size={15} color="var(--fg-3)" stroke={1.7} />
        </button>

        <button type="button"
        onClick={onViewDetail}
        style={{
          appearance: 'none', cursor: 'pointer', textAlign: 'left',
          display: 'flex', gap: 14, alignItems: 'center', padding: 14,
          borderRadius: 12, background: 'var(--surface)',
          border: '0.5px solid var(--border)',
          font: 'inherit', color: 'inherit'
        }}>
          <div style={{
            width: 38, height: 38, borderRadius: 12,
            background: 'var(--primary-soft-2)', color: 'var(--primary)',
            display: 'flex', alignItems: 'center', justifyContent: 'center'
          }}>
            <Icon name="book" size={18} stroke={1.8} />
          </div>
          <div style={{ flex: 1, minWidth: 0 }}>
            <div className="h-row" style={{ fontSize: 14 }}>
              {lang === 'zh' ? '查看詳情' : 'View course detail'}
            </div>
            <div className="muted" style={{ fontSize: 12, marginTop: 2, lineHeight: 1.4 }}>
              {lang === 'zh' ? '檢視課程簡介、時間表及主辦機構' : 'See description, schedule and provider info'}
            </div>
          </div>
          <Icon name="chev-r" size={15} color="var(--fg-3)" stroke={1.7} />
        </button>
      </div>
    </Sheet>);

}

// ─────────────────────────────────────────────────────────────────
// <SessionScheduleDialog> — full editor for a course's session
// schedule. Two modes:
//   • 固定日期 (Fixed) — total count + days-of-week + per-day time,
//     auto-generates date list from an anchor start date.
//   • 不固定日期 (Custom) — manual list of date+time rows.
// Both modes commit a list of normalised { iso, time } entries.
// ─────────────────────────────────────────────────────────────────
function SessionScheduleDialog({ course, lang, sessions, hasOverride, onClose, onSave, onReset }) {
  const DOW_LABELS = lang === 'zh' ?
  ['日', '一', '二', '三', '四', '五', '六'] :
  ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];

  const todayIso = todayLocalIso();
  const anchorDefault = sessions[0]?.iso || course.date || todayIso;

  const [mode, setMode] = rxS(hasOverride && sessions.some((s) => !s.time) ? 'custom' : 'fixed');
  // — Fixed mode state ————————————————————————————————————————————
  const initialCount = Math.max(1, sessions.length || course.sessions || 1);
  const [count, setCount] = rxS(String(initialCount));
  const [anchor, setAnchor] = rxS(anchorDefault);
  // Derive selected days-of-week + per-day default time from incoming
  // sessions if possible; otherwise fall back to the anchor's day-of-week.
  const initialFromSessions = rxM(() => {
    const map = {};
    for (const s of sessions) {
      if (!s.iso) continue;
      // Build the Date from y/m/d explicitly so timezone never shifts
      // the day-of-week (the same bug that previously made selecting
      // 每周二 generate dates that read as 每周一 in the preview).
      const dow = localDateFromIso(s.iso).getDay();
      if (!map[dow]) map[dow] = s.time || '14:00';
    }
    if (Object.keys(map).length) return map;
    const anchorDow = localDateFromIso(anchorDefault).getDay();
    return { [anchorDow]: '14:00' };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
  const [dayTimes, setDayTimes] = rxS(initialFromSessions); // { 1: '14:00', 3: '20:00' }

  // — Custom mode state ————————————————————————————————————————————
  const [items, setItems] = rxS(
    sessions.length ?
    sessions.map((s) => ({ date: s.iso, time: s.time || '' })) :
    [{ date: anchorDefault, time: '' }]
  );

  // — Fixed-mode preview ————————————————————————————————————————
  // Always live — the preview recomputes the instant any input changes.
  const fixedPreview = rxM(() => {
    const n = Math.max(0, Math.min(60, parseInt(count, 10) || 0));
    const days = Object.keys(dayTimes).map((d) => parseInt(d, 10)).sort((a, b) => a - b);
    if (!n || !days.length || !anchor) return [];
    const out = [];
    // Build the cursor from local Y/M/D components so getDay() reflects
    // the local calendar (NOT shifted by the UTC offset).
    const cur = localDateFromIso(anchor);
    let safety = 0;
    while (out.length < n && safety < 365 * 4) {
      const dow = cur.getDay();
      if (days.includes(dow)) {
        out.push({ iso: ymdLocal(cur), time: dayTimes[dow] || '14:00' });
      }
      cur.setDate(cur.getDate() + 1);
      safety += 1;
    }
    return out;
  }, [count, anchor, dayTimes]);

  const toggleDay = (dow) => {
    setDayTimes((prev) => {
      const next = { ...prev };
      if (next[dow]) delete next[dow];else
      next[dow] = '14:00';
      return next;
    });
  };
  const setDayTime = (dow, t) => {
    setDayTimes((prev) => ({ ...prev, [dow]: t }));
  };

  // — Custom-mode helpers ————————————————————————————————————————
  const addItem = () => {
    const last = items[items.length - 1];
    let suggested = todayIso;
    if (last && last.date) {
      const d = localDateFromIso(last.date);
      d.setDate(d.getDate() + 7);
      suggested = ymdLocal(d);
    }
    setItems((prev) => [...prev, { date: suggested, time: last?.time || '' }]);
  };
  const updateItem = (i, patch) => {
    setItems((prev) => prev.map((it, idx) => idx === i ? { ...it, ...patch } : it));
  };
  const removeItem = (i) => {
    setItems((prev) => prev.filter((_, idx) => idx !== i));
  };

  const handleSave = () => {
    if (mode === 'fixed') {
      const entries = fixedPreview.map((p) => ({ iso: p.iso, time: p.time || null }));
      onSave(entries);
    } else {
      const entries = items.
      filter((it) => it.date).
      map((it) => ({ iso: it.date, time: it.time || null }));
      // Custom mode requires ≥1 entry so the card never goes empty.
      if (entries.length === 0) return;
      onSave(entries);
    }
  };

  const canSave = mode === 'fixed' ?
  fixedPreview.length > 0 :
  items.filter((it) => it.date).length > 0;

  return (
    <Sheet open={true} onClose={onClose} title={lang === 'zh' ? '編輯課堂日期' : 'Edit session schedule'}>
      <div style={{ padding: '0 20px 24px', display: 'flex', flexDirection: 'column', gap: 14 }}>

        {/* Mode switcher */}
        <div style={{
          display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 4,
          padding: 4, borderRadius: 12, background: 'var(--bg-2)',
          border: '0.5px solid var(--border)'
        }}>
          {[
          { id: 'fixed', label: lang === 'zh' ? '固定日期' : 'Fixed schedule' },
          { id: 'custom', label: lang === 'zh' ? '不固定日期' : 'Custom dates' }].
          map((opt) =>
          <button key={opt.id}
          type="button"
          onClick={() => setMode(opt.id)}
          style={{
            appearance: 'none', cursor: 'pointer',
            padding: '10px 12px', borderRadius: 8,
            background: mode === opt.id ? 'var(--surface)' : 'transparent',
            border: 0, color: mode === opt.id ? 'var(--fg)' : 'var(--fg-3)',
            font: 'inherit', fontSize: 13, fontWeight: mode === opt.id ? 600 : 500,
            boxShadow: mode === opt.id ? '0 1px 3px rgba(0,0,0,0.08)' : 'none',
            transition: 'background .15s, color .15s'
          }}>
              {opt.label}
            </button>
          )}
        </div>

        {/* Mode body */}
        {mode === 'fixed' ?
        <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
            <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
              <div className="field">
                <div className="field-label">{lang === 'zh' ? '課堂總數' : 'Total sessions'}</div>
                <input className="field-input" type="number" min="1" max="60"
              value={count}
              onChange={(e) => setCount(e.target.value)} />
              </div>
              <div className="field">
                <div className="field-label">{lang === 'zh' ? '由此日期起' : 'Starting from'}</div>
                <input className="field-input" type="date" value={anchor}
              onChange={(e) => setAnchor(e.target.value)} />
              </div>
            </div>

            <div className="field">
              <div className="field-label">{lang === 'zh' ? '每周幾' : 'Days of week'}</div>
              <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
                {DOW_LABELS.map((label, dow) => {
                const on = !!dayTimes[dow];
                return (
                  <button key={dow}
                  type="button"
                  onClick={() => toggleDay(dow)}
                  style={{
                    appearance: 'none', cursor: 'pointer',
                    width: 38, height: 38, borderRadius: 999,
                    background: on ? 'var(--accent)' : 'var(--surface)',
                    border: on ? 0 : '0.5px solid var(--border)',
                    color: on ? '#fff' : 'var(--fg-2)',
                    font: 'inherit', fontSize: 13, fontWeight: on ? 600 : 500,
                    display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
                    transition: 'background .15s, color .15s'
                  }}>
                      {label}
                    </button>);

              })}
              </div>
              <div className="muted-2" style={{ fontSize: 10.5, marginTop: 6, letterSpacing: '0.02em' }}>
                {lang === 'zh' ? '可多選 — 例:每周一、三' : 'Multi-select — e.g. Mon, Wed'}
              </div>
            </div>

            {/* Per-day time inputs */}
            {Object.keys(dayTimes).length > 0 &&
          <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
                <div className="eyebrow">{lang === 'zh' ? '每日時間' : 'Time per day'}</div>
                {Object.keys(dayTimes).
            map((d) => parseInt(d, 10)).
            sort((a, b) => a - b).
            map((dow) =>
            <div key={dow} style={{
              display: 'flex', alignItems: 'center', gap: 10,
              padding: '8px 10px', borderRadius: 8,
              background: 'var(--surface)',
              border: '0.5px solid var(--border)'
            }}>
                      <span style={{
                minWidth: 64, fontSize: 13, color: 'var(--fg)'
              }}>
                        {lang === 'zh' ? `每周${DOW_LABELS[dow]}` : DOW_LABELS[dow]}
                      </span>
                      <input
                type="time"
                value={dayTimes[dow]}
                onChange={(e) => setDayTime(dow, e.target.value)}
                className="field-input"
                style={{ flex: 1, padding: '6px 10px', fontSize: 13 }} />
              
                    </div>
            )}
              </div>
          }

            {/* Preview list — live, recomputes the instant any input changes. */}
            {fixedPreview.length > 0 &&
          <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
                <div className="eyebrow" style={{
              display: 'flex', justifyContent: 'space-between', alignItems: 'center'
            }}>
                  <span>{lang === 'zh' ? '預覽' : 'Preview'}</span>
                  <span className="muted-2" style={{
                fontSize: 10, letterSpacing: '0.08em'
              }}>
                    {fixedPreview.length} {lang === 'zh' ? '節' : 'sessions'}
                  </span>
                </div>
                <div style={{
              display: 'flex', flexDirection: 'column', gap: 4,
              maxHeight: 220, overflowY: 'auto',
              padding: '6px 8px', borderRadius: 10,
              background: 'var(--surface)',
              border: '0.5px solid var(--border)'
            }}>
                  {fixedPreview.map((p, i) => {
                const [yy, mm, dd] = p.iso.split('-').map((n) => parseInt(n, 10));
                const dow = new Date(yy, mm - 1, dd).getDay();
                return (
                  <div key={p.iso + i} style={{
                    display: 'flex', alignItems: 'center', gap: 8,
                    padding: '6px 4px', fontSize: 12.5,
                    borderBottom: i === fixedPreview.length - 1 ? 0 : '0.5px solid var(--border)'
                  }}>
                        <span className="mono muted-2" style={{ minWidth: 26, fontSize: 10, letterSpacing: '0.06em' }}>
                          #{i + 1}
                        </span>
                        <span style={{ flex: 1, color: 'var(--fg)' }}>
                          {`${dd}/${mm} (${DOW_LABELS[dow]}) · ${p.time}`}
                        </span>
                      </div>);

              })}
                </div>
              </div>
          }
          </div> :

        <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
            <div className="muted-2" style={{ fontSize: 11.5, letterSpacing: '0.02em' }}>
              {lang === 'zh' ?
            '逐個加入課堂日期和時間。' :
            'Add each session date and time, one at a time.'}
            </div>
            {items.map((it, i) =>
          <div key={i} style={{
            display: 'flex', alignItems: 'center', gap: 8,
            padding: '8px 10px', borderRadius: 10,
            background: 'var(--surface)',
            border: '0.5px solid var(--border)'
          }}>
                <span className="mono muted-2" style={{
              fontSize: 10, letterSpacing: '0.08em',
              minWidth: 24, textAlign: 'center'
            }}>
                  #{i + 1}
                </span>
                <input
              type="date"
              value={it.date}
              onChange={(e) => updateItem(i, { date: e.target.value })}
              className="field-input"
              style={{ flex: 1.2, minWidth: 0, padding: '6px 10px', fontSize: 12.5 }} />
            
                <input
              type="time"
              value={it.time}
              onChange={(e) => updateItem(i, { time: e.target.value })}
              className="field-input"
              style={{ flex: 1, minWidth: 0, padding: '6px 10px', fontSize: 12.5 }} />
            
                <button
              type="button"
              onClick={() => removeItem(i)}
              aria-label={lang === 'zh' ? '移除' : 'Remove'}
              disabled={items.length === 1}
              style={{
                width: 30, height: 30, borderRadius: 8,
                background: 'transparent',
                border: '0.5px solid var(--border)',
                color: items.length === 1 ? 'var(--fg-3)' : 'var(--danger)',
                cursor: items.length === 1 ? 'not-allowed' : 'pointer',
                opacity: items.length === 1 ? 0.5 : 1,
                display: 'flex', alignItems: 'center', justifyContent: 'center'
              }}>
                  <Icon name="close" size={13} stroke={2} />
                </button>
              </div>
          )}
            <button
            type="button"
            onClick={addItem}
            style={{
              appearance: 'none', cursor: 'pointer',
              padding: '10px 12px', borderRadius: 10,
              background: 'transparent',
              border: '1px dashed var(--border-strong)',
              color: 'var(--accent)', font: 'inherit', fontSize: 13,
              display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6
            }}>
              <Icon name="plus" size={13} stroke={2.2} />
              {lang === 'zh' ? '加入課堂' : 'Add session'}
            </button>
          </div>
        }

        {/* Action row */}
        <div style={{ display: 'flex', gap: 10, marginTop: 6 }}>
          {hasOverride &&
          <button type="button" className="btn btn-ghost"
          onClick={onReset}
          style={{ flex: '0 0 auto' }}
          title={lang === 'zh' ? '還原為課程預設' : 'Reset to default'}>
              {lang === 'zh' ? '還原' : 'Reset'}
            </button>
          }
          <button type="button" className="btn btn-ghost"
          onClick={onClose}
          style={{ flex: '0 0 auto' }}>
            {lang === 'zh' ? '取消' : 'Cancel'}
          </button>
          <button type="button" className="btn btn-primary btn-fill"
          disabled={!canSave}
          onClick={handleSave}>
            <Icon name="check" size={15} stroke={2.2} />
            {lang === 'zh' ? '儲存' : 'Save'}
          </button>
        </div>
      </div>
    </Sheet>);

}

// ─────────────────────────────────────────────────────────────────
// <InProgressSection> — list of enrolled courses + manually-logged
// in-progress records, merged into a single "正在參與的課程" list.
// Manual records become pseudo-course objects so the same card layout
// (progress, sessions, attendance) renders for both kinds — the only
// behavioural difference is the click handler (see ActionPickerDialog
// inside InProgressCourseCard).
// ─────────────────────────────────────────────────────────────────
function manualRecordToPseudoCourse(record, lang) {
  const startMatch = (record.notes || '').match(/START:(\d{4}-\d{2}-\d{2})/);
  const startDate = startMatch ? startMatch[1] : record.date;
  // Synthesise a session count — if start ≠ end we treat it as a
  // multi-day course, otherwise just one session. Capped so a wild
  // date range doesn't generate 200 pills.
  let sessions = 1;
  if (startDate && record.date && startDate !== record.date) {
    const [sy, sm, sd] = startDate.split('-').map((n) => parseInt(n, 10));
    const [ey, em, ed] = record.date.split('-').map((n) => parseInt(n, 10));
    const diff = Math.round((new Date(ey, em - 1, ed) - new Date(sy, sm - 1, sd)) / 86400000);
    sessions = Math.max(1, Math.min(12, Math.floor(diff / 7) + 1));
  }
  return {
    id: `manual-${record.id}`,
    title: record.title,
    provider: record.provider,
    providerShort: record.provider,
    topic: 'misc',
    topicLabel: lang === 'zh' ? { en: 'Manual entry', zh: '手動加入' } : { en: 'Manual entry', zh: '手動加入' },
    date: startDate,
    sessions,
    category: record.category,
    points: record.points,
    isManual: true,
    _record: record
  };
}

function InProgressSection({ lang, enrolledIds, inProgressRecords = [], records = [], onOpenCourse, onEditRecord, onLogCourse, onMarkComplete }) {
  const [attendance, setAttendance] = useAttendance();
  const items = rxM(() => {
    const allCourses = typeof COURSES !== 'undefined' ? COURSES : [];
    const catalog = enrolledIds && enrolledIds.size ?
    allCourses.filter((c) => enrolledIds.has(c.id)) :
    [];
    const manual = (inProgressRecords || []).map((r) => manualRecordToPseudoCourse(r, lang));
    return [...catalog, ...manual];
  }, [enrolledIds, inProgressRecords, lang]);

  if (items.length === 0) {
    return (
      <div className="vital-empty" style={{ marginTop: 8 }}>
        <div className="vital-empty-icon"><Icon name="calendar" size={22} stroke={1.7} /></div>
        <div className="vital-empty-title">
          {lang === 'zh' ? '未有正在進行的課程' : 'No active courses'}
        </div>
        <div className="vital-empty-sub">
          {lang === 'zh' ?
          '在「探索」頁面報名,或手動加入正在進行的課程,均會出現在這裡。' :
          'Enrol from Discover, or manually log an in-progress course — both will appear here.'}
        </div>
      </div>);

  }

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
      {items.map((c) =>
      <InProgressCourseCard
        key={c.id}
        course={c}
        lang={lang}
        attendance={attendance}
        onCycle={setAttendance}
        onSelect={() => {
          if (c.isManual) {
            // No catalog detail page exists — go straight to record edit.
            onEditRecord && onEditRecord(c._record);
          }
        }}
        onViewDetail={() => onOpenCourse && onOpenCourse(c.id)}
        onEditRecord={() => {
          // Catalog course: open the matching record if it exists, else
          // surface the log sheet prefilled with this course.
          const existing = (records || []).find((r) => r.courseId === c.id);
          if (existing) {
            onEditRecord && onEditRecord(existing);
          } else if (onLogCourse) {
            onLogCourse(c);
          } else {
            onEditRecord && onEditRecord(null);
          }
        }}
        onMarkComplete={onMarkComplete ? (sessions) => onMarkComplete(c, sessions) : null} />

      )}
    </div>);

}

// ─────────────────────────────────────────────────────────────────
// <PendingVerificationSection> — enrolled courses whose last
// session has passed but have no record/certificate yet.
// ─────────────────────────────────────────────────────────────────
function PendingVerificationSection({ lang, enrolledIds, records, onUploadCert, onAdvance }) {
  const [attendance] = useAttendance();
  const today = todayLocalIso();

  // A "done" course = end date in the past AND not already in records.
  const pending = rxM(() => {
    if (!enrolledIds || !enrolledIds.size) return [];
    const all = typeof COURSES !== 'undefined' ? COURSES : [];
    const loggedIds = new Set((records || []).map((r) => r.courseId).filter(Boolean));
    return all.filter((c) => {
      if (!enrolledIds.has(c.id)) return false;
      if (loggedIds.has(c.id)) return false;
      const ses = parseSessions(c);
      const last = ses[ses.length - 1];
      return last && last.iso < today;
    });
  }, [enrolledIds, records]);

  // Hidden file input — single shared instance per section.
  const fileRef = rxR(null);
  const pendingForUploadRef = rxR(null);
  const [busyId, setBusyId] = rxS(null);

  const pickFile = (course) => {
    pendingForUploadRef.current = course;
    fileRef.current && fileRef.current.click();
  };

  const onFile = async (e) => {
    const file = e.target.files?.[0];
    e.target.value = '';
    const c = pendingForUploadRef.current;
    pendingForUploadRef.current = null;
    if (!file || !c) return;
    setBusyId(c.id);
    try {
      // Per-course upload — preview just simulates, real impl wires to Supabase.
      await onUploadCert?.(c.id, file);
      // Toast + advance: tell the parent we just completed this course.
      const t = document.createElement('div');
      t.className = 'vital-toast';
      t.textContent = lang === 'zh' ? '證書已上傳，已完成課程' : 'Certificate uploaded';
      document.body.appendChild(t);
      setTimeout(() => t.remove(), 1500);
      onAdvance?.(c);
    } finally {
      setBusyId(null);
    }
  };

  if (pending.length === 0) return null;

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
      <input ref={fileRef} type="file" accept="image/*,application/pdf"
      style={{ display: 'none' }} onChange={onFile} />
      {pending.map((c) => {
        const sessions = parseSessions(c);
        const my = attendance[c.id] || {};
        const attended = sessions.filter((s) => my[s.idx] === 'attended').length;
        const missed = sessions.filter((s) => my[s.idx] === 'missed').length;
        const isBusy = busyId === c.id;
        return (
          <div key={c.id} className="card" style={{
            padding: 14, display: 'flex', flexDirection: 'column', gap: 10,
            background: 'color-mix(in oklab, var(--accent) 4%, var(--surface))',
            borderColor: 'color-mix(in oklab, var(--accent) 25%, var(--border))'
          }}>
            <div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
              <div style={{
                width: 36, height: 36, borderRadius: 10,
                background: 'var(--accent-soft)', color: 'var(--accent)',
                display: 'flex', alignItems: 'center', justifyContent: 'center',
                flexShrink: 0
              }}>
                <Icon name="upload" size={17} stroke={1.7} />
              </div>
              <div style={{ flex: 1, minWidth: 0 }}>
                <div className="mono muted-2" style={{ fontSize: 10, letterSpacing: '0.10em', textTransform: 'uppercase', marginBottom: 3 }}>
                  {L(c.providerShort, lang)} · {lang === 'zh' ? '待上傳證書' : 'awaiting certificate'}
                </div>
                <div className="h-row" style={{ fontSize: 14 }}>{L(c.title, lang)}</div>
                <div className="muted" style={{ fontSize: 12, marginTop: 4 }}>
                  {lang === 'zh' ?
                  `已上 ${attended} / ${sessions.length} 堂` + (missed > 0 ? ` · 缺席 ${missed} 堂` : '') :
                  `${attended} of ${sessions.length} sessions attended` + (missed > 0 ? ` · ${missed} missed` : '')}
                </div>
              </div>
              <div style={{ textAlign: 'right', flexShrink: 0 }}>
                <div className="serif" style={{ fontSize: 22, lineHeight: 1, letterSpacing: '-0.02em', color: 'var(--accent)' }}>
                  +{c.points}
                </div>
                <div className="mono muted-2" style={{ fontSize: 9, letterSpacing: '0.10em', textTransform: 'uppercase', marginTop: 3 }}>
                  {L(STR.pts, lang)}
                </div>
              </div>
            </div>
            <button
              type="button"
              className="btn btn-primary btn-fill"
              disabled={isBusy}
              onClick={() => pickFile(c)}
              style={{ padding: '10px 14px' }}>
              {isBusy ?
              lang === 'zh' ? '上傳中…' : 'Uploading…' :
              <>
                    <Icon name="upload" size={15} stroke={2} />
                    {lang === 'zh' ? '上傳證書並完成' : 'Upload certificate'}
                  </>}
            </button>
          </div>);

      })}
    </div>);

}

// ─────────────────────────────────────────────────────────────────
// Notes field schema
// ─────────────────────────────────────────────────────────────────
// The DB's `notes` column is a single text blob. We pack three things in:
//   • START:YYYY-MM-DD   — start date for date-range courses (so the Log /
//                          Edit sheets can show 開始日期 + 結束日期 without
//                          requiring a schema migration).
//   • IMAGES:[...]        — JSON array of up to 3 supabase storage paths
//                          for attached photos / images.
//   • freeform text       — everything else, the user-visible note.
// parseNoteBlob / buildNoteBlob keep this format reversible.
// ─────────────────────────────────────────────────────────────────
function parseNoteBlob(raw) {
  const out = { startDate: null, imagePaths: [], completed: false, text: '' };
  if (!raw) return out;
  const textLines = [];
  for (const line of String(raw).split('\n')) {
    const startM = line.match(/^START:(\d{4}-\d{2}-\d{2})$/);
    const imgM = line.match(/^IMAGES:(.+)$/);
    if (startM) {out.startDate = startM[1];continue;}
    if (line === 'COMPLETED:1') {out.completed = true;continue;}
    if (imgM) {
      try {
        const arr = JSON.parse(imgM[1]);
        if (Array.isArray(arr)) out.imagePaths = arr.filter((p) => typeof p === 'string').slice(0, 3);
      } catch (e) {/* malformed marker — ignore */}
      continue;
    }
    textLines.push(line);
  }
  out.text = textLines.join('\n').replace(/^\n+|\n+$/g, '');
  return out;
}
function buildNoteBlob({ startDate, imagePaths, completed, text }) {
  const parts = [];
  if (startDate) parts.push(`START:${startDate}`);
  if (completed) parts.push('COMPLETED:1');
  if (imagePaths && imagePaths.length) parts.push(`IMAGES:${JSON.stringify(imagePaths.slice(0, 3))}`);
  if (text && text.trim()) parts.push(text);
  return parts.length ? parts.join('\n') : null;
}
// ─────────────────────────────────────────────────────────────────
// isRecordCompleted — true when the user has explicitly tapped
// "標記為已完成" on a record whose end date is still in the future.
// We can't rely solely on `record.date <= today` because the user
// can finish a course early (e.g. all sessions ticked off before
// the official last session). Persisted as `COMPLETED:1` in notes.
// ─────────────────────────────────────────────────────────────────
function isRecordCompleted(record) {
  if (!record || !record.notes) return false;
  return /(^|\n)COMPLETED:1(\n|$)/.test(record.notes);
}

// ─────────────────────────────────────────────────────────────────
// <RecordDetailSheet> — edit a single record. Allows changing
// metadata + uploading/replacing the verification image.
// ─────────────────────────────────────────────────────────────────
function RecordDetailSheet({ record, lang, onClose, onSave, onUploadCert, onDelete }) {
  const parsedNotes = parseNoteBlob(record.notes);
  const [form, setForm] = rxS({
    title: L(record.title, lang),
    title_en: record.title?.en || '',
    title_zh: record.title?.zh || '',
    provider: L(record.provider, lang),
    provider_en: record.provider?.en || '',
    provider_zh: record.provider?.zh || '',
    startDate: parsedNotes.startDate || record.date || '',
    endDate: record.date || '',
    points: String(record.points || 0),
    category: record.category || 'elective',
    notesText: parsedNotes.text || ''
  });
  // Attached note-images: existing paths from supabase, plus newly-staged
  // File objects that get uploaded on Save.
  const [existingImages, setExistingImages] = rxS(parsedNotes.imagePaths || []);
  const [newImages, setNewImages] = rxS([]); // { file, previewUrl }
  const [existingUrls, setExistingUrls] = rxS({}); // path → signed url
  const [imageUploadErr, setImageUploadErr] = rxS('');
  const [certFile, setCertFile] = rxS(null);
  const [certPreview, setCertPreview] = rxS(null);
  const [certUrl, setCertUrl] = rxS(null); // signed url for the legacy single-cert
  const [busy, setBusy] = rxS(false);
  const [delConfirm, setDelConfirm] = rxS(false);
  // View vs edit. Default = view (course detail + verifications gallery).
  // Tapping "編輯" toggles into the existing form. New record sheets opened
  // from completed timeline land in view; in-progress edits still land in
  // edit (handled by the parent passing initialEditing=true when needed).
  const [editing, setEditing] = rxS(false);
  // Zoom overlay — image src currently being viewed full-screen. Tap a
  // verification thumbnail to populate, tap backdrop to dismiss.
  const [zoomSrc, setZoomSrc] = rxS(null);
  // While a view-mode upload is in flight we disable the +Upload tile
  // and show a small spinner so the user knows it's processing.
  const [viewUploading, setViewUploading] = rxS(false);
  const [viewUploadErr, setViewUploadErr] = rxS('');
  const fileRef = rxR(null);
  const noteImgRef = rxR(null);
  // Hidden picker used by the view-mode "+ Upload verification" CTA. Kept
  // separate from noteImgRef so re-opening the edit form doesn't conflict.
  const verifyFileRef = rxR(null);

  // Whether this record is "in progress" — drives certificate visibility.
  const TODAY_ISO = todayLocalIso();
  const isInProgress = !!form.endDate && form.endDate > TODAY_ISO;

  // Resolve signed URLs for existing note-images so we can render them.
  rxE(() => {
    let cancelled = false;
    (async () => {
      const next = { ...existingUrls };
      for (const p of existingImages) {
        if (next[p]) continue;
        const url = await (window.DB?.getNoteImageUrl?.(p) || Promise.resolve(null));
        if (cancelled) return;
        if (url) next[p] = url;
      }
      if (!cancelled) setExistingUrls(next);
    })();
    return () => {cancelled = true;};
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [existingImages]);

  // Resolve a signed URL for the legacy single-certificate so the view-mode
  // gallery can render it as one of the verification tiles. Older records
  // (uploaded before the multi-verification refactor) only have this.
  rxE(() => {
    let cancelled = false;
    const path = record.certificateUrl;
    if (!path) {setCertUrl(null);return;}
    (async () => {
      const url = await (window.DB?.getCertificateUrl?.(path) || Promise.resolve(null));
      if (cancelled) return;
      setCertUrl(url || null);
    })();
    return () => {cancelled = true;};
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [record.certificateUrl]);

  // Build a preview URL when a file is chosen
  rxE(() => {
    if (!certFile) {setCertPreview(null);return;}
    const url = URL.createObjectURL(certFile);
    setCertPreview(url);
    return () => URL.revokeObjectURL(url);
  }, [certFile]);

  // Revoke object URLs for new note-images when the component unmounts.
  rxE(() => {
    return () => {
      newImages.forEach((img) => {try {URL.revokeObjectURL(img.previewUrl);} catch (e) {}});
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const totalImages = existingImages.length + newImages.length;
  const canAddMoreImages = totalImages < 3;

  const onPickNoteImages = (e) => {
    const files = Array.from(e.target.files || []);
    if (!files.length) return;
    setImageUploadErr('');
    const remaining = 3 - totalImages;
    const accepted = files.slice(0, remaining).filter((f) => {
      if (!f.type.startsWith('image/')) {
        setImageUploadErr(lang === 'zh' ? '只接受圖片檔案' : 'Only image files are allowed');
        return false;
      }
      if (f.size > 5 * 1024 * 1024) {
        setImageUploadErr(lang === 'zh' ? '單張圖片不能超過 5 MB' : 'Each image must be 5 MB or smaller');
        return false;
      }
      return true;
    });
    const staged = accepted.map((f) => ({ file: f, previewUrl: URL.createObjectURL(f) }));
    setNewImages((prev) => [...prev, ...staged]);
    if (e.target) e.target.value = '';
  };

  const removeExistingImage = (path) => {
    setExistingImages((prev) => prev.filter((p) => p !== path));
    // best-effort delete from storage — we don't block on this
    window.DB?.deleteNoteImage?.(path).catch(() => {});
  };
  const removeNewImage = (i) => {
    setNewImages((prev) => {
      const next = [...prev];
      const [removed] = next.splice(i, 1);
      if (removed) {try {URL.revokeObjectURL(removed.previewUrl);} catch (e) {}}
      return next;
    });
  };

  const hasCert = record.certificate || record.certificateUrl || certFile;
  const fmtSize = (b) => b < 1024 ? `${b} B` : b < 1024 * 1024 ? `${(b / 1024).toFixed(1)} KB` : `${(b / 1024 / 1024).toFixed(1)} MB`;

  // ── Unified verifications model (view mode) ─────────────────────────────
  // Combines the legacy single certificate column with the multi-image note
  // attachments into one capped-at-3 list so the user can manage all
  // verifications from a single gallery.
  //   • Slot 0..N-1: existing note images (paths in record.notes IMAGES:[...])
  //   • Optional extra slot: the legacy certificate_url if present
  // The cert is shown last and isn't deletable from view mode — users can
  // still replace it in edit mode. New uploads always go to the note-images
  // bucket (uploadNoteImage), keeping the cert column read-only here.
  const verifyTiles = [];
  for (const p of existingImages) {
    verifyTiles.push({ kind: 'note', path: p, url: existingUrls[p] || null });
  }
  if (record.certificateUrl) {
    verifyTiles.push({ kind: 'cert', path: record.certificateUrl, url: certUrl });
  }
  const verifyCount = verifyTiles.length;
  const canAddVerify = verifyCount < 3 && !viewUploading;

  // Upload from view mode: stage one file → push to storage → patch the
  // record's notes blob with the new path. We bypass the full submit() path
  // (which also wants form fields validated) so the user can drop in a cert
  // without entering edit mode.
  const handleViewUpload = async (file) => {
    if (!file) return;
    setViewUploadErr('');
    if (!file.type.startsWith('image/') && file.type !== 'application/pdf') {
      setViewUploadErr(lang === 'zh' ? '只接受圖片或 PDF 檔案' : 'Image or PDF only');
      return;
    }
    if (file.size > 8 * 1024 * 1024) {
      setViewUploadErr(lang === 'zh' ? '檔案不能超過 8 MB' : 'File must be 8 MB or smaller');
      return;
    }
    setViewUploading(true);
    try {
      const sess = await window.AuthAPI?.getSession?.().catch(() => null);
      const userId = sess?.user?.id || null;
      if (!userId) {
        setViewUploadErr(lang === 'zh' ? '請先登入' : 'Please sign in first');
        return;
      }
      const res = await window.DB?.uploadNoteImage?.(userId, record.id, file);
      if (!res || res.error || !res.path) {
        setViewUploadErr(lang === 'zh' ? '上傳失敗，請再試' : 'Upload failed — please try again');
        return;
      }
      const nextPaths = [...existingImages, res.path].slice(0, 3);
      // Persist via onSave with only the notes patch. Title/provider/date
      // stay untouched.
      await onSave?.(record.id, {
        notes: buildNoteBlob({
          startDate: parsedNotes.startDate || form.startDate || null,
          imagePaths: nextPaths,
          completed: parsedNotes.completed || isRecordCompleted(record),
          text: parsedNotes.text || form.notesText || ''
        })
      });
      // Optimistically reflect locally so the gallery updates without
      // waiting for the parent to push a new `record` prop.
      setExistingImages(nextPaths);
    } finally {
      setViewUploading(false);
    }
  };

  const removeVerifyTile = async (tile) => {
    if (tile.kind === 'cert') {
      // Legacy cert removal is opt-in only via edit mode (keeps existing
      // certificate_url column behaviour intact). Silently no-op here.
      return;
    }
    const nextPaths = existingImages.filter((p) => p !== tile.path);
    setExistingImages(nextPaths);
    // best-effort delete from storage
    window.DB?.deleteNoteImage?.(tile.path).catch(() => {});
    await onSave?.(record.id, {
      notes: buildNoteBlob({
        startDate: parsedNotes.startDate || form.startDate || null,
        imagePaths: nextPaths,
        completed: parsedNotes.completed || isRecordCompleted(record),
        text: parsedNotes.text || form.notesText || ''
      })
    });
  };

  // Pretty-format an ISO date according to the lang.
  const fmtDateLong = (iso) => {
    if (!iso) return '—';
    try {
      const [y, m, d] = iso.split('-').map((n) => parseInt(n, 10));
      const dt = new Date(y, m - 1, d);
      return lang === 'zh' ?
      `${y}年${m}月${d}日` :
      dt.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
    } catch (e) {return iso;}
  };

  const submit = async () => {
    if (busy) return;
    setBusy(true);
    try {
      // Resolve user id once for all note-image uploads.
      const sess = await window.AuthAPI?.getSession?.().catch(() => null);
      const userId = sess?.user?.id || null;

      // Upload any newly-staged note images first so we have their paths.
      const uploadedPaths = [];
      for (const img of newImages) {
        if (!userId) break;
        const res = await window.DB?.uploadNoteImage?.(userId, record.id, img.file);
        if (res && res.path) uploadedPaths.push(res.path);else
        if (res && res.error) console.warn('uploadNoteImage failed', res.error);
      }
      const finalImagePaths = [...existingImages, ...uploadedPaths].slice(0, 3);

      const patch = {
        title: { en: form.title_en || form.title, zh: form.title_zh || form.title_en || form.title },
        provider: { en: form.provider_en || form.provider, zh: form.provider_zh || form.provider_en || form.provider },
        date: form.endDate,
        year: Number((form.endDate || '').slice(0, 4)) || record.year,
        points: Number(form.points) || 0,
        category: form.category,
        notes: buildNoteBlob({
          startDate: form.startDate || null,
          imagePaths: finalImagePaths,
          // Preserve the COMPLETED:1 marker — without this an edit would
          // strip the early-finish flag and silently push the record back
          // into 進行中 if its end date is still in the future.
          completed: parsedNotes.completed || isRecordCompleted(record),
          text: form.notesText
        })
      };
      if (certFile && !isInProgress) {
        await onUploadCert?.(record.id, certFile);
        patch.certificate = true;
      }
      await onSave?.(record.id, patch);
      onClose();
    } finally {
      setBusy(false);
    }
  };

  return (
    <Sheet open={true} onClose={onClose} title={editing ? lang === 'zh' ? '編輯紀錄' : 'Edit record' : lang === 'zh' ? '紀錄詳情' : 'Record detail'}>
      {/* Hidden file input shared by the view-mode "+ Upload verification"
           CTA. Kept outside the conditional so refs are stable on toggle. */}
      <input
        ref={verifyFileRef}
        type="file"
        accept="image/*,application/pdf"
        style={{ display: 'none' }}
        onChange={(e) => {
          const f = e.target.files?.[0];
          e.target.value = '';
          if (f) handleViewUpload(f);
        }} />
      

      {!editing ? (
      /* ── VIEW MODE ────────────────────────────────────────────────── */
      <div style={{ padding: '0 20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>

          {/* Course header — title, provider, status chip */}
          <div>
            <div className="mono muted-2" style={{ fontSize: 10.5, letterSpacing: '0.10em', textTransform: 'uppercase', marginBottom: 6 }}>
              {L(record.provider, lang)}
            </div>
            <div className="serif" style={{ fontSize: 22, lineHeight: 1.15, letterSpacing: '-0.01em', textWrap: 'pretty' }}>
              {L(record.title, lang)}
            </div>
            <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 10, alignItems: 'center' }}>
              <span className="chip">
                <CatDot cat={record.category || 'elective'} size={6} />
                {L(STR[record.category] || STR.elective, lang)}
              </span>
              <span className="chip">
                <span className="serif" style={{ fontSize: 13, letterSpacing: '-0.01em' }}>+{record.points}</span>
                <span className="muted-2" style={{ fontSize: 10.5, letterSpacing: '0.08em', textTransform: 'uppercase', marginLeft: 3 }}>
                  {L(STR.pts, lang)}
                </span>
              </span>
              {(() => {
              const inProg = !!record.date && record.date > todayLocalIso() && !isRecordCompleted(record);
              return (
                <span className="chip" style={{
                  color: inProg ? 'var(--accent)' : 'var(--success)'
                }}>
                    <span style={{
                    width: 6, height: 6, borderRadius: '50%',
                    background: inProg ? 'var(--accent)' : 'var(--success)',
                    display: 'inline-block'
                  }} />
                    {inProg ?
                  lang === 'zh' ? '進行中' : 'In progress' :
                  lang === 'zh' ? '已完成' : 'Completed'}
                  </span>);

            })()}
            </div>
          </div>

          {/* Date range card */}
          <div style={{
          display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 0,
          border: '0.5px solid var(--border)', borderRadius: 12,
          overflow: 'hidden', background: 'var(--surface)'
        }}>
            <div style={{ padding: '12px 14px' }}>
              <div className="mono muted-2" style={{ fontSize: 9.5, letterSpacing: '0.10em', textTransform: 'uppercase' }}>
                {lang === 'zh' ? '開始日期' : 'Start date'}
              </div>
              <div style={{ fontSize: 14, marginTop: 4 }}>
                {fmtDateLong(parsedNotes.startDate || record.date)}
              </div>
            </div>
            <div style={{ padding: '12px 14px', borderLeft: '0.5px solid var(--border)' }}>
              <div className="mono muted-2" style={{ fontSize: 9.5, letterSpacing: '0.10em', textTransform: 'uppercase' }}>
                {lang === 'zh' ? '結束日期' : 'End date'}
              </div>
              <div style={{ fontSize: 14, marginTop: 4 }}>
                {fmtDateLong(record.date)}
              </div>
            </div>
          </div>

          {/* Notes display (read-only) */}
          {parsedNotes.text &&
        <div>
              <div className="eyebrow" style={{ marginBottom: 8 }}>
                {lang === 'zh' ? '備註' : 'Notes'}
              </div>
              <div style={{
            padding: '12px 14px', borderRadius: 10,
            background: 'var(--surface)', border: '0.5px solid var(--border)',
            fontSize: 13.5, lineHeight: 1.55, whiteSpace: 'pre-wrap', textWrap: 'pretty'
          }}>
                {parsedNotes.text}
              </div>
            </div>
        }

          {/* Verifications gallery — up to 3 thumbs, tap to zoom */}
          <div>
            <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: 8 }}>
              <div className="eyebrow">
                {lang === 'zh' ? '上傳的證明' : 'Verifications'}
              </div>
              <div className="mono muted-2" style={{ fontSize: 10, letterSpacing: '0.08em' }}>
                {verifyCount}/3
              </div>
            </div>
            <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 10 }}>
              {verifyTiles.map((tile, idx) =>
            <div key={`${tile.kind}-${tile.path}`} style={{
              position: 'relative',
              aspectRatio: '1 / 1',
              borderRadius: 12, overflow: 'hidden',
              background: 'var(--surface-2)',
              border: '0.5px solid var(--border)'
            }}>
                  {tile.url ?
              /\.pdf$/i.test(tile.path || '') ?
              <button type="button"
              onClick={() => setZoomSrc({ src: tile.url, kind: 'pdf', label: tile.kind })}
              style={{
                width: '100%', height: '100%', cursor: 'zoom-in',
                background: 'color-mix(in oklab, var(--accent) 6%, var(--surface))',
                color: 'var(--accent)',
                display: 'flex', flexDirection: 'column',
                alignItems: 'center', justifyContent: 'center',
                gap: 6, border: 0, padding: 0
              }}>
                        <Icon name="cert" size={26} stroke={1.6} />
                        <span className="mono" style={{ fontSize: 9.5, letterSpacing: '0.08em' }}>PDF</span>
                      </button> :

              <button type="button"
              onClick={() => setZoomSrc({ src: tile.url, kind: 'image', label: tile.kind })}
              aria-label={lang === 'zh' ? '查看證明' : 'View verification'}
              style={{
                width: '100%', height: '100%', padding: 0, border: 0,
                background: 'transparent', cursor: 'zoom-in'
              }}>
                        <img src={tile.url} alt="" style={{
                  width: '100%', height: '100%', objectFit: 'cover', display: 'block'
                }} />
                      </button> :


              <div style={{
                width: '100%', height: '100%',
                display: 'flex', alignItems: 'center', justifyContent: 'center',
                color: 'var(--fg-3)'
              }}>
                      <Icon name="upload" size={20} stroke={1.5} />
                    </div>
              }
                  {tile.kind === 'cert' &&
              <div style={{
                position: 'absolute', bottom: 0, left: 0, right: 0,
                background: 'linear-gradient(to top, rgba(0,0,0,0.65), transparent)',
                color: '#fff', fontSize: 9.5, padding: '3px 6px',
                letterSpacing: '0.08em', textTransform: 'uppercase', textAlign: 'center'
              }}>
                      {lang === 'zh' ? '主要證書' : 'Certificate'}
                    </div>
              }
                  {tile.kind === 'note' &&
              <button type="button"
              onClick={() => removeVerifyTile(tile)}
              aria-label={lang === 'zh' ? '移除' : 'Remove'}
              style={{
                position: 'absolute', top: 6, right: 6,
                width: 22, height: 22, borderRadius: '50%',
                background: 'rgba(0,0,0,0.7)', color: '#fff',
                border: 0, padding: 0, cursor: 'pointer',
                display: 'flex', alignItems: 'center', justifyContent: 'center'
              }}>
                      <Icon name="close" size={11} stroke={2.4} />
                    </button>
              }
                </div>
            )}
              {canAddVerify &&
            <button type="button"
            onClick={() => verifyFileRef.current?.click()}
            disabled={viewUploading}
            style={{
              aspectRatio: '1 / 1',
              borderRadius: 12,
              background: 'transparent',
              border: '1px dashed var(--border-strong)',
              color: 'var(--fg-3)',
              cursor: viewUploading ? 'wait' : 'pointer',
              display: 'flex', flexDirection: 'column',
              alignItems: 'center', justifyContent: 'center',
              gap: 6
            }}>
                  {viewUploading ?
              <span className="cert-upload-spinner" aria-hidden="true" /> :

              <Icon name="plus" size={18} stroke={2} />
              }
                  <span style={{ fontSize: 10.5, letterSpacing: '0.04em', textAlign: 'center', padding: '0 4px' }}>
                    {viewUploading ?
                lang === 'zh' ? '上傳中…' : 'Uploading…' :
                lang === 'zh' ? '加入證明' : 'Add'}
                  </span>
                </button>
            }
            </div>
            {verifyCount === 0 && !canAddVerify &&
          <div className="muted" style={{ fontSize: 12, marginTop: 8 }}>
                {lang === 'zh' ? '未有證明上傳' : 'No verifications uploaded yet'}
              </div>
          }
            {viewUploadErr &&
          <div style={{ fontSize: 11.5, color: 'var(--danger)', marginTop: 8 }}>
                {viewUploadErr}
              </div>
          }
            <div className="muted-2" style={{ fontSize: 10.5, marginTop: 8, letterSpacing: '0.02em' }}>
              {lang === 'zh' ?
            '最多 3 份證明 · 圖片或 PDF · 單檔最大 8 MB · 點擊圖片可放大查看' :
            'Up to 3 verifications · image or PDF · 8 MB each · tap to zoom'}
            </div>
          </div>

          {/* Action row — Edit / Delete */}
          <div style={{ display: 'flex', gap: 10, marginTop: 4 }}>
            <button type="button" className="btn btn-ghost"
          style={{ flex: '0 0 auto', color: 'var(--danger)' }}
          onClick={() => setDelConfirm(true)}>
              <Icon name="close" size={14} stroke={2} />
              {lang === 'zh' ? '刪除' : 'Delete'}
            </button>
            <button type="button" className="btn btn-primary btn-fill"
          onClick={() => setEditing(true)}>
              <Icon name="edit" size={14} stroke={2} />
              {lang === 'zh' ? '編輯課程' : 'Edit course'}
            </button>
          </div>

          {/* Inline delete confirm — shared with edit mode below */}
          {delConfirm &&
        <div className="rx-del-confirm">
              <div style={{ fontSize: 13, color: 'var(--fg)' }}>
                {lang === 'zh' ? '確定要刪除這項紀錄？' : 'Delete this record?'}
              </div>
              <div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
                <button type="button" className="btn btn-ghost"
            onClick={() => setDelConfirm(false)}>
                  {lang === 'zh' ? '取消' : 'Cancel'}
                </button>
                <button type="button" className="btn btn-primary"
            style={{ background: 'var(--danger)', color: '#fff' }}
            onClick={async () => {
              await onDelete?.(record.id);
              onClose();
            }}>
                  {lang === 'zh' ? '刪除' : 'Delete'}
                </button>
              </div>
            </div>
        }
        </div>) : (

      /* ── EDIT MODE (existing form) ──────────────────────────────────── */
      <div style={{ padding: '0 20px 24px', display: 'flex', flexDirection: 'column', gap: 14 }}>
        {/* Back-to-view link */}
        <button type="button" className="filter-chip"
        onClick={() => setEditing(false)}
        style={{ alignSelf: 'flex-start' }}>
          <Icon name="chev-l" size={13} stroke={1.7} />
          {lang === 'zh' ? '返回詳情' : 'Back to detail'}
        </button>

        {/* Verification preview block — hidden for in-progress records. */}
        {!isInProgress &&
        <div className="rx-cert-block">
          <div className="eyebrow" style={{ marginBottom: 8 }}>
            {lang === 'zh' ? '證書 / 驗證' : 'Certificate / verification'}
          </div>
          <input
            ref={fileRef}
            type="file"
            accept="image/*,application/pdf"
            style={{ display: 'none' }}
            onChange={(e) => {
              const f = e.target.files?.[0];
              if (!f) return;
              if (f.size > 8 * 1024 * 1024) {
                alert(lang === 'zh' ? '檔案不能超過 8 MB' : 'File must be 8 MB or smaller');
                e.target.value = '';
                return;
              }
              setCertFile(f);
            }} />
          
          {certPreview ?
          <div className="rx-cert-preview">
              {certFile?.type?.startsWith('image/') ?
            <img src={certPreview} alt="cert preview" /> :

            <div className="rx-cert-pdf">
                  <Icon name="cert" size={32} stroke={1.5} />
                  <div style={{ fontSize: 12, marginTop: 6 }}>{certFile.name}</div>
                  <div className="muted-2" style={{ fontSize: 10.5, marginTop: 2 }}>{fmtSize(certFile.size)}</div>
                </div>
            }
              <div className="rx-cert-preview-actions">
                <button type="button" className="btn btn-ghost"
              onClick={() => fileRef.current?.click()}>
                  <Icon name="upload" size={13} stroke={1.8} />
                  {lang === 'zh' ? '更換檔案' : 'Replace'}
                </button>
                <button type="button" className="btn btn-ghost"
              onClick={() => {setCertFile(null);}}>
                  <Icon name="close" size={13} stroke={2} />
                  {lang === 'zh' ? '移除' : 'Remove'}
                </button>
              </div>
            </div> :
          hasCert ?
          <div className="rx-cert-existing">
              <div className="rx-cert-existing-icon">
                <Icon name="check-circ" size={20} stroke={1.7} color="var(--success)" />
              </div>
              <div style={{ flex: 1 }}>
                <div className="h-row" style={{ fontSize: 13.5 }}>
                  {lang === 'zh' ? '證書已上傳' : 'Certificate on file'}
                </div>
                <div className="muted" style={{ fontSize: 11.5, marginTop: 2 }}>
                  {record.certificateUrl ?
                record.certificateUrl.split('/').pop() :
                lang === 'zh' ? '已驗證' : 'verified'}
                </div>
              </div>
              <button type="button" className="btn btn-ghost"
            onClick={() => fileRef.current?.click()}
            style={{ padding: '7px 12px' }}>
                {lang === 'zh' ? '更換' : 'Replace'}
              </button>
            </div> :

          <button type="button" className="rx-cert-upload-cta"
          onClick={() => fileRef.current?.click()}>
              <div className="rx-cert-upload-icon">
                <Icon name="upload" size={22} stroke={1.7} />
              </div>
              <div>
                <div className="h-row" style={{ fontSize: 14 }}>
                  {lang === 'zh' ? '上傳證書' : 'Upload certificate'}
                </div>
                <div className="muted" style={{ fontSize: 11.5, marginTop: 3 }}>
                  {lang === 'zh' ? 'PDF / JPG / PNG · 最大 8 MB' : 'PDF / JPG / PNG · 8 MB max'}
                </div>
              </div>
            </button>
          }
        </div>
        }

        {/* In-progress hint — replaces the cert block so the user knows why
             it's hidden and what would unlock it. */}
        {isInProgress &&
        <div style={{
          display: 'flex', gap: 10, alignItems: 'flex-start',
          padding: '12px 14px', borderRadius: 10,
          background: 'color-mix(in oklab, var(--accent) 8%, var(--surface))',
          border: '0.5px solid color-mix(in oklab, var(--accent) 30%, transparent)'
        }}>
            <Icon name="calendar" size={16} stroke={1.8} color="var(--accent)" />
            <div style={{ fontSize: 12.5, color: 'var(--fg-2)', lineHeight: 1.5 }}>
              {lang === 'zh' ?
            '此紀錄為「進行中」,證書欄會在結束日期當天或之後出現。' :
            'This record is in progress — the certificate field will appear once the end date has passed.'}
            </div>
          </div>
        }

        {/* Edit fields */}
        <div className="field">
          <div className="field-label">{L(STR.courseTitle, lang)}</div>
          <input className="field-input" value={form.title}
          onChange={(e) => setForm({ ...form, title: e.target.value, title_en: e.target.value })} />
        </div>
        <div className="field">
          <div className="field-label">{L(STR.provider, lang)}</div>
          <input className="field-input" value={form.provider}
          onChange={(e) => setForm({ ...form, provider: e.target.value, provider_en: e.target.value })} />
        </div>
        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
          <div className="field">
            <div className="field-label">{L(STR.startDate, lang)}</div>
            <input className="field-input" type="date" value={form.startDate}
            max={form.endDate || undefined}
            onChange={(e) => setForm({ ...form, startDate: e.target.value })} />
          </div>
          <div className="field">
            <div className="field-label">{L(STR.endDate, lang)}</div>
            <input className="field-input" type="date" value={form.endDate}
            min={form.startDate || undefined}
            onChange={(e) => setForm({ ...form, endDate: e.target.value })} />
          </div>
        </div>
        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
          <div className="field">
            <div className="field-label">{L(STR.points, lang)}</div>
            <input className="field-input" type="number" min="0" step="0.5"
            value={form.points}
            onChange={(e) => setForm({ ...form, points: e.target.value })} />
          </div>
          <div className="field" style={{ display: 'flex', flexDirection: 'column', justifyContent: 'flex-end' }}>
            {form.endDate &&
            <div className="muted-2" style={{
              fontSize: 11, padding: '8px 10px',
              border: '0.5px solid var(--border)', borderRadius: 8,
              background: 'var(--surface)',
              display: 'flex', alignItems: 'center', gap: 6
            }}>
                <span style={{
                width: 6, height: 6, borderRadius: '50%',
                background: isInProgress ? 'var(--accent)' : 'var(--success)'
              }} />
                {isInProgress ?
              lang === 'zh' ? '進行中' : 'In progress' :
              lang === 'zh' ? '已完成' : 'Completed'}
              </div>
            }
          </div>
        </div>
        <div className="field">
          <div className="field-label">{L(STR.category, lang)}</div>
          <div style={{ display: 'flex', gap: 8 }}>
            {['core', 'elective', 'specialty'].map((cat) =>
            <button key={cat} type="button"
            className={'filter-chip' + (form.category === cat ? ' on' : '')}
            style={{ flex: 1, justifyContent: 'center' }}
            onClick={() => setForm({ ...form, category: cat })}>
                <CatDot cat={cat} size={6} /> {L(STR[cat], lang)}
              </button>
            )}
          </div>
        </div>
        <div className="field">
          <div className="field-label" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
            <span>{lang === 'zh' ? '備註（選填）' : 'Notes (optional)'}</span>
            <span className="muted-2" style={{ fontSize: 10.5, letterSpacing: '0.04em' }}>
              {lang === 'zh' ?
              `圖片 ${totalImages}/3` :
              `Images ${totalImages}/3`}
            </span>
          </div>
          <textarea className="field-input" rows={3} value={form.notesText}
          style={{ resize: 'vertical', minHeight: 64, font: 'inherit' }}
          onChange={(e) => setForm({ ...form, notesText: e.target.value })}
          placeholder={lang === 'zh' ? '例：講者、主題、心得…' : 'Speaker, topic, takeaway…'} />

          {/* Image attachments — up to 3 photos / images on the notes. */}
          <input
            ref={noteImgRef}
            type="file"
            accept="image/*"
            multiple
            style={{ display: 'none' }}
            onChange={onPickNoteImages} />
          
          <div style={{
            display: 'flex', gap: 8, flexWrap: 'wrap',
            marginTop: 10
          }}>
            {existingImages.map((p) =>
            <div key={p} className="rx-note-img" style={{
              position: 'relative',
              width: 78, height: 78, borderRadius: 10, overflow: 'hidden',
              background: 'var(--surface-2)',
              border: '0.5px solid var(--border)'
            }}>
                {existingUrls[p] ?
              <img src={existingUrls[p]} alt="note image" style={{
                width: '100%', height: '100%', objectFit: 'cover', display: 'block'
              }} /> :

              <div style={{
                width: '100%', height: '100%',
                display: 'flex', alignItems: 'center', justifyContent: 'center',
                color: 'var(--fg-3)'
              }}>
                    <Icon name="upload" size={20} stroke={1.5} />
                  </div>
              }
                <button type="button"
              onClick={() => removeExistingImage(p)}
              aria-label="Remove image"
              style={{
                position: 'absolute', top: 4, right: 4,
                width: 22, height: 22, borderRadius: '50%',
                background: 'rgba(0,0,0,0.7)', color: '#fff',
                border: 0, padding: 0, cursor: 'pointer',
                display: 'flex', alignItems: 'center', justifyContent: 'center'
              }}>
                  <Icon name="close" size={11} stroke={2.4} />
                </button>
              </div>
            )}
            {newImages.map((img, i) =>
            <div key={`new-${i}`} style={{
              position: 'relative',
              width: 78, height: 78, borderRadius: 10, overflow: 'hidden',
              background: 'var(--surface-2)',
              border: '0.5px solid var(--accent)'
            }}>
                <img src={img.previewUrl} alt="" style={{
                width: '100%', height: '100%', objectFit: 'cover', display: 'block'
              }} />
                <div style={{
                position: 'absolute', bottom: 0, left: 0, right: 0,
                background: 'linear-gradient(to top, rgba(0,0,0,0.6), transparent)',
                color: '#fff', fontSize: 9.5, padding: '2px 4px',
                textAlign: 'center', letterSpacing: '0.08em', textTransform: 'uppercase'
              }}>
                  {lang === 'zh' ? '新' : 'New'}
                </div>
                <button type="button"
              onClick={() => removeNewImage(i)}
              aria-label="Remove image"
              style={{
                position: 'absolute', top: 4, right: 4,
                width: 22, height: 22, borderRadius: '50%',
                background: 'rgba(0,0,0,0.7)', color: '#fff',
                border: 0, padding: 0, cursor: 'pointer',
                display: 'flex', alignItems: 'center', justifyContent: 'center'
              }}>
                  <Icon name="close" size={11} stroke={2.4} />
                </button>
              </div>
            )}
            {canAddMoreImages &&
            <button type="button"
            onClick={() => noteImgRef.current?.click()}
            style={{
              width: 78, height: 78, borderRadius: 10,
              background: 'transparent',
              border: '1px dashed var(--border-strong)',
              color: 'var(--fg-3)', cursor: 'pointer',
              display: 'flex', flexDirection: 'column',
              alignItems: 'center', justifyContent: 'center',
              gap: 4
            }}>
                <Icon name="plus" size={16} stroke={2} />
                <span style={{ fontSize: 10, letterSpacing: '0.06em' }}>
                  {lang === 'zh' ? '加入圖片' : 'Add image'}
                </span>
              </button>
            }
          </div>
          {imageUploadErr &&
          <div style={{ fontSize: 11.5, color: 'var(--danger)', marginTop: 6 }}>
              {imageUploadErr}
            </div>
          }
          <div className="muted-2" style={{ fontSize: 10.5, marginTop: 6, letterSpacing: '0.02em' }}>
            {lang === 'zh' ?
            '最多 3 張圖片 · JPG / PNG · 單張最大 5 MB' :
            'Up to 3 images · JPG / PNG · 5 MB each'}
          </div>
        </div>

        {/* Action row */}
        <div style={{ display: 'flex', gap: 10, marginTop: 6 }}>
          <button type="button" className="btn btn-ghost"
          style={{ flex: '0 0 auto', color: 'var(--danger)' }}
          onClick={() => setDelConfirm(true)}>
            <Icon name="close" size={14} stroke={2} />
            {lang === 'zh' ? '刪除' : 'Delete'}
          </button>
          <button type="button" className="btn btn-primary btn-fill"
          disabled={busy || !form.title || !form.startDate || !form.endDate || form.endDate < form.startDate}
          onClick={submit}>
            <Icon name="check" size={15} stroke={2.2} />
            {busy ? lang === 'zh' ? '儲存中…' : 'Saving…' :
            lang === 'zh' ? '儲存' : 'Save'}
          </button>
        </div>

        {/* Inline delete confirm */}
        {delConfirm &&
        <div className="rx-del-confirm">
            <div style={{ fontSize: 13, color: 'var(--fg)' }}>
              {lang === 'zh' ? '確定要刪除這項紀錄？' : 'Delete this record?'}
            </div>
            <div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
              <button type="button" className="btn btn-ghost"
            onClick={() => setDelConfirm(false)}>
                {lang === 'zh' ? '取消' : 'Cancel'}
              </button>
              <button type="button" className="btn btn-primary"
            style={{ background: 'var(--danger)', color: '#fff' }}
            onClick={async () => {
              await onDelete?.(record.id);
              onClose();
            }}>
                {lang === 'zh' ? '刪除' : 'Delete'}
              </button>
            </div>
          </div>
        }
      </div>)
      }

      {/* Zoom overlay — full-bleed image / pdf preview, dismiss by tap. */}
      {zoomSrc &&
      <div
        role="dialog"
        aria-modal="true"
        onClick={() => setZoomSrc(null)}
        style={{
          position: 'fixed', inset: 0,
          background: 'rgba(0,0,0,0.92)',
          zIndex: 1000,
          display: 'flex', alignItems: 'center', justifyContent: 'center',
          padding: 20, cursor: 'zoom-out'
        }}>
        
          <button type="button"
        onClick={(e) => {e.stopPropagation();setZoomSrc(null);}}
        aria-label="Close"
        style={{
          position: 'absolute', top: 16, right: 16,
          width: 40, height: 40, borderRadius: '50%',
          background: 'rgba(255,255,255,0.12)', color: '#fff',
          border: 0, padding: 0, cursor: 'pointer',
          display: 'flex', alignItems: 'center', justifyContent: 'center',
          backdropFilter: 'blur(8px)'
        }}>
            <Icon name="close" size={18} stroke={2} />
          </button>
          {zoomSrc.kind === 'pdf' ?
        <div onClick={(e) => e.stopPropagation()} style={{
          width: 'min(94vw, 900px)', height: 'min(86vh, 1100px)',
          background: '#fff', borderRadius: 8, overflow: 'hidden',
          cursor: 'default'
        }}>
              <iframe src={zoomSrc.src} title="verification pdf"
          style={{ width: '100%', height: '100%', border: 0, display: 'block' }} />
            </div> :

        <img src={zoomSrc.src} alt="verification"
        onClick={(e) => e.stopPropagation()}
        style={{
          maxWidth: '94vw', maxHeight: '86vh',
          objectFit: 'contain', borderRadius: 6,
          boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
          cursor: 'default'
        }} />
        }
        </div>
      }
    </Sheet>);

}

Object.assign(window, {
  parseSessions,
  useAttendance,
  SessionPill,
  InProgressCourseCard,
  InProgressSection,
  PendingVerificationSection,
  RecordDetailSheet,
  // Note-blob format + completion-flag helpers (screens.jsx + handlers
  // outside this file use these so the COMPLETED:1 marker stays in sync).
  parseNoteBlob,
  buildNoteBlob,
  isRecordCompleted,
  // Shared local-date helpers (screens.jsx reuses these so the in-progress
  // ↔ completed split uses the same notion of "today" everywhere).
  ymdLocal,
  todayLocalIso,
  localDateFromIso
});