/* ── 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() {
if (!activeTimer) return 0;
const acc = activeTimer.accumulatedMs || 0;
return activeTimer.paused ? acc : acc + (Date.now() - activeTimer.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) {
if (timerInterval) clearInterval(timerInterval);
_lastChimeMinute = null;
activeTimer = { entryId, startTs: Date.now(), accumulatedMs: 0, paused: false };
save();
timerInterval = 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() {
if (!activeTimer || activeTimer.paused) return;
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
activeTimer.accumulatedMs = getElapsedMs();
activeTimer.paused = true;
activeTimer.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() {
if (!activeTimer || !activeTimer.paused) return;
activeTimer.paused = false;
activeTimer.startTs = Date.now();
save();
timerInterval = 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 (!activeTimer) return;
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
const entry = entries.find((e) => e.id === activeTimer.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);
activeTimer = 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 || !activeTimer) return;
const entry = entries.find((e) => e.id === activeTimer.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
/**
* 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 };
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 (e) {} // 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() {
if (!activeTimer) {
document.title = 'Work Log';
setFavicon('idle');
return;
}
const entry = entries.find((e) => e.id === activeTimer.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 (activeTimer.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 (!activeTimer || activeTimer.paused) return;
const elapsedMins = Math.floor(elapsedMs / 60000);
if (elapsedMins === _lastChimeMinute) return;
if (CHIME_INTERVALS_MINS.some((n) => elapsedMins > 0 && elapsedMins % n === 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 (e) {
wlLog.warn('playChime: Web Audio API unavailable', e.message);
}
}
/**
* No-op: `#timerBar` was replaced by the Hero Card whose colour is driven by
* CSS state-modifier classes (`hero-card--running`, `--paused`, etc.).
* Kept so existing call-sites in `tickTimer` compile without changes.
*/
function updateTimerBarColor() {
// Hero card colour is handled entirely by CSS — nothing to do here.
}
/**
* 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 (!activeTimer) return;
const entry = entries.find((e) => e.id === activeTimer.entryId);
const elapsed = getElapsedMs();
// Update hero card clock and header tracking total every tick
heroUpdateClock();
updateHeaderTracking();
// 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();
updateTimerBarColor();
checkChime(elapsed);
if (emergencyMode) {
const emergEl = document.getElementById('emergencyTask');
if (emergEl) emergEl.textContent = entry ? entry.text : '—';
renderEmergencyCps();
}
} catch (e) {
console.error('[wl] tickTimer error:', e);
}
}
/**
* 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 (!activeTimer) {
if (hookBtn) hookBtn.disabled = true;
return;
}
if (pauseBtn) pauseBtn.textContent = activeTimer.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() {
if (!activeTimer) return;
if (!entries.find((e) => e.id === activeTimer.entryId)) {
if (
entries.length > 0 ||
!localStorage.getItem(STORE_ENTRIES) ||
localStorage.getItem(STORE_ENTRIES) === '[]'
) {
activeTimer = null;
save();
}
return;
}
if (!activeTimer.paused) timerInterval = setInterval(tickTimer, 1000);
tickTimer();
updateTimerBar();
updateTimerBtn(true);
}
// Refresh the "time by task" chart every 15 minutes while a timer runs so the
// active task's accumulated time appears in (near) real time. renderChart()
// decorates the active timer's entry with a synthetic tsEnd (= now or
// ts+accumulated for paused) so the bar grows without modifying stored data.
const CHART_REFRESH_MS = 15 * 60 * 1000;
setInterval(() => {
if (!activeTimer) return;
try {
renderChart(viewEntries());
} catch (e) {
/* renderChart may not be ready on very first tick */
}
}, CHART_REFRESH_MS);
/* ── 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 || !activeTimer) return;
const snTs = Date.now();
logNotes.push({
id: snTs + '-sn',
text: note,
ts: snTs,
date: dk(new Date()),
type: 'session-note',
entryId: activeTimer.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' };
const text = labelMap[kind] || kind;
const tag = tagMap[kind] || (categories[0] ? categories[0].id : 'other');
const entry = {
id: Date.now() + '',
text,
tag,
ts: safeRoundedStart(),
date: dk(new Date()),
};
entries.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', (e) => {
e.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', (e) => {
if (!moodBtn.contains(e.target) && !moodPanel.contains(e.target)) {
moodPanel.style.display = 'none';
moodBtn.setAttribute('aria-expanded', 'false');
}
});
}
// ── Quick-note input ──
const noteInput = document.getElementById('tbNoteInput');
if (noteInput) {
noteInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
commitBannerNote();
}
// Prevent Space from triggering the rapid-log overlay
if (e.code === 'Space') e.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'));
}