/* global React, diary, SplitPane, InlineAdd, DelBtn, openExternalUrl, PomodoroBarButton, buildTodoTapeStyle, todoTapeClassName */
// ===========================================================
// 할 일 — 위(2/3): 입력 + 모든 할 일
//          아래(1/3): 핀(!) · 반복 · 마감일 설정한 할 일 자동 표시
// • 행은 마스킹테이프 느낌 (연한 파스텔)
// • 메모는 (지금은) 표시하지 않음
// ===========================================================
const { useState, useEffect } = React;

// macOS WKWebView: drop 시 dataTransfer.getData 가 비는 경우가 있어 dragStart 에서 ID 보관
let _todoHtml5DragId = null;

function todoDragIdFromEvent(e) {
  return _todoHtml5DragId
    || e.dataTransfer.getData("text/todo-id")
    || e.dataTransfer.getData("text/plain")
    || null;
}

const EXCEL_COL_STORAGE_KEY = "todoary.excel.colWidths";
// [행 거터, ✓, 시간] — 할 일 열은 minmax(0,1fr). 일정(C) 열은 그룹 헤더로 대체되어 제거.
const EXCEL_COL_DEFAULT = [26, 30, 52];
const EXCEL_COL_MIN = [20, 24, 40];
const EXCEL_COL_MAX = [40, 44, 80];

function normalizeExcelColWidths(parsed) {
  // 구버전 4열([거터, ✓, 일정, 시간]) 저장값 → 일정 폭을 버리고 3열로 이행
  const arr = Array.isArray(parsed) && parsed.length === 4
    ? [parsed[0], parsed[1], parsed[3]]
    : parsed;
  if (!Array.isArray(arr) || arr.length !== 3) return [...EXCEL_COL_DEFAULT];
  return arr.map((n, i) => {
    const v = Number(n);
    if (!Number.isFinite(v)) return EXCEL_COL_DEFAULT[i];
    return Math.max(EXCEL_COL_MIN[i], Math.min(EXCEL_COL_MAX[i], Math.round(v)));
  });
}

function loadExcelColWidths() {
  try {
    const raw = localStorage.getItem(EXCEL_COL_STORAGE_KEY);
    if (!raw) return [...EXCEL_COL_DEFAULT];
    return normalizeExcelColWidths(JSON.parse(raw));
  } catch (_) {
    return [...EXCEL_COL_DEFAULT];
  }
}

function excelTodoColsFromWidths(widths) {
  const w = widths || EXCEL_COL_DEFAULT;
  return `${w[0]}px ${w[1]}px minmax(0, 1fr) ${w[2]}px`;
}

function excelTodoGridStyle(widths) {
  const cols = excelTodoColsFromWidths(widths);
  return {
    "--excel-todo-cols": cols,
    width: "100%",
    minWidth: 0,
    maxWidth: "100%",
    overflow: "hidden",
    boxSizing: "border-box",
  };
}

function excelTodoRowGridStyle(tapeStyle, widths) {
  return {
    ...tapeStyle,
    display: "grid",
    gridTemplateColumns: excelTodoColsFromWidths(widths),
    width: "100%",
    minWidth: 0,
    maxWidth: "100%",
    boxSizing: "border-box",
    overflow: "hidden",
  };
}

function computeExcelTodoAgg(list) {
  const rows = list ?? [];
  const total = rows.length;
  const done = rows.filter((t) => t.done).length;
  const remain = Math.max(0, total - done);
  let itemTotal = 0;
  let itemDone = 0;
  rows.forEach((t) => {
    itemTotal += 1 + (t.subTasks?.length ?? 0);
    itemDone += (t.done ? 1 : 0) + (t.subTasks?.filter((st) => st.done).length ?? 0);
  });
  const pct = itemTotal > 0 ? Math.round((itemDone / itemTotal) * 100) : 0;
  return { total, done, remain, itemTotal, itemDone, pct };
}

function usePrefersReducedMotion() {
  const [reduced, setReduced] = useState(() => {
    try { return window.matchMedia("(prefers-reduced-motion: reduce)").matches; } catch (_) { return false; }
  });
  useEffect(() => {
    try {
      const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
      const fn = () => setReduced(mq.matches);
      mq.addEventListener("change", fn);
      return () => mq.removeEventListener("change", fn);
    } catch (_) { return undefined; }
  }, []);
  return reduced;
}

function ExcelAnimStat({ value, format, className = "" }) {
  const reduced = usePrefersReducedMotion();
  const prevRef = React.useRef(value);
  const [display, setDisplay] = useState(value);
  const [flash, setFlash] = useState(false);

  useEffect(() => {
    const prev = prevRef.current;
    if (prev === value) return;
    prevRef.current = value;
    if (reduced) {
      setDisplay(value);
      setFlash(true);
      const t = setTimeout(() => setFlash(false), 280);
      return () => clearTimeout(t);
    }
    setFlash(true);
    const start = prev;
    const end = value;
    const t0 = performance.now();
    const dur = 260;
    let raf = 0;
    const tick = (now) => {
      const p = Math.min(1, (now - t0) / dur);
      const eased = 1 - (1 - p) * (1 - p);
      setDisplay(Math.round(start + (end - start) * eased));
      if (p < 1) raf = requestAnimationFrame(tick);
      else {
        setDisplay(end);
        setTimeout(() => setFlash(false), 280);
      }
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [value, reduced]);

  const text = format ? format(display) : String(display);
  return (
    <span className={["excel-todo-footer-val", className, flash ? "is-recalc" : ""].filter(Boolean).join(" ")}>
      {text}
    </span>
  );
}

// 하단 워크시트 탭 + 엑셀식 상태바 — `개수 N · 완료 N · NN%`
function ExcelWorkbookBar({ tabs, status, calToggle }) {
  useI18n();
  return (
    <div className="excel-status-row" aria-label={L("todo.excelFooterAria")}>
      <div className="excel-sheet-tabs" role="tablist" aria-label={L("todo.excelSheetTabs")}>
        {tabs.map((t) => (
          <button
            key={t.id}
            type="button"
            role="tab"
            aria-selected={t.active}
            className={"excel-sheet-tab" + (t.active ? " is-active" : "")}
            title={t.hint || undefined}
            onClick={t.onClick}
          >{t.label}</button>
        ))}
        <span className="excel-sheet-tab-plus" aria-hidden>+</span>
      </div>
      <div className="excel-statusbar">{status}</div>
      {calToggle && (
        <button
          type="button"
          className={"excel-cal-toggle" + (calToggle.open ? " is-open" : "")}
          onClick={calToggle.onToggle}
          disabled={calToggle.forced}
          aria-pressed={calToggle.open}
          title={calToggle.forced
            ? L("todo.excelCalForced")
            : (calToggle.open ? L("todo.excelCalHide") : L("todo.excelCalShow"))}
        >▦</button>
      )}
    </div>
  );
}

function ExcelStatusCounts({ stats }) {
  useI18n();
  return (
    <>
      <span className="excel-statusbar-item">
        <ExcelAnimStat value={stats.total} format={(n) => L("todo.excelStatusCountN", { n })} />
      </span>
      <span className="excel-statusbar-sep" aria-hidden>·</span>
      <span className="excel-statusbar-item">
        {L("todo.excelStatusDoneN")}&nbsp;<ExcelAnimStat value={stats.done} />
      </span>
      <span className="excel-statusbar-sep" aria-hidden>·</span>
      <span className="excel-statusbar-item">
        <ExcelAnimStat value={stats.pct} format={(n) => L("todo.excelFooterPct", { n })} />
      </span>
    </>
  );
}

function dateOf(ts) { return ts ? (ts.length >= 10 ? ts.slice(0, 10) : ts) : null; }
function fmtMD(iso) { if (!iso) return ""; const [, m, d] = iso.split("-").map(Number); return `${m}/${d}`; }
function fmtCompletedClock(iso) {
  if (!iso) return "";
  const m = String(iso).match(/T(\d{2}):(\d{2})/);
  return m ? `${m[1]}:${m[2]}` : "";
}

function fmtMinClock(min) {
  const h = Math.floor(min / 60);
  const m = min % 60;
  return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}

/** 통계용 H:MM (예: 0:50, 1:04) */
function fmtClockDuration(min) {
  const m = Math.max(0, Math.floor(min || 0));
  return `${Math.floor(m / 60)}:${String(m % 60).padStart(2, "0")}`;
}

/** 일정 셀 조건부 서식 — 오늘/지남 = due(빨강), D-3 이내 = soon(노랑) */
function excelSchedUrgency(t, todayStr) {
  if (t.done) return null;
  const p = normalizedPeriod(t);
  const start = p ? p.start : (t.dueDate || null);
  const end = p ? p.end : (t.dueDate || null);
  if (!start) return null;
  if (start <= todayStr) return "due"; // 지났거나 오늘 시작(포함)
  const diff = Math.round(
    (new Date(start + "T12:00:00") - new Date(todayStr + "T12:00:00")) / 86400000,
  );
  if (diff <= 3) return "soon";
  return null;
}

/** MM/DD 고정폭 (0패딩) — tabular-nums 정렬용 */
function fmtMD2(iso) {
  if (!iso) return "";
  return `${iso.slice(5, 7)}/${iso.slice(8, 10)}`;
}

/** 일정 텍스트 — 단일 날짜 MM/DD, 기간은 MM/DD–MM/DD 압축 */
function excelSchedDateText(t) {
  const p = normalizedPeriod(t);
  if (p) return p.start === p.end ? fmtMD2(p.start) : `${fmtMD2(p.start)}–${fmtMD2(p.end)}`;
  return t.dueDate ? fmtMD2(t.dueDate) : "";
}

function excelNowMinute() {
  const n = new Date();
  return n.getHours() * 60 + n.getMinutes();
}

function excelNowBlocksForDate(state, dateStr) {
  const nowMin = excelNowMinute();
  return diary.select.timetableBlocksForDate(state, dateStr)
    .filter((b) => b.startMin <= nowMin && nowMin < b.endMin)
    .sort((a, b) => a.startMin - b.startMin);
}

// 일정 그룹 헤더 행 — 엑셀 셀 병합 패러디. 같은 일정의 할 일들을 한 헤더 아래 묶는다.
// 현재 시간대 일정(is-now)만 옅은 주황, 그 외는 중립 회색.
function ExcelTodoGroupRow({ kind, block, items, rowNum, isNow, gridCols }) {
  useI18n();
  const done = items.filter((t) => t.done).length;
  const glyph = typeof window.skinG === "function" ? window.skinG("clock", "🕐") : "🕐";
  return (
    <div
      className={"excel-todo-group-row"
        + (kind === "block" ? (isNow ? " is-now" : " is-block") : " is-neutral")}
      style={{ display: "grid", gridTemplateColumns: gridCols }}
      role="rowheader"
    >
      <span className="excel-todo-gutter excel-todo-cell-idx" aria-hidden>{rowNum}</span>
      <div className="excel-todo-group-cell">
        {kind === "block" ? (
          <>
            {isNow
              ? <span className="excel-todo-group-icon" aria-hidden>{glyph}</span>
              : <span className="excel-todo-group-dot" style={{ background: block.color }} aria-hidden />}
            <span className="excel-todo-group-time">
              {fmtMinClock(block.startMin)}–{fmtMinClock(block.endMin)}
            </span>
            <span className="excel-todo-group-title" title={block.title}>{block.title}</span>
            <span className="excel-todo-group-sep" aria-hidden>·</span>
            <span className="excel-todo-group-progress">
              {L("todo.excelGroupProgress", { total: items.length, done })}
            </span>
          </>
        ) : (
          <>
            <span className="excel-todo-group-title">
              {kind === "scheduled" ? L("todo.excelGroupScheduled") : L("todo.excelGroupNoDue")}
            </span>
            <span className="excel-todo-group-sep" aria-hidden>·</span>
            <span className="excel-todo-group-progress">
              {L("todo.excelGroupCount", { n: items.length })}
            </span>
          </>
        )}
      </div>
    </div>
  );
}

// 수식 입력줄 — [이름상자(B4) | fx | 내용]. 선택 행 편집 / 미선택 시 할 일 추가.
function ExcelFormulaBar({ cellRef, todo, placeholder, onAddTodo, onUpdateTitle }) {
  useI18n();
  const [draft, setDraft] = useState(todo ? todo.title : "");
  React.useEffect(() => {
    setDraft(todo ? todo.title : "");
  }, [todo?.id, todo?.title]);
  const commit = () => {
    const v = draft.trim();
    if (todo) {
      if (v && v !== todo.title) onUpdateTitle(todo.id, v);
    } else if (v) {
      onAddTodo(v);
      setDraft("");
    }
  };
  return (
    <div className="excel-formula-bar">
      <span className="excel-formula-name">{cellRef}</span>
      <span className="excel-formula-fx" aria-hidden>fx</span>
      <input
        className="excel-formula-input"
        value={draft}
        placeholder={todo ? "" : placeholder}
        onChange={(e) => setDraft(e.target.value)}
        onKeyDown={(e) => {
          if (e.key === "Enter") { e.preventDefault(); commit(); }
          else if (e.key === "Escape") {
            e.preventDefault();
            setDraft(todo ? todo.title : "");
            e.target.blur();
          }
        }}
        onBlur={() => { if (todo) commit(); }}
      />
    </div>
  );
}

const EXCEL_TT_START_H = 6;
const EXCEL_TT_END_H = 24;
const EXCEL_TT_SLOT_H = 22;
const EXCEL_TT_HOURS = Array.from({ length: EXCEL_TT_END_H - EXCEL_TT_START_H }, (_, i) => i + EXCEL_TT_START_H);
const EXCEL_TT_BODY_H = EXCEL_TT_HOURS.length * EXCEL_TT_SLOT_H;
const EXCEL_SCHEDULE_COLORS = [
  "#c8e6d4", "#b8d4f0", "#f5d0a8", "#e8b8d4", "#d4c8f0", "#a8e0d4", "#f0c8b8", "#c8e8f0",
];

function pickScheduleColor(state) {
  const n = (diary.select.timetableBlocksForProject(state) || []).length;
  return EXCEL_SCHEDULE_COLORS[n % EXCEL_SCHEDULE_COLORS.length];
}

// i18n.weekdays() 는 일요일 시작 배열
function excelWeekdayLabels() {
  return typeof window.i18n?.weekdays === "function"
    ? window.i18n.weekdays()
    : ["일", "월", "화", "수", "목", "금", "토"];
}

function excelDayInfo(date) {
  const iso = date || diary.today();
  const d = new Date(iso + "T12:00:00");
  const label = excelWeekdayLabels()[d.getDay()];
  return { iso, label, md: `${d.getMonth() + 1}/${d.getDate()}` };
}

function excelWeekColumns(anchorDate) {
  const base = new Date((anchorDate || diary.today()) + "T12:00:00");
  const dow = base.getDay();
  const monOffset = dow === 0 ? -6 : 1 - dow;
  const monday = new Date(base);
  monday.setDate(base.getDate() + monOffset);
  const labels = excelWeekdayLabels();
  return Array.from({ length: 7 }, (_, i) => {
    const d = new Date(monday);
    d.setDate(monday.getDate() + i);
    const iso = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
    // i = 0(월)…6(일) → 일요일 시작 배열 인덱스 보정
    return { iso, label: labels[(i + 1) % 7], md: `${d.getMonth() + 1}/${d.getDate()}` };
  });
}

function excelWeekRangeLabel(anchorDate) {
  const days = excelWeekColumns(anchorDate);
  return `${days[0].md} – ${days[6].md}`;
}

function fmtWorkDuration(min) {
  const m = Math.max(0, Math.floor(min || 0));
  const h = Math.floor(m / 60);
  const r = m % 60;
  if (h > 0 && r > 0) return `${h}h ${r}m`;
  if (h > 0) return `${h}h`;
  return `${r}m`;
}

function computeExcelWeekDashboard(state, anchorDate, todos, todayStr) {
  const days = excelWeekColumns(anchorDate);
  const dayStrings = days.map((d) => d.iso);
  const curPid = state.currentProjectId;
  const workByDay = {};
  const doneByDay = {};
  const totalByDay = {};
  dayStrings.forEach((d) => {
    workByDay[d] = 0;
    doneByDay[d] = 0;
    totalByDay[d] = 0;
  });
  (state.workSessions ?? []).forEach((w) => {
    if (w.projectId !== curPid) return;
    if (!dayStrings.includes(w.date)) return;
    workByDay[w.date] += w.minutes || 0;
  });
  (todos || []).forEach((t) => {
    dayStrings.forEach((d) => {
      if (!diary.matchesTodoDay(t, d, todayStr)) return;
      totalByDay[d] += 1;
      if (t.done) doneByDay[d] += 1;
    });
  });
  const weekTotalMin = dayStrings.reduce((s, d) => s + workByDay[d], 0);
  const weekDoneCnt = dayStrings.reduce((s, d) => s + doneByDay[d], 0);
  const weekTotalCnt = dayStrings.reduce((s, d) => s + totalByDay[d], 0);
  const activeDays = dayStrings.filter((d) => workByDay[d] > 0 || doneByDay[d] > 0).length;
  const streak = diary.select.workStreak(state);
  return {
    days,
    workByDay,
    doneByDay,
    totalByDay,
    weekTotalMin,
    weekDoneCnt,
    weekTotalCnt,
    activeDays,
    streak,
    dailyAvgMin: Math.round(weekTotalMin / 7),
  };
}

function timetableClientToMin(colEl, clientY) {
  if (!colEl) return EXCEL_TT_START_H * 60;
  const rect = colEl.getBoundingClientRect();
  const y = Math.max(0, Math.min(rect.height, clientY - rect.top));
  const spanMin = EXCEL_TT_HOURS.length * 60;
  const raw = EXCEL_TT_START_H * 60 + (y / rect.height) * spanMin;
  const snapped = Math.round(raw / 15) * 15;
  const maxMin = EXCEL_TT_END_H * 60;
  return Math.max(EXCEL_TT_START_H * 60, Math.min(maxMin - 15, snapped));
}

function excelMinToTop(min) {
  return ((min - EXCEL_TT_START_H * 60) / 60) * EXCEL_TT_SLOT_H;
}
function excelMinToHeight(startMin, endMin) {
  return Math.max(EXCEL_TT_SLOT_H / 2, ((endMin - startMin) / 60) * EXCEL_TT_SLOT_H);
}

function scheduleBlocksToView(blocks) {
  return (blocks || []).map((b) => ({
    id: b.id,
    kind: "schedule",
    title: b.title,
    startMin: b.startMin,
    endMin: b.endMin,
    color: b.color || "#c8e6d4",
    done: false,
  }));
}

function ExcelScheduleLinkPicker({ day, blocks, currentBlockId, onPick, onUnlink, onClose, anchorRef }) {
  useI18n();
  const panelRef = React.useRef(null);
  const [pos, setPos] = useState(null);

  useEffect(() => {
    // 뷰포트 안으로 클램프 — 우측 끝 셀(일정 열)에서 열어도 잘리지 않게
    const sync = () => {
      if (!anchorRef?.current) return;
      const r = anchorRef.current.getBoundingClientRect();
      const width = Math.max(180, r.width);
      const left = Math.max(8, Math.min(r.left, window.innerWidth - width - 8));
      let top = r.bottom + 2;
      const panelH = panelRef.current?.offsetHeight || 0;
      if (panelH && top + panelH > window.innerHeight - 8) {
        top = Math.max(8, r.top - panelH - 2);
      }
      setPos({ top, left, width });
    };
    sync();
    // 첫 렌더 후 패널 실제 높이를 알게 되면 위/아래 배치 재계산
    const raf = requestAnimationFrame(sync);
    window.addEventListener("resize", sync);
    window.addEventListener("scroll", sync, true);
    return () => {
      cancelAnimationFrame(raf);
      window.removeEventListener("resize", sync);
      window.removeEventListener("scroll", sync, true);
    };
  }, [anchorRef]);

  useEffect(() => {
    const onDoc = (e) => {
      if (panelRef.current?.contains(e.target) || anchorRef?.current?.contains(e.target)) return;
      onClose();
    };
    document.addEventListener("mousedown", onDoc);
    return () => document.removeEventListener("mousedown", onDoc);
  }, [onClose, anchorRef]);

  if (!pos) return null;
  return (
    <div
      ref={panelRef}
      className="excel-schedule-link-picker"
      style={{ position: "fixed", top: pos.top, left: pos.left, width: pos.width, zIndex: 40 }}
    >
      <div className="excel-schedule-link-picker-head">{L("todo.excelScheduleLink")}</div>
      {blocks.length === 0 ? (
        <p className="excel-schedule-link-picker-empty">{L("todo.excelScheduleLinkEmpty")}</p>
      ) : (
        <div className="excel-schedule-link-picker-list">
          {blocks.map((b) => (
            <button
              key={b.id}
              type="button"
              className={"excel-schedule-link-chip" + (currentBlockId === b.id ? " is-active" : "")}
              onClick={() => onPick(b.id)}
            >
              <span className="excel-schedule-link-dot" style={{ background: b.color }} aria-hidden />
              <span className="excel-schedule-link-time">
                {fmtMinClock(b.startMin)}–{fmtMinClock(b.endMin)}
              </span>
              <span className="excel-schedule-link-title">{b.title}</span>
            </button>
          ))}
        </div>
      )}
      {currentBlockId && (
        <button type="button" className="excel-schedule-link-unlink" onClick={onUnlink}>
          {L("todo.excelScheduleUnlink")}
        </button>
      )}
    </div>
  );
}

function ExcelScheduleColorRow({ color, onPick }) {
  useI18n();
  return (
    <div className="excel-schedule-dock-colors">
      {EXCEL_SCHEDULE_COLORS.map((c) => (
        <button
          key={c}
          type="button"
          className={"excel-schedule-color-swatch" + (color === c ? " is-active" : "")}
          style={{ background: c }}
          title={L("todo.excelScheduleColor")}
          onClick={() => onPick(c)}
        />
      ))}
    </div>
  );
}

function ExcelWeekScheduleDock({
  draft, blockId, days, state, actions, onClose, onCreated, onSelectBlock,
}) {
  useI18n();
  const block = blockId ? diary.select.scheduleBlockById(state, blockId) : null;
  const linked = blockId ? diary.select.todosForScheduleBlock(state, blockId) : [];
  const [title, setTitle] = useState("");
  const [draftColor, setDraftColor] = useState(() => pickScheduleColor(state));
  const titleRef = React.useRef(null);

  useEffect(() => {
    if (draft) {
      setTitle("");
      setDraftColor(pickScheduleColor(state));
      const t = setTimeout(() => titleRef.current?.focus(), 30);
      return () => clearTimeout(t);
    }
    if (block) setTitle(block.title || "");
    return undefined;
  }, [draft, block?.id, block?.title, state]);

  const dayMeta = draft
    ? (days.find((d) => d.iso === draft.date) || excelDayInfo(draft.date))
    : (block ? (days.find((d) => d.iso === block.date) || excelDayInfo(block.date)) : null);

  const confirmCreate = () => {
    if (!draft) return;
    const nextTitle = title.trim() || L("todo.excelTimetableDefault");
    const id = actions.addExcelTimetableBlock({
      date: draft.date,
      startMin: draft.startMin,
      endMin: draft.endMin,
      title: nextTitle,
      color: draftColor,
    });
    if (id) onSelectBlock(id);
    onCreated();
  };

  const saveEditTitle = () => {
    if (!block) return;
    const next = title.trim();
    if (!next) return;
    if (next !== block.title) actions.updateExcelTimetableBlock(blockId, { title: next });
  };

  if (draft) {
    return (
      <div className="excel-week-schedule-dock is-create" role="region" aria-label={L("todo.excelScheduleDockCreate")}>
        <div className="excel-schedule-dock-head">
          <span className="excel-schedule-dock-mode">{L("todo.excelScheduleDockCreate")}</span>
          {dayMeta && (
            <span className="excel-schedule-dock-meta">
              {dayMeta.label} {dayMeta.md} · {fmtMinClock(draft.startMin)}–{fmtMinClock(draft.endMin)}
            </span>
          )}
          <div className="excel-schedule-dock-actions">
            <button type="button" className="excel-schedule-dock-btn" onClick={confirmCreate}>
              {L("todo.excelScheduleConfirm")}
            </button>
            <button type="button" className="excel-schedule-dock-btn is-ghost" onClick={onClose}>
              {L("todo.excelScheduleCancel")}
            </button>
          </div>
        </div>
        <input
          ref={titleRef}
          className="excel-schedule-dock-title"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          onKeyDown={(e) => { if (e.key === "Enter") confirmCreate(); }}
          placeholder={L("todo.excelTimetablePrompt")}
        />
        <ExcelScheduleColorRow color={draftColor} onPick={setDraftColor} />
      </div>
    );
  }

  if (block) {
    return (
      <div className="excel-week-schedule-dock is-edit" role="region" aria-label={block.title}>
        <div className="excel-schedule-dock-head">
          <span className="excel-schedule-dock-dot" style={{ background: block.color }} aria-hidden />
          <span className="excel-schedule-dock-mode">{L("todo.excelScheduleDockEdit")}</span>
          {dayMeta && (
            <span className="excel-schedule-dock-meta">
              {dayMeta.label} {dayMeta.md} · {fmtMinClock(block.startMin)}–{fmtMinClock(block.endMin)}
            </span>
          )}
          <div className="excel-schedule-dock-actions">
            <button
              type="button"
              className="excel-schedule-dock-btn is-danger"
              onClick={() => { actions.removeExcelTimetableBlock(blockId); onClose(); }}
            >
              {L("todo.excelScheduleDelete")}
            </button>
            <button type="button" className="excel-schedule-dock-btn is-ghost" onClick={onClose}>
              {L("todo.excelScheduleClose")}
            </button>
          </div>
        </div>
        <input
          className="excel-schedule-dock-title"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          onBlur={saveEditTitle}
          onKeyDown={(e) => { if (e.key === "Enter") e.target.blur(); }}
          placeholder={L("todo.excelTimetablePrompt")}
        />
        <ExcelScheduleColorRow
          color={block.color}
          onPick={(c) => actions.updateExcelTimetableBlock(blockId, { color: c })}
        />
        <div className="excel-schedule-dock-prep">
          <span className="excel-schedule-dock-prep-label">{L("todo.excelSchedulePrep")}</span>
          {linked.length === 0 ? (
            <p className="excel-schedule-dock-prep-empty">{L("todo.excelSchedulePrepEmpty")}</p>
          ) : (
            <ul className="excel-schedule-dock-prep-list">
              {linked.map((t) => (
                <li key={t.id} className={"excel-schedule-dock-prep-item" + (t.done ? " is-done" : "")}>
                  <span className="excel-schedule-dock-prep-dot" style={{ background: block.color }} aria-hidden />
                  <span className="excel-schedule-dock-prep-title">{t.title}</span>
                  <button
                    type="button"
                    className="excel-schedule-dock-prep-unlink"
                    title={L("todo.excelScheduleUnlink")}
                    onClick={() => actions.unlinkTodoScheduleBlock(t.id)}
                  >×</button>
                </li>
              ))}
            </ul>
          )}
          <div className="excel-schedule-dock-prep-add">
            <InlineAdd
              placeholder={L("todo.excelSchedulePrepAdd")}
              onAdd={(text) => actions.addPrepTodoForScheduleBlock(blockId, text)}
              dashed={false}
            />
          </div>
          <p className="excel-schedule-dock-hint">{L("todo.excelScheduleDropHint")}</p>
        </div>
      </div>
    );
  }

  return null;
}

function ExcelTimetableDayColumn({
  date, blocks, editable, preview, onSlotDown, onSlotEnter, onBlockClick, onDropTodo,
  onLinkTodoToBlock, linkedCountByBlock, selectedBlockId, colClass,
}) {
  const colRef = React.useRef(null);
  const [dropOver, setDropOver] = useState(false);
  const droppable = editable && !!onDropTodo;

  const onDragOver = droppable ? (e) => {
    e.preventDefault();
    e.dataTransfer.dropEffect = "copy";
    setDropOver(true);
  } : undefined;
  const onDragLeave = droppable ? () => setDropOver(false) : undefined;
  const onDrop = droppable ? (e) => {
    e.preventDefault();
    setDropOver(false);
    const todoId = e.dataTransfer.getData("text/todo-graph-id");
    if (!todoId || !onDropTodo) return;
    onDropTodo(todoId, timetableClientToMin(colRef.current, e.clientY));
  } : undefined;

  return (
    <div
      ref={colRef}
      className={["excel-tt-day-col", colClass, dropOver ? "is-drop-over" : ""].filter(Boolean).join(" ")}
      style={{ height: EXCEL_TT_BODY_H }}
      onDragOver={onDragOver}
      onDragLeave={onDragLeave}
      onDrop={onDrop}
    >
      {EXCEL_TT_HOURS.map((h) => {
        const inPreview = preview && preview.date === date
          && h * 60 >= preview.startMin && h * 60 < preview.endMin;
        return (
          <div
            key={h}
            className={"excel-tt-slot" + (inPreview ? " is-preview" : "")}
            role="gridcell"
            onMouseDown={editable ? (e) => { e.preventDefault(); onSlotDown(date, h, e); } : undefined}
            onMouseEnter={editable ? () => onSlotEnter(date, h) : undefined}
          />
        );
      })}
      {blocks.map((b) => {
        const linkable = editable && b.kind === "schedule" && !!onLinkTodoToBlock;
        const linkedN = linkedCountByBlock?.[b.id] || 0;
        return (
          <button
            key={b.id}
            type="button"
            className={[
              "excel-tt-block",
              b.kind === "todo" ? "is-todo" : "is-schedule",
              b.done ? "is-done" : "",
              selectedBlockId === b.id ? "is-selected" : "",
            ].filter(Boolean).join(" ")}
            style={{
              top: excelMinToTop(b.startMin),
              height: excelMinToHeight(b.startMin, b.endMin),
              background: b.color,
              borderLeftColor: b.color,
            }}
            title={linkedN > 0 ? `${b.title} · ${linkedN}` : b.title}
            onClick={(e) => { e.stopPropagation(); if (onBlockClick) onBlockClick(b, date); }}
            onDragOver={linkable ? (e) => {
              e.preventDefault();
              e.stopPropagation();
              e.dataTransfer.dropEffect = "link";
              e.currentTarget.classList.add("is-drop-over");
            } : undefined}
            onDragLeave={linkable ? (e) => { e.currentTarget.classList.remove("is-drop-over"); } : undefined}
            onDrop={linkable ? (e) => {
              e.preventDefault();
              e.stopPropagation();
              e.currentTarget.classList.remove("is-drop-over");
              const todoId = e.dataTransfer.getData("text/todo-id")
                || e.dataTransfer.getData("text/todo-graph-id")
                || _todoHtml5DragId;
              if (todoId) onLinkTodoToBlock(todoId, b.id);
            } : undefined}
          >
            <span className="excel-tt-block-title">{b.title}</span>
            <span className="excel-tt-block-time">
              {String(Math.floor(b.startMin / 60)).padStart(2, "0")}:{String(b.startMin % 60).padStart(2, "0")}
            </span>
            {linkedN > 0 && <span className="excel-tt-block-badge">{linkedN}</span>}
          </button>
        );
      })}
    </div>
  );
}

function ExcelTimetableGrid({
  days, blocksByDate, editable, onDraftCreate, onEditBlock, onDropTodo, onTodoBlockClick,
  onBlockOpen, onLinkTodoToBlock, linkedCountByBlock, selectedBlockId,
  singleDay, highlightDate, todayStr,
}) {
  useI18n();
  const dragRef = React.useRef(null);
  const anchorRef = React.useRef(null);
  const [preview, setPreview] = useState(null);
  const gridCols = singleDay
    ? "44px minmax(0, 1fr)"
    : `44px repeat(${days.length}, minmax(0, 1fr))`;

  useEffect(() => {
    if (!editable) return;
    const endDrag = () => {
      const p = preview;
      dragRef.current = null;
      setPreview(null);
      if (!p || !onDraftCreate) return;
      onDraftCreate(p);
    };
    window.addEventListener("mouseup", endDrag);
    return () => window.removeEventListener("mouseup", endDrag);
  }, [editable, preview, onDraftCreate]);

  const onSlotDown = (date, hour, e) => {
    if (e?.shiftKey && anchorRef.current?.date === date) {
      const a = Math.min(anchorRef.current.hour, hour);
      const b = Math.max(anchorRef.current.hour, hour);
      dragRef.current = { date, hour: b };
      setPreview({ date, startMin: a * 60, endMin: (b + 1) * 60 });
      return;
    }
    anchorRef.current = { date, hour };
    dragRef.current = { date, hour };
    setPreview({ date, startMin: hour * 60, endMin: (hour + 1) * 60 });
  };
  const onSlotEnter = (date, hour) => {
    if (!dragRef.current || dragRef.current.date !== date) return;
    const a = Math.min(dragRef.current.hour, hour);
    const b = Math.max(dragRef.current.hour, hour);
    setPreview({ date, startMin: a * 60, endMin: (b + 1) * 60 });
  };
  const onBlockClick = (block, date) => {
    if (!editable) return;
    if (block.kind === "todo") {
      if (onTodoBlockClick) onTodoBlockClick(block, date);
      return;
    }
    if (onBlockOpen) {
      onBlockOpen(block, date);
      return;
    }
    if (!onEditBlock) return;
    const msg = L("todo.excelTimetableEditPrompt");
    const run = async () => {
      const next = window.dialog && window.dialog.prompt
        ? await window.dialog.prompt(msg, block.title)
        : prompt(msg, block.title);
      if (next == null) return;
      onEditBlock(block.id, String(next).trim());
    };
    run();
  };

  return (
    <div className={"excel-tt-grid" + (singleDay ? " is-single-day" : "")}>
      <div className="excel-tt-head" role="row" style={{ gridTemplateColumns: gridCols }}>
        <span className="excel-tt-corner">{L("todo.excelTimetableTime")}</span>
        {days.map((d) => (
          <span
            key={d.iso}
            className={[
              "excel-tt-day-head",
              highlightDate === d.iso ? "is-cal-selected" : "",
              todayStr === d.iso ? "is-today" : "",
            ].filter(Boolean).join(" ")}
          >
            <span className="excel-tt-day-label">{d.label}</span>
            <span className="excel-tt-day-md">{d.md}</span>
          </span>
        ))}
      </div>
      <div className="excel-tt-scroll">
        <div className="excel-tt-body" style={{ gridTemplateColumns: gridCols }}>
          <div className="excel-tt-times" style={{ height: EXCEL_TT_BODY_H }}>
            {EXCEL_TT_HOURS.map((h) => (
              <span key={h} className="excel-tt-time-label">{String(h).padStart(2, "0")}:00</span>
            ))}
          </div>
          {days.map((d) => (
            <ExcelTimetableDayColumn
              key={d.iso}
              date={d.iso}
              blocks={blocksByDate[d.iso] || []}
              editable={editable}
              preview={preview}
              onSlotDown={onSlotDown}
              onSlotEnter={onSlotEnter}
              onBlockClick={onBlockClick}
              onDropTodo={onDropTodo ? (todoId, startMin) => onDropTodo(todoId, startMin, d.iso) : undefined}
              onLinkTodoToBlock={onLinkTodoToBlock}
              linkedCountByBlock={linkedCountByBlock}
              selectedBlockId={selectedBlockId}
              colClass={[
                highlightDate === d.iso ? "is-cal-selected" : "",
                todayStr === d.iso ? "is-today" : "",
              ].filter(Boolean).join(" ")}
            />
          ))}
        </div>
      </div>
    </div>
  );
}

function ExcelWeekScheduleLayout({ anchorDate, todayStr, actions, state }) {
  const [selectedBlockId, setSelectedBlockId] = useState(null);
  const [draft, setDraft] = useState(null);
  const days = excelWeekColumns(anchorDate);
  const daySet = new Set(days.map((d) => d.iso));

  const blocksByDate = React.useMemo(() => {
    const map = {};
    days.forEach((d) => { map[d.iso] = []; });
    diary.select.timetableBlocksForProject(state).forEach((b) => {
      if (!daySet.has(b.date)) return;
      map[b.date].push(...scheduleBlocksToView([b]));
    });
    return map;
  }, [days, daySet, state]);

  const linkedCountByBlock = React.useMemo(() => {
    const map = {};
    (state.todos ?? []).forEach((t) => {
      if (!t.scheduleBlockId || t.projectId !== state.currentProjectId) return;
      map[t.scheduleBlockId] = (map[t.scheduleBlockId] || 0) + 1;
    });
    return map;
  }, [state.todos, state.currentProjectId]);

  const clearDock = () => {
    setSelectedBlockId(null);
    setDraft(null);
  };

  return (
    <div className="excel-week-schedule-layout">
      <ExcelTimetableGrid
        days={days}
        blocksByDate={blocksByDate}
        editable
        highlightDate={anchorDate}
        todayStr={todayStr}
        linkedCountByBlock={linkedCountByBlock}
        selectedBlockId={selectedBlockId}
        onDraftCreate={(p) => {
          setSelectedBlockId(null);
          setDraft(p);
        }}
        onBlockOpen={(block) => {
          setDraft(null);
          setSelectedBlockId(block.id);
        }}
        onLinkTodoToBlock={(todoId, blockId) => {
          actions.linkTodoToScheduleBlock(todoId, blockId);
          setDraft(null);
          setSelectedBlockId(blockId);
        }}
        onEditBlock={(id, title) => {
          if (!title) actions.removeExcelTimetableBlock(id);
          else actions.updateExcelTimetableBlock(id, { title });
        }}
      />
      <ExcelWeekScheduleDock
        draft={draft}
        blockId={selectedBlockId}
        days={days}
        state={state}
        actions={actions}
        onClose={clearDock}
        onCreated={() => setDraft(null)}
        onSelectBlock={setSelectedBlockId}
      />
    </div>
  );
}

// 주간 시간표 — 이번 주(월–일) 고정
function ExcelWeekSchedulePane({ anchorDate, todayStr }) {
  useI18n();
  const { state, actions } = diary.useDiary();
  const d = anchorDate || todayStr;
  return (
    <div className="excel-week-schedule-pane">
      <div className="excel-week-schedule-head">
        <div className="excel-week-schedule-title">
          {L("todo.excelWeekScheduleTitle", { range: excelWeekRangeLabel(d) })}
        </div>
      </div>
      <ExcelWeekScheduleLayout
        anchorDate={d}
        todayStr={todayStr}
        actions={actions}
        state={state}
      />
    </div>
  );
}

// 통계 탭 — 요일별 표 + 인셀 데이터 바 + 합계 행. 활동 없는 연속 요일은 한 행으로 병합.
function excelDashRows(days, workByDay, doneByDay) {
  const rows = [];
  let i = 0;
  while (i < days.length) {
    const d = days[i];
    const work = workByDay[d.iso] || 0;
    const done = doneByDay[d.iso] || 0;
    if (work === 0 && done === 0) {
      let j = i;
      while (
        j + 1 < days.length
        && (workByDay[days[j + 1].iso] || 0) === 0
        && (doneByDay[days[j + 1].iso] || 0) === 0
      ) j += 1;
      rows.push({
        key: d.iso,
        label: j > i ? `${d.label}–${days[j].label}` : d.label,
        empty: true,
      });
      i = j + 1;
    } else {
      rows.push({ key: d.iso, label: d.label, work, done, empty: false });
      i += 1;
    }
  }
  return rows;
}

function ExcelDashboardPane({ list, anchorDate, todayStr }) {
  useI18n();
  const { state } = diary.useDiary();
  const d = anchorDate || todayStr;
  const stats = React.useMemo(
    () => computeExcelWeekDashboard(state, d, list, todayStr),
    [state, d, list, todayStr],
  );
  const rows = excelDashRows(stats.days, stats.workByDay, stats.doneByDay);
  const maxWork = Math.max(1, ...stats.days.map((dd) => stats.workByDay[dd.iso] || 0));
  const weekPct = stats.weekTotalCnt > 0
    ? Math.round((stats.weekDoneCnt / stats.weekTotalCnt) * 100)
    : 0;
  const rateCls = weekPct >= 70 ? " is-rate-high" : (weekPct >= 40 ? " is-rate-mid" : " is-rate-low");
  return (
    <div className="excel-dashboard-pane">
      <div className="excel-dashboard-head">
        <div className="excel-dashboard-title">
          {L("todo.excelDashboardWeek", { range: excelWeekRangeLabel(d) })}
        </div>
      </div>
      <div className="excel-dashboard-scroll">
        <table className="excel-dash-table">
          <thead>
            <tr>
              <th className="excel-dash-col-dow">{L("todo.excelStatsDow")}</th>
              <th className="excel-dash-col-time">{L("todo.excelColTime")}</th>
              <th className="excel-dash-col-bar">{L("todo.excelStatsDist")}</th>
              <th className="excel-dash-col-done">{L("todo.excelColDone")}</th>
            </tr>
          </thead>
          <tbody>
            {rows.map((r) => (
              <tr key={r.key} className={r.empty ? "is-empty" : ""}>
                <td className="excel-dash-col-dow">{r.label}</td>
                <td className="excel-dash-col-time">{r.empty ? "–" : fmtClockDuration(r.work)}</td>
                <td className="excel-dash-col-bar">
                  {!r.empty && r.work > 0 && (
                    <div
                      className="excel-dash-databar"
                      style={{ width: `${Math.max(4, Math.round((r.work / maxWork) * 100))}%` }}
                      title={fmtClockDuration(r.work)}
                    />
                  )}
                </td>
                <td className="excel-dash-col-done">{r.empty ? "–" : r.done}</td>
              </tr>
            ))}
            <tr className="excel-dash-total-row">
              <td className="excel-dash-col-dow">{L("todo.excelStatsTotal")}</td>
              <td className="excel-dash-col-time">{fmtClockDuration(stats.weekTotalMin)}</td>
              <td className="excel-dash-col-bar" />
              <td className="excel-dash-col-done">{stats.weekDoneCnt}</td>
            </tr>
          </tbody>
        </table>
        <table className="excel-dash-summary-table">
          <thead>
            <tr>
              <th>{L("todo.excelDashDailyAvg")}</th>
              <th>{L("todo.excelDashActiveDays")}</th>
              <th>{L("todo.excelDashStreak")}</th>
              <th>{L("todo.excelDashCompletion")}</th>
            </tr>
          </thead>
          <tbody>
            <tr>
              <td>{fmtClockDuration(stats.dailyAvgMin)}</td>
              <td>{L("todo.excelDashActiveDaysVal", { n: stats.activeDays })}</td>
              <td>{L("planner.days", { n: stats.streak })}</td>
              <td className={"excel-dash-rate" + rateCls}>{weekPct}%</td>
            </tr>
          </tbody>
        </table>
      </div>
    </div>
  );
}

function ExcelGraphPane({ list, anchorDate, todayStr }) {
  return (
    <div className="excel-graph-pane">
      <ExcelDashboardPane list={list} anchorDate={anchorDate} todayStr={todayStr} />
    </div>
  );
}
function normalizedPeriod(t) {
  const a = t.startDate || null;
  const b = t.endDate || null;
  if (!a && !b) return null;
  const start = a || b;
  const end = b || a;
  return start <= end ? { start, end } : { start: end, end: start };
}
function isInTodoPeriod(t, day) {
  const p = normalizedPeriod(t);
  return !!p && p.start <= day && day <= p.end;
}
function todoMatchesDay(t, day, today) {
  return diary.matchesTodoDay(t, day, today);
}
function fmtMonthLabel(iso) {
  const m = parseInt(iso.slice(5, 7), 10);
  const lng = window.i18n?.get?.() || "ko";
  if (lng === "en") return ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][m - 1];
  if (lng === "ja" || lng === "zh") return `${m}月`;
  return `${m}월`;
}
function periodLabel(t) {
  const p = normalizedPeriod(t);
  if (!p) return "";
  return p.start === p.end ? fmtMD(p.start) : `${fmtMD(p.start)}-${fmtMD(p.end)}`;
}
const SCHEDULE_ORANGE = "#e07a20";
function collectTodoScheduleMarks(todo, recRule, allTodos, previewRule) {
  const recurringPending = new Set();
  const recurringDone = new Set();
  const plainDue = new Set();
  const plainDone = new Set();
  const periodRange = new Set();
  const p = normalizedPeriod(todo);
  const rule = previewRule || recRule;

  if (p) {
    let d = p.start;
    for (let i = 0; i < 370 && d <= p.end; i += 1) {
      periodRange.add(d);
      if (rule) {
        if (!diary.dayMatchesRecurrence?.(d, rule)) {
          d = addDaysIso(d, 1);
          continue;
        }
        const completed = (allTodos || []).some((td) => td.recurrenceId === todo.recurrenceId
          && td.id !== todo.id
          && td.done
          && diary.completionDay(td) === d
          && !normalizedPeriod(td));
        if (completed) recurringDone.add(d);
        else recurringPending.add(d);
      }
      d = addDaysIso(d, 1);
    }
  } else if (todo.dueDate && !todo.done) {
    plainDue.add(todo.dueDate);
  }

  if (todo.done) {
    const cd = diary.completionDay(todo);
    if (cd) {
      if (todo.recurrenceId && !p) recurringDone.add(cd);
      else plainDone.add(cd);
    }
  }

  return { recurringPending, recurringDone, plainDue, plainDone, periodRange, isRecurring: !!(p && rule) };
}
function mergeCalendarMarks(items, recById, schedulingOverride) {
  const merged = {
    recurringPending: new Set(),
    recurringDone: new Set(),
  };
  (items || []).forEach((todo) => {
    const isOverride = schedulingOverride?.todoId === todo.id;
    const overrideRule = isOverride ? schedulingOverride.previewRule : null;
    const previewPeriod = isOverride ? schedulingOverride.previewPeriod : null;
    const recRule = overrideRule ?? recById[todo.recurrenceId];
    const p = previewPeriod || normalizedPeriod(todo);
    const todoForMarks = previewPeriod
      ? { ...todo, startDate: previewPeriod.start, endDate: previewPeriod.end }
      : todo;

    if (p && !todo.done && recRule) {
      const m = collectTodoScheduleMarks(todoForMarks, recRule, items, null);
      m.recurringPending.forEach((d) => merged.recurringPending.add(d));
      m.recurringDone.forEach((d) => merged.recurringDone.add(d));
    }

    if (todo.done && todo.recurrenceId && !p) {
      const cd = diary.completionDay(todo);
      if (cd) merged.recurringDone.add(cd);
    }
  });
  return merged;
}
function ScheduleRing({ filled }) {
  return (
    <span style={{
      width: 7, height: 7, borderRadius: "50%", flexShrink: 0,
      border: `1.5px solid ${SCHEDULE_ORANGE}`,
      background: filled ? SCHEDULE_ORANGE : "transparent",
      boxSizing: "border-box",
    }} />
  );
}
function addDaysIso(iso, days) {
  const [y, m, d] = iso.split("-").map(Number);
  const dt = new Date(y, m - 1, d);
  dt.setDate(dt.getDate() + days);
  const yy = dt.getFullYear();
  const mm = String(dt.getMonth() + 1).padStart(2, "0");
  const dd = String(dt.getDate()).padStart(2, "0");
  return `${yy}-${mm}-${dd}`;
}
function addMonthsIso(iso, months) {
  const [y, m, d] = iso.split("-").map(Number);
  const dt = new Date(y, m - 1 + months, d);
  const yy = dt.getFullYear();
  const mm = String(dt.getMonth() + 1).padStart(2, "0");
  const dd = String(dt.getDate()).padStart(2, "0");
  return `${yy}-${mm}-${dd}`;
}
function clonePlain(value) {
  return JSON.parse(JSON.stringify(value));
}
function isTextEditingTarget(target) {
  if (!target) return false;
  const tag = (target.tagName || "").toLowerCase();
  return tag === "input" || tag === "textarea" || target.isContentEditable || !!target.closest?.("[contenteditable='true']");
}
function todoClipboardText(todo) {
  if (!todo) return "";
  const lines = [todo.title || ""];
  (todo.subTasks || []).forEach(st => {
    const mark = st.done ? "[x]" : "[ ]";
    const link = st.linkUrl ? ` ${st.linkUrl}` : "";
    lines.push(`  - ${mark} ${st.title || ""}${link}`);
  });
  return lines.filter(Boolean).join("\n");
}
async function writeClipboardText(text) {
  try {
    if (navigator.clipboard?.writeText) {
      await navigator.clipboard.writeText(text);
      return true;
    }
  } catch (_) {}
  try {
    const ta = document.createElement("textarea");
    ta.value = text;
    ta.style.position = "fixed";
    ta.style.left = "-9999px";
    document.body.appendChild(ta);
    ta.focus();
    ta.select();
    const ok = document.execCommand("copy");
    ta.remove();
    return ok;
  } catch (_) {
    return false;
  }
}
async function readClipboardText() {
  try {
    if (navigator.clipboard?.readText) return await navigator.clipboard.readText();
  } catch (_) {}
  return "";
}

function recLabel(r) {
  if (!r) return "";
  if (r.frequency === "daily") return L("todo.recDaily");
  if (r.frequency === "weekdays") return L("todo.recWeekdays");
  const DOW = (window.i18n && window.i18n.weekdays) ? window.i18n.weekdays() : ["일", "월", "화", "수", "목", "금", "토"];
  return (r.weeklyDays || []).map(d => DOW[d]).join("·") || L("todo.specificDays");
}

// 달력 영역 높이 — 1주 단위로 스냅. (헤더+요일행+컨테이너 보더+패딩 ≈ 70px, 주 한 행 ≈ 30px)
const CAL_WEEK_PX = 30;
const CAL_BASE_PX = 70;
const CAL_MIN_WEEKS = 1;
const CAL_MAX_WEEKS = 6;
const CAL_STORAGE_KEY = "vibe.todoCalendarHeight";
/** 스케줄 패널+달력 그리드가 함께 보이는 최소 달력 영역 높이 */
const CAL_SCHEDULE_MIN_H = CAL_BASE_PX + CAL_WEEK_PX * 5 + 200;
const CAL_GRID_MIN_WEEKS_SCHED = 4;
const SCHEDULE_PANEL_MAX_H = 152;
function calSnap(h) {
  const weeks = Math.round((h - CAL_BASE_PX) / CAL_WEEK_PX);
  const clamped = Math.max(CAL_MIN_WEEKS, Math.min(CAL_MAX_WEEKS, weeks));
  return CAL_BASE_PX + clamped * CAL_WEEK_PX;
}

function TodoView({ tweaks } = {}) {
  const showPomodoro = tweaks?.showPomodoro ?? true;
  const excelSkin = typeof window.isExcelSkin === "function" && window.isExcelSkin();
  const excelGridLines = tweaks?.excelGridLines !== false;
  const [excelColWidths, setExcelColWidths] = useState(loadExcelColWidths);
  const [excelPane, setExcelPane] = useState("todo");
  // 행 타이머 — "진행 중" 할 일 (window.todoFocus 와 동기화)
  const [trackingTodoId, setTrackingTodoId] = useState(
    () => (window.todoFocus ? window.todoFocus.getActiveId() : null),
  );
  useEffect(() => {
    const onChange = (e) => setTrackingTodoId(e.detail?.id ?? null);
    window.addEventListener("todo-focus-change", onChange);
    return () => window.removeEventListener("todo-focus-change", onChange);
  }, []);
  const excelGridCols = React.useMemo(() => excelTodoColsFromWidths(excelColWidths), [excelColWidths]);

  const startExcelColResize = React.useCallback((colIndex) => (e) => {
    e.preventDefault();
    e.stopPropagation();
    const startX = e.clientX;
    const startW = excelColWidths[colIndex];
    const onMove = (ev) => {
      const next = Math.max(
        EXCEL_COL_MIN[colIndex],
        Math.min(EXCEL_COL_MAX[colIndex], startW + (ev.clientX - startX)),
      );
      setExcelColWidths((prev) => {
        if (prev[colIndex] === next) return prev;
        const copy = [...prev];
        copy[colIndex] = next;
        return copy;
      });
    };
    const onUp = () => {
      window.removeEventListener("mousemove", onMove);
      window.removeEventListener("mouseup", onUp);
      document.body.style.cursor = "";
      document.body.style.userSelect = "";
      setExcelColWidths((prev) => {
        try { localStorage.setItem(EXCEL_COL_STORAGE_KEY, JSON.stringify(prev)); } catch (_) {}
        return prev;
      });
    };
    document.body.style.cursor = "col-resize";
    document.body.style.userSelect = "none";
    window.addEventListener("mousemove", onMove);
    window.addEventListener("mouseup", onUp);
  }, [excelColWidths]);
  const tapeOpts = React.useMemo(() => ({
    palette: tweaks?.todoTapePalette,
    pattern: tweaks?.todoTapePattern ?? "dots",
    patternColor: tweaks?.todoTapePatternColor,
    pinColor: tweaks?.todoTapePinColor,
    borderColor: tweaks?.todoTapeBorderColor,
    borderWidth: tweaks?.todoTapeBorderWidth,
    edgeFade: tweaks?.todoTapeEdgeFade,
  }), [
    tweaks?.todoTapePalette,
    tweaks?.todoTapePattern,
    tweaks?.todoTapePatternColor,
    tweaks?.todoTapePinColor,
    tweaks?.todoTapeBorderColor,
    tweaks?.todoTapeBorderWidth,
    tweaks?.todoTapeEdgeFade,
  ]);
  const { state, actions } = diary.useDiary();
  const [selId, setSel] = useState(null);
  const [schedulingId, setSchedulingId] = useState(null);
  const [linkingScheduleId, setLinkingScheduleId] = useState(null);
  const [nowScheduleFilter, setNowScheduleFilter] = useState(null);
  const undoStackRef = React.useRef([]);
  const redoStackRef = React.useRef([]);
  const copiedTodoRef = React.useRef(null);
  const todayStr = diary.today();
  // 현재 보고 있는 날짜. null = 오늘.
  const [selectedDate, setSelectedDate] = useState(todayStr);
  // 필터 모드 — 'date': selectedDate 기반 (기존), 'all': 이번달 전체
  const [filterMode, setFilterMode] = useState("date");
  // 달력 영역 높이 — 저장된 값 복원, 없으면 4주 기본
  const [calHeight, setCalHeight] = useState(() => {
    try {
      const v = parseInt(localStorage.getItem(CAL_STORAGE_KEY) || "", 10);
      return Number.isFinite(v) ? calSnap(v) : calSnap(CAL_BASE_PX + 4 * CAL_WEEK_PX);
    } catch (_) { return CAL_BASE_PX + 4 * CAL_WEEK_PX; }
  });
  const onCalResize = (h) => {
    const snapped = calSnap(h);
    setCalHeight(snapped);
    try { localStorage.setItem(CAL_STORAGE_KEY, String(snapped)); } catch (_) {}
  };
  const effectiveCalHeight = schedulingId
    ? Math.max(calHeight, CAL_SCHEDULE_MIN_H)
    : calHeight;
  // 엑셀: 달력 접기 토글 — 오늘/주간/통계 탭 기본 접힘, 월간 탭 기본 펼침.
  // 탭 전환 시 사용자 토글은 초기화(탭별 기본값으로 복귀). 날짜 지정 모드에선 강제 펼침.
  const excelCalDefault = excelPane === "todo" && filterMode === "all";
  const [excelCalOverride, setExcelCalOverride] = useState(null);
  useEffect(() => { setExcelCalOverride(null); }, [excelPane, filterMode]);
  const excelCalOpen = !excelSkin || !!schedulingId || (excelCalOverride ?? excelCalDefault);
  const toggleExcelCal = () => {
    if (schedulingId) return;
    setExcelCalOverride((o) => !(o ?? excelCalDefault));
  };
  useEffect(() => { actions.generateRecurrences(); }, []);

  const prevTodayRef = React.useRef(todayStr);
  useEffect(() => {
    const prev = prevTodayRef.current;
    if (filterMode === "date" && selectedDate === prev && prev !== todayStr) {
      setSelectedDate(todayStr);
    }
    prevTodayRef.current = todayStr;
  }, [todayStr, filterMode, selectedDate]);

  useEffect(() => {
    if (filterMode !== "date") return;
    if (selectedDate !== todayStr) return;
    const id = setInterval(() => {
      const now = diary.today();
      if (now !== todayStr) setSelectedDate(now);
    }, 60 * 1000);
    return () => clearInterval(id);
  }, [filterMode, selectedDate, todayStr]);

  const items = diary.select.todosForCurrent(state);
  const isToday = selectedDate === todayStr;

  // 이번달 범위 — '전체' 모드에서 사용
  const monthPrefix = todayStr.slice(0, 8); // "YYYY-MM-"
  const inThisMonth = (d) => typeof d === "string" && d.startsWith(monthPrefix);
  // todo 가 이번달 범위에 걸리는지 (마감/기간/완료 기준 — 생성일만으로는 포함하지 않음)
  const inMonthRange = (t) => {
    if (t.dueDate && inThisMonth(t.dueDate)) return true;
    const p = normalizedPeriod(t);
    if (p) {
      const monthStart = monthPrefix + "01";
      const monthEnd = monthPrefix + "31";
      return p.start <= monthEnd && p.end >= monthStart;
    }
    const compDay = diary.completionDay(t);
    if (compDay && inThisMonth(compDay)) return true;
    if (!t.dueDate && !p && inThisMonth(t.createdAt)) return true;
    return false;
  };

  // 모드별 미완료 카운트 — 토글 뱃지에 표시
  const dateModeIncomplete = items.filter(t => !t.done && todoMatchesDay(t, selectedDate, todayStr)).length;
  const allModeIncomplete = items.filter(t => !t.done && inMonthRange(t)).length;

  // 필터 — 모드에 따라
  const scheduleBlockMap = React.useMemo(() => {
    const map = {};
    diary.select.timetableBlocksForProject(state).forEach((b) => { map[b.id] = b; });
    return map;
  }, [state]);

  const [, nowTick] = useState(0);
  useEffect(() => {
    if (!excelSkin) return undefined;
    const id = setInterval(() => nowTick((n) => n + 1), 30000);
    return () => clearInterval(id);
  }, [excelSkin]);
  const nowBlocks = React.useMemo(
    () => (excelSkin ? excelNowBlocksForDate(state, todayStr) : []),
    [excelSkin, state, todayStr, nowTick],
  );
  const nowBlockIds = React.useMemo(() => new Set(nowBlocks.map((b) => b.id)), [nowBlocks]);

  const list = items.filter(t => {
    if (filterMode === "all") return inMonthRange(t);
    if (!todoMatchesDay(t, selectedDate, todayStr)) return false;
    if (excelSkin && nowScheduleFilter === "now") {
      return t.scheduleBlockId && nowBlockIds.has(t.scheduleBlockId);
    }
    return true;
  }).slice().sort((a, b) => {
    if (a.done !== b.done) return a.done ? 1 : -1;
    if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
    if (excelSkin) {
      const aBlock = a.scheduleBlockId ? scheduleBlockMap[a.scheduleBlockId] : null;
      const bBlock = b.scheduleBlockId ? scheduleBlockMap[b.scheduleBlockId] : null;
      if (aBlock && bBlock && aBlock.startMin !== bBlock.startMin) return aBlock.startMin - bBlock.startMin;
      if (aBlock && !bBlock) return -1;
      if (!aBlock && bBlock) return 1;
    }
    if (a.dueDate && b.dueDate) return a.dueDate.localeCompare(b.dueDate);
    if (a.dueDate) return -1;
    if (b.dueDate) return 1;
    return (a.order ?? 0) - (b.order ?? 0);
  });

  // 엑셀: 일정 그룹 병합 — 같은 일정 블록의 할 일을 colspan 그룹 헤더 아래 묶는다.
  // 블록 그룹(시작시간순) → 예정(날짜만 있음) → 기한 없음. 행 번호는 헤더 포함 연속.
  let excelRows = null;
  if (excelSkin) {
    const blockGroups = new Map();
    const scheduled = [];
    const noDue = [];
    list.forEach((t) => {
      const block = t.scheduleBlockId ? scheduleBlockMap[t.scheduleBlockId] : null;
      if (block) {
        if (!blockGroups.has(block.id)) blockGroups.set(block.id, { kind: "block", block, items: [] });
        blockGroups.get(block.id).items.push(t);
      } else if (normalizedPeriod(t) || t.dueDate) {
        scheduled.push(t);
      } else {
        noDue.push(t);
      }
    });
    const groups = [...blockGroups.values()].sort((a, b) => a.block.startMin - b.block.startMin);
    if (scheduled.length) groups.push({ kind: "scheduled", items: scheduled });
    if (noDue.length) groups.push({ kind: "nodue", items: noDue });
    // "기한 없음" 하나뿐이면 헤더 생략 — 의미 없는 크롬 방지
    const showHeaders = groups.length > 1 || (groups.length === 1 && groups[0].kind !== "nodue");
    excelRows = [];
    groups.forEach((g) => {
      if (showHeaders) excelRows.push({ type: "group", group: g });
      g.items.forEach((t) => excelRows.push({ type: "todo", t }));
    });
  }

  const recById = Object.fromEntries(
    (state.recurrences ?? []).filter(r => r.projectId === state.currentProjectId).map(r => [r.id, r])
  );
  const memoById = React.useMemo(() => {
    const map = {};
    (state.memos ?? []).forEach((m) => { map[m.id] = m; });
    return map;
  }, [state.memos]);

  const onPick = (id) => {
    setSel(s => (s === id ? null : id));
    if (schedulingId && schedulingId !== id) setSchedulingId(null);
    if (linkingScheduleId && linkingScheduleId !== id) setLinkingScheduleId(null);
  };
  const clear = () => { setSel(null); setSchedulingId(null); setLinkingScheduleId(null); };
  const startSchedule = (id) => { setSel(id); setSchedulingId(id); setLinkingScheduleId(null); };
  const endSchedule = () => { setSchedulingId(null); setSel(null); };
  const startLinkSchedule = (id) => {
    setSel(id);
    setLinkingScheduleId(id);
    setSchedulingId(null);
  };
  const endLinkSchedule = () => setLinkingScheduleId(null);
  const makeHistorySnapshot = React.useCallback(() => {
    const s = diary.getState();
    return {
      todos: clonePlain(s.todos || []),
      recurrences: clonePlain(s.recurrences || []),
      selId,
    };
  }, [selId]);
  const pushUndo = React.useCallback(() => {
    undoStackRef.current = [...undoStackRef.current.slice(-24), makeHistorySnapshot()];
    redoStackRef.current = [];
  }, [makeHistorySnapshot]);
  const pushRedo = React.useCallback(() => {
    redoStackRef.current = [...redoStackRef.current.slice(-24), makeHistorySnapshot()];
  }, [makeHistorySnapshot]);
  const restoreHistorySnapshot = React.useCallback((snapshot) => {
    actions.restoreTodosSnapshot(snapshot);
    setSel(snapshot.selId || null);
  }, [actions]);

  const onAdd = (text, scheduleBlockId) => {
    if (!text?.trim()) return;
    pushUndo();
    const block = scheduleBlockId ? scheduleBlockMap[scheduleBlockId] : null;
    const opts = {};
    if (block) {
      opts.dueDate = block.date;
      opts.scheduleBlockId = scheduleBlockId;
    } else if (filterMode !== "all" && !isToday) {
      opts.dueDate = selectedDate;
    }
    const id = actions.addTodo(text, opts);
    if (id) setSel(id);
  };

  // "지금" 필터 — 상단 상태바(타이머 줄)의 `지금:` 셀 클릭과 동기화
  useEffect(() => {
    if (!excelSkin) return undefined;
    const onToggle = () => setNowScheduleFilter((f) => (f === "now" ? null : "now"));
    window.addEventListener("excel-now-filter-toggle", onToggle);
    return () => window.removeEventListener("excel-now-filter-toggle", onToggle);
  }, [excelSkin]);
  useEffect(() => {
    if (!excelSkin) return undefined;
    window.dispatchEvent(new CustomEvent("excel-now-filter-state", {
      detail: { active: nowScheduleFilter === "now" },
    }));
    return undefined;
  }, [excelSkin, nowScheduleFilter]);

  const dateLabel = isToday ? L("todo.today") : (window.i18n && window.i18n.fmtDate ? window.i18n.fmtDate(selectedDate) : selectedDate);
  const dateLabelShort = isToday ? L("todo.today") : fmtMD(selectedDate);
  const placeholder = filterMode === "all"
    ? L("todo.addPh")
    : (isToday ? L("todo.addPh") : `${fmtMD(selectedDate)} ${L("todo.addPhDate")}`);
  // 엑셀 fx 줄 — 상시 노출 빠른 추가 입력창 (다른 날짜 보기일 땐 날짜 명시)
  const excelQuickAddPh = (filterMode !== "all" && !isToday)
    ? `${fmtMD(selectedDate)} ${L("todo.addPhDate")}`
    : L("todo.excelQuickAddPh");

  const monthLabel = fmtMonthLabel(todayStr);
  // 헤더 — 기본: [날짜][N월] 토글 / 엑셀: 표 상단 상태표 (시트 탭은 표 하단)
  const headerToggle = excelSkin ? null : (
    <div style={{ display: "flex", alignItems: "center", gap: 4, width: "100%", minWidth: 0 }}>
      <div style={{ display: "flex", gap: 4, minWidth: 0 }}>
        <SegPill
          active={filterMode === "date"}
          onClick={() => { setFilterMode("date"); setSelectedDate(todayStr); }}
          icon={typeof window.skinG === "function" ? window.skinG("cal", "📅") : "📅"}
          label={dateLabelShort}
          count={dateModeIncomplete}
        />
        <SegPill
          active={filterMode === "all"}
          onClick={() => setFilterMode("all")}
          icon={typeof window.skinG === "function" ? window.skinG("list", "📋") : "📋"}
          label={monthLabel}
          count={allModeIncomplete}
        />
      </div>
      {showPomodoro && <PomodoroBarButton inline />}
    </div>
  );

  // 진행도 — 보이는 할 일 + 그들의 서브태스크 체크박스 전체 카운팅
  const progressTotal = list.reduce((s, t) => s + 1 + (t.subTasks?.length ?? 0), 0);
  const progressDone = list.reduce((s, t) => s + (t.done ? 1 : 0) + (t.subTasks?.filter(st => st.done).length ?? 0), 0);
  const excelAgg = React.useMemo(() => computeExcelTodoAgg(list), [list]);
  const showBackToday = filterMode === "date" && !isToday;
  const completionContextDay = filterMode === "date" ? selectedDate : todayStr;
  const dayScheduleBlocks = React.useMemo(
    () => diary.select.timetableBlocksForDate(state, completionContextDay),
    [state, completionContextDay],
  );
  const toggleOpts = () => ({ completionDay: completionContextDay });

  useEffect(() => {
    const selectedTodo = () => {
      if (!selId) return null;
      const s = diary.getState();
      return (s.todos || []).find(t => t.id === selId) || null;
    };
    const addOpts = () => (filterMode === "all" || selectedDate === diary.today())
      ? {}
      : { dueDate: selectedDate };
    const copyTodo = async (todo) => {
      copiedTodoRef.current = clonePlain(todo);
      await writeClipboardText(todoClipboardText(todo));
    };
    const onKeyDown = async (e) => {
      if (!(e.ctrlKey || e.metaKey) || e.altKey || isTextEditingTarget(e.target)) return;
      const key = e.key.toLowerCase();
      if ((key === "c" || key === "x") && String(window.getSelection?.() || "").trim()) return;
      if (key === "z" && !e.shiftKey) {
        const prev = undoStackRef.current.pop();
        if (!prev) return;
        e.preventDefault();
        pushRedo();
        restoreHistorySnapshot(prev);
        return;
      }

      if (key === "y" || (key === "z" && e.shiftKey) || (key === "x" && redoStackRef.current.length > 0)) {
        const next = redoStackRef.current.pop();
        if (!next) return;
        e.preventDefault();
        undoStackRef.current = [...undoStackRef.current.slice(-24), makeHistorySnapshot()];
        restoreHistorySnapshot(next);
        return;
      }

      if (key === "c") {
        const todo = selectedTodo();
        if (!todo) return;
        e.preventDefault();
        await copyTodo(todo);
        return;
      }

      if (key === "x") {
        const todo = selectedTodo();
        if (!todo) return;
        e.preventDefault();
        await copyTodo(todo);
        pushUndo();
        actions.removeTodo(todo.id);
        setSel(null);
        return;
      }

      if (key === "v") {
        e.preventDefault();
        const text = await readClipboardText();
        const copied = copiedTodoRef.current;
        const copiedText = copied ? todoClipboardText(copied).trim() : "";
        if (copied && (!text.trim() || text.trim() === copiedText)) {
          pushUndo();
          const id = actions.addTodoFromSnapshot(copied);
          if (id) setSel(id);
          return;
        }
        const lines = text.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
        if (!lines.length) return;
        pushUndo();
        let lastId = null;
        lines.forEach(line => { lastId = actions.addTodo(line, addOpts()) || lastId; });
        if (lastId) setSel(lastId);
      }
    };
    window.addEventListener("keydown", onKeyDown);
    return () => window.removeEventListener("keydown", onKeyDown);
  }, [actions, filterMode, makeHistorySnapshot, pushRedo, pushUndo, restoreHistorySnapshot, selectedDate, selId]);

  // 엑셀 스킨 — 시트형 키보드: ↑/↓ 행 이동, Enter 아래 행, Space 체크 토글, F2 수식줄 편집.
  // 기존 단축키는 Ctrl/Meta 조합만 사용하므로 충돌 없음 (텍스트 편집 중에는 비활성).
  useEffect(() => {
    if (!excelSkin) return;
    const scrollSelIntoView = () => requestAnimationFrame(() => {
      document.querySelector(".tape-excel-grid.is-selected")?.scrollIntoView({ block: "nearest" });
    });
    const onSheetKeyDown = (e) => {
      if (e.ctrlKey || e.metaKey || e.altKey || isTextEditingTarget(e.target)) return;
      const key = e.key;
      if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "F2" && key !== " ") return;
      const ids = (excelRows || []).filter((r) => r.type === "todo").map((r) => r.t.id);
      if (!ids.length) return;
      const idx = selId ? ids.indexOf(selId) : -1;
      if (key === "ArrowDown" || key === "Enter") {
        e.preventDefault();
        const next = idx < 0 ? 0 : Math.min(idx + 1, ids.length - 1);
        setSel(ids[next]);
        scrollSelIntoView();
        return;
      }
      if (key === "ArrowUp") {
        e.preventDefault();
        const prev = idx < 0 ? ids.length - 1 : Math.max(idx - 1, 0);
        setSel(ids[prev]);
        scrollSelIntoView();
        return;
      }
      if (idx < 0) return;
      if (key === " ") {
        e.preventDefault();
        actions.toggleTodo(selId, { completionDay: completionContextDay });
        return;
      }
      if (key === "F2") {
        e.preventDefault();
        const input = document.querySelector(".excel-formula-input");
        if (input) { input.focus(); input.select?.(); }
      }
    };
    window.addEventListener("keydown", onSheetKeyDown);
    return () => window.removeEventListener("keydown", onSheetKeyDown);
  }, [excelSkin, list, selId, actions, completionContextDay]);

  return (
    <SplitPane
      topLabel={headerToggle}
      topRight={!excelSkin && filterMode === "date" && !isToday && (
        <button onClick={() => setSelectedDate(todayStr)} style={{
          all: "unset", cursor: "pointer",
          padding: "2px 9px", borderRadius: 99,
          border: "1.1px solid var(--ink)", background: "var(--paper)",
          fontFamily: "var(--hand)", fontSize: 11, color: "var(--ink)",
        }}>↩ {L("todo.backToday")}</button>
      )}
      top={
        <div onClick={clear}>
          {excelSkin ? (
            <div
              className={"excel-todo-sheet" + (excelGridLines ? "" : " is-grid-off")}
              style={excelPane === "todo" ? excelTodoGridStyle(excelColWidths) : undefined}
              onClick={(e) => e.stopPropagation()}
            >
              {excelPane === "todo" && (
                <>
                  <div className="excel-todo-sheet-top">
                    {showBackToday && (
                      <div className="excel-todo-sheet-util">
                        <button type="button" className="excel-todo-sheet-util" onClick={() => setSelectedDate(todayStr)}>
                          ↩ {L("todo.backToday")}
                        </button>
                      </div>
                    )}
                    <ExcelFormulaBar
                      cellRef={`B${(selId && excelRows.findIndex((r) => r.type === "todo" && r.t.id === selId) >= 0
                        ? excelRows.findIndex((r) => r.type === "todo" && r.t.id === selId)
                        : excelRows.length) + 2}`}
                      todo={selId ? (list.find((t) => t.id === selId) || null) : null}
                      placeholder={excelQuickAddPh}
                      onAddTodo={(v) => { onAdd(v); setSel(null); }}
                      onUpdateTitle={(id, v) => actions.updateTodo(id, { title: v })}
                    />
                    <ExcelTodoHead
                      gridCols={excelGridCols}
                      onResizeStart={startExcelColResize}
                    />
                  </div>
                  <div className="excel-todo-sheet-body">
                    {excelRows.map((row, i) => row.type === "group" ? (
                      <ExcelTodoGroupRow
                        key={"grp-" + (row.group.block ? row.group.block.id : row.group.kind)}
                        kind={row.group.kind}
                        block={row.group.block}
                        items={row.group.items}
                        rowNum={i + 2}
                        isNow={row.group.kind === "block" && nowBlockIds.has(row.group.block.id)}
                        gridCols={excelGridCols}
                      />
                    ) : (
                      <TodoRow key={row.t.id} t={row.t} actions={actions} recRule={recById[row.t.recurrenceId]} i={i}
                        selected={selId === row.t.id} onPick={onPick} completionDay={completionContextDay}
                        schedulingActive={schedulingId === row.t.id} onScheduleDate={startSchedule}
                        linkingSchedule={linkingScheduleId === row.t.id}
                        onLinkSchedule={startLinkSchedule}
                        onCloseLinkSchedule={endLinkSchedule}
                        scheduleBlock={row.t.scheduleBlockId ? scheduleBlockMap[row.t.scheduleBlockId] : null}
                        dayScheduleBlocks={dayScheduleBlocks}
                        memo={row.t.memoId ? memoById[row.t.memoId] : null}
                        tapeOpts={tapeOpts}
                        tracking={trackingTodoId === row.t.id}
                        excelColWidths={excelColWidths} />
                    ))}
                    {list.length === 0 && (
                      <div className="excel-todo-empty">
                        {filterMode === "all"
                          ? L("todo.emptyMonth")
                          : (isToday ? L("todo.emptyToday") : L("todo.emptyDate", { d: fmtMD(selectedDate) }))}
                      </div>
                    )}
                  </div>
                </>
              )}
              {excelPane === "timetable" && (
                <div className="excel-todo-sheet-body">
                  <ExcelWeekSchedulePane anchorDate={todayStr} todayStr={todayStr} />
                </div>
              )}
              {excelPane === "graph" && (
                <div className="excel-todo-sheet-body">
                  <ExcelGraphPane
                    list={items}
                    anchorDate={filterMode === "date" ? selectedDate : todayStr}
                    todayStr={todayStr}
                  />
                </div>
              )}
              <ExcelWorkbookBar
                tabs={[
                  {
                    id: "today",
                    label: dateLabelShort,
                    active: excelPane === "todo" && filterMode === "date",
                    hint: L("todo.excelPaneTodoHint"),
                    onClick: () => { setExcelPane("todo"); setFilterMode("date"); setSelectedDate(todayStr); },
                  },
                  {
                    id: "month",
                    label: monthLabel,
                    active: excelPane === "todo" && filterMode === "all",
                    onClick: () => { setExcelPane("todo"); setFilterMode("all"); },
                  },
                  {
                    id: "week",
                    label: L("todo.excelTabWeek"),
                    active: excelPane === "timetable",
                    hint: L("todo.excelPaneTimetableHint"),
                    onClick: () => setExcelPane("timetable"),
                  },
                  {
                    id: "stats",
                    label: L("todo.excelTabStats"),
                    active: excelPane === "graph",
                    hint: L("todo.excelPaneGraphHint"),
                    onClick: () => setExcelPane("graph"),
                  },
                ]}
                status={excelPane === "graph"
                  ? <span className="excel-statusbar-csv" title={L("todo.excelCsvExport")}>{L("todo.excelCsvExport")}</span>
                  : (excelPane === "timetable"
                    ? <span className="excel-statusbar-item">{excelWeekRangeLabel(filterMode === "date" ? selectedDate : todayStr)}</span>
                    : <ExcelStatusCounts stats={excelAgg} />)}
                calToggle={{
                  open: excelCalOpen,
                  forced: !!schedulingId,
                  onToggle: toggleExcelCal,
                }}
              />
            </div>
          ) : (
            <>
              <div style={{ marginBottom: 8 }} onClick={(e) => e.stopPropagation()}>
                <InlineAdd placeholder={placeholder} onAdd={onAdd} />
              </div>
              {list.map((t, i) => (
                <TodoRow key={t.id} t={t} actions={actions} recRule={recById[t.recurrenceId]} i={i}
                  selected={selId === t.id} onPick={onPick} completionDay={completionContextDay}
                  schedulingActive={schedulingId === t.id} onScheduleDate={startSchedule}
                  tapeOpts={tapeOpts} />
              ))}
              {list.length === 0 && (
                <div className="sk-cap" style={{ padding: "4px 2px" }}>
                  {filterMode === "all"
                    ? L("todo.emptyMonth")
                    : (isToday ? L("todo.emptyToday") : L("todo.emptyDate", { d: fmtMD(selectedDate) }))}
                </div>
              )}
            </>
          )}
        </div>
      }
      middle={!excelSkin && progressTotal > 0 && <TodoProgressBar done={progressDone} total={progressTotal} />}
      bottomHeight={effectiveCalHeight}
      onBottomHeightChange={onCalResize}
      bottomScroll={false}
      bottomCollapsed={!excelCalOpen}
      bottom={
        <TodoMonthCalendar
          items={items}
          recById={recById}
          selectedDate={filterMode === "date" ? selectedDate : null}
          onPick={(d) => { setSelectedDate(d); setFilterMode("date"); }}
          actions={actions}
          selId={selId}
          selectedTodo={selId ? items.find(t => t.id === selId) : null}
          recRule={selId ? recById[items.find(t => t.id === selId)?.recurrenceId] : null}
          schedulingActive={!!schedulingId && schedulingId === selId}
          onEndSchedule={endSchedule}
          pushUndo={pushUndo}
        />
      }
    />
  );
}

// 헤더 세그먼트 알약 — sk-label 의 uppercase / 라벨 폰트를 셀프 리셋
function SegPill({ active, onClick, icon, label, count }) {
  return (
    <button onClick={onClick} style={{
      all: "unset", cursor: "pointer",
      display: "inline-flex", alignItems: "center", gap: 5,
      padding: "2px 9px", borderRadius: 99,
      border: active ? "1.2px solid var(--ink)" : "1px solid var(--ink-soft)",
      background: active ? "var(--paper)" : "transparent",
      boxShadow: active ? "0 1.5px 0 var(--paper-3)" : "none",
      fontFamily: "var(--hand)", fontSize: 12,
      textTransform: "none", letterSpacing: "normal",
      color: active ? "var(--ink)" : "var(--ink-3)",
      minWidth: 0, flexShrink: 1,
    }}>
      <span style={{ fontSize: 11, flexShrink: 0 }}>{icon}</span>
      <span style={{
        whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
        minWidth: 0,
      }}>{label}</span>
      <span style={{
        fontFamily: "var(--mono)", fontSize: 10, fontWeight: 700,
        color: active ? "var(--ink)" : "var(--ink-3)",
        background: active ? "rgba(255,255,255,0.7)" : "transparent",
        padding: active ? "0 5px" : "0 2px", borderRadius: 99,
        border: active ? "1px solid rgba(40,51,63,0.2)" : "none",
        flexShrink: 0,
      }}>{count}</span>
    </button>
  );
}

// ---- 진행도 바 — 투두리스트와 달력 사이 ----
// 보이는 모든 체크박스(상위 + 하위) 합산 비율.
function TodoProgressBar({ done, total }) {
  const pct = total ? Math.round((done / total) * 100) : 0;
  const complete = total > 0 && done === total;
  const excel = typeof window.isExcelSkin === "function" && window.isExcelSkin();
  return (
    <div
      className={excel ? "excel-todo-progress" : undefined}
      style={{
      display: "flex", alignItems: "center", gap: 6,
      padding: excel ? "3px 8px" : "3px 12px",
      background: excel ? "#ffffff" : "rgba(255,255,255,0.55)",
      borderTop: excel ? "1px solid #a6a6a6" : "1px solid var(--ink-soft)",
    }}
    >
      <div style={{
        flex: 1, height: 5, borderRadius: 99,
        background: "rgba(0,0,0,0.1)", overflow: "hidden",
      }}>
        <div style={{
          width: `${pct}%`, height: "100%",
          background: complete
            ? "#52c759"
            : "linear-gradient(90deg, var(--point-soft, #ffe3a0), var(--point, #fdff85))",
          transition: "width 0.2s ease",
        }} />
      </div>
      <span style={{
        fontFamily: "var(--mono)", fontSize: 10,
        color: complete ? "#2f7d44" : "var(--ink-2)",
        flexShrink: 0, minWidth: 28, textAlign: "right",
      }}>{done}/{total}</span>
    </div>
  );
}

// ---- 컴팩트 월간 달력 (할 일 탭 아래) ----
// 통합 일정 패널(반복+기간) — 기간 드래그는 선택, 드래그 후 확인 단계
function TodoMonthCalendar({ items, recById, selectedDate, onPick, actions, selId, selectedTodo, recRule, schedulingActive, onEndSchedule, pushUndo }) {
  const today = new Date();
  const [year, setYear] = useState(today.getFullYear());
  const [month, setMonth] = useState(today.getMonth());
  const todayStr = diary.today();

  const [downDate, setDownDate] = useState(null);
  const [hoverDate, setHoverDate] = useState(null);
  const [dropDate, setDropDate] = useState(null);
  const [schedulingPhase, setSchedulingPhase] = useState("panel");
  const [pendingPeriod, setPendingPeriod] = useState(null);
  const [showRecPanel, setShowRecPanel] = useState(false);
  const [previewRecRule, setPreviewRecRule] = useState(null);
  const [draftPeriod, setDraftPeriod] = useState(null);
  const [savedFlash, setSavedFlash] = useState(false);
  const scheduleSessionRef = React.useRef(null);
  const gridRef = React.useRef(null);

  const selPeriod = selectedTodo ? normalizedPeriod(selectedTodo) : null;
  const showHint = schedulingActive;

  const calendarMarks = React.useMemo(() => {
    if (!schedulingActive || !showRecPanel || !previewRecRule || !selId) {
      return mergeCalendarMarks(items, recById, null);
    }
    const override = { todoId: selId, previewRule: previewRecRule };
    const previewP = (schedulingPhase === "confirm" && pendingPeriod)
      ? { start: pendingPeriod.lo, end: pendingPeriod.hi }
      : (schedulingPhase === "panel" && draftPeriod ? draftPeriod : null);
    if (previewP) override.previewPeriod = previewP;
    return mergeCalendarMarks(items, recById, override);
  }, [items, recById, schedulingActive, showRecPanel, previewRecRule, selId, schedulingPhase, pendingPeriod, draftPeriod]);

  useEffect(() => {
    if (!schedulingActive) {
      scheduleSessionRef.current = null;
      setPendingPeriod(null);
      setShowRecPanel(false);
      setPreviewRecRule(null);
      setDraftPeriod(null);
      setSavedFlash(false);
      setSchedulingPhase("panel");
      return;
    }
    // selId 기준 세션 초기화 — selectedTodo/recRule 변경(저장 직후)에 재실행되면 패널이 닫힌 채 고착되므로 의존성에서 제외
    if (scheduleSessionRef.current === selId) return;
    scheduleSessionRef.current = selId;

    setPendingPeriod(null);
    setPreviewRecRule(null);
    setDraftPeriod(null);
    setSavedFlash(false);
    setSchedulingPhase("panel");
    setShowRecPanel(true);
  }, [schedulingActive, selId]);

  useEffect(() => {
    if (!savedFlash) return;
    const id = setTimeout(() => setSavedFlash(false), 2000);
    return () => clearTimeout(id);
  }, [savedFlash]);

  useEffect(() => {
    if (!downDate) return;
    const onUp = () => {
      if (schedulingActive && selId && schedulingPhase === "panel") {
        const lo = downDate < hoverDate ? downDate : hoverDate;
        const hi = downDate < hoverDate ? hoverDate : downDate;
        setPendingPeriod({ lo, hi });
        setSchedulingPhase("confirm");
        setSavedFlash(false);
      } else if (!schedulingActive) {
        onPick(downDate);
      }
      setDownDate(null);
      setHoverDate(null);
    };
    window.addEventListener("mouseup", onUp);
    return () => window.removeEventListener("mouseup", onUp);
  }, [downDate, hoverDate, selId, onPick, schedulingActive, schedulingPhase]);

  const confirmPeriod = () => {
    if (!pendingPeriod || !selId || !actions) return;
    pushUndo?.();
    actions.setTodoPeriod(selId, pendingPeriod.lo, pendingPeriod.hi);
    actions.setTodoDue(selId, null);
    setPendingPeriod(null);
    setPreviewRecRule(null);
    onEndSchedule?.();
  };

  const cancelPeriod = () => {
    setPendingPeriod(null);
    setSchedulingPhase("panel");
    setShowRecPanel(true);
  };

  const saveRecurrence = () => {
    pushUndo?.();
    setSavedFlash(true);
  };

  const applyPeriodFromPanel = (lo, hi) => {
    if (!selId || !actions || !lo || !hi || lo > hi) return;
    pushUndo?.();
    actions.setTodoPeriod(selId, lo, hi);
    actions.setTodoDue(selId, null);
    setPendingPeriod(null);
    setDraftPeriod(null);
    setSchedulingPhase("panel");
    setSavedFlash(true);
  };

  const canDragPeriod = schedulingActive && schedulingPhase === "panel";

  const inRangeDrag = (d) => {
    if (schedulingPhase === "confirm" && pendingPeriod) {
      return pendingPeriod.lo <= d && d <= pendingPeriod.hi;
    }
    if (schedulingPhase === "panel" && draftPeriod && !downDate) {
      return draftPeriod.start <= d && d <= draftPeriod.end;
    }
    if (!canDragPeriod || !downDate || !hoverDate) return false;
    const lo = downDate < hoverDate ? downDate : hoverDate;
    const hi = downDate < hoverDate ? hoverDate : downDate;
    return lo <= d && d <= hi;
  };

  const inRepeatPreview = (d) => {
    if (!showRecPanel || !previewRecRule) return false;
    let lo, hi;
    if (schedulingPhase === "confirm" && pendingPeriod) {
      lo = pendingPeriod.lo;
      hi = pendingPeriod.hi;
    } else if (schedulingPhase === "panel" && draftPeriod) {
      lo = draftPeriod.start;
      hi = draftPeriod.end;
    } else if (schedulingPhase === "panel" && selPeriod) {
      lo = selPeriod.start;
      hi = selPeriod.end;
    } else return false;
    if (d < lo || d > hi) return false;
    return diary.dayMatchesRecurrence?.(d, previewRecRule);
  };

  const startDow = new Date(year, month, 1).getDay();
  const daysInMonth = new Date(year, month + 1, 0).getDate();
  const cells = [];
  for (let i = 0; i < startDow; i++) cells.push(null);
  for (let d = 1; d <= daysInMonth; d++) cells.push(d);
  while (cells.length % 7 !== 0) cells.push(null);
  const rows = cells.length / 7;
  const dows = (window.i18n && window.i18n.weekdays) ? window.i18n.weekdays() : ["일","월","화","수","목","금","토"];

  useEffect(() => {
    const el = gridRef.current;
    if (!el) return;
    const onWheel = (e) => {
      if (el.scrollHeight <= el.clientHeight + 1) return;
      el.scrollTop += e.deltaY;
      e.preventDefault();
    };
    el.addEventListener("wheel", onWheel, { passive: false });
    return () => el.removeEventListener("wheel", onWheel);
  }, [rows, schedulingActive, month, year]);

  const prev = () => { if (month === 0) { setYear(y => y - 1); setMonth(11); } else setMonth(m => m - 1); };
  const next = () => { if (month === 11) { setYear(y => y + 1); setMonth(0); } else setMonth(m => m + 1); };
  const goToday = () => { setYear(today.getFullYear()); setMonth(today.getMonth()); };

  const ymLabel = (() => {
    const lng = window.i18n && window.i18n.get && window.i18n.get();
    if (lng === "en") return `${["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"][month]} ${year}`;
    if (lng === "ko") return `${year}년 ${month + 1}월`;
    return `${year}年 ${month + 1}月`;
  })();

  const hintText = savedFlash
    ? L("todo.scheduleSaved")
    : (L("todo.schedulePanelHint") || L("todo.setRepeatHintNoPeriod") || L("todo.setRepeatHint"));

  const pendingRangeLabel = pendingPeriod
    ? (pendingPeriod.lo === pendingPeriod.hi ? fmtMD(pendingPeriod.lo) : `${fmtMD(pendingPeriod.lo)}-${fmtMD(pendingPeriod.hi)}`)
    : "";

  const calPanelCls = typeof window.excelPanelClass === "function" ? window.excelPanelClass("excel-cal-panel") : "";
  const calSheetCls = calPanelCls ? "excel-cal-sheet" : "";
  const ribbonCls = typeof window.excelRibbonClass === "function" ? window.excelRibbonClass("excel-cal-toolbar") : "";
  const schedulePanelOpen = schedulingActive && showRecPanel && selectedTodo
    && (schedulingPhase === "panel" || schedulingPhase === "confirm");
  const gridMinHeight = schedulingActive ? CAL_WEEK_PX * CAL_GRID_MIN_WEEKS_SCHED : 0;
  return (
    <div
      className={[calSheetCls, calPanelCls].filter(Boolean).join(" ") || undefined}
      style={{
        height: "100%", minHeight: 0, display: "flex", flexDirection: "column",
        border: calPanelCls
          ? (schedulingActive ? "2px solid var(--point)" : "none")
          : (schedulingActive ? "2px solid var(--point)" : "1.1px solid var(--ink)"),
        borderRadius: calPanelCls ? 0 : 10,
        overflow: "hidden",
        background: "white",
        boxShadow: schedulingActive && !calPanelCls
          ? "0 0 0 3px color-mix(in srgb, var(--point) 28%, transparent)"
          : "none",
        transition: "border-color 0.15s, box-shadow 0.15s",
      }}
    >
      {/* 헤더 — 스프레드 시트: 월 이동 한 줄 표 */}
      <div
        className={[ribbonCls, calPanelCls && schedulingActive ? "is-scheduling" : ""].filter(Boolean).join(" ") || undefined}
        style={{
          flexShrink: 0, display: "flex", alignItems: "center", gap: calPanelCls ? 0 : 4,
          padding: calPanelCls ? 0 : "4px 8px",
          background: ribbonCls
            ? undefined
            : (schedulingActive
              ? "linear-gradient(180deg, color-mix(in srgb, var(--point) 55%, white), color-mix(in srgb, var(--point) 18%, white))"
              : "linear-gradient(180deg, color-mix(in srgb, var(--chrome,#a9cdf5) 42%, white), color-mix(in srgb, var(--chrome,#a9cdf5) 12%, white))"),
          borderBottom: calPanelCls ? undefined : "1.1px solid var(--ink)",
        }}
      >
        <button onClick={prev} style={navBtn}>{typeof window.skinG === "function" ? window.skinG("prev", "◀") : "◀"}</button>
        <div style={{
          flex: 1, textAlign: "center",
          fontFamily: "var(--hand)", fontSize: 13, fontWeight: 700, color: "var(--ink)",
          cursor: "pointer",
        }} onClick={goToday}>{ymLabel}</div>
        <button onClick={next} style={navBtn}>{typeof window.skinG === "function" ? window.skinG("next", "▶") : "▶"}</button>
        {schedulingActive && (
          <button onClick={onEndSchedule} title={L("todo.close")} style={{ ...navBtn, width: "auto", padding: "0 6px", fontSize: 11 }}>✕</button>
        )}
      </div>
      {showHint && schedulingPhase !== "confirm" && (
        <div
          className={calPanelCls ? "excel-cal-hint" : undefined}
          style={{
          flexShrink: 0, padding: calPanelCls ? "4px 8px" : "5px 10px",
          fontFamily: "var(--hand)", fontSize: 12,
          color: savedFlash ? "#2f7d44" : "var(--ink)",
          background: savedFlash
            ? "color-mix(in srgb, #52c759 18%, white)"
            : "color-mix(in srgb, var(--point) 12%, white)",
          borderBottom: "1px dashed rgba(40,51,63,.22)",
          lineHeight: 1.35,
          wordBreak: "keep-all",
        }}>
          {hintText}
        </div>
      )}
      {schedulingActive && schedulingPhase === "confirm" && pendingPeriod && (
        <PeriodConfirmBar
          rangeLabel={pendingRangeLabel}
          onYes={confirmPeriod}
          onNo={cancelPeriod}
        />
      )}
      {schedulePanelOpen && (
        <SchedulePanel
          key={`${selectedTodo.id}-${recRule?.id || "n"}-${recRule?.frequency || "daily"}`}
          t={selectedTodo}
          recRule={recRule}
          actions={actions}
          pendingPeriod={schedulingPhase === "confirm" ? pendingPeriod : null}
          periodConfirmActive={schedulingPhase === "confirm"}
          onPreviewChange={setPreviewRecRule}
          onClose={() => {
            setShowRecPanel(false);
            setPreviewRecRule(null);
            onEndSchedule?.();
          }}
          onSave={saveRecurrence}
          onApplyPeriod={applyPeriodFromPanel}
          onPreviewPeriodChange={setDraftPeriod}
          onClearPeriod={() => {
            pushUndo?.();
            actions.setTodoPeriod(selectedTodo.id, null, null);
            setPendingPeriod(null);
            setDraftPeriod(null);
          }}
          onClearSchedule={() => {
            pushUndo?.();
            actions.clearTodoSchedule(selectedTodo.id);
            setPendingPeriod(null);
            setPreviewRecRule(null);
            onEndSchedule?.();
          }}
        />
      )}
      {/* 요일 헤더 행 */}
      <div className={calPanelCls ? "excel-cal-dow" : undefined} style={{ display: "grid", gridTemplateColumns: "repeat(7,1fr)", flexShrink: 0, background: calPanelCls ? undefined : "#f7f9fc" }}>
        {dows.map((w, i) => (
          <div key={i} className={calPanelCls ? "excel-cal-dow-cell" : undefined} style={calPanelCls ? undefined : {
            textAlign: "center", padding: "2px 0",
            fontFamily: "var(--mono)", fontSize: 10,
            color: i === 0 ? "#e06a7a" : (i === 6 ? "#5b8fd6" : "var(--ink-3)"),
          }}>{w}</div>
        ))}
      </div>
      {/* 날짜 그리드 — 셀 최소 높이 보장 + 부족하면 스크롤. 스크롤은 주 단위로 스냅. */}
      <div
        ref={gridRef}
        className={calPanelCls ? "excel-cal-grid" : undefined}
        style={{
        flex: 1,
        minHeight: gridMinHeight,
        overflowY: "auto", overflowX: "hidden",
        display: "grid",
        gridTemplateColumns: "repeat(7,1fr)",
        gridTemplateRows: `repeat(${rows}, minmax(30px, 1fr))`,
        scrollSnapType: "y mandatory",
        scrollPaddingTop: 0,
      }}
      >
        {cells.map((d, i) => {
          if (d == null) {
            return (
              <div
                key={i}
                className={calPanelCls ? "excel-cal-cell excel-cal-empty" : undefined}
                style={calPanelCls ? { scrollSnapAlign: "start" } : {
                  borderTop: "1px solid #eef0f3",
                  borderLeft: i % 7 === 0 ? "none" : "1px solid #eef0f3",
                  scrollSnapAlign: "start",
                }}
              />
            );
          }
          const ds = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
          const isToday = ds === todayStr;
          const isSelected = ds === selectedDate;
          const isDropHover = dropDate === ds;
          const recDone = calendarMarks.recurringDone.has(ds) && !calendarMarks.recurringPending.has(ds);
          const recPending = calendarMarks.recurringPending.has(ds);
          const isDragRange = inRangeDrag(ds);
          const isRepeatDay = inRepeatPreview(ds);

          let cellBg = "transparent";
          if (isDropHover) cellBg = "var(--hi)";
          else if (isDragRange) cellBg = "rgba(224, 122, 32, 0.28)";
          else if (isRepeatDay) cellBg = "rgba(224, 122, 32, 0.14)";
          else if (isSelected) cellBg = "var(--hi-soft)";

          const cellCls = calPanelCls
            ? ["excel-cal-cell", isToday ? "excel-cal-today" : "", isSelected ? "excel-cal-selected" : ""].filter(Boolean).join(" ")
            : undefined;
          return (
            <div
              key={i}
              className={cellCls}
              onMouseDown={(e) => {
                if (e.button !== 0) return;
                if (canDragPeriod) {
                  setDownDate(ds);
                  setHoverDate(ds);
                } else {
                  onPick(ds);
                }
              }}
              onMouseEnter={() => { if (canDragPeriod && downDate) setHoverDate(ds); }}
              onDragOver={(e) => {
                if (!actions) return;
                e.preventDefault();
                e.dataTransfer.dropEffect = "move";
                if (dropDate !== ds) setDropDate(ds);
              }}
              onDragLeave={() => { if (dropDate === ds) setDropDate(null); }}
              onDrop={(e) => {
                e.preventDefault();
                e.stopPropagation();
                const id = todoDragIdFromEvent(e);
                setDropDate(null);
                _todoHtml5DragId = null;
                if (id && actions) actions.moveTodoToDate(id, ds);
              }}
              style={{
                cursor: canDragPeriod ? "crosshair" : "pointer", position: "relative",
                display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 1,
                borderTop: calPanelCls ? undefined : "1px solid #eef0f3",
                borderLeft: calPanelCls ? undefined : (i % 7 === 0 ? "none" : "1px solid #eef0f3"),
                background: calPanelCls
                  ? (cellBg !== "transparent" ? cellBg : undefined)
                  : cellBg,
                boxShadow: (!calPanelCls && isSelected && !isDragRange && !isDropHover)
                  ? "inset 0 0 0 1.5px var(--ink)" : "none",
                userSelect: "none",
                scrollSnapAlign: "start",
              }}>
              <span className={calPanelCls ? "excel-cal-day-num" : undefined} style={calPanelCls ? undefined : {
                width: 20, height: 20, display: "grid", placeItems: "center", borderRadius: "50%",
                fontFamily: "var(--mono)", fontSize: 10.5, fontWeight: isToday ? 700 : 400,
                background: isToday ? "var(--point)" : "transparent",
                color: "var(--ink)",
                pointerEvents: "none",
              }}>{d}</span>
              <span style={{ display: "inline-flex", gap: 2, height: 8, alignItems: "center", pointerEvents: "none" }}>
                {recPending && <ScheduleRing filled={false} />}
                {recDone && <ScheduleRing filled={true} />}
              </span>
            </div>
          );
        })}
      </div>
    </div>
  );
}

const navBtn = {
  all: "unset", cursor: "pointer",
  width: 22, height: 20, borderRadius: 4,
  display: "grid", placeItems: "center",
  fontSize: 10, color: "var(--ink-2)",
  background: "rgba(255,255,255,0.6)", border: "1px solid var(--ink-soft)",
};

function PeriodConfirmBar({ rangeLabel, onYes, onNo }) {
  const stop = (e) => e.stopPropagation();
  return (
    <div style={{
      flexShrink: 0, padding: "8px 10px",
      borderBottom: "1.1px solid var(--ink)",
      background: "color-mix(in srgb, var(--point) 10%, white)",
      display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap",
    }}>
      <span style={{ fontFamily: "var(--hand)", fontSize: 12, color: "var(--ink)", flex: 1, minWidth: 0 }}>
        {L("todo.periodConfirm", { range: rangeLabel })}
      </span>
      <button type="button" onMouseDown={stop} onMouseUp={stop} onClick={(e) => { stop(e); onYes(); }} style={{ ...miniBtn, background: "var(--point)", padding: "2px 12px" }}>
        {L("todo.yes")}
      </button>
      <button type="button" onMouseDown={stop} onMouseUp={stop} onClick={(e) => { stop(e); onNo(); }} style={miniBtn}>{L("todo.no")}</button>
    </div>
  );
}

function excelMemoLabel(memo, full) {
  if (!memo) return "";
  const title = (memo.title || "").trim();
  if (title) return full ? title : (title.length > 10 ? title.slice(0, 10) + "…" : title);
  const text = ((memo.html || "").replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
  if (!text) return "■";
  if (full) return text;
  return text.length > 10 ? text.slice(0, 10) + "…" : text;
}

// 열 문자 헤더(A,B,C,D) + 1행 의미 헤더(✓/할 일/일정/시간). "번호" 라벨 제거 → 잘림 원천 차단.
function ExcelTodoHead({ gridCols, onResizeStart }) {
  useI18n();
  const letters = [
    { key: "corner", label: "", cls: "excel-todo-letter excel-todo-corner", resize: 0 },
    { key: "A", label: "A", cls: "excel-todo-letter", resize: 1 },
    { key: "B", label: "B", cls: "excel-todo-letter", resize: null },
    { key: "C", label: "C", cls: "excel-todo-letter", resize: 2 },
  ];
  return (
    <>
      <div
        className="excel-todo-letters"
        role="presentation"
        style={{ display: "grid", gridTemplateColumns: gridCols }}
      >
        {letters.map((c) => (
          <span key={c.key} className={c.cls}>
            {c.label}
            {c.resize != null && onResizeStart && (
              <span
                className="excel-col-resizer"
                role="separator"
                aria-orientation="vertical"
                aria-label={L("todo.excelResizeCol")}
                onMouseDown={onResizeStart(c.resize)}
              />
            )}
          </span>
        ))}
      </div>
      <div
        className="excel-todo-head"
        role="row"
        style={{ display: "grid", gridTemplateColumns: gridCols }}
      >
        <span className="excel-todo-gutter" aria-hidden>1</span>
        <span className="excel-th-done" title={L("todo.excelColDone")}>✓</span>
        <span className="excel-th-title">{L("todo.excelColTitle")}</span>
        <span className="excel-th-time">{L("todo.excelColTime")}</span>
      </div>
    </>
  );
}

// ---- 할 일 행 ----
// • selected: 그 행만 액션 아이콘 / 외곽선 / 드래그 활성
// • compact:  중요·예정 트레이용 (드래그 없음)
// • t.done:   취소선 + 회색
// • subTasks: 접히는 하위 목록 + 진행도 (n/m) 뱃지
function TodoRow({
  t, actions, recRule, i = 0, compact = false, selected = false, onPick, completionDay,
  schedulingActive = false, onScheduleDate, linkingSchedule = false, onLinkSchedule, onCloseLinkSchedule,
  scheduleBlock = null, dayScheduleBlocks = [], memo = null, tapeOpts, excelColWidths,
  tracking = false,
}) {
  const markDay = completionDay || diary.today();
  const toggleOpts = { completionDay: markDay };
  const excelRow = typeof window.isExcelSkin === "function" && window.isExcelSkin() && !compact;
  // 서브태스크 — 스프레드 시트 표는 기본 접힘(한 줄 유지), 그 외는 항목 있으면 펼침
  const [subOpen, setSubOpen] = useState(() => {
    if (excelRow) return false;
    return (t.subTasks?.length ?? 0) > 0;
  });
  const [subInput, setSubInput] = useState("");
  // 호버 기반 액션 노출 — 흑백 스킨 패턴 (셀렉트 없이 호버만으로 드래그 핸들/액션 보임).
  const [hover, setHover] = useState(false);
  // 제목 인라인 편집 — Things 3 패턴 (클릭하면 그 자리에서 input).
  const [editingTitle, setEditingTitle] = useState(false);
  // 외부에서 첫 서브태스크 추가됐을 때(드래그 nest 등) 자동 펼침
  const hadSubsRef = React.useRef((t.subTasks?.length ?? 0) > 0);
  React.useEffect(() => {
    const has = (t.subTasks?.length ?? 0) > 0;
    if (has && !hadSubsRef.current && !excelRow) setSubOpen(true);
    hadSubsRef.current = has;
  }, [t.subTasks?.length, excelRow]);
  // 드롭 모드 — null | "before"(위로 이동) | "into"(하위로 넣기)
  const [dropMode, setDropMode] = useState(null);
  // 호버 시 액션 노출. 스케줄링 중에는 📅만 항상 표시.
  const showActions = !compact && hover;
  const showScheduleBtn = !compact && (hover || schedulingActive);
  const showDrag = !compact && !t.done;
  const showHandle = showDrag;

  const subs = t.subTasks || [];
  const subDone = subs.filter(s => s.done).length;
  const hasSubs = subs.length > 0;

  const stop = (e) => e.stopPropagation();
  const onRowClick = (e) => { e.stopPropagation(); if (onPick) onPick(t.id); };

  const onDragStart = (e) => {
    _todoHtml5DragId = t.id;
    e.dataTransfer.setData("text/todo-id", t.id);
    e.dataTransfer.setData("text/plain", t.id);
    e.dataTransfer.effectAllowed = "move";
  };
  const onDragEnd = () => { _todoHtml5DragId = null; };
  const onDragOver = (e) => {
    e.preventDefault();
    e.dataTransfer.dropEffect = "move";
    if (compact) return;
    // 위쪽 32% = 위로 이동(reorder), 그 외 = 하위로 넣기(nest)
    const rect = e.currentTarget.getBoundingClientRect();
    const ratio = (e.clientY - rect.top) / Math.max(1, rect.height);
    const mode = ratio < 0.32 ? "before" : "into";
    if (dropMode !== mode) setDropMode(mode);
  };
  const onDragLeave = (e) => {
    // 자식 진입으로 인한 false leave 방지 — geometry 로 실제 row 밖인지 확인
    const rect = e.currentTarget.getBoundingClientRect();
    if (e.clientX < rect.left || e.clientX > rect.right || e.clientY < rect.top || e.clientY > rect.bottom) {
      setDropMode(null);
    }
  };
  const onDrop = (e) => {
    e.preventDefault(); e.stopPropagation();
    const fromId = todoDragIdFromEvent(e);
    const mode = dropMode;
    setDropMode(null);
    _todoHtml5DragId = null;
    if (!fromId || fromId === t.id) return;
    if (mode === "into") {
      actions.nestAsSubTask(t.id, fromId);
    } else {
      actions.reorderTodoBefore(fromId, t.id);
    }
  };

  const addSub = () => {
    const v = subInput.trim();
    if (!v) return;
    actions.addSubTask(t.id, v);
    setSubInput("");
  };

  const openTodoMemo = (e) => {
    stop(e);
    const memoId = actions.ensureTodoMemo ? actions.ensureTodoMemo(t.id) : null;
    if (!memoId) return;
    window.todoaryPendingMemoId = memoId;
    window.dispatchEvent(new CustomEvent("todoary-open-memo", { detail: { memoId, todoId: t.id } }));
  };

  const schedCellRef = React.useRef(null);
  const period = normalizedPeriod(t);
  const recPart = recRule ? recLabel(recRule) : null;
  const periodPart = period ? periodLabel(t) : null;
  let schedLabel = null;
  if (periodPart && recPart) schedLabel = `${periodPart} · ${recPart}`;
  else if (periodPart) schedLabel = periodPart;
  else if (recPart) schedLabel = recPart;
  const schedText = schedLabel
    ? (t.dueDate && !periodPart ? `${schedLabel} · ${fmtMD(t.dueDate)}` : schedLabel)
    : (t.dueDate ? fmtMD(t.dueDate) : "");
  // C열 "시간" — 타이머로 실제 측정한 시간 (H:MM). 값 없으면 "–".
  const trackedMin = Math.floor((t.trackedSeconds || 0) / 60);
  const trackedText = trackedMin > 0 || tracking
    ? `${Math.floor(trackedMin / 60)}:${String(trackedMin % 60).padStart(2, "0")}`
    : "";
  const schedDateText = excelSchedDateText(t);
  const schedUrgency = excelSchedUrgency(t, diary.today());

  const titleField = editingTitle ? (
    <input
      className="excel-todo-title-text is-editing"
      autoFocus
      defaultValue={t.title}
      onClick={stop}
      onMouseDown={stop}
      onBlur={(e) => {
        const v = e.target.value.trim();
        if (v && v !== t.title) actions.updateTodo(t.id, { title: v });
        setEditingTitle(false);
      }}
      onKeyDown={(e) => {
        if (e.key === "Enter") { e.preventDefault(); e.target.blur(); }
        else if (e.key === "Escape") {
          e.preventDefault();
          e.target.value = t.title;
          setEditingTitle(false);
        }
      }}
      style={{
        border: 0,
        background: "#ffffff", borderRadius: 0, padding: "1px 4px",
        fontFamily: "var(--hand)", fontSize: 13, color: "var(--ink)",
      }}
    />
  ) : (
    <span
      className="excel-todo-title-text"
      onClick={(e) => { stop(e); if (!t.done) setEditingTitle(true); }}
      title={t.title}
      style={{
        // 타이포 위계 — 본문 13px, 메타(날짜·시간·그룹 헤더)는 10–11px
        fontFamily: "var(--hand)", fontSize: 13,
        color: t.done ? "var(--ink-3)" : "var(--ink)",
        textDecoration: t.done ? "line-through" : "none",
        cursor: t.done ? "default" : "text",
      }}
    >{t.title}</span>
  );

  if (excelRow) {
    const parentStripe = i % 2 ? " excel-todo-row-even" : " excel-todo-row-odd";
    return (
      <>
        <div
          onClick={onRowClick}
          onMouseEnter={() => setHover(true)}
          onMouseLeave={() => setHover(false)}
          onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop}
          className={todoTapeClassName(tapeOpts?.edgeFade, compact)
            + (selected ? " is-selected" : "")
            + parentStripe
            + (t.done ? " is-done-row" : "")
            + (tracking && !t.done ? " is-tracking-row" : "")
            + (subOpen && hasSubs ? " has-subs-expanded" : "")}
          style={excelTodoRowGridStyle({
            ...(() => {
              const tape = buildTodoTapeStyle({ index: i, pinned: t.pinned, ...(tapeOpts || {}) });
              return t.pinned ? tape : { ...tape, background: undefined };
            })(),
            outline: dropMode === "into" ? "2px solid var(--point)" : "none",
            outlineOffset: 0,
            boxShadow: dropMode === "before" ? "inset 0 3px 0 var(--ink-soft)" : (schedulingActive ? "inset 3px 0 0 var(--ink-2)" : undefined),
            cursor: "default",
            position: "relative",
          }, excelColWidths)}
        >
          <div
            className="excel-todo-gutter excel-todo-cell-idx"
            title={showHandle ? L("todo.dragMove") : ""}
            draggable={showHandle}
            onDragStart={(e) => { if (!showHandle) return; stop(e); onDragStart(e); }}
            onDragEnd={onDragEnd}
            style={{ cursor: showHandle ? "grab" : "default" }}
          >
            <button
              type="button"
              className={"excel-todo-toggle" + (subOpen ? " is-open" : "") + (hasSubs ? " has-subs" : "")}
              onClick={(e) => { stop(e); setSubOpen(o => !o); }}
              title={subOpen ? L("todo.subCollapse") : L("todo.subExpand")}
            >▸</button>
            <span className="excel-todo-idx-num">{i + 2}</span>
          </div>
          <div className="excel-todo-cell excel-todo-cell-done">
            <button
              onClick={(e) => {
                stop(e);
                if (!t.done && tracking && window.todoFocus) window.todoFocus.setActiveId(null);
                actions.toggleTodo(t.id, toggleOpts);
              }}
              className={"sk-check" + (t.done ? " done" : "")}
              style={{ cursor: "pointer" }}
              title={t.done ? L("todo.undoDone") : L("todo.markDone")}
            />
          </div>
          <div
            className={"excel-todo-cell excel-todo-cell-title" + (editingTitle ? " is-editing" : "")}
          >
            <div className="excel-todo-title-line">
              {titleField}
              {tracking && !t.done && (
                <span className="excel-todo-tracking-badge">{L("todo.excelTracking")}</span>
              )}
              {!editingTitle && !scheduleBlock && schedDateText && (
                <span
                  className={"excel-todo-date-inline" + (schedUrgency === "due" ? " is-due" : "")}
                  onClick={(e) => { stop(e); if (onScheduleDate) onScheduleDate(t.id); }}
                  title={schedLabel ? `${L("todo.period")} ${schedLabel}` : `${L("todo.due")} ${schedText}`}
                >{schedDateText}</span>
              )}
              <span className="excel-todo-title-spacer" aria-hidden />
              {hasSubs && !subOpen && (
                <span className="excel-sub-count" title={L("todo.subExpand")}>{subDone}/{subs.length}</span>
              )}
              <div className={"excel-todo-hover-tools" + (hover || schedulingActive || linkingSchedule || tracking ? " is-visible" : "")}>
                {!t.done && (
                  <button
                    type="button"
                    onClick={(e) => { stop(e); if (window.todoFocus) window.todoFocus.toggle(t.id); }}
                    title={tracking ? L("todo.excelTrackStop") : L("todo.excelTrackStart")}
                    className={"excel-todo-hover-track" + (tracking ? " is-tracking" : "")}
                    style={{
                      ...iconBtn,
                      color: tracking ? "#217346" : "var(--ink-3)",
                      fontWeight: tracking ? 800 : 400,
                    }}
                  >{tracking ? "■" : "▶"}</button>
                )}
                <button
                  type="button"
                  ref={schedCellRef}
                  onClick={(e) => {
                    stop(e);
                    if (onLinkSchedule) onLinkSchedule(t.id);
                    else if (onScheduleDate) onScheduleDate(t.id);
                  }}
                  title={scheduleBlock
                    ? `${fmtMinClock(scheduleBlock.startMin)}–${fmtMinClock(scheduleBlock.endMin)} · ${scheduleBlock.title}`
                    : L("todo.excelScheduleLink")}
                  className={"excel-todo-hover-sched" + (scheduleBlock ? " has-value" : "")}
                  style={{
                    ...iconBtn,
                    color: scheduleBlock ? "var(--ink)" : "var(--ink-3)",
                    fontWeight: scheduleBlock ? 700 : 400,
                  }}
                >{typeof window.skinG === "function" ? window.skinG("clock", "🕐") : "🕐"}</button>
                <button
                  type="button"
                  onClick={(e) => { stop(e); actions.togglePin(t.id); }}
                  title={t.pinned ? L("todo.unpin") : L("todo.pin")}
                  className={"excel-todo-hover-pin" + (t.pinned ? " is-pinned" : "")}
                  style={{
                    ...iconBtn,
                    color: t.pinned ? "var(--ink)" : "var(--ink-3)",
                    fontWeight: t.pinned ? 800 : 400,
                  }}
                >!</button>
                <button
                  type="button"
                  onClick={openTodoMemo}
                  title={memo ? excelMemoLabel(memo, true) : L("todo.openMemo")}
                  className={"excel-todo-hover-memo" + (t.memoId ? " has-value" : "")}
                  style={{
                    ...iconBtn,
                    color: t.memoId ? "var(--ink)" : "var(--ink-3)",
                    fontWeight: t.memoId ? 700 : 400,
                  }}
                >{t.memoId ? "✓" : "□"}</button>
                <DelBtn onClick={(e) => {
                  if (e && e.stopPropagation) e.stopPropagation();
                  if (tracking && window.todoFocus) window.todoFocus.setActiveId(null);
                  actions.removeTodo(t.id);
                }} />
              </div>
            </div>
          </div>
          {/* C열 "시간" — 행 타이머로 측정한 시간 (H:MM) */}
          <div
            className={"excel-todo-cell excel-todo-cell-time"
              + (trackedText ? " has-value" : "")
              + (tracking && !t.done ? " is-tracking" : "")}
            title={trackedText ? L("todo.excelTrackedTime", { t: trackedText }) : undefined}
          >
            {trackedText || "–"}
          </div>
          {linkingSchedule && onCloseLinkSchedule && (
            <ExcelScheduleLinkPicker
              day={completionDay}
              blocks={dayScheduleBlocks}
              currentBlockId={t.scheduleBlockId}
              anchorRef={schedCellRef}
              onPick={(blockId) => {
                actions.linkTodoToScheduleBlock(t.id, blockId);
                onCloseLinkSchedule();
              }}
              onUnlink={() => {
                actions.unlinkTodoScheduleBlock(t.id);
                onCloseLinkSchedule();
              }}
              onClose={onCloseLinkSchedule}
            />
          )}
        </div>
        {subOpen && subs.map((st, si) => (
          <ExcelSubTaskGridRow
            key={st.id}
            st={st}
            parentId={t.id}
            parentIdx={i}
            subIdx={si}
            actions={actions}
            completionDay={markDay}
            excelColWidths={excelColWidths}
            rowStripe={(i + si + 1) % 2}
          />
        ))}
        {subOpen && (
          <ExcelSubTaskAddRow
            parentIdx={i}
            subCount={subs.length}
            value={subInput}
            onChange={setSubInput}
            onAdd={addSub}
            excelColWidths={excelColWidths}
          />
        )}
      </>
    );
  }

  return (
    <div
      onClick={onRowClick}
      onMouseEnter={() => setHover(true)}
      onMouseLeave={() => setHover(false)}
      onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop}
      className={todoTapeClassName(tapeOpts?.edgeFade, compact)}
      style={{
        ...buildTodoTapeStyle({ index: i, pinned: t.pinned, ...(tapeOpts || {}) }),
        padding: compact ? "5px 14px" : "6px 10px 6px 4px",
        marginBottom: compact ? 5 : 6,
        outline: dropMode === "into" ? "2px solid var(--point)" : "none",
        outlineOffset: 1,
        // Things 3 식 — 선택 시 살짝 두꺼운 왼쪽 액센트 (큰 외곽선 대신)
        boxShadow: dropMode === "before"
          ? "inset 0 3px 0 var(--ink)"
          : (schedulingActive ? "inset 3px 0 0 var(--ink)" : undefined),
        opacity: t.done ? 0.7 : 1,
        cursor: "default",
        position: "relative",
      }}
    >
      <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
        {/* 드래그 핸들 — macOS WKWebView 에서는 핸들에서만 dragStart 가 안정적 */}
        <span
          draggable={showDrag}
          onDragStart={(e) => { stop(e); onDragStart(e); }}
          onDragEnd={onDragEnd}
          title={showHandle ? L("todo.dragMove") : ""}
          style={{
            flexShrink: 0, width: 12,
            cursor: showHandle ? "grab" : "default",
            color: "var(--ink-3)", fontSize: 12, lineHeight: 1,
            userSelect: "none", textAlign: "center",
            WebkitUserDrag: showDrag ? "element" : "auto",
            opacity: showHandle ? (hover ? 0.55 : 0.28) : 0,
            transition: "opacity 0.12s",
          }}
        >{showHandle ? "⋮⋮" : ""}</span>
        <button
          onClick={(e) => { stop(e); actions.toggleTodo(t.id, toggleOpts); }}
          className={"sk-check" + (t.done ? " done" : "")}
          style={{ cursor: "pointer", flexShrink: 0 }}
          title={t.done ? L("todo.undoDone") : L("todo.markDone")}
        />
        {/* 서브태스크 접힘 토글 — 항목 있거나 호버/선택일 때 노출 */}
        {(hasSubs || hover) && !compact && (
          <button
            onClick={(e) => { stop(e); setSubOpen(o => !o); }}
            title={subOpen ? L("todo.subCollapse") : L("todo.subExpand")}
            style={{
              all: "unset", cursor: "pointer", flexShrink: 0,
              width: 14, height: 14, display: "grid", placeItems: "center",
              fontSize: 10, color: hasSubs ? "var(--ink-2)" : "var(--ink-3)", lineHeight: 1,
              transform: subOpen ? "rotate(90deg)" : "rotate(0deg)",
              transition: "transform 0.15s",
              opacity: hasSubs ? 1 : 0.55,
            }}
          >▸</button>
        )}
        {/* 제목 — 클릭하면 인라인 편집 (Things 3 패턴) */}
        {editingTitle ? (
          <input
            autoFocus
            defaultValue={t.title}
            onClick={stop}
            onMouseDown={stop}
            onBlur={(e) => {
              const v = e.target.value.trim();
              if (v && v !== t.title) actions.updateTodo(t.id, { title: v });
              setEditingTitle(false);
            }}
            onKeyDown={(e) => {
              if (e.key === "Enter") { e.preventDefault(); e.target.blur(); }
              else if (e.key === "Escape") {
                e.preventDefault();
                e.target.value = t.title;
                setEditingTitle(false);
              }
            }}
            style={{
              flex: 1, minWidth: 0,
              border: 0, outline: "none", background: "rgba(255,255,255,0.55)",
              borderRadius: 4, padding: "1px 4px",
              fontFamily: "var(--hand)", fontSize: compact ? 14 : 15,
              color: "var(--ink)",
            }}
          />
        ) : (
          <span
            onClick={(e) => { stop(e); if (!t.done) setEditingTitle(true); }}
            title={L("todo.editTitle")}
            style={{
              flex: 1, minWidth: 0, fontFamily: "var(--hand)",
              fontSize: compact ? 14 : 15,
              color: t.done ? "var(--ink-3)" : "var(--ink)",
              textDecoration: t.done ? "line-through" : "none",
              whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
              cursor: t.done ? "default" : "text",
              padding: "1px 2px",
            }}
          >{t.title}</span>
        )}

        {/* 일정 뱃지 — 기간·반복 또는 단일 마감일 */}
        {schedLabel ? (
          <span title={`${L("todo.period")} ${schedLabel}`} style={dueBadge}>{schedLabel}</span>
        ) : t.dueDate ? (
          <span title={`${L("todo.due")} ${t.dueDate}`} style={dueBadge}>{fmtMD(t.dueDate)}</span>
        ) : null}

        {(showActions || t.memoId) && !compact && (
          <button
            onClick={openTodoMemo}
            title={L("todo.openMemo")}
            style={{ ...iconBtn, color: t.memoId ? "var(--ink)" : "var(--ink-3)" }}
          >{typeof window.skinG === "function" ? window.skinG("note", "📝") : "📝"}</button>
        )}

        {/* 호버/스케줄링 시 액션 아이콘 */}
        {(showActions || showScheduleBtn) && (
          <>
            {showActions && (
              <button onClick={(e) => { stop(e); actions.togglePin(t.id); }} title={t.pinned ? L("todo.unpin") : L("todo.pin")}
                style={{ ...iconBtn, color: t.pinned ? "#7a5a10" : "var(--ink-3)", fontWeight: t.pinned ? 800 : 400 }}>!</button>
            )}
            {showScheduleBtn && (
              <button
                onClick={(e) => { stop(e); if (onScheduleDate) onScheduleDate(t.id); }}
                title={L("todo.dueDate")}
                style={{
                  ...iconBtn,
                  color: schedulingActive ? "var(--ink)" : "var(--ink-3)",
                  background: schedulingActive ? "var(--hi-soft)" : "transparent",
                  borderRadius: 4,
                  outline: schedulingActive ? "1.5px solid var(--point)" : "none",
                }}
              >{typeof window.skinG === "function" ? window.skinG("schedule", "📅") : "📅"}</button>
            )}
            {showActions && (
              <DelBtn onClick={(e) => { if (e && e.stopPropagation) e.stopPropagation(); actions.removeTodo(t.id); }} />
            )}
          </>
        )}
      </div>

      {/* 서브태스크 펼침 — 흑백 스킨 식 들여쓰기, 박스 없음 */}
      {subOpen && !compact && (
        <div style={{
          marginTop: 4,
          paddingLeft: 22,  // 체크박스 + 핸들 위치 정도로 들여쓰기
          display: "flex", flexDirection: "column", gap: 1,
        }} onClick={stop}>
          {subs.map(st => (
            <SubTaskRow key={st.id} st={st} parentId={t.id} actions={actions} completionDay={markDay} />
          ))}
          {/* 새 항목 입력 — 버튼 토글 없이 항상 보임. 클릭해서 바로 타이핑, Enter 로 추가하고 연속 입력. */}
          <div style={{ display: "flex", alignItems: "center", gap: 6, padding: "1px 0" }}>
            <span
              aria-hidden="true"
              style={{
                width: 14, flexShrink: 0,
                color: "var(--ink-3)", fontSize: 11, textAlign: "center",
                opacity: subInput ? 0.7 : 0.35,
                transition: "opacity 0.12s",
              }}
            >+</span>
            <input
              value={subInput}
              onChange={(e) => setSubInput(e.target.value)}
              onKeyDown={(e) => {
                if (e.key === "Enter") { e.preventDefault(); addSub(); }
                else if (e.key === "Escape") {
                  e.preventDefault();
                  setSubInput("");
                  e.target.blur();
                }
              }}
              placeholder={L("todo.subAdd")}
              style={{
                flex: 1, minWidth: 0,
                border: 0, outline: "none", background: "transparent",
                fontFamily: "var(--hand)", fontSize: 13, color: "var(--ink)",
                padding: "1px 2px",
              }}
            />
          </div>
        </div>
      )}

    </div>
  );
}

function ExcelSubTaskGridRow({ st, parentId, parentIdx, subIdx, actions, completionDay, excelColWidths, rowStripe }) {
  const markDay = completionDay || diary.today();
  const toggleOpts = { completionDay: markDay };
  const [hover, setHover] = useState(false);
  const [editing, setEditing] = useState(false);
  const linkUrl = (st.linkUrl || "").trim();
  const stop = (e) => e.stopPropagation();

  const editLink = async (e) => {
    stop(e);
    const msg = L("todo.subLinkPrompt");
    const next = window.dialog && window.dialog.prompt
      ? await window.dialog.prompt(msg, linkUrl)
      : prompt(msg, linkUrl);
    if (next == null) return;
    actions.setSubTaskLink(parentId, st.id, next.trim());
  };
  const openLink = (e) => {
    stop(e);
    if (linkUrl) openExternalUrl(linkUrl);
  };

  return (
    <div
      className={"tape tape-excel-grid excel-todo-sub-row"
        + (rowStripe ? " excel-todo-row-even" : " excel-todo-row-odd")}
      style={excelTodoRowGridStyle({ opacity: st.done ? 0.72 : 1 }, excelColWidths)}
      onMouseEnter={() => setHover(true)}
      onMouseLeave={() => setHover(false)}
      onClick={stop}
    >
      <div className="excel-todo-gutter excel-todo-cell-idx excel-todo-sub-idx" aria-hidden>
        <span className="excel-todo-sub-branch">↳</span>
        <span className="excel-todo-sub-num">{parentIdx + 2}.{subIdx + 1}</span>
      </div>
      <div className="excel-todo-cell excel-todo-cell-done">
        <button
          onClick={(e) => { stop(e); actions.toggleSubTask(parentId, st.id, toggleOpts); }}
          className={"sk-check" + (st.done ? " done" : "")}
          style={{ cursor: "pointer", transform: "scale(0.9)" }}
          title={st.done ? L("todo.undoDone") : L("todo.markDone")}
        />
      </div>
      <div className={"excel-todo-cell excel-todo-cell-title" + (editing ? " is-editing" : "")}>
        <div className="excel-todo-title-line">
          {editing ? (
            <input
              className="excel-todo-title-text is-editing"
              autoFocus
              defaultValue={st.title}
              onClick={stop}
              onMouseDown={stop}
              onBlur={(e) => {
                const v = e.target.value.trim();
                if (v && v !== st.title) actions.renameSubTask(parentId, st.id, v);
                setEditing(false);
              }}
              onKeyDown={(e) => {
                if (e.key === "Enter") { e.preventDefault(); e.target.blur(); }
                else if (e.key === "Escape") {
                  e.preventDefault();
                  e.target.value = st.title;
                  setEditing(false);
                }
              }}
            />
          ) : (
            <span
              className="excel-todo-title-text"
              onClick={(e) => { stop(e); if (!st.done) setEditing(true); }}
              title={st.title}
              style={{
                color: st.done ? "var(--ink-3)" : "var(--ink)",
                textDecoration: st.done ? "line-through" : "none",
                cursor: st.done ? "default" : "text",
              }}
            >{st.title}</span>
          )}
          {linkUrl && (
            <span
              className="excel-todo-sub-link"
              onClick={openLink}
              title={`${L("todo.subLinkOpen")}: ${linkUrl}`}
              style={{ cursor: "pointer" }}
            >↗</span>
          )}
          <span className="excel-todo-title-spacer" aria-hidden />
          <div className={"excel-todo-hover-tools" + (hover ? " is-visible" : "")}>
            <button
              onClick={editLink}
              title={linkUrl ? L("todo.subLinkEdit") : L("todo.subLinkAdd")}
              style={{ ...iconBtn, opacity: 0.75 }}
            >{typeof window.skinG === "function" ? window.skinG("link", "🔗") : "🔗"}</button>
            <button
              onClick={(e) => { stop(e); actions.removeSubTask(parentId, st.id); }}
              title={L("common.delete")}
              style={{ ...iconBtn, opacity: 0.75 }}
            >✕</button>
          </div>
        </div>
      </div>
      {(() => {
        const doneClock = st.done && st.completedAt ? fmtCompletedClock(st.completedAt) : "";
        return (
          <div
            className={"excel-todo-cell excel-todo-cell-time" + (doneClock ? " has-value" : "")}
            title={doneClock ? L("todo.doneAt", { t: doneClock }) : undefined}
          >
            {doneClock || "–"}
          </div>
        );
      })()}
    </div>
  );
}

function ExcelSubTaskAddRow({ parentIdx, subCount, value, onChange, onAdd, excelColWidths }) {
  useI18n();
  const stop = (e) => e.stopPropagation();
  return (
    <div
      className="tape tape-excel-grid excel-todo-sub-row excel-todo-sub-add-row"
      style={excelTodoRowGridStyle({}, excelColWidths)}
      onClick={stop}
    >
      <div className="excel-todo-gutter excel-todo-cell-idx excel-todo-sub-idx" aria-hidden>
        <span className="excel-todo-sub-branch">+</span>
      </div>
      <span className="excel-todo-cell excel-todo-cell-done" aria-hidden />
      <div className="excel-todo-cell excel-todo-cell-title">
        <input
          className="excel-todo-sub-add-input"
          value={value}
          onChange={(e) => onChange(e.target.value)}
          onKeyDown={(e) => {
            if (e.key === "Enter") { e.preventDefault(); onAdd(); }
            else if (e.key === "Escape") { e.preventDefault(); onChange(""); e.target.blur(); }
          }}
          placeholder={L("todo.subAdd")}
        />
      </div>
      <span className="excel-todo-cell excel-todo-cell-time" aria-hidden />
    </div>
  );
}

// 서브태스크 한 줄 — 호버 시 × 노출, 제목 클릭하면 인라인 편집
function SubTaskRow({ st, parentId, actions, completionDay }) {
  const markDay = completionDay || diary.today();
  const toggleOpts = { completionDay: markDay };
  const [hover, setHover] = useState(false);
  const [editing, setEditing] = useState(false);
  const linkUrl = (st.linkUrl || "").trim();
  const editLink = async (e) => {
    e.stopPropagation();
    const msg = L("todo.subLinkPrompt");
    const next = window.dialog && window.dialog.prompt
      ? await window.dialog.prompt(msg, linkUrl)
      : prompt(msg, linkUrl);
    if (next == null) return;
    actions.setSubTaskLink(parentId, st.id, next.trim());
  };
  const openLink = (e) => {
    e.stopPropagation();
    if (linkUrl) openExternalUrl(linkUrl);
  };
  return (
    <div
      onMouseEnter={() => setHover(true)}
      onMouseLeave={() => setHover(false)}
      style={{
        display: "flex", alignItems: "center", gap: 6,
        padding: "1px 0",
      }}
    >
      <button
        onClick={(e) => { e.stopPropagation(); actions.toggleSubTask(parentId, st.id, toggleOpts); }}
        className={"sk-check" + (st.done ? " done" : "")}
        style={{ cursor: "pointer", flexShrink: 0, transform: "scale(0.82)" }}
        title={st.done ? L("todo.undoDone") : L("todo.markDone")}
      />
      {editing ? (
        <input
          autoFocus
          defaultValue={st.title}
          onClick={(e) => e.stopPropagation()}
          onMouseDown={(e) => e.stopPropagation()}
          onBlur={(e) => {
            const v = e.target.value.trim();
            if (v && v !== st.title) actions.renameSubTask(parentId, st.id, v);
            setEditing(false);
          }}
          onKeyDown={(e) => {
            if (e.key === "Enter") { e.preventDefault(); e.target.blur(); }
            else if (e.key === "Escape") {
              e.preventDefault();
              e.target.value = st.title;
              setEditing(false);
            }
          }}
          style={{
            flex: 1, minWidth: 0,
            border: 0, outline: "none",
            background: "rgba(255,255,255,0.55)", borderRadius: 4,
            padding: "1px 4px",
            fontFamily: "var(--hand)", fontSize: 13, color: "var(--ink)",
          }}
        />
      ) : (
        <span
          onClick={(e) => { e.stopPropagation(); if (!st.done) setEditing(true); }}
          style={{
            flex: 1, minWidth: 0, fontFamily: "var(--hand)", fontSize: 13,
            color: st.done ? "var(--ink-3)" : "var(--ink)",
            textDecoration: st.done ? "line-through" : "none",
            wordBreak: "break-word",
            cursor: st.done ? "default" : "text",
            padding: "1px 2px",
          }}
        >{st.title}</span>
      )}
      {linkUrl && (
        <button
          onClick={openLink}
          title={`${L("todo.subLinkOpen")}: ${linkUrl}`}
          style={{ ...subIconBtn, opacity: 0.78 }}
        >↗</button>
      )}
      {hover && (
        <button
          onClick={editLink}
          title={linkUrl ? L("todo.subLinkEdit") : L("todo.subLinkAdd")}
          style={{ ...subIconBtn, opacity: 0.68 }}
        >{typeof window.skinG === "function" ? window.skinG("link", "🔗") : "🔗"}</button>
      )}
      <button
        onClick={(e) => { e.stopPropagation(); actions.removeSubTask(parentId, st.id); }}
        title={L("memo.delete")}
        style={{
          all: "unset", cursor: "pointer", flexShrink: 0,
          width: 14, height: 14, display: "grid", placeItems: "center",
          fontSize: 11, color: "var(--ink-3)", borderRadius: 3,
          opacity: hover ? 0.6 : 0,
          transition: "opacity 0.12s",
        }}
      >×</button>
    </div>
  );
}

const scheduleSectionHead = {
  fontFamily: "var(--hand)", fontSize: 10, fontWeight: 700, color: "var(--ink-3)",
  textTransform: "uppercase", letterSpacing: "0.04em", marginBottom: 3, flexShrink: 0,
};

const periodDateInput = {
  fontFamily: "var(--mono)", fontSize: 10.5, padding: "1px 3px",
  border: "1.1px solid var(--ink)", borderRadius: 6, background: "var(--paper)",
  color: "var(--ink)", width: 108, maxWidth: "32vw",
};

const schedulePanelSection = {
  padding: "4px 8px",
  borderBottom: "1px dashed rgba(40,51,63,.16)",
};

function SchedulePanel({
  t, recRule, actions, pendingPeriod, periodConfirmActive,
  onClose, onSave, onApplyPeriod, onPreviewPeriodChange, onClearPeriod, onClearSchedule, onPreviewChange,
}) {
  const [freq, setFreq] = useState(recRule?.frequency || "daily");
  const initialDays = Array.isArray(recRule?.weeklyDays) && recRule.weeklyDays.length
    ? recRule.weeklyDays.map(Number).filter(d => Number.isInteger(d) && d >= 0 && d <= 6)
    : [new Date().getDay()];
  const [days, setDays] = useState(initialDays);
  const [startDate, setStartDate] = useState("");
  const [endDate, setEndDate] = useState("");
  const DOW = (window.i18n && window.i18n.weekdays) ? window.i18n.weekdays() : ["일", "월", "화", "수", "목", "금", "토"];
  const toggleDay = (d) => setDays(prev => prev.includes(d) ? prev.filter(x => x !== d) : [...prev, d].sort());
  const savedPeriod = normalizedPeriod(t);
  const pendingLabel = pendingPeriod
    ? (pendingPeriod.lo === pendingPeriod.hi ? fmtMD(pendingPeriod.lo) : `${fmtMD(pendingPeriod.lo)}-${fmtMD(pendingPeriod.hi)}`)
    : null;
  const hasSchedule = !!(savedPeriod || recRule || t.recurrenceId);
  const todayStr = diary.today();

  useEffect(() => {
    if (periodConfirmActive) return;
    const p = normalizedPeriod(t);
    setStartDate(p?.start || "");
    setEndDate(p?.end || "");
  }, [t.id, t.startDate, t.endDate, periodConfirmActive]);

  useEffect(() => {
    if (periodConfirmActive || !onPreviewPeriodChange) return;
    if (startDate && endDate && startDate <= endDate) {
      onPreviewPeriodChange({ start: startDate, end: endDate });
    } else {
      onPreviewPeriodChange(null);
    }
  }, [startDate, endDate, periodConfirmActive, onPreviewPeriodChange]);

  useEffect(() => {
    if (!onPreviewChange) return;
    const weeklyDays = days.map(Number).filter(d => Number.isInteger(d) && d >= 0 && d <= 6);
    onPreviewChange({
      frequency: freq,
      weeklyDays: freq === "weekly" ? weeklyDays : [],
    });
  }, [freq, days]);

  const apply = () => {
    const weeklyDays = days.map(Number).filter(d => Number.isInteger(d) && d >= 0 && d <= 6);
    if (freq === "weekly" && weeklyDays.length === 0) return;
    if (onSave) onSave();
    const patch = { title: t.title, frequency: freq, weeklyDays: freq === "weekly" ? weeklyDays : [] };
    if (recRule) actions.updateRecurrence(recRule.id, patch);
    else {
      const id = actions.addRecurrence(patch);
      actions.updateTodo(t.id, { recurrenceId: id });
    }
  };

  const applyPeriodInputs = () => {
    if (!startDate || !endDate || startDate > endDate || !onApplyPeriod) return;
    onApplyPeriod(startDate, endDate);
  };

  const applyPreset = (months) => {
    const start = startDate || savedPeriod?.start || todayStr;
    const end = addMonthsIso(start, months);
    setStartDate(start);
    setEndDate(end);
    if (onApplyPeriod) onApplyPeriod(start, end);
  };

  return (
    <div style={{
      marginTop: 0,
      borderTop: "1.1px solid var(--ink)",
      borderRadius: 0,
      flexShrink: 0,
      maxHeight: SCHEDULE_PANEL_MAX_H,
      overflowY: "auto",
      overflowX: "hidden",
      background: "color-mix(in srgb, var(--point) 4%, white)",
    }}>
      <div style={schedulePanelSection}>
        <div style={scheduleSectionHead}>{L("todo.repeat")}</div>
        <div style={{ display: "flex", gap: 4, alignItems: "center", flexWrap: "wrap" }}>
          {[["daily", L("todo.recDaily")], ["weekdays", L("todo.recWeekdays")], ["weekly", L("todo.recWeekly")]].map(([v, lbl]) => (
            <button key={v} onClick={() => setFreq(v)} style={{ ...chipBtn, padding: "1px 8px", fontSize: 11, background: freq === v ? "var(--hi)" : "var(--paper)" }}>{lbl}</button>
          ))}
          {freq === "weekly" && (
            <div style={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
              {DOW.map((d, idx) => (
                <button key={d} onClick={() => toggleDay(idx)} style={{
                  ...chipBtn, padding: "1px 6px", fontSize: 11,
                  background: days.includes(idx) ? "var(--hi)" : "var(--paper)",
                  color: idx === 0 ? "#e06a7a" : idx === 6 ? "#5b8fd6" : "var(--ink)",
                }}>{d}</button>
              ))}
            </div>
          )}
        </div>
      </div>
      <div style={schedulePanelSection}>
        <div style={{ display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap", marginBottom: periodConfirmActive ? 0 : 4 }}>
          <div style={{ ...scheduleSectionHead, marginBottom: 0 }}>{L("todo.period")}</div>
          {!periodConfirmActive && (
            <div style={{ display: "flex", gap: 4, alignItems: "center", flexWrap: "wrap" }}>
              <button type="button" onClick={() => applyPreset(1)} style={{ ...chipBtn, padding: "1px 7px", fontSize: 11 }}>{L("todo.periodPreset1m")}</button>
              <button type="button" onClick={() => applyPreset(3)} style={{ ...chipBtn, padding: "1px 7px", fontSize: 11 }}>{L("todo.periodPreset3m")}</button>
              {onClearPeriod && (
                <button type="button" onClick={onClearPeriod} style={{ ...chipBtn, padding: "1px 7px", fontSize: 11 }}>{L("todo.periodPresetOngoing")}</button>
              )}
            </div>
          )}
        </div>
        {periodConfirmActive && pendingLabel ? (
          <div style={{ fontFamily: "var(--hand)", fontSize: 11, color: "var(--point)", fontWeight: 600 }}>
            {L("todo.periodPending", { range: pendingLabel })}
          </div>
        ) : (
          <div style={{ display: "flex", gap: 5, alignItems: "center", flexWrap: "wrap" }}>
            <input
              type="date"
              value={startDate}
              onChange={(e) => setStartDate(e.target.value)}
              title={L("todo.periodStart")}
              style={periodDateInput}
            />
            <span style={{ fontFamily: "var(--hand)", fontSize: 11, color: "var(--ink-3)" }}>~</span>
            <input
              type="date"
              value={endDate}
              onChange={(e) => setEndDate(e.target.value)}
              title={L("todo.periodEnd")}
              style={periodDateInput}
            />
            <button
              type="button"
              onClick={applyPeriodInputs}
              disabled={!startDate || !endDate || startDate > endDate}
              style={{
                ...miniBtn,
                background: "var(--hi-soft)",
                opacity: (!startDate || !endDate || startDate > endDate) ? 0.45 : 1,
                cursor: (!startDate || !endDate || startDate > endDate) ? "not-allowed" : "pointer",
              }}
            >{L("todo.applyPeriod")}</button>
            <span style={{ fontSize: 10.5, color: "var(--ink-3)", fontFamily: "var(--hand)" }}>
              {L("todo.periodOrDrag")}
            </span>
          </div>
        )}
      </div>
      <div style={{ padding: "4px 8px", display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" }}>
        <button
          type="button"
          onClick={apply}
          disabled={freq === "weekly" && days.length === 0}
          style={{
            ...miniBtn,
            background: "var(--point)",
            opacity: freq === "weekly" && days.length === 0 ? 0.45 : 1,
            cursor: freq === "weekly" && days.length === 0 ? "not-allowed" : "pointer",
          }}
        >{L("todo.save")}</button>
        {onClearSchedule && hasSchedule && (
          <button type="button" onClick={onClearSchedule} title={L("todo.clearScheduleHint")} style={{ ...miniBtn, color: "var(--bad)" }}>
            {L("todo.clearSchedule")}
          </button>
        )}
        <button type="button" onClick={onClose} style={miniBtn}>{L("todo.close")}</button>
      </div>
    </div>
  );
}

const sortBtn = {
  all: "unset", cursor: "pointer", padding: "3px 9px", borderRadius: 99,
  border: "1.1px solid var(--ink)", background: "var(--paper)",
  fontFamily: "var(--hand)", fontSize: 12, color: "var(--ink)",
};
const iconBtn = {
  all: "unset", cursor: "pointer", fontSize: 13, color: "var(--ink-2)",
  padding: "0 4px", lineHeight: 1, flexShrink: 0,
};
const dueBadge = {
  all: "unset", cursor: "pointer", padding: "1px 7px", borderRadius: 99,
  border: "1px solid rgba(40,51,63,.25)", background: "rgba(255,255,255,.55)",
  fontFamily: "var(--mono)", fontSize: 10.5, color: "var(--ink-2)", flexShrink: 0,
};
const expanderRow = {
  display: "flex", alignItems: "center", gap: 6, marginTop: 5, flexWrap: "wrap",
  padding: "5px 8px", borderRadius: 8, background: "rgba(255,255,255,.55)",
  border: "1px dashed rgba(40,51,63,.25)",
};
const chipBtn = {
  all: "unset", cursor: "pointer", padding: "2px 9px", borderRadius: 99,
  border: "1.1px solid var(--ink)", background: "var(--paper)",
  fontFamily: "var(--hand)", fontSize: 12, color: "var(--ink)",
};
const miniBtn = {
  all: "unset", cursor: "pointer", padding: "1px 8px", borderRadius: 99,
  border: "1.1px solid var(--ink)", background: "var(--paper)",
  fontFamily: "var(--hand)", fontSize: 11, color: "var(--ink)",
};
const miniLabel = {
  fontFamily: "var(--hand)", fontSize: 11, fontWeight: 700, color: "var(--ink-2)",
};
const subIconBtn = {
  all: "unset", cursor: "pointer", flexShrink: 0,
  width: 16, height: 16, display: "grid", placeItems: "center",
  fontSize: 11, lineHeight: 1, color: "var(--ink-2)", borderRadius: 3,
  transition: "opacity 0.12s, background 0.12s",
};

window.TodoView = TodoView;
