// components.jsx — shared components & helpers
// Exposes to window so other Babel scripts can use them.

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

// ─────────────────────────────────────────────────────────────
// Time helpers

const WEEKDAY_SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];

// All times are computed in the "server timezone" given as an offset from UTC.
// `event` can be one of:
//   { recurrence: 'weekly',   weekday, start_time }
//   { recurrence: 'interval', intervalDays, anchorDate (YYYY-MM-DD), start_time }
//   { recurrence: 'once',     date (YYYY-MM-DD), start_time }
//   (legacy: no recurrence field → treated as 'weekly')
// Returns the next Date (UTC instant) at or after `from`, or null if 'once' is already past.
function nextOccurrence(event, from, tzOffsetHrs) {
  const recurrence = event.recurrence || 'weekly';
  const [hh, mm] = (event.start_time || '00:00').split(':').map(Number);
  const nowMs = from.getTime();

  // Helper: build a UTC instant for a given server-local date + time
  const serverDateAt = (year, month, day) => {
    const serverLocal = Date.UTC(year, month, day, hh, mm, 0, 0);
    return new Date(serverLocal - tzOffsetHrs * 3600000);
  };

  if (recurrence === 'once') {
    if (!event.date) return null;
    const [y, mo, d] = event.date.split('-').map(Number);
    const t = serverDateAt(y, mo - 1, d);
    return t.getTime() >= nowMs - 60000 ? t : null;
  }

  if (recurrence === 'interval') {
    const interval = Math.max(1, parseInt(event.intervalDays, 10) || 2);
    const anchor = event.anchorDate || new Date(nowMs).toISOString().slice(0, 10);
    const [ay, amo, ad] = anchor.split('-').map(Number);
    const anchorT = serverDateAt(ay, amo - 1, ad);
    // Step forward in interval-day increments from anchor until >= nowMs
    const stepMs = interval * 86400000;
    let t = anchorT.getTime();
    if (t < nowMs - 60000) {
      const k = Math.ceil((nowMs - t) / stepMs);
      t += k * stepMs;
    }
    return new Date(t);
  }

  // 'weekly' (default)
  const weekday = event.weekday ?? 1;
  for (let i = 0; i < 8; i++) {
    const probe = new Date(nowMs + i * 86400000);
    const shifted = new Date(probe.getTime() + tzOffsetHrs * 3600000);
    if (shifted.getUTCDay() === weekday) {
      const t = serverDateAt(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate());
      if (t.getTime() >= nowMs - 60000) return t;
    }
  }
  for (let i = 0; i < 14; i++) {
    const probe = new Date(nowMs + i * 86400000);
    const shifted = new Date(probe.getTime() + tzOffsetHrs * 3600000);
    if (shifted.getUTCDay() === weekday) {
      return serverDateAt(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate());
    }
  }
  return new Date(from);
}

// Does this event occur on this specific server-local date?
// `dateUTC` is a UTC Date whose UTC y/m/d matches the server-local day we're querying.
function eventOccursOn(event, dateUTC) {
  const recurrence = event.recurrence || 'weekly';
  if (recurrence === 'weekly') {
    return dateUTC.getUTCDay() === (event.weekday ?? 1);
  }
  if (recurrence === 'once') {
    if (!event.date) return false;
    const [y, m, d] = event.date.split('-').map(Number);
    return dateUTC.getUTCFullYear() === y && dateUTC.getUTCMonth() === m - 1 && dateUTC.getUTCDate() === d;
  }
  if (recurrence === 'interval') {
    if (!event.anchorDate) return false;
    const interval = Math.max(1, parseInt(event.intervalDays, 10) || 2);
    const [ay, am, ad] = event.anchorDate.split('-').map(Number);
    const anchor = Date.UTC(ay, am - 1, ad);
    const target = Date.UTC(dateUTC.getUTCFullYear(), dateUTC.getUTCMonth(), dateUTC.getUTCDate());
    if (target < anchor) return false;
    const days = Math.round((target - anchor) / 86400000);
    return days % interval === 0;
  }
  return false;
}

function formatDuration(ms) {
  if (ms < 0) ms = 0;
  const totalSec = Math.floor(ms / 1000);
  const d = Math.floor(totalSec / 86400);
  const h = Math.floor((totalSec % 86400) / 3600);
  const m = Math.floor((totalSec % 3600) / 60);
  const s = totalSec % 60;
  if (d > 0) return `${d}d ${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
  return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}

function formatDurationMin(min) {
  if (min >= 60 && min % 60 === 0) return `${min / 60}h`;
  if (min >= 60) return `${Math.floor(min / 60)}h ${min % 60}m`;
  return `${min}m`;
}

function formatRelativePast(iso, now) {
  const ms = now.getTime() - new Date(iso).getTime();
  if (ms < 60000) return 'just now';
  const min = Math.floor(ms / 60000);
  if (min < 60) return `${min}m ago`;
  const hr = Math.floor(min / 60);
  if (hr < 48) return `${hr}h ago`;
  const dy = Math.floor(hr / 24);
  return `${dy}d ago`;
}

function formatServerTime(date, tzOffsetHrs) {
  const shifted = new Date(date.getTime() + tzOffsetHrs * 3600000);
  const wd = WEEKDAY_SHORT[shifted.getUTCDay()];
  const hh = String(shifted.getUTCHours()).padStart(2, '0');
  const mm = String(shifted.getUTCMinutes()).padStart(2, '0');
  return `${wd} ${hh}:${mm}`;
}

function tzLabel(offsetHrs) {
  if (offsetHrs === 0) return 'UTC';
  const sign = offsetHrs > 0 ? '+' : '-';
  const a = Math.abs(offsetHrs);
  return `UTC${sign}${a}`;
}

// ─────────────────────────────────────────────────────────────
// Live clock — re-renders every second

function useTick(intervalMs = 1000) {
  const [, setT] = useState(0);
  useEffect(() => {
    const i = setInterval(() => setT(x => x + 1), intervalMs);
    return () => clearInterval(i);
  }, [intervalMs]);
}

// ─────────────────────────────────────────────────────────────
// Power formatting — raw integer (e.g. 614_860_225) → '614.86M'

function formatPower(p) {
  if (p == null) return '—';
  const m = p / 1_000_000;
  if (m >= 1000) return (m / 1000).toFixed(2) + 'B';
  if (m >= 100)  return m.toFixed(1) + 'M';
  return m.toFixed(2) + 'M';
}

// ─────────────────────────────────────────────────────────────
// Activity algorithm
//
// Definition of "change-day": a day where this member's power grew by
// at least `settings.minChangePower` raw units compared to the previous
// recorded snapshot. Tiny daily drift from passive gather is ignored.

function computeActivity(member, history, settings, now) {
  // history: array of {timestamp, power} for this member, sorted by timestamp ASC
  const windowMs = settings.windowDays * 86400000;
  const cutoff = now.getTime() - windowMs;
  const recent = history.filter(h => new Date(h.timestamp).getTime() >= cutoff);
  const minDelta = settings.minChangePower ?? 1_000_000;

  let changes = 0;
  let prev = null;
  for (const h of recent) {
    if (prev !== null && (h.power - prev) >= minDelta) changes++;
    prev = h.power;
  }

  const loginDays = member.last_login
    ? (now.getTime() - new Date(member.last_login).getTime()) / 86400000
    : Infinity; // null login = never

  // 'new' = less than 5 days of data
  if (recent.length < 5) return { level: 'new', changes, loginDays };
  if (changes >= settings.activeThreshold) return { level: 'active', changes, loginDays };
  if (changes >= settings.normalThreshold) return { level: 'normal', changes, loginDays };
  if (loginDays <= settings.dormantLoginDays) return { level: 'dormant', changes, loginDays };
  return { level: 'lost', changes, loginDays };
}

// Group history by member_id, sorted ASC
function groupHistory(history) {
  const map = {};
  for (const h of history) {
    if (!map[h.member_id]) map[h.member_id] = [];
    map[h.member_id].push(h);
  }
  for (const id in map) {
    map[id].sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
  }
  return map;
}

// ─────────────────────────────────────────────────────────────
// Sparkline — flat-line/step chart for power over N days

function Sparkline({ history, days = 14, width = 140, height = 22 }) {
  if (!history || history.length === 0) {
    return <svg className="spark" width={width} height={height}></svg>;
  }
  const values = history.slice(-days).map(h => h.power);
  const min = Math.min(...values);
  const max = Math.max(...values);
  const range = max - min || 1;
  const padding = 2;
  const w = width - padding * 2;
  const h = height - padding * 2;
  const step = w / Math.max(values.length - 1, 1);

  const points = values.map((v, i) => {
    const x = padding + i * step;
    const y = padding + h - ((v - min) / range) * h;
    return [x, y];
  });

  // Step path
  let d = `M ${points[0][0].toFixed(1)} ${points[0][1].toFixed(1)}`;
  for (let i = 1; i < points.length; i++) {
    const [x, y] = points[i];
    const prevY = points[i - 1][1];
    d += ` L ${x.toFixed(1)} ${prevY.toFixed(1)} L ${x.toFixed(1)} ${y.toFixed(1)}`;
  }

  const isFlat = min === max;

  return (
    <svg className="spark" width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
      <line x1={padding} x2={width - padding} y1={height / 2} y2={height / 2}
            stroke="var(--border-soft)" strokeWidth="1" strokeDasharray="2 3" />
      <path
        d={d}
        fill="none"
        stroke={isFlat ? 'var(--fg-4)' : 'var(--accent)'}
        strokeWidth="1.4"
        strokeLinejoin="miter"
        strokeLinecap="square"
      />
      {points.map(([x, y], i) => (
        i === points.length - 1 ? (
          <circle key={i} cx={x} cy={y} r="2" fill={isFlat ? 'var(--fg-4)' : 'var(--accent)'} />
        ) : null
      ))}
    </svg>
  );
}

// ─────────────────────────────────────────────────────────────
// Activity badge

function ActivityBadge({ level }) {
  const letter = { active: 'A', normal: 'N', dormant: 'D', lost: 'L', new: 'NEW' }[level] || '?';
  return (
    <span className="act" data-l={level}>
      <span className="dot"></span>
      <span className="letter">{letter}</span>
    </span>
  );
}

// ─────────────────────────────────────────────────────────────
// Inline editable field — for nicknames, wave names, distance

function InlineEdit({ value, onChange, placeholder, className = '', numeric = false }) {
  const ref = useRef(null);
  const handleBlur = (e) => {
    let v = e.currentTarget.innerText.trim();
    if (numeric) {
      const n = parseInt(v, 10);
      onChange(isNaN(n) ? null : n);
    } else {
      onChange(v);
    }
  };
  const handleKey = (e) => {
    if (e.key === 'Enter') {
      e.preventDefault();
      e.currentTarget.blur();
    } else if (e.key === 'Escape') {
      e.preventDefault();
      e.currentTarget.innerText = value == null ? '' : String(value);
      e.currentTarget.blur();
    }
  };
  return (
    <span
      ref={ref}
      className={`inline-edit ${className} ${value == null || value === '' ? 'empty' : ''}`}
      contentEditable
      suppressContentEditableWarning
      onBlur={handleBlur}
      onKeyDown={handleKey}
      data-placeholder={placeholder}
    >{value == null || value === '' ? (placeholder || '—') : value}</span>
  );
}

// ─────────────────────────────────────────────────────────────
// Toast

function useToast() {
  const [toast, setToast] = useState(null);
  const timer = useRef(null);
  const show = useCallback((msg) => {
    setToast(msg);
    if (timer.current) clearTimeout(timer.current);
    timer.current = setTimeout(() => setToast(null), 1800);
  }, []);
  return [toast, show];
}

// ─────────────────────────────────────────────────────────────
// Copy to clipboard

async function copyText(text) {
  try {
    await navigator.clipboard.writeText(text);
    return true;
  } catch (e) {
    // Fallback
    const ta = document.createElement('textarea');
    ta.value = text;
    ta.style.position = 'fixed';
    ta.style.opacity = '0';
    document.body.appendChild(ta);
    ta.select();
    try { document.execCommand('copy'); } catch (_) {}
    document.body.removeChild(ta);
    return false;
  }
}

// ─────────────────────────────────────────────────────────────
// Top bar

function TopBar({ tab, setTab, tzOffsetHrs, snapshotMeta }) {
  useTick(1000);
  const now = new Date();
  const snapshotAgeMs = now.getTime() - new Date(snapshotMeta.last_sync).getTime();
  const snapshotAgeHrs = snapshotAgeMs / 3600000;
  const ageBucket = snapshotAgeHrs < 12 ? 'fresh' : snapshotAgeHrs < 36 ? 'stale' : 'dead';
  const ageLabel = formatRelativePast(snapshotMeta.last_sync, now);

  const tabs = [
    { id: 'home', label: 'Home', kbd: '1' },
    { id: 'waves', label: 'BT', kbd: '2' },
    { id: 'activity', label: 'Activity', kbd: '3' },
    { id: 'settings', label: 'Settings', kbd: '4' },
  ];

  return (
    <header className="topbar">
      <div className="brand">
        KINGSHOT<span className="slash">//</span>R4
      </div>
      <nav className="tabs" role="tablist">
        {tabs.map(t => (
          <button
            key={t.id}
            className="tab"
            role="tab"
            aria-selected={tab === t.id}
            onClick={() => setTab(t.id)}
          >
            {t.label}
            <span className="kbd">{t.kbd}</span>
          </button>
        ))}
      </nav>
      <div className="topbar-right">
        <div className="snap" data-age={ageBucket} title={`Last sync: ${new Date(snapshotMeta.last_sync).toUTCString()}`}>
          <span className="dot"></span>
          <span className="label">SYNC {ageLabel}</span>
          <span className="sep">·</span>
          <span>{snapshotMeta.member_count} members</span>
        </div>
        <div className="clock">
          {formatServerTime(now, tzOffsetHrs)} <span className="muted">{tzLabel(tzOffsetHrs)}</span>
        </div>
      </div>
    </header>
  );
}

// ─────────────────────────────────────────────────────────────
// Confirm dialog

function ConfirmDialog({ open, message, onCancel, onConfirm, confirmLabel = 'Confirm' }) {
  useEffect(() => {
    if (!open) return;
    const h = (e) => {
      if (e.key === 'Escape') onCancel();
      if (e.key === 'Enter') onConfirm();
    };
    window.addEventListener('keydown', h);
    return () => window.removeEventListener('keydown', h);
  }, [open, onCancel, onConfirm]);
  if (!open) return null;
  return (
    <>
      <div className="drawer-mask" onClick={onCancel}></div>
      <div style={{
        position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%,-50%)',
        background: 'var(--surface)', border: '1px solid var(--border-hard)',
        padding: '20px', borderRadius: 6, zIndex: 100, minWidth: 320, maxWidth: 420,
      }}>
        <div style={{ marginBottom: 16, fontSize: 13, color: 'var(--fg)' }}>{message}</div>
        <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
          <button className="btn ghost" onClick={onCancel}>Cancel</button>
          <button className="btn primary" onClick={onConfirm}>{confirmLabel}</button>
        </div>
      </div>
    </>
  );
}

// ─────────────────────────────────────────────────────────────
// Distance → seconds (march time)
// tiles / (tiles per second) = seconds

function tilesToSeconds(tiles, marchSpeed) {
  if (tiles == null) return null;
  if (!marchSpeed || marchSpeed <= 0) return null;
  return Math.round(tiles / marchSpeed);
}

function formatMarchTime(seconds) {
  if (seconds == null) return '—';
  if (seconds < 60) return `${seconds}s`;
  const m = Math.floor(seconds / 60);
  const s = seconds % 60;
  if (m < 60) return `${m}m ${String(s).padStart(2, '0')}s`;
  const h = Math.floor(m / 60);
  const mm = m % 60;
  return `${h}h ${String(mm).padStart(2, '0')}m`;
}

// ─────────────────────────────────────────────
// BarChart — daily-delta visualization for furnace history

function BarChart({ history, days = 14, width = 140, height = 22 }) {
  if (!history || history.length === 0) {
    return <svg className="spark" width={width} height={height}></svg>;
  }
  const values = history.slice(-days).map(h => h.power);
  // daily delta (today minus yesterday)
  const deltas = [];
  for (let i = 1; i < values.length; i++) {
    deltas.push(values[i] - values[i - 1]);
  }
  // Pad the start with one zero so we have `days` bars total
  while (deltas.length < days) deltas.unshift(0);
  const maxDelta = Math.max(1, ...deltas);
  const padding = 2;
  const w = width - padding * 2;
  const h = height - padding * 2;
  const slotW = w / days;
  const barW = Math.max(2, slotW - 2);

  return (
    <svg className="spark" width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
      <line x1={padding} x2={width - padding} y1={height - padding} y2={height - padding}
            stroke="var(--border-soft)" strokeWidth="1" />
      {deltas.map((d, i) => {
        const x = padding + i * slotW + (slotW - barW) / 2;
        const isZero = d <= 0;
        const barH = isZero ? 1 : Math.max(2, (d / maxDelta) * h);
        const y = height - padding - barH;
        return (
          <rect
            key={i}
            x={x}
            y={y}
            width={barW}
            height={barH}
            fill={isZero ? 'var(--border-hard)' : 'var(--accent)'}
            rx={1}
          >
            <title>{`Day ${-(days - 1 - i)}: ${d > 0 ? '+' + formatPower(d) : '0'}`}</title>
          </rect>
        );
      })}
    </svg>
  );
}

// ─────────────────────────────────────────────
// Chunked copy — split text into <= maxLen chunks at sensible boundaries

function splitChunks(text, maxLen = 512) {
  if (!text) return [''];
  if (text.length <= maxLen) return [text];
  const chunks = [];
  let rest = text;
  while (rest.length > maxLen) {
    let cut = rest.lastIndexOf('\n\n', maxLen);
    if (cut < maxLen * 0.5) cut = rest.lastIndexOf('\n', maxLen);
    if (cut < maxLen * 0.5) cut = rest.lastIndexOf(' ', maxLen);
    if (cut < maxLen * 0.5) cut = maxLen;
    chunks.push(rest.slice(0, cut).trim());
    rest = rest.slice(cut).trim();
  }
  if (rest.length > 0) chunks.push(rest);
  return chunks;
}

// ─────────────────────────────────────────────
// Event color palette — used in EventModal + calendar pills

const EVENT_COLORS = [
  '#ef4444', // red
  '#f59e0b', // amber
  '#84cc16', // lime
  '#10b981', // emerald
  '#06b6d4', // cyan
  '#3b82f6', // blue
  '#8b5cf6', // violet
  '#ec4899', // pink
  '#71717a', // zinc
];

function defaultColorForCategory(cat) {
  if (cat === 'pvp')      return '#ef4444';
  if (cat === 'pve')      return '#3b82f6';
  if (cat === 'alliance') return '#8b5cf6';
  if (cat === 'personal') return '#f59e0b';
  return '#71717a';
}

function colorToTint(hex, alpha = 0.14) {
  if (!hex || !hex.startsWith('#')) return 'transparent';
  const r = parseInt(hex.slice(1, 3), 16);
  const g = parseInt(hex.slice(3, 5), 16);
  const b = parseInt(hex.slice(5, 7), 16);
  return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}

// ─────────────────────────────────────────────
// EventOccurrencePreview — shows next 3 occurrences of a draft event
// Gives live feedback inside the EventModal as the user edits recurrence.

function EventOccurrencePreview({ event }) {
  const previews = [];
  if (event.name) {
    let from = new Date();
    for (let i = 0; i < 3; i++) {
      const occ = nextOccurrence(event, from, 0);
      if (!occ) break;
      previews.push(occ);
      from = new Date(occ.getTime() + 60000);
    }
  }

  return (
    <div className="form-row">
      <label>Next occurrences (server time)</label>
      <div style={{
        background: 'var(--bg)',
        border: '1px solid var(--border-soft)',
        borderRadius: 4,
        padding: '8px 10px',
        fontFamily: 'var(--font-mono)',
        fontSize: 11,
        color: 'var(--fg-2)',
        minHeight: 32,
      }}>
        {previews.length === 0 ? (
          <span className="muted">No upcoming — check recurrence settings.</span>
        ) : (
          <div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
            {previews.map((d, i) => {
              const wd = WEEKDAY_SHORT[d.getUTCDay()];
              const mo = String(d.getUTCMonth() + 1).padStart(2, '0');
              const day = String(d.getUTCDate()).padStart(2, '0');
              const hh = String(d.getUTCHours()).padStart(2, '0');
              const mm = String(d.getUTCMinutes()).padStart(2, '0');
              const ms = d.getTime() - Date.now();
              const inText = ms < 0 ? 'now' : `in ${formatDuration(ms).replace(/:00$/, '')}`;
              return (
                <div key={i} style={{ display: 'flex', gap: 10 }}>
                  <span style={{ color: 'var(--fg)' }}>{wd} {mo}/{day} {hh}:{mm}</span>
                  <span className="muted">— {inText}</span>
                </div>
              );
            })}
          </div>
        )}
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────
// Event edit modal — create or edit a single event

function EventModal({ event, templates, onSave, onDelete, onCancel }) {
  const isNew = !event.id || event.id.startsWith('new-');
  const today = new Date().toISOString().slice(0, 10);
  const [form, setForm] = useState({
    id: event.id || `evt-${Date.now()}`,
    name: event.name || '',
    recurrence: event.recurrence || 'weekly',
    weekday: event.weekday ?? 1,
    intervalDays: event.intervalDays ?? 2,
    anchorDate: event.anchorDate || today,
    date: event.date || today,
    start_time: event.start_time || '20:00',
    duration_min: event.duration_min ?? 60,
    category: event.category || 'alliance',
    color: event.color || defaultColorForCategory(event.category || 'alliance'),
    guide_text: event.guide_text || '',
  });

  useEffect(() => {
    const h = (e) => { if (e.key === 'Escape') onCancel(); };
    window.addEventListener('keydown', h);
    return () => window.removeEventListener('keydown', h);
  }, [onCancel]);

  const applyTemplate = (tplId) => {
    const tpl = templates.find(t => t.id === tplId);
    if (!tpl) return;
    setForm(f => ({
      ...f,
      name: tpl.name,
      duration_min: tpl.duration_min,
      category: tpl.category,
      guide_text: tpl.guide_text,
    }));
  };

  const set = (k, v) => setForm(f => {
    const next = { ...f, [k]: v };
    // When category changes, auto-update color if it was the previous category's default
    if (k === 'category' && f.color === defaultColorForCategory(f.category)) {
      next.color = defaultColorForCategory(v);
    }
    return next;
  });

  return (
    <>
      <div className="drawer-mask" onClick={onCancel}></div>
      <div className="modal">
        <div className="modal-hd">
          <h3>{isNew ? 'New event' : 'Edit event'}</h3>
          <button className="close" onClick={onCancel}>×</button>
        </div>
        <div className="modal-body">
          <div className="form-row">
            <label>Template</label>
            <div className="chips">
              {templates.map(tpl => (
                <span key={tpl.id} className="chip" onClick={() => applyTemplate(tpl.id)}>
                  {tpl.label}
                </span>
              ))}
            </div>
          </div>
          <div className="form-row">
            <label>Name</label>
            <input className="input" value={form.name} onChange={(e) => set('name', e.target.value)} />
          </div>
          <div className="form-row">
            <label>Repeat</label>
            <div className="chips">
              {[
                { v: 'weekly',   l: 'Weekly' },
                { v: 'interval', l: 'Every N days' },
                { v: 'once',     l: 'One-off' },
              ].map(o => (
                <span
                  key={o.v}
                  className="chip"
                  data-on={form.recurrence === o.v}
                  onClick={() => set('recurrence', o.v)}
                >{o.l}</span>
              ))}
            </div>
          </div>
          <div className="form-grid">
            {form.recurrence === 'weekly' && (
              <div className="form-row">
                <label>Weekday</label>
                <select className="input" value={form.weekday} onChange={(e) => set('weekday', parseInt(e.target.value, 10))}>
                  {WEEKDAY_SHORT.map((wd, i) => <option key={i} value={i}>{wd}</option>)}
                </select>
              </div>
            )}
            {form.recurrence === 'interval' && (
              <>
                <div className="form-row">
                  <label>Every</label>
                  <input className="input" type="number" min="1" max="60" value={form.intervalDays}
                         onChange={(e) => set('intervalDays', parseInt(e.target.value, 10) || 1)} />
                </div>
                <div className="form-row">
                  <label>days · from</label>
                  <input className="input" type="date" value={form.anchorDate}
                         onChange={(e) => set('anchorDate', e.target.value)} />
                </div>
              </>
            )}
            {form.recurrence === 'once' && (
              <div className="form-row">
                <label>Date</label>
                <input className="input" type="date" value={form.date}
                       onChange={(e) => set('date', e.target.value)} />
              </div>
            )}
            <div className="form-row">
              <label>Start (HH:mm)</label>
              <input className="input" type="time" value={form.start_time} onChange={(e) => set('start_time', e.target.value)} />
            </div>
            <div className="form-row">
              <label>Duration (min)</label>
              <input className="input" type="number" min="1" value={form.duration_min}
                     onChange={(e) => set('duration_min', parseInt(e.target.value, 10) || 0)} />
            </div>
            <div className="form-row">
              <label>Category</label>
              <select className="input" value={form.category} onChange={(e) => set('category', e.target.value)}>
                <option value="pvp">pvp</option>
                <option value="pve">pve</option>
                <option value="alliance">alliance</option>
                <option value="personal">personal</option>
              </select>
            </div>
          </div>
          <div className="form-row">
            <label>Color</label>
            <div className="color-swatches">
              {EVENT_COLORS.map(c => (
                <button
                  key={c}
                  type="button"
                  className="swatch"
                  data-on={form.color === c}
                  style={{ background: c }}
                  onClick={() => set('color', c)}
                  aria-label={`Color ${c}`}
                />
              ))}
              <input
                type="color"
                className="swatch swatch-custom"
                value={form.color || '#71717a'}
                onChange={(e) => set('color', e.target.value)}
                title="Custom color"
              />
            </div>
          </div>
          <div className="form-row">
            <label>Guide text <span className="muted">({form.guide_text.length} chars)</span></label>
            <textarea
              className="input"
              rows={10}
              value={form.guide_text}
              onChange={(e) => set('guide_text', e.target.value)}
              placeholder="What to paste into Discord/chat when this event starts."
            />
          </div>
          <EventOccurrencePreview event={form} />
        </div>
        <div className="modal-ft">
          {!isNew && <button className="btn danger" onClick={() => onDelete(form.id)}>Delete</button>}
          <span className="spacer"></span>
          <button className="btn ghost" onClick={onCancel}>Cancel</button>
          <button className="btn primary" onClick={() => onSave(form)} disabled={!form.name.trim()}>
            {isNew ? 'Create' : 'Save'}
          </button>
        </div>
      </div>
    </>
  );
}

// ─────────────────────────────────────────────
// Expose

Object.assign(window, {
  WEEKDAY_SHORT,
  EVENT_COLORS,
  defaultColorForCategory,
  colorToTint,
  nextOccurrence,
  eventOccursOn,
  formatDuration,
  formatDurationMin,
  formatRelativePast,
  formatServerTime,
  formatPower,
  tzLabel,
  useTick,
  computeActivity,
  groupHistory,
  Sparkline,
  BarChart,
  ActivityBadge,
  InlineEdit,
  useToast,
  copyText,
  TopBar,
  ConfirmDialog,
  tilesToSeconds,
  formatMarchTime,
  splitChunks,
  EventModal,
});
