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()); }, snapshot, }; } function createTodayTimerView({ timer, isActive, queryAll, formatEstimate }) { let progress = null; let runway = null; 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 = () => { if (!progress) return; const snapshot = timer.snapshot(); const timing = isActive() && snapshot.identity ? ' · ' + elapsed(snapshot.elapsed_ms) + (runway?.current_minutes ? ' / ' + formatEstimate(runway.current_minutes) : '') : ''; queryAll('[data-work-session-progress]').forEach(element => { element.textContent = 'Item ' + progress.index + ' of ' + progress.total + timing + (runway?.remaining_minutes ? ' · ' + formatEstimate(runway.remaining_minutes) + ' remaining' : ''); }); 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) { active ? timer.activate(identity) : timer.stop(); render(); }, finish() { timer.stop(); progress = null; runway = null; }, update(nextProgress, nextRunway) { progress = nextProgress; runway = nextRunway; render(); }, reset() { progress = null; runway = null; }, toggle() { const state = timer.snapshot(); state.running ? timer.pause() : timer.resume(); render(); }, render, }; } 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; module.exports = createTodayTimer; }