Source: 11-timeflow.js

// ── 11-timeflow.js — Today's Flow unified section (Flow / Log / Blocks views) ──

/** Activity-line SVG icon for the section header (Lucide-style, 24×24 viewBox, 1.5px stroke). */
const ICON_ACTIVITY =
  '<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24"' +
  ' fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">' +
  '<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>';

const STORE_FLOW_VIEW = 'wl_flow_view';

/** 07:00 in minutes from midnight — left edge of the day-overview strip. */
const TF_STRIP_START = 7 * 60;
/** 21:00 in minutes from midnight — right edge of the day-overview strip. */
const TF_STRIP_END = 21 * 60;

/** Ordered list of view ids — drives the segmented control and keyboard nav. */
const TF_VIEWS = ['flow', 'log', 'blocks', 'month', 'summary'];

/** Display labels for the segmented control. Static so we avoid recomputing on every render. */
const TF_VIEW_LABELS = {
  flow: 'Flow',
  log: 'Log',
  blocks: 'Blocks',
  month: 'Month',
  summary: 'Summary',
};

/** Maps each view to the DOM id of the pane that hosts it. */
const TF_PANE_IDS = {
  flow: 'tfFlowPane',
  log: 'tfLogPane',
  blocks: 'tfBlocksPane',
  month: 'monthlyLogSection',
  summary: 'tfSummaryPane',
};

/** Task ID whose note is currently being edited inline in the Flow view. */
let _flowNoteEditId = null;

// ─────────────────────────── view preference ───────────────────────────

/**
 * Returns the persisted view preference, defaulting to 'flow'.
 * @returns {'flow'|'log'|'blocks'|'month'|'summary'}
 */
function getFlowView() {
  const stored = localStorage.getItem(STORE_FLOW_VIEW);
  return TF_VIEWS.includes(stored) ? stored : 'flow';
}

/**
 * Persists the active view selection.
 * @param {'flow'|'log'|'blocks'} view
 */
function setFlowView(view) {
  localStorage.setItem(STORE_FLOW_VIEW, view);
}

// ─────────────────────────── day-overview strip ───────────────────────────

/**
 * Converts a minutes-from-midnight value to a percentage across the strip range.
 * Clamped to [0, 100].
 * @param {number} mins
 * @returns {number}
 */
function stripPct(mins) {
  return Math.max(
    0,
    Math.min(100, ((mins - TF_STRIP_START) / (TF_STRIP_END - TF_STRIP_START)) * 100)
  );
}

/**
 * Converts a Unix timestamp (ms) to minutes-from-midnight in local time.
 * @param {number} ts
 * @returns {number}
 */
function tsToMins(ts) {
  const date = new Date(ts);
  return date.getHours() * 60 + date.getMinutes();
}

/**
 * Formats a Unix timestamp (ms) as `HH:MM` in local time.
 * @param {number} ts
 * @returns {string}
 */
function fmtHm(ts) {
  const date = new Date(ts);
  const hh = String(date.getHours()).padStart(2, '0');
  const mm = String(date.getMinutes()).padStart(2, '0');
  return `${hh}:${mm}`;
}

/**
 * Returns the effective tracked duration (ms) of an active-timer entry,
 * honouring pause state — matches the formula used by renderFlowHeader so
 * the strip bar and Flow-view duration freeze while paused.
 * @param {object} entry
 * @returns {number} 0 if no timer is active for this entry.
 */
function activeTimerDurationMs(entry) {
  const timer = getActiveTimer();
  if (!timer || timer.entryId !== entry.id) return 0;
  if (timer.paused) return timer.accumulatedMs || 0;
  return Math.max(0, Date.now() - (timer.startTs || entry.ts));
}

/**
 * Renders the compact day-overview strip: hour-tick labels, entry footprints,
 * and (today only) a live "now" cursor.
 * @param {string} dateKey - YYYY-MM-DD.
 */
function renderDayStrip(dateKey) {
  const el = document.getElementById('tfDayStrip');
  if (!el) return;

  // Capture wall-clock time once so the live bar and now-cursor stay consistent
  const nowMins = tsToMins(Date.now());

  // Hour-tick labels at two-hour intervals
  const ticks = [7, 9, 11, 13, 15, 17, 19, 21]
    .map(
      (hour) =>
        `<span class="tf-tick" style="left:${stripPct(hour * 60)}%">${String(hour).padStart(2, '0')}</span>`
    )
    .join('');

  // Completed entry footprints — skip entries entirely outside the strip
  // (right === left after clamping) to avoid phantom slivers at the edges.
  const bars = getEntries()
    .filter((entry) => entry.date === dateKey && entry.tsEnd && entry.signifier !== 'cancelled')
    .map((entry) => {
      const cat = getCat(entry.tag);
      const left = stripPct(Math.max(TF_STRIP_START, tsToMins(entry.ts)));
      const right = stripPct(Math.min(TF_STRIP_END, tsToMins(entry.tsEnd)));
      if (right <= left) return '';
      return `<div class="tf-bar" style="left:${left}%;width:${Math.max(0.5, right - left)}%;background:${safeCssColor(cat.color)}"></div>`;
    })
    .join('');

  // Live timer footprint — endpoint freezes at pause so the bar doesn't keep
  // growing while the user is paused (matches renderFlowHeader's totals math).
  let liveBar = '';
  if (getActiveTimer() && isToday(getViewDate())) {
    const liveEntry = getEntries().find((entry) => entry.id === getActiveTimer().entryId);
    if (liveEntry && liveEntry.date === dateKey) {
      const liveEndMins = tsToMins(liveEntry.ts + activeTimerDurationMs(liveEntry));
      const left = stripPct(Math.max(TF_STRIP_START, tsToMins(liveEntry.ts)));
      const right = stripPct(Math.min(TF_STRIP_END, liveEndMins));
      if (right > left) {
        const cat = getCat(liveEntry.tag);
        liveBar = `<div class="tf-bar tf-bar-live" style="left:${left}%;width:${right - left}%;background:${safeCssColor(cat.color)}"></div>`;
      }
    }
  }

  // Now cursor (today only)
  let nowCursor = '';
  if (isToday(getViewDate()) && nowMins >= TF_STRIP_START && nowMins <= TF_STRIP_END) {
    nowCursor = `<div class="tf-now-cursor" style="left:${stripPct(nowMins)}%"></div>`;
  }

  el.innerHTML = `<div class="tf-strip-ticks">${ticks}</div><div class="tf-strip-bar">${bars}${liveBar}${nowCursor}</div>`;
}

// ─────────────────────────── gap reminder ───────────────────────────

/**
 * Finds the largest untracked gap (≥ 15 min) today, including gaps between
 * consecutive completed entries AND the trailing gap from the most recent
 * entry's end to now (which is often the most actionable). Returns null for
 * past days — only actionable today.
 * @param {string} dateKey - YYYY-MM-DD.
 * @returns {{startTs: number, endTs: number, gapMin: number}|null}
 */
function findLargestGap(dateKey) {
  if (!isToday(getViewDate())) return null;
  const timed = getEntries()
    .filter((entry) => entry.date === dateKey && entry.tsEnd && entry.signifier !== 'cancelled')
    .sort((a, b) => a.ts - b.ts);

  let largest = null;

  // Internal gaps between consecutive completed entries
  for (let i = 0; i < timed.length - 1; i++) {
    // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
    const gapMin = Math.floor((timed[i + 1].ts - timed[i].tsEnd) / 60000);
    if (gapMin >= 15 && (!largest || gapMin > largest.gapMin)) {
      // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
      largest = { startTs: timed[i].tsEnd, endTs: timed[i + 1].ts, gapMin };
    }
  }

  // Trailing gap: last entry's end → now (or EOD if the day has been ended).
  // Suppressed while a live timer is running, since the user is actively
  // tracking and the gap will close itself.
  if (timed.length && !getActiveTimer()) {
    const last = timed[timed.length - 1];
    const eodTs = getEodTs();
    const ceiling = eodTs || Date.now();
    const trailingMin = Math.floor((ceiling - last.tsEnd) / 60000);
    if (trailingMin >= 15 && (!largest || trailingMin > largest.gapMin)) {
      largest = { startTs: last.tsEnd, endTs: ceiling, gapMin: trailingMin };
    }
  }

  return largest;
}

/**
 * Renders or hides the gap-reminder banner.
 * @param {string} dateKey
 */
function renderGapReminder(dateKey) {
  const el = document.getElementById('tfGapReminder');
  if (!el) return;
  const gap = findLargestGap(dateKey);
  if (!gap) {
    el.style.display = 'none';
    return;
  }
  const hours = Math.floor(gap.gapMin / 60);
  const mins = gap.gapMin % 60;
  let dur;
  if (hours > 0) dur = mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
  else dur = `${mins}m`;
  el.style.display = '';
  el.innerHTML = `<span class="tf-gap-text">${fmtHm(gap.startTs)} – ${fmtHm(gap.endTs)} · ${dur} untracked between blocks</span><button type="button" class="tf-gap-btn" id="tfGapLogBtn">+ log it</button>`;
}

// ─────────────────────────── section header ───────────────────────────

/**
 * Renders the section header: icon, title, tracked/billable totals, and
 * the Flow / Log / Blocks segmented control.
 * @param {string} dateKey
 * @param {'flow'|'log'|'blocks'} activeView
 */
function renderFlowHeader(dateKey, activeView) {
  const el = document.getElementById('tfHeader');
  if (!el) return;

  const dayEntries = getEntries().filter(
    (entry) => entry.date === dateKey && entry.tsEnd && entry.signifier !== 'cancelled'
  );
  let totalMs = dayEntries.reduce((sum, e) => sum + (e.tsEnd - e.ts), 0);
  let billMs = dayEntries
    .filter((entry) => isEntryBillable(entry))
    .reduce((sum, e) => sum + (e.tsEnd - e.ts), 0);

  // Include live timer duration so both totals update while tracking
  if (getActiveTimer() && isToday(getViewDate())) {
    const liveEntry = getEntries().find((entry) => entry.id === getActiveTimer().entryId);
    if (liveEntry && liveEntry.date === dateKey && !liveEntry.tsEnd) {
      const liveMs = activeTimerDurationMs(liveEntry);
      totalMs += liveMs;
      if (isEntryBillable(liveEntry)) billMs += liveMs;
    }
  }

  const totalsHtml =
    totalMs > 0
      ? `<span class="tf-totals">${fmtDur(totalMs)} tracked${billMs > 0 ? ` · <span class="tf-bill">${fmtDur(billMs)} billable</span>` : ''}</span>`
      : '';

  // Segmented control uses ARIA `tablist`/`tab` so screen readers announce the
  // mutually-exclusive selection correctly and link each tab to its pane.
  // Roving tabindex: only the active tab is in the tab order; arrows move within.
  const segHtml = TF_VIEWS.map((view) => {
    const isActive = view === activeView;
    // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
    return `<button type="button" role="tab" class="tf-seg-btn${isActive ? ' active' : ''}" data-view="${view}" id="tfTab-${view}" aria-selected="${isActive}" aria-controls="${TF_PANE_IDS[view]}" tabindex="${isActive ? '0' : '-1'}">${TF_VIEW_LABELS[view]}</button>`;
  }).join('');

  el.innerHTML = `<span class="section-icon tf-icon" aria-hidden="true">${ICON_ACTIVITY}</span><span class="tf-title">TODAY'S FLOW</span>${totalsHtml}<div class="tf-seg" id="tfSeg" role="tablist" aria-label="Select view">${segHtml}</div>`;
}

// ─────────────────────────── Flow view ───────────────────────────

/**
 * Partitions a flat item list from buildDailyLogItems into two structures:
 * non-session-note items (the main timeline rows) and a lookup of session-note
 * items keyed by their `parentEntryId`.  Session-notes render nested inside
 * their parent entry row rather than as standalone timeline entries.
 *
 * @param {Array<object>} allItems - Items returned by buildDailyLogItems.
 * @returns {{ items: Array<object>, sessionNotesByEntry: Record<string, Array<object>> }}
 */
function partitionSessionNotes(allItems) {
  const sessionNotesByEntry = {};
  const items = allItems.filter((item) => {
    if (item.type !== 'session-note') return true;
    const pid = item.parentEntryId;
    if (!pid) {
      wlLog.warn('partitionSessionNotes: orphaned session-note discarded, id=' + item.id);
      return false;
    }
    // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
    if (!sessionNotesByEntry[pid]) sessionNotesByEntry[pid] = [];
    // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
    sessionNotesByEntry[pid].push(item);
    return false;
  });
  return { items, sessionNotesByEntry };
}

/**
 * Builds the HTML fragment for a list of session-notes nested under a parent
 * entry row. Returns an empty string when there are no notes.
 * @param {Array<object>} notes - Session-note items for one parent entry.
 * @returns {string} HTML string, or `''` if notes is empty.
 */
function buildSessionNotesHtml(notes) {
  if (!notes.length) return '';
  return (
    `<ul class="tf-session-notes" aria-label="Session notes">` +
    notes
      .map(
        (note) =>
          `<li class="tf-session-note">` +
          `<span class="tf-sn-time">${fmtHm(note.ts)}</span>` +
          `<span class="tf-sn-text">${note.text}</span>` +
          `</li>`
      )
      .join('') +
    `</ul>`
  );
}

/**
 * Builds the note display / edit widget for a task-type flow row.
 * Shows read-only text when not editing; shows a textarea when editing.
 * @param {{ id: string, note: (string|undefined) }} task - The plan task object.
 * @param {boolean} isEditing - Whether this task's note is being edited.
 * @returns {string} HTML string.
 */
function buildFlowTaskNoteHtml(task, isEditing) {
  const hasNote = !!(task.note && task.note.trim());
  if (isEditing) {
    return `<div class="tf-task-note-edit" data-taskid="${task.id}">
        <textarea class="tf-task-note-input" data-taskid="${task.id}" rows="2" aria-label="note for task" placeholder="add a note…">${escHtml(task.note || '')}</textarea>
        <div class="tf-task-note-actions">
          <button class="tf-task-note-save" data-taskid="${task.id}">save</button>
          ${hasNote ? `<button class="tf-task-note-del" data-taskid="${task.id}">remove</button>` : ''}
          <button class="tf-task-note-cancel" data-taskid="${task.id}">cancel</button>
        </div>
      </div>`;
  }
  const label = hasNote ? 'edit note' : 'add note';
  return `<div class="tf-task-note">
      ${hasNote ? `<span class="tf-task-note-text">${escHtml(task.note)}</span>` : ''}
      <button class="tf-task-note-btn${hasNote ? ' tf-task-note-btn--has' : ''}" data-taskid="${task.id}" title="${label}" aria-label="${label}">${hasNote ? '📝' : '+ note'}</button>
    </div>`;
}

/**
 * Binds note edit/save/remove/cancel events on the Flow pane after each render.
 * Listeners are attached to freshly-rendered child elements; prior children are
 * discarded by the innerHTML replacement in renderFlowView, so there are no
 * orphan listeners. Both renderFlowView and renderPlan are called after mutations
 * so the Flow pane and the kanban board stay in sync.
 * @param {HTMLElement} pane - The #tfFlowPane element.
 */
function bindFlowNoteEvents(pane) {
  const dateKey = dk(getViewDate());

  /** Focuses the note textarea for the given task ID after the next render. */
  function focusFlowNoteInput(tid) {
    setTimeout(() => {
      const ta = pane.querySelector(`.tf-task-note-input[data-taskid="${tid}"]`);
      if (ta) {
        ta.focus();
        ta.setSelectionRange(ta.value.length, ta.value.length);
      }
    }, 0);
  }

  pane.querySelectorAll('.tf-task-note-btn').forEach((btn) => {
    btn.addEventListener('click', (event) => {
      event.stopPropagation();
      const tid = btn.dataset.taskid;
      _flowNoteEditId = _flowNoteEditId === tid ? null : tid;
      renderFlowView(dateKey);
      if (_flowNoteEditId) focusFlowNoteInput(tid);
    });
  });

  pane.querySelectorAll('.tf-task-note-save').forEach((btn) => {
    btn.addEventListener('click', () => {
      const tid = btn.dataset.taskid;
      const ta = btn.closest('.tf-task-note-edit').querySelector('.tf-task-note-input');
      const val = ta.value.trim();
      const task = getPlanTasks().find((planTask) => planTask.id === tid);
      if (task) {
        if (val) task.note = val;
        else delete task.note;
        savePlan();
      }
      _flowNoteEditId = null;
      renderFlowView(dateKey);
      renderPlan();
    });
  });

  pane.querySelectorAll('.tf-task-note-del').forEach((btn) => {
    btn.addEventListener('click', () => {
      const tid = btn.dataset.taskid;
      const task = getPlanTasks().find((planTask) => planTask.id === tid);
      if (task) {
        delete task.note;
        savePlan();
      }
      _flowNoteEditId = null;
      renderFlowView(dateKey);
      renderPlan();
    });
  });

  pane.querySelectorAll('.tf-task-note-cancel').forEach((btn) => {
    btn.addEventListener('click', () => {
      _flowNoteEditId = null;
      renderFlowView(dateKey);
    });
  });

  pane.querySelectorAll('.tf-task-note-input').forEach((ta) => {
    ta.addEventListener('keydown', (event) => {
      event.stopPropagation();
      if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
        event.preventDefault();
        ta.closest('.tf-task-note-edit').querySelector('.tf-task-note-save').click();
      }
      if (event.key === 'Escape') {
        ta.closest('.tf-task-note-edit').querySelector('.tf-task-note-cancel').click();
      }
    });
    ta.addEventListener('click', (event) => event.stopPropagation());
  });
}

/**
 * Renders the Flow view: a vertical list where each entry's accent strip height
 * is proportional to its duration (height = max(64, 0.6 × minutes) px), giving
 * longer tasks more visual weight.
 * @param {string} dateKey
 */
function renderFlowView(dateKey) {
  const el = document.getElementById('tfFlowPane');
  if (!el) return;

  const { items, sessionNotesByEntry } = partitionSessionNotes(buildDailyLogItems(dateKey));

  if (!items.length) {
    el.innerHTML = `<div class="tf-empty">No entries for ${isToday(getViewDate()) ? 'today' : 'this day'} yet.</div>`;
    return;
  }

  el.innerHTML = items
    .map((item) => {
      const startLabel = fmtHm(item.ts);

      // Look up the underlying entry object for entry-type items
      const entryObj =
        item.type === 'entry' && item.entryId
          ? getEntries().find((entry) => entry.id === item.entryId)
          : null;

      // Look up the task object for task-type items (status update rows)
      const taskObj =
        item.type === 'task' && item.taskId
          ? getPlanTasks().find((task) => task.id === item.taskId)
          : null;

      let durationMin = 0;
      let durMs = 0;
      let isLive = false;
      if (entryObj) {
        isLive = !!(getActiveTimer() && getActiveTimer().entryId === entryObj.id);
        // Use the paused-aware helper for live entries so the duration freezes
        // while the timer is paused, matching renderFlowHeader and renderDayStrip.
        const liveMs = isLive ? activeTimerDurationMs(entryObj) : 0;
        durMs = entryObj.tsEnd ? entryObj.tsEnd - entryObj.ts : liveMs;
        if (durMs > 0) durationMin = Math.max(1, Math.round(durMs / 60000));
      }

      // Strip height scales with duration; non-entry items (notes, tasks) get a fixed height
      const stripH = item.type === 'entry' ? Math.max(64, Math.round(0.6 * durationMin)) : 40;

      const notes = entryObj ? sessionNotesByEntry[entryObj.id] || [] : [];

      return `
        <div class="tf-flow-row${isLive ? ' live' : ''}">
          <div class="tf-flow-time">
            <span class="tf-flow-hm">${startLabel}</span>
            ${durMs > 0 ? `<span class="tf-flow-dur">${fmtDur(durMs)}</span>` : ''}
          </div>
          <div class="tf-flow-strip" style="height:${stripH}px;background:${safeCssColor(item.color)}">
            ${isLive ? '<span class="tf-flow-pulse" aria-hidden="true"></span>' : ''}
          </div>
          <div class="tf-flow-body" style="min-height:${stripH}px">
            <div class="tf-flow-text">${item.text}</div>
            <div class="tf-flow-sub">${item.sub}</div>
            ${buildSessionNotesHtml(notes)}
            ${taskObj ? buildFlowTaskNoteHtml(taskObj, _flowNoteEditId === taskObj.id) : ''}
          </div>
        </div>`;
    })
    .join('');

  bindFlowNoteEvents(el);
}

// ─────────────────────────── main render ───────────────────────────

/**
 * Renders the Today's Flow section: header, day-overview strip, gap reminder,
 * and the active view pane (Flow / Log / Blocks / Month).
 * Called from render() on every state change and when the view toggle fires.
 */
function renderTodayFlow() {
  const dateKey = dk(getViewDate());
  const activeView = getFlowView();

  renderFlowHeader(dateKey, activeView);

  // Day strip and gap reminder are hidden in Month and Summary views
  const stripWrap = document.querySelector('.tf-day-strip-wrap');
  const gapEl = document.getElementById('tfGapReminder');
  const hideStripAndGap = activeView === 'month' || activeView === 'summary';
  if (stripWrap) stripWrap.style.display = hideStripAndGap ? 'none' : '';
  if (!hideStripAndGap) {
    renderDayStrip(dateKey);
    renderGapReminder(dateKey);
  } else if (gapEl) {
    gapEl.style.display = 'none';
  }

  Object.entries(TF_PANE_IDS).forEach(([view, id]) => {
    const pane = document.getElementById(id);
    if (pane) pane.style.display = view === activeView ? '' : 'none';
  });

  if (activeView === 'flow') renderFlowView(dateKey);
  else if (activeView === 'log') {
    const noteRow = document.getElementById('dailyLogNoteRow');
    if (noteRow) noteRow.style.display = isToday(getViewDate()) ? '' : 'none';
  } else if (activeView === 'blocks') renderTimeblock();
  else if (activeView === 'month') renderMonthlyLog();
  else if (activeView === 'summary') renderRollingSummary();
}

/**
 * Selects the view at index `nextIndex` in TF_VIEWS, focuses its tab button,
 * and re-renders. Shared by the keyboard handler in initTodayFlow().
 * @param {number} nextIndex
 */
function focusTabAt(nextIndex) {
  // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
  const view = TF_VIEWS[nextIndex];
  setFlowView(view);
  renderTodayFlow();
  document.getElementById(`tfTab-${view}`)?.focus();
}

/**
 * Binds the static listeners exactly once on DOMContentLoaded:
 *   - Log-note input/button (static HTML, never recreated)
 *   - Segmented-control click + keyboard nav, delegated off the stable
 *     #tfHeader container so we survive its innerHTML being rewritten
 *     by every renderFlowHeader() call.
 * Avoids the listener-accumulation bug that occurred when binding happened
 * inside render functions.
 */
function initTodayFlow() {
  document.getElementById('dailyLogNoteBtn')?.addEventListener('click', addLogNote);
  document.getElementById('dailyLogNoteInput')?.addEventListener('keydown', (event) => {
    if (event.key === 'Enter') addLogNote();
  });

  // Gap-reminder "+ log it" button: delegated from the stable #tfGapReminder
  // container so renderGapReminder() stays single-purpose (markup only).
  document.getElementById('tfGapReminder')?.addEventListener('click', (event) => {
    if (!event.target.closest('#tfGapLogBtn')) return;
    document.getElementById('captureInput')?.focus();
  });

  const header = document.getElementById('tfHeader');
  if (!header) return;

  header.addEventListener('click', (event) => {
    const btn = event.target.closest('.tf-seg-btn');
    if (!btn) return;
    setFlowView(btn.dataset.view);
    // Sync month calendar to viewDate when entering Month tab
    if (btn.dataset.view === 'month') {
      _mlYear = getViewDate().getFullYear();
      _mlMonth = getViewDate().getMonth();
    }
    renderTodayFlow();
  });

  // WCAG 2.1.1: Arrow keys + Home/End navigate between tabs in the tablist.
  header.addEventListener('keydown', (event) => {
    if (!event.target.classList || !event.target.classList.contains('tf-seg-btn')) return;
    const current = TF_VIEWS.indexOf(getFlowView());
    let next;
    if (event.key === 'ArrowLeft') next = (current - 1 + TF_VIEWS.length) % TF_VIEWS.length;
    else if (event.key === 'ArrowRight') next = (current + 1) % TF_VIEWS.length;
    else if (event.key === 'Home') next = 0;
    else if (event.key === 'End') next = TF_VIEWS.length - 1;
    else return;
    event.preventDefault();
    focusTabAt(next);
  });
}