Source: 11-timeblock.js

/* ── Timeblock ── */
/**
 * localStorage key for the time-block array.
 * @type {string}
 */
const STORE_BLOCKS = 'wl_blocks_v1';
// Assumption: a standard workday starts no earlier than 07:00 and ends no later
// than 21:00. Tasks scheduled outside this window are rare enough that they do
// not need to appear in the visual grid. If the assumption changes, update
// TB_START / TB_END here — slots and pixel heights are derived automatically.
const TB_START = 7; // 07:00
const TB_END = 21; // 21:00
const TB_SLOTS = (TB_END - TB_START) * 2; // 28 half-hour slots
const TB_SLOT_H = 36; // px per slot

const notifiedBlocks = new Set();

/**
 * Loads time blocks from localStorage into `blocks`, filtering invalid entries.
 * Drops are reported via wlLog.warn so data-quality issues are visible in DevTools.
 * Applies a one-time migration to shift existing block slots by +2 when the
 * time-block grid start time changed from 08:00 to 07:00.
 */
function loadBlocks() {
  try {
    const raw = JSON.parse(localStorage.getItem(STORE_BLOCKS) || '[]');
    const all = Array.isArray(raw) ? raw : [];
    setBlocks(all.filter(validBlock));
    if (getBlocks().length < all.length)
      wlLog.warn(`loadBlocks: dropped ${all.length - getBlocks().length} invalid block record(s)`, {
        total: all.length,
        kept: getBlocks().length,
      });
  } catch (err) {
    setBlocks([]);
    wlLog.error('loadBlocks: failed to parse time blocks from localStorage', err);
  }
  // One-time migration: TB_START shifted from 8→7, add 2 slots to all existing blocks
  if (!localStorage.getItem('wl_tb_migrated_7')) {
    setBlocks(getBlocks().map((block) => ({ ...block, slot: block.slot + 2 })));
    saveBlocks();
    localStorage.setItem('wl_tb_migrated_7', '1');
  }
}
/** Persists the current `blocks` array to localStorage. */
function saveBlocks() {
  localStorage.setItem(STORE_BLOCKS, JSON.stringify(getBlocks()));
}

/**
 * Removes one time block, persists the remaining blocks and redraws the grid.
 * Shared by the block's delete button and the start prompt's "no" branches so
 * all three drop a block the same way.
 * @param {string} blockId - ID of the block to remove.
 * @returns {void}
 */
function removeBlockById(blockId) {
  setBlocks(getBlocks().filter((block) => block.id !== blockId));
  saveBlocks();
  renderTimeblock();
}

/**
 * Keeps a block inside the day grid: one of `duration` slots can start no
 * earlier than slot 0 and no later than `TB_SLOTS - duration`.
 * @param {number} slot - Requested start slot.
 * @param {number} duration - Block length in half-hour slots.
 * @returns {number} The nearest valid start slot.
 */
function clampBlockSlot(slot, duration) {
  return Math.max(0, Math.min(TB_SLOTS - duration, slot));
}

/**
 * Moves a planned block to another start slot. This is the one place that
 * changes a block's time, so drag-and-drop and the keyboard handler clamp,
 * warn about overlaps, save and redraw identically.
 * @param {string} blockId - ID of the block to move.
 * @param {number} requestedSlot - Wanted start slot; clamped into the grid.
 * @param {string} dateKey - YYYY-MM-DD of the day in view, for the overlap check.
 * @returns {('moved'|'unchanged'|'declined'|'missing')} `moved` after saving and
 *   redrawing; `unchanged` when the clamped slot is the current one (nothing is
 *   saved or redrawn); `declined` when the user refused the overlap prompt;
 *   `missing` when no block has that ID.
 */
function moveBlockToSlot(blockId, requestedSlot, dateKey) {
  const block = getBlocks().find((candidate) => candidate.id === blockId);
  if (!block) return 'missing';
  const newSlot = clampBlockSlot(requestedSlot, block.duration);
  if (newSlot === block.slot) return 'unchanged';
  const newStart = TB_START * 60 + newSlot * 30;
  const newEnd = newStart + block.duration * 30;
  const hits = tbOverlaps(newStart, newEnd, dateKey, block.id);
  if (hits.length && !confirm(`This overlaps with ${hits}.\n\nMove here anyway?`)) {
    return 'declined';
  }
  block.slot = newSlot;
  saveBlocks();
  renderTimeblock();
  return 'moved';
}

/**
 * Converts a 0-based half-hour slot index to an "HH:MM" label.
 * Slot 0 = `TB_START:00`, slot 2 = `TB_START+1:00`, etc.
 * @param {number} slot - 0-based slot index.
 * @returns {string} "HH:MM" formatted time string.
 */
function slotToTime(slot) {
  const total = TB_START * 60 + slot * 30;
  return (
    String(Math.floor(total / 60)).padStart(2, '0') + ':' + String(total % 60).padStart(2, '0')
  );
}
/**
 * Converts a time value to a 0-based slot index relative to `TB_START`.
 * Accepts either an "HH:MM" string or two separate (hours, minutes) arguments.
 * @param {string|number} hhmm - "HH:MM" string, or hours when `m2` is provided.
 * @param {number}        [m2] - Minutes (only when `hhmm` is a number).
 * @returns {number} 0-based slot index.
 */
function timeToSlot(hhmm, m2) {
  // Accept either "HH:MM" string or (hours, minutes) numbers
  const h = m2 !== undefined ? hhmm : parseInt(hhmm.split(':')[0]);
  const m = m2 !== undefined ? m2 : parseInt(hhmm.split(':')[1]);
  return (h - TB_START) * 2 + Math.round(m / 30);
}

/**
 * Returns a comma-separated string of task names that overlap a proposed time
 * range, checking both planned blocks and logged time entries. Returns an
 * empty string if there are no overlaps.
 * @param {number} newStartMins - Proposed start time in minutes from midnight.
 * @param {number} newEndMins   - Proposed end time in minutes from midnight.
 * @param {string} dateKey      - Date string in YYYY-MM-DD format.
 * @param {string} [excludeId]  - Block ID to exclude from the check (when moving).
 * @returns {string} Overlapping task names, or '' if none.
 */
function tbOverlaps(newStartMins, newEndMins, dateKey, excludeId) {
  const hits = [];
  // Check against manual planned blocks
  getBlocks()
    .filter((block) => block.date === dateKey && block.id !== excludeId)
    .forEach((block) => {
      const s = TB_START * 60 + block.slot * 30,
        e = s + block.duration * 30;
      if (newStartMins < e && newEndMins > s) hits.push(block.text);
    });
  // Check against completed log entries
  getEntries()
    .filter((entry) => entry.date === dateKey && entry.tsEnd && entry.tsEnd > entry.ts)
    .forEach((entry) => {
      const s = new Date(entry.ts).getHours() * 60 + new Date(entry.ts).getMinutes();
      const en = new Date(entry.tsEnd).getHours() * 60 + new Date(entry.tsEnd).getMinutes();
      if (newStartMins < en && newEndMins > s) hits.push(entry.text);
    });
  // Deduplicate and format
  const unique = [...new Set(hits)];
  if (!unique.length) return '';
  return unique.map((text) => `"${text}"`).join(', ');
}

/**
 * Creates one selectable emoji button for the picker grid. The emoji is the
 * visible label, but an explicit aria-label ("Select 😀") keeps the name
 * consistent across screen readers, which otherwise announce emoji
 * differently or skip them (WCAG 4.1.2).
 * @param {string} emoji - The emoji character this button selects.
 * @param {Function} onSelect - Called when the button is clicked.
 * @returns {HTMLButtonElement} The button, not yet attached to the DOM.
 */
function createEmojiOptionButton(emoji, onSelect) {
  const button = document.createElement('button');
  button.type = 'button';
  button.textContent = emoji;
  button.setAttribute('aria-label', 'Select ' + emoji);
  button.addEventListener('click', onSelect);
  return button;
}

/**
 * Focuses the emoji trigger button for a planned block after the grid is
 * re-rendered by `renderTimeblock()`. Searches by `data-bid` rather than
 * building a CSS selector from untrusted data (avoids injection risk).
 * @param {string} bid - Block ID.
 * @returns {void}
 */
function focusBlockEmojiButton(bid) {
  const grid = document.getElementById('tbGrid');
  if (!grid) return;
  const blockEl = Array.from(grid.querySelectorAll('.tb-block.plan')).find(
    (el) => el.dataset.bid === bid
  );
  if (blockEl) blockEl.querySelector('.tb-block-emoji')?.focus();
}

/**
 * Opens a floating emoji picker anchored below `anchor` for a time block.
 * The picker is exposed as an ARIA dialog (WCAG 4.1.2 / 2.1.2): it carries
 * `role="dialog"`, `aria-modal`, and `aria-label`; Tab navigation wraps
 * inside via `trapFocusInOverlay()`; Escape closes the picker and returns
 * focus to the trigger.  Calling again for the same block ID closes the
 * picker (toggle).
 * @param {string}      bid    - Block ID.
 * @param {HTMLElement} anchor - Element to position the picker below.
 * @returns {void}
 */
function openBlockEmojiPicker(bid, anchor) {
  const existing = document.getElementById('__emojiPicker');
  if (existing) {
    existing.remove();
    // Reset aria-expanded on whichever block's picker was open
    const prevBid = _emojiPickerPid;
    _emojiPickerPid = null;
    if (prevBid) {
      const tbGrid = document.getElementById('tbGrid');
      const prevEl =
        tbGrid &&
        Array.from(tbGrid.querySelectorAll('.tb-block.plan')).find(
          (el) => el.dataset.bid === prevBid
        );
      prevEl?.querySelector('.tb-block-emoji')?.setAttribute('aria-expanded', 'false');
    }
    if (prevBid === bid) return; // same block: toggled closed
  }

  const block = getBlocks().find((timeBlock) => timeBlock.id === bid);
  if (!block) return;

  _emojiPickerPid = bid;
  anchor.setAttribute('aria-expanded', 'true');

  const picker = document.createElement('div');
  picker.id = '__emojiPicker';
  picker.className = 'emoji-picker';
  picker.setAttribute('role', 'dialog');
  picker.setAttribute('aria-label', 'Choose emoji');
  picker.setAttribute('aria-modal', 'true');

  const input = document.createElement('input');
  input.className = 'emoji-picker-input';
  input.setAttribute('aria-label', 'Type or paste an emoji');
  input.placeholder = 'type or paste any emoji…';
  input.value = block.emoji || '';
  picker.appendChild(input);

  const grid = document.createElement('div');
  grid.className = 'emoji-picker-grid';
  grid.setAttribute('role', 'group');
  grid.setAttribute('aria-label', 'Quick-select emoji');
  EMOJI_COMMON.forEach((em) => {
    grid.appendChild(createEmojiOptionButton(em, () => setBlockEmoji(bid, em)));
  });
  picker.appendChild(grid);

  const clear = document.createElement('button');
  clear.type = 'button';
  clear.className = 'emoji-picker-clear';
  clear.textContent = '✕ remove emoji';
  clear.setAttribute('aria-label', 'Remove emoji');
  clear.addEventListener('click', () => setBlockEmoji(bid, null));
  picker.appendChild(clear);

  document.body.appendChild(picker);
  const rect = anchor.getBoundingClientRect();
  const scrollY = window.scrollY || document.documentElement.scrollTop;
  picker.style.top = rect.bottom + scrollY + 4 + 'px';
  picker.style.left = Math.min(rect.left, window.innerWidth - 250) + 'px';

  input.focus();
  input.select();

  // Tab trap: keeps keyboard focus inside the dialog (WCAG 2.1.2)
  picker.addEventListener('keydown', (event) => {
    if (event.key === 'Tab') trapFocusInOverlay(picker, event);
    if (event.key === 'Escape') {
      picker.remove();
      _emojiPickerPid = null;
      anchor.setAttribute('aria-expanded', 'false');
      anchor.focus();
    }
  });

  input.addEventListener('keydown', (event) => {
    if (event.key === 'Enter') {
      const v = input.value.trim();
      setBlockEmoji(bid, v || null);
    }
    // Escape is handled by the picker-level keydown above
  });

  setTimeout(() => {
    document.addEventListener('click', function close(ev) {
      if (!picker.contains(ev.target)) {
        picker.remove();
        _emojiPickerPid = null;
        anchor.setAttribute('aria-expanded', 'false');
        document.removeEventListener('click', close);
        // Focus is intentionally not returned here: the user clicked somewhere
        // specific with the pointer, so moving focus back to the trigger would
        // be disruptive. aria-modal confines virtual-cursor keyboard navigation
        // inside the dialog, so this path is not keyboard-reachable in practice.
      }
    });
  }, 50);
}

/**
 * Saves an emoji to a time block and closes the picker.
 * Pass null or an empty string to remove the block's emoji.
 * @param {string}      bid   - Block ID.
 * @param {string|null} emoji - Emoji character to assign, or null to remove.
 */
function setBlockEmoji(bid, emoji) {
  const block = getBlocks().find((timeBlock) => timeBlock.id === bid);
  if (!block) return;
  if (emoji) block.emoji = emoji;
  else delete block.emoji;
  const p = document.getElementById('__emojiPicker');
  if (p) {
    p.remove();
    _emojiPickerPid = null;
  }
  saveBlocks();
  renderTimeblock();
  // Return focus to the trigger that opened the picker. The grid was just
  // re-rendered by renderTimeblock(), so we must find the new DOM element.
  focusBlockEmojiButton(bid);
}

/**
 * Checks all of today's time blocks and acts on ones that have just become active:
 * - Meeting blocks: auto-starts a log entry and timer at the scheduled start time.
 * - Task blocks: prompts the user to switch/start within a 3-minute window.
 * Each block is only acted on once (tracked in `notifiedBlocks`).
 * No-ops when not viewing today.
 */
function checkBlockNotifications() {
  if (!isToday(getViewDate())) return;
  const now = new Date();
  const nowMins = now.getHours() * 60 + now.getMinutes();
  const todayKey = dk(new Date());
  const entries = getEntries();
  const timer = getActiveTimer();

  const pending = getBlocks().filter(
    (block) => block.date === todayKey && !notifiedBlocks.has(block.id)
  );

  for (const b of pending) {
    const startMins = TB_START * 60 + b.slot * 30;
    const endMins = startMins + b.duration * 30;

    if (b.type === 'meeting') {
      // Auto-start if currently in progress (started but not ended yet)
      if (nowMins >= startMins && nowMins < endMins) {
        notifiedBlocks.add(b.id);
        // Skip if already logged or timer already running for this meeting
        const alreadyLogged = entries.some(
          (entry) =>
            entry.date === todayKey &&
            entry.text.toLowerCase() === b.text.toLowerCase() &&
            !entry.tsEnd // only count open entries — not pre-created completed ones
        );
        const curEntry = timer ? entries.find((entry) => entry.id === timer.entryId) : null;
        const alreadyActive = curEntry && curEntry.text.toLowerCase() === b.text.toLowerCase();
        if (!alreadyLogged && !alreadyActive) {
          // Use the meeting's scheduled start time, not now
          const d = new Date();
          const scheduledTs = new Date(
            d.getFullYear(),
            d.getMonth(),
            d.getDate(),
            Math.floor((TB_START * 60 + b.slot * 30) / 60),
            (TB_START * 60 + b.slot * 30) % 60,
            0
          ).getTime();
          tbStartBlock(b.id, scheduledTs);
        }
      }
    } else {
      // Task blocks — prompt within 3-minute window after start
      if (nowMins < startMins || nowMins >= startMins + 3) continue;
      notifiedBlocks.add(b.id);
      if (timer) {
        const cur = entries.find((entry) => entry.id === timer.entryId);
        const curName = cur ? cur.text : 'current task';
        const sw = confirm(`⏰ Time for: "${b.text}"\n\nSwitch from "${curName}"?`);
        if (sw) {
          tbStartBlock(b.id);
        } else {
          removeBlockById(b.id);
        }
      } else {
        const go = confirm(`⏰ Time for: "${b.text}"\n\nStart timer?`);
        if (go) {
          tbStartBlock(b.id);
        } else {
          removeBlockById(b.id);
        }
      }
      break;
    }
  }
}

/**
 * Starts a timer for the given time block: creates (or promotes) the matching
 * plan task to "in progress", stops any running timer, creates a new log entry,
 * and starts the tick interval. Uses `overrideTs` as the entry start time so
 * elapsed time is counted from the scheduled start, not wall-clock now.
 * @param {string} blockId       - ID of the time block to start.
 * @param {number} [overrideTs]  - Optional explicit start timestamp (ms). Defaults to `safeRoundedStart()`.
 */
function tbStartBlock(blockId, overrideTs) {
  const b = getBlocks().find((bl) => bl.id === blockId);
  if (!b) return;
  const todayKey = dk(new Date());
  let task = getPlanTasks().find(
    (planTask) => planTask.date === todayKey && planTask.text.toLowerCase() === b.text.toLowerCase()
  );
  if (!task) {
    task = {
      id: Date.now() + '',
      text: b.text,
      status: 'inprogress',
      tag: b.tag || 'other',
      date: todayKey,
    };
    getPlanTasks().push(task);
  } else if (task.status !== 'done') {
    task.status = 'inprogress';
  }
  savePlan();
  if (getActiveTimer()) stopTimer();
  const ts = overrideTs || safeRoundedStart();
  const entry = {
    id: Date.now() + 1 + '',
    text: b.text,
    tag: b.tag || 'other',
    ts,
    date: todayKey,
  };
  getEntries().push(entry);
  // Set timer startTs so elapsed = time since scheduled start, not since now
  setViewDate(new Date());
  save();
  setActiveTimer({ entryId: entry.id, startTs: ts, accumulatedMs: 0, paused: false });
  save();
  tickTimer();
  setTimerInterval(setInterval(tickTimer, 1000));
  updateTimerBar();
  updateTimerBtn(true);
  render();
}