Source: 11a-timeblock-render.js

/* ── Timeblock — grid rendering ── */
// Split out of 11-timeblock.js (QA finding: module size). Contains the full
// renderTimeblock() loop — time labels, slot grid, auto blocks merged from log
// entries, manual planned blocks, untracked-time labels and the now-line —
// plus all grid drag-and-drop wiring and the module-global drag state it
// exclusively owns. positionNowLine() lives here because it is
// render-positioning logic called by renderTimeblock() and 09-clock-weather.js.
//
// Load order: this file must keep its 11 prefix so it concatenates before
// 12a-changelog.js, whose top-level bootstrap calls renderTimeblock() and
// therefore needs the top-level drag state declared here already evaluated.

let tbDragSource = null; // 'grid' | 'plan'
let tbDragId = null; // block id when dragging from grid

/**
 * Builds the action buttons shown on a planned block: start (task blocks
 * only), add/change emoji, and delete. Each button's accessible name includes
 * the block's text because a glyph or "▶ start" alone is announced identically
 * on every block, so screen-reader users cannot tell which one they are on
 * (WCAG 4.1.2 / 2.4.6).
 * @param {{ id: string, text: string, type: (string|undefined), emoji: (string|undefined) }} block - The planned block.
 * @returns {string} HTML for the buttons, ready to append inside the block element.
 */
function buildBlockActionButtonsHtml(block) {
  const subject = escHtml(block.text);
  const startButton =
    block.type !== 'meeting'
      ? `<button class="tb-block-start" data-bid="${block.id}" aria-label="Start tracking: ${subject}" draggable="false">▶ start</button>`
      : '';
  const emojiVerb = block.emoji ? 'Change' : 'Add';
  const emojiButton = `<button class="tb-block-emoji${block.emoji ? ' has-emoji' : ''}" data-bid="${block.id}" aria-label="${emojiVerb} emoji: ${subject}" aria-haspopup="dialog" aria-expanded="false" title="${emojiVerb.toLowerCase()} emoji" draggable="false">${block.emoji ? escHtml(block.emoji) : '✦'}</button>`;
  const deleteButton = `<button class="tb-block-del" data-bid="${block.id}" aria-label="Delete block: ${subject}" draggable="false">&times;</button>`;
  return startButton + emojiButton + deleteButton;
}

/**
 * Whether a planned meeting block is the one the running timer is tracking.
 * Meeting entries are logged with a leading "📅 ", so the entry's text is
 * compared both with and without that prefix.
 * @param {{ type: (string|undefined), text: string }} block - The planned block.
 * @param {{ text: string }|null|undefined} liveEntry - The log entry the timer runs on, if any.
 * @returns {boolean} True when `block` is a meeting that matches `liveEntry`.
 */
function isLiveMeetingBlock(block, liveEntry) {
  if (!liveEntry || block.type !== 'meeting') return false;
  const blockText = block.text.toLowerCase();
  const entryText = liveEntry.text.toLowerCase();
  return blockText === entryText || blockText === entryText.replace(/^📅\s*/, '');
}

/**
 * Accessible name for a planned block. A bare focusable `div` is dropped from
 * the accessibility tree unless it has a role, so the block is also given
 * `role="group"`; the name carries the time range and says how to move it,
 * which screen-reader users cannot otherwise discover.
 * @param {{ text: string, slot: number, duration: number }} block - The planned block.
 * @returns {string} Text such as "Write report — planned 11:00–12:00, press arrow keys to move".
 */
function blockAriaLabel(block) {
  const start = slotToTime(block.slot);
  const end = slotToTime(block.slot + block.duration);
  return `${block.text} — planned ${start}–${end}, press arrow keys to move`;
}

/**
 * Slot change a key press asks for.
 * @param {string} key - A `KeyboardEvent.key` value.
 * @returns {number} -1 for ArrowUp (earlier), 1 for ArrowDown (later), 0 for any other key.
 */
function blockMoveDelta(key) {
  if (key === 'ArrowUp') return -1;
  if (key === 'ArrowDown') return 1;
  return 0;
}

/**
 * Moves keyboard focus to a planned block's element, found by ID after the
 * grid has been redrawn. Compares IDs in code instead of building a selector,
 * because block IDs come from stored data.
 * @param {string} blockId - ID of the block to focus.
 * @returns {void}
 */
function focusPlannedBlock(blockId) {
  const blockEl = [...document.querySelectorAll('#tbGrid .tb-block.plan')].find(
    (candidate) => candidate.dataset.bid === blockId
  );
  if (blockEl) blockEl.focus();
}

/**
 * Keydown handler for a planned block: ArrowUp / ArrowDown move it one
 * half-hour slot through moveBlockToSlot(), the same path drag-and-drop uses,
 * and focus follows the block across the redraw. Keys pressed on the block's
 * own buttons and any Ctrl/Alt/Meta/Shift combination are left alone so those
 * controls and assistive-technology shortcuts keep working.
 * @param {KeyboardEvent} event - The keydown event, listened for on the block element.
 * @param {{ id: string }} block - The block the listener belongs to.
 * @param {string} dateKey - YYYY-MM-DD of the day in view.
 * @returns {void}
 */
function handleBlockKeydown(event, block, dateKey) {
  if (event.target !== event.currentTarget) return;
  if (event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) return;
  const delta = blockMoveDelta(event.key);
  if (!delta) return;
  event.preventDefault();
  const current = getBlocks().find((candidate) => candidate.id === block.id);
  if (!current) return;
  if (moveBlockToSlot(block.id, current.slot + delta, dateKey) === 'moved') {
    focusPlannedBlock(block.id);
  }
}

/**
 * Renders the full time-block grid for the currently viewed date: time labels,
 * grid rows, planned blocks (with drag-to-move), live timer block, a "now" line,
 * and the plan-task drag targets. Also handles drag-and-drop wiring for
 * moving existing blocks and dropping tasks from the plan list.
 */
function renderTimeblock() {
  const viewDate = getViewDate();
  const dateKey = dk(viewDate);
  const timer = getActiveTimer();
  const entries = getEntries();
  const blocks = getBlocks();
  const liveEntry = timer ? entries.find((entry) => entry.id === timer.entryId) : null;

  // Time labels
  const timesEl = document.getElementById('tbTimes');
  timesEl.innerHTML = '';
  for (let i = 0; i <= TB_SLOTS; i++) {
    const d = document.createElement('div');
    d.className = 'tb-time-label' + (i === TB_SLOTS ? ' end' : '');
    d.textContent = slotToTime(i);
    timesEl.appendChild(d);
  }

  // Build grid slots
  const grid = document.getElementById('tbGrid');
  grid.innerHTML = '';
  for (let i = 0; i < TB_SLOTS; i++) {
    const s = document.createElement('div');
    s.className = 'tb-slot' + (i % 2 === 1 ? ' half' : '');
    s.dataset.slot = i;
    grid.appendChild(s);
  }

  // ── Auto blocks from log entries (render first = below manual blocks) ──
  const liveId = timer ? timer.entryId : null;
  const tbStart = TB_START * 60,
    tbEnd = TB_END * 60;

  function minsFromTs(ts) {
    const d = new Date(ts);
    return d.getHours() * 60 + d.getMinutes();
  }
  function autoBlockEl(text, tag, startTs, endTs, isLive) {
    const cat = getCat(tag || 'other');
    const startMins = minsFromTs(startTs);
    const endMins = minsFromTs(endTs);
    if (startMins >= tbEnd || endMins <= tbStart) return null;
    const cStart = Math.max(startMins, tbStart);
    const cEnd = Math.min(endMins, tbEnd);
    const topPx = ((cStart - tbStart) / 30) * TB_SLOT_H;
    const hPx = Math.max(TB_SLOT_H * 0.5, ((cEnd - cStart) / 30) * TB_SLOT_H);
    const dur = Math.round((endTs - startTs) / 60000);
    const h = Math.floor(dur / 60),
      m = dur % 60;
    const durStr = h > 0 ? (m > 0 ? `${h}h ${m}m` : `${h}h`) : `${m}m`;
    const el = document.createElement('div');
    el.className = 'tb-block auto' + (isLive ? ' live' : '');
    if (isLive) el.id = 'tb-live-block';
    el.style.top = topPx + 'px';
    el.style.height = hPx + 'px';
    el.style.background = cat.color + '28';
    el.style.borderLeftColor = cat.color;
    el.style.color = cat.color;
    const icon = isLive ? '▶ ' : '● ';
    el.innerHTML =
      `<div class="tb-block-name">${icon}${escHtml(text)}</div>` +
      `<div class="tb-block-sub" id="${isLive ? 'tb-live-sub' : ''}">${escHtml(cat.label)} · ${durStr}</div>`;
    return el;
  }

  const meetingNames = new Set(
    getBlocks()
      .filter((block) => block.date === dateKey && block.type === 'meeting')
      .map((block) => block.text.toLowerCase())
  );

  // Merge same-task entries with < 30 min gap into a single visual block
  function mergeAutoEntries(rawEntries) {
    const sorted = [...rawEntries].sort((a, b) => a.ts - b.ts);
    const merged = [];
    for (const e of sorted) {
      const prev = merged[merged.length - 1];
      const prevEnd = prev ? prev._mergedEnd || prev.ts : 0;
      if (
        prev &&
        prev.text.toLowerCase() === e.text.toLowerCase() &&
        e.ts - prevEnd <= 30 * 60 * 1000
      ) {
        prev._mergedEnd = Math.max(prevEnd, e.tsEnd || e.ts);
        prev.tag = prev.tag || e.tag;
      } else {
        merged.push({ ...e, _mergedEnd: e.tsEnd || e.ts });
      }
    }
    return merged;
  }

  const dayAutoEntries = entries.filter(
    (entry) =>
      entry.date === dateKey &&
      entry.id !== liveId &&
      !meetingNames.has(entry.text.replace(/^📅\s*/, '').toLowerCase()) &&
      !meetingNames.has(entry.text.toLowerCase()) &&
      (entry.tsEnd || isToday(viewDate))
  );
  mergeAutoEntries(dayAutoEntries).forEach((entry) => {
    const endTs = entry._mergedEnd || (isToday(viewDate) ? Date.now() : null);
    if (!endTs) return;
    const el = autoBlockEl(entry.text, entry.tag, entry.ts, endTs, false);
    if (el) grid.appendChild(el);
  });

  // Live timer block — skip if the active timer is a meeting block (it will pulse instead)
  if (liveId) {
    const le = entries.find((entry) => entry.id === liveId);
    const isMeetingBlock =
      le &&
      blocks.some(
        (block) =>
          block.date === dateKey &&
          block.type === 'meeting' &&
          block.text.toLowerCase() === le.text.toLowerCase()
      );
    if (le && le.date === dateKey && !isMeetingBlock) {
      const fakeEnd = timer.paused
        ? le.ts + (timer.accumulatedMs || 0) // paused: stop at pause point
        : Math.max(Date.now(), le.ts + 60000); // running: extend to now
      const el = autoBlockEl(le.text, le.tag, le.ts, fakeEnd, true);
      if (el) grid.appendChild(el);
    }
  }

  // ── Manual planned blocks (render last = on top, dashed border) ──
  const dayBlocks = blocks.filter((block) => block.date === dateKey);
  const tbLiveEntry = liveEntry;
  dayBlocks.forEach((block) => {
    const cat = getCat(block.tag || 'other');
    const el = document.createElement('div');
    const isDone = getPlanTasks().some(
      (task) =>
        task.date === dateKey &&
        task.text.toLowerCase() === block.text.toLowerCase() &&
        task.status === 'done'
    );
    const isMeetingBlock = isLiveMeetingBlock(block, tbLiveEntry);
    el.className = 'tb-block plan' + (isDone ? ' task-done' : '') + (isMeetingBlock ? ' live' : '');
    el.dataset.bid = block.id;
    el.draggable = true;
    el.tabIndex = 0;
    el.setAttribute('role', 'group');
    el.setAttribute('aria-label', blockAriaLabel(block));
    el.style.top = block.slot * TB_SLOT_H + 1 + 'px';
    el.style.height = block.duration * TB_SLOT_H - 3 + 'px';
    el.style.background = cat.color + '18';
    el.style.borderLeftColor = cat.color;
    el.style.color = cat.color;

    const icon = block.type === 'meeting' ? '📅 ' : '';
    const emojiPrefix = block.emoji ? escHtml(block.emoji) + ' ' : '';
    const dur = block.duration * 30;
    const h = Math.floor(dur / 60),
      m = dur % 60;
    const durStr = h > 0 ? (m > 0 ? `${h}h ${m}min` : `${h}h`) : `${m}min`;
    el.innerHTML =
      `<div class="tb-block-name">${emojiPrefix}${icon}${escHtml(block.text)}</div>` +
      (block.duration > 1
        ? `<div class="tb-block-sub">${escHtml(cat.label)} · ${durStr}</div>`
        : '') +
      buildBlockActionButtonsHtml(block);

    el.addEventListener('dragstart', (event) => {
      tbDragSource = 'grid';
      tbDragId = block.id;
      event.dataTransfer.effectAllowed = 'move';
    });
    el.addEventListener('dragend', () => {
      tbDragSource = null;
      tbDragId = null;
    });
    el.addEventListener('keydown', (event) => handleBlockKeydown(event, block, dateKey));
    el.querySelector('.tb-block-del').addEventListener('click', (event) => {
      event.stopPropagation();
      removeBlockById(block.id);
    });
    const startBtn = el.querySelector('.tb-block-start');
    if (startBtn)
      startBtn.addEventListener('click', (event) => {
        event.stopPropagation();
        tbStartBlock(block.id);
      });
    el.querySelector('.tb-block-emoji').addEventListener('click', (event) => {
      event.stopPropagation();
      openBlockEmojiPicker(block.id, event.currentTarget);
    });
    grid.appendChild(el);
  });

  // Untracked time — show faint label on past slots with no coverage (any viewed date)
  const nowMins = isToday(viewDate)
    ? new Date().getHours() * 60 + new Date().getMinutes()
    : TB_END * 60; // for past days, all slots are "past"

  // Use start-of-day as floor — slots before work started aren't "untracked"
  const sodTs = isToday(viewDate) ? getDayStart() : null;
  const sodMins = sodTs
    ? new Date(sodTs).getHours() * 60 + new Date(sodTs).getMinutes()
    : TB_START * 60; // no start set — use grid start as default

  // Build a set of 30-min slots that have coverage (from entries or planned blocks)
  const coveredSlots = new Set();
  entries
    .filter((entry) => entry.date === dateKey && entry.tsEnd)
    .forEach((entry) => {
      const startSlot = timeToSlot(new Date(entry.ts).getHours(), new Date(entry.ts).getMinutes());
      // If tsEnd is exactly on a 30-min boundary (e.g. 09:30:00), back off 1 minute
      // so we don't accidentally mark the NEXT slot as covered
      const endD = new Date(entry.tsEnd);
      const onBoundary = endD.getMinutes() % 30 === 0 && endD.getSeconds() === 0;
      // timeToSlot uses Math.round(m/30), so backing off 1 min (→29) still rounds to slot+1.
      // Instead compute endSlot directly: if on a boundary, the entry ends AT that boundary,
      // meaning the boundary's slot is NOT covered — use the slot before it.
      const endSlot = onBoundary
        ? timeToSlot(endD.getHours(), endD.getMinutes()) - 1
        : timeToSlot(endD.getHours(), endD.getMinutes());
      for (let s = Math.max(0, startSlot); s < Math.min(TB_SLOTS, endSlot + 1); s++)
        coveredSlots.add(s);
    });
  if (timer && liveEntry && liveEntry.date === dateKey) {
    const startSlot = timeToSlot(
      new Date(liveEntry.ts).getHours(),
      new Date(liveEntry.ts).getMinutes()
    );
    if (timer.paused) {
      // Paused: only cover slots up to the pause point
      const pauseEnd = new Date(liveEntry.ts + (timer.accumulatedMs || 0));
      const endSlot = timeToSlot(pauseEnd.getHours(), pauseEnd.getMinutes());
      for (let s = Math.max(0, startSlot); s < Math.min(TB_SLOTS, endSlot + 1); s++)
        coveredSlots.add(s);
    } else {
      for (let s = Math.max(0, startSlot); s < TB_SLOTS; s++) coveredSlots.add(s);
    }
  }
  blocks
    .filter((block) => block.date === dateKey)
    .forEach((block) => {
      for (let s = block.slot; s < Math.min(TB_SLOTS, block.slot + block.duration); s++)
        coveredSlots.add(s);
    });

  for (let slot = 0; slot < TB_SLOTS; slot++) {
    const slotStartMins = TB_START * 60 + slot * 30;
    if (slotStartMins < sodMins) continue; // before work started — not untracked
    const isPast = slotStartMins < nowMins; // slot has started (not necessarily fully elapsed)
    if (!isPast || coveredSlots.has(slot)) continue;
    const untracked = document.createElement('div');
    untracked.className = 'tb-untracked';
    untracked.style.top = slot * TB_SLOT_H + 1 + 'px';
    untracked.style.height = TB_SLOT_H - 2 + 'px';
    untracked.textContent = 'untracked';
    grid.appendChild(untracked);
  }

  // Current time indicator (today only)
  if (isToday(viewDate)) {
    const nowLine = document.createElement('div');
    nowLine.className = 'tb-now-line';
    nowLine.id = 'tbNowLine';
    grid.appendChild(nowLine);
    positionNowLine();
  }

  // Grid-level drag/drop (works even when blocks overlap slots)
  grid._dragSlot = 0;
  grid.addEventListener('dragover', (event) => {
    event.preventDefault();
    event.dataTransfer.dropEffect = 'move';
    const rect = grid.getBoundingClientRect();
    const slot = Math.max(
      0,
      Math.min(TB_SLOTS - 1, Math.floor((event.clientY - rect.top) / TB_SLOT_H))
    );
    grid
      .querySelectorAll('.tb-slot.drag-over')
      .forEach((slotEl) => slotEl.classList.remove('drag-over'));
    const slotEl = grid.querySelector(`[data-slot="${slot}"]`);
    if (slotEl) slotEl.classList.add('drag-over');
    grid._dragSlot = slot;
  });
  grid.addEventListener('dragleave', (event) => {
    if (!grid.contains(event.relatedTarget))
      grid
        .querySelectorAll('.tb-slot.drag-over')
        .forEach((slotEl) => slotEl.classList.remove('drag-over'));
  });
  grid.addEventListener('drop', (event) => {
    event.preventDefault();
    grid
      .querySelectorAll('.tb-slot.drag-over')
      .forEach((slotEl) => slotEl.classList.remove('drag-over'));
    const target = grid._dragSlot;

    if (tbDragSource === 'grid' && tbDragId) moveBlockToSlot(tbDragId, target, dateKey);
    tbDragSource = null;
    tbDragId = null;
  });
}

/**
 * Positions the "now" indicator line in the time-block grid to reflect the
 * current time. Hides the line when outside the grid's time range
 * (`TB_START`–`TB_END`).
 */
function positionNowLine() {
  const el = document.getElementById('tbNowLine');
  if (!el) return;
  const now = new Date();
  const mins = now.getHours() * 60 + now.getMinutes();
  const tbStartMins = TB_START * 60,
    tbEndMins = TB_END * 60;
  if (mins < tbStartMins || mins > tbEndMins) {
    el.style.display = 'none';
    return;
  }
  el.style.display = 'block';
  el.style.top = ((mins - tbStartMins) / 30) * TB_SLOT_H + 'px';
}