Source: 03-timer.js

/* ── Timer ── */

/**
 * Returns the total elapsed milliseconds for the active timer.
 * Accounts for accumulated time from previous pause/resume cycles.
 * Returns 0 if no timer is active.
 * @returns {number} Elapsed time in milliseconds.
 */
function getElapsedMs() {
  const timer = getActiveTimer();
  if (!timer) return 0;
  const acc = timer.accumulatedMs || 0;
  return timer.paused ? acc : acc + (Date.now() - timer.startTs);
}
/**
 * Starts (or restarts) the timer for the given entry.
 * Clears any existing interval, resets the chime state, and begins a 1-second
 * tick. Persists state and updates the UI immediately.
 * @param {string} entryId - ID of the log entry to time.
 */
function startTimer(entryId) {
  clearTimerInterval();
  _lastChimeMinute = null;
  _longRunningWarnDismissed = false;
  setActiveTimer({ entryId, startTs: Date.now(), accumulatedMs: 0, paused: false });
  save();
  setTimerInterval(setInterval(tickTimer, 1000)); // set up BEFORE first tick so it always runs
  tickTimer();
  updateTimerBar();
  updateTimerBtn(true);
  renderHeroCard();
}
/**
 * Pauses the running timer, accumulating elapsed time so it can be resumed.
 * No-ops if no timer is active or it is already paused.
 */
function pauseTimer() {
  const timer = getActiveTimer();
  if (!timer || timer.paused) return;
  clearTimerInterval();
  timer.accumulatedMs = getElapsedMs();
  timer.paused = true;
  timer.startTs = null;
  save();
  updateTimerBar();
  updateTabAndFavicon();
  renderHeroCard();
}
/**
 * Resumes a paused timer from where it left off.
 * No-ops if no timer is active or it is not paused.
 */
function resumeTimer() {
  const timer = getActiveTimer();
  if (!timer || !timer.paused) return;
  timer.paused = false;
  timer.startTs = Date.now();
  save();
  setTimerInterval(setInterval(tickTimer, 1000));
  tickTimer();
  updateTimerBar();
  renderHeroCard();
}
/**
 * Stops the active timer, stamps the log entry with an end time (rounded to
 * the nearest 30 min for billable entries), clears `activeTimer`, and
 * triggers a full render. Resets the timer bar colour and closes the park
 * capture input if open. No-ops if no timer is active.
 */
function stopTimer() {
  if (!getActiveTimer()) return;
  clearTimerInterval();
  const entry = getEntries().find((logEntry) => logEntry.id === getActiveTimer().entryId);
  if (entry) entry.tsEnd = roundToNearest30IfBillable(entry.ts + getElapsedMs(), entry);
  // Enter the 6-second confirmation panel before clearing activeTimer so
  // heroEnterStopped() can snapshot the entry details.
  if (entry) heroEnterStopped(entry);
  setActiveTimer(null);
  _lastChimeMinute = null;
  save();
  render();
  // Close park capture if open
  const pc = document.getElementById('parkCapture');
  const pb = document.getElementById('timerParkBtn');
  if (pc) {
    pc.classList.remove('show');
    pc.value = '';
  }
  if (pb) pb.classList.remove('active');
  updateTabAndFavicon();
}
/**
 * Updates the live time-block element in the time-block view to reflect the
 * current elapsed time of the active timer. No-ops if the live block element
 * or the active timer's entry cannot be found.
 */
function updateLiveBlock() {
  const el = document.getElementById('tb-live-block');
  if (!el || !getActiveTimer()) return;
  const entry = getEntries().find((logEntry) => logEntry.id === getActiveTimer().entryId);
  if (!entry) return;
  const tbStartMins = TB_START * 60,
    tbEndMins = TB_END * 60;
  const startMins = new Date(entry.ts).getHours() * 60 + new Date(entry.ts).getMinutes();
  const cStart = Math.max(startMins, tbStartMins);
  const nowMins = new Date().getHours() * 60 + new Date().getMinutes();
  const endMins = Math.min(Math.max(nowMins, cStart + 1), tbEndMins);
  const hPx = Math.max(TB_SLOT_H * 0.5, ((endMins - cStart) / 30) * TB_SLOT_H);
  el.style.height = hPx + 'px';
  const sub = document.getElementById('tb-live-sub');
  if (sub) {
    const cat = getCat(entry.tag || 'other');
    const elapsedMins = Math.round(getElapsedMs() / 60000);
    const h = Math.floor(elapsedMins / 60),
      m = elapsedMins % 60;
    sub.textContent = cat.label + ' · ' + (h > 0 ? (m > 0 ? `${h}h ${m}m` : `${h}h`) : `${m}m`);
  }
}

// Favicon — drawn on a 32×32 canvas as a colored dot
const HYPERFOCUS_MINS = 90;
let _faviconState = null; // track last state to avoid redundant redraws

// Long-running-timer safety warning
const LONG_RUNNING_MINS = 240; // 4h — see renderLongRunningWarning()
let _longRunningWarnDismissed = false; // reset on startTimer(); survives pause/resume
let _longRunningWarnLabel = null; // last-rendered "Xh Ym" label, so ticks that don't change it skip the DOM rebuild

/**
 * Shows or hides the long-running-timer warning banner in the Hero Card's
 * running panel. Purely visual — never stops or modifies the timer itself;
 * the user must click "stop timer" for that. Stays hidden for the rest of
 * this timer session once dismissed (reset by the next startTimer() call).
 *
 * Called every second from tickTimer(); rebuilds the banner's innerHTML only
 * when the displayed "Xh Ym" label actually changes (once a minute), not on
 * every tick. The container is `role="alert"`, so a per-second DOM rewrite
 * would make screen readers re-announce it every second — this keeps
 * announcements to once per minute, matching the visible text change.
 * @param {number} elapsedMs - Elapsed timer time in milliseconds.
 */
function renderLongRunningWarning(elapsedMs) {
  const el = document.getElementById('heroLongRunningWarn');
  if (!el) return;
  const show = !_longRunningWarnDismissed && isLongRunningTimer(elapsedMs, LONG_RUNNING_MINS);
  if (!show) {
    el.style.display = 'none';
    _longRunningWarnLabel = null;
    return;
  }
  const hours = Math.floor(elapsedMs / 3600000);
  const mins = Math.floor((elapsedMs % 3600000) / 60000);
  const label = `${hours}h ${mins}m`;
  el.style.display = '';
  if (label === _longRunningWarnLabel) return;
  _longRunningWarnLabel = label;
  el.innerHTML =
    `<span class="hero-longrun-warn__msg">⚠ Timer has been running for ${label} — still working?</span>` +
    `<button type="button" class="hero-longrun-warn__stop" id="heroLongRunningStopBtn">stop timer</button>` +
    `<button type="button" class="hero-longrun-warn__dismiss" id="heroLongRunningDismissBtn" aria-label="dismiss">&times;</button>`;
}

/**
 * Updates the browser favicon to a coloured dot reflecting the timer state.
 * Skips redundant redraws by tracking the last rendered state.
 * @param {'active'|'paused'|'hyperfocus'|'idle'} state - Current timer state.
 */
function setFavicon(state) {
  if (state === _faviconState) return;
  _faviconState = state;
  const colors = { active: '#1D9E75', paused: '#EF9F27', hyperfocus: '#E74C3C', idle: null };
  // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
  const color = colors[state];
  let link = document.querySelector("link[rel~='icon']");
  if (!link) {
    link = document.createElement('link');
    link.rel = 'icon';
    document.head.appendChild(link);
  }
  if (!color) {
    link.href = '';
    return;
  }
  try {
    const canvas = document.createElement('canvas');
    canvas.width = canvas.height = 32;
    const ctx = canvas.getContext('2d');
    if (!ctx) return; // canvas blocked (e.g. privacy settings)
    ctx.beginPath();
    ctx.arc(16, 16, 13, 0, Math.PI * 2);
    ctx.fillStyle = color;
    ctx.fill();
    link.href = canvas.toDataURL();
  } catch (err) {} // silently skip favicon if canvas unavailable
}

/**
 * Synchronises the browser tab title and favicon with the current timer state.
 * Adds a ▶/⏸/🔴 prefix and shows the elapsed time and task name in the title.
 * Switches to hyperfocus state (red) after {@link HYPERFOCUS_MINS} minutes for
 * non-meeting tasks.
 */
function updateTabAndFavicon() {
  const timer = getActiveTimer();
  if (!timer) {
    document.title = 'Work Log';
    setFavicon('idle');
    return;
  }
  const entry = getEntries().find((logEntry) => logEntry.id === timer.entryId);
  const taskText = entry ? entry.text : '…';
  const elapsedMs = getElapsedMs();
  const elapsed = fmtElapsed(elapsedMs);
  const isMeeting = entry && entry.text.startsWith('📅');
  const isHyperfocus = !isMeeting && elapsedMs > HYPERFOCUS_MINS * 60 * 1000;

  if (timer.paused) {
    document.title = `⏸ ${elapsed} — ${taskText}`;
    setFavicon('paused');
  } else if (isHyperfocus) {
    document.title = `🔴 ${elapsed} — ${taskText}`;
    setFavicon('hyperfocus');
  } else {
    document.title = `▶ ${elapsed} — ${taskText}`;
    setFavicon('active');
  }
}

// Chime system
let CHIME_INTERVALS_MINS = [30]; // default, overridden by selector
let _lastChimeMinute = null;

function loadChimeSetting() {
  const saved = parseInt(localStorage.getItem('wl_chime_mins') || '30');
  CHIME_INTERVALS_MINS = saved > 0 ? [saved] : [];
  const sel = document.getElementById('chimeIntervalSel');
  if (sel) sel.value = String(saved);
}

document.getElementById('chimeIntervalSel').addEventListener('change', function () {
  const val = parseInt(this.value);
  CHIME_INTERVALS_MINS = val > 0 ? [val] : [];
  localStorage.setItem('wl_chime_mins', String(val));
  _lastChimeMinute = null; // reset so next interval fires fresh
});

/**
 * Fires an audible chime if the elapsed time has crossed a configured interval
 * boundary since the last chime. No-ops when the timer is paused.
 * @param {number} elapsedMs - Elapsed time in milliseconds.
 */
function checkChime(elapsedMs) {
  if (!getActiveTimer() || getActiveTimer().paused) return;
  const elapsedMins = Math.floor(elapsedMs / 60000);
  if (elapsedMins === _lastChimeMinute) return;
  if (
    CHIME_INTERVALS_MINS.some((intervalMins) => elapsedMins > 0 && elapsedMins % intervalMins === 0)
  ) {
    _lastChimeMinute = elapsedMins;
    playChime();
  }
}

/**
 * Plays two soft sine-wave tones (528 Hz then 660 Hz) using the Web Audio API
 * to signal an elapsed-time milestone. Silently skips if Web Audio is
 * unavailable (e.g. browser privacy settings).
 */
function playChime() {
  try {
    const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
    const chimeFrequencies = [528, 660]; // two soft tones — a gentle rising pair
    chimeFrequencies.forEach((frequency, toneIndex) => {
      const oscillator = audioCtx.createOscillator();
      const gainNode = audioCtx.createGain();
      oscillator.connect(gainNode);
      gainNode.connect(audioCtx.destination);
      oscillator.type = 'sine';
      oscillator.frequency.value = frequency;
      const toneStart = audioCtx.currentTime + toneIndex * 0.18;
      gainNode.gain.setValueAtTime(0, toneStart);
      gainNode.gain.linearRampToValueAtTime(0.18, toneStart + 0.05);
      gainNode.gain.exponentialRampToValueAtTime(0.001, toneStart + 0.5);
      oscillator.start(toneStart);
      oscillator.stop(toneStart + 0.5);
    });
  } catch (err) {
    wlLog.warn('playChime: Web Audio API unavailable', err.message);
  }
}

/**
 * Updates the SVG arc on the timer circle to reflect elapsed time relative to
 * the hyperfocus threshold. The arc fills 0→100% of the circle circumference
 * (2πr ≈ 150.8 px for r=24) as elapsed time goes from 0 to HYPERFOCUS_MINS.
 * @param {number} elapsedMs - Elapsed time in milliseconds.
 */
function updateTimerArc(elapsedMs) {
  const arc = document.getElementById('tbTickerArc');
  if (!arc) return;
  const circumference = 2 * Math.PI * 24; // r=24 → ≈150.796
  const fraction = Math.min(elapsedMs / (HYPERFOCUS_MINS * 60 * 1000), 1);
  const drawn = fraction * circumference;
  arc.setAttribute('stroke-dasharray', `${drawn.toFixed(2)} ${circumference.toFixed(2)}`);
  // Colour: green (#1D9E75 ≈ hsl 158,69,51) → red (#E74C3C ≈ hsl 5,72,57)
  const t = fraction;
  const hue = Math.round(158 - 153 * t);
  const sat = Math.round(69 + 3 * t);
  const lit = Math.round(51 + 6 * t);
  arc.setAttribute('stroke', `hsl(${hue},${sat}%,${lit}%)`);
}

/**
 * Called every second by the timer interval. Updates the timer bar text,
 * the live time-block element, the tab title/favicon, the bar colour, and
 * checks whether a chime should fire. Also refreshes the focus-mode overlay
 * when it is open. Errors are caught and logged so a single bad tick cannot
 * stop the interval.
 */
function tickTimer() {
  try {
    if (!getActiveTimer()) return;
    const entry = getEntries().find((logEntry) => logEntry.id === getActiveTimer().entryId);
    const elapsed = getElapsedMs();
    // Update hero card clock every tick
    heroUpdateClock();
    // Keep the task title element current for accessibility aria-live region
    const taskEl = document.getElementById('timerTask');
    if (taskEl) taskEl.innerHTML = entry ? jiraTicketHtml(entry.text) : '…';
    updateTimerArc(elapsed);
    updateLiveBlock();
    updateTabAndFavicon();
    const boardLiveClockEl = document.getElementById('boardLiveClock');
    if (boardLiveClockEl) boardLiveClockEl.textContent = fmtElapsed(elapsed);
    checkChime(elapsed);
    renderLongRunningWarning(elapsed);
    if (emergencyMode) {
      const emergEl = document.getElementById('emergencyTask');
      if (emergEl) emergEl.textContent = entry ? entry.text : '—';
      renderEmergencyCps();
    }
  } catch (err) {
    console.error('[wl] tickTimer error:', err);
  }
}
/**
 * Shows or hides the timer bar and updates the pause/resume button label.
 * Also enables or disables the "make it interesting" hook button.
 */
function updateTimerBar() {
  // #timerBar no longer exists — hero card replaces it.
  // Keep the pause-button label update so existing event listeners remain valid.
  const pauseBtn = document.getElementById('timerPause');
  const hookBtn = document.getElementById('timerHookBtn');
  if (!getActiveTimer()) {
    if (hookBtn) hookBtn.disabled = true;
    return;
  }
  if (pauseBtn) pauseBtn.textContent = getActiveTimer().paused ? 'resume' : 'pause';
  if (hookBtn) hookBtn.disabled = false;
}
/**
 * Updates the main start-timer button's label and disabled state.
 * @param {boolean} running - True if a timer is currently active.
 */
function updateTimerBtn(running) {
  const btn = document.getElementById('timerBtn');
  btn.disabled = running;
  btn.textContent = running ? '▶ timing…' : '▶ start';
}
/**
 * Called at startup to reconnect the tick interval when the app is reloaded
 * with an active timer persisted in localStorage. If the entry the timer was
 * tracking no longer exists the timer is cleared. No-ops if no timer is active.
 */
function resumeTimerIfActive() {
  const timer = getActiveTimer();
  if (!timer) return;
  const entries = getEntries();
  if (!entries.find((logEntry) => logEntry.id === timer.entryId)) {
    if (
      entries.length > 0 ||
      !localStorage.getItem(STORE_ENTRIES) ||
      localStorage.getItem(STORE_ENTRIES) === '[]'
    ) {
      setActiveTimer(null);
      save();
    }
    return;
  }
  if (!timer.paused) setTimerInterval(setInterval(tickTimer, 1000));
  tickTimer();
  updateTimerBar();
  updateTimerBtn(true);
}

// ── Idle-return check ──
// Timer safety net for the "left it running overnight" case: if the tab was
// hidden for more than IDLE_RETURN_THRESHOLD_MS while a timer was running,
// ask on return whether to stop it. Never stops or adjusts the timer
// automatically — the user always decides. Registered once at load; a no-op
// whenever no timer is running.
const IDLE_RETURN_THRESHOLD_MS = 30 * 60 * 1000; // 30 min
let _timerHiddenAt = null;

document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    _timerHiddenAt = Date.now();
    return;
  }
  const hiddenAt = _timerHiddenAt;
  _timerHiddenAt = null;
  if (!hiddenAt || !getActiveTimer() || getActiveTimer().paused) return;
  const hiddenMs = Date.now() - hiddenAt;
  if (hiddenMs <= IDLE_RETURN_THRESHOLD_MS) return;
  const hiddenMins = Math.round(hiddenMs / 60000);
  const stop = confirm(
    `The timer has been running in the background for about ${hiddenMins} min while this tab was hidden. Stop it?`
  );
  if (stop) stopTimer();
});

/* ── Banner controls (mood dropdown, note input, utility pills) ── */

/**
 * Saves a timestamped note attached to the currently-active timer entry.
 * The note is stored in logNotes (type: 'session-note') and rendered nested
 * under its parent entry in the flow/log views, rather than as a standalone
 * time-tracked entry.
 * No-ops when there is no active timer or the note is empty.
 */
function commitBannerNote() {
  const inp = document.getElementById('tbNoteInput');
  if (!inp) return;
  const note = inp.value.trim();
  if (!note || !getActiveTimer()) return;

  const snTs = Date.now();
  getLogNotes().push({
    id: snTs + '-sn',
    text: note,
    ts: snTs,
    date: dk(new Date()),
    type: 'session-note',
    entryId: getActiveTimer().entryId,
  });
  saveLogNotes();
  inp.value = '';
  render();
  renderHeroCard();
}

/**
 * Logs a short well-known activity (break / lunch / meeting) as a new entry
 * while keeping the active timer running. The active timer is NOT stopped so
 * the user can resume without friction.
 * @param {'break'|'lunch'|'meeting'} kind - Activity type.
 */
function logUtilEntry(kind) {
  const labelMap = { break: '☕ Break', lunch: '🥪 Lunch', meeting: '📅 Meeting' };
  const tagMap = { break: 'other', lunch: 'other', meeting: 'meeting' };
  // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
  const text = labelMap[kind] || kind;
  // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
  const tag = tagMap[kind] || getCategories()[0]?.id || 'other';

  const entry = {
    id: Date.now() + '',
    text,
    tag,
    ts: safeRoundedStart(),
    date: dk(new Date()),
  };
  getEntries().push(entry);
  save();
  render();
}

/**
 * Handles a mood selection from the banner dropdown. Records a distraction or
 * parked thought as an entry (for distracted/parked moods), triggers the hook
 * panel for 'interesting', and sets focus mode for 'focus'. Closes the panel
 * afterwards.
 * @param {string} mood - One of 'distracted' | 'parked' | 'focus' | 'interesting'.
 * @param {string} icon - Emoji icon for the mood.
 */
function handleMoodSelect(mood, icon) {
  const btnLabel = document.getElementById('tbMoodLabel');
  const btnIcon = document.getElementById('tbMoodIcon');
  if (btnLabel) btnLabel.textContent = mood;
  if (btnIcon) btnIcon.textContent = icon;

  // Close panel
  const panel = document.getElementById('tbMoodPanel');
  const btn = document.getElementById('tbMoodBtn');
  if (panel) panel.style.display = 'none';
  if (btn) btn.setAttribute('aria-expanded', 'false');

  if (mood === 'distracted') {
    // Re-use the existing distract button's handler by clicking it
    const distractBtn = document.getElementById('timerDistract');
    if (distractBtn) distractBtn.click();
  } else if (mood === 'parked') {
    // Show the park capture input
    const pb = document.getElementById('timerParkBtn');
    if (pb) pb.click();
  } else if (mood === 'interesting') {
    const hookBtn = document.getElementById('timerHookBtn');
    if (hookBtn && !hookBtn.disabled) hookBtn.click();
  } else if (mood === 'focus') {
    const emergBtn = document.getElementById('emergencyBtn');
    if (emergBtn) emergBtn.click();
  }
}

/**
 * Binds all interactive controls on the V5 timer banner:
 * mood dropdown, quick-note input, and Break / Lunch / Meeting utility pills.
 * Called once from `07-lifecycle.js` after DOMContentLoaded is guaranteed.
 */
function initBannerControls() {
  // ── Mood dropdown ──
  const moodBtn = document.getElementById('tbMoodBtn');
  const moodPanel = document.getElementById('tbMoodPanel');

  if (moodBtn && moodPanel) {
    moodBtn.addEventListener('click', (event) => {
      event.stopPropagation();
      const open = moodPanel.style.display !== 'none';
      moodPanel.style.display = open ? 'none' : 'block';
      moodBtn.setAttribute('aria-expanded', String(!open));
    });

    moodPanel.querySelectorAll('.tb-mood-item').forEach((item) => {
      item.addEventListener('click', () => {
        handleMoodSelect(item.dataset.mood, item.dataset.icon);
      });
    });

    // Close on outside click
    document.addEventListener('click', (event) => {
      if (!moodBtn.contains(event.target) && !moodPanel.contains(event.target)) {
        moodPanel.style.display = 'none';
        moodBtn.setAttribute('aria-expanded', 'false');
      }
    });
  }

  // ── Quick-note input ──
  const noteInput = document.getElementById('tbNoteInput');
  if (noteInput) {
    noteInput.addEventListener('keydown', (event) => {
      if (event.key === 'Enter') {
        event.preventDefault();
        commitBannerNote();
      }
      // Prevent Space from triggering the rapid-log overlay
      if (event.code === 'Space') event.stopPropagation();
    });
  }

  // ── Utility pills ──
  const breakBtn = document.getElementById('tbBreakBtn');
  const lunchBtn = document.getElementById('tbLunchBtn');
  const meetingBtn = document.getElementById('tbMeetingBtn');
  if (breakBtn) breakBtn.addEventListener('click', () => logUtilEntry('break'));
  if (lunchBtn) lunchBtn.addEventListener('click', () => logUtilEntry('lunch'));
  if (meetingBtn) meetingBtn.addEventListener('click', () => logUtilEntry('meeting'));

  // ── Long-running-timer warning: stop/dismiss, delegated from the stable
  // #heroLongRunningWarn container so renderLongRunningWarning() can rebuild
  // its innerHTML (whenever the displayed label changes) without losing
  // these listeners.
  const longRunWarnEl = document.getElementById('heroLongRunningWarn');
  if (longRunWarnEl) {
    longRunWarnEl.addEventListener('click', (event) => {
      if (event.target.closest('#heroLongRunningStopBtn')) {
        stopTimer();
      } else if (event.target.closest('#heroLongRunningDismissBtn')) {
        _longRunningWarnDismissed = true;
        renderLongRunningWarning(getElapsedMs());
      }
    });
  }
}