function createTodayTimer({ storage, getLogin, now = () => Date.now() }) { const key = () => { const login = String(getLogin?.() || '').trim().toLowerCase(); return login ? 'stackchain.today-timer.v1.' + encodeURIComponent(login) : ''; }; const empty = () => ({ version:1, active_identity:'', entries:{}, away_at:null, pending_interruption:null, attention_interruption:null, }); const read = () => { const ownerKey = key(); if (!ownerKey || !storage) return empty(); try { const value = JSON.parse(storage.getItem(ownerKey) || 'null'); if (value?.version !== 1 || typeof value.active_identity !== 'string' || !value.entries || typeof value.entries !== 'object') return empty(); return value; } catch (_error) { return empty(); } }; const write = state => { const ownerKey = key(); if (!ownerKey || !storage) return false; try { storage.setItem(ownerKey, JSON.stringify(state)); return true; } catch (_error) { return false; } }; const validPending = state => { const pending = state.pending_interruption; return pending && typeof pending.identity === 'string' && pending.identity && Number.isFinite(pending.away_ms) && pending.away_ms >= 0 ? { identity:pending.identity, away_ms:pending.away_ms } : null; }; const validAttention = state => { const pending = state.attention_interruption; return pending && typeof pending.identity === 'string' && pending.identity && typeof pending.resume === 'boolean' ? { identity:pending.identity, resume:pending.resume } : null; }; const settle = (state, at = now()) => { const entry = state.entries[state.active_identity]; if (!entry?.running) return state; entry.elapsed_ms = Math.max(0, Number(entry.elapsed_ms) || 0) + Math.max(0, at - Number(entry.started_at ?? at)); entry.started_at = null; entry.running = false; return state; }; const snapshot = (identity = '') => { const state = read(); const selected = identity || state.active_identity; const entry = state.entries[selected]; if (!selected || !entry) return { identity:selected, elapsed_ms:0, running:false }; const elapsed = Math.max(0, Number(entry.elapsed_ms) || 0) + (entry.running ? Math.max(0, now() - Number(entry.started_at ?? now())) : 0); return { identity:selected, elapsed_ms:elapsed, running:Boolean(entry.running) }; }; return { activate(identity) { if (!key() || typeof identity !== 'string' || !identity) return false; const state = read(); if (state.active_identity === identity && state.entries[identity]?.running) return true; settle(state); state.active_identity = identity; const entry = state.entries[identity] || { elapsed_ms:0, started_at:null, running:false }; entry.started_at = now(); entry.running = true; state.entries[identity] = entry; state.away_at = null; state.pending_interruption = null; state.attention_interruption = null; return write(state); }, pause() { const state = read(); settle(state); state.away_at = null; return write(state); }, resume() { const state = read(); const entry = state.entries[state.active_identity]; if (!entry) return false; if (!entry.running) { entry.started_at = now(); entry.running = true; } return write(state); }, stop() { const state = read(); settle(state); state.away_at = null; state.pending_interruption = null; state.attention_interruption = null; return write(state); }, beginAttention() { const state = read(); const existing = validAttention(state); if (existing) return existing; const identity = state.active_identity; const entry = state.entries[identity]; if (!identity || !entry) return null; const resume = Boolean(entry.running); if (resume) settle(state); state.away_at = null; state.attention_interruption = { identity, resume }; return write(state) ? { ...state.attention_interruption } : null; }, attentionInterruption() { return validAttention(read()); }, returnFromAttention() { const state = read(); const pending = validAttention(state); const entry = pending && state.entries[pending.identity]; if (!pending || !entry) return null; state.active_identity = pending.identity; if (pending.resume && !entry.running) { entry.started_at = now(); entry.running = true; } state.attention_interruption = null; state.away_at = null; return write(state) ? { identity:pending.identity, resumed:pending.resume } : null; }, markAway() { const state = read(); const entry = state.entries[state.active_identity]; if (!entry?.running || validPending(state)) return false; if (Number.isFinite(state.away_at) && state.away_at >= 0) return true; state.away_at = now(); return write(state); }, reconcileInterruption(thresholdMs = 5 * 60 * 1000) { const state = read(); const existing = validPending(state); if (existing) return existing; state.pending_interruption = null; const entry = state.entries[state.active_identity]; const awayAt = Number(state.away_at); const detectedAt = now(); state.away_at = null; if (!entry?.running || !Number.isFinite(awayAt) || awayAt < 0 || detectedAt < awayAt || detectedAt - awayAt < thresholdMs) { write(state); return null; } const awayMs = detectedAt - awayAt; settle(state, detectedAt); state.pending_interruption = { identity:state.active_identity, away_ms:awayMs }; write(state); return { ...state.pending_interruption }; }, pendingInterruption() { return validPending(read()); }, resolveInterruption(decision) { if (decision !== 'count' && decision !== 'exclude') return false; const state = read(); const pending = state.pending_interruption; const entry = pending && state.entries[pending.identity]; if (!entry || !Number.isFinite(pending.away_ms) || pending.away_ms < 0) return false; if (decision === 'exclude') { entry.elapsed_ms = Math.max(0, Number(entry.elapsed_ms) - pending.away_ms); } state.active_identity = pending.identity; entry.started_at = now(); entry.running = true; state.pending_interruption = null; state.away_at = null; return write(state); }, recapEntries() { const state = read(); settle(state); write(state); return Object.entries(state.entries).map(([identity, entry]) => ({ identity, elapsed_ms:Math.max(0, Number(entry.elapsed_ms) || 0), })).filter(entry => entry.elapsed_ms > 0); }, clearRecap() { return write(empty()); }, totalElapsed() { const state = read(); return Object.entries(state.entries).reduce((total, [identity, entry]) => { const live = identity === state.active_identity && entry.running ? Math.max(0, now() - Number(entry.started_at ?? now())) : 0; return total + Math.max(0, Number(entry.elapsed_ms) || 0) + live; }, 0); }, snapshot, }; } function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRunway, getItem, onReopen, onComplete }) { let progress = null; let runway = null; queryAll('[data-mobile-today-open]').forEach(button => button.addEventListener('click', () => { const identity = timer.snapshot().identity; if (identity) onReopen?.(identity); }) ); queryAll('[data-mobile-today-toggle]').forEach(button => button.addEventListener('click', () => view.toggle()) ); queryAll('[data-mobile-today-complete]').forEach(button => button.addEventListener('click', () => { const identity = timer.snapshot().identity; if (identity) onComplete?.(identity); }) ); if (typeof document !== 'undefined') queryAll('.work-session-nav').forEach(nav => { const button = document.createElement('button'); button.type = 'button'; button.hidden = true; button.dataset.workSessionAdjustPlan = ''; button.textContent = 'Adjust remaining plan'; nav.insertBefore(button, nav.querySelector('[data-work-session-complete]')); }); if (typeof MutationObserver !== 'undefined') { const plan = queryAll('#plan-today')[0]; const sheet = queryAll('#plan-today-sheet')[0]; if (plan && sheet) { const replan = createTodayBudgetReplan({ timer, openPlan:() => plan.click() }); queryAll('[data-work-session-adjust-plan]').forEach(button => button.addEventListener('click', () => replan.open()) ); new MutationObserver(() => { if (sheet.hidden && replan.restore()) render(); }).observe(sheet, { attributes:true, attributeFilter:['hidden'] }); } } const elapsed = milliseconds => { const seconds = Math.max(0, Math.floor(Number(milliseconds || 0) / 1000)); const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const remainder = String(seconds % 60).padStart(2, '0'); return hours ? hours + ':' + String(minutes).padStart(2, '0') + ':' + remainder : minutes + ':' + remainder; }; const render = () => { const snapshot = timer.snapshot(); const active = Boolean(progress && isActive() && snapshot.identity); queryAll('[data-mobile-today-hud]').forEach(element => { element.hidden = !active; }); queryAll('[data-mobile-today-open]').forEach(element => { element.textContent = active ? String(getItem?.(snapshot.identity)?.title || 'Current Today item') : ''; }); queryAll('[data-mobile-today-complete]').forEach(button => { const itemTitle = String(getItem?.(snapshot.identity)?.title || 'current Today item'); const finalItem = Boolean(progress && progress.index >= progress.total); button.hidden = !active; button.textContent = finalItem ? 'Done & recap' : 'Done & next'; button.setAttribute('aria-label', finalItem ? 'Complete ' + itemTitle + ' and open Today recap' : 'Complete ' + itemTitle + ' and open next Today item'); }); if (typeof document !== 'undefined') document.body.classList.toggle('mobile-today-active', active); if (!progress) return; const sourceRunway = getRunway?.(snapshot) || runway; const liveRunway = sourceRunway?.future_minutes !== undefined ? (() => { const currentElapsed = Math.max(0, Math.ceil(snapshot.elapsed_ms / 60000)); const currentRemaining = sourceRunway.current_minutes === null ? null : Math.max(0, sourceRunway.current_minutes - currentElapsed); const remaining = currentRemaining === null || sourceRunway.future_minutes === null ? null : currentRemaining + sourceRunway.future_minutes; const projected = remaining === null ? null : Math.ceil(timer.totalElapsed() / 60000) + remaining; const capacityRemaining = sourceRunway.capacity_minutes === null || projected === null ? null : sourceRunway.capacity_minutes - projected; return { ...sourceRunway, remaining_minutes:remaining, over_estimate_minutes:sourceRunway.current_minutes === null ? 0 : Math.max(0, currentElapsed - sourceRunway.current_minutes), over_capacity_minutes:capacityRemaining === null ? 0 : Math.max(0, -capacityRemaining), }; })() : sourceRunway; const timing = isActive() && snapshot.identity ? ' · ' + elapsed(snapshot.elapsed_ms) + (liveRunway?.current_minutes ? ' / ' + formatEstimate(liveRunway.current_minutes) : '') : ''; const estimateRisk = liveRunway?.over_estimate_minutes ? ' · ' + formatEstimate(liveRunway.over_estimate_minutes) + ' over estimate' : ''; const capacityRisk = liveRunway?.over_capacity_minutes ? ' · Today projected ' + formatEstimate(liveRunway.over_capacity_minutes) + ' over capacity' : ''; queryAll('[data-work-session-progress]').forEach(element => { element.textContent = 'Item ' + progress.index + ' of ' + progress.total + timing + estimateRisk + capacityRisk + (!estimateRisk && !capacityRisk && liveRunway?.remaining_minutes ? ' · ' + formatEstimate(liveRunway.remaining_minutes) + ' remaining' : ''); }); queryAll('[data-work-session-adjust-plan]').forEach(button => { button.hidden = !(liveRunway?.over_estimate_minutes || liveRunway?.over_capacity_minutes); }); queryAll('[data-work-session-timer-toggle]').forEach(button => { button.hidden = !isActive(); button.textContent = snapshot.running ? 'Pause timer' : 'Resume timer'; button.setAttribute('aria-pressed', String(!snapshot.running)); }); }; return { open(identity, active) { const current = timer.snapshot(); if (active && current.identity !== identity) timer.activate(identity); else if (!active) timer.stop(); render(); }, finish() { timer.stop(); progress = null; runway = null; render(); }, update(nextProgress, nextRunway) { progress = nextProgress; runway = nextRunway; render(); }, reset() { progress = null; runway = null; render(); }, toggle() { const state = timer.snapshot(); state.running ? timer.pause() : timer.resume(); render(); }, render, }; } function createTodayBudgetReplan({ timer, openPlan }) { let active = false; let resume = false; return { open() { if (active) return false; const state = timer.snapshot(); if (!state.identity) return false; resume = Boolean(state.running); if (resume && timer.pause() === false) return false; active = true; openPlan?.(state); return true; }, restore() { if (!active) return false; active = false; const shouldResume = resume; resume = false; return !shouldResume || timer.resume() !== false; }, }; } function createTodayInterruptionPrompt({ timer, sheet, description, getItemLabel, onResolved }) { const render = pending => { if (!pending) { sheet.hidden = true; return false; } const label = String(getItemLabel?.(pending.identity) || 'Current Today item'); const minutes = Math.max(1, Math.round(pending.away_ms / 60000)); description.textContent = label + ' · away for ' + minutes + ' minute' + (minutes === 1 ? '' : 's'); sheet.hidden = false; return true; }; return { background() { return timer.markAway(); }, foreground() { return render(timer.reconcileInterruption()); }, restore() { return render(timer.pendingInterruption()); }, resolve(decision) { if (!timer.resolveInterruption(decision)) return false; sheet.hidden = true; onResolved?.(); return true; }, }; } if (typeof module !== 'undefined' && module.exports) { createTodayTimer.createView = createTodayTimerView; createTodayTimer.createInterruptionPrompt = createTodayInterruptionPrompt; createTodayTimer.createBudgetReplan = createTodayBudgetReplan; module.exports = createTodayTimer; }