Source: 04c-render-timeline.js

/* ── Render — timeline entry list: build, bind, and its small helpers ── */
// Split out of 04-render.js (QA finding: module size, flagged five
// consecutive weekly reviews). renderTimelineSection() reproduces render()'s
// old two branches (empty-state vs. entry list) exactly, including the
// early return for the empty case — nothing runs after this call in
// render() either way, so the behaviour is unchanged.

/**
 * Binds click/Enter handlers for the ad-hoc inline log row (`#tlAdHocBtn` /
 * `#tlAdHocInput`). Called from renderTimelineSection() on both the
 * empty-state branch and the normal entry-list branch, since #timeline's
 * innerHTML — and the row inside it — is fully replaced on every render()
 * call. Both branches must call this, or the row exists in the DOM but
 * silently does nothing.
 * @returns {void}
 */
function bindAdHocRow() {
  const adHocBtn = document.getElementById('tlAdHocBtn');
  const adHocInput = document.getElementById('tlAdHocInput');
  if (!adHocBtn || !adHocInput) return;
  const commitAdHoc = () => {
    const text = adHocInput.value.trim();
    if (!text) {
      adHocInput.focus();
      return;
    }
    const entry = {
      id: Date.now() + '',
      text,
      tag: getSelectedTag() || (getCategories()[0] ? getCategories()[0].id : 'other'),
      ts: safeRoundedStart(),
      date: dk(new Date()),
    };
    getEntries().push(entry);
    save();
    render();
  };
  adHocBtn.addEventListener('click', commitAdHoc);
  adHocInput.addEventListener('keydown', (event) => {
    if (event.key === 'Enter') commitAdHoc();
    // Prevent Space from opening the rapid-log overlay while typing here
    if (event.code === 'Space') event.stopPropagation();
  });
}

/**
 * Builds and mounts the #timeline entry list (or its empty state) plus the
 * ad-hoc log row, then binds every entry-row event handler and renders the
 * sections that depend on the timeline. Mirrors render()'s old two branches:
 * an empty list bails out early after rendering its own sub-components,
 * exactly as before.
 * @param {Array<Object>} list - viewEntries() result for the currently viewed date.
 * @returns {void}
 */
function renderTimelineSection(list) {
  const timelineEl = document.getElementById('timeline');

  // Ad-hoc inline log row — shown only when viewing today, pinned at the bottom
  const adHocRow = isToday(getViewDate())
    ? `<div class="tl-adhoc-row">
         <input class="tl-adhoc-input" id="tlAdHocInput"
                aria-label="Log an entry directly in the time log"
                placeholder="log something…" autocomplete="off"/>
         <button class="tl-adhoc-btn" id="tlAdHocBtn" aria-label="Log entry">+ log</button>
       </div>`
    : '';

  // Empty state: render sub-components (plan, timeblock) and bail out early
  if (!list.length) {
    timelineEl.innerHTML =
      '<div class="empty-state">' +
      (isToday(getViewDate())
        ? 'nothing logged yet — type something below.'
        : 'nothing was logged on this day.') +
      '</div>' +
      adHocRow;
    bindAdHocRow();
    const chartEl = document.getElementById('chart');
    if (chartEl) chartEl.innerHTML = '';
    renderQuickPick();
    renderPlan();
    renderPlanReviewReminder();
    renderCompleted();
    renderTodayFlow();
    renderTrackers();
    return;
  }
  // Build entry row HTML — one <div class="entry"> per log entry; ad-hoc row pinned at bottom
  timelineEl.innerHTML =
    list
      .map((entry) => {
        const activeTimer = getActiveTimer();
        const isTiming = activeTimer && activeTimer.entryId === entry.id;
        const isPaused = isTiming && activeTimer.paused;
        const color = getCatColor(entry.tag);

        const endLine = isTiming
          ? isPaused
            ? `<span class="etime-end" style="color:#EF9F27;font-size:10px;">paused</span>`
            : `<span class="etime-end" style="color:#5DCAA5;font-size:10px;">timing…</span>`
          : entry.tsEnd
            ? `<span class="etime-end">&#8627; ${fmtTime(entry.tsEnd)}</span>${durLabel(entry.ts, entry.tsEnd)}`
            : `<span class="etime-end" style="color:var(--text3);font-style:italic;font-size:10px;">+ end time</span>`;

        const catOpts = buildEntryCatPickerHtml(
          entry,
          pickableCategories(getCategories(), entry.tag)
        );

        const startVal = toTimeInput(entry.ts);
        const endVal = entry.tsEnd ? toTimeInput(entry.tsEnd) : '';

        const billableEmoji = isEntryBillable(entry) ? '💰' : '💸';
        // The emoji alone is announced as e.g. "money bag"; name the state and the action.
        const billableAriaLabel = isEntryBillable(entry)
          ? 'Billable — tap to mark internal'
          : 'Internal — tap to mark billable';
        // Jira link rendered as a sibling so .etext[role="button"] contains no
        // interactive descendants (ARIA 1.2 forbids interactive children inside
        // role="button").
        const jiraHtml = jiraTicketHtml(entry.text);
        const plainHtml = escHtml(entry.text);
        const jiraSiblingHtml =
          jiraHtml !== plainHtml ? `<span class="jira-key-aside">${jiraHtml}</span>` : '';
        return `
        <div class="entry${isTiming ? ' is-timing' : ''}${entry.signifier === 'cancelled' ? ' sig-cancelled-row' : ''}" data-id="${entry.id}">
          <div class="etime-col">
            <span class="etime-display" data-id="${entry.id}" role="button" tabindex="0" aria-label="Edit start and end time">
              <span class="etime-start">${fmtTime(entry.ts)}</span>
              ${endLine}
            </span>
            <div class="etime-editor" id="ed-${entry.id}">
              <div class="etime-editor-row"><label class="etime-lbl" for="ts-${entry.id}">start</label><input class="etime-input" type="time" id="ts-${entry.id}" value="${startVal}" /></div>
              <div class="etime-editor-row"><label class="etime-lbl" for="te-${entry.id}">end</label><input class="etime-input" type="time" id="te-${entry.id}" value="${endVal}" placeholder="--:--" /></div>
              <div class="etime-actions">
                <button class="etime-save" data-id="${entry.id}" aria-label="Save time edit">save</button>
                <button class="etime-cancel" data-id="${entry.id}" aria-label="Cancel time edit">cancel</button>
              </div>
            </div>
          </div>
          ${sigHtml(entry)}
          <span class="edot" style="background:${color};margin-top:6px;"></span>
          <div class="ebody">
            <div class="etext" data-id="${entry.id}" role="button" tabindex="0">${plainHtml}${entry._uncategorised ? `<span class="entry-uncategorised" aria-label="No category assigned" title="No category — tap to assign">○</span>` : ''}</div>${jiraSiblingHtml}
            <button class="etag-btn" data-id="${entry.id}">
              <span class="etag-cdot" style="background:${color}"></span>
              ${escHtml(getCatLabel(entry.tag))} &#9660;
            </button>
            <div class="cat-picker" id="cp-${entry.id}">${catOpts}</div>
            ${buildEntryMetaHtml(entry, _entryMetaEditId === entry.id)}
          </div>
          <button class="ebill-btn" data-id="${entry.id}" aria-label="${billableAriaLabel}" title="toggle billable/internal" style="cursor:pointer;background:none;border:none;padding:4px 8px;font-size:16px;color:inherit">${billableEmoji}</button>
          <button class="erestart" data-id="${entry.id}" aria-label="Restart with timer" title="restart with timer">&#9654;</button>
          <button class="edel" data-id="${entry.id}" aria-label="Delete entry" title="delete">&times;</button>
        </div>`;
      })
      .join('') + adHocRow;

  bindAdHocRow();
  bindSignifierClicks();
  bindEntryMetaEvents(timelineEl);
  bindTimelineEntryEvents(timelineEl);

  renderQuickPick();
  renderPlan();
  renderPlanReviewReminder();
  renderCompleted();
  renderTodayFlow();
  renderTrackers();
}

/**
 * Binds every per-entry event handler in the timeline: the time editor, the
 * category picker (including its inline "+ new epic" creator), the billable
 * toggle, delete, restart, and inline text rename. Called once per
 * renderTimelineSection() call on the non-empty branch, since #timeline's
 * innerHTML is fully replaced on every render() call.
 * @param {HTMLElement} timelineEl - The #timeline element.
 * @returns {void}
 */
function bindTimelineEntryEvents(timelineEl) {
  /* time editor */
  timelineEl.querySelectorAll('.etime-display').forEach((el) => {
    const openEditor = () => {
      const id = el.dataset.id;
      closeAllEditors();
      el.style.display = 'none';
      const edPanel = document.getElementById('ed-' + id);
      edPanel.classList.add('open');
      const firstInput = edPanel.querySelector('input');
      if (firstInput) firstInput.focus();
    };
    el.addEventListener('click', openEditor);
    el.addEventListener('keydown', (ev) => {
      if (ev.key === 'Enter' || ev.key === ' ') {
        ev.preventDefault();
        openEditor();
      }
    });
  });
  timelineEl.querySelectorAll('.etime-save').forEach((btn) => {
    btn.addEventListener('click', () => {
      const id = btn.dataset.id,
        entry = getEntries().find((logEntry) => logEntry.id === id);
      if (!entry) return;
      const newStartTime = document.getElementById('ts-' + id).value;
      const newEndTime = document.getElementById('te-' + id).value;
      if (newStartTime) entry.ts = roundToNearest30(applyTime(entry.ts, newStartTime));
      if (newEndTime) entry.tsEnd = roundToNearest30(applyTime(entry.ts, newEndTime));
      else delete entry.tsEnd;
      // If this entry's timer is running, reset startTs to the new entry.ts
      const runningTimer = getActiveTimer();
      if (runningTimer && runningTimer.entryId === id && newStartTime) {
        runningTimer.startTs = entry.ts;
        runningTimer.accumulatedMs = 0;
        runningTimer.paused = false;
      }
      save();
      render();
    });
  });
  timelineEl
    .querySelectorAll('.etime-cancel')
    .forEach((btn) => btn.addEventListener('click', () => render()));

  /* category picker */
  timelineEl.querySelectorAll('.etag-btn').forEach((btn) => {
    btn.addEventListener('click', () => {
      const id = btn.dataset.id;
      const picker = document.getElementById('cp-' + id);
      const isOpen = picker.classList.contains('open');
      document.querySelectorAll('.cat-picker.open').forEach((el) => el.classList.remove('open'));
      if (!isOpen) picker.classList.add('open');
    });
  });
  timelineEl.querySelectorAll('.cat-opt').forEach((btn) => {
    btn.addEventListener('click', () => {
      const entry = getEntries().find((logEntry) => logEntry.id === btn.dataset.id);
      if (entry) {
        const taskText = entry.text.toLowerCase();
        getEntries().forEach((sameEntry) => {
          if (sameEntry.text.toLowerCase() === taskText) sameEntry.tag = btn.dataset.cat;
        });
        save();
        render();
      }
    });
  });
  timelineEl.querySelectorAll('.cat-cancel').forEach((btn) => {
    btn.addEventListener('click', () => {
      document.getElementById('cp-' + btn.dataset.id).classList.remove('open');
    });
  });

  /* + new epic inside entry category picker */
  timelineEl.querySelectorAll('.pcat-add-btn').forEach((btn) => {
    btn.addEventListener('click', () => {
      btn.style.display = 'none';
      const form = document.getElementById('ecaf-' + btn.dataset.id);
      form.classList.add('open');
      form.querySelector('.pcat-add-input').focus();
    });
  });
  timelineEl.querySelectorAll('.pcat-add-ok').forEach((btn) => {
    btn.addEventListener('click', () => {
      const form = document.getElementById('ecaf-' + btn.dataset.id);
      const input = form.querySelector('.pcat-add-input');
      if (!input.value.trim()) {
        input.focus();
        return;
      }
      const category = createCategory(input.value);
      if (!category) {
        markInputInvalid(input);
        return;
      }
      const entry = getEntries().find((logEntry) => logEntry.id === btn.dataset.id);
      if (entry) {
        const taskText = entry.text.toLowerCase();
        getEntries().forEach((sameEntry) => {
          if (sameEntry.text.toLowerCase() === taskText) sameEntry.tag = category.id;
        });
      }
      save();
      renderTagRow();
      render();
    });
  });
  timelineEl.querySelectorAll('.pcat-add-input').forEach((inp) => {
    inp.addEventListener('keydown', (event) => {
      if (event.key === 'Enter')
        inp.closest('.pcat-add-form').querySelector('.pcat-add-ok').click();
      if (event.key === 'Escape')
        inp.closest('.pcat-add-form').querySelector('.pcat-add-cancel2').click();
    });
  });
  timelineEl.querySelectorAll('.pcat-add-cancel2').forEach((btn) => {
    btn.addEventListener('click', () => {
      const form = document.getElementById('ecaf-' + btn.dataset.id);
      form.classList.remove('open');
      const addBtn = form.closest('.pcat-add-row').querySelector('.pcat-add-btn');
      if (addBtn) addBtn.style.display = '';
    });
  });

  /* billable toggle */
  timelineEl.querySelectorAll('.ebill-btn').forEach((btn) => {
    btn.addEventListener('click', () => {
      const entry = getEntries().find((logEntry) => logEntry.id === btn.dataset.id);
      if (entry) {
        entry.billable = entry.billable === false ? undefined : false;
        save();
        render();
      }
    });
  });

  /* delete */
  timelineEl.querySelectorAll('.edel').forEach((btn) => {
    btn.addEventListener('click', () => {
      const id = btn.dataset.id;
      if (getActiveTimer() && getActiveTimer().entryId === id) {
        clearTimerInterval();
        setActiveTimer(null);
        save();
        updateTimerBtn(false);
      }
      setEntries(getEntries().filter((entry) => entry.id !== id));
      save();
      render();
    });
  });

  /* restart */
  timelineEl.querySelectorAll('.erestart').forEach((btn) => {
    btn.addEventListener('click', () => {
      const sourceEntry = getEntries().find((entry) => entry.id === btn.dataset.id);
      if (!sourceEntry) return;
      if (getActiveTimer()) stopTimer();
      const newEntry = createRestartedEntry(sourceEntry.text, sourceEntry.tag);
      getEntries().push(newEntry);
      setViewDate(new Date());
      save();
      startTimer(newEntry.id);
      render();
    });
  });

  /* rename entry text (propagates to all entries + plan tasks with same text) */
  timelineEl.querySelectorAll('.etext').forEach((el) => {
    const openRename = () => {
      if (el.querySelector('.etext-input')) return;
      const id = el.dataset.id;
      const entry = getEntries().find((logEntry) => logEntry.id === id);
      if (!entry) return;
      // ARIA forbids interactive children inside role="button"; remove the role
      // while the <input> is present, restore via render() when editing ends.
      el.removeAttribute('role');
      el.setAttribute('tabindex', '-1');
      const origText = entry.text;
      const input = document.createElement('input');
      input.className = 'etext-input';
      input.setAttribute('aria-label', `Rename entry: ${origText}`);
      input.value = origText;
      el.innerHTML = '';
      el.appendChild(input);
      input.focus();
      input.select();
      let saved = false;
      const doSave = () => {
        if (saved) return;
        saved = true;
        const newText = input.value.trim();
        if (newText && newText !== origText) {
          const origLower = origText.toLowerCase();
          getEntries().forEach((sameEntry) => {
            if (sameEntry.text.toLowerCase() === origLower) sameEntry.text = newText;
          });
          getPlanTasks().forEach((task) => {
            if (task.text.toLowerCase() === origLower) task.text = newText;
          });
          save();
          savePlan();
        }
        render();
      };
      input.addEventListener('keydown', (ev) => {
        if (ev.key === 'Enter') {
          ev.preventDefault();
          doSave();
        }
        if (ev.key === 'Escape') {
          saved = true;
          render();
        }
      });
      input.addEventListener('blur', doSave);
    };
    el.addEventListener('click', openRename);
    el.addEventListener('keydown', (ev) => {
      if (ev.key === 'Enter' || ev.key === ' ') {
        ev.preventDefault();
        openRename();
      }
    });
  });
}

/* ── Helpers ── */

/** Closes every open inline time-editor panel and restores the display spans. */
function closeAllEditors() {
  document.querySelectorAll('.etime-editor.open').forEach((el) => el.classList.remove('open'));
  document.querySelectorAll('.etime-display').forEach((el) => (el.style.display = ''));
}
/**
 * Converts a Unix timestamp (ms) to an HH:MM string suitable for an
 * `<input type="time">` value.
 * @param {number} ts - Unix timestamp in milliseconds.
 * @returns {string} Local time formatted as "HH:MM".
 */
function toTimeInput(ts) {
  const d = new Date(ts);
  return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
}
/**
 * Replaces the hours/minutes of a base timestamp with values parsed from a
 * "HH:MM" string, returning the resulting timestamp in milliseconds.
 * @param {number} baseTsMs - Base Unix timestamp (ms) that supplies the date.
 * @param {string} timeStr  - Time string in "HH:MM" format.
 * @returns {number} New Unix timestamp (ms) with the updated time.
 */
function applyTime(baseTsMs, timeStr) {
  const d = new Date(baseTsMs),
    [hh, mm] = timeStr.split(':').map(Number);
  d.setHours(hh, mm, 0, 0);
  return d.getTime();
}
/**
 * Builds an HTML `<span class="etime-dur">` containing the human-readable
 * duration between two timestamps.  Returns an empty string if the duration
 * is zero or negative.
 * @param {number} tsStart - Start Unix timestamp (ms).
 * @param {number} tsEnd   - End Unix timestamp (ms).
 * @returns {string} HTML string, or '' if duration ≤ 0.
 */
function durLabel(tsStart, tsEnd) {
  const mins = Math.round((tsEnd - tsStart) / 60000);
  if (mins <= 0) return '';
  const h = Math.floor(mins / 60),
    m = mins % 60;
  return `<span class="etime-dur">${h > 0 ? (m > 0 ? `${h}h ${m}m` : `${h}h`) : `${m}m`}</span>`;
}