/* Week, Channel Lanes, Timeline views.
   v2: stage chip + colored product tag on every card; renders sub-tasks
       as smaller items inside Week cards; conditional product chip.
*/

const { useState: useStateV, useMemo: useMemoV } = React;
const UV = window.mcalUtils;

// ───── WEEK VIEW ─────
function WeekView({ cursor, today, events, options, colorBy, openEvent, addEventAt, update, nestAsSubtask, liftSubtask, moveSubtask }) {
  const weekStart = UV.weekStartMonday(cursor);
  const days = useMemoV(() => {
    const out = [];
    for (let i = 0; i < 7; i++) out.push(UV.addDays(weekStart, i));
    return out;
  }, [weekStart]);

  /* Drag state — mirrors MonthView exactly so visual feedback matches:
     - draggingId: id of a top-level event being dragged (drives .dragging)
     - dragHoverDate: ISO date currently under cursor (drives .drop-target)
     A sub-pill drag doesn't set draggingId; cell highlight still triggers
     because onCellDragOver also accepts any text/plain payload. */
  const [draggingId, setDraggingId] = useStateV(null);
  const [dragHoverDate, setDragHoverDate] = useStateV(null);

  const onBarDragStart = (e, ev) => {
    e.dataTransfer.effectAllowed = 'move';
    e.dataTransfer.setData('text/plain', ev.id);
    setDraggingId(ev.id);
    window.mcalSetHoverDisabled && window.mcalSetHoverDisabled(true);
  };
  const onBarDragEnd = () => {
    setDraggingId(null);
    setDragHoverDate(null);
    window.mcalSetHoverDisabled && window.mcalSetHoverDisabled(false);
  };
  const onCellDragOver = (e, dateIso) => {
    if (!draggingId && !(e.dataTransfer.types && e.dataTransfer.types.indexOf('text/plain') >= 0)) return;
    e.preventDefault();
    e.dataTransfer.dropEffect = 'move';
    setDragHoverDate(dateIso);
  };

  /* Drag-drop helpers — mirror the month view exactly:
     - Drop a top-level event on a cell → date shift.
     - Drop a top-level event on a bar → nest as sub-task on the column.
     - Drop a sub-pill on a cell → lift to standalone event on that date.
     - Drop a sub-pill on its own parent's bar → reschedule the sub-task. */
  const clearDragState = () => { setDraggingId(null); setDragHoverDate(null); };
  const handleCellDrop = (e, dateIso) => {
    e.preventDefault();
    const raw = e.dataTransfer.getData('text/plain') || (draggingId || '');
    if (!raw) { clearDragState(); return; }
    if (raw.startsWith('subtask:') && liftSubtask) {
      const [, parentId, subId] = raw.split(':');
      liftSubtask(parentId, subId, dateIso);
      clearDragState();
      return;
    }
    const ev = events.find(x => x.id === raw);
    if (!ev) { clearDragState(); return; }
    const delta = UV.diffDays(ev.start, dateIso);
    const shiftedSubs = (ev.subtasks || []).map(st => ({ ...st, date: UV.addDays(st.date, delta) }));
    update && update(ev.id, {
      start: UV.addDays(ev.start, delta),
      end: UV.addDays(ev.end, delta),
      subtasks: shiftedSubs,
    });
    clearDragState();
  };
  const handleBarDrop = (e, targetEv, dropDate) => {
    e.preventDefault();
    e.stopPropagation();
    const raw = e.dataTransfer.getData('text/plain') || (draggingId || '');
    if (!raw) { clearDragState(); return; }
    if (raw.startsWith('subtask:')) {
      const [, parentId, subId] = raw.split(':');
      if (parentId === targetEv.id) {
        const newSubs = (targetEv.subtasks || []).map(s =>
          s.id === subId ? { ...s, date: dropDate } : s);
        update && update(targetEv.id, { subtasks: newSubs });
      } else if (moveSubtask) {
        moveSubtask(parentId, subId, targetEv.id, dropDate);
      }
      clearDragState();
      return;
    }
    if (raw === targetEv.id) { clearDragState(); return; }
    if (nestAsSubtask) nestAsSubtask(raw, targetEv.id, dropDate);
    clearDragState();
  };

  const weekEnd = days[6];

  /* Lane-placement + per-parent sub-task band, replicating the month
     view exactly. Each parent gets a thin span-bar; sub-tasks render as
     small subpills BELOW the bar in their scheduled column. */
  const layout = useMemoV(() => {
    const overlapping = events
      .filter(ev => ev.start <= weekEnd && ev.end >= days[0])
      .sort((a, b) => {
        // Match MonthView sort: start ASC, then length DESC, then title.
        // This puts the earliest-starting event in lane 0 so it visually
        // sits at the top of its day column with no blank space above.
        if (a.start !== b.start) return a.start < b.start ? -1 : 1;
        const lenA = UV.diffDays(a.start, a.end) || 0;
        const lenB = UV.diffDays(b.start, b.end) || 0;
        if (lenB !== lenA) return lenB - lenA;
        return (a.title || '').localeCompare(b.title || '');
      });

    const lanes = [];
    const placed = overlapping.map(ev => {
      const startCol = Math.max(0, UV.diffDays(days[0], ev.start));
      const endCol = Math.min(6, UV.diffDays(days[0], ev.end));
      let laneIdx = lanes.findIndex(e => e < startCol);
      if (laneIdx === -1) { laneIdx = lanes.length; lanes.push(endCol); }
      else lanes[laneIdx] = endCol;
      return { ev, startCol, endCol, lane: laneIdx };
    });

    // For each parent: sub-tasks visible this week + per-col stack depth.
    const subBand = new Map();
    placed.forEach(p => {
      const list = (p.ev.subtasks || []).filter(st => st.date >= days[0] && st.date <= weekEnd);
      if (!list.length) return;
      const perCol = {};
      list.forEach(st => {
        const c = UV.diffDays(days[0], st.date);
        if (c < 0 || c > 6) return;
        if (!perCol[c]) perCol[c] = [];
        perCol[c].push(st);
      });
      const maxStack = Object.values(perCol).reduce((m, arr) => Math.max(m, arr.length), 0);
      subBand.set(p.ev.id, { list, perCol, maxStack });
    });

    return { placed, subBand };
  }, [events, days, weekEnd]);

  const { placed, subBand } = layout;
  const parentsInWeek = new Set(placed.map(p => p.ev.id));

  /* Lane-level vertical layout — every event at lane K shares the same
     y. Avoids the column-aware-top quirk where two same-lane events
     could land at different y values when one happened to be in a
     column with a sub-pill from a lower lane and the other wasn't. */
  const BAR_H = 30;
  const SUB_H = 22;
  const SUB_GAP = 3;
  const LANE_GAP = 8;
  const TOP_PAD = 10;
  const maxStackByLane = new Map();
  let maxLane = -1;
  placed.forEach(p => {
    if (p.lane > maxLane) maxLane = p.lane;
    const band = subBand.get(p.ev.id);
    const stack = band ? band.maxStack : 0;
    const cur = maxStackByLane.get(p.lane) || 0;
    if (stack > cur) maxStackByLane.set(p.lane, stack);
  });
  const laneTops = [TOP_PAD];
  for (let k = 0; k <= maxLane; k++) {
    const stack = maxStackByLane.get(k) || 0;
    laneTops[k + 1] = laneTops[k] + BAR_H + stack * (SUB_H + SUB_GAP) + LANE_GAP;
  }
  const tops = new Map();
  placed.forEach(p => tops.set(p.ev.id, laneTops[p.lane]));
  // Total span-band height = the deepest bottom of any (bar + its full
  // sub-stack). Drives the wbody's margin-top so orphan-parent sub-tasks
  // render below the lanes section.
  let contentBottom = TOP_PAD;
  placed.forEach(p => {
    const top = tops.get(p.ev.id);
    let bot = top + BAR_H;
    const band = subBand.get(p.ev.id);
    if (band && band.maxStack > 0) bot += band.maxStack * (SUB_H + SUB_GAP);
    if (bot > contentBottom) contentBottom = bot;
  });
  const totalLanesH = placed.length > 0 ? contentBottom + LANE_GAP : 0;

  return (
    <div className="week-v2" style={{ '--week-lane-offset': totalLanesH + 'px' }}>
      <div className="week-headers">
        {days.map(d => {
          const isToday = d === today;
          const cls = ['whead'];
          if (isToday) cls.push('today');
          if (UV.isWeekend(d)) cls.push('weekend');
          return (
            <div key={d} className={cls.join(' ')}>
              <div>
                <div className="wd">{UV.dowShort(d)}{isToday ? ' · Today' : ''}</div>
                <div className="wn">{UV.dayOfMonth(d)}</div>
              </div>
              <button className="iconbtn sm" onClick={() => addEventAt({ title: '', start: d, end: d })} title="Add">+</button>
            </div>
          );
        })}
      </div>
      <div className="week-body">
        {/* Day-column backgrounds (for today / weekend tinting) + per-day
            sub-tasks whose parent isn't already shown as a spanning card. */}
        {days.map(d => {
          const isToday = d === today;
          const cls = ['wcol'];
          if (isToday) cls.push('today');
          if (UV.isWeekend(d)) cls.push('weekend');
          if (dragHoverDate === d) cls.push('drop-target');
          const daySubs = [];
          events.forEach(ev => {
            if (parentsInWeek.has(ev.id)) return;
            (ev.subtasks || []).forEach(st => {
              if (st.date === d) daySubs.push({ st, ev });
            });
          });
          return (
            <div
              key={d}
              className={cls.join(' ')}
              onDragOver={e => onCellDragOver(e, d)}
              onDrop={e => handleCellDrop(e, d)}
            >
              <div className="wbody">
                {daySubs.map(({ st, ev }) => (
                  <WeekSubCard key={st.id} st={st} ev={ev} options={options} onOpen={() => openEvent(ev.id)} />
                ))}
              </div>
            </div>
          );
        })}
        {/* Spanning event bars — absolutely positioned across day columns. */}
        {placed.map(p => {
          const draggedEv = draggingId ? events.find(x => x.id === draggingId) : null;
          const dragHasSubtasks = !!(draggedEv && draggedEv.subtasks && draggedEv.subtasks.length > 0);
          return (
            <WeekSpanBar
              key={p.ev.id}
              ev={p.ev}
              startCol={p.startCol}
              endCol={p.endCol}
              top={tops.get(p.ev.id)}
              onBarDrop={handleBarDrop}
              onBarDragStart={onBarDragStart}
              onBarDragEnd={onBarDragEnd}
              isDragging={draggingId === p.ev.id}
              draggingId={draggingId}
              dragHasSubtasks={dragHasSubtasks}
              days={days}
              barH={BAR_H}
              options={options}
              colorBy={colorBy}
              today={today}
              onOpen={() => openEvent(p.ev.id)}
            />
          );
        })}

        {/* Sub-pills below each parent — month-view pattern. Stack
            vertically within a column when a day has multiple sub-tasks. */}
        {placed.flatMap(p => {
          const band = subBand.get(p.ev.id);
          if (!band) return [];
          const colW = 100 / 7;
          const baseTop = tops.get(p.ev.id) + BAR_H + SUB_GAP;
          const out = [];
          Object.entries(band.perCol).forEach(([colStr, list]) => {
            const col = +colStr;
            if (col < p.startCol || col > p.endCol) return;
            list.forEach((st, idx) => {
              out.push(
                <WeekSubPill
                  key={p.ev.id + '_' + st.id}
                  st={st}
                  ev={p.ev}
                  options={options}
                  style={{
                    left: `calc(${col * colW}% + 4px)`,
                    width: `calc(${colW}% - 8px)`,
                    top: baseTop + idx * (SUB_H + SUB_GAP),
                    height: SUB_H,
                  }}
                  onClick={() => openEvent(p.ev.id, st.id)}
                />
              );
            });
          });
          return out;
        })}
      </div>
    </div>
  );
}

/* Thin spanning bar — title + stage glyph + product tag. Sub-tasks live
   OUTSIDE the bar, as separate pills below in their scheduled column. */
function WeekSpanBar({ ev, startCol, endCol, top, barH, options, colorBy, today, onOpen, onBarDrop, onBarDragStart, onBarDragEnd, isDragging, draggingId, dragHasSubtasks, days }) {
  const isPastDue = isPastDueEvent(ev, today, options);
  const cls = ['week-span-bar'];
  if (ev.tbd && ev.stage !== 'done') cls.push('tbd');
  if (ev.needsReview && ev.stage !== 'done') cls.push('needs-review');
  if (isPastDue) cls.push('past-due');
  if (ev.start !== ev.end) cls.push('is-range');
  if (isDragging) cls.push('dragging');
  const color = colorForEvent(ev, options, colorBy);
  const colW = 100 / 7;
  const style = {
    left: `calc(${startCol * colW}% + 4px)`,
    width: `calc(${(endCol - startCol + 1) * colW}% - 8px)`,
    top,
    height: barH,
    borderLeftColor: color,
  };
  const isShort = endCol === startCol;
  return (
    <window.mcalHoverCard.HoverWrap event={ev} options={options} onEdit={onOpen}>
      {tp => (
        <div
          className={cls.join(' ')}
          style={style}
          onClick={onOpen}
          draggable={!ev.isHoliday && !ev.isConference}
          onDragStart={e => onBarDragStart && onBarDragStart(e, ev)}
          onDragEnd={() => onBarDragEnd && onBarDragEnd()}
          onDragOver={e => {
            if (draggingId === ev.id) return; // don't nest onto self
            if (dragHasSubtasks) return; // parented tasks can only re-date
            if (!(e.dataTransfer.types && e.dataTransfer.types.indexOf('text/plain') >= 0)) return;
            e.preventDefault();
            e.stopPropagation();
            e.dataTransfer.dropEffect = 'move';
            if (!e.currentTarget.classList.contains('drop-nest-target')) {
              e.currentTarget.classList.add('drop-nest-target');
            }
          }}
          onDragLeave={e => { e.currentTarget.classList.remove('drop-nest-target'); }}
          onDrop={e => {
            e.currentTarget.classList.remove('drop-nest-target');
            const rect = e.currentTarget.getBoundingClientRect();
            const fraction = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
            const numCols = endCol - startCol + 1;
            const colOffset = Math.min(numCols - 1, Math.floor(fraction * numCols));
            const dropDate = UV.addDays(days[startCol], colOffset);
            onBarDrop && onBarDrop(e, ev, dropDate);
          }}
          {...tp}
        >
          <window.mcalStageGlyphPill stageId={ev.stage} options={options} size={12} />
          <span className="wsb-title">{ev.title ? (window.mcalRichText ? window.mcalRichText(ev.title, options) : ev.title) : 'Untitled'}</span>
          {!isShort && <window.mcalProductTag event={ev} options={options} />}
          {(ev.subtasks || []).length > 0 && (
            <span style={{
              fontFamily: 'var(--font-mono)', fontSize: 9, color: 'var(--fg-3)',
              letterSpacing: '0.04em', padding: '0 4px', flexShrink: 0,
            }} title={`${ev.subtasks.length} sub-tasks`}>
              +{ev.subtasks.length}
            </span>
          )}
        </div>
      )}
    </window.mcalHoverCard.HoverWrap>
  );
}

function WeekSubPill({ st, ev, options, style, onClick }) {
  const effCat = (st.category !== undefined && st.category !== null && st.category !== '') ? st.category : ev.category;
  const catColor = UV.colorOf(options, 'categories', effCat);
  const stStage = UV.stageById(options, st.stage || 'ideation');
  return (
    <window.mcalHoverCard.HoverWrap event={ev} subtask={st} options={options} onEdit={onClick}>
      {tp => (
        <div
          className={'subpill abs'
            + (st.stage === 'done' ? ' done' : '')
            + (st.needsReview && st.stage !== 'done' ? ' needs-review' : '')}
          style={{ position: 'absolute', borderLeftColor: catColor, ...(style || {}) }}
          draggable
          onDragStart={e => {
            e.stopPropagation();
            e.dataTransfer.effectAllowed = 'move';
            e.dataTransfer.setData('text/plain', 'subtask:' + ev.id + ':' + st.id);
            window.mcalSetHoverDisabled && window.mcalSetHoverDisabled(true);
          }}
          onDragEnd={() => { window.mcalSetHoverDisabled && window.mcalSetHoverDisabled(false); }}
          onClick={e => { e.stopPropagation(); onClick && onClick(); }}
          {...tp}
        >
          <span style={{ color: stStage.color || 'var(--fg-3)', flexShrink: 0, display: 'inline-flex' }}>
            {window.mcalStageGlyph ? <window.mcalStageGlyph pct={stStage.fillPct != null ? stStage.fillPct : ((stStage.order + 1) / 4) * 100} size={9} /> : stStage.glyph}
          </span>
          <span className="stitle">{window.mcalRichText ? window.mcalRichText(st.title, options) : st.title}</span>
        </div>
      )}
    </window.mcalHoverCard.HoverWrap>
  );
}

function WeekCard({ ev, options, colorBy, today, onOpen }) {
  const isPastDue = isPastDueEvent(ev, today, options);
  const cls = ['wevent'];
  if (ev.tbd && ev.stage !== 'done') cls.push('tbd');
  if (ev.needsReview && ev.stage !== 'done') cls.push('needs-review');
  if (isPastDue) cls.push('past-due');
  const color = colorForEvent(ev, options, colorBy);
  return (
    <window.mcalHoverCard.HoverWrap event={ev} options={options} onEdit={onOpen}>
      {tp => (
        <div className={cls.join(' ')} style={{ borderLeftColor: color }} onClick={onOpen} {...tp}>
          <div className="wev-head">
            <window.mcalStageChip stageId={ev.stage} options={options} bigger full />
          </div>
          {ev.product && (
            <div style={{ marginTop: 2 }}>
              <window.mcalProductTag event={ev} options={options} bigger />
            </div>
          )}
          <div className="title">{ev.title ? (window.mcalRichText ? window.mcalRichText(ev.title, options) : ev.title) : 'Untitled'}</div>
          <div className="meta">
            <span>{UV.nameOf(options, 'channels', ev.channel)}</span>
            <span>·</span>
            <span>{ownerNamesForV(ev, options)}</span>
            <span>·</span>
            <span>{UV.nameOf(options, 'formats', ev.format)}</span>
            {ev.start !== ev.end && (
              <>
                <span>·</span>
                <span>
                  {UV.shortDate(ev.start)}
                  <span className="dr-arrow">→</span>
                  {UV.shortDate(ev.end)}
                </span>
              </>
            )}
          </div>
          {(ev.subtasks || []).length > 0 && (
            <div className="wev-subs">
              <div className="wev-subs-h">{(ev.subtasks || []).length} sub-task{ev.subtasks.length > 1 ? 's' : ''}</div>
              {ev.subtasks
                .slice()
                .sort((a, b) => a.date < b.date ? -1 : a.date > b.date ? 1 : 0)
                .map(st => {
                  const stStage = UV.stageById(options, st.stage || 'ideation');
                  return (
                    <div key={st.id} className="wev-sub">
                      <span className="wev-sub-date">{UV.shortDate(st.date)}</span>
                      <span className="wev-sub-glyph" style={{ color: stStage.color || 'var(--fg-3)' }}>{stStage.glyph}</span>
                      <span className="wev-sub-title">{window.mcalRichText ? window.mcalRichText(st.title, options) : st.title}</span>
                    </div>
                  );
                })}
            </div>
          )}
        </div>
      )}
    </window.mcalHoverCard.HoverWrap>
  );
}

function WeekSubCard({ st, ev, options, onOpen }) {
  const cat = UV.colorOf(options, 'categories', ev.category);
  const stage = UV.stageById(options, st.stage || 'ideation');
  return (
    <window.mcalHoverCard.HoverWrap event={ev} subtask={st} options={options} onEdit={onOpen}>
      {tp => (
        <div
          className="wevent"
          style={{
            borderLeftColor: cat,
            borderLeftWidth: 2,
            padding: '6px 8px 7px 22px',
            background: 'transparent',
            position: 'relative',
          }}
          onClick={onOpen}
          {...tp}
        >
          <span style={{
            position: 'absolute', left: 8, top: 7,
            fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--fg-4)',
          }}>↳</span>
          <div className="wev-head">
            <window.mcalStageChip stageId={st.stage || 'ideation'} options={options} full />
            <span style={{ fontFamily: 'var(--font-mono)', fontSize: 9, color: 'var(--fg-3)', letterSpacing: '0.04em', textTransform: 'uppercase' }}>
              {UV.nameOf(options, 'channels', st.channel || ev.channel)}
            </span>
          </div>
          <div className="title" style={{ fontSize: 12, fontWeight: 500 }}>{window.mcalRichText ? window.mcalRichText(st.title, options) : st.title}</div>
          <div className="meta">
            <span style={{ color: 'var(--fg-3)' }}>↳ {window.mcalRichText ? window.mcalRichText(ev.title, options) : ev.title}</span>
          </div>
        </div>
      )}
    </window.mcalHoverCard.HoverWrap>
  );
}

// ───── LANES VIEW ─────
function LanesView({ cursor, today, events, options, colorBy, laneGroup, openEvent, addEventAt, update, nestAsSubtask, liftSubtask, moveSubtask }) {
  const DAYS = 7;
  const start = UV.weekStartMonday(cursor);
  const days = useMemoV(() => {
    const out = [];
    for (let i = 0; i < DAYS; i++) out.push(UV.addDays(start, i));
    return out;
  }, [start]);
  const end = days[days.length - 1];

  /* Drag state mirrors MonthView. Shared across all lanes so the cell
     highlight + .dragging visuals are consistent regardless of which row
     the event lives in. */
  const [draggingId, setDraggingId] = useStateV(null);
  const [dragHoverDate, setDragHoverDate] = useStateV(null);
  const onBarDragStart = (e, ev) => {
    e.dataTransfer.effectAllowed = 'move';
    e.dataTransfer.setData('text/plain', ev.id);
    setDraggingId(ev.id);
    window.mcalSetHoverDisabled && window.mcalSetHoverDisabled(true);
  };
  const onBarDragEnd = () => {
    setDraggingId(null);
    setDragHoverDate(null);
    window.mcalSetHoverDisabled && window.mcalSetHoverDisabled(false);
  };
  const onCellDragOver = (e, dateIso) => {
    if (!draggingId && !(e.dataTransfer.types && e.dataTransfer.types.indexOf('text/plain') >= 0)) return;
    e.preventDefault();
    e.dataTransfer.dropEffect = 'move';
    setDragHoverDate(dateIso);
  };
  const clearDragState = () => { setDraggingId(null); setDragHoverDate(null); };

  const handleCellDrop = (e, dateIso) => {
    e.preventDefault();
    const raw = e.dataTransfer.getData('text/plain') || (draggingId || '');
    if (!raw) { clearDragState(); return; }
    if (raw.startsWith('subtask:') && liftSubtask) {
      const [, parentId, subId] = raw.split(':');
      liftSubtask(parentId, subId, dateIso);
      clearDragState();
      return;
    }
    const ev = events.find(x => x.id === raw);
    if (!ev) { clearDragState(); return; }
    const delta = UV.diffDays(ev.start, dateIso);
    const shiftedSubs = (ev.subtasks || []).map(st => ({ ...st, date: UV.addDays(st.date, delta) }));
    update && update(ev.id, {
      start: UV.addDays(ev.start, delta),
      end: UV.addDays(ev.end, delta),
      subtasks: shiftedSubs,
    });
    clearDragState();
  };
  const handleBarDrop = (e, targetEv, dropDate) => {
    e.preventDefault();
    e.stopPropagation();
    const raw = e.dataTransfer.getData('text/plain') || (draggingId || '');
    if (!raw) { clearDragState(); return; }
    if (raw.startsWith('subtask:')) {
      const [, parentId, subId] = raw.split(':');
      if (parentId === targetEv.id) {
        const newSubs = (targetEv.subtasks || []).map(s =>
          s.id === subId ? { ...s, date: dropDate } : s);
        update && update(targetEv.id, { subtasks: newSubs });
      } else if (moveSubtask) {
        moveSubtask(parentId, subId, targetEv.id, dropDate);
      }
      clearDragState();
      return;
    }
    if (raw === targetEv.id) { clearDragState(); return; }
    if (nestAsSubtask) nestAsSubtask(raw, targetEv.id, dropDate);
    clearDragState();
  };

  const groupKey = singularForV(laneGroup);
  const lanesList = options[laneGroup] || [];

  return (
    <div className="lanes" style={{ gridTemplateColumns: '160px 1fr' }}>
      <div className="lanes-axis">
        <div className="corner">{laneGroup.slice(0, -1).toUpperCase()} ↓ &nbsp;/ DATE →</div>
        <div className="dayrow" style={{ gridTemplateColumns: `repeat(${DAYS}, 1fr)` }}>
          {days.map((d, i) => {
            const cls = ['d'];
            if (UV.isWeekend(d)) cls.push('weekend');
            if (d === today) cls.push('today');
            if (i > 0 && UV.dayOfMonth(d) === 1) cls.push('month-flip');
            return (
              <div key={d} className={cls.join(' ')}>
                <span className="ddow">{UV.dowShort(d)}</span>
                {UV.dayOfMonth(d) === 1 ? <strong>{UV.monthShort(d)} 1</strong> : UV.dayOfMonth(d)}
              </div>
            );
          })}
        </div>
      </div>

      {lanesList.map(grp => {
        const laneEventsRaw = events
          .filter(ev => ev[groupKey] === grp.id && UV.eventOverlaps(ev, days[0], end))
          .sort((a, b) => {
            // Match MonthView sort: start ASC, then length DESC, then title.
            if (a.start !== b.start) return a.start < b.start ? -1 : 1;
            const la = UV.diffDays(a.start, a.end) || 0;
            const lb = UV.diffDays(b.start, b.end) || 0;
            if (lb !== la) return lb - la;
            return (a.title || '').localeCompare(b.title || '');
          });
        const count = laneEventsRaw.length;

        // Lane assignment — events with overlapping date ranges get stacked
        // into separate vertical lanes. Same algorithm as MonthView/WeekView.
        const laneCols = []; // index = lane idx, value = last endCol placed
        const placed = laneEventsRaw.map(ev => {
          const startCol = Math.max(0, UV.diffDays(days[0], ev.start));
          const endCol = Math.min(DAYS - 1, UV.diffDays(days[0], ev.end));
          if (endCol < 0 || startCol >= DAYS) return null;
          let laneIdx = laneCols.findIndex(e => e < startCol);
          if (laneIdx === -1) { laneIdx = laneCols.length; laneCols.push(endCol); }
          else laneCols[laneIdx] = endCol;
          return { ev, startCol, endCol, lane: laneIdx };
        }).filter(Boolean);
        const laneEvents = laneEventsRaw; // keep for the no-posts count

        // Sub-band per event: which columns have how many subtasks. Used to
        // reserve vertical space + position sub-pills under each parent.
        const BAR_H = 44;
        const SUB_H = 22;
        const SUB_GAP = 2;
        const LANE_GAP = 8;
        const TOP_PAD = 10;
        const subBand = new Map();
        laneEventsRaw.forEach(ev => {
          const list = (ev.subtasks || []).filter(st => st.date >= days[0] && st.date <= end);
          if (!list.length) return;
          const perCol = {};
          list.forEach(st => {
            const c = UV.diffDays(days[0], st.date);
            if (c < 0 || c >= DAYS) return;
            if (!perCol[c]) perCol[c] = [];
            perCol[c].push(st);
          });
          const maxStack = Object.values(perCol).reduce((m, arr) => Math.max(m, arr.length), 0);
          subBand.set(ev.id, { perCol, maxStack });
        });

        // Lane-level top calc — every event in lane K shares the same y.
        // Mirror of the lane-level model now used by MonthView + WeekView
        // (see month-view comment for the trade-off vs column-aware).
        const maxStackByLane = new Map();
        let maxLane = -1;
        placed.forEach(p => {
          if (p.lane > maxLane) maxLane = p.lane;
          const band = subBand.get(p.ev.id);
          const stack = band ? band.maxStack : 0;
          const cur = maxStackByLane.get(p.lane) || 0;
          if (stack > cur) maxStackByLane.set(p.lane, stack);
        });
        const laneTops = [TOP_PAD];
        for (let k = 0; k <= maxLane; k++) {
          const stack = maxStackByLane.get(k) || 0;
          laneTops[k + 1] = laneTops[k] + BAR_H + stack * (SUB_H + SUB_GAP) + LANE_GAP;
        }
        const tops = new Map();
        placed.forEach(p => tops.set(p.ev.id, laneTops[p.lane]));
        // Canvas height = deepest bottom of any bar + its full sub-stack.
        let contentBottom = TOP_PAD;
        placed.forEach(p => {
          const top = tops.get(p.ev.id);
          let bot = top + BAR_H;
          const band = subBand.get(p.ev.id);
          if (band && band.maxStack > 0) bot += band.maxStack * (SUB_H + SUB_GAP);
          if (bot > contentBottom) contentBottom = bot;
        });
        // Floor at the CSS baseline (152px); grow if multiple lanes need
        // more vertical room.
        const canvasMinH = Math.max(152, contentBottom + 8);
        return (
          <div key={grp.id} className="lane">
            <div className="lane-label">
              <div className="ln" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
                {grp.color && <span style={{ width: 8, height: 8, background: grp.color, border: '1px solid var(--border-2)' }}></span>}
                {grp.name}
              </div>
              <div className={'lm' + (count === 0 ? ' gap' : '')}>{count === 0 ? 'no posts' : count + ' scheduled'}</div>
            </div>
            <div className="lane-canvas" style={{ gridTemplateColumns: `repeat(${DAYS}, 1fr)`, minHeight: canvasMinH }}>
              {days.map(d => {
                const cls = ['lcol'];
                if (UV.isWeekend(d)) cls.push('weekend');
                if (d === today) cls.push('today');
                if (dragHoverDate === d) cls.push('drop-target');
                return (
                  <div
                    key={d}
                    className={cls.join(' ')}
                    onDragOver={e => onCellDragOver(e, d)}
                    onDrop={e => handleCellDrop(e, d)}
                  >
                    {addEventAt && (
                      <button
                        className="quick-add lane-add"
                        onClick={e => {
                          e.stopPropagation();
                          addEventAt({ start: d, end: d, [groupKey]: grp.id });
                        }}
                        title="Add event"
                      >+</button>
                    )}
                  </div>
                );
              })}
              {placed.map(p => {
                const ev = p.ev;
                const { startCol, endCol } = p;
                const colW = 100 / DAYS;
                const left = startCol * colW;
                const width = (endCol - startCol + 1) * colW;
                const color = colorForEvent(ev, options, colorBy);
                const cls = ['lane-bar'];
                if (ev.tbd && ev.stage !== 'done') cls.push('tbd');
                if (ev.needsReview && ev.stage !== 'done') cls.push('needs-review');
                if (isPastDueEvent(ev, today, options)) cls.push('past-due');
                if (draggingId === ev.id) cls.push('dragging');
                const isSingleCellShort = endCol === startCol;
                return (
                  <window.mcalHoverCard.HoverWrap
                    key={ev.id}
                    event={ev}
                    options={options}
                    onEdit={() => openEvent(ev.id)}
                  >
                    {tp => (
                      <div
                        className={cls.join(' ')}
                        style={{
                          left: `calc(${left}% + 4px)`,
                          width: `calc(${width}% - 8px)`,
                          top: tops.get(ev.id),
                          borderLeftColor: color,
                        }}
                        onClick={() => openEvent(ev.id)}
                        draggable={!ev.isHoliday && !ev.isConference}
                        onDragStart={e => onBarDragStart(e, ev)}
                        onDragEnd={() => onBarDragEnd()}
                        onDragOver={e => {
                          if (draggingId === ev.id) return; // don't nest onto self
                          // Parented tasks can only re-date.
                          if (draggingId) {
                            const dragged = events.find(x => x.id === draggingId);
                            if (dragged && dragged.subtasks && dragged.subtasks.length > 0) return;
                          }
                          if (!(e.dataTransfer.types && e.dataTransfer.types.indexOf('text/plain') >= 0)) return;
                          e.preventDefault();
                          e.stopPropagation();
                          e.dataTransfer.dropEffect = 'move';
                          if (!e.currentTarget.classList.contains('drop-nest-target')) {
                            e.currentTarget.classList.add('drop-nest-target');
                          }
                        }}
                        onDragLeave={e => { e.currentTarget.classList.remove('drop-nest-target'); }}
                        onDrop={e => {
                          e.currentTarget.classList.remove('drop-nest-target');
                          const rect = e.currentTarget.getBoundingClientRect();
                          const fraction = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
                          const numCols = endCol - startCol + 1;
                          const colOffset = Math.min(numCols - 1, Math.floor(fraction * numCols));
                          const dropDate = UV.addDays(ev.start, colOffset);
                          handleBarDrop(e, ev, dropDate);
                        }}
                        {...tp}
                      >
                        <window.mcalStageGlyphPill stageId={ev.stage} options={options} size={11} />
                        <span className="lb-title">{window.mcalRichText ? window.mcalRichText(ev.title, options) : ev.title}</span>
                        {!isSingleCellShort && <window.mcalProductTag event={ev} options={options} />}
                        {(ev.subtasks || []).length > 0 && (
                          <span style={{
                            fontFamily: 'var(--font-mono)', fontSize: 9, color: 'var(--fg-3)',
                            letterSpacing: '0.04em', padding: '0 4px', flexShrink: 0,
                          }} title={`${ev.subtasks.length} sub-tasks`}>
                            +{ev.subtasks.length}
                          </span>
                        )}
                      </div>
                    )}
                  </window.mcalHoverCard.HoverWrap>
                );
              })}
              {/* Sub-pills under each parent — month-view pattern.
                  Sub-pills anchor to the parent's column-aware top + BAR_H. */}
              {placed.flatMap(p => {
                const ev = p.ev;
                const band = subBand.get(ev.id);
                if (!band) return [];
                const colW = 100 / DAYS;
                const baseTop = tops.get(ev.id) + BAR_H + SUB_GAP;
                const out = [];
                Object.entries(band.perCol).forEach(([colStr, list]) => {
                  const col = +colStr;
                  if (col < p.startCol || col > p.endCol) return;
                  list.forEach((st, idx) => {
                    out.push(
                      <WeekSubPill
                        key={ev.id + '_' + st.id}
                        st={st}
                        ev={ev}
                        options={options}
                        style={{
                          left: `calc(${col * colW}% + 4px)`,
                          width: `calc(${colW}% - 8px)`,
                          top: baseTop + idx * (SUB_H + SUB_GAP),
                          height: SUB_H,
                        }}
                        onClick={() => openEvent(ev.id, st.id)}
                      />
                    );
                  });
                });
                return out;
              })}
            </div>
          </div>
        );
      })}
    </div>
  );
}

// ───── TIMELINE / GANTT ─────
function TimelineView({ cursor, today, events, options, colorBy, timelineGroup, openEvent, addEventAt, update, nestAsSubtask, liftSubtask, moveSubtask }) {
  /* Drag state — mirrors MonthView. Because the timeline track is one
     drop zone spanning 7 days (no per-day cells), we also track the day
     index under the cursor as {rowId, dayIdx} so we can render a column
     highlight overlay in the right place. */
  const [draggingId, setDraggingId] = useStateV(null);
  const [dragHover, setDragHover] = useStateV(null); // { rowId, dayIdx } | null

  const onBarDragStart = (e, ev) => {
    e.dataTransfer.effectAllowed = 'move';
    e.dataTransfer.setData('text/plain', ev.id);
    setDraggingId(ev.id);
    window.mcalSetHoverDisabled && window.mcalSetHoverDisabled(true);
  };
  const onBarDragEnd = () => {
    setDraggingId(null);
    setDragHover(null);
    window.mcalSetHoverDisabled && window.mcalSetHoverDisabled(false);
  };
  const clearDragState = () => { setDraggingId(null); setDragHover(null); };

  const handleTrackDrop = (e, dateIso) => {
    e.preventDefault();
    const raw = e.dataTransfer.getData('text/plain') || (draggingId || '');
    if (!raw) { clearDragState(); return; }
    if (raw.startsWith('subtask:') && liftSubtask) {
      const [, parentId, subId] = raw.split(':');
      liftSubtask(parentId, subId, dateIso);
      clearDragState();
      return;
    }
    const ev = events.find(x => x.id === raw);
    if (!ev) { clearDragState(); return; }
    const delta = UV.diffDays(ev.start, dateIso);
    const shiftedSubs = (ev.subtasks || []).map(st => ({ ...st, date: UV.addDays(st.date, delta) }));
    update && update(ev.id, {
      start: UV.addDays(ev.start, delta),
      end: UV.addDays(ev.end, delta),
      subtasks: shiftedSubs,
    });
    clearDragState();
  };
  const handleBarDrop = (e, targetEv, dropDate) => {
    e.preventDefault();
    e.stopPropagation();
    const raw = e.dataTransfer.getData('text/plain') || (draggingId || '');
    if (!raw) { clearDragState(); return; }
    if (raw.startsWith('subtask:')) {
      const [, parentId, subId] = raw.split(':');
      if (parentId === targetEv.id) {
        const newSubs = (targetEv.subtasks || []).map(s =>
          s.id === subId ? { ...s, date: dropDate } : s);
        update && update(targetEv.id, { subtasks: newSubs });
      } else if (moveSubtask) {
        moveSubtask(parentId, subId, targetEv.id, dropDate);
      }
      clearDragState();
      return;
    }
    if (raw === targetEv.id) { clearDragState(); return; }
    if (nestAsSubtask) nestAsSubtask(raw, targetEv.id, dropDate);
    clearDragState();
  };
  // Rolling 1-week window from Monday of cursor week
  const totalDays = 7;
  const start = UV.weekStartMonday(cursor);
  const end = UV.addDays(start, totalDays - 1);
  const todayIdx = UV.diffDays(start, today);

  const groupKey = singularForV(timelineGroup);
  const groupsList = options[timelineGroup] || [];

  // Range label: e.g. "MAY 18 — MAY 31"
  // Detect if range spans a month boundary
  const days = [];
  for (let i = 0; i < totalDays; i++) days.push(UV.addDays(start, i));
  const firstMonth = UV.parseISO(start).getUTCMonth();
  const flipIdx = days.findIndex(d => UV.parseISO(d).getUTCMonth() !== firstMonth);

  return (
    <div className="timeline">
      <div className="tl-axis-corner">{timelineGroup.toUpperCase()} ↓ &nbsp;/ DATE →</div>
      <div className="tl-axis">
        <div className="months" style={{
          gridTemplateColumns: flipIdx > 0
            ? `${flipIdx / totalDays * 100}% ${(totalDays - flipIdx) / totalDays * 100}%`
            : '100%'
        }}>
          <div className="am">{UV.monthShort(start)} {UV.parseISO(start).getUTCFullYear()}</div>
          {flipIdx > 0 && <div className="am">{UV.monthShort(days[flipIdx])} {UV.parseISO(days[flipIdx]).getUTCFullYear()}</div>}
        </div>
        <div className="days" style={{ gridTemplateColumns: `repeat(${totalDays}, 1fr)` }}>
          {days.map((dateIso, i) => {
            const isWeekStart = i % 7 === 0;
            const isToday = dateIso === today;
            const cls = ['ad'];
            if (isWeekStart) cls.push('tick');
            if (isToday) cls.push('today');
            const dayN = UV.dayOfMonth(dateIso);
            return <div key={i} className={cls.join(' ')}>{dayN}</div>;
          })}
        </div>
      </div>

      {groupsList.map((grp, gi) => {
        const grpEventsRaw = events
          .filter(ev => ev[groupKey] === grp.id && UV.eventOverlaps(ev, start, end))
          .sort((a, b) => {
            // Match MonthView sort: start ASC, then length DESC, then title.
            if (a.start !== b.start) return a.start < b.start ? -1 : 1;
            const la = UV.diffDays(a.start, a.end) || 0;
            const lb = UV.diffDays(b.start, b.end) || 0;
            if (lb !== la) return lb - la;
            return (a.title || '').localeCompare(b.title || '');
          });

        // Lane assignment per row — overlapping events get separate lanes.
        const laneCols = [];
        const placed = grpEventsRaw.map(ev => {
          const sIdx = UV.diffDays(start, ev.start);
          const eIdx = UV.diffDays(start, ev.end);
          if (eIdx < 0 || sIdx >= totalDays) return null;
          const lo = Math.max(0, sIdx);
          const hi = Math.min(totalDays - 1, eIdx);
          let laneIdx = laneCols.findIndex(e => e < lo);
          if (laneIdx === -1) { laneIdx = laneCols.length; laneCols.push(hi); }
          else laneCols[laneIdx] = hi;
          return { ev, sIdx, eIdx, lo, hi, lane: laneIdx };
        }).filter(Boolean);
        const grpEvents = grpEventsRaw; // for the row-label event count

        // Sub-bands per event for the visible window — used to render
        // sub-pills under each parent bar at their scheduled day.
        const BAR_H = 46;
        const SUB_H = 18;
        const SUB_GAP = 2;
        const LANE_GAP = 8;
        const TOP_PAD = 8;
        const subBand = new Map();
        grpEventsRaw.forEach(ev => {
          const list = (ev.subtasks || []).filter(st => st.date >= start && st.date <= end);
          if (!list.length) return;
          const perCol = {};
          list.forEach(st => {
            const c = UV.diffDays(start, st.date);
            if (c < 0 || c >= totalDays) return;
            if (!perCol[c]) perCol[c] = [];
            perCol[c].push(st);
          });
          const maxStack = Object.values(perCol).reduce((m, arr) => Math.max(m, arr.length), 0);
          subBand.set(ev.id, { perCol, maxStack });
        });

        // Column-aware top calc — mirrors MonthView.topForPlaced.
        const overlapsCols = (a1, b1, a2, b2) => !(b1 < a2 || a1 > b2);
        const stackInRange = (band, a, b) => {
          if (!band) return 0;
          let m = 0;
          for (let c = a; c <= b; c++) {
            const arr = band.perCol[c];
            if (arr && arr.length > m) m = arr.length;
          }
          return m;
        };
        const tops = new Map();
        placed.forEach(p => {
          let top = TOP_PAD;
          for (let k = 0; k < p.lane; k++) {
            let laneOverlaps = false;
            let laneStack = 0;
            placed.forEach(q => {
              if (q.lane !== k) return;
              if (!overlapsCols(q.lo, q.hi, p.lo, p.hi)) return;
              laneOverlaps = true;
              const band = subBand.get(q.ev.id);
              const s = stackInRange(band,
                Math.max(q.lo, p.lo),
                Math.min(q.hi, p.hi));
              if (s > laneStack) laneStack = s;
            });
            if (laneOverlaps) {
              top += BAR_H;
              if (laneStack > 0) top += laneStack * (SUB_H + SUB_GAP);
              top += LANE_GAP;
            }
          }
          tops.set(p.ev.id, top);
        });
        // Track height = deepest bottom of any bar + its full sub-stack.
        let contentBottom = TOP_PAD;
        placed.forEach(p => {
          const top = tops.get(p.ev.id);
          let bot = top + BAR_H;
          const band = subBand.get(p.ev.id);
          if (band && band.maxStack > 0) bot += band.maxStack * (SUB_H + SUB_GAP);
          if (bot > contentBottom) contentBottom = bot;
        });
        // Floor every row at the CSS baseline (192px) — grow if multiple
        // lanes or tall sub-task stacks need more room.
        const trackMinH = Math.max(192, contentBottom + 6);
        return (
          <div key={grp.id} className="tl-row">
            <div className="row-label">
              <div className="rl-name">
                <span className="sw" style={{ background: grp.color || 'transparent' }}></span>
                {grp.name}
              </div>
              <div className="rl-count">{grpEvents.length} {grpEvents.length === 1 ? 'event' : 'events'}</div>
            </div>
            <div
              className={'track' + (gi % 2 === 0 ? '' : ' alt')}
              style={{ minHeight: trackMinH }}
              onDragOver={e => {
                if (!draggingId && !(e.dataTransfer.types && e.dataTransfer.types.indexOf('text/plain') >= 0)) return;
                e.preventDefault();
                e.dataTransfer.dropEffect = 'move';
                // Map cursor X → day index so we can highlight the column.
                const rect = e.currentTarget.getBoundingClientRect();
                const fraction = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
                const dayIdx = Math.min(totalDays - 1, Math.floor(fraction * totalDays));
                if (!dragHover || dragHover.rowId !== grp.id || dragHover.dayIdx !== dayIdx) {
                  setDragHover({ rowId: grp.id, dayIdx });
                }
              }}
              onDragLeave={e => {
                // Only clear if the cursor truly left the track (not just
                // moved between children). relatedTarget is the new hover.
                if (e.currentTarget.contains(e.relatedTarget)) return;
                if (dragHover && dragHover.rowId === grp.id) setDragHover(null);
              }}
              onDrop={e => {
                // Compute the day under the cursor from clientX vs track rect.
                const rect = e.currentTarget.getBoundingClientRect();
                const fraction = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
                const dayIdx = Math.min(totalDays - 1, Math.floor(fraction * totalDays));
                handleTrackDrop(e, days[dayIdx]);
              }}
            >
              {Array.from({ length: Math.floor(totalDays / 7) }).map((_, w) => (
                <div key={w} className="gridline" style={{ left: `${(w + 1) * 7 / totalDays * 100}%` }}></div>
              ))}
              {todayIdx >= 0 && todayIdx < totalDays && (
                <div className="today-line" style={{ left: `${todayIdx / totalDays * 100 + 0.5 / totalDays * 100}%` }}></div>
              )}
              {/* Per-day + buttons (hover-revealed) — pre-fill the row's
                  group so the drafted event lands in the right lane. */}
              {addEventAt && days.map((d, idx) => (
                <button
                  key={`add_${idx}`}
                  className="quick-add tl-add"
                  style={{
                    position: 'absolute',
                    left: `calc(${idx / totalDays * 100}% + 4px)`,
                    top: 4,
                  }}
                  onClick={e => {
                    e.stopPropagation();
                    addEventAt({ start: d, end: d, [groupKey]: grp.id });
                  }}
                  title="Add event"
                >+</button>
              ))}
              {/* Column highlight while a drag is hovering this track. */}
              {dragHover && dragHover.rowId === grp.id && (
                <div
                  className="track-drop-col"
                  style={{
                    left: `${dragHover.dayIdx / totalDays * 100}%`,
                    width: `${100 / totalDays}%`,
                  }}
                />
              )}
              {placed.map(p => {
                const ev = p.ev;
                const { sIdx, lo, hi } = p;
                const left = lo / totalDays * 100;
                const width = (hi - lo + 1) / totalDays * 100;
                const color = colorForEvent(ev, options, colorBy);
                const cls = ['tl-bar'];
                if (ev.tbd && ev.stage !== 'done') cls.push('tbd');
                if (ev.needsReview && ev.stage !== 'done') cls.push('needs-review');
                if (isPastDueEvent(ev, today, options)) cls.push('past-due');
                if (draggingId === ev.id) cls.push('dragging');
                const isShort = hi === lo;
                return (
                  <window.mcalHoverCard.HoverWrap
                    key={ev.id}
                    event={ev}
                    options={options}
                    onEdit={() => openEvent(ev.id)}
                  >
                    {tp => (
                      <div
                        className={cls.join(' ')}
                        style={{
                          left: `${left}%`,
                          width: `calc(${width}% - 2px)`,
                          top: tops.get(ev.id),
                          borderLeftColor: color,
                        }}
                        onClick={() => openEvent(ev.id)}
                        draggable={!ev.isHoliday && !ev.isConference}
                        onDragStart={e => onBarDragStart(e, ev)}
                        onDragEnd={() => onBarDragEnd()}
                        onDragOver={e => {
                          if (draggingId === ev.id) return; // don't nest onto self
                          // Parented tasks can only re-date.
                          if (draggingId) {
                            const dragged = events.find(x => x.id === draggingId);
                            if (dragged && dragged.subtasks && dragged.subtasks.length > 0) return;
                          }
                          if (!(e.dataTransfer.types && e.dataTransfer.types.indexOf('text/plain') >= 0)) return;
                          e.preventDefault();
                          e.stopPropagation();
                          e.dataTransfer.dropEffect = 'move';
                          if (!e.currentTarget.classList.contains('drop-nest-target')) {
                            e.currentTarget.classList.add('drop-nest-target');
                          }
                        }}
                        onDragLeave={e => { e.currentTarget.classList.remove('drop-nest-target'); }}
                        onDrop={e => {
                          e.currentTarget.classList.remove('drop-nest-target');
                          const rect = e.currentTarget.getBoundingClientRect();
                          const fraction = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
                          const span = (hi - lo + 1);
                          const colOffset = Math.min(span - 1, Math.floor(fraction * span));
                          const dropDate = UV.addDays(ev.start, Math.max(0, lo - sIdx) + colOffset);
                          handleBarDrop(e, ev, dropDate);
                        }}
                        {...tp}
                      >
                        <window.mcalStageGlyphPill stageId={ev.stage} options={options} size={11} />
                        <span className="tl-title">{window.mcalRichText ? window.mcalRichText(ev.title, options) : ev.title}</span>
                        {!isShort && <window.mcalProductTag event={ev} options={options} />}
                        {(ev.subtasks || []).length > 0 && (
                          <span style={{
                            fontFamily: 'var(--font-mono)', fontSize: 9, color: 'var(--fg-3)',
                            letterSpacing: '0.04em', padding: '0 4px', flexShrink: 0,
                          }} title={`${ev.subtasks.length} sub-tasks`}>
                            +{ev.subtasks.length}
                          </span>
                        )}
                      </div>
                    )}
                  </window.mcalHoverCard.HoverWrap>
                );
              })}
              {/* Sub-pills under each parent — month-view pattern.
                  Sub-pills anchor to the parent's column-aware top + BAR_H. */}
              {placed.flatMap(p => {
                const ev = p.ev;
                const band = subBand.get(ev.id);
                if (!band) return [];
                const colW = 100 / totalDays;
                const baseTop = tops.get(ev.id) + BAR_H + SUB_GAP;
                const out = [];
                Object.entries(band.perCol).forEach(([colStr, list]) => {
                  const col = +colStr;
                  if (col < p.lo || col > p.hi) return;
                  list.forEach((st, idx) => {
                    out.push(
                      <WeekSubPill
                        key={ev.id + '_' + st.id}
                        st={st}
                        ev={ev}
                        options={options}
                        style={{
                          left: `${col * colW}%`,
                          width: `calc(${colW}% - 2px)`,
                          top: baseTop + idx * (SUB_H + SUB_GAP),
                          height: SUB_H,
                          fontSize: 10,
                        }}
                        onClick={() => openEvent(ev.id, st.id)}
                      />
                    );
                  });
                });
                return out;
              })}
            </div>
          </div>
        );
      })}
    </div>
  );
}

function singularForV(plural) {
  return plural === 'channels' ? 'channel'
    : plural === 'categories' ? 'category'
    : plural === 'owners' ? 'owner'
    : plural === 'products' ? 'product'
    : plural === 'stages' ? 'stage'
    : 'category';
}
function colorForEvent(ev, options, colorBy) {
  if (colorBy === 'owners') {
    const ids = UV.ownerIdsOf(ev);
    return UV.colorOf(options, 'owners', ids[0] || '');
  }
  return UV.colorOf(options, colorBy, ev[singularForV(colorBy)]);
}
function ownerNamesForV(ev, options) {
  const ids = UV.ownerIdsOf(ev);
  if (ids.length === 0) return '—';
  if (ids.length === 1) return UV.nameOf(options, 'owners', ids[0]);
  return `${ids.length} owners`;
}
function isPastDueEvent(ev, today, options) {
  if (ev.tbd) return false;
  if (ev.end >= today) return false;
  const order = UV.stageOrder(options, ev.stage);
  return order < 2;
}

window.mcalCalViews = {
  WeekView: React.memo(WeekView),
  LanesView: React.memo(LanesView),
  TimelineView: React.memo(TimelineView),
};
